diff --git a/pkg/hicli/database/event.go b/pkg/hicli/database/event.go index cfef7e7..e0420de 100644 --- a/pkg/hicli/database/event.go +++ b/pkg/hicli/database/event.go @@ -36,7 +36,11 @@ const ( getEventByID = getEventBaseQuery + `WHERE event_id = $1` getEventByTransactionID = getEventBaseQuery + `WHERE transaction_id = $1` getFailedEventsByMegolmSessionID = getEventBaseQuery + `WHERE room_id = $1 AND megolm_session_id = $2 AND decryption_error IS NOT NULL` - insertEventBaseQuery = ` + getRelatedEventsQuery = getEventBaseQuery + ` + WHERE room_id = $1 AND relates_to = $2 AND ($3 = '' OR relation_type = $3) + ORDER BY timestamp ASC + ` + insertEventBaseQuery = ` INSERT INTO event ( room_id, event_id, sender, type, state_key, timestamp, content, decrypted, decrypted_type, unsigned, local_content, transaction_id, redacted_by, relates_to, relation_type, @@ -112,6 +116,10 @@ func (eq *EventQuery) GetByRowID(ctx context.Context, rowID EventRowID) (*Event, return eq.QueryOne(ctx, getEventByRowID, rowID) } +func (eq *EventQuery) GetRelatedEvents(ctx context.Context, roomID id.RoomID, eventID id.EventID, relationType event.RelationType) ([]*Event, error) { + return eq.QueryMany(ctx, getRelatedEventsQuery, roomID, eventID, relationType) +} + func (eq *EventQuery) GetByRowIDs(ctx context.Context, rowIDs ...EventRowID) ([]*Event, error) { query, params := buildMultiEventGetFunction(nil, rowIDs, getManyEventsByRowID) return eq.QueryMany(ctx, query, params...) diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index ea7c70f..3b29725 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -125,10 +125,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) { return h.GetEvent(ctx, params.RoomID, params.EventID) }) - //case "get_events_by_rowids": - // return unmarshalAndCall(req.Data, func(params *getEventsByRowIDsParams) ([]*database.Event, error) { - // return h.GetEventsByRowIDs(ctx, params.RowIDs) - // }) + case "get_related_events": + return unmarshalAndCall(req.Data, func(params *getRelatedEventsParams) ([]*database.Event, error) { + return h.DB.Event.GetRelatedEvents(ctx, params.RoomID, params.EventID, params.RelationType) + }) case "get_room_state": return unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) { return h.GetRoomState(ctx, params.RoomID, params.IncludeMembers, params.FetchMembers, params.Refetch) @@ -315,9 +315,12 @@ type getEventParams struct { EventID id.EventID `json:"event_id"` } -//type getEventsByRowIDsParams struct { -// RowIDs []database.EventRowID `json:"row_ids"` -//} +type getRelatedEventsParams struct { + RoomID id.RoomID `json:"room_id"` + EventID id.EventID `json:"event_id"` + + RelationType event.RelationType `json:"relation_type"` +} type getRoomStateParams struct { RoomID id.RoomID `json:"room_id"` diff --git a/pkg/hicli/paginate.go b/pkg/hicli/paginate.go index 98996d2..424635d 100644 --- a/pkg/hicli/paginate.go +++ b/pkg/hicli/paginate.go @@ -22,37 +22,6 @@ import ( var ErrPaginationAlreadyInProgress = errors.New("pagination is already in progress") -/*func (h *HiClient) GetEventsByRowIDs(ctx context.Context, rowIDs []database.EventRowID) ([]*database.Event, error) { - events, err := h.DB.Event.GetByRowIDs(ctx, rowIDs...) - if err != nil { - return nil, err - } else if len(events) == 0 { - return events, nil - } - firstRoomID := events[0].RoomID - allInSameRoom := true - for _, evt := range events { - h.ReprocessExistingEvent(ctx, evt) - if evt.RoomID != firstRoomID { - allInSameRoom = false - break - } - } - if allInSameRoom { - err = h.DB.Event.FillLastEditRowIDs(ctx, firstRoomID, events) - if err != nil { - return events, fmt.Errorf("failed to fill last edit row IDs: %w", err) - } - err = h.DB.Event.FillReactionCounts(ctx, firstRoomID, events) - if err != nil { - return events, fmt.Errorf("failed to fill reaction counts: %w", err) - } - } else { - // TODO slow path where events are collected and filling is done one room at a time? - } - return events, nil -}*/ - func (h *HiClient) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*database.Event, error) { if evt, err := h.DB.Event.GetByID(ctx, eventID); err != nil { return nil, fmt.Errorf("failed to get event from database: %w", err) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 4f26ef4..331ed47 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -26,6 +26,7 @@ import type { ImagePackRooms, RPCEvent, RawDBEvent, + RelationType, RoomID, RoomStateGUID, SyncStatus, @@ -235,6 +236,17 @@ export default class Client { ) } + async getRelatedEvents(room: RoomStateStore | RoomID | undefined, eventID: EventID, relationType?: RelationType) { + if (typeof room === "string") { + room = this.store.rooms.get(room) + } + if (!room) { + return [] + } + const events = await this.rpc.getRelatedEvents(room.roomID, eventID, relationType) + return events.map(evt => room.getOrApplyEvent(evt)) + } + async pinMessage(room: RoomStateStore, evtID: EventID, wantPinned: boolean) { const pinnedEvents = room.getPinnedEvents() const currentlyPinned = pinnedEvents.includes(evtID) diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index 525c373..e79c820 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -19,7 +19,6 @@ import type { ClientWellKnown, DBPushRegistration, EventID, - EventRowID, EventType, JSONValue, LoginFlowsResponse, @@ -34,6 +33,7 @@ import type { RawDBEvent, ReceiptType, RelatesTo, + RelationType, ResolveAliasResponse, RespOpenIDToken, RespRoomJoin, @@ -222,8 +222,8 @@ export default abstract class RPCClient { return this.request("get_event", { room_id, event_id }) } - getEventsByRowIDs(row_ids: EventRowID[]): Promise { - return this.request("get_events_by_row_ids", { row_ids }) + getRelatedEvents(room_id: RoomID, event_id: EventID, relation_type?: RelationType): Promise { + return this.request("get_related_events", { room_id, event_id, relation_type }) } paginate(room_id: RoomID, max_timeline_id: TimelineRowID, limit: number): Promise { diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index f0f5a0f..497d6e3 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -344,6 +344,14 @@ export class RoomStateStore { return true } + getOrApplyEvent(evt: RawDBEvent) { + const existing = this.eventsByRowID.get(evt.rowid) + if (existing) { + return existing + } + return this.applyEvent(evt) + } + applyEvent(evt: RawDBEvent, pending: boolean = false) { const memEvt = evt as MemDBEvent memEvt.mem = true @@ -362,6 +370,7 @@ export class RoomStateStore { memEvt.last_edit = this.eventsByRowID.get(memEvt.last_edit_rowid) if (memEvt.last_edit) { memEvt.orig_content = memEvt.content + memEvt.orig_local_content = memEvt.local_content memEvt.content = memEvt.last_edit.content["m.new_content"] memEvt.local_content = memEvt.last_edit.local_content } @@ -371,6 +380,7 @@ export class RoomStateStore { this.eventsByRowID.set(editTarget.rowid, { ...editTarget, last_edit: memEvt, + orig_local_content: editTarget.local_content, orig_content: editTarget.content, content: memEvt.content["m.new_content"], local_content: memEvt.local_content, diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index 7defc86..bb352d4 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -156,6 +156,7 @@ export interface MemDBEvent extends BaseDBEvent { pending: boolean encrypted?: EncryptedEventContent orig_content?: UnknownEventContent + orig_local_content?: LocalContent last_edit?: MemDBEvent } diff --git a/web/src/ui/timeline/EventEditHistory.css b/web/src/ui/timeline/EventEditHistory.css new file mode 100644 index 0000000..171322a --- /dev/null +++ b/web/src/ui/timeline/EventEditHistory.css @@ -0,0 +1,14 @@ +div.event-edit-history-wrapper { + padding: 0 !important; +} + +div.event-edit-history-modal { + --timeline-status-size: 0; + --timeline-horizontal-padding: 0; + min-width: 20rem; + padding: 1rem; + + > p { + margin: 0; + } +} diff --git a/web/src/ui/timeline/EventEditHistory.tsx b/web/src/ui/timeline/EventEditHistory.tsx new file mode 100644 index 0000000..7fbfb0f --- /dev/null +++ b/web/src/ui/timeline/EventEditHistory.tsx @@ -0,0 +1,81 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2025 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 { use, useEffect, useState } from "react" +import { ScaleLoader } from "react-spinners" +import { MemDBEvent } from "@/api/types" +import ClientContext from "../ClientContext.ts" +import { RoomContext, RoomContextData } from "../roomview/roomcontext.ts" +import TimelineEvent from "./TimelineEvent.tsx" +import "./EventEditHistory.css" + +interface EventEditHistoryProps { + evt: MemDBEvent + roomCtx: RoomContextData +} + +const EventEditHistory = ({ evt, roomCtx }: EventEditHistoryProps) => { + const client = use(ClientContext)! + const [revisions, setRevisions] = useState([]) + const [error, setError] = useState("") + const [loading, setLoading] = useState(true) + useEffect(() => { + setLoading(true) + setError("") + setRevisions([]) + client.getRelatedEvents(roomCtx.store, evt.event_id, "m.replace").then( + edits => { + setRevisions([{ + ...evt, + content: evt.orig_content ?? evt.content, + local_content: evt.orig_local_content ?? evt.local_content, + last_edit: undefined, + reactions: undefined, + orig_content: undefined, + orig_local_content: undefined, + }, ...edits.map(editEvt => ({ + ...editEvt, + content: editEvt.content["m.new_content"] ?? editEvt.content, + orig_content: editEvt.content, + relation_type: undefined, + reactions: undefined, + }))]) + }, + err => { + console.error("Failed to get event edit history", err) + setError(`${err}`) + }, + ).finally(() => setLoading(false)) + }, [client, roomCtx, evt]) + + if (loading) { + return + } else if (error) { + return
Failed to load :( {error}
+ } + return <> + +

Event has {revisions.length} revisions

+ {revisions.map((rev, i) => )} +
+ +} + +export default EventEditHistory diff --git a/web/src/ui/timeline/TimelineEvent.css b/web/src/ui/timeline/TimelineEvent.css index 912fecd..0d3c54b 100644 --- a/web/src/ui/timeline/TimelineEvent.css +++ b/web/src/ui/timeline/TimelineEvent.css @@ -79,7 +79,7 @@ div.timeline-event { } } - > span.event-time, > span.event-edited { + > span.event-time { font-size: .7rem; color: var(--secondary-text-color); } @@ -100,6 +100,13 @@ div.timeline-event { overflow: hidden; overflow-wrap: anywhere; contain: content; + + > div.event-edited { + font-size: .7rem; + color: var(--secondary-text-color); + user-select: none; + cursor: var(--clickable-cursor); + } } > div.event-send-status { diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 0c6e711..72eaafa 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -22,8 +22,9 @@ import { isMobileDevice } from "@/util/ismobile.ts" import { getDisplayname, isEventID } from "@/util/validation.ts" import ClientContext from "../ClientContext.ts" import MainScreenContext from "../MainScreenContext.ts" -import { ModalContext } from "../modal" +import { ModalContext, NestableModalContext } from "../modal" import { useRoomContext } from "../roomview/roomcontext.ts" +import EventEditHistory from "./EventEditHistory.tsx" import ReadReceipts from "./ReadReceipts.tsx" import { ReplyIDBody } from "./ReplyBody.tsx" import URLPreviews from "./URLPreviews.tsx" @@ -40,6 +41,7 @@ export interface TimelineEventProps { disableMenu?: boolean smallReplies?: boolean isFocused?: boolean + editHistoryView?: boolean } const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" }) @@ -75,11 +77,14 @@ const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => { } } -const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: TimelineEventProps) => { +const TimelineEvent = ({ + evt, prevEvt, disableMenu, smallReplies, isFocused, editHistoryView, +}: TimelineEventProps) => { const roomCtx = useRoomContext() const client = use(ClientContext)! const mainScreen = use(MainScreenContext) const openModal = use(ModalContext) + const openNestableModal = use(NestableModalContext) const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false) const onContextMenu = (mouseEvt: React.MouseEvent) => { const targetElem = mouseEvt.target as HTMLElement @@ -115,6 +120,15 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T mouseEvt.stopPropagation() roomCtx.setFocusedEventRowID(roomCtx.focusedEventRowID === evt.rowid ? null : evt.rowid) } + const openEditHistory = () => { + openNestableModal({ + content: , + dimmed: true, + boxed: true, + boxClass: "event-edit-history-wrapper", + innerBoxClass: "event-edit-history-modal", + }) + } const memberEvt = useRoomMember(client, roomCtx.store, evt.sender) const memberEvtContent = memberEvt?.content as MemberEventContent | undefined const BodyType = getBodyType(evt) @@ -136,7 +150,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T if (evt.sender === client.userID) { wrapperClassNames.push("own-event") } - if (isMobileDevice || disableMenu) { + if ((isMobileDevice && !editHistoryView) || disableMenu) { wrapperClassNames.push("no-hover") } if (isFocused) { @@ -159,7 +173,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T const replyTo = relatesTo?.["m.in_reply_to"]?.event_id let replyAboveMessage: JSX.Element | null = null let replyInMessage: JSX.Element | null = null - if (isEventID(replyTo) && BodyType !== HiddenEvent && !evt.redacted_by) { + if (isEventID(replyTo) && BodyType !== HiddenEvent && !evt.redacted_by && !editHistoryView) { const replyElem = - {!disableMenu && !isMobileDevice &&
@@ -258,11 +275,8 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
} {shortTime} - {(editEventTS && editTime) ? - (edited at {formatShortTime(editEventTS)}) - : null} :
- {shortTime} + {shortTime}
}
{replyInMessage} @@ -270,11 +284,18 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T {!isSmallBodyType && } + {(!editHistoryView && editEventTS && editTime) ?
+ (edited at {formatShortTime(editEventTS)}) +
: null} {evt.reactions ? : null}
- {!evt.event_id.startsWith("~") && roomCtx.store.preferences.display_read_receipts && + {!evt.event_id.startsWith("~") && roomCtx.store.preferences.display_read_receipts && !editHistoryView && } - {evt.sender === client.userID && evt.transaction_id ? : null} + {evt.sender === client.userID && evt.transaction_id && !editHistoryView ? : null} return <> {dateSeparator}