Skip to content

Commit

Permalink
web/timeline: add right click context menu for messages
Browse files Browse the repository at this point in the history
  • Loading branch information
tulir committed Dec 13, 2024
1 parent 69c127a commit 1858444
Show file tree
Hide file tree
Showing 8 changed files with 294 additions and 139 deletions.
6 changes: 6 additions & 0 deletions web/src/api/types/preferences/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ export const preferences = {
// allowedContexts: anyContext,
// defaultValue: false,
// }),
message_context_menu: new Preference<boolean>({
displayName: "Right-click menu on messages",
description: "Show a context menu when right-clicking on messages.",
allowedContexts: anyContext,
defaultValue: true,
}),
custom_notification_sound: new Preference<ContentURI>({
displayName: "Custom notification sound",
description: "The mxc:// URI to a custom notification sound.",
Expand Down
27 changes: 23 additions & 4 deletions web/src/ui/timeline/TimelineEvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,18 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useState } from "react"
import React, { use, useCallback, useState } from "react"
import { getAvatarURL, getMediaURL, getUserColorIndex } from "@/api/media.ts"
import { useRoomState } from "@/api/statestore"
import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types"
import { getDisplayname, isEventID } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts"
import MainScreenContext from "../MainScreenContext.ts"
import { ModalContext } from "../modal/Modal.tsx"
import { useRoomContext } from "../roomview/roomcontext.ts"
import { ReplyIDBody } from "./ReplyBody.tsx"
import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content"
import EventMenu from "./menu/EventMenu.tsx"
import { EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu"
import ErrorIcon from "../../icons/error.svg?react"
import PendingIcon from "../../icons/pending.svg?react"
import SentIcon from "../../icons/sent.svg?react"
Expand Down Expand Up @@ -72,7 +73,21 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
const roomCtx = useRoomContext()
const client = use(ClientContext)!
const mainScreen = use(MainScreenContext)
const openModal = use(ModalContext)
const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false)
const onContextMenu = useCallback((mouseEvt: React.MouseEvent) => {
if (!roomCtx.store.preferences.message_context_menu) {
return
}
mouseEvt.preventDefault()
openModal({
content: <EventFullMenu
evt={evt}
roomCtx={roomCtx}
style={getModalStyleFromMouse(mouseEvt, 9 * 40)}
/>,
})
}, [openModal, evt, roomCtx])
const memberEvt = useRoomState(roomCtx.store, "m.room.member", evt.sender)
if (!memberEvt) {
client.requestMemberEvent(roomCtx.store, evt.sender)
Expand Down Expand Up @@ -129,9 +144,13 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
const editTime = editEventTS ? `Edited at ${fullTimeFormatter.format(editEventTS)}` : null
const relatesTo = (evt.orig_content ?? evt.content)?.["m.relates_to"]
const replyTo = relatesTo?.["m.in_reply_to"]?.event_id
const mainEvent = <div data-event-id={evt.event_id} className={wrapperClassNames.join(" ")}>
const mainEvent = <div
data-event-id={evt.event_id}
className={wrapperClassNames.join(" ")}
onContextMenu={onContextMenu}
>
{!disableMenu && <div className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}>
<EventMenu evt={evt} setForceOpen={setForceContextMenuOpen}/>
<EventHoverMenu evt={evt} setForceOpen={setForceContextMenuOpen}/>
</div>}
{renderAvatar && <div
className="sender-avatar"
Expand Down
126 changes: 25 additions & 101 deletions web/src/ui/timeline/menu/EventMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,117 +13,41 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { CSSProperties, use, useCallback, useRef } from "react"
import { MemDBEvent, PowerLevelEventContent } from "@/api/types"
import { emojiToReactionContent } from "@/util/emoji"
import { useEventAsState } from "@/util/eventdispatcher.ts"
import { CSSProperties, use } from "react"
import { MemDBEvent } from "@/api/types"
import ClientContext from "../../ClientContext.ts"
import EmojiPicker from "../../emojipicker/EmojiPicker.tsx"
import { ModalContext } from "../../modal/Modal.tsx"
import { useRoomContext } from "../../roomview/roomcontext.ts"
import EventExtraMenu from "./EventExtraMenu.tsx"
import EditIcon from "@/icons/edit.svg?react"
import MoreIcon from "@/icons/more.svg?react"
import ReactIcon from "@/icons/react.svg?react"
import RefreshIcon from "@/icons/refresh.svg?react"
import ReplyIcon from "@/icons/reply.svg?react"
import "./index.css"
import { RoomContextData, useRoomContext } from "../../roomview/roomcontext.ts"
import { usePrimaryItems } from "./usePrimaryItems.tsx"
import { useSecondaryItems } from "./useSecondaryItems.tsx"

interface EventHoverMenuProps {
evt: MemDBEvent
setForceOpen: (forceOpen: boolean) => void
}

function getModalStyle(button: HTMLButtonElement, modalHeight: number): CSSProperties {
const rect = button.getBoundingClientRect()
const style: CSSProperties = { right: window.innerWidth - rect.right }
if (rect.bottom + modalHeight > window.innerHeight) {
style.bottom = window.innerHeight - rect.top
} else {
style.top = rect.bottom
}
return style
export const EventHoverMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
const elements = usePrimaryItems(use(ClientContext)!, useRoomContext(), evt, true, undefined, setForceOpen)
return <div className="event-hover-menu">{elements}</div>
}

const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
const client = use(ClientContext)!
const userID = client.userID
const roomCtx = useRoomContext()
const openModal = use(ModalContext)
const contextMenuRef = useRef<HTMLDivElement>(null)
const onClickReply = useCallback(() => roomCtx.setReplyTo(evt.event_id), [roomCtx, evt.event_id])
const onClickReact = useCallback((mevt: React.MouseEvent<HTMLButtonElement>) => {
const emojiPickerHeight = 34 * 16
setForceOpen(true)
openModal({
content: <EmojiPicker
style={getModalStyle(mevt.currentTarget, emojiPickerHeight)}
onSelect={emoji => {
client.sendEvent(evt.room_id, "m.reaction", emojiToReactionContent(emoji, evt.event_id))
.catch(err => window.alert(`Failed to send reaction: ${err}`))
}}
room={roomCtx.store}
closeOnSelect={true}
allowFreeform={true}
/>,
onClose: () => setForceOpen(false),
})
}, [client, roomCtx, evt, setForceOpen, openModal])
const onClickEdit = useCallback(() => {
roomCtx.setEditing(evt)
}, [roomCtx, evt])
const onClickResend = useCallback(() => {
if (!evt.transaction_id) {
return
}
client.resendEvent(evt.transaction_id)
.catch(err => window.alert(`Failed to resend message: ${err}`))
}, [client, evt.transaction_id])
const onClickMore = useCallback((mevt: React.MouseEvent<HTMLButtonElement>) => {
const moreMenuHeight = 10 * 16
setForceOpen(true)
openModal({
content: <EventExtraMenu
evt={evt}
room={roomCtx.store}
style={getModalStyle(mevt.currentTarget, moreMenuHeight)}
/>,
onClose: () => setForceOpen(false),
})
}, [evt, roomCtx, setForceOpen, openModal])
const isEditing = useEventAsState(roomCtx.isEditing)
const isPending = evt.event_id.startsWith("~")
const pendingTitle = isPending ? "Can't action messages that haven't been sent yet" : undefined
// TODO should these subscribe to the store?
const plEvent = roomCtx.store.getStateEvent("m.room.power_levels", "")
const encryptionEvent = roomCtx.store.getStateEvent("m.room.encryption", "")
const isEncrypted = encryptionEvent?.content?.algorithm === "m.megolm.v1.aes-sha2"
const pls = (plEvent?.content ?? {}) as PowerLevelEventContent
const ownPL = pls.users?.[userID] ?? pls.users_default ?? 0
const reactPL = pls.events?.["m.reaction"] ?? pls.events_default ?? 0
const evtSendType = isEncrypted ? "m.room.encrypted" : evt.type === "m.sticker" ? "m.sticker" : "m.room.message"
const messageSendPL = pls.events?.[evtSendType] ?? pls.events_default ?? 0
interface EventContextMenuProps {
evt: MemDBEvent
roomCtx: RoomContextData
style: CSSProperties
}

const didFail = !!evt.send_error && evt.send_error !== "not sent" && !!evt.transaction_id
const canSend = !didFail && ownPL >= messageSendPL
const canEdit = canSend
&& evt.sender === userID
&& evt.type === "m.room.message"
&& evt.relation_type !== "m.replace"
&& !evt.redacted_by
const canReact = !didFail && ownPL >= reactPL
export const EventExtraMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
const elements = useSecondaryItems(use(ClientContext)!, roomCtx, evt)
return <div style={style} className="event-context-menu extra">{elements}</div>
}

return <div className="event-hover-menu" ref={contextMenuRef}>
{canReact && <button disabled={isPending} title={pendingTitle} onClick={onClickReact}><ReactIcon/></button>}
{canSend && <button
disabled={isEditing || isPending}
title={isEditing ? "Can't reply to messages while editing a message" : pendingTitle}
onClick={onClickReply}
><ReplyIcon/></button>}
{canEdit && <button onClick={onClickEdit} disabled={isPending} title={pendingTitle}><EditIcon/></button>}
{didFail && <button onClick={onClickResend} title="Resend message"><RefreshIcon/></button>}
<button onClick={onClickMore}><MoreIcon/></button>
export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
const client = use(ClientContext)!
const primary = usePrimaryItems(client, roomCtx, evt, false, style, undefined)
const secondary = useSecondaryItems(client, roomCtx, evt)
return <div style={style} className="event-context-menu full">
{primary}
<hr/>
{secondary}
</div>
}

export default EventMenu
7 changes: 6 additions & 1 deletion web/src/ui/timeline/menu/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ div.event-hover-menu {
}
}

