From 24342a5dcec659525c2acbc8af8a83f6bad06eae Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 19 Oct 2024 15:41:10 +0300 Subject: [PATCH] web/composer: add support for rich drafts --- web/src/api/statestore/hooks.ts | 9 +- web/src/ui/MessageComposer.tsx | 140 +++++++++++++++++--------- web/src/ui/RoomView.tsx | 13 ++- web/src/ui/timeline/TimelineEvent.tsx | 8 +- web/src/ui/timeline/TimelineView.tsx | 17 ++-- 5 files changed, 115 insertions(+), 72 deletions(-) diff --git a/web/src/api/statestore/hooks.ts b/web/src/api/statestore/hooks.ts index e214eb7..76ddce4 100644 --- a/web/src/api/statestore/hooks.ts +++ b/web/src/api/statestore/hooks.ts @@ -24,9 +24,12 @@ export function useRoomTimeline(room: RoomStateStore): (MemDBEvent | null)[] { ) } -export function useRoomEvent(room: RoomStateStore, eventID: EventID): MemDBEvent | null { +const noopSubscribe = () => () => {} +const returnNull = () => null + +export function useRoomEvent(room: RoomStateStore, eventID: EventID | null): MemDBEvent | null { return useSyncExternalStore( - room.getEventSubscriber(eventID).subscribe, - () => room.eventsByID.get(eventID) ?? null, + eventID ? room.getEventSubscriber(eventID).subscribe : noopSubscribe, + eventID ? (() => room.eventsByID.get(eventID) ?? null) : returnNull, ) } diff --git a/web/src/ui/MessageComposer.tsx b/web/src/ui/MessageComposer.tsx index 2ef174d..b73c894 100644 --- a/web/src/ui/MessageComposer.tsx +++ b/web/src/ui/MessageComposer.tsx @@ -13,10 +13,10 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { use, useCallback, useLayoutEffect, useRef, useState } from "react" +import React, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react" import { ScaleLoader } from "react-spinners" -import { RoomStateStore } from "@/api/statestore" -import { MediaMessageEventContent, MemDBEvent, Mentions, RoomID } from "@/api/types" +import { RoomStateStore, useRoomEvent } from "@/api/statestore" +import { EventID, MediaMessageEventContent, Mentions, RoomID } from "@/api/types" import { ClientContext } from "./ClientContext.ts" import { ReplyBody } from "./timeline/ReplyBody.tsx" import { useMediaContent } from "./timeline/content/useMediaContent.tsx" @@ -27,63 +27,78 @@ import "./MessageComposer.css" interface MessageComposerProps { room: RoomStateStore - setTextRows: (rows: number) => void - replyTo: MemDBEvent | null - closeReply: () => void + scrollToBottomRef: React.RefObject<() => void> + setReplyToRef: React.RefObject<(evt: EventID | null) => void> } +interface ComposerState { + text: string + media: MediaMessageEventContent | null + replyTo: EventID | null + uninited?: boolean +} + +const emptyComposer: ComposerState = { text: "", media: null, replyTo: null } +const uninitedComposer: ComposerState = { ...emptyComposer, uninited: true } +const composerReducer = (state: ComposerState, action: Partial) => + ({ ...state, ...action, uninited: undefined }) + const draftStore = { - get: (roomID: RoomID) => localStorage.getItem(`draft-${roomID}`) ?? "", - set: (roomID: RoomID, text: string) => localStorage.setItem(`draft-${roomID}`, text), + get: (roomID: RoomID): ComposerState | null => { + const data = localStorage.getItem(`draft-${roomID}`) + if (!data) { + return null + } + try { + return JSON.parse(data) + } catch { + return null + } + }, + set: (roomID: RoomID, data: ComposerState) => localStorage.setItem(`draft-${roomID}`, JSON.stringify(data)), clear: (roomID: RoomID)=> localStorage.removeItem(`draft-${roomID}`), } -const MessageComposer = ({ room, replyTo, setTextRows, closeReply }: MessageComposerProps) => { +const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComposerProps) => { const client = use(ClientContext)! - const [text, setText] = useState("") - const [media, setMedia] = useState(null) + const [state, setState] = useReducer(composerReducer, uninitedComposer) const [loadingMedia, setLoadingMedia] = useState(false) const fileInput = useRef(null) + const textInput = useRef(null) const textRows = useRef(1) const typingSentAt = useRef(0) - const fullSetText = useCallback((text: string, setDraft: boolean) => { - setText(text) - textRows.current = text === "" ? 1 : text.split("\n").length - setTextRows(textRows.current) - if (setDraft) { - if (text === "") { - draftStore.clear(room.roomID) - } else { - draftStore.set(room.roomID, text) - } - } - }, [setTextRows, room.roomID]) + const replyToEvt = useRoomEvent(room, state.replyTo) + setReplyToRef.current = useCallback((evt: EventID | null) => { + setState({ replyTo: evt }) + }, []) const sendMessage = useCallback((evt: React.FormEvent) => { evt.preventDefault() - if (text === "" && !media) { + if (state.text === "" && !state.media) { return } - fullSetText("", true) - setMedia(null) - closeReply() - const room_id = room.roomID + setState(emptyComposer) const mentions: Mentions = { user_ids: [], room: false, } - if (replyTo) { - mentions.user_ids.push(replyTo.sender) + if (replyToEvt) { + mentions.user_ids.push(replyToEvt.sender) } - client.sendMessage({ room_id, base_content: media ?? undefined, text, reply_to: replyTo?.event_id, mentions }) - .catch(err => window.alert("Failed to send message: " + err)) - }, [fullSetText, closeReply, replyTo, media, text, room, client]) + client.sendMessage({ + room_id: room.roomID, + base_content: state.media ?? undefined, + text: state.text, + reply_to: replyToEvt?.event_id, + mentions, + }).catch(err => window.alert("Failed to send message: " + err)) + }, [replyToEvt, state, room, client]) const onKeyDown = useCallback((evt: React.KeyboardEvent) => { if (evt.key === "Enter" && !evt.shiftKey) { sendMessage(evt) } }, [sendMessage]) const onChange = useCallback((evt: React.ChangeEvent) => { - fullSetText(evt.target.value, true) + setState({ text: evt.target.value }) const now = Date.now() if (evt.target.value !== "" && typingSentAt.current + 5_000 < now) { typingSentAt.current = now @@ -94,13 +109,7 @@ const MessageComposer = ({ room, replyTo, setTextRows, closeReply }: MessageComp client.rpc.setTyping(room.roomID, 0) .catch(err => console.error("Failed to send stop typing notification:", err)) } - }, [client, room.roomID, fullSetText]) - const openFilePicker = useCallback(() => { - fileInput.current!.click() - }, []) - const clearMedia = useCallback(() => { - setMedia(null) - }, []) + }, [client, room.roomID]) const onAttachFile = useCallback((evt: React.ChangeEvent) => { setLoadingMedia(true) const file = evt.target.files![0] @@ -114,7 +123,7 @@ const MessageComposer = ({ room, replyTo, setTextRows, closeReply }: MessageComp if (!res.ok) { throw new Error(json.error) } else { - setMedia(json) + setState({ media: json }) } }) .catch(err => window.alert("Failed to upload file: " + err)) @@ -123,7 +132,8 @@ const MessageComposer = ({ room, replyTo, setTextRows, closeReply }: MessageComp // To ensure the cursor jumps to the end, do this in an effect rather than as the initial value of useState // To try to avoid the input bar flashing, use useLayoutEffect instead of useEffect useLayoutEffect(() => { - fullSetText(draftStore.get(room.roomID), false) + const draft = draftStore.get(room.roomID) + setState(draft ?? emptyComposer) return () => { if (typingSentAt.current > 0) { typingSentAt.current = 0 @@ -131,16 +141,43 @@ const MessageComposer = ({ room, replyTo, setTextRows, closeReply }: MessageComp .catch(err => console.error("Failed to send stop typing notification due to room switch:", err)) } } - }, [client, room.roomID, fullSetText]) + }, [client, room.roomID]) + useLayoutEffect(() => { + if (!textInput.current) { + return + } + // This is a hacky way to auto-resize the text area. Setting the rows to 1 and then + // checking scrollHeight seems to be the only reliable way to get the size of the text. + textInput.current.rows = 1 + const newTextRows = (textInput.current.scrollHeight - 16) / 20 + textInput.current.rows = newTextRows + textRows.current = newTextRows + // This has to be called unconditionally, because setting rows = 1 messes up the scroll state otherwise + scrollToBottomRef.current?.() + }, [state, scrollToBottomRef]) + useEffect(() => { + if (state.uninited) { + return + } + if (!state.text && !state.media && !state.replyTo) { + draftStore.clear(room.roomID) + } else { + draftStore.set(room.roomID, state) + } + }, [room, state]) + const openFilePicker = useCallback(() => fileInput.current!.click(), []) + const clearMedia = useCallback(() => setState({ media: null }), []) + const closeReply = useCallback(() => setState({ replyTo: null }), []) return
- {replyTo && } + {replyToEvt && } {loadingMedia &&
} - {media && } + {state.media && }