(null)
const prevOldestTimelineRow = useRef(0)
- const paginationRequestedForRow = useRef(-1)
const oldScrollHeight = useRef(0)
const scrolledToBottom = useRef(true)
+ const focused = useFocus()
// When the user scrolls the timeline manually, remember if they were at the bottom,
// so that we can keep them at the bottom when new events are added.
@@ -55,7 +56,7 @@ const TimelineView = ({ room, textRows, replyTo, setReplyTo }: TimelineViewProps
if (timelineViewRef.current) {
oldScrollHeight.current = timelineViewRef.current.scrollHeight
}
- useEffect(() => {
+ useLayoutEffect(() => {
if (bottomRef.current && scrolledToBottom.current) {
// For any timeline changes, if we were at the bottom, scroll to the new bottom
bottomRef.current.scrollIntoView()
@@ -65,14 +66,30 @@ const TimelineView = ({ room, textRows, replyTo, setReplyTo }: TimelineViewProps
}
prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
}, [textRows, replyTo, timeline])
+ useEffect(() => {
+ const newestEvent = timeline[timeline.length - 1]
+ if (
+ scrolledToBottom.current
+ && focused
+ && newestEvent
+ && newestEvent.timeline_rowid > 0
+ && room.readUpToRow < newestEvent.timeline_rowid
+ ) {
+ room.readUpToRow = newestEvent.timeline_rowid
+ client.rpc.markRead(room.roomID, newestEvent.event_id, "m.read").then(
+ () => console.log("Marked read up to", newestEvent.event_id, newestEvent.timeline_rowid),
+ err => console.error(`Failed to send read receipt for ${newestEvent.event_id}:`, err),
+ )
+ }
+ }, [focused, client, room, timeline])
useEffect(() => {
const topElem = topRef.current
if (!topElem) {
return
}
const observer = new IntersectionObserver(entries => {
- if (entries[0]?.isIntersecting && paginationRequestedForRow.current !== prevOldestTimelineRow.current) {
- paginationRequestedForRow.current = prevOldestTimelineRow.current
+ if (entries[0]?.isIntersecting && room.paginationRequestedForRow !== prevOldestTimelineRow.current) {
+ room.paginationRequestedForRow = prevOldestTimelineRow.current
loadHistory()
}
}, {
@@ -82,7 +99,7 @@ const TimelineView = ({ room, textRows, replyTo, setReplyTo }: TimelineViewProps
})
observer.observe(topElem)
return () => observer.unobserve(topElem)
- }, [loadHistory, topRef])
+ }, [room, loadHistory])
let prevEvt: MemDBEvent | null = null
return
diff --git a/web/src/util/focus.ts b/web/src/util/focus.ts
new file mode 100644
index 0000000..b3af480
--- /dev/null
+++ b/web/src/util/focus.ts
@@ -0,0 +1,24 @@
+// 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 .
+import { NonNullCachedEventDispatcher, useNonNullEventAsState } from "@/util/eventdispatcher.ts"
+
+const focused = new NonNullCachedEventDispatcher(document.hasFocus())
+window.addEventListener("focus", () => focused.emit(true))
+window.addEventListener("blur", () => focused.emit(true))
+
+export default function useFocus() {
+ return useNonNullEventAsState(focused)
+}
diff --git a/web/src/util/subscribable.ts b/web/src/util/subscribable.ts
new file mode 100644
index 0000000..62eeaef
--- /dev/null
+++ b/web/src/util/subscribable.ts
@@ -0,0 +1,41 @@
+// 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 .
+
+export type Subscriber = () => void
+export type SubscribeFunc = (callback: Subscriber) => () => void
+
+export default class Subscribable {
+ readonly subscribers: Set = new Set()
+
+ constructor(private onEmpty?: () => void) {
+ }
+
+ subscribe: SubscribeFunc = callback => {
+ this.subscribers.add(callback)
+ return () => {
+ this.subscribers.delete(callback)
+ if (this.subscribers.size === 0) {
+ this.onEmpty?.()
+ }
+ }
+ }
+
+ notify() {
+ for (const sub of this.subscribers) {
+ sub()
+ }
+ }
+}