From 89ece7fb45e19a0c3a07b2e81ac8d0ee9cb84175 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 15 Oct 2024 01:06:32 +0300 Subject: [PATCH] web/timeline: send read receipts --- web/src/api/statestore/room.ts | 29 ++------------------ web/src/ui/timeline/TimelineView.tsx | 31 ++++++++++++++++----- web/src/util/focus.ts | 24 ++++++++++++++++ web/src/util/subscribable.ts | 41 ++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 33 deletions(-) create mode 100644 web/src/util/focus.ts create mode 100644 web/src/util/subscribable.ts diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index 474c193..8c05571 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" +import Subscribable from "@/util/subscribable.ts" import type { DBRoom, EncryptedEventContent, @@ -60,32 +61,6 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean { meta1.has_member_list === meta2.has_member_list } -type Subscriber = () => void -type SubscribeFunc = (callback: Subscriber) => () => void - -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() - } - } -} - class EventSubscribable extends Subscribable { requested: boolean = false } @@ -103,6 +78,8 @@ export class RoomStateStore { readonly eventSubs: Map = new Map() readonly pendingEvents: EventRowID[] = [] paginating = false + paginationRequestedForRow = -1 + readUpToRow = -1 constructor(meta: DBRoom) { this.roomID = meta.room_id diff --git a/web/src/ui/timeline/TimelineView.tsx b/web/src/ui/timeline/TimelineView.tsx index 1af5fb9..7f81963 100644 --- a/web/src/ui/timeline/TimelineView.tsx +++ b/web/src/ui/timeline/TimelineView.tsx @@ -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 { use, useCallback, useEffect, useRef } from "react" +import { use, useCallback, useEffect, useLayoutEffect, useRef } from "react" import { RoomStateStore, useRoomTimeline } from "@/api/statestore" import { MemDBEvent } from "@/api/types" +import useFocus from "@/util/focus.ts" import { ClientContext } from "../ClientContext.ts" import TimelineEvent from "./TimelineEvent.tsx" import "./TimelineView.css" @@ -33,14 +34,14 @@ const TimelineView = ({ room, textRows, replyTo, setReplyTo }: TimelineViewProps const loadHistory = useCallback(() => { client.loadMoreHistory(room.roomID) .catch(err => console.error("Failed to load history", err)) - }, [client, room.roomID]) + }, [client, room]) const bottomRef = useRef(null) const topRef = useRef(null) const timelineViewRef = useRef(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() + } + } +}