div.event-context-menu-extra {
div.event-context-menu {
position: fixed;
background-color: var(--background-color);
border-radius: .5rem;
Expand All @@ -26,6 +26,11 @@ div.event-context-menu-extra {
display: flex;
flex-direction: column;

> hr {
margin: 0;
opacity: .2;
}

> button {
border-radius: 0;
padding: .5rem .75rem;
Expand Down
17 changes: 17 additions & 0 deletions web/src/ui/timeline/menu/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
export { EventExtraMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx"
export { getModalStyleFromMouse } from "./util.ts"
131 changes: 131 additions & 0 deletions web/src/ui/timeline/menu/usePrimaryItems.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { CSSProperties, use, useCallback } from "react"
import Client from "@/api/client.ts"
import { MemDBEvent } from "@/api/types"
import { emojiToReactionContent } from "@/util/emoji"
import { useEventAsState } from "@/util/eventdispatcher.ts"
import EmojiPicker from "../../emojipicker/EmojiPicker.tsx"
import { ModalCloseContext, ModalContext } from "../../modal/Modal.tsx"
import { RoomContextData } from "../../roomview/roomcontext.ts"
import { EventExtraMenu } from "./EventMenu.tsx"
import { getEncryption, getModalStyleFromButton, getPending, getPowerLevels } from "./util.ts"
import EditIcon from "@/icons/edit.svg?react"
import MoreIcon from "@/icons/more.svg?react"
import ReactIcon from "@/icons/react.svg?react"
import RefreshIcon from "@/icons/refresh.svg?react"
import ReplyIcon from "@/icons/reply.svg?react"
import "./index.css"

const noop = () => {}

export const usePrimaryItems = (
client: Client,
roomCtx: RoomContextData,
evt: MemDBEvent,
isHover: boolean,
style?: CSSProperties,
setForceOpen?: (forceOpen: boolean) => void,
) => {
const closeModal = !isHover ? use(ModalCloseContext) : noop
const openModal = use(ModalContext)

const onClickReply = useCallback(() => {
roomCtx.setReplyTo(evt.event_id)
closeModal()
}, [roomCtx, evt.event_id, closeModal])
const onClickReact = useCallback((mevt: React.MouseEvent<HTMLButtonElement>) => {
const emojiPickerHeight = 34 * 16
setForceOpen?.(true)
openModal({
content: <EmojiPicker
style={style ?? getModalStyleFromButton(mevt.currentTarget, emojiPickerHeight)}
onSelect={emoji => {
client.sendEvent(evt.room_id, "m.reaction", emojiToReactionContent(emoji, evt.event_id))
.catch(err => window.alert(`Failed to send reaction: ${err}`))
}}
room={roomCtx.store}
closeOnSelect={true}
allowFreeform={true}
/>,
onClose: () => setForceOpen?.(false),
})
}, [client, roomCtx, evt, style, setForceOpen, openModal])
const onClickEdit = useCallback(() => {
closeModal()
roomCtx.setEditing(evt)
}, [roomCtx, evt, closeModal])
const onClickResend = useCallback(() => {
if (!evt.transaction_id) {
return
}
closeModal()
client.resendEvent(evt.transaction_id)
.catch(err => window.alert(`Failed to resend message: ${err}`))
}, [client, evt.transaction_id, closeModal])
const onClickMore = useCallback((mevt: React.MouseEvent<HTMLButtonElement>) => {
const moreMenuHeight = 4 * 40
setForceOpen!(true)
openModal({
content: <EventExtraMenu
evt={evt}
roomCtx={roomCtx}
style={getModalStyleFromButton(mevt.currentTarget, moreMenuHeight)}
/>,
onClose: () => setForceOpen!(false),
})
}, [evt, roomCtx, setForceOpen, openModal])
const isEditing = useEventAsState(roomCtx.isEditing)
const [isPending, pendingTitle] = getPending(evt)
const isEncrypted = getEncryption(roomCtx.store)
const [pls, ownPL] = getPowerLevels(roomCtx.store, client)
const reactPL = pls.events?.["m.reaction"] ?? pls.events_default ?? 0
const evtSendType = isEncrypted ? "m.room.encrypted" : evt.type === "m.sticker" ? "m.sticker" : "m.room.message"
const messageSendPL = pls.events?.[evtSendType] ?? pls.events_default ?? 0

const didFail = !!evt.send_error && evt.send_error !== "not sent" && !!evt.transaction_id
const canSend = !didFail && ownPL >= messageSendPL
const canEdit = canSend
&& evt.sender === client.userID
&& evt.type === "m.room.message"
&& evt.relation_type !== "m.replace"
&& !evt.redacted_by
const canReact = !didFail && ownPL >= reactPL

return <>
{didFail && <button onClick={onClickResend} title="Resend message">
<RefreshIcon/>
{!isHover && "Resend"}
</button>}
{canReact && <button disabled={isPending} title={pendingTitle} onClick={onClickReact}>
<ReactIcon/>
{!isHover && "React"}
</button>}
{canSend && <button
disabled={isEditing || isPending}
title={isEditing ? "Can't reply to messages while editing a message" : pendingTitle}
onClick={onClickReply}
>
<ReplyIcon/>
{!isHover && "Reply"}
</button>}
{canEdit && <button onClick={onClickEdit} disabled={isPending} title={pendingTitle}>
<EditIcon/>
{!isHover && "Edit"}
</button>}
{isHover && <button onClick={onClickMore}><MoreIcon/></button>}
</>
}
Loading

0 comments on commit 1858444

Please sign in to comment.