From a14f01a3ec228be1119d3337b185b7bbdd92ef8e Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 23 Feb 2025 18:33:16 +0200 Subject: [PATCH] web/timeline: implement MSC2815 Fixes #510 --- desktop/go.mod | 2 +- desktop/go.sum | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- pkg/hicli/json-commands.go | 8 ++++++-- pkg/hicli/paginate.go | 20 +++++++++++++++++++ web/src/api/client.ts | 20 ++++++++++++++----- web/src/api/rpc.ts | 4 ++-- web/src/api/statestore/room.ts | 11 +++++++++- web/src/api/types/hitypes.ts | 1 + web/src/icons/restore-trash.svg | 1 + web/src/ui/timeline/TimelineEvent.tsx | 5 +++-- web/src/ui/timeline/content/index.ts | 7 ++++--- web/src/ui/timeline/menu/usePrimaryItems.tsx | 2 +- .../ui/timeline/menu/useSecondaryItems.tsx | 20 +++++++++++++++++++ 15 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 web/src/icons/restore-trash.svg diff --git a/desktop/go.mod b/desktop/go.mod index 49a6812..402586b 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -79,7 +79,7 @@ require ( gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - maunium.net/go/mautrix v0.23.1 // indirect + maunium.net/go/mautrix v0.23.2-0.20250223161309-1cc073cde6ca // indirect mvdan.cc/xurls/v2 v2.6.0 // indirect ) diff --git a/desktop/go.sum b/desktop/go.sum index 0aac851..09a6b4f 100644 --- a/desktop/go.sum +++ b/desktop/go.sum @@ -261,7 +261,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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/mautrix v0.23.1 h1:xZtX43YZF3WRxkdR+oMUrpiQe+jbjc+LeXLxHuXP5IM= -maunium.net/go/mautrix v0.23.1/go.mod h1:kldoZQDneV/jquIhwG1MmMw5j2A2M/MnQYRSWt863cY= +maunium.net/go/mautrix v0.23.2-0.20250223161309-1cc073cde6ca h1:xPbRPallD4qh/XuQWheRsvxsf/5stfdA+uIj0S0P2kQ= +maunium.net/go/mautrix v0.23.2-0.20250223161309-1cc073cde6ca/go.mod h1:kldoZQDneV/jquIhwG1MmMw5j2A2M/MnQYRSWt863cY= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= diff --git a/go.mod b/go.mod index 7a3b7ea..3e19779 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( golang.org/x/text v0.22.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 - maunium.net/go/mautrix v0.23.1 + maunium.net/go/mautrix v0.23.2-0.20250223161309-1cc073cde6ca mvdan.cc/xurls/v2 v2.6.0 ) diff --git a/go.sum b/go.sum index 9fb04cf..8ba720c 100644 --- a/go.sum +++ b/go.sum @@ -100,7 +100,7 @@ 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.23.1 h1:xZtX43YZF3WRxkdR+oMUrpiQe+jbjc+LeXLxHuXP5IM= -maunium.net/go/mautrix v0.23.1/go.mod h1:kldoZQDneV/jquIhwG1MmMw5j2A2M/MnQYRSWt863cY= +maunium.net/go/mautrix v0.23.2-0.20250223161309-1cc073cde6ca h1:xPbRPallD4qh/XuQWheRsvxsf/5stfdA+uIj0S0P2kQ= +maunium.net/go/mautrix v0.23.2-0.20250223161309-1cc073cde6ca/go.mod h1:kldoZQDneV/jquIhwG1MmMw5j2A2M/MnQYRSWt863cY= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index 3b29725..ba5b551 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -123,6 +123,9 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any }) case "get_event": return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) { + if params.Unredact { + return h.GetUnredactedEvent(ctx, params.RoomID, params.EventID) + } return h.GetEvent(ctx, params.RoomID, params.EventID) }) case "get_related_events": @@ -311,8 +314,9 @@ type setProfileFieldParams struct { } type getEventParams struct { - RoomID id.RoomID `json:"room_id"` - EventID id.EventID `json:"event_id"` + RoomID id.RoomID `json:"room_id"` + EventID id.EventID `json:"event_id"` + Unredact bool `json:"unredact"` } type getRelatedEventsParams struct { diff --git a/pkg/hicli/paginate.go b/pkg/hicli/paginate.go index 424635d..4cbf64c 100644 --- a/pkg/hicli/paginate.go +++ b/pkg/hicli/paginate.go @@ -35,6 +35,26 @@ func (h *HiClient) GetEvent(ctx context.Context, roomID id.RoomID, eventID id.Ev } } +func (h *HiClient) GetUnredactedEvent(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) + // TODO this check doesn't handle events which keep some fields on redaction + } else if evt != nil && len(evt.Content) > 2 { + h.ReprocessExistingEvent(ctx, evt) + return evt, nil + } else if serverEvt, err := h.Client.GetUnredactedEventContent(ctx, roomID, eventID); err != nil { + return nil, fmt.Errorf("failed to get event from server: %w", err) + } else if redactedServerEvt, err := h.Client.GetEvent(ctx, roomID, eventID); err != nil { + return nil, fmt.Errorf("failed to get redacted event from server: %w", err) + // TODO this check will have false positives on actually empty events + } else if len(serverEvt.Content.VeryRaw) == 2 { + return nil, fmt.Errorf("server didn't return content") + } else { + serverEvt.Unsigned.RedactedBecause = redactedServerEvt.Unsigned.RedactedBecause + return h.processEvent(ctx, serverEvt, nil, nil, false) + } +} + func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fetchMembers, refetch, dispatchEvt bool) error { var evts []*event.Event if refetch { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 331ed47..600ec1a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -222,17 +222,27 @@ export default class Client { }) } - requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID) { + requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID, unredact?: boolean) { if (typeof room === "string") { room = this.store.rooms.get(room) } - if (!room || room.eventsByID.has(eventID) || room.requestedEvents.has(eventID)) { + if (!room || (!unredact && room.eventsByID.has(eventID)) ||room.requestedEvents.has(eventID)) { return } room.requestedEvents.add(eventID) - this.rpc.getEvent(room.roomID, eventID).then( - evt => room.applyEvent(evt), - err => console.error(`Failed to fetch event ${eventID}`, err), + this.rpc.getEvent(room.roomID, eventID, unredact).then( + evt => { + room.applyEvent(evt, false, unredact) + if (unredact) { + room.notifyTimelineSubscribers() + } + }, + err => { + console.error(`Failed to fetch event ${eventID}`, err) + if (unredact) { + window.alert(`Failed to get unredacted content: ${err}`) + } + }, ) } diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index e79c820..3cc7236 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -218,8 +218,8 @@ export default abstract class RPCClient { return this.request("get_room_state", { room_id, include_members, fetch_members, refetch }) } - getEvent(room_id: RoomID, event_id: EventID): Promise { - return this.request("get_event", { room_id, event_id }) + getEvent(room_id: RoomID, event_id: EventID, unredact?: boolean): Promise { + return this.request("get_event", { room_id, event_id, unredact }) } getRelatedEvents(room_id: RoomID, event_id: EventID, relation_type?: RelationType): Promise { diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index 497d6e3..a1334f9 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -352,13 +352,22 @@ export class RoomStateStore { return this.applyEvent(evt) } - applyEvent(evt: RawDBEvent, pending: boolean = false) { + setViewingRedacted(evt: MemDBEvent, view: boolean) { + evt.viewing_redacted = view + this.eventSubs.notify(evt.event_id) + this.notifyTimelineSubscribers() + } + + applyEvent(evt: RawDBEvent, pending: boolean = false, viewRedacted: boolean = false) { const memEvt = evt as MemDBEvent memEvt.mem = true memEvt.pending = pending if (pending) { memEvt.timeline_rowid = UNSENT_TIMELINE_ROWID_BASE + memEvt.timestamp } + if (viewRedacted) { + memEvt.viewing_redacted = true + } if (evt.type === "m.room.encrypted" && evt.decrypted && evt.decrypted_type) { memEvt.type = evt.decrypted_type memEvt.encrypted = evt.content as EncryptedEventContent diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index bb352d4..688ecc4 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -158,6 +158,7 @@ export interface MemDBEvent extends BaseDBEvent { orig_content?: UnknownEventContent orig_local_content?: LocalContent last_edit?: MemDBEvent + viewing_redacted?: boolean } export interface DBAccountData { diff --git a/web/src/icons/restore-trash.svg b/web/src/icons/restore-trash.svg new file mode 100644 index 0000000..80812ca --- /dev/null +++ b/web/src/icons/restore-trash.svg @@ -0,0 +1 @@ + diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 72eaafa..5ee6628 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -138,7 +138,8 @@ const TimelineEvent = ({ if (evt.unread_type & UnreadType.Highlight) { wrapperClassNames.push("highlight") } - if (evt.redacted_by) { + const isRedacted = evt.redacted_by && !evt.viewing_redacted + if (isRedacted) { wrapperClassNames.push("redacted-event") } if (evt.type === "m.room.member") { @@ -173,7 +174,7 @@ const TimelineEvent = ({ 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 && !editHistoryView) { + if (isEventID(replyTo) && BodyType !== HiddenEvent && !isRedacted && !editHistoryView) { const replyElem = window.alert(`Failed to resend message: ${err}`)) } const onClickMore = (mevt: React.MouseEvent) => { - const moreMenuHeight = 4 * 40 + const moreMenuHeight = 5 * 40 setForceOpen!(true) openModal({ content: , }) } + const onClickHideUnredacted = () => { + closeModal() + roomCtx.store.setViewingRedacted(evt, false) + } + const onClickUnredact = () => { + closeModal() + if (Object.entries(evt.content).length > 0) { + roomCtx.store.setViewingRedacted(evt, true) + } else { + client.requestEvent(roomCtx.store, evt.event_id, true) + } + } const onClickPin = (pin: boolean) => () => { closeModal() client.pinMessage(roomCtx.store, evt.event_id, pin) @@ -146,6 +159,8 @@ export const useSecondaryItems = ( const canRedact = !evt.redacted_by && ownPL >= redactEvtPL && (evt.sender === client.userID || ownPL >= redactOtherPL) + // TODO check server admin status and room PLs + const canUnredact = Boolean(evt.redacted_by) return <> @@ -166,5 +181,10 @@ export const useSecondaryItems = ( title={pendingTitle} className="redact-button" >{names && "Remove"}} + {canUnredact && (evt.viewing_redacted ? : )} }