1
0
Fork 0
forked from Mirrors/gomuks

web/timeline: send read receipts

This commit is contained in:
Tulir Asokan 2024-10-15 01:06:32 +03:00
parent 3c596a200f
commit 89ece7fb45
4 changed files with 92 additions and 33 deletions

View file

@ -14,6 +14,7 @@
// 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/>.
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<Subscriber> = 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<EventID, EventSubscribable> = new Map()
readonly pendingEvents: EventRowID[] = []
paginating = false
paginationRequestedForRow = -1
readUpToRow = -1
constructor(meta: DBRoom) {
this.roomID = meta.room_id

View file

@ -13,9 +13,10 @@
//
// 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/>.
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<HTMLDivElement>(null)
const topRef = useRef<HTMLDivElement>(null)
const timelineViewRef = useRef<HTMLDivElement>(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 <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}>

24
web/src/util/focus.ts Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
export type Subscriber = () => void
export type SubscribeFunc = (callback: Subscriber) => () => void
export default class Subscribable {
readonly subscribers: Set<Subscriber> = 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()
}
}
}