web/composer: add support for rich drafts

This commit is contained in:
Tulir Asokan 2024-10-19 15:41:10 +03:00
parent b37e4644b7
commit 24342a5dce
5 changed files with 115 additions and 72 deletions

View file

@ -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( return useSyncExternalStore(
room.getEventSubscriber(eventID).subscribe, eventID ? room.getEventSubscriber(eventID).subscribe : noopSubscribe,
() => room.eventsByID.get(eventID) ?? null, eventID ? (() => room.eventsByID.get(eventID) ?? null) : returnNull,
) )
} }

View file

@ -13,10 +13,10 @@
// //
// 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, useCallback, useLayoutEffect, useRef, useState } from "react" import React, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react"
import { ScaleLoader } from "react-spinners" import { ScaleLoader } from "react-spinners"
import { RoomStateStore } from "@/api/statestore" import { RoomStateStore, useRoomEvent } from "@/api/statestore"
import { MediaMessageEventContent, MemDBEvent, Mentions, RoomID } from "@/api/types" import { EventID, MediaMessageEventContent, Mentions, RoomID } from "@/api/types"
import { ClientContext } from "./ClientContext.ts" import { ClientContext } from "./ClientContext.ts"
import { ReplyBody } from "./timeline/ReplyBody.tsx" import { ReplyBody } from "./timeline/ReplyBody.tsx"
import { useMediaContent } from "./timeline/content/useMediaContent.tsx" import { useMediaContent } from "./timeline/content/useMediaContent.tsx"
@ -27,63 +27,78 @@ import "./MessageComposer.css"
interface MessageComposerProps { interface MessageComposerProps {
room: RoomStateStore room: RoomStateStore
setTextRows: (rows: number) => void scrollToBottomRef: React.RefObject<() => void>
replyTo: MemDBEvent | null setReplyToRef: React.RefObject<(evt: EventID | null) => void>
closeReply: () => 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<ComposerState>) =>
({ ...state, ...action, uninited: undefined })
const draftStore = { const draftStore = {
get: (roomID: RoomID) => localStorage.getItem(`draft-${roomID}`) ?? "", get: (roomID: RoomID): ComposerState | null => {
set: (roomID: RoomID, text: string) => localStorage.setItem(`draft-${roomID}`, text), 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}`), 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 client = use(ClientContext)!
const [text, setText] = useState("") const [state, setState] = useReducer(composerReducer, uninitedComposer)
const [media, setMedia] = useState(null)
const [loadingMedia, setLoadingMedia] = useState(false) const [loadingMedia, setLoadingMedia] = useState(false)
const fileInput = useRef<HTMLInputElement>(null) const fileInput = useRef<HTMLInputElement>(null)
const textInput = useRef<HTMLTextAreaElement>(null)
const textRows = useRef(1) const textRows = useRef(1)
const typingSentAt = useRef(0) const typingSentAt = useRef(0)
const fullSetText = useCallback((text: string, setDraft: boolean) => { const replyToEvt = useRoomEvent(room, state.replyTo)
setText(text) setReplyToRef.current = useCallback((evt: EventID | null) => {
textRows.current = text === "" ? 1 : text.split("\n").length setState({ replyTo: evt })
setTextRows(textRows.current) }, [])
if (setDraft) {
if (text === "") {
draftStore.clear(room.roomID)
} else {
draftStore.set(room.roomID, text)
}
}
}, [setTextRows, room.roomID])
const sendMessage = useCallback((evt: React.FormEvent) => { const sendMessage = useCallback((evt: React.FormEvent) => {
evt.preventDefault() evt.preventDefault()
if (text === "" && !media) { if (state.text === "" && !state.media) {
return return
} }
fullSetText("", true) setState(emptyComposer)
setMedia(null)
closeReply()
const room_id = room.roomID
const mentions: Mentions = { const mentions: Mentions = {
user_ids: [], user_ids: [],
room: false, room: false,
} }
if (replyTo) { if (replyToEvt) {
mentions.user_ids.push(replyTo.sender) mentions.user_ids.push(replyToEvt.sender)
} }
client.sendMessage({ room_id, base_content: media ?? undefined, text, reply_to: replyTo?.event_id, mentions }) client.sendMessage({
.catch(err => window.alert("Failed to send message: " + err)) room_id: room.roomID,
}, [fullSetText, closeReply, replyTo, media, text, room, client]) 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) => { const onKeyDown = useCallback((evt: React.KeyboardEvent) => {
if (evt.key === "Enter" && !evt.shiftKey) { if (evt.key === "Enter" && !evt.shiftKey) {
sendMessage(evt) sendMessage(evt)
} }
}, [sendMessage]) }, [sendMessage])
const onChange = useCallback((evt: React.ChangeEvent<HTMLTextAreaElement>) => { const onChange = useCallback((evt: React.ChangeEvent<HTMLTextAreaElement>) => {
fullSetText(evt.target.value, true) setState({ text: evt.target.value })
const now = Date.now() const now = Date.now()
if (evt.target.value !== "" && typingSentAt.current + 5_000 < now) { if (evt.target.value !== "" && typingSentAt.current + 5_000 < now) {
typingSentAt.current = now typingSentAt.current = now
@ -94,13 +109,7 @@ const MessageComposer = ({ room, replyTo, setTextRows, closeReply }: MessageComp
client.rpc.setTyping(room.roomID, 0) client.rpc.setTyping(room.roomID, 0)
.catch(err => console.error("Failed to send stop typing notification:", err)) .catch(err => console.error("Failed to send stop typing notification:", err))
} }
}, [client, room.roomID, fullSetText]) }, [client, room.roomID])
const openFilePicker = useCallback(() => {
fileInput.current!.click()
}, [])
const clearMedia = useCallback(() => {
setMedia(null)
}, [])
const onAttachFile = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => { const onAttachFile = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
setLoadingMedia(true) setLoadingMedia(true)
const file = evt.target.files![0] const file = evt.target.files![0]
@ -114,7 +123,7 @@ const MessageComposer = ({ room, replyTo, setTextRows, closeReply }: MessageComp
if (!res.ok) { if (!res.ok) {
throw new Error(json.error) throw new Error(json.error)
} else { } else {
setMedia(json) setState({ media: json })
} }
}) })
.catch(err => window.alert("Failed to upload file: " + err)) .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 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 // To try to avoid the input bar flashing, use useLayoutEffect instead of useEffect
useLayoutEffect(() => { useLayoutEffect(() => {
fullSetText(draftStore.get(room.roomID), false) const draft = draftStore.get(room.roomID)
setState(draft ?? emptyComposer)
return () => { return () => {
if (typingSentAt.current > 0) { if (typingSentAt.current > 0) {
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)) .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 <div className="message-composer"> return <div className="message-composer">
{replyTo && <ReplyBody room={room} event={replyTo} onClose={closeReply}/>} {replyToEvt && <ReplyBody room={room} event={replyToEvt} onClose={closeReply}/>}
{loadingMedia && <div className="composer-media"><ScaleLoader/></div>} {loadingMedia && <div className="composer-media"><ScaleLoader/></div>}
{media && <ComposerMedia content={media} clearMedia={clearMedia}/>} {state.media && <ComposerMedia content={state.media} clearMedia={clearMedia}/>}
<div className="input-area"> <div className="input-area">
<textarea <textarea
autoFocus autoFocus
ref={textInput}
rows={textRows.current} rows={textRows.current}
value={text} value={state.text}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onChange={onChange} onChange={onChange}
placeholder="Send a message" placeholder="Send a message"
@ -148,10 +185,13 @@ const MessageComposer = ({ room, replyTo, setTextRows, closeReply }: MessageComp
/> />
<button <button
onClick={openFilePicker} onClick={openFilePicker}
disabled={!!media || loadingMedia} disabled={!!state.media || loadingMedia}
title={media ? "You can only attach one file at a time" : ""} title={state.media ? "You can only attach one file at a time" : ""}
><AttachIcon/></button> ><AttachIcon/></button>
<button onClick={sendMessage} disabled={(!text && !media) || loadingMedia}><SendIcon/></button> <button
onClick={sendMessage}
disabled={(!state.text && !state.media) || loadingMedia}
><SendIcon/></button>
<input ref={fileInput} onChange={onAttachFile} type="file" value="" style={{ display: "none" }}/> <input ref={fileInput} onChange={onAttachFile} type="file" value="" style={{ display: "none" }}/>
</div> </div>
</div> </div>

View file

@ -13,10 +13,10 @@
// //
// 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 { use, useCallback, useState } from "react" import { use, useRef } from "react"
import { getMediaURL } from "@/api/media.ts" import { getMediaURL } from "@/api/media.ts"
import { RoomStateStore } from "@/api/statestore" import { RoomStateStore } from "@/api/statestore"
import { MemDBEvent } from "@/api/types" import { EventID } from "@/api/types"
import { useNonNullEventAsState } from "@/util/eventdispatcher.ts" import { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
import { LightboxContext } from "./Lightbox.tsx" import { LightboxContext } from "./Lightbox.tsx"
import MessageComposer from "./MessageComposer.tsx" import MessageComposer from "./MessageComposer.tsx"
@ -53,13 +53,12 @@ const onKeyDownRoomView = (evt: React.KeyboardEvent) => {
} }
const RoomView = ({ room, clearActiveRoom }: RoomViewProps) => { const RoomView = ({ room, clearActiveRoom }: RoomViewProps) => {
const [replyTo, setReplyTo] = useState<MemDBEvent | null>(null) const scrollToBottomRef = useRef<() => void>(() => {})
const [textRows, setTextRows] = useState(1) const setReplyToRef = useRef<(evt: EventID | null) => void>(() => {})
const closeReply = useCallback(() => setReplyTo(null), [])
return <div className="room-view" onKeyDown={onKeyDownRoomView} tabIndex={-1}> return <div className="room-view" onKeyDown={onKeyDownRoomView} tabIndex={-1}>
<RoomHeader room={room} clearActiveRoom={clearActiveRoom}/> <RoomHeader room={room} clearActiveRoom={clearActiveRoom}/>
<TimelineView room={room} textRows={textRows} replyTo={replyTo} setReplyTo={setReplyTo}/> <TimelineView room={room} scrollToBottomRef={scrollToBottomRef} setReplyToRef={setReplyToRef}/>
<MessageComposer room={room} setTextRows={setTextRows} replyTo={replyTo} closeReply={closeReply}/> <MessageComposer room={room} scrollToBottomRef={scrollToBottomRef} setReplyToRef={setReplyToRef}/>
</div> </div>
} }

View file

@ -16,7 +16,7 @@
import React, { use, useCallback } from "react" import React, { use, useCallback } from "react"
import { getAvatarURL } from "@/api/media.ts" import { getAvatarURL } from "@/api/media.ts"
import { RoomStateStore } from "@/api/statestore" import { RoomStateStore } from "@/api/statestore"
import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types" import { EventID, MemDBEvent, MemberEventContent, UnreadType } from "@/api/types"
import { isEventID } from "@/util/validation.ts" import { isEventID } from "@/util/validation.ts"
import { ClientContext } from "../ClientContext.ts" import { ClientContext } from "../ClientContext.ts"
import { LightboxContext } from "../Lightbox.tsx" import { LightboxContext } from "../Lightbox.tsx"
@ -36,7 +36,7 @@ export interface TimelineEventProps {
room: RoomStateStore room: RoomStateStore
evt: MemDBEvent evt: MemDBEvent
prevEvt: MemDBEvent | null prevEvt: MemDBEvent | null
setReplyTo: (evt: MemDBEvent) => void setReplyToRef: React.RefObject<(evt: EventID | null) => void>
} }
function getBodyType(evt: MemDBEvent): React.FunctionComponent<EventContentProps> { function getBodyType(evt: MemDBEvent): React.FunctionComponent<EventContentProps> {
@ -109,8 +109,8 @@ function isSmallEvent(bodyType: React.FunctionComponent<EventContentProps>): boo
return bodyType === HiddenEvent || bodyType === MemberBody return bodyType === HiddenEvent || bodyType === MemberBody
} }
const TimelineEvent = ({ room, evt, prevEvt, setReplyTo }: TimelineEventProps) => { const TimelineEvent = ({ room, evt, prevEvt, setReplyToRef }: TimelineEventProps) => {
const wrappedSetReplyTo = useCallback(() => setReplyTo(evt), [evt, setReplyTo]) const wrappedSetReplyTo = useCallback(() => setReplyToRef.current(evt.event_id), [evt, setReplyToRef])
const client = use(ClientContext)! const client = use(ClientContext)!
const memberEvt = room.getStateEvent("m.room.member", evt.sender) const memberEvt = room.getStateEvent("m.room.member", evt.sender)
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined const memberEvtContent = memberEvt?.content as MemberEventContent | undefined

View file

@ -13,9 +13,9 @@
// //
// 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 { use, useCallback, useEffect, useLayoutEffect, useRef } from "react" import React, { use, useCallback, useEffect, useLayoutEffect, useRef } from "react"
import { RoomStateStore, useRoomTimeline } from "@/api/statestore" import { RoomStateStore, useRoomTimeline } from "@/api/statestore"
import { MemDBEvent } from "@/api/types" import { EventID, MemDBEvent } from "@/api/types"
import useFocus from "@/util/focus.ts" import useFocus from "@/util/focus.ts"
import { ClientContext } from "../ClientContext.ts" import { ClientContext } from "../ClientContext.ts"
import TimelineEvent from "./TimelineEvent.tsx" import TimelineEvent from "./TimelineEvent.tsx"
@ -23,12 +23,11 @@ import "./TimelineView.css"
interface TimelineViewProps { interface TimelineViewProps {
room: RoomStateStore room: RoomStateStore
textRows: number scrollToBottomRef: React.RefObject<() => void>
replyTo: MemDBEvent | null setReplyToRef: React.RefObject<(evt: EventID | null) => void>
setReplyTo: (evt: MemDBEvent) => void
} }
const TimelineView = ({ room, textRows, replyTo, setReplyTo }: TimelineViewProps) => { const TimelineView = ({ room, scrollToBottomRef, setReplyToRef }: TimelineViewProps) => {
const timeline = useRoomTimeline(room) const timeline = useRoomTimeline(room)
const client = use(ClientContext)! const client = use(ClientContext)!
const loadHistory = useCallback(() => { const loadHistory = useCallback(() => {
@ -56,6 +55,8 @@ const TimelineView = ({ room, textRows, replyTo, setReplyTo }: TimelineViewProps
if (timelineViewRef.current) { if (timelineViewRef.current) {
oldScrollHeight.current = timelineViewRef.current.scrollHeight oldScrollHeight.current = timelineViewRef.current.scrollHeight
} }
scrollToBottomRef.current = () =>
bottomRef.current && scrolledToBottom.current && bottomRef.current.scrollIntoView()
useLayoutEffect(() => { useLayoutEffect(() => {
if (bottomRef.current && scrolledToBottom.current) { if (bottomRef.current && scrolledToBottom.current) {
// For any timeline changes, if we were at the bottom, scroll to the new bottom // For any timeline changes, if we were at the bottom, scroll to the new bottom
@ -65,7 +66,7 @@ const TimelineView = ({ room, textRows, replyTo, setReplyTo }: TimelineViewProps
timelineViewRef.current.scrollTop += timelineViewRef.current.scrollHeight - oldScrollHeight.current timelineViewRef.current.scrollTop += timelineViewRef.current.scrollHeight - oldScrollHeight.current
} }
prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0 prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
}, [textRows, replyTo, timeline]) }, [timeline])
useEffect(() => { useEffect(() => {
const newestEvent = timeline[timeline.length - 1] const newestEvent = timeline[timeline.length - 1]
if ( if (
@ -114,7 +115,7 @@ const TimelineView = ({ room, textRows, replyTo, setReplyTo }: TimelineViewProps
return null return null
} }
const thisEvt = <TimelineEvent const thisEvt = <TimelineEvent
key={entry.rowid} room={room} evt={entry} prevEvt={prevEvt} setReplyTo={setReplyTo} key={entry.rowid} room={room} evt={entry} prevEvt={prevEvt} setReplyToRef={setReplyToRef}
/> />
prevEvt = entry prevEvt = entry
return thisEvt return thisEvt