forked from Mirrors/gomuks
web/timeline: add right click context menu for messages
This commit is contained in:
parent
69c127a0a2
commit
185844468c
8 changed files with 295 additions and 140 deletions
|
@ -135,6 +135,12 @@ export const preferences = {
|
||||||
// allowedContexts: anyContext,
|
// allowedContexts: anyContext,
|
||||||
// defaultValue: false,
|
// 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>({
|
custom_notification_sound: new Preference<ContentURI>({
|
||||||
displayName: "Custom notification sound",
|
displayName: "Custom notification sound",
|
||||||
description: "The mxc:// URI to a custom notification sound.",
|
description: "The mxc:// URI to a custom notification sound.",
|
||||||
|
|
|
@ -13,17 +13,18 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// 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 { getAvatarURL, getMediaURL, getUserColorIndex } from "@/api/media.ts"
|
||||||
import { useRoomState } from "@/api/statestore"
|
import { useRoomState } from "@/api/statestore"
|
||||||
import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types"
|
import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types"
|
||||||
import { getDisplayname, isEventID } from "@/util/validation.ts"
|
import { getDisplayname, isEventID } from "@/util/validation.ts"
|
||||||
import ClientContext from "../ClientContext.ts"
|
import ClientContext from "../ClientContext.ts"
|
||||||
import MainScreenContext from "../MainScreenContext.ts"
|
import MainScreenContext from "../MainScreenContext.ts"
|
||||||
|
import { ModalContext } from "../modal/Modal.tsx"
|
||||||
import { useRoomContext } from "../roomview/roomcontext.ts"
|
import { useRoomContext } from "../roomview/roomcontext.ts"
|
||||||
import { ReplyIDBody } from "./ReplyBody.tsx"
|
import { ReplyIDBody } from "./ReplyBody.tsx"
|
||||||
import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content"
|
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 ErrorIcon from "../../icons/error.svg?react"
|
||||||
import PendingIcon from "../../icons/pending.svg?react"
|
import PendingIcon from "../../icons/pending.svg?react"
|
||||||
import SentIcon from "../../icons/sent.svg?react"
|
import SentIcon from "../../icons/sent.svg?react"
|
||||||
|
@ -72,7 +73,21 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
|
||||||
const roomCtx = useRoomContext()
|
const roomCtx = useRoomContext()
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const mainScreen = use(MainScreenContext)
|
const mainScreen = use(MainScreenContext)
|
||||||
|
const openModal = use(ModalContext)
|
||||||
const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false)
|
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)
|
const memberEvt = useRoomState(roomCtx.store, "m.room.member", evt.sender)
|
||||||
if (!memberEvt) {
|
if (!memberEvt) {
|
||||||
client.requestMemberEvent(roomCtx.store, evt.sender)
|
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 editTime = editEventTS ? `Edited at ${fullTimeFormatter.format(editEventTS)}` : null
|
||||||
const relatesTo = (evt.orig_content ?? evt.content)?.["m.relates_to"]
|
const relatesTo = (evt.orig_content ?? evt.content)?.["m.relates_to"]
|
||||||
const replyTo = relatesTo?.["m.in_reply_to"]?.event_id
|
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" : ""}`}>
|
{!disableMenu && <div className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}>
|
||||||
<EventMenu evt={evt} setForceOpen={setForceContextMenuOpen}/>
|
<EventHoverMenu evt={evt} setForceOpen={setForceContextMenuOpen}/>
|
||||||
</div>}
|
</div>}
|
||||||
{renderAvatar && <div
|
{renderAvatar && <div
|
||||||
className="sender-avatar"
|
className="sender-avatar"
|
||||||
|
|
|
@ -13,117 +13,41 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { CSSProperties, use, useCallback, useRef } from "react"
|
import { CSSProperties, use } from "react"
|
||||||
import { MemDBEvent, PowerLevelEventContent } from "@/api/types"
|
import { MemDBEvent } from "@/api/types"
|
||||||
import { emojiToReactionContent } from "@/util/emoji"
|
|
||||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
|
||||||
import ClientContext from "../../ClientContext.ts"
|
import ClientContext from "../../ClientContext.ts"
|
||||||
import EmojiPicker from "../../emojipicker/EmojiPicker.tsx"
|
import { RoomContextData, useRoomContext } from "../../roomview/roomcontext.ts"
|
||||||
import { ModalContext } from "../../modal/Modal.tsx"
|
import { usePrimaryItems } from "./usePrimaryItems.tsx"
|
||||||
import { useRoomContext } from "../../roomview/roomcontext.ts"
|
import { useSecondaryItems } from "./useSecondaryItems.tsx"
|
||||||
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"
|
|
||||||
|
|
||||||
interface EventHoverMenuProps {
|
interface EventHoverMenuProps {
|
||||||
evt: MemDBEvent
|
evt: MemDBEvent
|
||||||
setForceOpen: (forceOpen: boolean) => void
|
setForceOpen: (forceOpen: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModalStyle(button: HTMLButtonElement, modalHeight: number): CSSProperties {
|
export const EventHoverMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
|
||||||
const rect = button.getBoundingClientRect()
|
const elements = usePrimaryItems(use(ClientContext)!, useRoomContext(), evt, true, undefined, setForceOpen)
|
||||||
const style: CSSProperties = { right: window.innerWidth - rect.right }
|
return <div className="event-hover-menu">{elements}</div>
|
||||||
if (rect.bottom + modalHeight > window.innerHeight) {
|
|
||||||
style.bottom = window.innerHeight - rect.top
|
|
||||||
} else {
|
|
||||||
style.top = rect.bottom
|
|
||||||
}
|
|
||||||
return style
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
|
interface EventContextMenuProps {
|
||||||
|
evt: MemDBEvent
|
||||||
|
roomCtx: RoomContextData
|
||||||
|
style: CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EventFullMenu = ({ evt, roomCtx, style }: EventContextMenuProps) => {
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const userID = client.userID
|
const primary = usePrimaryItems(client, roomCtx, evt, false, style, undefined)
|
||||||
const roomCtx = useRoomContext()
|
const secondary = useSecondaryItems(client, roomCtx, evt)
|
||||||
const openModal = use(ModalContext)
|
return <div style={style} className="event-context-menu full">
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
{primary}
|
||||||
const onClickReply = useCallback(() => roomCtx.setReplyTo(evt.event_id), [roomCtx, evt.event_id])
|
<hr/>
|
||||||
const onClickReact = useCallback((mevt: React.MouseEvent<HTMLButtonElement>) => {
|
{secondary}
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EventMenu
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ div.event-hover-menu {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div.event-context-menu-extra {
|
div.event-context-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
border-radius: .5rem;
|
border-radius: .5rem;
|
||||||
|
@ -26,6 +26,11 @@ div.event-context-menu-extra {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
> hr {
|
||||||
|
margin: 0;
|
||||||
|
opacity: .2;
|
||||||
|
}
|
||||||
|
|
||||||
> button {
|
> button {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: .5rem .75rem;
|
padding: .5rem .75rem;
|
||||||
|
|
17
web/src/ui/timeline/menu/index.ts
Normal file
17
web/src/ui/timeline/menu/index.ts
Normal file
|
@ -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
web/src/ui/timeline/menu/usePrimaryItems.tsx
Normal file
131
web/src/ui/timeline/menu/usePrimaryItems.tsx
Normal file
|
@ -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>}
|
||||||
|
</>
|
||||||
|
}
|
|
@ -13,29 +13,26 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { CSSProperties, use, useCallback } from "react"
|
import { use, useCallback } from "react"
|
||||||
import { RoomStateStore, useRoomState } from "@/api/statestore"
|
import Client from "@/api/client.ts"
|
||||||
import { MemDBEvent, PowerLevelEventContent } from "@/api/types"
|
import { useRoomState } from "@/api/statestore"
|
||||||
import ClientContext from "../../ClientContext.ts"
|
import { MemDBEvent } from "@/api/types"
|
||||||
import { ModalCloseContext, ModalContext } from "../../modal/Modal.tsx"
|
import { ModalCloseContext, ModalContext } from "../../modal/Modal.tsx"
|
||||||
import { RoomContext, RoomContextData } from "../../roomview/roomcontext.ts"
|
import { RoomContext, RoomContextData } from "../../roomview/roomcontext.ts"
|
||||||
import JSONView from "../../util/JSONView.tsx"
|
import JSONView from "../../util/JSONView.tsx"
|
||||||
import ConfirmWithMessageModal from "./ConfirmWithMessageModal.tsx"
|
import ConfirmWithMessageModal from "./ConfirmWithMessageModal.tsx"
|
||||||
|
import { getPending, getPowerLevels } from "./util.ts"
|
||||||
import ViewSourceIcon from "@/icons/code.svg?react"
|
import ViewSourceIcon from "@/icons/code.svg?react"
|
||||||
import DeleteIcon from "@/icons/delete.svg?react"
|
import DeleteIcon from "@/icons/delete.svg?react"
|
||||||
import PinIcon from "@/icons/pin.svg?react"
|
import PinIcon from "@/icons/pin.svg?react"
|
||||||
import ReportIcon from "@/icons/report.svg?react"
|
import ReportIcon from "@/icons/report.svg?react"
|
||||||
import UnpinIcon from "@/icons/unpin.svg?react"
|
import UnpinIcon from "@/icons/unpin.svg?react"
|
||||||
|
|
||||||
interface EventExtraMenuProps {
|
export const useSecondaryItems = (
|
||||||
evt: MemDBEvent
|
client: Client,
|
||||||
room: RoomStateStore
|
roomCtx: RoomContextData,
|
||||||
style: CSSProperties
|
evt: MemDBEvent,
|
||||||
}
|
) => {
|
||||||
|
|
||||||
const EventExtraMenu = ({ evt, room, style }: EventExtraMenuProps) => {
|
|
||||||
const client = use(ClientContext)!
|
|
||||||
const userID = client.userID
|
|
||||||
const closeModal = use(ModalCloseContext)
|
const closeModal = use(ModalCloseContext)
|
||||||
const openModal = use(ModalContext)
|
const openModal = use(ModalContext)
|
||||||
const onClickViewSource = useCallback(() => {
|
const onClickViewSource = useCallback(() => {
|
||||||
|
@ -50,7 +47,7 @@ const EventExtraMenu = ({ evt, room, style }: EventExtraMenuProps) => {
|
||||||
dimmed: true,
|
dimmed: true,
|
||||||
boxed: true,
|
boxed: true,
|
||||||
innerBoxClass: "confirm-message-modal",
|
innerBoxClass: "confirm-message-modal",
|
||||||
content: <RoomContext value={new RoomContextData(room)}>
|
content: <RoomContext value={roomCtx}>
|
||||||
<ConfirmWithMessageModal
|
<ConfirmWithMessageModal
|
||||||
evt={evt}
|
evt={evt}
|
||||||
title="Report Message"
|
title="Report Message"
|
||||||
|
@ -64,13 +61,13 @@ const EventExtraMenu = ({ evt, room, style }: EventExtraMenuProps) => {
|
||||||
/>
|
/>
|
||||||
</RoomContext>,
|
</RoomContext>,
|
||||||
})
|
})
|
||||||
}, [evt, room, openModal, client])
|
}, [evt, roomCtx, openModal, client])
|
||||||
const onClickRedact = useCallback(() => {
|
const onClickRedact = useCallback(() => {
|
||||||
openModal({
|
openModal({
|
||||||
dimmed: true,
|
dimmed: true,
|
||||||
boxed: true,
|
boxed: true,
|
||||||
innerBoxClass: "confirm-message-modal",
|
innerBoxClass: "confirm-message-modal",
|
||||||
content: <RoomContext value={new RoomContextData(room)}>
|
content: <RoomContext value={roomCtx}>
|
||||||
<ConfirmWithMessageModal
|
<ConfirmWithMessageModal
|
||||||
evt={evt}
|
evt={evt}
|
||||||
title="Remove Message"
|
title="Remove Message"
|
||||||
|
@ -84,32 +81,32 @@ const EventExtraMenu = ({ evt, room, style }: EventExtraMenuProps) => {
|
||||||
/>
|
/>
|
||||||
</RoomContext>,
|
</RoomContext>,
|
||||||
})
|
})
|
||||||
}, [evt, room, openModal, client])
|
}, [evt, roomCtx, openModal, client])
|
||||||
const onClickPin = useCallback(() => {
|
const onClickPin = useCallback(() => {
|
||||||
closeModal()
|
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}`))
|
.catch(err => window.alert(`Failed to pin message: ${err}`))
|
||||||
}, [closeModal, client, room, evt.event_id])
|
}, [closeModal, client, roomCtx, evt.event_id])
|
||||||
const onClickUnpin = useCallback(() => {
|
const onClickUnpin = useCallback(() => {
|
||||||
closeModal()
|
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}`))
|
.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 [isPending, pendingTitle] = getPending(evt)
|
||||||
const pendingTitle = isPending ? "Can't action messages that haven't been sent yet" : undefined
|
useRoomState(roomCtx.store, "m.room.power_levels", "")
|
||||||
const plEvent = useRoomState(room, "m.room.power_levels", "")
|
|
||||||
// We get pins from getPinnedEvents, but use the hook anyway to subscribe to changes
|
// We get pins from getPinnedEvents, but use the hook anyway to subscribe to changes
|
||||||
useRoomState(room, "m.room.pinned_events", "")
|
useRoomState(roomCtx.store, "m.room.pinned_events", "")
|
||||||
const pls = (plEvent?.content ?? {}) as PowerLevelEventContent
|
const [pls, ownPL] = getPowerLevels(roomCtx.store, client)
|
||||||
const pins = room.getPinnedEvents()
|
const pins = roomCtx.store.getPinnedEvents()
|
||||||
const ownPL = pls.users?.[userID] ?? pls.users_default ?? 0
|
|
||||||
const pinPL = pls.events?.["m.room.pinned_events"] ?? pls.state_default ?? 50
|
const pinPL = pls.events?.["m.room.pinned_events"] ?? pls.state_default ?? 50
|
||||||
const redactEvtPL = pls.events?.["m.room.redaction"] ?? pls.events_default ?? 0
|
const redactEvtPL = pls.events?.["m.room.redaction"] ?? pls.events_default ?? 0
|
||||||
const redactOtherPL = pls.redact ?? 50
|
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 <div style={style} className="event-context-menu-extra">
|
return <>
|
||||||
<button onClick={onClickViewSource}><ViewSourceIcon/>View source</button>
|
<button onClick={onClickViewSource}><ViewSourceIcon/>View source</button>
|
||||||
{ownPL >= pinPL && (pins.includes(evt.event_id)
|
{ownPL >= pinPL && (pins.includes(evt.event_id)
|
||||||
? <button onClick={onClickUnpin}><UnpinIcon/>Unpin message</button>
|
? <button onClick={onClickUnpin}><UnpinIcon/>Unpin message</button>
|
||||||
|
@ -121,7 +118,5 @@ const EventExtraMenu = ({ evt, room, style }: EventExtraMenuProps) => {
|
||||||
title={pendingTitle}
|
title={pendingTitle}
|
||||||
className="redact-button"
|
className="redact-button"
|
||||||
><DeleteIcon/>Remove</button>}
|
><DeleteIcon/>Remove</button>}
|
||||||
</div>
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EventExtraMenu
|
|
58
web/src/ui/timeline/menu/util.ts
Normal file
58
web/src/ui/timeline/menu/util.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
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
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue