forked from Mirrors/gomuks
web/timeline: send read receipts
This commit is contained in:
parent
3c596a200f
commit
89ece7fb45
4 changed files with 92 additions and 33 deletions
|
@ -14,6 +14,7 @@
|
||||||
// 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 { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
||||||
|
import Subscribable from "@/util/subscribable.ts"
|
||||||
import type {
|
import type {
|
||||||
DBRoom,
|
DBRoom,
|
||||||
EncryptedEventContent,
|
EncryptedEventContent,
|
||||||
|
@ -60,32 +61,6 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
|
||||||
meta1.has_member_list === meta2.has_member_list
|
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 {
|
class EventSubscribable extends Subscribable {
|
||||||
requested: boolean = false
|
requested: boolean = false
|
||||||
}
|
}
|
||||||
|
@ -103,6 +78,8 @@ export class RoomStateStore {
|
||||||
readonly eventSubs: Map<EventID, EventSubscribable> = new Map()
|
readonly eventSubs: Map<EventID, EventSubscribable> = new Map()
|
||||||
readonly pendingEvents: EventRowID[] = []
|
readonly pendingEvents: EventRowID[] = []
|
||||||
paginating = false
|
paginating = false
|
||||||
|
paginationRequestedForRow = -1
|
||||||
|
readUpToRow = -1
|
||||||
|
|
||||||
constructor(meta: DBRoom) {
|
constructor(meta: DBRoom) {
|
||||||
this.roomID = meta.room_id
|
this.roomID = meta.room_id
|
||||||
|
|
|
@ -13,9 +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, useEffect, useRef } from "react"
|
import { 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 { MemDBEvent } from "@/api/types"
|
||||||
|
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"
|
||||||
import "./TimelineView.css"
|
import "./TimelineView.css"
|
||||||
|
@ -33,14 +34,14 @@ const TimelineView = ({ room, textRows, replyTo, setReplyTo }: TimelineViewProps
|
||||||
const loadHistory = useCallback(() => {
|
const loadHistory = useCallback(() => {
|
||||||
client.loadMoreHistory(room.roomID)
|
client.loadMoreHistory(room.roomID)
|
||||||
.catch(err => console.error("Failed to load history", err))
|
.catch(err => console.error("Failed to load history", err))
|
||||||
}, [client, room.roomID])
|
}, [client, room])
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
const topRef = useRef<HTMLDivElement>(null)
|
const topRef = useRef<HTMLDivElement>(null)
|
||||||
const timelineViewRef = useRef<HTMLDivElement>(null)
|
const timelineViewRef = useRef<HTMLDivElement>(null)
|
||||||
const prevOldestTimelineRow = useRef(0)
|
const prevOldestTimelineRow = useRef(0)
|
||||||
const paginationRequestedForRow = useRef(-1)
|
|
||||||
const oldScrollHeight = useRef(0)
|
const oldScrollHeight = useRef(0)
|
||||||
const scrolledToBottom = useRef(true)
|
const scrolledToBottom = useRef(true)
|
||||||
|
const focused = useFocus()
|
||||||
|
|
||||||
// When the user scrolls the timeline manually, remember if they were at the bottom,
|
// 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.
|
// 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) {
|
if (timelineViewRef.current) {
|
||||||
oldScrollHeight.current = timelineViewRef.current.scrollHeight
|
oldScrollHeight.current = timelineViewRef.current.scrollHeight
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
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
|
||||||
bottomRef.current.scrollIntoView()
|
bottomRef.current.scrollIntoView()
|
||||||
|
@ -65,14 +66,30 @@ const TimelineView = ({ room, textRows, replyTo, setReplyTo }: TimelineViewProps
|
||||||
}
|
}
|
||||||
prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
|
prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
|
||||||
}, [textRows, replyTo, timeline])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const topElem = topRef.current
|
const topElem = topRef.current
|
||||||
if (!topElem) {
|
if (!topElem) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const observer = new IntersectionObserver(entries => {
|
const observer = new IntersectionObserver(entries => {
|
||||||
if (entries[0]?.isIntersecting && paginationRequestedForRow.current !== prevOldestTimelineRow.current) {
|
if (entries[0]?.isIntersecting && room.paginationRequestedForRow !== prevOldestTimelineRow.current) {
|
||||||
paginationRequestedForRow.current = prevOldestTimelineRow.current
|
room.paginationRequestedForRow = prevOldestTimelineRow.current
|
||||||
loadHistory()
|
loadHistory()
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
|
@ -82,7 +99,7 @@ const TimelineView = ({ room, textRows, replyTo, setReplyTo }: TimelineViewProps
|
||||||
})
|
})
|
||||||
observer.observe(topElem)
|
observer.observe(topElem)
|
||||||
return () => observer.unobserve(topElem)
|
return () => observer.unobserve(topElem)
|
||||||
}, [loadHistory, topRef])
|
}, [room, loadHistory])
|
||||||
|
|
||||||
let prevEvt: MemDBEvent | null = null
|
let prevEvt: MemDBEvent | null = null
|
||||||
return <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}>
|
return <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}>
|
||||||
|
|
24
web/src/util/focus.ts
Normal file
24
web/src/util/focus.ts
Normal 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)
|
||||||
|
}
|
41
web/src/util/subscribable.ts
Normal file
41
web/src/util/subscribable.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue