diff --git a/packages/core/src/matrix/client/providers/matrix-provider.tsx b/packages/core/src/matrix/client/providers/matrix-provider.tsx index 80ffdb381f..9985666a78 100644 --- a/packages/core/src/matrix/client/providers/matrix-provider.tsx +++ b/packages/core/src/matrix/client/providers/matrix-provider.tsx @@ -14,6 +14,8 @@ import { import { HYPHA_MEDIA_BUNDLE_FIELD, HYPHA_SPOILER_FIELD, + awaitNonProvisionalMatrixEventId, + getMessageReplaceTargetEventId, messageFromRoomMessageEvent, resolveReplyTargetForSend, type HyphaMediaBundleItemWire, @@ -43,6 +45,13 @@ interface SendMessageInput { onUploadProgress?: (p: SendMessageUploadProgress) => void; } +export interface EditRoomMessageInput { + roomId: string; + /** Timeline id of the `m.room.message` to replace (not an edit event id). */ + targetEventId: string; + message: string; +} + /** * Thrown when some attachment events were committed but a later send step failed. * Callers should restore only `attachments.slice(sentAttachmentCount)` and optionally caption text. @@ -169,6 +178,7 @@ interface MatrixContextType { isAuthenticated: boolean; createRoom: (title: string) => Promise<{ roomId: string }>; sendMessage: (params: SendMessageInput) => Promise; + editRoomMessage: (params: EditRoomMessageInput) => Promise; toggleReaction: (params: ToggleReactionInput) => Promise; getRoomMessages: (roomId: string) => Message[] | null; getPinnedMessageIds: (roomId: string) => string[]; @@ -615,6 +625,107 @@ export const MatrixProvider: React.FC = ({ children }) => { [client], ); + const editRoomMessage = React.useCallback( + async ({ roomId, targetEventId, message }: EditRoomMessageInput) => { + if (!client) { + throw new Error('Client should be specified'); + } + if (!message.trim()) { + return; + } + if (!roomId?.trim() || !targetEventId?.trim()) { + return; + } + + const room = client.getRoom(roomId); + if (!room) { + throw new Error('Room not found'); + } + + const targetEv = + room.findEventById(targetEventId) ?? + (typeof room.getPendingEvent === 'function' + ? room.getPendingEvent(targetEventId) + : null); + + if (!targetEv) { + throw new Error('Message to edit not found'); + } + await awaitNonProvisionalMatrixEventId(targetEv); + const resolvedTargetId = targetEv.getId(); + if (!resolvedTargetId?.trim()) { + throw new Error('Message to edit not found'); + } + if (targetEv.getType() !== EventType.RoomMessage) { + throw new Error('Only chat messages can be edited'); + } + if (targetEv.isRedacted()) { + throw new Error('Cannot edit a redacted message'); + } + const sender = targetEv.getSender(); + const uid = client.getUserId(); + if (!sender || !uid || sender !== uid) { + throw new Error('You can only edit your own messages'); + } + + const originalContent = targetEv.getContent() as { + msgtype?: string; + body?: string; + }; + if (originalContent.msgtype !== MsgType.Text) { + throw new Error('Only text messages can be edited in this client'); + } + + const replyToId = targetEv.getWireContent()?.['m.relates_to']?.[ + 'm.in_reply_to' + ]?.event_id as string | undefined; + + let newContentPayload: RoomMessageEventContent; + + if (replyToId?.trim()) { + const { + eventId: resolvedTargetId, + sender: replyTargetSender, + body: targetBody, + } = await resolveReplyTargetForSend(client, roomId, replyToId); + const rich = buildRichReplyMatrixContent( + replyTargetSender, + targetBody, + message, + ); + newContentPayload = { + msgtype: MsgType.Text, + ...rich, + 'm.relates_to': { + 'm.in_reply_to': { + event_id: resolvedTargetId, + }, + }, + } as RoomMessageEventContent; + } else { + newContentPayload = { + msgtype: MsgType.Text, + ...matrixTextEventContentWithOptionalFormatting(message), + } as RoomMessageEventContent; + } + + const newBody = + 'body' in newContentPayload ? newContentPayload.body : message; + const fallbackBody = `* ${newBody}`; + + await client.sendEvent(roomId, EventType.RoomMessage, { + ...newContentPayload, + body: fallbackBody, + 'm.new_content': newContentPayload, + 'm.relates_to': { + rel_type: MatrixSdk.RelationType.Replace, + event_id: resolvedTargetId, + }, + } as RoomMessageEventContent); + }, + [client], + ); + const getPinnedMessageIds = React.useCallback( (roomId: string): string[] => { if (!client) { @@ -659,6 +770,7 @@ export const MatrixProvider: React.FC = ({ children }) => { .getEvents() .filter((event) => event.getType() === EventType.RoomMessage) .filter((event) => event.getId() && event.getSender()) + .filter((event) => getMessageReplaceTargetEventId(event) == null) .map((event) => { const base = messageFromRoomMessageEvent( client, @@ -794,6 +906,36 @@ export const MatrixProvider: React.FC = ({ children }) => { const type = event.getType(); if (type === EventType.RoomMessage) { + const replaceTargetId = getMessageReplaceTargetEventId(event); + if (replaceTargetId && room) { + const targetEv = + room.findEventById(replaceTargetId) ?? + (typeof room.getPendingEvent === 'function' + ? room.getPendingEvent(replaceTargetId) + : null); + if (!targetEv || targetEv.getType() !== EventType.RoomMessage) { + return; + } + const pinnedIds = getPinnedMessageIds(roomId); + const targetEventId = targetEv.getId(); + const targetSender = targetEv.getSender(); + if (!targetEventId || !targetSender) return; + targetEv.makeReplaced(event); + let message = messageFromRoomMessageEvent( + client, + roomId, + targetEv, + pinnedIds.includes(targetEventId), + ); + message = attachReactionsToMessage( + room, + message, + client.getUserId(), + ); + await messageListener(message); + return; + } + const eventId = event.getId(); const sender = event.getSender(); if (!eventId || !sender) return; @@ -869,6 +1011,37 @@ export const MatrixProvider: React.FC = ({ children }) => { ); await messageListener(message); } else if (redacted.getType() === EventType.RoomMessage) { + const replaceTargetId = getMessageReplaceTargetEventId(redacted); + + if (replaceTargetId) { + const targetEv = + room.findEventById(replaceTargetId) ?? + (typeof room.getPendingEvent === 'function' + ? room.getPendingEvent(replaceTargetId) + : null); + if (!targetEv || targetEv.getType() !== EventType.RoomMessage) { + return; + } + const pinnedIds = getPinnedMessageIds(roomId); + const targetEventId = targetEv.getId(); + const targetSender = targetEv.getSender(); + if (!targetEventId || !targetSender) return; + targetEv.makeReplaced(undefined); + let message = messageFromRoomMessageEvent( + client, + roomId, + targetEv, + pinnedIds.includes(targetEventId), + ); + message = attachReactionsToMessage( + room, + message, + client.getUserId(), + ); + await messageListener(message); + return; + } + const pinnedIds = getPinnedMessageIds(roomId); const mid = redacted.getId(); const ms = redacted.getSender(); @@ -905,6 +1078,7 @@ export const MatrixProvider: React.FC = ({ children }) => { isAuthenticated, createRoom, sendMessage, + editRoomMessage, toggleReaction, getRoomMessages, getPinnedMessageIds, @@ -930,6 +1104,9 @@ const noopMatrixContext: MatrixContextType = { sendMessage: async () => { throw new Error('Matrix unavailable'); }, + editRoomMessage: async () => { + throw new Error('Matrix unavailable'); + }, toggleReaction: async () => { throw new Error('Matrix unavailable'); }, diff --git a/packages/core/src/matrix/rich-reply.ts b/packages/core/src/matrix/rich-reply.ts index 02723e7231..869205c1b4 100644 --- a/packages/core/src/matrix/rich-reply.ts +++ b/packages/core/src/matrix/rich-reply.ts @@ -1,6 +1,6 @@ /** Matrix rich reply plaintext helpers (Client-Server API — rich replies). */ -import { MatrixEventEvent } from 'matrix-js-sdk'; +import { MatrixEventEvent, RelationType } from 'matrix-js-sdk'; import type * as MatrixSdk from 'matrix-js-sdk'; import type { Message, MessageMediaBundleItem } from './types'; @@ -54,6 +54,75 @@ export function isLocalProvisionalEventId(eventId: string): boolean { return eventId.startsWith('~'); } +/** + * Wait until a timeline event has a server-assigned id (not `~…`), e.g. before + * sending `m.relates_to.event_id` that the homeserver must accept. + */ +export async function awaitNonProvisionalMatrixEventId( + event: MatrixSdk.MatrixEvent, +): Promise { + const initial = event.getId(); + if (!initial || !isLocalProvisionalEventId(initial)) { + return; + } + + await new Promise((resolve, reject) => { + const timeoutMs = 30_000; + const timeout = setTimeout(() => { + cleanup(); + reject( + new Error('Message is still sending; wait a moment and try again'), + ); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeout); + event.off(MatrixEventEvent.LocalEventIdReplaced, onReplaced); + }; + + const onReplaced = () => { + const cur = event.getId(); + if (cur && !isLocalProvisionalEventId(cur)) { + cleanup(); + resolve(); + } + }; + + event.on(MatrixEventEvent.LocalEventIdReplaced, onReplaced); + + const cur = event.getId(); + if (cur && !isLocalProvisionalEventId(cur)) { + cleanup(); + resolve(); + } + }); + + const finalId = event.getId(); + if (!finalId || isLocalProvisionalEventId(finalId)) { + throw new Error('Message is still sending; wait a moment and try again'); + } +} + +/** + * When `event` is an `m.room.message` with `m.relates_to.rel_type === m.replace`, + * returns the event id of the message being edited. Otherwise `undefined`. + */ +export function getMessageReplaceTargetEventId( + event: MatrixSdk.MatrixEvent, +): string | undefined { + const rel = event.getWireContent()?.['m.relates_to'] as + | { rel_type?: string; event_id?: string } + | undefined; + if ( + rel?.rel_type === RelationType.Replace && + typeof rel.event_id === 'string' && + rel.event_id.length > 0 + ) { + return rel.event_id; + } + return undefined; +} + /** * Resolve the target message for a rich reply, waiting if the UI still holds a * provisional `~…` id (outbound echo not yet received). @@ -88,45 +157,9 @@ export async function resolveReplyTargetForSend( throw new Error('Reply target message not found'); } - if (isLocalProvisionalEventId(target.getId()!)) { - await new Promise((resolve, reject) => { - const timeoutMs = 30_000; - const timeout = setTimeout(() => { - cleanup(); - reject( - new Error( - 'Reply target is still sending; wait a moment and try again', - ), - ); - }, timeoutMs); - - const cleanup = () => { - clearTimeout(timeout); - target!.off(MatrixEventEvent.LocalEventIdReplaced, onReplaced); - }; - - const onReplaced = () => { - if (!isLocalProvisionalEventId(target!.getId()!)) { - cleanup(); - resolve(); - } - }; - - target.on(MatrixEventEvent.LocalEventIdReplaced, onReplaced); - - if (!isLocalProvisionalEventId(target.getId()!)) { - cleanup(); - resolve(); - } - }); - } + await awaitNonProvisionalMatrixEventId(target); const eventId = target.getId()!; - if (isLocalProvisionalEventId(eventId)) { - throw new Error( - 'Reply target is still sending; wait a moment and try again', - ); - } const sender = target.getSender(); if (!sender) { diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-chat-bar.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-chat-bar.tsx index 6f7a889018..9d5d1e2464 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-chat-bar.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-chat-bar.tsx @@ -40,6 +40,11 @@ type ReplyPreview = { onDismiss: () => void; }; +type EditPreview = { + excerpt: string; + onDismiss: () => void; +}; + export type ChatDraftAttachment = { id: string; file: File; @@ -56,6 +61,8 @@ type HumanChatPanelChatBarProps = { channelName?: string; /** Rich reply: composer preview above the textarea */ replyPreview?: ReplyPreview; + /** Editing an existing own message (Matrix `m.replace`). */ + editPreview?: EditPreview; draftAttachments?: ChatDraftAttachment[]; onDraftAttachmentsChange?: (next: ChatDraftAttachment[]) => void; }; @@ -217,6 +224,7 @@ export function HumanChatPanelChatBar({ placeholder, channelName, replyPreview, + editPreview, draftAttachments = [], onDraftAttachmentsChange, }: HumanChatPanelChatBarProps) { @@ -228,6 +236,7 @@ export function HumanChatPanelChatBar({ const textareaRef = useRef(null); const composerShellRef = useRef(null); const replyPreviewWasOpenRef = useRef(false); + const editPreviewWasOpenRef = useRef(false); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const [colonOpen, setColonOpen] = useState(false); const [colonSuggestions, setColonSuggestions] = useState( @@ -280,6 +289,14 @@ export function HumanChatPanelChatBar({ replyPreviewWasOpenRef.current = isOpen; }, [replyPreview]); + useEffect(() => { + const isOpen = Boolean(editPreview); + if (isOpen && !editPreviewWasOpenRef.current) { + textareaRef.current?.focus(); + } + editPreviewWasOpenRef.current = isOpen; + }, [editPreview]); + const autoResize = useCallback(() => { if (textareaRef.current) { textareaRef.current.style.height = 'auto'; @@ -794,6 +811,31 @@ export function HumanChatPanelChatBar({ )} + {editPreview && ( +
+
+

+ + {t('editingMessage')} + + + {editPreview.excerpt} +

+
+ +
+ )} {colonOpen && colonSuggestions.length > 0 && (
void; + /** When set, Edit is enabled (own text messages only; parent omits otherwise). */ + onEdit?: () => void; /** When set, user can open react picker (omit for welcome). */ onReact?: (emoji: string) => void | Promise; }; @@ -706,6 +709,7 @@ export function HumanChatPanelMessageBubble({ message, isStreaming, onReply, + onEdit, onReact, }: HumanChatPanelMessageBubbleProps) { const t = useTranslations('HumanChatPanel'); @@ -758,6 +762,7 @@ export function HumanChatPanelMessageBubble({ const reactions = message.reactions ?? []; const isSendPendingRow = Boolean(message.sendPending); const canReply = Boolean(onReply) && !isSendPendingRow; + const canEdit = Boolean(onEdit) && !isSendPendingRow; const canReact = Boolean(onReact) && !isSendPendingRow; const sendPendingMainLabel = (() => { @@ -1275,6 +1280,16 @@ export function HumanChatPanelMessageBubble({ +