Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions packages/core/src/matrix/client/providers/matrix-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import {
HYPHA_MEDIA_BUNDLE_FIELD,
HYPHA_SPOILER_FIELD,
getMessageReplaceTargetEventId,
messageFromRoomMessageEvent,
resolveReplyTargetForSend,
type HyphaMediaBundleItemWire,
Expand Down Expand Up @@ -43,6 +44,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.
Expand Down Expand Up @@ -169,6 +177,7 @@ interface MatrixContextType {
isAuthenticated: boolean;
createRoom: (title: string) => Promise<{ roomId: string }>;
sendMessage: (params: SendMessageInput) => Promise<void>;
editRoomMessage: (params: EditRoomMessageInput) => Promise<void>;
toggleReaction: (params: ToggleReactionInput) => Promise<void>;
getRoomMessages: (roomId: string) => Message[] | null;
getPinnedMessageIds: (roomId: string) => string[];
Expand Down Expand Up @@ -615,6 +624,102 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ 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');
}
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: targetEventId,
},
} as RoomMessageEventContent);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
[client],
);

const getPinnedMessageIds = React.useCallback(
(roomId: string): string[] => {
if (!client) {
Expand Down Expand Up @@ -659,6 +764,7 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ 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,
Expand Down Expand Up @@ -794,6 +900,36 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ 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;
Expand Down Expand Up @@ -869,6 +1005,37 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ 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();
Expand Down Expand Up @@ -905,6 +1072,7 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ children }) => {
isAuthenticated,
createRoom,
sendMessage,
editRoomMessage,
toggleReaction,
getRoomMessages,
getPinnedMessageIds,
Expand All @@ -930,6 +1098,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');
},
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/matrix/rich-reply.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -54,6 +54,26 @@ export function isLocalProvisionalEventId(eventId: string): boolean {
return eventId.startsWith('~');
}

/**
* 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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ type ReplyPreview = {
onDismiss: () => void;
};

type EditPreview = {
excerpt: string;
onDismiss: () => void;
};

export type ChatDraftAttachment = {
id: string;
file: File;
Expand All @@ -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;
};
Expand Down Expand Up @@ -217,6 +224,7 @@ export function HumanChatPanelChatBar({
placeholder,
channelName,
replyPreview,
editPreview,
draftAttachments = [],
onDraftAttachmentsChange,
}: HumanChatPanelChatBarProps) {
Expand All @@ -228,6 +236,7 @@ export function HumanChatPanelChatBar({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const composerShellRef = useRef<HTMLDivElement>(null);
const replyPreviewWasOpenRef = useRef(false);
const editPreviewWasOpenRef = useRef(false);
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
const [colonOpen, setColonOpen] = useState(false);
const [colonSuggestions, setColonSuggestions] = useState<EmojiIndexEntry[]>(
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -794,6 +811,31 @@ export function HumanChatPanelChatBar({
</button>
</div>
)}
{editPreview && (
<div
data-testid="chat-edit-preview"
className="flex items-start gap-2 border-b border-border px-3 py-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{t('editingMessage')}
</span>
<span className="text-muted-foreground"> — </span>
<span>{editPreview.excerpt}</span>
</p>
</div>
<button
type="button"
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={t('editDismiss')}
title={t('editDismiss')}
onClick={editPreview.onDismiss}
>
<X className="h-4 w-4" />
</button>
</div>
)}
{colonOpen && colonSuggestions.length > 0 && (
<div
role="listbox"
Expand Down
Loading
Loading