web/timeline: implement MSC2815

Fixes #510
This commit is contained in:
Tulir Asokan 2025-02-23 18:33:16 +02:00
parent 4885dab2e1
commit a14f01a3ec
15 changed files with 89 additions and 22 deletions

View file

@ -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
)

View file

@ -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=

2
go.mod
View file

@ -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
)

4
go.sum
View file

@ -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=

View file

@ -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 {

View file

@ -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 {

View file

@ -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}`)
}
},
)
}

View file

@ -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<RawDBEvent> {
return this.request("get_event", { room_id, event_id })
getEvent(room_id: RoomID, event_id: EventID, unredact?: boolean): Promise<RawDBEvent> {
return this.request("get_event", { room_id, event_id, unredact })
}
getRelatedEvents(room_id: RoomID, event_id: EventID, relation_type?: RelationType): Promise<RawDBEvent[]> {

View file

@ -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

View file

@ -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 {

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M440-320h80v-166l64 62 56-56-160-160-160 160 56 56 64-62v166ZM280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520Zm-400 0v520-520Z"/></svg>

After

Width:  |  Height:  |  Size: 332 B

View file

@ -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 = <ReplyIDBody
room={roomCtx.store}
eventID={replyTo}

View file

@ -65,10 +65,11 @@ export function getBodyType(evt: MemDBEvent, forReply = false): React.FunctionCo
return PolicyRuleBody
}
} else {
const isRedacted = evt.redacted_by && !evt.viewing_redacted
// Non-state events
switch (evt.type) {
case "m.room.message":
if (evt.redacted_by) {
if (isRedacted) {
return RedactedBody
}
switch (evt.content?.msgtype) {
@ -93,14 +94,14 @@ export function getBodyType(evt: MemDBEvent, forReply = false): React.FunctionCo
return UnknownMessageBody
}
case "m.sticker":
if (evt.redacted_by) {
if (isRedacted) {
return RedactedBody
} else if (forReply) {
return TextMessageBody
}
return MediaMessageBody
case "m.room.encrypted":
if (evt.redacted_by) {
if (isRedacted) {
return RedactedBody
}
return EncryptedBody

View file

@ -79,7 +79,7 @@ export const usePrimaryItems = (
.catch(err => window.alert(`Failed to resend message: ${err}`))
}
const onClickMore = (mevt: React.MouseEvent<HTMLButtonElement>) => {
const moreMenuHeight = 4 * 40
const moreMenuHeight = 5 * 40
setForceOpen!(true)
openModal({
content: <EventExtraMenu

View file

@ -27,6 +27,7 @@ import ViewSourceIcon from "@/icons/code.svg?react"
import DeleteIcon from "@/icons/delete.svg?react"
import PinIcon from "@/icons/pin.svg?react"
import ReportIcon from "@/icons/report.svg?react"
import RestoreTrashIcon from "@/icons/restore-trash.svg?react"
import ShareIcon from "@/icons/share.svg?react"
import UnpinIcon from "@/icons/unpin.svg?react"
@ -85,6 +86,18 @@ export const useSecondaryItems = (
</RoomContext>,
})
}
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 <>
<button onClick={onClickViewSource}><ViewSourceIcon/>{names && "View source"}</button>
@ -166,5 +181,10 @@ export const useSecondaryItems = (
title={pendingTitle}
className="redact-button"
><DeleteIcon/>{names && "Remove"}</button>}
{canUnredact && (evt.viewing_redacted ? <button onClick={onClickHideUnredacted}>
<DeleteIcon/>{names && "Hide content"}
</button> : <button onClick={onClickUnredact}>
<RestoreTrashIcon/>{names && "View content"}
</button>)}
</>
}