mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
web/timeline: add edit history modal
This commit is contained in:
parent
a9e459b448
commit
4fc9b88ec6
11 changed files with 181 additions and 55 deletions
|
@ -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...)
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -156,6 +156,7 @@ export interface MemDBEvent extends BaseDBEvent {
|
|||
pending: boolean
|
||||
encrypted?: EncryptedEventContent
|
||||
orig_content?: UnknownEventContent
|
||||
orig_local_content?: LocalContent
|
||||
last_edit?: MemDBEvent
|
||||
}
|
||||
|
||||
|
|
14
web/src/ui/timeline/EventEditHistory.css
Normal file
14
web/src/ui/timeline/EventEditHistory.css
Normal 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;
|
||||
}
|
||||
}
|
81
web/src/ui/timeline/EventEditHistory.tsx
Normal file
81
web/src/ui/timeline/EventEditHistory.tsx
Normal 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
|
|
@ -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 {
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Add table
Reference in a new issue