From cc0067bb3fd9a895d2087fba908869fef781d8a3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 25 Oct 2024 03:03:15 +0300 Subject: [PATCH] web/composer: add edit support --- pkg/hicli/send.go | 6 +++ web/src/ui/composer/MessageComposer.tsx | 58 +++++++++++++++++++++++-- web/src/ui/roomcontext.ts | 7 ++- web/src/ui/timeline/EventMenu.tsx | 14 ++++-- web/src/ui/timeline/ReplyBody.css | 5 +++ web/src/ui/timeline/ReplyBody.tsx | 19 +++++--- web/src/util/eventdispatcher.ts | 6 ++- 7 files changed, 97 insertions(+), 18 deletions(-) diff --git a/pkg/hicli/send.go b/pkg/hicli/send.go index eb48a2f..7402fbb 100644 --- a/pkg/hicli/send.go +++ b/pkg/hicli/send.go @@ -77,9 +77,15 @@ func (h *HiClient) SendMessage( if relatesTo.Type == event.RelReplace { contentCopy := content content = event.MessageEventContent{ + Body: "", + MsgType: contentCopy.MsgType, + URL: contentCopy.URL, NewContent: &contentCopy, RelatesTo: relatesTo, } + if contentCopy.File != nil { + content.URL = contentCopy.File.URL + } } else { content.RelatesTo = relatesTo } diff --git a/web/src/ui/composer/MessageComposer.tsx b/web/src/ui/composer/MessageComposer.tsx index c405e79..22ba8f0 100644 --- a/web/src/ui/composer/MessageComposer.tsx +++ b/web/src/ui/composer/MessageComposer.tsx @@ -16,7 +16,15 @@ import React, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react" import { ScaleLoader } from "react-spinners" import { useRoomEvent } from "@/api/statestore" -import type { EventID, MediaMessageEventContent, Mentions, RelatesTo, RoomID } from "@/api/types" +import type { + EventID, + MediaMessageEventContent, + MemDBEvent, + Mentions, + MessageEventContent, + RelatesTo, + RoomID, +} from "@/api/types" import useEvent from "@/util/useEvent.ts" import { ClientContext } from "../ClientContext.ts" import { useRoomContext } from "../roomcontext.ts" @@ -67,6 +75,7 @@ const MessageComposer = () => { const client = use(ClientContext)! const [autocomplete, setAutocomplete] = useState(null) const [state, setState] = useReducer(composerReducer, uninitedComposer) + const [editing, rawSetEditing] = useState(null) const [loadingMedia, setLoadingMedia] = useState(false) const fileInput = useRef(null) const textInput = useRef(null) @@ -77,19 +86,48 @@ const MessageComposer = () => { setState({ replyTo: evt }) textInput.current?.focus() }, []) + roomCtx.setEditing = useCallback((evt: MemDBEvent | null) => { + if (evt === null) { + rawSetEditing(null) + setState(emptyComposer) + return + } + if (state.text || state.media) { + // TODO save as draft instead of discarding? + const ok = window.confirm("Discard current message to start editing?") + if (!ok) { + return + } + } + const evtContent = evt.content as MessageEventContent + const mediaMsgTypes = ["m.image", "m.audio", "m.video", "m.file"] + const isMedia = mediaMsgTypes.includes(evtContent.msgtype) && (evt.content?.url || evt.content?.file?.url) + rawSetEditing(evt) + setState({ + media: isMedia ? evtContent as MediaMessageEventContent : null, + text: (!evt.content.filename || evt.content.filename !== evt.content.body) ? (evtContent.body ?? "") : "", + }) + textInput.current?.focus() + }, [state]) const sendMessage = useEvent((evt: React.FormEvent) => { evt.preventDefault() if (state.text === "" && !state.media) { return } setState(emptyComposer) + rawSetEditing(null) setAutocomplete(null) const mentions: Mentions = { user_ids: [], room: false, } let relates_to: RelatesTo | undefined = undefined - if (replyToEvt) { + if (editing) { + relates_to = { + rel_type: "m.replace", + event_id: editing.event_id, + } + } else if (replyToEvt) { mentions.user_ids.push(replyToEvt.sender) relates_to = { "m.in_reply_to": { @@ -229,7 +267,8 @@ const MessageComposer = () => { }, [state, roomCtx]) // Saving to localStorage could be done in the reducer, but that's not very proper, so do it in an effect. useEffect(() => { - if (state.uninited) { + roomCtx.isEditing.emit(editing !== null) + if (state.uninited || editing) { return } if (!state.text && !state.media && !state.replyTo) { @@ -237,13 +276,17 @@ const MessageComposer = () => { } else { draftStore.set(room.roomID, state) } - }, [room, state]) + }, [roomCtx, room, state, editing]) const openFilePicker = useCallback(() => fileInput.current!.click(), []) const clearMedia = useCallback(() => setState({ media: null }), []) const closeReply = useCallback((evt: React.MouseEvent) => { evt.stopPropagation() setState({ replyTo: null }) }, []) + const stopEditing = useCallback((evt: React.MouseEvent) => { + evt.stopPropagation() + roomCtx.setEditing(null) + }, [roomCtx]) const Autocompleter = getAutocompleter(autocomplete) return
{Autocompleter && autocomplete &&
{ onClose={closeReply} isThread={replyToEvt.content?.["m.relates_to"]?.rel_type === "m.thread"} />} + {editing && } {loadingMedia &&
} {state.media && }
diff --git a/web/src/ui/roomcontext.ts b/web/src/ui/roomcontext.ts index a310788..3d7e694 100644 --- a/web/src/ui/roomcontext.ts +++ b/web/src/ui/roomcontext.ts @@ -13,9 +13,10 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { RefObject, createContext, createRef, use } from "react" +import { RefObject, createContext, createRef, use } from "react" import { RoomStateStore } from "@/api/statestore" -import { EventID } from "@/api/types" +import { EventID, MemDBEvent } from "@/api/types" +import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" const noop = (name: string) => () => { console.warn(`${name} called before initialization`) @@ -24,6 +25,8 @@ const noop = (name: string) => () => { export class RoomContextData { public timelineBottomRef: RefObject = createRef() public setReplyTo: (eventID: EventID | null) => void = noop("setReplyTo") + public setEditing: (evt: MemDBEvent | null) => void = noop("setEditing") + public isEditing = new NonNullCachedEventDispatcher(false) constructor(public store: RoomStateStore) {} diff --git a/web/src/ui/timeline/EventMenu.tsx b/web/src/ui/timeline/EventMenu.tsx index 035b420..db2ed5f 100644 --- a/web/src/ui/timeline/EventMenu.tsx +++ b/web/src/ui/timeline/EventMenu.tsx @@ -16,6 +16,7 @@ import { use, useCallback } from "react" import { useRoomState } from "@/api/statestore" import { MemDBEvent, PowerLevelEventContent } from "@/api/types" +import { useNonNullEventAsState } from "@/util/eventdispatcher.ts" import { ClientContext } from "../ClientContext.ts" import { useRoomContext } from "../roomcontext.ts" import EditIcon from "../../icons/edit.svg?react" @@ -47,11 +48,12 @@ const EventMenu = ({ evt }: EventHoverMenuProps) => { window.alert("No reactions yet :(") }, []) const onClickEdit = useCallback(() => { - window.alert("No edits yet :(") - }, []) + roomCtx.setEditing(evt) + }, [roomCtx, evt]) const onClickMore = useCallback(() => { window.alert("Nothing here yet :(") }, []) + const isEditing = useNonNullEventAsState(roomCtx.isEditing) const plEvent = useRoomState(roomCtx.store, "m.room.power_levels", "") // We get pins from getPinnedEvents, but use the hook anyway to subscribe to changes useRoomState(roomCtx.store, "m.room.pinned_events", "") @@ -61,8 +63,12 @@ const EventMenu = ({ evt }: EventHoverMenuProps) => { const pinPL = pls.events?.["m.room.pinned_events"] ?? pls.state_default ?? 50 return
- - + + {evt.sender === userID && evt.type === "m.room.message" && } {ownPL >= pinPL && (pins.includes(evt.event_id) ? : )} diff --git a/web/src/ui/timeline/ReplyBody.css b/web/src/ui/timeline/ReplyBody.css index 03af9f4..3561407 100644 --- a/web/src/ui/timeline/ReplyBody.css +++ b/web/src/ui/timeline/ReplyBody.css @@ -33,6 +33,11 @@ blockquote.reply-body { color: #666; } + &.editing > div.reply-sender > span.event-sender::after { + content: " (editing message)"; + color: #666; + } + > div.reply-sender { display: flex; align-items: center; diff --git a/web/src/ui/timeline/ReplyBody.tsx b/web/src/ui/timeline/ReplyBody.tsx index 48ece9f..cc97086 100644 --- a/web/src/ui/timeline/ReplyBody.tsx +++ b/web/src/ui/timeline/ReplyBody.tsx @@ -26,6 +26,7 @@ interface ReplyBodyProps { room: RoomStateStore event: MemDBEvent isThread: boolean + isEditing?: boolean onClose?: (evt: React.MouseEvent) => void } @@ -64,15 +65,21 @@ const onClickReply = (evt: React.MouseEvent) => { } } -export const ReplyBody = ({ room, event, onClose, isThread }: ReplyBodyProps) => { +export const ReplyBody = ({ room, event, onClose, isThread, isEditing }: ReplyBodyProps) => { const memberEvt = useRoomState(room, "m.room.member", event.sender) const memberEvtContent = memberEvt?.content as MemberEventContent | undefined const BodyType = getBodyType(event, true) - return
+ const classNames = ["reply-body"] + if (onClose) { + classNames.push("composer") + } + if (isThread) { + classNames.push("thread") + } + if (isEditing) { + classNames.push("editing") + } + return
extends EventDispatcher { } emit(data: T) { - this.current = data - super.emit(data) + if (!Object.is(this.current, data)) { + this.current = data + super.emit(data) + } } listen(listener: (data: T) => void): () => void {