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 && }
diff --git a/web/src/ui/RoomView.tsx b/web/src/ui/RoomView.tsx
index 457a911..b5e7ce2 100644
--- a/web/src/ui/RoomView.tsx
+++ b/web/src/ui/RoomView.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 { use, useCallback, useState } from "react"
+import { use, useRef } from "react"
import { getMediaURL } from "@/api/media.ts"
import { RoomStateStore } from "@/api/statestore"
-import { MemDBEvent } from "@/api/types"
+import { EventID } from "@/api/types"
import { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
import { LightboxContext } from "./Lightbox.tsx"
import MessageComposer from "./MessageComposer.tsx"
@@ -53,13 +53,12 @@ const onKeyDownRoomView = (evt: React.KeyboardEvent) => {
}
const RoomView = ({ room, clearActiveRoom }: RoomViewProps) => {
- const [replyTo, setReplyTo] = useState(null)
- const [textRows, setTextRows] = useState(1)
- const closeReply = useCallback(() => setReplyTo(null), [])
+ const scrollToBottomRef = useRef<() => void>(() => {})
+ const setReplyToRef = useRef<(evt: EventID | null) => void>(() => {})
return
-
-
+
+
}
diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx
index 87b037e..1d1555e 100644
--- a/web/src/ui/timeline/TimelineEvent.tsx
+++ b/web/src/ui/timeline/TimelineEvent.tsx
@@ -16,7 +16,7 @@
import React, { use, useCallback } from "react"
import { getAvatarURL } from "@/api/media.ts"
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 { ClientContext } from "../ClientContext.ts"
import { LightboxContext } from "../Lightbox.tsx"
@@ -36,7 +36,7 @@ export interface TimelineEventProps {
room: RoomStateStore
evt: MemDBEvent
prevEvt: MemDBEvent | null
- setReplyTo: (evt: MemDBEvent) => void
+ setReplyToRef: React.RefObject<(evt: EventID | null) => void>
}
function getBodyType(evt: MemDBEvent): React.FunctionComponent {
@@ -109,8 +109,8 @@ function isSmallEvent(bodyType: React.FunctionComponent): boo
return bodyType === HiddenEvent || bodyType === MemberBody
}
-const TimelineEvent = ({ room, evt, prevEvt, setReplyTo }: TimelineEventProps) => {
- const wrappedSetReplyTo = useCallback(() => setReplyTo(evt), [evt, setReplyTo])
+const TimelineEvent = ({ room, evt, prevEvt, setReplyToRef }: TimelineEventProps) => {
+ const wrappedSetReplyTo = useCallback(() => setReplyToRef.current(evt.event_id), [evt, setReplyToRef])
const client = use(ClientContext)!
const memberEvt = room.getStateEvent("m.room.member", evt.sender)
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
diff --git a/web/src/ui/timeline/TimelineView.tsx b/web/src/ui/timeline/TimelineView.tsx
index a508d50..8555c85 100644
--- a/web/src/ui/timeline/TimelineView.tsx
+++ b/web/src/ui/timeline/TimelineView.tsx
@@ -13,9 +13,9 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-import { use, useCallback, useEffect, useLayoutEffect, useRef } from "react"
+import React, { use, useCallback, useEffect, useLayoutEffect, useRef } from "react"
import { RoomStateStore, useRoomTimeline } from "@/api/statestore"
-import { MemDBEvent } from "@/api/types"
+import { EventID, MemDBEvent } from "@/api/types"
import useFocus from "@/util/focus.ts"
import { ClientContext } from "../ClientContext.ts"
import TimelineEvent from "./TimelineEvent.tsx"
@@ -23,12 +23,11 @@ import "./TimelineView.css"
interface TimelineViewProps {
room: RoomStateStore
- textRows: number
- replyTo: MemDBEvent | null
- setReplyTo: (evt: MemDBEvent) => void
+ scrollToBottomRef: React.RefObject<() => void>
+ setReplyToRef: React.RefObject<(evt: EventID | null) => void>
}
-const TimelineView = ({ room, textRows, replyTo, setReplyTo }: TimelineViewProps) => {
+const TimelineView = ({ room, scrollToBottomRef, setReplyToRef }: TimelineViewProps) => {
const timeline = useRoomTimeline(room)
const client = use(ClientContext)!
const loadHistory = useCallback(() => {
@@ -56,6 +55,8 @@ const TimelineView = ({ room, textRows, replyTo, setReplyTo }: TimelineViewProps
if (timelineViewRef.current) {
oldScrollHeight.current = timelineViewRef.current.scrollHeight
}
+ scrollToBottomRef.current = () =>
+ bottomRef.current && scrolledToBottom.current && bottomRef.current.scrollIntoView()
useLayoutEffect(() => {
if (bottomRef.current && scrolledToBottom.current) {
// 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
}
prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
- }, [textRows, replyTo, timeline])
+ }, [timeline])
useEffect(() => {
const newestEvent = timeline[timeline.length - 1]
if (
@@ -114,7 +115,7 @@ const TimelineView = ({ room, textRows, replyTo, setReplyTo }: TimelineViewProps
return null
}
const thisEvt =
prevEvt = entry
return thisEvt