diff --git a/web/src/api/types/preferences/preferences.ts b/web/src/api/types/preferences/preferences.ts index 6638057e..2e4c0330 100644 --- a/web/src/api/types/preferences/preferences.ts +++ b/web/src/api/types/preferences/preferences.ts @@ -135,6 +135,12 @@ export const preferences = { // allowedContexts: anyContext, // defaultValue: false, // }), + message_context_menu: new Preference({ + 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({ displayName: "Custom notification sound", description: "The mxc:// URI to a custom notification sound.", diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index f3ed282c..9134d5b6 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -13,17 +13,18 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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" @@ -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: , + }) + }, [openModal, evt, roomCtx]) const memberEvt = useRoomState(roomCtx.store, "m.room.member", evt.sender) if (!memberEvt) { client.requestMemberEvent(roomCtx.store, evt.sender) @@ -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 =
+ const mainEvent =
{!disableMenu &&
- +
} {renderAvatar &&
. -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
{elements}
} -const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => { - const client = use(ClientContext)! - const userID = client.userID - const roomCtx = useRoomContext() - const openModal = use(ModalContext) - const contextMenuRef = useRef(null) - const onClickReply = useCallback(() => roomCtx.setReplyTo(evt.event_id), [roomCtx, evt.event_id]) - const onClickReact = useCallback((mevt: React.MouseEvent) => { - const emojiPickerHeight = 34 * 16 - setForceOpen(true) - openModal({ - content: { - 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) => { - const moreMenuHeight = 10 * 16 - setForceOpen(true) - openModal({ - content: , - 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
{elements}
+} - return
- {canReact && } - {canSend && } - {canEdit && } - {didFail && } - +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
+ {primary} +
+ {secondary}
} - -export default EventMenu diff --git a/web/src/ui/timeline/menu/index.css b/web/src/ui/timeline/menu/index.css index 7e2b03d7..f4685dc0 100644 --- a/web/src/ui/timeline/menu/index.css +++ b/web/src/ui/timeline/menu/index.css @@ -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; @@ -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; diff --git a/web/src/ui/timeline/menu/index.ts b/web/src/ui/timeline/menu/index.ts new file mode 100644 index 00000000..fc25eb01 --- /dev/null +++ b/web/src/ui/timeline/menu/index.ts @@ -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 . +export { EventExtraMenu, EventFullMenu, EventHoverMenu } from "./EventMenu.tsx" +export { getModalStyleFromMouse } from "./util.ts" diff --git a/web/src/ui/timeline/menu/usePrimaryItems.tsx b/web/src/ui/timeline/menu/usePrimaryItems.tsx new file mode 100644 index 00000000..a729a604 --- /dev/null +++ b/web/src/ui/timeline/menu/usePrimaryItems.tsx @@ -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 . +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) => { + const emojiPickerHeight = 34 * 16 + setForceOpen?.(true) + openModal({ + content: { + 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) => { + const moreMenuHeight = 4 * 40 + setForceOpen!(true) + openModal({ + content: , + 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 && } + {canReact && } + {canSend && } + {canEdit && } + {isHover && } + +} diff --git a/web/src/ui/timeline/menu/EventExtraMenu.tsx b/web/src/ui/timeline/menu/useSecondaryItems.tsx similarity index 71% rename from web/src/ui/timeline/menu/EventExtraMenu.tsx rename to web/src/ui/timeline/menu/useSecondaryItems.tsx index a6757121..1aca07db 100644 --- a/web/src/ui/timeline/menu/EventExtraMenu.tsx +++ b/web/src/ui/timeline/menu/useSecondaryItems.tsx @@ -13,29 +13,26 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { CSSProperties, use, useCallback } from "react" -import { RoomStateStore, useRoomState } from "@/api/statestore" -import { MemDBEvent, PowerLevelEventContent } from "@/api/types" -import ClientContext from "../../ClientContext.ts" +import { use, useCallback } from "react" +import Client from "@/api/client.ts" +import { useRoomState } from "@/api/statestore" +import { MemDBEvent } from "@/api/types" import { ModalCloseContext, ModalContext } from "../../modal/Modal.tsx" import { RoomContext, RoomContextData } from "../../roomview/roomcontext.ts" import JSONView from "../../util/JSONView.tsx" import ConfirmWithMessageModal from "./ConfirmWithMessageModal.tsx" +import { getPending, getPowerLevels } from "./util.ts" import ViewSourceIcon from "@/icons/code.svg?react" import DeleteIcon from "@/icons/delete.svg?react" import PinIcon from "@/icons/pin.svg?react" import ReportIcon from "@/icons/report.svg?react" import UnpinIcon from "@/icons/unpin.svg?react" -interface EventExtraMenuProps { - evt: MemDBEvent - room: RoomStateStore - style: CSSProperties -} - -const EventExtraMenu = ({ evt, room, style }: EventExtraMenuProps) => { - const client = use(ClientContext)! - const userID = client.userID +export const useSecondaryItems = ( + client: Client, + roomCtx: RoomContextData, + evt: MemDBEvent, +) => { const closeModal = use(ModalCloseContext) const openModal = use(ModalContext) const onClickViewSource = useCallback(() => { @@ -50,7 +47,7 @@ const EventExtraMenu = ({ evt, room, style }: EventExtraMenuProps) => { dimmed: true, boxed: true, innerBoxClass: "confirm-message-modal", - content: + content: { /> , }) - }, [evt, room, openModal, client]) + }, [evt, roomCtx, openModal, client]) const onClickRedact = useCallback(() => { openModal({ dimmed: true, boxed: true, innerBoxClass: "confirm-message-modal", - content: + content: { /> , }) - }, [evt, room, openModal, client]) + }, [evt, roomCtx, openModal, client]) const onClickPin = useCallback(() => { closeModal() - client.pinMessage(room, evt.event_id, true) + client.pinMessage(roomCtx.store, evt.event_id, true) .catch(err => window.alert(`Failed to pin message: ${err}`)) - }, [closeModal, client, room, evt.event_id]) + }, [closeModal, client, roomCtx, evt.event_id]) const onClickUnpin = useCallback(() => { closeModal() - client.pinMessage(room, evt.event_id, false) + client.pinMessage(roomCtx.store, evt.event_id, false) .catch(err => window.alert(`Failed to unpin message: ${err}`)) - }, [closeModal, client, room, evt.event_id]) + }, [closeModal, client, roomCtx, evt.event_id]) - const isPending = evt.event_id.startsWith("~") - const pendingTitle = isPending ? "Can't action messages that haven't been sent yet" : undefined - const plEvent = useRoomState(room, "m.room.power_levels", "") + const [isPending, pendingTitle] = getPending(evt) + useRoomState(roomCtx.store, "m.room.power_levels", "") // We get pins from getPinnedEvents, but use the hook anyway to subscribe to changes - useRoomState(room, "m.room.pinned_events", "") - const pls = (plEvent?.content ?? {}) as PowerLevelEventContent - const pins = room.getPinnedEvents() - const ownPL = pls.users?.[userID] ?? pls.users_default ?? 0 + useRoomState(roomCtx.store, "m.room.pinned_events", "") + const [pls, ownPL] = getPowerLevels(roomCtx.store, client) + const pins = roomCtx.store.getPinnedEvents() const pinPL = pls.events?.["m.room.pinned_events"] ?? pls.state_default ?? 50 const redactEvtPL = pls.events?.["m.room.redaction"] ?? pls.events_default ?? 0 const redactOtherPL = pls.redact ?? 50 - const canRedact = !evt.redacted_by && ownPL >= redactEvtPL && (evt.sender === userID || ownPL >= redactOtherPL) + const canRedact = !evt.redacted_by + && ownPL >= redactEvtPL + && (evt.sender === client.userID || ownPL >= redactOtherPL) - return
+ return <> {ownPL >= pinPL && (pins.includes(evt.event_id) ? @@ -121,7 +118,5 @@ const EventExtraMenu = ({ evt, room, style }: EventExtraMenuProps) => { title={pendingTitle} className="redact-button" >Remove} -
+ } - -export default EventExtraMenu diff --git a/web/src/ui/timeline/menu/util.ts b/web/src/ui/timeline/menu/util.ts new file mode 100644 index 00000000..62b3eeb7 --- /dev/null +++ b/web/src/ui/timeline/menu/util.ts @@ -0,0 +1,58 @@ +// 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 . +import React, { CSSProperties } from "react" +import Client from "@/api/client.ts" +import { RoomStateStore } from "@/api/statestore" +import { MemDBEvent, PowerLevelEventContent } from "@/api/types" + +export const getPending = (evt: MemDBEvent): [pending: boolean, pendingTitle: string | undefined] => { + const isPending = evt.event_id.startsWith("~") + const pendingTitle = isPending ? "Can't action messages that haven't been sent yet" : undefined + return [isPending, pendingTitle] +} + +export const getPowerLevels = (room: RoomStateStore, client: Client): [pls: PowerLevelEventContent, ownPL: number] => { + const plEvent = room.getStateEvent("m.room.power_levels", "") + const pls = (plEvent?.content ?? {}) as PowerLevelEventContent + const ownPL = pls.users?.[client.userID] ?? pls.users_default ?? 0 + return [pls, ownPL] +} + +export const getEncryption = (room: RoomStateStore): boolean =>{ + const encryptionEvent = room.getStateEvent("m.room.encryption", "") + return encryptionEvent?.content?.algorithm === "m.megolm.v1.aes-sha2" +} + +export function getModalStyleFromMouse(evt: React.MouseEvent, modalHeight: number): CSSProperties { + const style: CSSProperties = { right: window.innerWidth - evt.clientX } + if (evt.clientY + modalHeight > window.innerHeight) { + style.bottom = window.innerHeight - evt.clientY + } else { + style.top = evt.clientY + } + return style +} + +export function getModalStyleFromButton(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 +}