1
0
Fork 0
forked from Mirrors/gomuks

web/timeline: add edit history modal

This commit is contained in:
Tulir Asokan 2025-02-23 17:53:32 +02:00
parent a9e459b448
commit 4fc9b88ec6
11 changed files with 181 additions and 55 deletions

View file

@ -36,6 +36,10 @@ const (
getEventByID = getEventBaseQuery + `WHERE event_id = $1` getEventByID = getEventBaseQuery + `WHERE event_id = $1`
getEventByTransactionID = getEventBaseQuery + `WHERE transaction_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` getFailedEventsByMegolmSessionID = getEventBaseQuery + `WHERE room_id = $1 AND megolm_session_id = $2 AND decryption_error IS NOT NULL`
getRelatedEventsQuery = getEventBaseQuery + `
WHERE room_id = $1 AND relates_to = $2 AND ($3 = '' OR relation_type = $3)
ORDER BY timestamp ASC
`
insertEventBaseQuery = ` insertEventBaseQuery = `
INSERT INTO event ( INSERT INTO event (
room_id, event_id, sender, type, state_key, timestamp, content, decrypted, decrypted_type, room_id, event_id, sender, type, state_key, timestamp, content, decrypted, decrypted_type,
@ -112,6 +116,10 @@ func (eq *EventQuery) GetByRowID(ctx context.Context, rowID EventRowID) (*Event,
return eq.QueryOne(ctx, getEventByRowID, rowID) 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) { func (eq *EventQuery) GetByRowIDs(ctx context.Context, rowIDs ...EventRowID) ([]*Event, error) {
query, params := buildMultiEventGetFunction(nil, rowIDs, getManyEventsByRowID) query, params := buildMultiEventGetFunction(nil, rowIDs, getManyEventsByRowID)
return eq.QueryMany(ctx, query, params...) return eq.QueryMany(ctx, query, params...)

View file

@ -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 unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) {
return h.GetEvent(ctx, params.RoomID, params.EventID) return h.GetEvent(ctx, params.RoomID, params.EventID)
}) })
//case "get_events_by_rowids": case "get_related_events":
// return unmarshalAndCall(req.Data, func(params *getEventsByRowIDsParams) ([]*database.Event, error) { return unmarshalAndCall(req.Data, func(params *getRelatedEventsParams) ([]*database.Event, error) {
// return h.GetEventsByRowIDs(ctx, params.RowIDs) return h.DB.Event.GetRelatedEvents(ctx, params.RoomID, params.EventID, params.RelationType)
// }) })
case "get_room_state": case "get_room_state":
return unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) { return unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) {
return h.GetRoomState(ctx, params.RoomID, params.IncludeMembers, params.FetchMembers, params.Refetch) 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"` EventID id.EventID `json:"event_id"`
} }
//type getEventsByRowIDsParams struct { type getRelatedEventsParams struct {
// RowIDs []database.EventRowID `json:"row_ids"` RoomID id.RoomID `json:"room_id"`
//} EventID id.EventID `json:"event_id"`
RelationType event.RelationType `json:"relation_type"`
}
type getRoomStateParams struct { type getRoomStateParams struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id"`

View file

@ -22,37 +22,6 @@ import (
var ErrPaginationAlreadyInProgress = errors.New("pagination is already in progress") 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) { 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 { if evt, err := h.DB.Event.GetByID(ctx, eventID); err != nil {
return nil, fmt.Errorf("failed to get event from database: %w", err) return nil, fmt.Errorf("failed to get event from database: %w", err)

View file

@ -26,6 +26,7 @@ import type {
ImagePackRooms, ImagePackRooms,
RPCEvent, RPCEvent,
RawDBEvent, RawDBEvent,
RelationType,
RoomID, RoomID,
RoomStateGUID, RoomStateGUID,
SyncStatus, 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) { async pinMessage(room: RoomStateStore, evtID: EventID, wantPinned: boolean) {
const pinnedEvents = room.getPinnedEvents() const pinnedEvents = room.getPinnedEvents()
const currentlyPinned = pinnedEvents.includes(evtID) const currentlyPinned = pinnedEvents.includes(evtID)

View file

@ -19,7 +19,6 @@ import type {
ClientWellKnown, ClientWellKnown,
DBPushRegistration, DBPushRegistration,
EventID, EventID,
EventRowID,
EventType, EventType,
JSONValue, JSONValue,
LoginFlowsResponse, LoginFlowsResponse,
@ -34,6 +33,7 @@ import type {
RawDBEvent, RawDBEvent,
ReceiptType, ReceiptType,
RelatesTo, RelatesTo,
RelationType,
ResolveAliasResponse, ResolveAliasResponse,
RespOpenIDToken, RespOpenIDToken,
RespRoomJoin, RespRoomJoin,
@ -222,8 +222,8 @@ export default abstract class RPCClient {
return this.request("get_event", { room_id, event_id }) return this.request("get_event", { room_id, event_id })
} }
getEventsByRowIDs(row_ids: EventRowID[]): Promise<RawDBEvent[]> { getRelatedEvents(room_id: RoomID, event_id: EventID, relation_type?: RelationType): Promise<RawDBEvent[]> {
return this.request("get_events_by_row_ids", { row_ids }) return this.request("get_related_events", { room_id, event_id, relation_type })
} }
paginate(room_id: RoomID, max_timeline_id: TimelineRowID, limit: number): Promise<PaginationResponse> { paginate(room_id: RoomID, max_timeline_id: TimelineRowID, limit: number): Promise<PaginationResponse> {

View file

@ -344,6 +344,14 @@ export class RoomStateStore {
return true 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) { applyEvent(evt: RawDBEvent, pending: boolean = false) {
const memEvt = evt as MemDBEvent const memEvt = evt as MemDBEvent
memEvt.mem = true memEvt.mem = true
@ -362,6 +370,7 @@ export class RoomStateStore {
memEvt.last_edit = this.eventsByRowID.get(memEvt.last_edit_rowid) memEvt.last_edit = this.eventsByRowID.get(memEvt.last_edit_rowid)
if (memEvt.last_edit) { if (memEvt.last_edit) {
memEvt.orig_content = memEvt.content memEvt.orig_content = memEvt.content
memEvt.orig_local_content = memEvt.local_content
memEvt.content = memEvt.last_edit.content["m.new_content"] memEvt.content = memEvt.last_edit.content["m.new_content"]
memEvt.local_content = memEvt.last_edit.local_content memEvt.local_content = memEvt.last_edit.local_content
} }
@ -371,6 +380,7 @@ export class RoomStateStore {
this.eventsByRowID.set(editTarget.rowid, { this.eventsByRowID.set(editTarget.rowid, {
...editTarget, ...editTarget,
last_edit: memEvt, last_edit: memEvt,
orig_local_content: editTarget.local_content,
orig_content: editTarget.content, orig_content: editTarget.content,
content: memEvt.content["m.new_content"], content: memEvt.content["m.new_content"],
local_content: memEvt.local_content, local_content: memEvt.local_content,

View file

@ -156,6 +156,7 @@ export interface MemDBEvent extends BaseDBEvent {
pending: boolean pending: boolean
encrypted?: EncryptedEventContent encrypted?: EncryptedEventContent
orig_content?: UnknownEventContent orig_content?: UnknownEventContent
orig_local_content?: LocalContent
last_edit?: MemDBEvent last_edit?: MemDBEvent
} }

View file

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

View file

@ -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 <https://www.gnu.org/licenses/>.
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<MemDBEvent[]>([])
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 <ScaleLoader color="var(--primary-color)"/>
} else if (error) {
return <div>Failed to load :( {error}</div>
}
return <>
<RoomContext value={roomCtx}>
<p>Event has {revisions.length} revisions</p>
{revisions.map((rev, i) => <TimelineEvent
key={rev.rowid}
evt={rev}
prevEvt={revisions[i-1] ?? null}
editHistoryView={true}
/>)}
</RoomContext>
</>
}
export default EventEditHistory

View file

@ -79,7 +79,7 @@ div.timeline-event {
} }
} }
> span.event-time, > span.event-edited { > span.event-time {
font-size: .7rem; font-size: .7rem;
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }
@ -100,6 +100,13 @@ div.timeline-event {
overflow: hidden; overflow: hidden;
overflow-wrap: anywhere; overflow-wrap: anywhere;
contain: content; contain: content;
> div.event-edited {
font-size: .7rem;
color: var(--secondary-text-color);
user-select: none;
cursor: var(--clickable-cursor);
}
} }
> div.event-send-status { > div.event-send-status {

View file

@ -22,8 +22,9 @@ import { isMobileDevice } from "@/util/ismobile.ts"
import { getDisplayname, isEventID } from "@/util/validation.ts" import { getDisplayname, isEventID } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import MainScreenContext from "../MainScreenContext.ts" import MainScreenContext from "../MainScreenContext.ts"
import { ModalContext } from "../modal" import { ModalContext, NestableModalContext } from "../modal"
import { useRoomContext } from "../roomview/roomcontext.ts" import { useRoomContext } from "../roomview/roomcontext.ts"
import EventEditHistory from "./EventEditHistory.tsx"
import ReadReceipts from "./ReadReceipts.tsx" import ReadReceipts from "./ReadReceipts.tsx"
import { ReplyIDBody } from "./ReplyBody.tsx" import { ReplyIDBody } from "./ReplyBody.tsx"
import URLPreviews from "./URLPreviews.tsx" import URLPreviews from "./URLPreviews.tsx"
@ -40,6 +41,7 @@ export interface TimelineEventProps {
disableMenu?: boolean disableMenu?: boolean
smallReplies?: boolean smallReplies?: boolean
isFocused?: boolean isFocused?: boolean
editHistoryView?: boolean
} }
const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" }) 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 roomCtx = useRoomContext()
const client = use(ClientContext)! const client = use(ClientContext)!
const mainScreen = use(MainScreenContext) const mainScreen = use(MainScreenContext)
const openModal = use(ModalContext) const openModal = use(ModalContext)
const openNestableModal = use(NestableModalContext)
const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false) const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false)
const onContextMenu = (mouseEvt: React.MouseEvent) => { const onContextMenu = (mouseEvt: React.MouseEvent) => {
const targetElem = mouseEvt.target as HTMLElement const targetElem = mouseEvt.target as HTMLElement
@ -115,6 +120,15 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
mouseEvt.stopPropagation() mouseEvt.stopPropagation()
roomCtx.setFocusedEventRowID(roomCtx.focusedEventRowID === evt.rowid ? null : evt.rowid) roomCtx.setFocusedEventRowID(roomCtx.focusedEventRowID === evt.rowid ? null : evt.rowid)
} }
const openEditHistory = () => {
openNestableModal({
content: <EventEditHistory evt={evt} roomCtx={roomCtx}/>,
dimmed: true,
boxed: true,
boxClass: "event-edit-history-wrapper",
innerBoxClass: "event-edit-history-modal",
})
}
const memberEvt = useRoomMember(client, roomCtx.store, evt.sender) const memberEvt = useRoomMember(client, roomCtx.store, evt.sender)
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
const BodyType = getBodyType(evt) const BodyType = getBodyType(evt)
@ -136,7 +150,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
if (evt.sender === client.userID) { if (evt.sender === client.userID) {
wrapperClassNames.push("own-event") wrapperClassNames.push("own-event")
} }
if (isMobileDevice || disableMenu) { if ((isMobileDevice && !editHistoryView) || disableMenu) {
wrapperClassNames.push("no-hover") wrapperClassNames.push("no-hover")
} }
if (isFocused) { if (isFocused) {
@ -159,7 +173,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
const replyTo = relatesTo?.["m.in_reply_to"]?.event_id const replyTo = relatesTo?.["m.in_reply_to"]?.event_id
let replyAboveMessage: JSX.Element | null = null let replyAboveMessage: JSX.Element | null = null
let replyInMessage: 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 = <ReplyIDBody const replyElem = <ReplyIDBody
room={roomCtx.store} room={roomCtx.store}
eventID={replyTo} eventID={replyTo}
@ -204,6 +218,9 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
eventTimeOnly = true eventTimeOnly = true
renderAvatar = false renderAvatar = false
} }
if (editHistoryView) {
wrapperClassNames.push("edit-history-event")
}
const fullTime = fullTimeFormatter.format(eventTS) const fullTime = fullTimeFormatter.format(eventTS)
const shortTime = formatShortTime(eventTS) const shortTime = formatShortTime(eventTS)
const editTime = editEventTS ? `Edited at ${fullTimeFormatter.format(editEventTS)}` : null const editTime = editEventTS ? `Edited at ${fullTimeFormatter.format(editEventTS)}` : null
@ -211,9 +228,9 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
data-event-id={evt.event_id} data-event-id={evt.event_id}
className={wrapperClassNames.join(" ")} className={wrapperClassNames.join(" ")}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
onClick={!disableMenu && isMobileDevice ? onClick : undefined} onClick={!disableMenu && !editHistoryView && isMobileDevice ? onClick : undefined}
> >
{!disableMenu && !isMobileDevice && <div {!disableMenu && (!isMobileDevice || editHistoryView) && <div
className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`} className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}
> >
<EventHoverMenu evt={evt} roomCtx={roomCtx} setForceOpen={setForceContextMenuOpen}/> <EventHoverMenu evt={evt} roomCtx={roomCtx} setForceOpen={setForceContextMenuOpen}/>
@ -258,11 +275,8 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
</span> </span>
</div>} </div>}
<span className="event-time" title={fullTime}>{shortTime}</span> <span className="event-time" title={fullTime}>{shortTime}</span>
{(editEventTS && editTime) ? <span className="event-edited" title={editTime}>
(edited at {formatShortTime(editEventTS)})
</span> : null}
</div> : <div className="event-time-only"> </div> : <div className="event-time-only">
<span className="event-time" title={editTime ? `${fullTime} - ${editTime}` : fullTime}>{shortTime}</span> <span className="event-time" title={fullTime}>{shortTime}</span>
</div>} </div>}
<div className="event-content"> <div className="event-content">
{replyInMessage} {replyInMessage}
@ -270,11 +284,18 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
<BodyType room={roomCtx.store} sender={memberEvt} event={evt}/> <BodyType room={roomCtx.store} sender={memberEvt} event={evt}/>
{!isSmallBodyType && <URLPreviews room={roomCtx.store} event={evt}/>} {!isSmallBodyType && <URLPreviews room={roomCtx.store} event={evt}/>}
</ContentErrorBoundary> </ContentErrorBoundary>
{(!editHistoryView && editEventTS && editTime) ? <div
className="event-edited"
title={editTime}
onClick={openEditHistory}
>
(edited at {formatShortTime(editEventTS)})
</div> : null}
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null} {evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
</div> </div>
{!evt.event_id.startsWith("~") && roomCtx.store.preferences.display_read_receipts && {!evt.event_id.startsWith("~") && roomCtx.store.preferences.display_read_receipts && !editHistoryView &&
<ReadReceipts room={roomCtx.store} eventID={evt.event_id} />} <ReadReceipts room={roomCtx.store} eventID={evt.event_id} />}
{evt.sender === client.userID && evt.transaction_id ? <EventSendStatus evt={evt}/> : null} {evt.sender === client.userID && evt.transaction_id && !editHistoryView ? <EventSendStatus evt={evt}/> : null}
</div> </div>
return <> return <>
{dateSeparator} {dateSeparator}