diff --git a/go.mod b/go.mod index c7db0d7..5f2988a 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( golang.org/x/crypto v0.27.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 - maunium.net/go/mautrix v0.21.1-0.20241010172818-50f4a2eec193 + maunium.net/go/mautrix v0.21.1-0.20241012091419-9e796dd66c0d ) require ( diff --git a/go.sum b/go.sum index e7d846e..322a484 100644 --- a/go.sum +++ b/go.sum @@ -60,5 +60,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= -maunium.net/go/mautrix v0.21.1-0.20241010172818-50f4a2eec193 h1:WK4mjzzDZ+9cEthJHyc5h7yAYYK/syUPJDYjkn90ujs= -maunium.net/go/mautrix v0.21.1-0.20241010172818-50f4a2eec193/go.mod h1:+fF5qsmXRCEXQZgW5ececC0PI3c7gISHTLcyftP4Bh0= +maunium.net/go/mautrix v0.21.1-0.20241012091419-9e796dd66c0d h1:wiiX3GSf8/6o3HIfjlgxdZTq3EiXQ6sgeXm9rLkGGDU= +maunium.net/go/mautrix v0.21.1-0.20241012091419-9e796dd66c0d/go.mod h1:+fF5qsmXRCEXQZgW5ececC0PI3c7gISHTLcyftP4Bh0= diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f23c4bd..718d6df 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -70,11 +70,19 @@ export default class Client { if (!room) { throw new Error("Room not found") } - const oldestRowID = room.timeline.current[0]?.timeline_rowid - const resp = await this.rpc.paginate(roomID, oldestRowID ?? 0, 100) - if (room.timeline.current[0]?.timeline_rowid !== oldestRowID) { - throw new Error("Timeline changed while loading history") + if (room.paginating) { + return + } + room.paginating = true + try { + const oldestRowID = room.timeline[0]?.timeline_rowid + const resp = await this.rpc.paginate(roomID, oldestRowID ?? 0, 100) + if (room.timeline[0]?.timeline_rowid !== oldestRowID) { + throw new Error("Timeline changed while loading history") + } + room.applyPagination(resp.events) + } finally { + room.paginating = false } - room.applyPagination(resp.events) } } diff --git a/web/src/api/media.ts b/web/src/api/media.ts index 067e2d9..2a53896 100644 --- a/web/src/api/media.ts +++ b/web/src/api/media.ts @@ -14,6 +14,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { UserID } from "./types" + const mediaRegex = /^mxc:\/\/([a-zA-Z0-9.:-]+)\/([a-zA-Z0-9_-]+)$/ export const getMediaURL = (mxc?: string): string | undefined => { @@ -26,3 +28,17 @@ export const getMediaURL = (mxc?: string): string | undefined => { } return `_gomuks/media/${match[1]}/${match[2]}` } + +export const getAvatarURL = (_userID: UserID, mxc?: string): string | undefined => { + if (!mxc) { + return undefined + // return `_gomuks/avatar/${encodeURIComponent(userID)}` + } + const match = mxc.match(mediaRegex) + if (!match) { + return undefined + // return `_gomuks/avatar/${encodeURIComponent(userID)}` + } + return `_gomuks/media/${match[1]}/${match[2]}` + // return `_gomuks/avatar/${encodeURIComponent(userID)}/${match[1]}/${match[2]}` +} diff --git a/web/src/api/statestore.ts b/web/src/api/statestore.ts index 7895563..67b1f12 100644 --- a/web/src/api/statestore.ts +++ b/web/src/api/statestore.ts @@ -13,6 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { useSyncExternalStore } from "react" import { NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts" import type { ContentURI, @@ -62,20 +63,51 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean { meta1.has_member_list === meta2.has_member_list } +export function useRoomTimeline(room: RoomStateStore): (MemDBEvent | null)[] { + return useSyncExternalStore( + room.subscribeTimeline, + () => room.timelineCache, + ) +} + +type SubscribeFunc = (callback: () => void) => () => void + export class RoomStateStore { readonly roomID: RoomID readonly meta: NonNullCachedEventDispatcher - readonly timeline = new NonNullCachedEventDispatcher([]) + timeline: TimelineRowTuple[] = [] + timelineCache: (MemDBEvent | null)[] = [] state: Map> = new Map() stateLoaded = false readonly eventsByRowID: Map = new Map() readonly eventsByID: Map = new Map() + readonly timelineSubscribers: Set<() => void> = new Set() + paginating = false constructor(meta: DBRoom) { this.roomID = meta.room_id this.meta = new NonNullCachedEventDispatcher(meta) } + subscribeTimeline: SubscribeFunc = callback => { + this.timelineSubscribers.add(callback) + return () => this.timelineSubscribers.delete(callback) + } + + notifyTimelineSubscribers() { + this.timelineCache = this.timeline.map(rt => { + const evt = this.eventsByRowID.get(rt.event_rowid) + if (!evt) { + return null + } + evt.timeline_rowid = rt.timeline_rowid + return evt + }) + for (const sub of this.timelineSubscribers) { + sub() + } + } + getStateEvent(type: EventType, stateKey: string): MemDBEvent | undefined { const rowID = this.state.get(type)?.get(stateKey) if (!rowID) { @@ -91,7 +123,8 @@ export class RoomStateStore { this.applyEvent(evt) return { timeline_rowid: evt.timeline_rowid, event_rowid: evt.rowid } }) - this.timeline.emit([...newTimeline, ...this.timeline.current]) + this.timeline.splice(0, 0, ...newTimeline) + this.notifyTimelineSubscribers() } applyEvent(evt: RawDBEvent) { @@ -104,6 +137,13 @@ export class RoomStateStore { } delete evt.decrypted delete evt.decrypted_type + if (memEvt.last_edit_rowid) { + memEvt.last_edit = this.eventsByRowID.get(memEvt.last_edit_rowid) + if (memEvt.last_edit) { + memEvt.orig_content = memEvt.content + memEvt.content = memEvt.last_edit.content["m.new_content"] + } + } this.eventsByRowID.set(memEvt.rowid, memEvt) this.eventsByID.set(memEvt.event_id, memEvt) } @@ -128,20 +168,21 @@ export class RoomStateStore { } } if (sync.reset) { - this.timeline.emit(sync.timeline) + this.timeline = sync.timeline } else { - this.timeline.emit([...this.timeline.current, ...sync.timeline]) + this.timeline.push(...sync.timeline) } + this.notifyTimelineSubscribers() } applyDecrypted(decrypted: EventsDecryptedData) { let timelineChanged = false for (const evt of decrypted.events) { - timelineChanged = timelineChanged || !!this.timeline.current.find(rt => rt.event_rowid === evt.rowid) + timelineChanged = timelineChanged || !!this.timeline.find(rt => rt.event_rowid === evt.rowid) this.applyEvent(evt) } if (timelineChanged) { - this.timeline.emit([...this.timeline.current]) + this.notifyTimelineSubscribers() } if (decrypted.preview_event_rowid) { this.meta.current.preview_event_rowid = decrypted.preview_event_rowid diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index 1bd9c70..af717e7 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -62,6 +62,9 @@ export interface DBRoom { prev_batch: string } +//eslint-disable-next-line @typescript-eslint/no-explicit-any +export type UnknownEventContent = Record + export interface BaseDBEvent { rowid: EventRowID timeline_rowid: TimelineRowID @@ -73,8 +76,7 @@ export interface BaseDBEvent { state_key?: string timestamp: number - //eslint-disable-next-line @typescript-eslint/no-explicit-any - content: Record + content: UnknownEventContent unsigned: EventUnsigned transaction_id?: string @@ -90,14 +92,15 @@ export interface BaseDBEvent { } export interface RawDBEvent extends BaseDBEvent { - //eslint-disable-next-line @typescript-eslint/no-explicit-any - decrypted?: Record + decrypted?: UnknownEventContent decrypted_type?: EventType } export interface MemDBEvent extends BaseDBEvent { mem: true encrypted?: EncryptedEventContent + orig_content?: UnknownEventContent + last_edit?: MemDBEvent } export interface DBAccountData { diff --git a/web/src/ui/timeline/TimelineEvent.css b/web/src/ui/timeline/TimelineEvent.css index 6cb24a5..0d89b73 100644 --- a/web/src/ui/timeline/TimelineEvent.css +++ b/web/src/ui/timeline/TimelineEvent.css @@ -7,7 +7,7 @@ div.timeline-event { grid-template: "avatar gap sender" auto "avatar gap content" auto - / 40px .25rem 1fr; + / 2.5rem .25rem 1fr; > div.sender-avatar { grid-area: avatar; @@ -29,13 +29,13 @@ div.timeline-event { display: flex; align-items: center; - gap: .5rem; + gap: .25rem; - span.event-sender { + > span.event-sender { font-weight: bold; } - span.event-time { + > span.event-time, > span.event-edited { font-size: .8rem; } } @@ -44,6 +44,32 @@ div.timeline-event { grid-area: content; overflow: hidden; } + + &.hidden-event { + grid-template: + "sender avatar content" auto + / 2.5rem 1.5rem 1fr; + margin-top: 0; + + > div.sender-avatar { + margin-top: 0; + width: 1.5rem; + height: 1.5rem; + display: flex; + align-items: center; + + > img { + width: 1rem; + height: 1rem; + } + } + + > div.event-sender-and-time { + > span.event-sender, > span.event-edited { + display: none; + } + } + } } div.html-body { diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 2db8b72..fd3fac1 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -14,27 +14,37 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import React from "react" -import { getMediaURL } from "../../api/media.ts" +import { getAvatarURL } from "../../api/media.ts" import { RoomStateStore } from "../../api/statestore.ts" import { MemDBEvent, MemberEventContent } from "../../api/types" +import EncryptedBody from "./content/EncryptedBody.tsx" import HiddenEvent from "./content/HiddenEvent.tsx" import MessageBody from "./content/MessageBody.tsx" +import RedactedBody from "./content/RedactedBody.tsx" import { EventContentProps } from "./content/props.ts" import "./TimelineEvent.css" export interface TimelineEventProps { room: RoomStateStore - eventRowID: number + evt: MemDBEvent } function getBodyType(evt: MemDBEvent): React.FunctionComponent { - if (evt.content["m.relates_to"]?.relation_type === "m.replace") { + if (evt.relation_type === "m.replace") { return HiddenEvent } switch (evt.type) { case "m.room.message": case "m.sticker": + if (evt.redacted_by) { + return RedactedBody + } return MessageBody + case "m.room.encrypted": + if (evt.redacted_by) { + return RedactedBody + } + return EncryptedBody } return HiddenEvent } @@ -43,30 +53,34 @@ const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", const formatShortTime = (time: Date) => `${time.getHours().toString().padStart(2, "0")}:${time.getMinutes().toString().padStart(2, "0")}` -const TimelineEvent = ({ room, eventRowID }: TimelineEventProps) => { - const evt = room.eventsByRowID.get(eventRowID) - if (!evt) { - return null - } +const EventReactions = ({ reactions }: { reactions: Record }) => { + return
+ {Object.entries(reactions).map(([reaction, count]) => + {reaction} {count} + )} +
+} + +const TimelineEvent = ({ room, evt }: TimelineEventProps) => { const memberEvt = room.getStateEvent("m.room.member", evt.sender) const memberEvtContent = memberEvt?.content as MemberEventContent | undefined const BodyType = getBodyType(evt) - // if (BodyType === HiddenEvent) { - // return
- // - //
- // } const eventTS = new Date(evt.timestamp) - return
-
- + const editEventTS = evt.last_edit ? new Date(evt.last_edit.timestamp) : null + return
+
+
{memberEvtContent?.displayname ?? evt.sender} {formatShortTime(eventTS)} + {editEventTS ? + (edited at {formatShortTime(editEventTS)}) + : null}
+ {evt.reactions ? : null}
} diff --git a/web/src/ui/timeline/TimelineView.tsx b/web/src/ui/timeline/TimelineView.tsx index b2cb62c..006e036 100644 --- a/web/src/ui/timeline/TimelineView.tsx +++ b/web/src/ui/timeline/TimelineView.tsx @@ -14,8 +14,7 @@ // 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 { RoomStateStore } from "../../api/statestore.ts" -import { useNonNullEventAsState } from "../../util/eventdispatcher.ts" +import { RoomStateStore, useRoomTimeline } from "../../api/statestore.ts" import { ClientContext } from "../ClientContext.ts" import TimelineEvent from "./TimelineEvent.tsx" import "./TimelineView.css" @@ -25,15 +24,17 @@ interface TimelineViewProps { } const TimelineView = ({ room }: TimelineViewProps) => { - const timeline = useNonNullEventAsState(room.timeline) + const timeline = useRoomTimeline(room) const client = use(ClientContext)! const loadHistory = useCallback(() => { client.loadMoreHistory(room.roomID) .catch(err => console.error("Failed to load history", err)) }, [client, room.roomID]) 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) @@ -54,21 +55,40 @@ const TimelineView = ({ room }: TimelineViewProps) => { if (bottomRef.current && scrolledToBottom.current) { // For any timeline changes, if we were at the bottom, scroll to the new bottom bottomRef.current.scrollIntoView() - } else if (timelineViewRef.current && prevOldestTimelineRow.current > timeline[0]?.timeline_rowid) { + } else if (timelineViewRef.current && prevOldestTimelineRow.current > (timeline[0]?.timeline_rowid ?? 0)) { // When new entries are added to the top of the timeline, scroll down to keep the same position timelineViewRef.current.scrollTop += timelineViewRef.current.scrollHeight - oldScrollHeight.current } prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0 }, [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 + loadHistory() + } + }, { + root: topElem.parentElement!.parentElement, + rootMargin: "0px", + threshold: 1.0, + }) + observer.observe(topElem) + return () => observer.unobserve(topElem) + }, [loadHistory, topRef]) return
- {timeline.map(entry => )} +
+ {timeline.map(entry => entry ? : null)}
diff --git a/web/src/ui/timeline/content/EncryptedBody.tsx b/web/src/ui/timeline/content/EncryptedBody.tsx new file mode 100644 index 0000000..54d2e61 --- /dev/null +++ b/web/src/ui/timeline/content/EncryptedBody.tsx @@ -0,0 +1,25 @@ +// 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 { EventContentProps } from "./props.ts" + +const EncryptedBody = ({ event }: EventContentProps) => { + if (event.decryption_error) { + return `Failed to decrypt: ${event.decryption_error}` + } + return `Waiting for message` +} + +export default EncryptedBody diff --git a/web/src/ui/timeline/content/MessageBody.tsx b/web/src/ui/timeline/content/MessageBody.tsx index 474c17c..27ad6d3 100644 --- a/web/src/ui/timeline/content/MessageBody.tsx +++ b/web/src/ui/timeline/content/MessageBody.tsx @@ -104,7 +104,7 @@ const MessageBody = ({ event }: EventContentProps) => { } } - return {`{ "type": "${event.type}" }`} + return {`{ "type": "${event.type}", "content": { "msgtype": "${content.msgtype}" } }`} } export default MessageBody diff --git a/web/src/ui/timeline/content/RedactedBody.tsx b/web/src/ui/timeline/content/RedactedBody.tsx new file mode 100644 index 0000000..ec95dbc --- /dev/null +++ b/web/src/ui/timeline/content/RedactedBody.tsx @@ -0,0 +1,20 @@ +// 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 . +const RedactedBody = () => { + return `Message deleted` +} + +export default RedactedBody diff --git a/websocket.go b/websocket.go index 752e748..f9d2a7c 100644 --- a/websocket.go +++ b/websocket.go @@ -252,17 +252,18 @@ func (gmx *Gomuks) sendInitialData(ctx context.Context, conn *websocket.Conn) { if err != nil { log.Err(err).Msg("Failed to get preview event for room") return - } else if previewEvent != nil { - syncRoom.Events = append(syncRoom.Events, previewEvent) } - if previewEvent != nil && previewEvent.LastEditRowID != nil { - lastEdit, err := gmx.Client.DB.Event.GetByRowID(ctx, *previewEvent.LastEditRowID) - if err != nil { - log.Err(err).Msg("Failed to get last edit for preview event") - return - } else if lastEdit != nil { - syncRoom.Events = append(syncRoom.Events, lastEdit) + if previewEvent != nil { + if previewEvent.LastEditRowID != nil { + lastEdit, err := gmx.Client.DB.Event.GetByRowID(ctx, *previewEvent.LastEditRowID) + if err != nil { + log.Err(err).Msg("Failed to get last edit for preview event") + return + } else if lastEdit != nil { + syncRoom.Events = append(syncRoom.Events, lastEdit) + } } + syncRoom.Events = append(syncRoom.Events, previewEvent) } } }