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`
getEventByTransactionID = getEventBaseQuery + `WHERE transaction_id = $1`
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 = `
INSERT INTO event (
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)
}
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...)

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 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"`

View file

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

View file

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

View file

@ -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<RawDBEvent[]> {
return this.request("get_events_by_row_ids", { row_ids })
getRelatedEvents(room_id: RoomID, event_id: EventID, relation_type?: RelationType): Promise<RawDBEvent[]> {
return this.request("get_related_events", { room_id, event_id, relation_type })
}
paginate(room_id: RoomID, max_timeline_id: TimelineRowID, limit: number): Promise<PaginationResponse> {

View file

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

View file

@ -156,6 +156,7 @@ export interface MemDBEvent extends BaseDBEvent {
pending: boolean
encrypted?: EncryptedEventContent
orig_content?: UnknownEventContent
orig_local_content?: LocalContent
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;
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 {

View file

@ -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: <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 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 = <ReplyIDBody
room={roomCtx.store}
eventID={replyTo}
@ -204,6 +218,9 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
eventTimeOnly = true
renderAvatar = false
}
if (editHistoryView) {
wrapperClassNames.push("edit-history-event")
}
const fullTime = fullTimeFormatter.format(eventTS)
const shortTime = formatShortTime(eventTS)
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}
className={wrapperClassNames.join(" ")}
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" : ""}`}
>
<EventHoverMenu evt={evt} roomCtx={roomCtx} setForceOpen={setForceContextMenuOpen}/>
@ -258,11 +275,8 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
</span>
</div>}
<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">
<span className="event-time" title={editTime ? `${fullTime} - ${editTime}` : fullTime}>{shortTime}</span>
<span className="event-time" title={fullTime}>{shortTime}</span>
</div>}
<div className="event-content">
{replyInMessage}
@ -270,11 +284,18 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
<BodyType room={roomCtx.store} sender={memberEvt} event={evt}/>
{!isSmallBodyType && <URLPreviews room={roomCtx.store} event={evt}/>}
</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}
</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} />}
{evt.sender === client.userID && evt.transaction_id ? <EventSendStatus evt={evt}/> : null}
{evt.sender === client.userID && evt.transaction_id && !editHistoryView ? <EventSendStatus evt={evt}/> : null}
</div>
return <>
{dateSeparator}