From 387e045fa480c2e4c92b920f0c60e9bc70b8dd34 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 18:45:03 +0000 Subject: [PATCH 01/60] feat(chat): combine captions with media and fix redacted timeline rows Send composer text on the first attachment Matrix event, map media body and formatted HTML into the UI model, render captions above assets, skip fully redacted room messages in history, and remove rows on redaction. Co-authored-by: webguru-hypha --- .../client/providers/matrix-provider.tsx | 209 +++++++++++------- packages/core/src/matrix/rich-reply.ts | 25 ++- packages/core/src/matrix/types.ts | 2 + .../human-chat-panel-message-bubble.tsx | 82 ++++--- .../epics/src/common/human-right-panel.tsx | 17 +- 5 files changed, 216 insertions(+), 119 deletions(-) diff --git a/packages/core/src/matrix/client/providers/matrix-provider.tsx b/packages/core/src/matrix/client/providers/matrix-provider.tsx index b1db15d073..3b73627c44 100644 --- a/packages/core/src/matrix/client/providers/matrix-provider.tsx +++ b/packages/core/src/matrix/client/providers/matrix-provider.tsx @@ -16,6 +16,7 @@ import { HYPHA_SPOILER_FIELD, awaitNonProvisionalMatrixEventId, getMessageReplaceTargetEventId, + isRedactedRoomMessageEvent, messageFromRoomMessageEvent, resolveReplyTargetForSend, type HyphaMediaBundleItemWire, @@ -480,98 +481,126 @@ export const MatrixProvider: React.FC = ({ children }) => { return base; }; - const mediaPayloads: HyphaMediaEventContent[] = []; - for (let i = 0; i < list.length; i++) { - if (i > 0) { - await delay(MATRIX_UPLOAD_STAGGER_MS); - } - const att = list[i]!; - let attempt = 0; - while (true) { - try { - mediaPayloads.push(await prepareMediaPayload(att)); - onUploadProgress?.({ - completed: mediaPayloads.length, - total: list.length, - }); - break; - } catch (e) { - if ( - !isMatrixRateLimitedError(e) || - attempt >= MATRIX_UPLOAD_RATE_LIMIT_MAX_ATTEMPTS - 1 - ) { - throw e; + if (hasAttachments) { + const mediaPayloads: HyphaMediaEventContent[] = []; + for (let i = 0; i < list.length; i++) { + if (i > 0) { + await delay(MATRIX_UPLOAD_STAGGER_MS); + } + const att = list[i]!; + let attempt = 0; + while (true) { + try { + mediaPayloads.push(await prepareMediaPayload(att)); + onUploadProgress?.({ + completed: mediaPayloads.length, + total: list.length, + }); + break; + } catch (e) { + if ( + !isMatrixRateLimitedError(e) || + attempt >= MATRIX_UPLOAD_RATE_LIMIT_MAX_ATTEMPTS - 1 + ) { + throw e; + } + await delay(matrixRateLimitBackoffMs(e, attempt)); + attempt += 1; } - await delay(matrixRateLimitBackoffMs(e, attempt)); - attempt += 1; } } - } - let sentMediaCount = 0; - try { - if (mediaPayloads.length === 1) { - const base = mediaPayloads[0]!; - const eventContent = replyContext - ? { - ...base, - 'm.relates_to': { - 'm.in_reply_to': { - event_id: replyContext.resolvedTargetId, + if (trimmed) { + const first = mediaPayloads[0]!; + if (replyContext) { + const rich = buildRichReplyMatrixContent( + replyContext.sender, + replyContext.targetBody, + trimmed, + ); + mediaPayloads[0] = { + ...first, + body: rich.body, + format: rich.format, + formatted_body: rich.formatted_body, + }; + } else { + const textExtras = + matrixTextEventContentWithOptionalFormatting(trimmed); + mediaPayloads[0] = { + ...first, + ...textExtras, + body: trimmed, + } as HyphaMediaEventContent; + } + } + + let sentMediaCount = 0; + try { + if (mediaPayloads.length === 1) { + const base = mediaPayloads[0]!; + const eventContent = replyContext + ? { + ...base, + 'm.relates_to': { + 'm.in_reply_to': { + event_id: replyContext.resolvedTargetId, + }, }, - }, - } - : base; - await client.sendEvent( - roomId, - EventType.RoomMessage, - eventContent as RoomMessageEventContent, - ); - sentMediaCount = 1; - } else if (mediaPayloads.length > 1) { - const [first, ...rest] = mediaPayloads; - const bundleItems: HyphaMediaBundleItemWire[] = rest.map((item) => { - const spoiler = item[HYPHA_SPOILER_FIELD] === true; - const c = item as HyphaMediaBundleItemWire; - const { msgtype, body, filename, url, info } = c; - return { - msgtype, - body, - filename, - url, - info, - ...(spoiler ? { [HYPHA_SPOILER_FIELD]: true } : {}), + } + : base; + await client.sendEvent( + roomId, + EventType.RoomMessage, + eventContent as RoomMessageEventContent, + ); + sentMediaCount = 1; + } else if (mediaPayloads.length > 1) { + const [first, ...rest] = mediaPayloads; + const bundleItems: HyphaMediaBundleItemWire[] = rest.map((item) => { + const spoiler = item[HYPHA_SPOILER_FIELD] === true; + const c = item as HyphaMediaBundleItemWire; + const { msgtype, body, filename, url, info } = c; + return { + msgtype, + body, + filename, + url, + info, + ...(spoiler ? { [HYPHA_SPOILER_FIELD]: true } : {}), + }; + }); + const combined: HyphaMediaEventContent = { + ...first!, + [HYPHA_MEDIA_BUNDLE_FIELD]: bundleItems, }; - }); - const combined: HyphaMediaEventContent = { - ...first!, - [HYPHA_MEDIA_BUNDLE_FIELD]: bundleItems, - }; - const eventContent = replyContext - ? { - ...combined, - 'm.relates_to': { - 'm.in_reply_to': { - event_id: replyContext.resolvedTargetId, + const eventContent = replyContext + ? { + ...combined, + 'm.relates_to': { + 'm.in_reply_to': { + event_id: replyContext.resolvedTargetId, + }, }, - }, - } - : combined; - await client.sendEvent( - roomId, - EventType.RoomMessage, - eventContent as RoomMessageEventContent, + } + : combined; + await client.sendEvent( + roomId, + EventType.RoomMessage, + eventContent as RoomMessageEventContent, + ); + sentMediaCount = list.length; + } + } catch (mediaErr) { + throw new SendMessagePartialFailureError( + mediaErr instanceof Error + ? mediaErr.message + : 'Failed to send attachment', + sentMediaCount, + true, ); - sentMediaCount = list.length; } - } catch (mediaErr) { - throw new SendMessagePartialFailureError( - mediaErr instanceof Error - ? mediaErr.message - : 'Failed to send attachment', - sentMediaCount, - true, - ); + return; } if (!trimmed) { @@ -804,6 +833,7 @@ export const MatrixProvider: React.FC = ({ children }) => { .getLiveTimeline() .getEvents() .filter((event) => event.getType() === EventType.RoomMessage) + .filter((event) => !isRedactedRoomMessageEvent(event)) .filter((event) => event.getId() && event.getSender()) .filter((event) => getMessageReplaceTargetEventId(event) == null) .map((event) => { @@ -954,6 +984,19 @@ export const MatrixProvider: React.FC = ({ children }) => { const type = event.getType(); if (type === EventType.RoomMessage) { + if (isRedactedRoomMessageEvent(event)) { + const id = event.getId(); + if (id) { + await messageListener({ + id, + sender: event.getSender() ?? '', + content: '', + timestamp: new Date(event.getTs()), + redacted: true, + }); + } + return; + } const replaceTargetId = getMessageReplaceTargetEventId(event); if (replaceTargetId && room) { const targetEv = diff --git a/packages/core/src/matrix/rich-reply.ts b/packages/core/src/matrix/rich-reply.ts index 869205c1b4..305bb4dff8 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, RelationType } from 'matrix-js-sdk'; +import { EventType, MatrixEventEvent, RelationType } from 'matrix-js-sdk'; import type * as MatrixSdk from 'matrix-js-sdk'; import type { Message, MessageMediaBundleItem } from './types'; @@ -257,6 +257,18 @@ function parseReplyFallbackFirstLine(body: string): { /** * Map a timeline `m.room.message` event to Hypha `Message` (reply metadata + display body). */ +/** + * Redacted `m.room.message` events still exist on the timeline; skip them so the UI + * does not render empty rows (avatar + “You” with no body). + */ +export function isRedactedRoomMessageEvent( + event: MatrixSdk.MatrixEvent, +): boolean { + return ( + event.getType() === EventType.RoomMessage && event.isRedacted() === true + ); +} + export function messageFromRoomMessageEvent( client: MatrixSdk.MatrixClient, roomId: string, @@ -318,6 +330,11 @@ export function messageFromRoomMessageEvent( content.formatted_body, ); } + } else if ( + content.format === MATRIX_CUSTOM_HTML_FORMAT && + typeof content.formatted_body === 'string' + ) { + formattedContentHtml = extractReplyFormattedHtml(content.formatted_body); } } else if ( !isMedia && @@ -325,6 +342,12 @@ export function messageFromRoomMessageEvent( typeof content.formatted_body === 'string' ) { formattedContentHtml = content.formatted_body; + } else if ( + isMedia && + content.format === MATRIX_CUSTOM_HTML_FORMAT && + typeof content.formatted_body === 'string' + ) { + formattedContentHtml = content.formatted_body; } const id = event.getId(); diff --git a/packages/core/src/matrix/types.ts b/packages/core/src/matrix/types.ts index e76a39418e..6cced69e91 100644 --- a/packages/core/src/matrix/types.ts +++ b/packages/core/src/matrix/types.ts @@ -69,6 +69,8 @@ export type MessageMediaBundleItem = { export interface Message { id: string; sender: string; + /** Synthetic row from a redaction handler: remove this timeline id from UI. */ + redacted?: boolean; /** Matrix msgtype for timeline rendering (`m.text` is implicit when omitted). */ msgtype?: 'm.text' | 'm.file' | 'm.image'; /** Visible message text (reply fallback stripped when applicable). */ diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx index 127e25fb4c..b80d7f6073 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx @@ -920,6 +920,55 @@ export function HumanChatPanelMessageBubble({ )} + {/* Message text above attachments (caption + media in one Matrix event) */} + {textContent && + (message.formattedContentHtml ? ( +

0) + ? 'mt-1' + : 'mt-0', + )} + > + +

+ ) : jumboLayout.mode === 'jumbo' ? ( +

0) + ? 'mt-1' + : 'mt-0', + )} + aria-label={textContent.trim()} + > + {jumboLayout.graphemes.map((g, i) => ( + + {g} + + ))} +

+ ) : ( +

0) + ? 'mt-1' + : 'mt-0', + )} + > + {renderTextWithMentions(textContent)} +

+ ))} + {message.mediaSlots && message.mediaSlots.length > 1 && (
)} - {/* Message text — Matrix HTML, or Discord-style jumboji, or plain + mentions */} - {textContent && - (message.formattedContentHtml ? ( -

- -

- ) : jumboLayout.mode === 'jumbo' ? ( -

- {jumboLayout.graphemes.map((g, i) => ( - - {g} - - ))} -

- ) : ( -

- {renderTextWithMentions(textContent)} -

- ))} - {/* Streaming indicator */} {isStreaming && ( diff --git a/packages/epics/src/common/human-right-panel.tsx b/packages/epics/src/common/human-right-panel.tsx index 41bbea59ad..31b2991acb 100644 --- a/packages/epics/src/common/human-right-panel.tsx +++ b/packages/epics/src/common/human-right-panel.tsx @@ -139,6 +139,11 @@ function toUIMessage( const isMedia = msg.msgtype === 'm.file' || msg.msgtype === 'm.image'; + const captionForMedia = + isMedia && msg.content.trim().length > 0 + ? stripMatrixReplyFallback(msg.content).trim() + : ''; + let replyTo: UIMessage['replyTo']; if (msg.inReplyToEventId) { const authorLabel = resolveMemberLabel(msg.inReplyToSender); @@ -189,10 +194,14 @@ function toUIMessage( id: msg.id, role: isCurrentUser ? 'user' : 'member', isSynthetic: false, - parts: isMedia ? [] : [{ type: 'text', text: msg.content }], + parts: isMedia + ? captionForMedia + ? [{ type: 'text', text: captionForMedia }] + : [] + : [{ type: 'text', text: msg.content }], media, mediaSlots, - formattedContentHtml: isMedia ? undefined : msg.formattedContentHtml, + formattedContentHtml: msg.formattedContentHtml, senderName: isCurrentUser ? undefined : resolveMemberLabel(msg.sender), senderMatrixId: msg.sender, avatarUrl: isCurrentUser ? currentUserAvatarUrl : undefined, @@ -666,6 +675,9 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { roomId, async (message: Message) => { setMessages((prev) => { + if (message.redacted) { + return prev.filter((m) => m.id !== message.id); + } const next = toUIMessage( message, currentUserIdRef.current, @@ -819,6 +831,7 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { setDeleteError(null); try { await matrixRef.current.redactRoomEvent({ roomId, eventId: messageId }); + setMessages((prev) => prev.filter((m) => m.id !== messageId)); if (editDraft?.messageId === messageId) { setEditDraft(null); setInput(''); From a700b1a0fd0c4a4f636da5364fc7a2b1087d4f26 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 18:45:54 +0000 Subject: [PATCH 02/60] feat(chat): add drag-and-drop file attach to human chat composer Accept file drops on the composer shell with visual feedback and i18n prompt across supported locales. Co-authored-by: webguru-hypha --- .../human-chat-panel-chat-bar.tsx | 53 +++++++++++++++++++ packages/i18n/src/messages/de.json | 1 + packages/i18n/src/messages/en.json | 1 + packages/i18n/src/messages/es.json | 1 + packages/i18n/src/messages/fr.json | 1 + packages/i18n/src/messages/pt.json | 1 + 6 files changed, 58 insertions(+) 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 3c77fb060b..2344704d61 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 @@ -264,6 +264,8 @@ export function HumanChatPanelChatBar({ top: number; left: number; } | null>(null); + const [composerDragDepth, setComposerDragDepth] = useState(0); + const isComposerDropActive = composerDragDepth > 0; const updateSelectionBar = useCallback(() => { const el = textareaRef.current; @@ -704,8 +706,59 @@ export function HumanChatPanelChatBar({ className={cn( 'relative flex min-w-0 flex-col rounded-lg border border-border bg-muted/50', 'transition-all duration-200 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/20', + isComposerDropActive && 'border-primary/50 ring-2 ring-primary/25', )} + onDragEnter={(e) => { + if ( + !onDraftAttachmentsChange || + !e.dataTransfer?.types.includes('Files') + ) { + return; + } + e.preventDefault(); + if (e.currentTarget.contains(e.relatedTarget as Node)) { + return; + } + setComposerDragDepth((d) => d + 1); + }} + onDragLeave={(e) => { + if (!onDraftAttachmentsChange) return; + if (e.currentTarget.contains(e.relatedTarget as Node)) { + return; + } + setComposerDragDepth((d) => Math.max(0, d - 1)); + }} + onDragOver={(e) => { + if ( + !onDraftAttachmentsChange || + !e.dataTransfer?.types.includes('Files') + ) { + return; + } + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + }} + onDrop={(e) => { + if (!onDraftAttachmentsChange) { + return; + } + e.preventDefault(); + setComposerDragDepth(0); + const files = e.dataTransfer?.files; + if (!files?.length) return; + pushDrafts(files, 'file'); + }} > + {isComposerDropActive && ( +
+

+ {t('composerDropPrompt')} +

+
+ )} {selectionBar && ( <>
Date: Tue, 14 Apr 2026 18:46:00 +0000 Subject: [PATCH 03/60] fix(chat): preserve human chat scroll when history shrinks Only auto-scroll to bottom when the user is already near the end or a new message was appended, so deletions no longer jump the viewport. Co-authored-by: webguru-hypha --- .../human-chat-panel-messages.tsx | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx index 31921852fb..d126dbd3e9 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import { HumanChatPanelMessageBubble } from './human-chat-panel-message-bubble'; @@ -74,6 +74,9 @@ export function HumanChatPanelMessages({ senderName: t('systemSender'), }; const containerRef = useRef(null); + const prevLenRef = useRef(0); + const prevLastIdRef = useRef(null); + const stickToBottomRef = useRef(true); /** At most one floating action bar: pointer hover, or locked while that row's hover emoji picker is open. */ const [hoverActionMessageId, setHoverActionMessageId] = useState< string | null @@ -86,11 +89,29 @@ export function HumanChatPanelMessages({ /** Pointer left row while hover picker was open (portal); hide bar when picker closes. */ const leaveWhileLockedRef = useRef(null); - useEffect(() => { + useLayoutEffect(() => { const container = containerRef.current; - if (container) { + if (!container) return; + + const len = messages.length; + const lastId = len > 0 ? messages[len - 1]!.id : null; + const prevLen = prevLenRef.current; + const prevLastId = prevLastIdRef.current; + + const appended = + len > prevLen || + (len === prevLen && len > 0 && lastId != null && lastId !== prevLastId); + + if (appended) { + stickToBottomRef.current = true; + } + + if (stickToBottomRef.current || isStreaming) { container.scrollTop = container.scrollHeight; } + + prevLenRef.current = len; + prevLastIdRef.current = lastId; }, [messages, isStreaming]); const displayMessages = messages.length > 0 ? messages : [welcomeMessage]; @@ -98,6 +119,14 @@ export function HumanChatPanelMessages({ return (
{ + const el = containerRef.current; + if (!el) return; + const threshold = 80; + const distanceFromBottom = + el.scrollHeight - el.scrollTop - el.clientHeight; + stickToBottomRef.current = distanceFromBottom <= threshold; + }} className="narrow-scrollbar flex min-w-0 flex-1 flex-col overflow-y-auto px-3 py-3" >
From 6e81095e83d8a7fe9c5333961b502a678d2475ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 23:49:36 +0000 Subject: [PATCH 04/60] fix: apply CodeRabbit auto-fixes Suppress default browser behavior on file drags before optional handlers; avoid treating bare media filenames as captions; align pt composerDropPrompt. Co-authored-by: webguru-hypha --- .../human-chat-panel-chat-bar.tsx | 21 +++++++++++-------- .../epics/src/common/human-right-panel.tsx | 13 +++++++++--- packages/i18n/src/messages/pt.json | 2 +- 3 files changed, 23 insertions(+), 13 deletions(-) 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 2344704d61..2f689207a6 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 @@ -709,13 +709,13 @@ export function HumanChatPanelChatBar({ isComposerDropActive && 'border-primary/50 ring-2 ring-primary/25', )} onDragEnter={(e) => { - if ( - !onDraftAttachmentsChange || - !e.dataTransfer?.types.includes('Files') - ) { + if (!e.dataTransfer?.types.includes('Files')) { return; } e.preventDefault(); + if (!onDraftAttachmentsChange) { + return; + } if (e.currentTarget.contains(e.relatedTarget as Node)) { return; } @@ -729,21 +729,24 @@ export function HumanChatPanelChatBar({ setComposerDragDepth((d) => Math.max(0, d - 1)); }} onDragOver={(e) => { - if ( - !onDraftAttachmentsChange || - !e.dataTransfer?.types.includes('Files') - ) { + if (!e.dataTransfer?.types.includes('Files')) { return; } e.preventDefault(); + if (!onDraftAttachmentsChange) { + return; + } e.dataTransfer.dropEffect = 'copy'; }} onDrop={(e) => { - if (!onDraftAttachmentsChange) { + if (!e.dataTransfer?.types.includes('Files')) { return; } e.preventDefault(); setComposerDragDepth(0); + if (!onDraftAttachmentsChange) { + return; + } const files = e.dataTransfer?.files; if (!files?.length) return; pushDrafts(files, 'file'); diff --git a/packages/epics/src/common/human-right-panel.tsx b/packages/epics/src/common/human-right-panel.tsx index 31b2991acb..b38783f807 100644 --- a/packages/epics/src/common/human-right-panel.tsx +++ b/packages/epics/src/common/human-right-panel.tsx @@ -139,9 +139,15 @@ function toUIMessage( const isMedia = msg.msgtype === 'm.file' || msg.msgtype === 'm.image'; + const strippedMediaBody = isMedia + ? stripMatrixReplyFallback(msg.content).trim() + : ''; + const mediaFilenameForCaption = (msg.filename ?? msg.content).trim(); const captionForMedia = - isMedia && msg.content.trim().length > 0 - ? stripMatrixReplyFallback(msg.content).trim() + isMedia && + strippedMediaBody.length > 0 && + strippedMediaBody !== mediaFilenameForCaption + ? strippedMediaBody : ''; let replyTo: UIMessage['replyTo']; @@ -201,7 +207,8 @@ function toUIMessage( : [{ type: 'text', text: msg.content }], media, mediaSlots, - formattedContentHtml: msg.formattedContentHtml, + formattedContentHtml: + isMedia && !captionForMedia ? undefined : msg.formattedContentHtml, senderName: isCurrentUser ? undefined : resolveMemberLabel(msg.sender), senderMatrixId: msg.sender, avatarUrl: isCurrentUser ? currentUserAvatarUrl : undefined, diff --git a/packages/i18n/src/messages/pt.json b/packages/i18n/src/messages/pt.json index c8bd640508..ae183f3b51 100644 --- a/packages/i18n/src/messages/pt.json +++ b/packages/i18n/src/messages/pt.json @@ -1655,7 +1655,7 @@ "composerAttachImage": "Foto ou imagem", "composerAttachVideo": "Vídeo", "composerAttachFile": "Arquivo", - "composerDropPrompt": "Solte ficheiros para anexar", + "composerDropPrompt": "Solte arquivos para anexar", "mentionNotAvailable": "Menções (em breve)", "composerVoiceRecord": "Gravar mensagem de voz", "composerVoiceStop": "Parar gravação", From bb861934cfed0e3cc1e88b38d14d34d461cbd218 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 23:58:43 +0000 Subject: [PATCH 05/60] fix(matrix): satisfy RoomMessageEventContent format literal in sendMessage CI check-types failed: rich.format is typed as string but Matrix expects org.matrix.custom.html. Use MATRIX_CUSTOM_HTML_FORMAT when merging reply caption into the first media event. Co-authored-by: webguru-hypha --- packages/core/src/matrix/client/providers/matrix-provider.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/matrix/client/providers/matrix-provider.tsx b/packages/core/src/matrix/client/providers/matrix-provider.tsx index 3b73627c44..a5012af8e8 100644 --- a/packages/core/src/matrix/client/providers/matrix-provider.tsx +++ b/packages/core/src/matrix/client/providers/matrix-provider.tsx @@ -14,6 +14,7 @@ import { import { HYPHA_MEDIA_BUNDLE_FIELD, HYPHA_SPOILER_FIELD, + MATRIX_CUSTOM_HTML_FORMAT, awaitNonProvisionalMatrixEventId, getMessageReplaceTargetEventId, isRedactedRoomMessageEvent, @@ -521,7 +522,7 @@ export const MatrixProvider: React.FC = ({ children }) => { mediaPayloads[0] = { ...first, body: rich.body, - format: rich.format, + format: MATRIX_CUSTOM_HTML_FORMAT, formatted_body: rich.formatted_body, }; } else { From d08a24fed8ae8fa71ad8cc4f452e72a149a09b38 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 00:01:06 +0000 Subject: [PATCH 06/60] fix(matrix): allow format on Hypha media event content for captions RoomMessageEventContent for m.image/m.file omits format/formatted_body; merging rich-reply or markup captions into the first slot needs those fields. Extend HyphaMediaEventContent so check-types passes. Co-authored-by: webguru-hypha --- packages/core/src/matrix/client/providers/matrix-provider.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/core/src/matrix/client/providers/matrix-provider.tsx b/packages/core/src/matrix/client/providers/matrix-provider.tsx index a5012af8e8..91c6d9ff9a 100644 --- a/packages/core/src/matrix/client/providers/matrix-provider.tsx +++ b/packages/core/src/matrix/client/providers/matrix-provider.tsx @@ -133,6 +133,9 @@ function matrixRateLimitBackoffMs( type HyphaMediaEventContent = RoomMessageEventContent & { [HYPHA_SPOILER_FIELD]?: boolean; [HYPHA_MEDIA_BUNDLE_FIELD]?: HyphaMediaBundleItemWire[]; + /** Caption with markup on the same event as media (not in Matrix's narrow image/file union). */ + format?: typeof MATRIX_CUSTOM_HTML_FORMAT; + formatted_body?: string; }; function loadImageDimensions( From 6134e39f04657bb449039bd22dc8c7aee6e96d4c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 00:29:36 +0000 Subject: [PATCH 07/60] fix(chat): keep add-reaction emoji picker open inside overflow menus Nested Popover inside DropdownMenu/ContextMenu items dismissed the parent when moving the pointer to the portaled picker. Use Radix menu Sub with emoji-mart in SubContent and extract HumanChatPanelEmojiMartSurface for reuse. Co-authored-by: webguru-hypha --- .../human-chat-panel-emoji-mart-surface.tsx | 65 +++++++++++++++ .../human-chat-panel-emoji-picker.tsx | 45 ++--------- .../human-chat-panel-message-overflow.tsx | 80 +++++++++++-------- 3 files changed, 121 insertions(+), 69 deletions(-) create mode 100644 packages/epics/src/common/human-chat-panel/human-chat-panel-emoji-mart-surface.tsx diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-emoji-mart-surface.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-emoji-mart-surface.tsx new file mode 100644 index 0000000000..f21fc0879d --- /dev/null +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-emoji-mart-surface.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useLocale } from 'next-intl'; +import { useTheme } from 'next-themes'; +import Picker from '@emoji-mart/react'; +import data from '@emoji-mart/data'; +import { cn } from '@hypha-platform/ui-utils'; + +import { getEmojiMartI18n } from './emoji-mart-i18n'; + +type HumanChatPanelEmojiMartSurfaceProps = { + /** Accessible label for the picker surface. */ + ariaLabel: string; + className?: string; + onEmojiSelect: (native: string) => void; +}; + +/** + * emoji-mart grid only (no Popover). Use inside PopoverContent, DropdownMenuSubContent, etc. + */ +export function HumanChatPanelEmojiMartSurface({ + ariaLabel, + className, + onEmojiSelect, +}: HumanChatPanelEmojiMartSurfaceProps) { + const locale = useLocale(); + const { resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const pickerLocale = ['en', 'es', 'fr', 'de', 'pt'].includes(locale) + ? locale + : 'en'; + + const pickerTheme = mounted && resolvedTheme === 'dark' ? 'dark' : 'light'; + + return ( +
+ {mounted && ( + { + onEmojiSelect(emoji.native); + }} + theme={pickerTheme} + previewPosition="none" + skinTonePosition="search" + locale={pickerLocale} + /> + )} +
+ ); +} diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-emoji-picker.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-emoji-picker.tsx index 920fca3d5c..06585bde4e 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-emoji-picker.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-emoji-picker.tsx @@ -1,14 +1,9 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { useLocale } from 'next-intl'; -import { useTheme } from 'next-themes'; -import Picker from '@emoji-mart/react'; -import data from '@emoji-mart/data'; import { Popover, PopoverContent, PopoverTrigger } from '@hypha-platform/ui'; import { cn } from '@hypha-platform/ui-utils'; -import { getEmojiMartI18n } from './emoji-mart-i18n'; +import { HumanChatPanelEmojiMartSurface } from './human-chat-panel-emoji-mart-surface'; type HumanChatPanelEmojiPickerProps = { children: React.ReactNode; @@ -32,21 +27,6 @@ export function HumanChatPanelEmojiPicker({ align = 'end', className, }: HumanChatPanelEmojiPickerProps) { - const locale = useLocale(); - const { resolvedTheme } = useTheme(); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - const pickerLocale = ['en', 'es', 'fr', 'de', 'pt'].includes(locale) - ? locale - : 'en'; - - /** emoji-mart v5 + shadow DOM: avoid `theme="auto"` inside Radix portal — wrong root can yield invisible UI. */ - const pickerTheme = mounted && resolvedTheme === 'dark' ? 'dark' : 'light'; - return ( @@ -58,22 +38,13 @@ export function HumanChatPanelEmojiPicker({ aria-label={ariaLabel} onOpenAutoFocus={(e) => e.preventDefault()} > - {mounted && ( -
- { - onEmojiSelect(emoji.native); - onOpenChange(false); - }} - theme={pickerTheme} - previewPosition="none" - skinTonePosition="search" - locale={pickerLocale} - /> -
- )} + { + onEmojiSelect(native); + onOpenChange(false); + }} + />
); diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-overflow.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-overflow.tsx index 80241e3152..f1baa9363e 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-overflow.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-overflow.tsx @@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; import type { ReactNode } from 'react'; import { - ChevronRight, Copy, Link2, MoreHorizontal, @@ -26,17 +25,23 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, ContextMenuTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@hypha-platform/ui'; import { cn } from '@hypha-platform/ui-utils'; import { stripMatrixReplyFallback } from '@hypha-platform/core/client'; -import { HumanChatPanelEmojiPicker } from './human-chat-panel-emoji-picker'; +import { HumanChatPanelEmojiMartSurface } from './human-chat-panel-emoji-mart-surface'; import type { ChatPanelAttachmentMedia } from './chat-panel-media-types'; const RECENT_REACTIONS_STORAGE_KEY = 'hypha-chat-recent-reactions'; @@ -147,8 +152,6 @@ function useQuickReactions(): string[] { function MenuSections({ t, quickEmojis, - addReactionOpen, - setAddReactionOpen, canReact, onReact, onEdit, @@ -162,13 +165,15 @@ function MenuSections({ onSpeak, canDelete, onRequestDelete, + onAfterAddReaction, + Sub, + SubTrigger, + SubContent, Item, Separator, }: { t: (key: string, values?: Record) => string; quickEmojis: string[]; - addReactionOpen: boolean; - setAddReactionOpen: (o: boolean) => void; canReact: boolean; onReact?: (emoji: string) => void | Promise; onEdit?: () => void; @@ -182,6 +187,11 @@ function MenuSections({ onSpeak: () => void; canDelete: boolean; onRequestDelete: () => void; + /** e.g. close ⋯ dropdown after picking from full picker */ + onAfterAddReaction?: () => void; + Sub: typeof ContextMenuSub | typeof DropdownMenuSub; + SubTrigger: typeof ContextMenuSubTrigger | typeof DropdownMenuSubTrigger; + SubContent: typeof ContextMenuSubContent | typeof DropdownMenuSubContent; Item: typeof ContextMenuItem | typeof DropdownMenuItem; Separator: typeof ContextMenuSeparator | typeof DropdownMenuSeparator; }) { @@ -210,27 +220,28 @@ function MenuSections({ ))}
- { - pushRecentChatReaction(native); - if (onReact) void onReact(native); - }} - ariaLabel={t('addReactionButton')} - align="start" - > - { - e.preventDefault(); - setAddReactionOpen(true); - }} + + + {t('addReactionButton')} + + e.preventDefault()} > - {t('addReactionButton')} - - - + { + pushRecentChatReaction(native); + if (onReact) void onReact(native); + onAfterAddReaction?.(); + }} + /> + + @@ -418,7 +430,11 @@ export function HumanChatPanelMessageOverflow({ const dropdownMenu = ( setDropdownOpen(false)} + Sub={DropdownMenuSub} + SubTrigger={DropdownMenuSubTrigger} + SubContent={DropdownMenuSubContent} Item={DropdownMenuItem} Separator={DropdownMenuSeparator} /> From d6c9d5b740dec2d933159117f1de182138f6e0ed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 00:33:40 +0000 Subject: [PATCH 08/60] style(chat): compact composer selection format toolbar Co-authored-by: webguru-hypha --- .../human-chat-panel-chat-bar.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 2f689207a6..2327443d99 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 @@ -697,7 +697,7 @@ export function HumanChatPanelChatBar({ 'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-200 ease-out hover:bg-primary/12 hover:text-primary active:bg-primary/18 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-0'; const fmtBtn = - 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-popover-foreground transition-colors hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent dark:hover:text-accent-foreground'; + 'flex h-6 w-6 shrink-0 items-center justify-center rounded text-popover-foreground transition-colors hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent dark:hover:text-accent-foreground'; return (
@@ -772,15 +772,15 @@ export function HumanChatPanelChatBar({ }} aria-hidden > -
+
e.preventDefault()} > @@ -791,7 +791,7 @@ export function HumanChatPanelChatBar({ aria-label={t('bold')} onClick={() => applyFormat('bold')} > - +
From 66ee42886840796492872f8084466324a5ebd943 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 01:02:35 +0000 Subject: [PATCH 09/60] feat(matrix): send voice as m.audio with duration metadata Extend Message and bundle wire types for m.audio, parse duration in media info, upload audio attachments with MsgType.Audio and optional duration from local file metadata. Co-authored-by: webguru-hypha --- .../client/providers/matrix-provider.tsx | 46 +++++++++++++++++-- packages/core/src/matrix/rich-reply.ts | 14 ++++-- packages/core/src/matrix/types.ts | 6 ++- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/core/src/matrix/client/providers/matrix-provider.tsx b/packages/core/src/matrix/client/providers/matrix-provider.tsx index 91c6d9ff9a..b00fc2be0e 100644 --- a/packages/core/src/matrix/client/providers/matrix-provider.tsx +++ b/packages/core/src/matrix/client/providers/matrix-provider.tsx @@ -25,8 +25,8 @@ import { export interface SendAttachmentInput { file: File; - /** Drives Matrix `msgtype`: `m.image` vs `m.file`. */ - kind: 'file' | 'image'; + /** Drives Matrix `msgtype`: `m.image` vs `m.file` vs `m.audio`. */ + kind: 'file' | 'image' | 'audio'; /** Blur in timeline until clicked (`org.hypha.spoiler` on the event). */ spoiler?: boolean; } @@ -138,6 +138,35 @@ type HyphaMediaEventContent = RoomMessageEventContent & { formatted_body?: string; }; +function loadAudioDurationMs(file: File): Promise { + if ( + !file.type.startsWith('audio/') && + !/\.(webm|ogg|opus|mp3|m4a|wav)$/i.test(file.name) + ) { + return Promise.resolve(undefined); + } + return new Promise((resolve) => { + const url = URL.createObjectURL(file); + const el = document.createElement('audio'); + const done = (ms?: number) => { + URL.revokeObjectURL(url); + el.src = ''; + resolve(ms); + }; + el.preload = 'metadata'; + el.onloadedmetadata = () => { + const d = el.duration; + if (Number.isFinite(d) && d > 0) { + done(Math.round(d * 1000)); + } else { + done(undefined); + } + }; + el.onerror = () => done(undefined); + el.src = url; + }); +} + function loadImageDimensions( file: File, ): Promise<{ w: number; h: number } | undefined> { @@ -454,12 +483,18 @@ export const MatrixProvider: React.FC = ({ children }) => { clearTimeout(timeoutId); } const mxc = upload.content_uri; - const msgtype = att.kind === 'image' ? MsgType.Image : MsgType.File; + const msgtype = + att.kind === 'image' + ? MsgType.Image + : att.kind === 'audio' + ? MsgType.Audio + : MsgType.File; let info: { mimetype?: string; size?: number; w?: number; h?: number; + duration?: number; } = { mimetype: att.file.type || undefined, size: att.file.size, @@ -469,6 +504,11 @@ export const MatrixProvider: React.FC = ({ children }) => { if (dims) { info = { ...info, w: dims.w, h: dims.h }; } + } else if (msgtype === MsgType.Audio) { + const dur = await loadAudioDurationMs(att.file); + if (dur != null) { + info = { ...info, duration: dur }; + } } const caption = att.file.name; diff --git a/packages/core/src/matrix/rich-reply.ts b/packages/core/src/matrix/rich-reply.ts index 305bb4dff8..ad1f54cf99 100644 --- a/packages/core/src/matrix/rich-reply.ts +++ b/packages/core/src/matrix/rich-reply.ts @@ -18,7 +18,7 @@ export const HYPHA_SPOILER_FIELD = 'org.hypha.spoiler'; export const HYPHA_MEDIA_BUNDLE_FIELD = 'org.hypha.media_bundle'; export type HyphaMediaBundleItemWire = { - msgtype: 'm.file' | 'm.image'; + msgtype: 'm.file' | 'm.image' | 'm.audio'; url: string; body?: string; filename?: string; @@ -291,7 +291,10 @@ export function messageFromRoomMessageEvent( [key: string]: unknown; }; const msgtypeRaw = content.msgtype; - const isMedia = msgtypeRaw === 'm.file' || msgtypeRaw === 'm.image'; + const isMedia = + msgtypeRaw === 'm.file' || + msgtypeRaw === 'm.image' || + msgtypeRaw === 'm.audio'; const rawBody = content.body ?? ''; const replyToId = event.getWireContent()?.['m.relates_to']?.['m.in_reply_to'] ?.event_id as string | undefined; @@ -375,13 +378,16 @@ export function messageFromRoomMessageEvent( size: typeof o.size === 'number' ? o.size : undefined, w: typeof o.w === 'number' ? o.w : undefined, h: typeof o.h === 'number' ? o.h : undefined, + duration: typeof o.duration === 'number' ? o.duration : undefined, }; }; const parseBundleItem = (wire: unknown): MessageMediaBundleItem => { const w = wire as HyphaMediaBundleItemWire; const mt = - w.msgtype === 'm.file' || w.msgtype === 'm.image' + w.msgtype === 'm.file' || + w.msgtype === 'm.image' || + w.msgtype === 'm.audio' ? w.msgtype : 'm.file'; const url = @@ -406,7 +412,7 @@ export function messageFromRoomMessageEvent( let mediaBundle: Message['mediaBundle']; if (Array.isArray(bundleRaw) && bundleRaw.length > 0) { const first = { - msgtype: msgtypeRaw as 'm.file' | 'm.image', + msgtype: msgtypeRaw as 'm.file' | 'm.image' | 'm.audio', mxcUrl, filename: typeof content.filename === 'string' diff --git a/packages/core/src/matrix/types.ts b/packages/core/src/matrix/types.ts index 6cced69e91..a2b5060698 100644 --- a/packages/core/src/matrix/types.ts +++ b/packages/core/src/matrix/types.ts @@ -55,11 +55,13 @@ export type MessageMediaInfo = { size?: number; w?: number; h?: number; + /** Matrix `m.audio` / MSC1767: duration in milliseconds */ + duration?: number; }; /** One slot in a multi-attachment `org.hypha.media_bundle` message. */ export type MessageMediaBundleItem = { - msgtype: 'm.file' | 'm.image'; + msgtype: 'm.file' | 'm.image' | 'm.audio'; mxcUrl?: string; filename?: string; mediaInfo?: MessageMediaInfo; @@ -72,7 +74,7 @@ export interface Message { /** Synthetic row from a redaction handler: remove this timeline id from UI. */ redacted?: boolean; /** Matrix msgtype for timeline rendering (`m.text` is implicit when omitted). */ - msgtype?: 'm.text' | 'm.file' | 'm.image'; + msgtype?: 'm.text' | 'm.file' | 'm.image' | 'm.audio'; /** Visible message text (reply fallback stripped when applicable). */ content: string; /** From ea8f461ff2dbf4b6136f1a8152624b9ff03da298 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 01:02:51 +0000 Subject: [PATCH 10/60] feat(chat): mic menu for dictation vs voice, Telegram-style audio playback Add dropdown on mic: Web Speech dictation into composer vs MediaRecorder voice as audio draft; classify audio MIME separately from video; render voice with native audio play/pause; send audio kind as Matrix m.audio. Co-authored-by: webguru-hypha --- .../chat-panel-media-types.ts | 38 ++- .../human-chat-panel-chat-bar.tsx | 231 ++++++++++++++++-- .../human-chat-panel-message-bubble.tsx | 131 +++++++++- .../epics/src/common/human-right-panel.tsx | 14 +- 4 files changed, 375 insertions(+), 39 deletions(-) diff --git a/packages/epics/src/common/human-chat-panel/chat-panel-media-types.ts b/packages/epics/src/common/human-chat-panel/chat-panel-media-types.ts index 163eb275a1..95f29d4da9 100644 --- a/packages/epics/src/common/human-chat-panel/chat-panel-media-types.ts +++ b/packages/epics/src/common/human-chat-panel/chat-panel-media-types.ts @@ -2,7 +2,7 @@ * Matrix timeline attachment slice shared by chat list, bubbles, and HumanRightPanel. */ export type ChatPanelAttachmentMedia = { - msgtype: 'm.file' | 'm.image'; + msgtype: 'm.file' | 'm.image' | 'm.audio'; mxcUrl?: string; filename?: string; mediaInfo?: { @@ -10,10 +10,23 @@ export type ChatPanelAttachmentMedia = { size?: number; w?: number; h?: number; + duration?: number; }; spoiler?: boolean; }; +const AUDIO_FILE_EXTENSIONS = new Set([ + 'webm', + 'ogg', + 'oga', + 'opus', + 'mp3', + 'm4a', + 'aac', + 'flac', + 'wav', +]); + const VIDEO_FILE_EXTENSIONS = new Set([ 'mov', 'mp4', @@ -33,6 +46,17 @@ function extensionFromFileNameHint(name: string): string { return base.split('.').pop()?.toLowerCase() ?? ''; } +/** Local file draft: treat as voice/audio attachment (not video). */ +export function looksLikeAudioMimeOrName( + mimetype?: string, + filename?: string, +): boolean { + const mt = mimetype?.toLowerCase() ?? ''; + if (mt.startsWith('audio/')) return true; + const ext = extensionFromFileNameHint(filename ?? ''); + return AUDIO_FILE_EXTENSIONS.has(ext); +} + /** Local file or Matrix `info.mimetype` + name (composer drafts, timeline). */ export function looksLikeVideoMimeOrName( mimetype?: string, @@ -54,3 +78,15 @@ export function isChatPanelVideoFile( media.filename ?? '', ); } + +/** Matrix `m.audio` or audio-like `m.file` (voice clips often use m.file + audio/*). */ +export function isChatPanelAudioFile( + media: Pick, +): boolean { + if (media.msgtype === 'm.audio') return true; + if (media.msgtype !== 'm.file') return false; + const mt = media.mediaInfo?.mimetype?.toLowerCase() ?? ''; + if (mt.startsWith('audio/')) return true; + const ext = extensionFromFileNameHint(media.filename ?? ''); + return AUDIO_FILE_EXTENSIONS.has(ext); +} 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 2327443d99..c615ab8475 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 @@ -22,6 +22,8 @@ import { Paperclip, Video, Mic, + Keyboard, + Pause, } from 'lucide-react'; import { useTranslations } from 'next-intl'; @@ -29,6 +31,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@hypha-platform/ui'; import { cn } from '@hypha-platform/ui-utils'; @@ -41,7 +44,29 @@ import { type EmojiIndexEntry, } from './emoji-mart-index'; import { getTextareaSelectionCenter } from './textarea-caret-position'; -import { looksLikeVideoMimeOrName } from './chat-panel-media-types'; +import { + looksLikeAudioMimeOrName, + looksLikeVideoMimeOrName, +} from './chat-panel-media-types'; + +type SpeechRecognitionCtor = new () => SpeechRecognitionLike; + +type SpeechRecognitionLike = { + continuous: boolean; + interimResults: boolean; + lang: string; + onresult: ((ev: SpeechRecognitionEventLike) => void) | null; + onerror: ((ev: { error?: string }) => void) | null; + onend: (() => void) | null; + start: () => void; + stop: () => void; + abort: () => void; +}; + +type SpeechRecognitionEventLike = { + resultIndex: number; + results: ArrayLike<{ 0: { transcript: string }; isFinal: boolean }>; +}; type ReplyPreview = { authorLabel: string; @@ -57,7 +82,7 @@ type EditPreview = { export type ChatDraftAttachment = { id: string; file: File; - kind: 'file' | 'image' | 'video'; + kind: 'file' | 'image' | 'video' | 'audio'; previewUrl: string; spoiler: boolean; }; @@ -247,6 +272,14 @@ export function HumanChatPanelChatBar({ const mediaStreamRef = useRef(null); const [isVoiceRecording, setIsVoiceRecording] = useState(false); const [voiceError, setVoiceError] = useState(null); + /** When true, `MediaRecorder` stop should add an audio draft; when false (dictation), discard blob. */ + const voiceAsAttachmentRef = useRef(false); + const speechRecognitionRef = useRef(null); + const [isDictating, setIsDictating] = useState(false); + const [micMenuOpen, setMicMenuOpen] = useState(false); + const valueRef = useRef(value); + valueRef.current = value; + const textareaRef = useRef(null); const composerShellRef = useRef(null); const replyPreviewWasOpenRef = useRef(false); @@ -529,14 +562,20 @@ export function HumanChatPanelChatBar({ if (kind === 'image' && !file.type.startsWith('image/')) { continue; } + const isAudio = + kind === 'file' && looksLikeAudioMimeOrName(file.type, file.name); const isVideo = - kind === 'file' && looksLikeVideoMimeOrName(file.type, file.name); + kind === 'file' && + !isAudio && + looksLikeVideoMimeOrName(file.type, file.name); const slotKind: ChatDraftAttachment['kind'] = file.type.startsWith( 'image/', ) ? 'image' : isVideo ? 'video' + : isAudio + ? 'audio' : 'file'; next.push({ id: newAttachmentDraftId(), @@ -597,7 +636,7 @@ export function HumanChatPanelChatBar({ setIsVoiceRecording(false); }, []); - const startVoiceRecording = useCallback(async () => { + const startVoiceRecordingAsAttachment = useCallback(async () => { if (!onDraftAttachmentsChange) return; if (isVoiceRecording) { stopVoiceRecording(); @@ -610,7 +649,9 @@ export function HumanChatPanelChatBar({ setVoiceError(t('voiceRecordingNotSupported')); return; } + setMicMenuOpen(false); setVoiceError(null); + voiceAsAttachmentRef.current = true; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); mediaStreamRef.current = stream; @@ -642,7 +683,9 @@ export function HumanChatPanelChatBar({ mediaStreamRef.current = null; mediaRecorderRef.current = null; const blob = new Blob(chunks, { type: mr.mimeType || 'audio/webm' }); - if (blob.size < 256) { + const asAttach = voiceAsAttachmentRef.current; + voiceAsAttachmentRef.current = false; + if (!asAttach || blob.size < 256) { return; } const ext = blob.type.includes('mp4') ? 'm4a' : 'webm'; @@ -655,6 +698,7 @@ export function HumanChatPanelChatBar({ mr.start(); setIsVoiceRecording(true); } catch { + voiceAsAttachmentRef.current = false; const s = mediaStreamRef.current; if (s) { for (const track of s.getTracks()) { @@ -674,6 +718,80 @@ export function HumanChatPanelChatBar({ t, ]); + const stopDictation = useCallback(() => { + const r = speechRecognitionRef.current; + if (r) { + try { + r.stop(); + } catch { + try { + r.abort(); + } catch { + // ignore + } + } + speechRecognitionRef.current = null; + } + setIsDictating(false); + }, []); + + const startDictation = useCallback(() => { + setMicMenuOpen(false); + setVoiceError(null); + const SR = + (globalThis as unknown as { SpeechRecognition?: SpeechRecognitionCtor }) + .SpeechRecognition ?? + ( + globalThis as unknown as { + webkitSpeechRecognition?: SpeechRecognitionCtor; + } + ).webkitSpeechRecognition; + if (!SR) { + setVoiceError(t('dictationNotSupported')); + return; + } + if (isDictating) { + stopDictation(); + return; + } + const rec = new SR(); + rec.continuous = true; + rec.interimResults = true; + rec.lang = document.documentElement.lang || 'en'; + rec.onresult = (ev) => { + for (let i = ev.resultIndex; i < ev.results.length; i++) { + const res = ev.results[i]; + if (!res?.[0] || !res.isFinal) continue; + const piece = res[0].transcript.trim(); + if (!piece) continue; + const cur = valueRef.current.replace(/\u200b$/, ''); + const space = cur.length > 0 && !/\s$/.test(cur) ? ' ' : ''; + const next = cur + space + piece; + valueRef.current = next; + onChange(next); + } + }; + rec.onerror = () => { + speechRecognitionRef.current = null; + setIsDictating(false); + onChange(valueRef.current.replace(/\u200b$/, '')); + setVoiceError(t('dictationError')); + }; + rec.onend = () => { + speechRecognitionRef.current = null; + setIsDictating(false); + onChange(valueRef.current.replace(/\u200b$/, '')); + }; + speechRecognitionRef.current = rec; + try { + rec.start(); + setIsDictating(true); + } catch { + speechRecognitionRef.current = null; + setVoiceError(t('dictationNotSupported')); + } + }, [isDictating, onChange, stopDictation, t]); + useEffect(() => { return () => { const mr = mediaRecorderRef.current; @@ -690,6 +808,15 @@ export function HumanChatPanelChatBar({ track.stop(); } } + const r = speechRecognitionRef.current; + if (r) { + try { + r.abort(); + } catch { + // ignore + } + speechRecognitionRef.current = null; + } }; }, []); @@ -929,13 +1056,22 @@ export function HumanChatPanelChatBar({ playLabel={t('videoPreviewPlay')} spoilerBadge={t('draftSpoilerTag')} /> + ) : att.kind === 'audio' ? ( +
+ + + {t('voiceMessage')} + +
) : (
)}
- {(att.kind === 'image' || att.kind === 'video') && ( + {(att.kind === 'image' || + att.kind === 'video' || + att.kind === 'audio') && ( - + + + + + + { + requestAnimationFrame(() => startDictation()); + }} + > + + {t('composerDictateMessage')} + + { + requestAnimationFrame( + () => void startVoiceRecordingAsAttachment(), + ); + }} + > + + {t('composerSendAudioMessage')} + + {(isVoiceRecording || isDictating) && ( + <> + + { + if (isVoiceRecording) stopVoiceRecording(); + if (isDictating) stopDictation(); + }} + > + {t('composerMicStop')} + + + )} + +
+ +
+

+ {media.filename?.replace(/^voice-message-\d+\./, '') || + t('voiceMessage')} +

+

+ {durationLabel} +

+
+
+ ); } /** Inline Matrix video (`m.file` + video/* or known extension). */ @@ -975,9 +1072,8 @@ export function HumanChatPanelMessageBubble({ data-testid="chat-message-media-bundle" > {(() => { - const { images, videos, otherFiles } = partitionBundleSlots( - message.mediaSlots, - ); + const { images, audios, videos, otherFiles } = + partitionBundleSlots(message.mediaSlots); const gridClass = bundleImageGridClass(images.length); return ( <> @@ -997,6 +1093,17 @@ export function HumanChatPanelMessageBubble({ ))}
)} + {audios.length > 0 && ( +
+ {audios.map((slot, idx) => ( + + ))} +
+ )} {videos.length > 0 && (
{videos.map((slot, idx) => ( @@ -1100,6 +1207,13 @@ export function HumanChatPanelMessageBubble({
)} + {!message.mediaSlots?.length && + message.media && + (message.media.msgtype === 'm.audio' || + isChatPanelAudioFile(message.media)) && ( + + )} + {!message.mediaSlots?.length && message.media && message.media.msgtype === 'm.file' && @@ -1110,7 +1224,8 @@ export function HumanChatPanelMessageBubble({ {!message.mediaSlots?.length && message.media && message.media.msgtype === 'm.file' && - !isChatPanelVideoFile(message.media) && ( + !isChatPanelVideoFile(message.media) && + !isChatPanelAudioFile(message.media) && (
({ file: a.file, - kind: a.kind === 'video' ? 'file' : a.kind, + kind: + a.kind === 'image' + ? 'image' + : a.kind === 'audio' + ? 'audio' + : 'file', spoiler: a.spoiler, })), onUploadProgress: ({ completed, total }) => { From c275c805745bcb4c7a449a668b7f766883430ddb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 01:03:02 +0000 Subject: [PATCH 11/60] feat(i18n): add HumanChatPanel strings for dictation and voice UI Co-authored-by: webguru-hypha --- packages/i18n/src/messages/de.json | 10 ++++++++++ packages/i18n/src/messages/en.json | 10 ++++++++++ packages/i18n/src/messages/es.json | 10 ++++++++++ packages/i18n/src/messages/fr.json | 10 ++++++++++ packages/i18n/src/messages/pt.json | 10 ++++++++++ 5 files changed, 50 insertions(+) diff --git a/packages/i18n/src/messages/de.json b/packages/i18n/src/messages/de.json index 4f32021006..85a4d5265c 100644 --- a/packages/i18n/src/messages/de.json +++ b/packages/i18n/src/messages/de.json @@ -1659,6 +1659,16 @@ "mentionNotAvailable": "Erwähnungen (demnächst)", "composerVoiceRecord": "Sprachnachricht aufnehmen", "composerVoiceStop": "Aufnahme beenden", + "composerMicMenu": "Stimme und Diktat", + "composerDictateMessage": "Nachricht diktieren", + "composerSendAudioMessage": "Sprachnachricht senden", + "composerMicStop": "Stopp", + "dictationNotSupported": "Diktat wird in diesem Browser nicht unterstützt.", + "dictationError": "Diktat wurde wegen eines Fehlers beendet. Bitte erneut versuchen.", + "voiceMessage": "Sprachnachricht", + "voiceMessageShort": "Sprache", + "voicePlay": "Abspielen", + "voicePause": "Pause", "voiceRecordingNotSupported": "Sprachaufnahmen werden in diesem Browser nicht unterstützt.", "voiceMicPermissionDenied": "Mikrofonzugriff verweigert. Bitte erlauben, um eine Sprachnachricht aufzunehmen.", "attachFile": "Datei anhängen", diff --git a/packages/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json index a0d5ba79ef..f967fcac1a 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -1660,6 +1660,16 @@ "mentionNotAvailable": "Mentions (coming soon)", "composerVoiceRecord": "Record voice message", "composerVoiceStop": "Stop recording", + "composerMicMenu": "Voice and dictation", + "composerDictateMessage": "Dictate message", + "composerSendAudioMessage": "Send audio message", + "composerMicStop": "Stop", + "dictationNotSupported": "Dictation is not supported in this browser.", + "dictationError": "Dictation stopped due to an error. Try again.", + "voiceMessage": "Voice message", + "voiceMessageShort": "Voice", + "voicePlay": "Play", + "voicePause": "Pause", "voiceRecordingNotSupported": "Voice recording is not supported in this browser.", "voiceMicPermissionDenied": "Microphone access was denied. Allow the mic to record a voice message.", "attachFile": "Attach file", diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json index 53a04522f4..064eee2a48 100644 --- a/packages/i18n/src/messages/es.json +++ b/packages/i18n/src/messages/es.json @@ -1659,6 +1659,16 @@ "mentionNotAvailable": "Menciones (próximamente)", "composerVoiceRecord": "Grabar mensaje de voz", "composerVoiceStop": "Detener grabación", + "composerMicMenu": "Voz y dictado", + "composerDictateMessage": "Dictar mensaje", + "composerSendAudioMessage": "Enviar mensaje de audio", + "composerMicStop": "Detener", + "dictationNotSupported": "El dictado no es compatible con este navegador.", + "dictationError": "El dictado se detuvo por un error. Inténtalo de nuevo.", + "voiceMessage": "Mensaje de voz", + "voiceMessageShort": "Voz", + "voicePlay": "Reproducir", + "voicePause": "Pausa", "voiceRecordingNotSupported": "La grabación de voz no es compatible con este navegador.", "voiceMicPermissionDenied": "Se denegó el acceso al micrófono. Permítelo para grabar un mensaje de voz.", "attachFile": "Adjuntar archivo", diff --git a/packages/i18n/src/messages/fr.json b/packages/i18n/src/messages/fr.json index b5b29e299a..3dfa22f080 100644 --- a/packages/i18n/src/messages/fr.json +++ b/packages/i18n/src/messages/fr.json @@ -1659,6 +1659,16 @@ "mentionNotAvailable": "Mentions (bientôt)", "composerVoiceRecord": "Enregistrer un message vocal", "composerVoiceStop": "Arrêter l’enregistrement", + "composerMicMenu": "Voix et dictée", + "composerDictateMessage": "Dicter un message", + "composerSendAudioMessage": "Envoyer un message audio", + "composerMicStop": "Arrêter", + "dictationNotSupported": "La dictée n’est pas prise en charge dans ce navigateur.", + "dictationError": "La dictée s’est arrêtée à cause d’une erreur. Réessayez.", + "voiceMessage": "Message vocal", + "voiceMessageShort": "Vocal", + "voicePlay": "Lecture", + "voicePause": "Pause", "voiceRecordingNotSupported": "L’enregistrement vocal n’est pas pris en charge dans ce navigateur.", "voiceMicPermissionDenied": "Accès au micro refusé. Autorisez-le pour enregistrer un message vocal.", "attachFile": "Joindre un fichier", diff --git a/packages/i18n/src/messages/pt.json b/packages/i18n/src/messages/pt.json index ae183f3b51..c3b147b1e3 100644 --- a/packages/i18n/src/messages/pt.json +++ b/packages/i18n/src/messages/pt.json @@ -1659,6 +1659,16 @@ "mentionNotAvailable": "Menções (em breve)", "composerVoiceRecord": "Gravar mensagem de voz", "composerVoiceStop": "Parar gravação", + "composerMicMenu": "Voz e ditado", + "composerDictateMessage": "Ditar mensagem", + "composerSendAudioMessage": "Enviar mensagem de áudio", + "composerMicStop": "Parar", + "dictationNotSupported": "O ditado não é suportado neste navegador.", + "dictationError": "O ditado parou devido a um erro. Tente novamente.", + "voiceMessage": "Mensagem de voz", + "voiceMessageShort": "Voz", + "voicePlay": "Reproduzir", + "voicePause": "Pausa", "voiceRecordingNotSupported": "Gravação de voz não é suportada neste navegador.", "voiceMicPermissionDenied": "Acesso ao microfone negado. Permita para gravar uma mensagem de voz.", "attachFile": "Anexar arquivo", From e48dd9503cb88db75f3001e18d32ec6ec0ccefab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 01:08:20 +0000 Subject: [PATCH 12/60] fix(chat): YouTube-style inline video playback and draft play overlay Timeline videos use poster frame, gradient title, red play button, then native controls after play; unmute strip while playing. Draft video preview raises play overlay z-index so the button receives clicks. Co-authored-by: webguru-hypha --- .../human-chat-panel-chat-bar.tsx | 10 +- .../human-chat-panel-message-bubble.tsx | 142 ++++++++++++++---- packages/i18n/src/messages/de.json | 4 +- packages/i18n/src/messages/en.json | 4 +- packages/i18n/src/messages/es.json | 4 +- packages/i18n/src/messages/fr.json | 4 +- packages/i18n/src/messages/pt.json | 4 +- 7 files changed, 128 insertions(+), 44 deletions(-) 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 c615ab8475..d652178100 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 @@ -198,15 +198,13 @@ function ChatDraftVideoPreview({
)} {!playing && !spoiler && ( -
+
)} diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx index 79ed0b5009..1de4218c9c 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx @@ -426,7 +426,7 @@ function TimelineVoiceSlot({ ); } -/** Inline Matrix video (`m.file` + video/* or known extension). */ +/** Inline Matrix video (`m.file` + video/*): poster frame + large play, then native controls while playing. */ function TimelineMatrixVideo({ media, t, @@ -437,15 +437,17 @@ function TimelineMatrixVideo({ const { client } = useMatrix(); const { download: src } = useMxcUrls(client, media.mxcUrl); const [spoilerRevealed, setSpoilerRevealed] = useState(false); + const [playing, setPlaying] = useState(false); + const [muted, setMuted] = useState(true); const videoRef = useRef(null); - const boxStyle = + const knownAspect = media.mediaInfo?.w && media.mediaInfo?.h && media.mediaInfo.w > 0 && media.mediaInfo.h > 0 - ? { aspectRatio: `${media.mediaInfo.w} / ${media.mediaInfo.h}` } - : { minHeight: '200px' }; + ? `${media.mediaInfo.w} / ${media.mediaInfo.h}` + : null; if (!src) { return ( @@ -455,44 +457,118 @@ function TimelineMatrixVideo({ ); } + const spoilerActive = media.spoiler && !spoilerRevealed; + const showYoutubeChrome = !spoilerActive && !playing; + return (
-
); } diff --git a/packages/i18n/src/messages/de.json b/packages/i18n/src/messages/de.json index 85a4d5265c..88986678e9 100644 --- a/packages/i18n/src/messages/de.json +++ b/packages/i18n/src/messages/de.json @@ -1673,7 +1673,9 @@ "voiceMicPermissionDenied": "Mikrofonzugriff verweigert. Bitte erlauben, um eine Sprachnachricht aufzunehmen.", "attachFile": "Datei anhängen", "attachImage": "Bild anhängen", - "videoPreviewPlay": "Videovorschau abspielen", + "videoPreviewPlay": "Video abspielen", + "videoMute": "Stumm", + "videoUnmute": "Ton an", "attachmentRemove": "Anhang entfernen", "attachmentSpoiler": "Als Spoiler markieren", "attachmentSpoilerRemove": "Spoiler entfernen", diff --git a/packages/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json index f967fcac1a..acff784160 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -1674,7 +1674,9 @@ "voiceMicPermissionDenied": "Microphone access was denied. Allow the mic to record a voice message.", "attachFile": "Attach file", "attachImage": "Attach image", - "videoPreviewPlay": "Play video preview", + "videoPreviewPlay": "Play video", + "videoMute": "Mute", + "videoUnmute": "Unmute", "attachmentRemove": "Remove attachment", "attachmentSpoiler": "Mark as spoiler", "attachmentSpoilerRemove": "Remove spoiler", diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json index 064eee2a48..43b7f5579c 100644 --- a/packages/i18n/src/messages/es.json +++ b/packages/i18n/src/messages/es.json @@ -1673,7 +1673,9 @@ "voiceMicPermissionDenied": "Se denegó el acceso al micrófono. Permítelo para grabar un mensaje de voz.", "attachFile": "Adjuntar archivo", "attachImage": "Adjuntar imagen", - "videoPreviewPlay": "Reproducir vista previa del vídeo", + "videoPreviewPlay": "Reproducir vídeo", + "videoMute": "Silenciar", + "videoUnmute": "Activar sonido", "attachmentRemove": "Quitar adjunto", "attachmentSpoiler": "Marcar como spoiler", "attachmentSpoilerRemove": "Quitar spoiler", diff --git a/packages/i18n/src/messages/fr.json b/packages/i18n/src/messages/fr.json index 3dfa22f080..e2b5d8d357 100644 --- a/packages/i18n/src/messages/fr.json +++ b/packages/i18n/src/messages/fr.json @@ -1673,7 +1673,9 @@ "voiceMicPermissionDenied": "Accès au micro refusé. Autorisez-le pour enregistrer un message vocal.", "attachFile": "Joindre un fichier", "attachImage": "Joindre une image", - "videoPreviewPlay": "Lire l’aperçu vidéo", + "videoPreviewPlay": "Lire la vidéo", + "videoMute": "Couper le son", + "videoUnmute": "Activer le son", "attachmentRemove": "Retirer la pièce jointe", "attachmentSpoiler": "Marquer comme spoiler", "attachmentSpoilerRemove": "Retirer le spoiler", diff --git a/packages/i18n/src/messages/pt.json b/packages/i18n/src/messages/pt.json index c3b147b1e3..06a0805dde 100644 --- a/packages/i18n/src/messages/pt.json +++ b/packages/i18n/src/messages/pt.json @@ -1673,7 +1673,9 @@ "voiceMicPermissionDenied": "Acesso ao microfone negado. Permita para gravar uma mensagem de voz.", "attachFile": "Anexar arquivo", "attachImage": "Anexar imagem", - "videoPreviewPlay": "Reproduzir pré-visualização do vídeo", + "videoPreviewPlay": "Reproduzir vídeo", + "videoMute": "Silenciar", + "videoUnmute": "Ativar som", "attachmentRemove": "Remover anexo", "attachmentSpoiler": "Marcar como spoiler", "attachmentSpoilerRemove": "Remover spoiler", From fbd4285089555c9bd526eba1f2711027c3d565bb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 01:31:22 +0000 Subject: [PATCH 13/60] feat(matrix): edit media messages via m.replace with slots Extend editRoomMessage with existingMediaSlots and newAttachments; rebuild Hypha media/bundle content and send RelationType.Replace for m.file/m.image/m.audio roots while reusing MXC for kept slots. Co-authored-by: webguru-hypha --- .../client/providers/matrix-provider.tsx | 269 +++++++++++++++++- 1 file changed, 263 insertions(+), 6 deletions(-) diff --git a/packages/core/src/matrix/client/providers/matrix-provider.tsx b/packages/core/src/matrix/client/providers/matrix-provider.tsx index b00fc2be0e..714ddabd61 100644 --- a/packages/core/src/matrix/client/providers/matrix-provider.tsx +++ b/packages/core/src/matrix/client/providers/matrix-provider.tsx @@ -5,7 +5,7 @@ import * as MatrixSdk from 'matrix-js-sdk'; import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events'; import { useAuthentication } from '@hypha-platform/authentication'; import { MatrixTokenData, useMatrixToken } from '../hooks'; -import { Message } from '../../types'; +import type { Message, MessageMediaInfo } from '../../types'; import { attachReactionsToMessage, isValidReactionKey } from '../../reactions'; import { buildRichReplyMatrixContent, @@ -47,11 +47,27 @@ interface SendMessageInput { onUploadProgress?: (p: SendMessageUploadProgress) => void; } +/** Existing attachment slot when editing a media `m.room.message` (mxc stays on server). */ +export type EditRoomMessageExistingSlot = { + mxcUrl: string; + msgtype: 'm.file' | 'm.image' | 'm.audio'; + filename?: string; + mediaInfo?: MessageMediaInfo; + spoiler?: boolean; +}; + export interface EditRoomMessageInput { roomId: string; /** Timeline id of the `m.room.message` to replace (not an edit event id). */ targetEventId: string; message: string; + /** + * When editing a media message: ordered slots to keep (first = root event). + * New files are uploaded and appended after these (see `newAttachments`). + */ + existingMediaSlots?: EditRoomMessageExistingSlot[]; + /** New files to append when editing a media message (uploaded after `existingMediaSlots`). */ + newAttachments?: SendAttachmentInput[]; } export interface RedactRoomEventInput { @@ -705,11 +721,19 @@ export const MatrixProvider: React.FC = ({ children }) => { ); const editRoomMessage = React.useCallback( - async ({ roomId, targetEventId, message }: EditRoomMessageInput) => { + async ({ + roomId, + targetEventId, + message, + existingMediaSlots, + newAttachments, + }: EditRoomMessageInput) => { if (!client) { throw new Error('Client should be specified'); } - if (!message.trim()) { + const trimmed = message.trim(); + const newList = newAttachments?.length ? newAttachments : []; + if (!trimmed && newList.length === 0 && !existingMediaSlots?.length) { return; } if (!roomId?.trim() || !targetEventId?.trim()) { @@ -750,15 +774,248 @@ export const MatrixProvider: React.FC = ({ children }) => { const originalContent = targetEv.getContent() as { msgtype?: string; body?: string; + url?: string; + filename?: string; + info?: Record; + [key: string]: unknown; }; - if (originalContent.msgtype !== MsgType.Text) { - throw new Error('Only text messages can be edited in this client'); - } + const origMsgtype = originalContent.msgtype; const replyToId = targetEv.getWireContent()?.['m.relates_to']?.[ 'm.in_reply_to' ]?.event_id as string | undefined; + const isMediaEdit = + Array.isArray(existingMediaSlots) && + existingMediaSlots.length > 0 && + (origMsgtype === MsgType.File || + origMsgtype === MsgType.Image || + origMsgtype === MsgType.Audio); + + if (isMediaEdit) { + const slots = existingMediaSlots!; + const prepareMediaPayload = async ( + att: SendAttachmentInput, + ): Promise => { + const abortController = new AbortController(); + const timeoutMs = MATRIX_UPLOAD_TIMEOUT_MS; + const timeoutId = setTimeout(() => { + abortController.abort(); + }, timeoutMs); + let upload: { content_uri: string }; + try { + upload = await client.uploadContent(att.file, { + name: att.file.name, + type: att.file.type || undefined, + abortController, + }); + } catch (e) { + if (abortController.signal.aborted) { + throw new MatrixUploadTimeoutError( + `Matrix media upload timed out after ${timeoutMs}ms`, + ); + } + throw e; + } finally { + clearTimeout(timeoutId); + } + const mxc = upload.content_uri; + const msgtype = + att.kind === 'image' + ? MsgType.Image + : att.kind === 'audio' + ? MsgType.Audio + : MsgType.File; + let info: { + mimetype?: string; + size?: number; + w?: number; + h?: number; + duration?: number; + } = { + mimetype: att.file.type || undefined, + size: att.file.size, + }; + if (msgtype === MsgType.Image) { + const dims = await loadImageDimensions(att.file); + if (dims) { + info = { ...info, w: dims.w, h: dims.h }; + } + } else if (msgtype === MsgType.Audio) { + const dur = await loadAudioDurationMs(att.file); + if (dur != null) { + info = { ...info, duration: dur }; + } + } + const caption = att.file.name; + const base: HyphaMediaEventContent = { + msgtype, + body: caption, + filename: att.file.name, + url: mxc, + info, + } as HyphaMediaEventContent; + if (att.spoiler) { + base[HYPHA_SPOILER_FIELD] = true; + } + return base; + }; + + const slotToPayload = ( + slot: EditRoomMessageExistingSlot, + ): HyphaMediaEventContent => { + const base: HyphaMediaEventContent = { + msgtype: slot.msgtype, + body: slot.filename ?? 'attachment', + filename: slot.filename, + url: slot.mxcUrl, + info: slot.mediaInfo, + } as HyphaMediaEventContent; + if (slot.spoiler) { + base[HYPHA_SPOILER_FIELD] = true; + } + return base; + }; + + const rootFromSlot = slotToPayload(slots[0]!); + const restSlots = slots.slice(1).map(slotToPayload); + const uploaded: HyphaMediaEventContent[] = []; + for (let i = 0; i < newList.length; i++) { + if (i > 0) { + await delay(MATRIX_UPLOAD_STAGGER_MS); + } + let attempt = 0; + while (true) { + try { + uploaded.push(await prepareMediaPayload(newList[i]!)); + break; + } catch (e) { + if ( + !isMatrixRateLimitedError(e) || + attempt >= MATRIX_UPLOAD_RATE_LIMIT_MAX_ATTEMPTS - 1 + ) { + throw e; + } + await delay(matrixRateLimitBackoffMs(e, attempt)); + attempt += 1; + } + } + } + + const allAfterRoot = [...restSlots, ...uploaded]; + let combined: HyphaMediaEventContent; + if (allAfterRoot.length === 0) { + combined = { ...rootFromSlot }; + } else { + const bundleItems: HyphaMediaBundleItemWire[] = allAfterRoot.map( + (item) => { + const spoiler = item[HYPHA_SPOILER_FIELD] === true; + const c = item as HyphaMediaBundleItemWire; + const { msgtype, body, filename, url, info } = c; + return { + msgtype, + body, + filename, + url, + info, + ...(spoiler ? { [HYPHA_SPOILER_FIELD]: true } : {}), + }; + }, + ); + combined = { + ...rootFromSlot, + [HYPHA_MEDIA_BUNDLE_FIELD]: bundleItems, + }; + } + + if (trimmed) { + if (replyToId?.trim()) { + const { + eventId: resolvedReplyTargetId, + sender: replyTargetSender, + body: targetBody, + } = await resolveReplyTargetForSend(client, roomId, replyToId); + const rich = buildRichReplyMatrixContent( + replyTargetSender, + targetBody, + trimmed, + ); + combined = { + ...combined, + body: rich.body, + format: MATRIX_CUSTOM_HTML_FORMAT, + formatted_body: rich.formatted_body, + 'm.relates_to': { + 'm.in_reply_to': { + event_id: resolvedReplyTargetId, + }, + }, + }; + } else { + const textExtras = + matrixTextEventContentWithOptionalFormatting(trimmed); + combined = { + ...combined, + ...textExtras, + body: trimmed, + } as HyphaMediaEventContent; + } + } else if (replyToId?.trim()) { + const { + eventId: resolvedReplyTargetId, + sender: replyTargetSender, + body: targetBody, + } = await resolveReplyTargetForSend(client, roomId, replyToId); + const quoted = buildRichReplyMatrixContent( + replyTargetSender, + targetBody, + ' ', + ); + combined = { + ...combined, + body: quoted.body, + format: MATRIX_CUSTOM_HTML_FORMAT, + formatted_body: quoted.formatted_body, + 'm.relates_to': { + 'm.in_reply_to': { + event_id: resolvedReplyTargetId, + }, + }, + }; + } else { + const fn = + (combined.filename as string | undefined) ?? + (combined.body as string | undefined) ?? + 'attachment'; + combined = { + ...combined, + body: fn, + }; + } + + const newBody = + 'body' in combined ? String(combined.body) : trimmed || 'attachment'; + const fallbackBody = `* ${newBody}`; + + await client.sendEvent(roomId, EventType.RoomMessage, { + ...combined, + body: fallbackBody, + 'm.new_content': combined, + 'm.relates_to': { + rel_type: MatrixSdk.RelationType.Replace, + event_id: resolvedTargetId, + }, + } as RoomMessageEventContent); + return; + } + + if (origMsgtype !== MsgType.Text) { + throw new Error('Only text messages can be edited in this client'); + } + if (!trimmed) { + return; + } + let newContentPayload: RoomMessageEventContent; if (replyToId?.trim()) { From 417ba6bc0136bfbcbf9fc53f2be4b121d45d30f8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 01:31:41 +0000 Subject: [PATCH 14/60] feat(chat): edit captions and remove attachments on media messages Enable edit for user messages with media; load MXC thumbnails into draft strip with editSlot metadata; require one attachment; append new uploads via editRoomMessage newAttachments. Co-authored-by: webguru-hypha --- .../human-chat-panel-chat-bar.tsx | 32 +++- .../human-chat-panel-messages.tsx | 2 - .../epics/src/common/human-right-panel.tsx | 158 ++++++++++++++++-- packages/i18n/src/messages/de.json | 1 + packages/i18n/src/messages/en.json | 1 + packages/i18n/src/messages/es.json | 1 + packages/i18n/src/messages/fr.json | 1 + packages/i18n/src/messages/pt.json | 1 + 8 files changed, 178 insertions(+), 19 deletions(-) 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 d652178100..63522412c9 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 @@ -45,6 +45,7 @@ import { } from './emoji-mart-index'; import { getTextareaSelectionCenter } from './textarea-caret-position'; import { + type ChatPanelAttachmentMedia, looksLikeAudioMimeOrName, looksLikeVideoMimeOrName, } from './chat-panel-media-types'; @@ -79,18 +80,31 @@ type EditPreview = { onDismiss: () => void; }; +/** Existing server slot when editing a media message (no new upload). */ +export type ChatDraftEditSlot = { + mxcUrl: string; + msgtype: ChatPanelAttachmentMedia['msgtype']; + filename?: string; + mediaInfo?: ChatPanelAttachmentMedia['mediaInfo']; + spoiler?: boolean; +}; + export type ChatDraftAttachment = { id: string; file: File; kind: 'file' | 'image' | 'video' | 'audio'; previewUrl: string; spoiler: boolean; + /** When set, this row is an existing Matrix attachment (edit mode). */ + editSlot?: ChatDraftEditSlot; }; type HumanChatPanelChatBarProps = { value: string; onChange: (value: string) => void; onSend: () => void; + /** Editing a media message: keep at least one attachment row. */ + editMediaMode?: boolean; placeholder?: string; channelName?: string; /** Rich reply: composer preview above the textarea */ @@ -253,6 +267,7 @@ export function HumanChatPanelChatBar({ value, onChange, onSend, + editMediaMode = false, placeholder, channelName, replyPreview, @@ -545,7 +560,9 @@ export function HumanChatPanelChatBar({ } }; - const canSend = value.trim().length > 0 || draftAttachments.length > 0; + const canSend = + (value.trim().length > 0 || draftAttachments.length > 0) && + (!editMediaMode || draftAttachments.length > 0); const defaultPlaceholder = channelName ? t('placeholderChannel', { channel: channelName }) @@ -592,7 +609,9 @@ export function HumanChatPanelChatBar({ (id: string) => { if (!onDraftAttachmentsChange) return; const att = draftAttachments.find((a) => a.id === id); - if (att) URL.revokeObjectURL(att.previewUrl); + if (att?.previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(att.previewUrl); + } onDraftAttachmentsChange(draftAttachments.filter((a) => a.id !== id)); }, [draftAttachments, onDraftAttachmentsChange], @@ -1102,7 +1121,8 @@ export function HumanChatPanelChatBar({ )}

- {att.file.name} + {att.editSlot?.filename ?? att.file.name}

- {formatFileSize(att.file.size)} + {formatFileSize( + att.editSlot?.mediaInfo?.size ?? att.file.size, + )}

))} diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx index d126dbd3e9..ddcc1df868 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx @@ -181,8 +181,6 @@ export function HumanChatPanelMessages({ canInteract && onEditMessage && msg.role === 'user' && - !msg.media && - !msg.mediaSlots?.length && !msg.sendPending ? () => onEditMessage(msg.id) : undefined diff --git a/packages/epics/src/common/human-right-panel.tsx b/packages/epics/src/common/human-right-panel.tsx index 98ba4c770b..3c0c91d1f2 100644 --- a/packages/epics/src/common/human-right-panel.tsx +++ b/packages/epics/src/common/human-right-panel.tsx @@ -26,6 +26,10 @@ import { isMatrixRateLimitedError, type MessageReaction, } from '@hypha-platform/core/client'; +import { + isChatPanelAudioFile, + isChatPanelVideoFile, +} from './human-chat-panel/chat-panel-media-types'; import { UseMembers } from '../spaces'; import { @@ -42,7 +46,9 @@ import { useHumanChatPanel } from './human-chat-panel-context'; function disposeDraftAttachmentUrls(drafts: ChatDraftAttachment[]) { for (const a of drafts) { - URL.revokeObjectURL(a.previewUrl); + if (a.previewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(a.previewUrl); + } } } @@ -95,6 +101,8 @@ type ReplyDraft = { type EditDraft = { messageId: string; excerpt: string; + /** Editing a bundled / media Matrix message (caption + attachments). */ + editMediaMode?: boolean; }; const ROOM_STORAGE_KEY = 'hypha-chat-room-'; @@ -221,6 +229,73 @@ function toUIMessage( }; } +function dummyEditFile(filename: string, mime?: string): File { + return new File([], filename || 'attachment', { + type: mime || 'application/octet-stream', + }); +} + +function buildEditMediaDraftAttachments( + m: UIMessage, + previewForMxc: (mxc: string) => string | null, +): ChatDraftAttachment[] { + const slots: NonNullable[] = []; + if (m.media?.mxcUrl) { + slots.push(m.media); + } + if (m.mediaSlots && m.mediaSlots.length > 1) { + for (const s of m.mediaSlots.slice(1)) { + if (s.mxcUrl) slots.push(s); + } + } + const out: ChatDraftAttachment[] = []; + for (const slot of slots) { + const mxc = slot.mxcUrl!; + const thumb = previewForMxc(mxc) ?? EDIT_IMAGE_PLACEHOLDER; + const isVid = slot.msgtype === 'm.file' && isChatPanelVideoFile(slot); + const isAud = isChatPanelAudioFile(slot); + const kind: ChatDraftAttachment['kind'] = + slot.msgtype === 'm.image' + ? 'image' + : isAud + ? 'audio' + : isVid + ? 'video' + : 'file'; + out.push({ + id: newChatDraftAttachmentId(), + file: dummyEditFile( + slot.filename ?? 'attachment', + slot.mediaInfo?.mimetype, + ), + kind, + previewUrl: thumb || mxc, + spoiler: Boolean(slot.spoiler), + editSlot: { + mxcUrl: mxc, + msgtype: slot.msgtype, + filename: slot.filename, + mediaInfo: slot.mediaInfo, + spoiler: slot.spoiler, + }, + }); + } + return out; +} + +const EDIT_IMAGE_PLACEHOLDER = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + +function newChatDraftAttachmentId(): string { + if ( + typeof globalThis.crypto !== 'undefined' && + typeof globalThis.crypto.randomUUID === 'function' + ) { + return globalThis.crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + function getMessagePlainText(m: UIMessage): string { const textParts = m.parts?.filter( @@ -813,9 +888,6 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { (messageId: string) => { const target = messages.find((m) => m.id === messageId); if (!target || target.role !== 'user') return; - if (target.media || (target.mediaSlots && target.mediaSlots.length > 0)) { - return; - } const textParts = target.parts?.filter( (p): p is { type: 'text'; text: string } => p.type === 'text', @@ -825,6 +897,35 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { ); setReplyDraft(null); disposeDraftAttachmentUrls(draftAttachmentsRef.current); + setDraftAttachments([]); + + const hasMedia = + Boolean(target.media?.mxcUrl) || + (Boolean(target.mediaSlots?.length) && + (target.mediaSlots?.length ?? 0) > 1); + + if (hasMedia && !client) { + return; + } + + if (hasMedia && client) { + const previewForMxc = (mxc: string) => + mxc.startsWith('mxc://') + ? client.mxcUrlToHttp(mxc, 400, 300, 'scale', true, false, false) ?? + null + : null; + setDraftAttachments( + buildEditMediaDraftAttachments(target, previewForMxc), + ); + setEditDraft({ + messageId, + excerpt: firstLineForReplyPreview(plain), + editMediaMode: true, + }); + setInput(plain); + return; + } + setDraftAttachments([]); setEditDraft({ messageId, @@ -832,7 +933,7 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { }); setInput(plain); }, - [messages], + [messages, client], ); const handleDeleteMessage = useCallback( @@ -884,14 +985,44 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { } try { if (editTargetEventId) { - if (savedAttachments.length > 0) { - throw new Error(t('editAttachmentsNotSupported')); + if (savedEditDraft?.editMediaMode) { + const slots = savedAttachments + .map((a) => a.editSlot) + .filter((s): s is NonNullable => + Boolean(s), + ); + if (slots.length === 0) { + throw new Error(t('editMediaRequiresAttachment')); + } + const newFiles = savedAttachments + .filter((a) => !a.editSlot) + .map((a) => ({ + file: a.file, + kind: + a.kind === 'image' + ? 'image' + : a.kind === 'audio' + ? 'audio' + : 'file', + spoiler: a.spoiler, + })); + await matrixRef.current.editRoomMessage({ + roomId, + targetEventId: editTargetEventId, + message: text, + existingMediaSlots: slots, + ...(newFiles.length > 0 ? { newAttachments: newFiles } : {}), + }); + } else { + if (savedAttachments.length > 0) { + throw new Error(t('editAttachmentsNotSupported')); + } + await matrixRef.current.editRoomMessage({ + roomId, + targetEventId: editTargetEventId, + message: text, + }); } - await matrixRef.current.editRoomMessage({ - roomId, - targetEventId: editTargetEventId, - message: text, - }); } else { await matrixRef.current.sendMessage({ roomId, @@ -1076,10 +1207,13 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { onDismiss: () => { setEditDraft(null); setInput(''); + disposeDraftAttachmentUrls(draftAttachmentsRef.current); + setDraftAttachments([]); }, } : undefined } + editMediaMode={Boolean(editDraft?.editMediaMode)} /> )} diff --git a/packages/i18n/src/messages/de.json b/packages/i18n/src/messages/de.json index 88986678e9..0c5e0800d8 100644 --- a/packages/i18n/src/messages/de.json +++ b/packages/i18n/src/messages/de.json @@ -1635,6 +1635,7 @@ "editingMessage": "Nachricht bearbeiten", "editDismiss": "Bearbeitung abbrechen", "editAttachmentsNotSupported": "Anhänge werden beim Bearbeiten einer Nachricht nicht unterstützt", + "editMediaRequiresAttachment": "Behalten Sie mindestens einen Anhang beim Bearbeiten dieser Nachricht.", "contextReactWith": "Reagieren mit {emoji}", "contextEditMessage": "Nachricht bearbeiten", "contextReply": "Antworten", diff --git a/packages/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json index acff784160..96657cb9bb 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -1636,6 +1636,7 @@ "editingMessage": "Editing message", "editDismiss": "Cancel edit", "editAttachmentsNotSupported": "Attachments are not supported when editing a message", + "editMediaRequiresAttachment": "Keep at least one attachment when editing this message.", "contextReactWith": "React with {emoji}", "contextEditMessage": "Edit message", "contextReply": "Reply", diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json index 43b7f5579c..470ec16650 100644 --- a/packages/i18n/src/messages/es.json +++ b/packages/i18n/src/messages/es.json @@ -1635,6 +1635,7 @@ "editingMessage": "Editando mensaje", "editDismiss": "Cancelar edición", "editAttachmentsNotSupported": "No se admiten archivos adjuntos al editar un mensaje", + "editMediaRequiresAttachment": "Conserva al menos un adjunto al editar este mensaje.", "contextReactWith": "Reaccionar con {emoji}", "contextEditMessage": "Editar mensaje", "contextReply": "Responder", diff --git a/packages/i18n/src/messages/fr.json b/packages/i18n/src/messages/fr.json index e2b5d8d357..8799e72daf 100644 --- a/packages/i18n/src/messages/fr.json +++ b/packages/i18n/src/messages/fr.json @@ -1635,6 +1635,7 @@ "editingMessage": "Modification du message", "editDismiss": "Annuler la modification", "editAttachmentsNotSupported": "Les pièces jointes ne sont pas prises en charge lors de la modification d’un message", + "editMediaRequiresAttachment": "Conservez au moins une pièce jointe lors de la modification de ce message.", "contextReactWith": "Réagir avec {emoji}", "contextEditMessage": "Modifier le message", "contextReply": "Répondre", diff --git a/packages/i18n/src/messages/pt.json b/packages/i18n/src/messages/pt.json index 06a0805dde..1ca6efad6d 100644 --- a/packages/i18n/src/messages/pt.json +++ b/packages/i18n/src/messages/pt.json @@ -1635,6 +1635,7 @@ "editingMessage": "Editando mensagem", "editDismiss": "Cancelar edição", "editAttachmentsNotSupported": "Anexos não são suportados ao editar uma mensagem", + "editMediaRequiresAttachment": "Mantenha pelo menos um anexo ao editar esta mensagem.", "contextReactWith": "Reagir com {emoji}", "contextEditMessage": "Editar mensagem", "contextReply": "Responder", From ce431d3f706e57516f3a75be30e26c0c73122a03 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 01:56:00 +0000 Subject: [PATCH 15/60] feat(ui): add squircle shape and chat avatar sizes Extend PersonAvatar with optional squircle radius (~Discord) and dedicated timeline/reply dimensions for human chat. Co-authored-by: webguru-hypha --- .../epics/src/people/components/person-avatar.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/epics/src/people/components/person-avatar.tsx b/packages/epics/src/people/components/person-avatar.tsx index feaae4a7b1..3699162ae6 100644 --- a/packages/epics/src/people/components/person-avatar.tsx +++ b/packages/epics/src/people/components/person-avatar.tsx @@ -2,13 +2,17 @@ import { Avatar, AvatarImage, AvatarFallback } from '@hypha-platform/ui'; import { UserIcon } from 'lucide-react'; import { Skeleton } from '@hypha-platform/ui'; -type AvatarSize = 'xs' | 'sm' | 'md' | 'lg'; +type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'chat' | 'reply'; const sizeMap: Record = { xs: { avatar: 'w-[12px] h-[12px]', skeleton: '12px' }, sm: { avatar: 'w-[24px] h-[24px]', skeleton: '24px' }, md: { avatar: 'w-[32px] h-[32px]', skeleton: '32px' }, lg: { avatar: 'w-[64px] h-[64px]', skeleton: '64px' }, + /** Human chat timeline (~Discord proportions): main message */ + chat: { avatar: 'h-10 w-10', skeleton: '40px' }, + /** Rich-reply quoted author */ + reply: { avatar: 'h-4 w-4', skeleton: '16px' }, }; export const PersonAvatar = ({ @@ -17,12 +21,15 @@ export const PersonAvatar = ({ className = '', isLoading = false, size = 'md', + shape = 'rounded', }: { avatarSrc?: string; userName?: string; className?: string; isLoading?: boolean; size?: AvatarSize; + /** Discord-style “squircle” vs default rounded rect */ + shape?: 'rounded' | 'squircle'; }) => { const getFallbackContent = () => { if (!userName) { @@ -38,15 +45,16 @@ export const PersonAvatar = ({ }; const { avatar: avatarSize, skeleton: skeletonSize } = sizeMap[size]; + const radiusClass = shape === 'squircle' ? 'rounded-[35%]' : 'rounded-lg'; return ( - + {getFallbackContent()} From 4b0aa41c973a68af62c6fdefa2244dd82d63dc70 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 01:56:08 +0000 Subject: [PATCH 16/60] feat(chat): resolve Matrix member avatars for timeline and replies Use room member MXC avatars (cropped HTTP thumbs) for other users and quoted authors; refresh when the room loads. Co-authored-by: webguru-hypha --- .../epics/src/common/human-right-panel.tsx | 103 +++++++++++++++--- 1 file changed, 88 insertions(+), 15 deletions(-) diff --git a/packages/epics/src/common/human-right-panel.tsx b/packages/epics/src/common/human-right-panel.tsx index 3c0c91d1f2..87bf48bd53 100644 --- a/packages/epics/src/common/human-right-panel.tsx +++ b/packages/epics/src/common/human-right-panel.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import type { MatrixEvent, Room } from 'matrix-js-sdk'; +import type { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk'; import { useTranslations } from 'next-intl'; import { useParams } from 'next/navigation'; import { @@ -89,6 +89,8 @@ type UIMessage = { excerpt?: string; /** Quoted author MXID when known (for label refresh). */ sourceUserId?: string; + /** Matrix avatar thumbnail for reply header */ + authorAvatarUrl?: string; }; }; @@ -134,6 +136,26 @@ function clearStoredRoomId(spaceSlug: string): void { } } +/** + * Matrix room member avatar → HTTP thumbnail for `` (unauthenticated media URL). + */ +function matrixMemberAvatarSquare( + client: MatrixClient | null | undefined, + roomId: string | null | undefined, + userId: string | undefined, + px: number, +): string | undefined { + if (!client || !roomId || !userId) return undefined; + const room = client.getRoom(roomId); + const member = room?.getMember(userId); + if (!member) return undefined; + const mxc = member.getMxcAvatarUrl(); + if (!mxc || !mxc.startsWith('mxc://')) return undefined; + return ( + client.mxcUrlToHttp(mxc, px, px, 'crop', true, false, false) ?? undefined + ); +} + /** * Convert a Matrix Message to the UIMessage format expected by panel components. */ @@ -142,7 +164,23 @@ function toUIMessage( currentUserId: string | null | undefined, resolveMemberLabel: (userId: string | undefined) => string, currentUserAvatarUrl?: string, + resolveMemberAvatar?: (userId: string | undefined) => string | undefined, + roomIdForAvatars?: string | null, + clientForAvatars?: MatrixClient | null, ): UIMessage { + const resolveAvatarForUser = (userId: string | undefined) => { + if (!userId) return undefined; + return ( + resolveMemberAvatar?.(userId) ?? + matrixMemberAvatarSquare( + clientForAvatars ?? null, + roomIdForAvatars ?? null, + userId, + 96, + ) + ); + }; + const isCurrentUser = currentUserId ? msg.sender === currentUserId : false; const isMedia = @@ -172,6 +210,7 @@ function toUIMessage( authorLabel, excerpt, sourceUserId: msg.inReplyToSender, + authorAvatarUrl: resolveAvatarForUser(msg.inReplyToSender), }; } @@ -207,6 +246,9 @@ function toUIMessage( const media = mediaSingle; + const memberAvatar = + !isCurrentUser && msg.sender ? resolveAvatarForUser(msg.sender) : undefined; + return { id: msg.id, role: isCurrentUser ? 'user' : 'member', @@ -222,7 +264,7 @@ function toUIMessage( isMedia && !captionForMedia ? undefined : msg.formattedContentHtml, senderName: isCurrentUser ? undefined : resolveMemberLabel(msg.sender), senderMatrixId: msg.sender, - avatarUrl: isCurrentUser ? currentUserAvatarUrl : undefined, + avatarUrl: isCurrentUser ? currentUserAvatarUrl : memberAvatar, timestamp: msg.timestamp, reactions, replyTo, @@ -387,6 +429,10 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { const currentUserId = client?.getUserId?.() ?? null; const currentUserIdRef = useRef(currentUserId); currentUserIdRef.current = currentUserId; + const roomIdRef = useRef(roomId); + roomIdRef.current = roomId; + const matrixClientRef = useRef(client); + matrixClientRef.current = client; const resolveMemberLabel = useCallback( (userId: string | undefined) => { @@ -428,12 +474,31 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { ? { ...m.replyTo, authorLabel: newAuthorLabel ?? m.replyTo.authorLabel, + authorAvatarUrl: + matrixMemberAvatarSquare( + matrixClientRef.current, + roomIdRef.current, + m.replyTo.sourceUserId, + 64, + ) ?? m.replyTo.authorAvatarUrl, } : m.replyTo; + const nextMemberAvatar = + m.role === 'member' && m.senderMatrixId + ? matrixMemberAvatarSquare( + matrixClientRef.current, + roomIdRef.current, + m.senderMatrixId, + 96, + ) ?? m.avatarUrl + : m.avatarUrl; + if ( newSenderName === m.senderName && - nextReply?.authorLabel === m.replyTo?.authorLabel + nextReply?.authorLabel === m.replyTo?.authorLabel && + nextReply?.authorAvatarUrl === m.replyTo?.authorAvatarUrl && + nextMemberAvatar === m.avatarUrl ) { return m; } @@ -441,6 +506,7 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { return { ...m, senderName: newSenderName, + avatarUrl: nextMemberAvatar, replyTo: nextReply, }; }), @@ -578,6 +644,9 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { currentUserIdRef.current, resolveMemberLabelRef.current, currentUserAvatarUrlRef.current, + undefined, + targetRoomId, + matrixRef.current.client ?? null, ), ), ); @@ -718,6 +787,9 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { currentUserIdRef.current, resolveMemberLabelRef.current, currentUserAvatarUrlRef.current, + undefined, + targetRoomId, + matrixRef.current.client ?? null, ), ), ); @@ -768,6 +840,9 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { currentUserIdRef.current, resolveMemberLabelRef.current, currentUserAvatarUrlRef.current, + undefined, + roomId, + matrixRef.current.client ?? null, ); const idx = prev.findIndex((m) => m.id === next.id); if (idx === -1) { @@ -998,12 +1073,11 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { .filter((a) => !a.editSlot) .map((a) => ({ file: a.file, - kind: - a.kind === 'image' - ? 'image' - : a.kind === 'audio' - ? 'audio' - : 'file', + kind: (a.kind === 'image' + ? 'image' + : a.kind === 'audio' + ? 'audio' + : 'file') as 'image' | 'audio' | 'file', spoiler: a.spoiler, })); await matrixRef.current.editRoomMessage({ @@ -1032,12 +1106,11 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { ? { attachments: savedAttachments.map((a) => ({ file: a.file, - kind: - a.kind === 'image' - ? 'image' - : a.kind === 'audio' - ? 'audio' - : 'file', + kind: (a.kind === 'image' + ? 'image' + : a.kind === 'audio' + ? 'audio' + : 'file') as 'image' | 'audio' | 'file', spoiler: a.spoiler, })), onUploadProgress: ({ completed, total }) => { From c8dfd7e327d6df47d48299dda85665ac5c92d967 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 01:56:14 +0000 Subject: [PATCH 17/60] feat(chat): add Discord-style reply header and curved connector Render quoted author with small squircle avatar and @ handle, move reply above the sender line, and draw a curved SVG thread from the main avatar. Co-authored-by: webguru-hypha --- .../human-chat-panel-message-bubble.tsx | 100 ++++++++++++------ .../human-chat-panel-messages.tsx | 1 + 2 files changed, 69 insertions(+), 32 deletions(-) diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx index 1de4218c9c..76f085db1c 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx @@ -696,6 +696,7 @@ type HumanChatPanelMessageBubbleProps = { authorLabel: string; /** When omitted, UI shows “original unavailable” */ excerpt?: string; + authorAvatarUrl?: string; }; }; isStreaming?: boolean; @@ -870,6 +871,28 @@ function renderTextWithMentions(text: string): React.ReactNode[] { return parts; } +/** Discord-style hook from main avatar toward the quoted author row */ +function ChatReplyConnector() { + return ( + + + + ); +} + function reactionTooltipText( reaction: Reaction, resolveLabel: (userId: string) => string, @@ -997,17 +1020,55 @@ export function HumanChatPanelMessageBubble({ onPointerEnter={onRowPointerEnter} onPointerLeave={onRowPointerLeave} > - {/* Avatar */} -
- + {/* Avatar column: connector + squircle (Discord proportions) */} +
+ {replyTo && } +
+ +
{/* Content */}
+ {replyTo && ( +
+ +

+ + {replyTo.authorLabel.startsWith('@') + ? replyTo.authorLabel + : `@${replyTo.authorLabel}`} + + {replyTo.excerpt != null && replyTo.excerpt !== '' ? ( + + {replyTo.excerpt} + + ) : ( + {t('replyOriginalUnavailable')} + )} +

+
+ )} + {/* Name + Timestamp */}
@@ -1068,31 +1129,6 @@ export function HumanChatPanelMessageBubble({
)} - {replyTo && ( -
-

- - {replyTo.authorLabel} - - {replyTo.excerpt != null && replyTo.excerpt !== '' ? ( - <> - - - {replyTo.excerpt} - - - ) : ( - - {t('replyOriginalUnavailable')} - - )} -

-
- )} - {/* Message text above attachments (caption + media in one Matrix event) */} {textContent && (message.formattedContentHtml ? ( diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx index ddcc1df868..49820416ed 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx @@ -37,6 +37,7 @@ type UIMessage = { replyTo?: { authorLabel: string; excerpt?: string; + authorAvatarUrl?: string; }; }; From 9b4ac2859aeff6addb53d4b07b78b3e797257fad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 02:05:56 +0000 Subject: [PATCH 18/60] fix(ci): restore check-types for matrix media edit and chat overflow Assert HyphaMediaEventContent when merging rich replies into media edits, use existing slot filename for caption-clear body fallback, and remove onCloseAutoFocus from DropdownMenuSubContent (not in Radix types). Co-authored-by: webguru-hypha --- .../src/matrix/client/providers/matrix-provider.tsx | 11 +++++------ .../human-chat-panel-message-overflow.tsx | 1 - 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/core/src/matrix/client/providers/matrix-provider.tsx b/packages/core/src/matrix/client/providers/matrix-provider.tsx index 714ddabd61..e06e360de8 100644 --- a/packages/core/src/matrix/client/providers/matrix-provider.tsx +++ b/packages/core/src/matrix/client/providers/matrix-provider.tsx @@ -950,7 +950,7 @@ export const MatrixProvider: React.FC = ({ children }) => { event_id: resolvedReplyTargetId, }, }, - }; + } as HyphaMediaEventContent; } else { const textExtras = matrixTextEventContentWithOptionalFormatting(trimmed); @@ -981,16 +981,15 @@ export const MatrixProvider: React.FC = ({ children }) => { event_id: resolvedReplyTargetId, }, }, - }; + } as HyphaMediaEventContent; } else { const fn = - (combined.filename as string | undefined) ?? - (combined.body as string | undefined) ?? - 'attachment'; + slots[0]?.filename?.trim() || + String(rootFromSlot.body ?? 'attachment'); combined = { ...combined, body: fn, - }; + } as HyphaMediaEventContent; } const newBody = diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-overflow.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-overflow.tsx index f1baa9363e..101cc6e112 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-overflow.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-overflow.tsx @@ -230,7 +230,6 @@ function MenuSections({ e.preventDefault()} > Date: Wed, 15 Apr 2026 02:27:09 +0000 Subject: [PATCH 19/60] fix(chat): circular avatars and reply row alignment Use full-round avatars in human chat, move reply preview above the avatar column so the main avatar aligns with the sender line, and retune the SVG reply connector. Reposition the hover action bar when a reply is present. Co-authored-by: webguru-hypha --- .../human-chat-panel-message-bubble.tsx | 908 +++++++++--------- .../src/people/components/person-avatar.tsx | 11 +- 2 files changed, 469 insertions(+), 450 deletions(-) diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx index 76f085db1c..a03cd596c1 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx @@ -871,18 +871,21 @@ function renderTextWithMentions(text: string): React.ReactNode[] { return parts; } -/** Discord-style hook from main avatar toward the quoted author row */ +/** + * Reply thread line: from top-center of main avatar up into the reply row, + * then curves toward the quoted author avatar (coordinates tuned for w-10 + pl-[52px] layout). + */ function ChatReplyConnector() { return ( - {/* Avatar column: connector + squircle (Discord proportions) */} -
- {replyTo && } -
+ {replyTo && ( +
+

+ + {replyTo.authorLabel.startsWith('@') + ? replyTo.authorLabel + : `@${replyTo.authorLabel}`} + + {replyTo.excerpt != null && replyTo.excerpt !== '' ? ( + + {replyTo.excerpt} + + ) : ( + {t('replyOriginalUnavailable')} + )} +

-
+ )} - {/* Content */} -
- {replyTo && ( -
+
+ {/* Avatar: circle; top aligns with sender name row (same as non-reply rows) */} +
+ {replyTo && ( +
+
+ +
+
+ )} +
-

- - {replyTo.authorLabel.startsWith('@') - ? replyTo.authorLabel - : `@${replyTo.authorLabel}`} - - {replyTo.excerpt != null && replyTo.excerpt !== '' ? ( - - {replyTo.excerpt} - - ) : ( - {t('replyOriginalUnavailable')} - )} -

- )} - - {/* Name + Timestamp */} -
- - {senderName} - - {timestamp && ( - {timestamp} - )}
- {message.sendPending && ( -
-
-
- -
-
-
-

- {sendPendingMainLabel} -

- - {t('messageSendFilesBadge', { - count: message.sendPending.attachmentCount, - })} - -
- {sendPendingProgress.showBar && ( -
-
-
- )} - {message.sendPending.captionPreview.trim() !== '' && ( -

- {message.sendPending.captionPreview.trim()} -

- )} -
-
+ {/* Content */} +
+ {/* Name + Timestamp */} +
+ + {senderName} + + {timestamp && ( + {timestamp} + )}
- )} - {/* Message text above attachments (caption + media in one Matrix event) */} - {textContent && - (message.formattedContentHtml ? ( -

0) - ? 'mt-1' - : 'mt-0', - )} - > - -

- ) : jumboLayout.mode === 'jumbo' ? ( -

0) - ? 'mt-1' - : 'mt-0', - )} - aria-label={textContent.trim()} - > - {jumboLayout.graphemes.map((g, i) => ( - - {g} - - ))} -

- ) : ( -

0) - ? 'mt-1' - : 'mt-0', - )} + {message.sendPending && ( +

- {renderTextWithMentions(textContent)} -

- ))} - - {message.mediaSlots && message.mediaSlots.length > 1 && ( -
- {(() => { - const { images, audios, videos, otherFiles } = - partitionBundleSlots(message.mediaSlots); - const gridClass = bundleImageGridClass(images.length); - return ( - <> - {images.length > 0 && ( +
+
+ +
+
+
+

+ {sendPendingMainLabel} +

+ + {t('messageSendFilesBadge', { + count: message.sendPending.attachmentCount, + })} + +
+ {sendPendingProgress.showBar && (
- {images.map((slot, idx) => ( - - ))} -
- )} - {audios.length > 0 && ( -
- {audios.map((slot, idx) => ( - - ))} +
)} - {videos.length > 0 && ( -
- {videos.map((slot, idx) => ( - - ))} -
- )} - {otherFiles.length > 0 && ( -
- {otherFiles.map((slot, idx) => ( - - ))} -
+ {message.sendPending.captionPreview.trim() !== '' && ( +

+ {message.sendPending.captionPreview.trim()} +

)} - - ); - })()} -
- )} +
+
+
+ )} + + {/* Message text above attachments (caption + media in one Matrix event) */} + {textContent && + (message.formattedContentHtml ? ( +

0) + ? 'mt-1' + : 'mt-0', + )} + > + +

+ ) : jumboLayout.mode === 'jumbo' ? ( +

0) + ? 'mt-1' + : 'mt-0', + )} + aria-label={textContent.trim()} + > + {jumboLayout.graphemes.map((g, i) => ( + + {g} + + ))} +

+ ) : ( +

0) + ? 'mt-1' + : 'mt-0', + )} + > + {renderTextWithMentions(textContent)} +

+ ))} - {!message.mediaSlots?.length && - message.media && - message.media.msgtype === 'm.image' && ( + {message.mediaSlots && message.mediaSlots.length > 1 && (
0 && - message.media.mediaInfo.h > 0 - ? { - aspectRatio: `${message.media.mediaInfo.w} / ${message.media.mediaInfo.h}`, - } - : undefined - } + className="mt-1 max-w-md space-y-2" + data-testid="chat-message-media-bundle" > - {mediaPreviewUrl && mediaDownloadUrl ? ( - <> - { - const m = message.media; - if ( - m?.spoiler && - !spoilerRevealed && - (e.key === 'Enter' || e.key === ' ') - ) { - e.preventDefault(); - e.stopPropagation(); - } - }} - className={cn( - 'block cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-primary/50', - message.media.spoiler && - !spoilerRevealed && - 'pointer-events-none', + {(() => { + const { images, audios, videos, otherFiles } = + partitionBundleSlots(message.mediaSlots); + const gridClass = bundleImageGridClass(images.length); + return ( + <> + {images.length > 0 && ( +
+ {images.map((slot, idx) => ( + + ))} +
)} - aria-label={t('openAttachmentInNewTab')} - title={t('openAttachmentInNewTab')} - > - -
- {message.media.spoiler && !spoilerRevealed && ( - setSpoilerRevealed(true)} - /> - )} - - ) : ( -

- {message.media.filename ?? t('attachmentUnavailable')} -

- )} + {audios.length > 0 && ( +
+ {audios.map((slot, idx) => ( + + ))} +
+ )} + {videos.length > 0 && ( +
+ {videos.map((slot, idx) => ( + + ))} +
+ )} + {otherFiles.length > 0 && ( +
+ {otherFiles.map((slot, idx) => ( + + ))} +
+ )} + + ); + })()}
)} - {!message.mediaSlots?.length && - message.media && - (message.media.msgtype === 'm.audio' || - isChatPanelAudioFile(message.media)) && ( - - )} - - {!message.mediaSlots?.length && - message.media && - message.media.msgtype === 'm.file' && - isChatPanelVideoFile(message.media) && ( - - )} - - {!message.mediaSlots?.length && - message.media && - message.media.msgtype === 'm.file' && - !isChatPanelVideoFile(message.media) && - !isChatPanelAudioFile(message.media) && ( -
-
-
- -
-
- {mediaDownloadUrl ? ( + {!message.mediaSlots?.length && + message.media && + message.media.msgtype === 'm.image' && ( +
0 && + message.media.mediaInfo.h > 0 + ? { + aspectRatio: `${message.media.mediaInfo.w} / ${message.media.mediaInfo.h}`, + } + : undefined + } + > + {mediaPreviewUrl && mediaDownloadUrl ? ( + <> - - {message.media.filename ?? t('attachment')} - - - - ) : ( - - {message.media.filename ?? t('attachment')} - - )} - {message.media.mediaInfo?.size != null && ( -

- {(() => { - const size = message.media.mediaInfo.size; + tabIndex={ + message.media.spoiler && !spoilerRevealed ? -1 : 0 + } + aria-hidden={message.media.spoiler && !spoilerRevealed} + onKeyDown={(e) => { + const m = message.media; if ( - typeof size !== 'number' || - !Number.isFinite(size) || - size < 0 + m?.spoiler && + !spoilerRevealed && + (e.key === 'Enter' || e.key === ' ') ) { - return t('attachmentSizeUnknown'); - } - if (size < 1024) { - return format.number(size, { - style: 'unit', - unit: 'byte', - unitDisplay: 'narrow', - maximumFractionDigits: 0, - }); + e.preventDefault(); + e.stopPropagation(); } - if (size < 1024 * 1024) { - return format.number(size / 1024, { + }} + className={cn( + 'block cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-primary/50', + message.media.spoiler && + !spoilerRevealed && + 'pointer-events-none', + )} + aria-label={t('openAttachmentInNewTab')} + title={t('openAttachmentInNewTab')} + > + + + {message.media.spoiler && !spoilerRevealed && ( + setSpoilerRevealed(true)} + /> + )} + + ) : ( +

+ {message.media.filename ?? t('attachmentUnavailable')} +

+ )} +
+ )} + + {!message.mediaSlots?.length && + message.media && + (message.media.msgtype === 'm.audio' || + isChatPanelAudioFile(message.media)) && ( + + )} + + {!message.mediaSlots?.length && + message.media && + message.media.msgtype === 'm.file' && + isChatPanelVideoFile(message.media) && ( + + )} + + {!message.mediaSlots?.length && + message.media && + message.media.msgtype === 'm.file' && + !isChatPanelVideoFile(message.media) && + !isChatPanelAudioFile(message.media) && ( +
+
+
+ +
+
+ {mediaDownloadUrl ? ( + + + {message.media.filename ?? t('attachment')} + + + + ) : ( + + {message.media.filename ?? t('attachment')} + + )} + {message.media.mediaInfo?.size != null && ( +

+ {(() => { + const size = message.media.mediaInfo.size; + if ( + typeof size !== 'number' || + !Number.isFinite(size) || + size < 0 + ) { + return t('attachmentSizeUnknown'); + } + if (size < 1024) { + return format.number(size, { + style: 'unit', + unit: 'byte', + unitDisplay: 'narrow', + maximumFractionDigits: 0, + }); + } + if (size < 1024 * 1024) { + return format.number(size / 1024, { + style: 'unit', + unit: 'kilobyte', + unitDisplay: 'narrow', + maximumFractionDigits: 1, + }); + } + return format.number(size / (1024 * 1024), { style: 'unit', - unit: 'kilobyte', + unit: 'megabyte', unitDisplay: 'narrow', maximumFractionDigits: 1, }); - } - return format.number(size / (1024 * 1024), { - style: 'unit', - unit: 'megabyte', - unitDisplay: 'narrow', - maximumFractionDigits: 1, - }); - })()} -

- )} + })()} +

+ )} +
-
- )} + )} - {/* Streaming indicator */} - {isStreaming && ( - - - - - - )} + {/* Streaming indicator */} + {isStreaming && ( + + + + + + )} - {/* Reactions — Discord-style pills; inline add-reaction only when ≥1 reaction exists */} - {visibleReactions.length > 0 && ( -
- {visibleReactions.map((reaction) => { - const tooltip = - resolveReactionReactorLabel && - reactionTooltipText(reaction, resolveReactionReactorLabel, t); - const pill = ( - - ); - - if (tooltip) { - return ( - - {pill} - + {reaction.emoji} + + - {tooltip} - - + {reaction.count} + + ); - } - return {pill}; - })} - {hiddenReactionCount > 0 && ( - - {t('reactionsOverflow', { count: hiddenReactionCount })} - - )} - {canReact && onReact && ( - { - pushRecentChatReaction(native); - void onReact(native); - }} - ariaLabel={t('addReactionButton')} - align="start" - > - - - )} -
- )} + + + )} +
+ )} +
{/* Discord-style floating bar: compact height, tight to icon row */}
{ const getFallbackContent = () => { if (!userName) { @@ -45,7 +45,12 @@ export const PersonAvatar = ({ }; const { avatar: avatarSize, skeleton: skeletonSize } = sizeMap[size]; - const radiusClass = shape === 'squircle' ? 'rounded-[35%]' : 'rounded-lg'; + const radiusClass = + shape === 'circle' + ? 'rounded-full' + : shape === 'squircle' + ? 'rounded-[35%]' + : 'rounded-lg'; return ( Date: Wed, 15 Apr 2026 21:24:31 +0000 Subject: [PATCH 20/60] fix: apply CodeRabbit auto-fixes Clear reply/edit drafts when a message is redacted remotely; dedupe prepareUploadedAttachmentMediaPayload in matrix-provider; avoid classifying extension-only .webm as audio; normalize emoji-mart locale base tags; simplify audio bubble check and add dev-only debug for video seek failures. Co-authored-by: webguru-hypha --- .../client/providers/matrix-provider.tsx | 215 +++++++----------- .../chat-panel-media-types.ts | 3 +- .../human-chat-panel-emoji-mart-surface.tsx | 5 +- .../human-chat-panel-message-bubble.tsx | 10 +- .../epics/src/common/human-right-panel.tsx | 16 +- 5 files changed, 102 insertions(+), 147 deletions(-) diff --git a/packages/core/src/matrix/client/providers/matrix-provider.tsx b/packages/core/src/matrix/client/providers/matrix-provider.tsx index e06e360de8..943634b6d1 100644 --- a/packages/core/src/matrix/client/providers/matrix-provider.tsx +++ b/packages/core/src/matrix/client/providers/matrix-provider.tsx @@ -204,6 +204,75 @@ function loadImageDimensions( }); } +async function prepareUploadedAttachmentMediaPayload( + client: MatrixSdk.MatrixClient, + att: SendAttachmentInput, +): Promise { + const abortController = new AbortController(); + const timeoutMs = MATRIX_UPLOAD_TIMEOUT_MS; + const timeoutId = setTimeout(() => { + abortController.abort(); + }, timeoutMs); + let upload: { content_uri: string }; + try { + upload = await client.uploadContent(att.file, { + name: att.file.name, + type: att.file.type || undefined, + abortController, + }); + } catch (e) { + if (abortController.signal.aborted) { + throw new MatrixUploadTimeoutError( + `Matrix media upload timed out after ${timeoutMs}ms`, + ); + } + throw e; + } finally { + clearTimeout(timeoutId); + } + const mxc = upload.content_uri; + const msgtype = + att.kind === 'image' + ? MatrixSdk.MsgType.Image + : att.kind === 'audio' + ? MatrixSdk.MsgType.Audio + : MatrixSdk.MsgType.File; + let info: { + mimetype?: string; + size?: number; + w?: number; + h?: number; + duration?: number; + } = { + mimetype: att.file.type || undefined, + size: att.file.size, + }; + if (msgtype === MatrixSdk.MsgType.Image) { + const dims = await loadImageDimensions(att.file); + if (dims) { + info = { ...info, w: dims.w, h: dims.h }; + } + } else if (msgtype === MatrixSdk.MsgType.Audio) { + const dur = await loadAudioDurationMs(att.file); + if (dur != null) { + info = { ...info, duration: dur }; + } + } + + const caption = att.file.name; + const base: HyphaMediaEventContent = { + msgtype, + body: caption, + filename: att.file.name, + url: mxc, + info, + } as HyphaMediaEventContent; + if (att.spoiler) { + base[HYPHA_SPOILER_FIELD] = true; + } + return base; +} + export interface ToggleReactionInput { roomId: string; targetEventId: string; @@ -473,74 +542,6 @@ export const MatrixProvider: React.FC = ({ children }) => { }; } - const prepareMediaPayload = async ( - att: SendAttachmentInput, - ): Promise => { - const abortController = new AbortController(); - const timeoutMs = MATRIX_UPLOAD_TIMEOUT_MS; - const timeoutId = setTimeout(() => { - abortController.abort(); - }, timeoutMs); - let upload: { content_uri: string }; - try { - upload = await client.uploadContent(att.file, { - name: att.file.name, - type: att.file.type || undefined, - abortController, - }); - } catch (e) { - if (abortController.signal.aborted) { - throw new MatrixUploadTimeoutError( - `Matrix media upload timed out after ${timeoutMs}ms`, - ); - } - throw e; - } finally { - clearTimeout(timeoutId); - } - const mxc = upload.content_uri; - const msgtype = - att.kind === 'image' - ? MsgType.Image - : att.kind === 'audio' - ? MsgType.Audio - : MsgType.File; - let info: { - mimetype?: string; - size?: number; - w?: number; - h?: number; - duration?: number; - } = { - mimetype: att.file.type || undefined, - size: att.file.size, - }; - if (msgtype === MsgType.Image) { - const dims = await loadImageDimensions(att.file); - if (dims) { - info = { ...info, w: dims.w, h: dims.h }; - } - } else if (msgtype === MsgType.Audio) { - const dur = await loadAudioDurationMs(att.file); - if (dur != null) { - info = { ...info, duration: dur }; - } - } - - const caption = att.file.name; - const base: HyphaMediaEventContent = { - msgtype, - body: caption, - filename: att.file.name, - url: mxc, - info, - } as HyphaMediaEventContent; - if (att.spoiler) { - base[HYPHA_SPOILER_FIELD] = true; - } - return base; - }; - if (hasAttachments) { const mediaPayloads: HyphaMediaEventContent[] = []; for (let i = 0; i < list.length; i++) { @@ -551,7 +552,9 @@ export const MatrixProvider: React.FC = ({ children }) => { let attempt = 0; while (true) { try { - mediaPayloads.push(await prepareMediaPayload(att)); + mediaPayloads.push( + await prepareUploadedAttachmentMediaPayload(client, att), + ); onUploadProgress?.({ completed: mediaPayloads.length, total: list.length, @@ -794,73 +797,6 @@ export const MatrixProvider: React.FC = ({ children }) => { if (isMediaEdit) { const slots = existingMediaSlots!; - const prepareMediaPayload = async ( - att: SendAttachmentInput, - ): Promise => { - const abortController = new AbortController(); - const timeoutMs = MATRIX_UPLOAD_TIMEOUT_MS; - const timeoutId = setTimeout(() => { - abortController.abort(); - }, timeoutMs); - let upload: { content_uri: string }; - try { - upload = await client.uploadContent(att.file, { - name: att.file.name, - type: att.file.type || undefined, - abortController, - }); - } catch (e) { - if (abortController.signal.aborted) { - throw new MatrixUploadTimeoutError( - `Matrix media upload timed out after ${timeoutMs}ms`, - ); - } - throw e; - } finally { - clearTimeout(timeoutId); - } - const mxc = upload.content_uri; - const msgtype = - att.kind === 'image' - ? MsgType.Image - : att.kind === 'audio' - ? MsgType.Audio - : MsgType.File; - let info: { - mimetype?: string; - size?: number; - w?: number; - h?: number; - duration?: number; - } = { - mimetype: att.file.type || undefined, - size: att.file.size, - }; - if (msgtype === MsgType.Image) { - const dims = await loadImageDimensions(att.file); - if (dims) { - info = { ...info, w: dims.w, h: dims.h }; - } - } else if (msgtype === MsgType.Audio) { - const dur = await loadAudioDurationMs(att.file); - if (dur != null) { - info = { ...info, duration: dur }; - } - } - const caption = att.file.name; - const base: HyphaMediaEventContent = { - msgtype, - body: caption, - filename: att.file.name, - url: mxc, - info, - } as HyphaMediaEventContent; - if (att.spoiler) { - base[HYPHA_SPOILER_FIELD] = true; - } - return base; - }; - const slotToPayload = ( slot: EditRoomMessageExistingSlot, ): HyphaMediaEventContent => { @@ -887,7 +823,12 @@ export const MatrixProvider: React.FC = ({ children }) => { let attempt = 0; while (true) { try { - uploaded.push(await prepareMediaPayload(newList[i]!)); + uploaded.push( + await prepareUploadedAttachmentMediaPayload( + client, + newList[i]!, + ), + ); break; } catch (e) { if ( diff --git a/packages/epics/src/common/human-chat-panel/chat-panel-media-types.ts b/packages/epics/src/common/human-chat-panel/chat-panel-media-types.ts index 95f29d4da9..bbd2fb90a4 100644 --- a/packages/epics/src/common/human-chat-panel/chat-panel-media-types.ts +++ b/packages/epics/src/common/human-chat-panel/chat-panel-media-types.ts @@ -16,7 +16,6 @@ export type ChatPanelAttachmentMedia = { }; const AUDIO_FILE_EXTENSIONS = new Set([ - 'webm', 'ogg', 'oga', 'opus', @@ -54,6 +53,8 @@ export function looksLikeAudioMimeOrName( const mt = mimetype?.toLowerCase() ?? ''; if (mt.startsWith('audio/')) return true; const ext = extensionFromFileNameHint(filename ?? ''); + if (!ext) return false; + if (ext === 'webm') return false; return AUDIO_FILE_EXTENSIONS.has(ext); } diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-emoji-mart-surface.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-emoji-mart-surface.tsx index f21fc0879d..59dd6ebfc9 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-emoji-mart-surface.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-emoji-mart-surface.tsx @@ -32,8 +32,9 @@ export function HumanChatPanelEmojiMartSurface({ setMounted(true); }, []); - const pickerLocale = ['en', 'es', 'fr', 'de', 'pt'].includes(locale) - ? locale + const baseLocale = locale.toLowerCase().split(/[-_]/)[0] ?? 'en'; + const pickerLocale = ['en', 'es', 'fr', 'de', 'pt'].includes(baseLocale) + ? baseLocale : 'en'; const pickerTheme = mounted && resolvedTheme === 'dark' ? 'dark' : 'light'; diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx index a03cd596c1..13cf99cf7d 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx @@ -492,8 +492,10 @@ function TimelineMatrixVideo({ if (el.readyState >= 1 && el.currentTime === 0) { el.currentTime = 0.001; } - } catch { - // ignore + } catch (e) { + if (process.env.NODE_ENV === 'development') { + console.debug('[TimelineMatrixVideo] seek failed:', e); + } } }} onPlay={() => setPlaying(true)} @@ -1014,6 +1016,7 @@ export function HumanChatPanelMessageBubble({ const row = (moreSlot: ReactNode | null) => (
)} diff --git a/packages/epics/src/common/human-right-panel.tsx b/packages/epics/src/common/human-right-panel.tsx index 87bf48bd53..b34a5cf9bb 100644 --- a/packages/epics/src/common/human-right-panel.tsx +++ b/packages/epics/src/common/human-right-panel.tsx @@ -831,10 +831,20 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { registerRoomListener( roomId, async (message: Message) => { + if (message.redacted) { + const id = message.id; + setMessages((prev) => prev.filter((m) => m.id !== id)); + setReplyDraft((draft) => (draft?.messageId === id ? null : draft)); + setEditDraft((draft) => { + if (draft?.messageId !== id) return draft; + disposeDraftAttachmentUrls(draftAttachmentsRef.current); + setDraftAttachments([]); + setInput(''); + return null; + }); + return; + } setMessages((prev) => { - if (message.redacted) { - return prev.filter((m) => m.id !== message.id); - } const next = toUIMessage( message, currentUserIdRef.current, From d3281106396aca9b9233249b20c8d3b145d8737a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 21:32:06 +0000 Subject: [PATCH 21/60] feat(chat): improve voice composer stop control and dictation icon Show a filled square stop button in place of the mic menu while recording or dictating; remove redundant stop from the mic dropdown; use AudioLines for dictation and voice draft preview. Co-authored-by: webguru-hypha --- .../human-chat-panel-chat-bar.tsx | 126 +++++++++--------- 1 file changed, 61 insertions(+), 65 deletions(-) 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 63522412c9..f87d8b501b 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 @@ -22,8 +22,8 @@ import { Paperclip, Video, Mic, - Keyboard, - Pause, + AudioLines, + Square, } from 'lucide-react'; import { useTranslations } from 'next-intl'; @@ -31,7 +31,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from '@hypha-platform/ui'; import { cn } from '@hypha-platform/ui-utils'; @@ -1075,7 +1074,7 @@ export function HumanChatPanelChatBar({ /> ) : att.kind === 'audio' ? (
- + {t('voiceMessage')} @@ -1329,68 +1328,65 @@ export function HumanChatPanelChatBar({ > - - - - - - { - requestAnimationFrame(() => startDictation()); - }} - > - - {t('composerDictateMessage')} - - { - requestAnimationFrame( - () => void startVoiceRecordingAsAttachment(), - ); - }} - > - - {t('composerSendAudioMessage')} - - {(isVoiceRecording || isDictating) && ( - <> - - { - if (isVoiceRecording) stopVoiceRecording(); - if (isDictating) stopDictation(); - }} - > - {t('composerMicStop')} - - + {isVoiceRecording || isDictating ? ( + + ) : ( + + + + + + { + requestAnimationFrame(() => startDictation()); + }} + > + + {t('composerDictateMessage')} + + { + requestAnimationFrame( + () => void startVoiceRecordingAsAttachment(), + ); + }} + > + + {t('composerSendAudioMessage')} + + + + )}
- + { From e90f2038b38a93b0841ccc5c75cac3e742d608ec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 22:38:03 +0000 Subject: [PATCH 29/60] fix(human-chat): open sibling toolbar popovers in one click Use non-modal Radix dropdowns in the composer and optional non-modal emoji picker so an outside click on another icon opens it instead of only dismissing the attach menu. Co-authored-by: webguru-hypha --- .../common/human-chat-panel/human-chat-panel-chat-bar.tsx | 8 +++++++- .../human-chat-panel/human-chat-panel-emoji-picker.tsx | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) 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 1e2a277fe9..49e81bfe0a 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 @@ -1289,6 +1289,7 @@ export function HumanChatPanelChatBar({
@@ -1338,6 +1339,7 @@ export function HumanChatPanelChatBar({ ) : ( - + - -
-

- {/^voice-message-\d+\.[^.]+$/i.test(media.filename ?? '') - ? t('voiceMessage') - : media.filename ?? t('voiceMessage')} -

-

- {durationLabel} -

-
-
) : ( diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx index 191e107b0f..db75c1fb49 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx @@ -362,6 +362,7 @@ function TimelineVoiceSlot({ const { client } = useMatrix(); const { download: src } = useMxcUrls(client, media.mxcUrl); const durationMs = media.mediaInfo?.duration; + const [spoilerRevealed, setSpoilerRevealed] = useState(false); const durationLabel = formatVoiceDurationLabel( durationMs, @@ -380,14 +381,26 @@ function TimelineVoiceSlot({ ); } + const spoilerActive = Boolean(media.spoiler && !spoilerRevealed); + return ( -
+
+ {spoilerActive && ( + setSpoilerRevealed(true)} + /> + )}
); } diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-voice-audio-row.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-voice-audio-row.tsx index 0b2a788fa3..19caab9c7c 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-voice-audio-row.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-voice-audio-row.tsx @@ -42,6 +42,9 @@ export type ChatVoiceAudioRowProps = { /** Wider cap in timeline; drafts sit in narrow cards — use tighter max. */ variant?: 'timeline' | 'draft'; className?: string; + /** Draft preview: blur row + badge (timeline reveal uses parent overlay). */ + spoilerPreview?: boolean; + spoilerBadgeLabel?: string; }; /** @@ -53,6 +56,8 @@ export function ChatVoiceAudioRow({ voiceLabel, variant = 'timeline', className, + spoilerPreview = false, + spoilerBadgeLabel, }: ChatVoiceAudioRowProps) { const audioRef = useRef(null); const [playing, setPlaying] = useState(false); @@ -65,20 +70,25 @@ export function ChatVoiceAudioRow({ setPlaying(false); }, [audioSrc]); - return ( + const row = (
- + {durationLabel} @@ -117,6 +127,24 @@ export function ChatVoiceAudioRow({ />
); + + if (spoilerPreview && spoilerBadgeLabel && variant === 'draft') { + return ( +
+ {row} +
+ + {spoilerBadgeLabel} + +
+
+ ); + } + + return row; } /** Resolve mm:ss from optional duration ms; fallback for unknown length. */ From ea14f57e4b9d72c035e3a5d0cce063e7b421e22f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 01:14:21 +0000 Subject: [PATCH 42/60] feat(human-chat): cancel pending uploads and black voice play button Abort in-flight sends via AbortSignal during attachment uploads and new slots on media edit; add timeline cancel control and restore composer. Change voice row play affordance to black circular button. Co-authored-by: webguru-hypha --- .../client/providers/matrix-provider.tsx | 37 +++++++++++++++++++ .../human-chat-panel-message-bubble.tsx | 19 +++++++++- .../human-chat-panel-messages.tsx | 7 ++++ .../human-chat-panel-voice-audio-row.tsx | 2 +- .../epics/src/common/human-right-panel.tsx | 28 ++++++++++++++ packages/i18n/src/messages/de.json | 1 + packages/i18n/src/messages/en.json | 1 + packages/i18n/src/messages/es.json | 1 + packages/i18n/src/messages/fr.json | 1 + packages/i18n/src/messages/pt.json | 1 + 10 files changed, 95 insertions(+), 3 deletions(-) diff --git a/packages/core/src/matrix/client/providers/matrix-provider.tsx b/packages/core/src/matrix/client/providers/matrix-provider.tsx index 915b09ce7f..5a5339058f 100644 --- a/packages/core/src/matrix/client/providers/matrix-provider.tsx +++ b/packages/core/src/matrix/client/providers/matrix-provider.tsx @@ -46,6 +46,8 @@ interface SendMessageInput { attachments?: SendAttachmentInput[]; /** Fires after each attachment finishes uploading (before the room message is sent). */ onUploadProgress?: (p: SendMessageUploadProgress) => void; + /** Aborts upload/send between attachment steps (UI cancel). */ + signal?: AbortSignal; } /** Existing attachment slot when editing a media `m.room.message` (mxc stays on server). */ @@ -69,6 +71,8 @@ export interface EditRoomMessageInput { existingMediaSlots?: EditRoomMessageExistingSlot[]; /** New files to append when editing a media message (uploaded after `existingMediaSlots`). */ newAttachments?: SendAttachmentInput[]; + /** Aborts new attachment uploads before the replace event is sent. */ + signal?: AbortSignal; } export interface RedactRoomEventInput { @@ -92,6 +96,20 @@ export class SendMessagePartialFailureError extends Error { } } +/** Thrown when `AbortSignal` aborts an in-flight `sendMessage` / media edit upload. */ +export class SendMessageCancelledError extends Error { + constructor() { + super('Send cancelled'); + this.name = 'SendMessageCancelledError'; + } +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new SendMessageCancelledError(); + } +} + /** Matrix `uploadContent` exceeded the configured timeout (see `MATRIX_UPLOAD_TIMEOUT_MS`). */ export class MatrixUploadTimeoutError extends Error { constructor(message = 'Matrix media upload timed out') { @@ -507,6 +525,7 @@ export const MatrixProvider: React.FC = ({ children }) => { replyToEventId, attachments, onUploadProgress, + signal, }: SendMessageInput) => { if (!client) { throw new Error('Client should be specified'); @@ -522,6 +541,8 @@ export const MatrixProvider: React.FC = ({ children }) => { return; } + throwIfAborted(signal); + let replyContext: | { resolvedTargetId: string; @@ -531,6 +552,7 @@ export const MatrixProvider: React.FC = ({ children }) => { | undefined; if (replyToEventId?.trim()) { + throwIfAborted(signal); const resolved = await resolveReplyTargetForSend( client, roomId, @@ -546,6 +568,7 @@ export const MatrixProvider: React.FC = ({ children }) => { if (hasAttachments) { const mediaPayloads: HyphaMediaEventContent[] = []; for (let i = 0; i < list.length; i++) { + throwIfAborted(signal); if (i > 0) { await delay(MATRIX_UPLOAD_STAGGER_MS); } @@ -574,6 +597,8 @@ export const MatrixProvider: React.FC = ({ children }) => { } } + throwIfAborted(signal); + if (trimmed) { const first = mediaPayloads[0]!; if (replyContext) { @@ -613,6 +638,7 @@ export const MatrixProvider: React.FC = ({ children }) => { }, } : base; + throwIfAborted(signal); await client.sendEvent( roomId, EventType.RoomMessage, @@ -648,6 +674,7 @@ export const MatrixProvider: React.FC = ({ children }) => { }, } : combined; + throwIfAborted(signal); await client.sendEvent( roomId, EventType.RoomMessage, @@ -671,6 +698,8 @@ export const MatrixProvider: React.FC = ({ children }) => { return; } + throwIfAborted(signal); + try { if (replyContext && !hasAttachments) { const payload = buildRichReplyMatrixContent( @@ -678,6 +707,7 @@ export const MatrixProvider: React.FC = ({ children }) => { replyContext.targetBody, message, ); + throwIfAborted(signal); await client.sendEvent(roomId, EventType.RoomMessage, { msgtype: MsgType.Text, ...payload, @@ -693,6 +723,7 @@ export const MatrixProvider: React.FC = ({ children }) => { if (replyContext && hasAttachments) { const textPayload = matrixTextEventContentWithOptionalFormatting(message); + throwIfAborted(signal); await client.sendEvent(roomId, EventType.RoomMessage, { msgtype: MsgType.Text, ...textPayload, @@ -707,6 +738,7 @@ export const MatrixProvider: React.FC = ({ children }) => { const textPayload = matrixTextEventContentWithOptionalFormatting(message); + throwIfAborted(signal); await client.sendEvent(roomId, EventType.RoomMessage, { msgtype: MsgType.Text, ...textPayload, @@ -731,6 +763,7 @@ export const MatrixProvider: React.FC = ({ children }) => { message, existingMediaSlots, newAttachments, + signal, }: EditRoomMessageInput) => { if (!client) { throw new Error('Client should be specified'); @@ -818,6 +851,7 @@ export const MatrixProvider: React.FC = ({ children }) => { const restSlots = slots.slice(1).map(slotToPayload); const uploaded: HyphaMediaEventContent[] = []; for (let i = 0; i < newList.length; i++) { + throwIfAborted(signal); if (i > 0) { await delay(MATRIX_UPLOAD_STAGGER_MS); } @@ -873,6 +907,7 @@ export const MatrixProvider: React.FC = ({ children }) => { const filenameFallback = slots[0]?.filename?.trim() || String(rootFromSlot.body ?? 'attachment'); + throwIfAborted(signal); combined = await applyMediaEditCaptionAndReply( combined, trimmed, @@ -885,6 +920,8 @@ export const MatrixProvider: React.FC = ({ children }) => { 'body' in combined ? String(combined.body) : trimmed || 'attachment'; const fallbackBody = `* ${newBody}`; + throwIfAborted(signal); + await client.sendEvent(roomId, EventType.RoomMessage, { ...combined, body: fallbackBody, diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx index db75c1fb49..4541a94a4e 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx @@ -7,6 +7,7 @@ import type { TranslationValues } from 'next-intl'; import { Smile, SmilePlus, + X, Pencil, Reply, FileIcon, @@ -691,6 +692,8 @@ type HumanChatPanelMessageBubbleProps = { onDeleteMessage?: (messageId: string) => void | Promise; /** When set, user can open react picker (omit for welcome). */ onReact?: (emoji: string) => void | Promise; + /** Cancel in-flight attachment send (pending row only). */ + onCancelSendPending?: () => void; }; const MAX_VISIBLE_REACTIONS = 12; @@ -1024,6 +1027,7 @@ export function HumanChatPanelMessageBubble({ onEdit, onDeleteMessage, onReact, + onCancelSendPending, }: HumanChatPanelMessageBubbleProps) { const t = useTranslations('HumanChatPanel'); const format = useFormatter(); @@ -1272,9 +1276,20 @@ export function HumanChatPanelMessageBubble({ aria-live="polite" aria-busy="true" data-testid="chat-message-send-pending" - className="mt-1.5 max-w-md overflow-hidden rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm" + className="relative mt-1.5 max-w-md overflow-hidden rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm" > -
+ {onCancelSendPending && ( + + )} +
void; /** Map Matrix user id to display name for reaction hover tooltips. */ resolveReactionReactorLabel?: (userId: string) => string; + onCancelSendPending?: () => void; }; export function HumanChatPanelMessages({ @@ -68,6 +69,7 @@ export function HumanChatPanelMessages({ onDeleteMessage, onToggleReaction, resolveReactionReactorLabel, + onCancelSendPending, }: HumanChatPanelMessagesProps) { const t = useTranslations('HumanChatPanel'); @@ -200,6 +202,11 @@ export function HumanChatPanelMessages({ ? (emoji: string) => onToggleReaction(msg.id, emoji) : undefined } + onCancelSendPending={ + msg.sendPending && onCancelSendPending + ? onCancelSendPending + : undefined + } /> ); })} diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-voice-audio-row.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-voice-audio-row.tsx index 19caab9c7c..218661ea53 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-voice-audio-row.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-voice-audio-row.tsx @@ -86,7 +86,7 @@ export function ChatVoiceAudioRow({ type="button" disabled={spoilerPreview} className={cn( - 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-opacity hover:opacity-90', + 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-black text-white shadow-sm ring-1 ring-black/10 transition-opacity hover:opacity-90 dark:bg-black dark:text-white dark:ring-white/15', spoilerPreview && 'pointer-events-none opacity-80', )} aria-label={ diff --git a/packages/epics/src/common/human-right-panel.tsx b/packages/epics/src/common/human-right-panel.tsx index 0ec5e2dcea..8321c25304 100644 --- a/packages/epics/src/common/human-right-panel.tsx +++ b/packages/epics/src/common/human-right-panel.tsx @@ -22,6 +22,7 @@ import { stripMatrixReplyFallback, RoomEvent, MatrixUploadTimeoutError, + SendMessageCancelledError, SendMessagePartialFailureError, isMatrixRateLimitedError, type MessageReaction, @@ -424,6 +425,7 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { draftAttachmentsRef.current = draftAttachments; /** Latest in-flight send; used so error recovery does not clobber edits from a newer send. */ const sendOperationTokenRef = useRef(null); + const sendAbortControllerRef = useRef(null); const [messages, setMessages] = useState([]); const [replyDraft, setReplyDraft] = useState(null); const [editDraft, setEditDraft] = useState(null); @@ -1068,6 +1070,10 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { [roomId, editDraft?.messageId, replyDraft?.messageId, t], ); + const cancelSendInFlight = useCallback(() => { + sendAbortControllerRef.current?.abort(); + }, []); + const handleSend = useCallback(async () => { if (!roomId) return; const trimmed = input.trim(); @@ -1078,6 +1084,11 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { const savedDraft = replyDraft; const savedEditDraft = editDraft; const savedAttachments = draftAttachments; + sendAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + sendAbortControllerRef.current = abortController; + const signal = abortController.signal; + const sendToken = Symbol('send'); sendOperationTokenRef.current = sendToken; setComposerError(null); @@ -1121,6 +1132,7 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { message: text, existingMediaSlots: slots, ...(newFiles.length > 0 ? { newAttachments: newFiles } : {}), + ...(newFiles.length > 0 ? { signal } : {}), }); } else { if (savedAttachments.length > 0) { @@ -1136,6 +1148,7 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { await matrixRef.current.sendMessage({ roomId, message: text, + signal, ...(replyToEventId ? { replyToEventId } : {}), ...(savedAttachments.length > 0 ? { @@ -1170,6 +1183,9 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { if (sendOperationTokenRef.current === sendToken) { sendOperationTokenRef.current = null; } + if (sendAbortControllerRef.current === abortController) { + sendAbortControllerRef.current = null; + } } catch (err) { console.error('[HumanRightPanel] Failed to send message:', err); if (sendOperationTokenRef.current !== sendToken) { @@ -1179,6 +1195,17 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { } sendOperationTokenRef.current = null; setSendingPending(null); + if (sendAbortControllerRef.current === abortController) { + sendAbortControllerRef.current = null; + } + if (err instanceof SendMessageCancelledError) { + disposeDraftAttachmentUrls(savedAttachments); + setInput(text); + setDraftAttachments(savedAttachments); + setReplyDraft(savedDraft); + setEditDraft(savedEditDraft); + return; + } if (err instanceof SendMessagePartialFailureError) { const { sentAttachmentCount, restoreCaption, message } = err; setComposerError( @@ -1281,6 +1308,7 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { resolveReactionReactorLabel={(userId) => resolveMemberLabel(userId) } + onCancelSendPending={cancelSendInFlight} /> )} diff --git a/packages/i18n/src/messages/de.json b/packages/i18n/src/messages/de.json index ae0fc11b16..4f5b112316 100644 --- a/packages/i18n/src/messages/de.json +++ b/packages/i18n/src/messages/de.json @@ -1718,6 +1718,7 @@ "messageSendUploadProgress": "Lade {completed} von {total} hoch…", "messageSendFinishing": "Nachricht wird gesendet…", "messageSendFilesBadge": "{count, plural, one {# Datei} other {# Dateien}}", + "messageSendCancel": "Senden abbrechen", "sendPartialFailed": "Senden wurde nach {sent} Anhang(en) abgebrochen. {detail}" }, "CoherenceTab": { diff --git a/packages/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json index 0dc61b0e1c..caa5ed8598 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -1718,6 +1718,7 @@ "messageSendUploadProgress": "Uploading {completed} of {total}…", "messageSendFinishing": "Posting message…", "messageSendFilesBadge": "{count, plural, one {# file} other {# files}}", + "messageSendCancel": "Cancel sending", "sendPartialFailed": "Send stopped after {sent} attachment(s). {detail}" }, "CoherenceTab": { diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json index 3b02b56089..1e8ff585e0 100644 --- a/packages/i18n/src/messages/es.json +++ b/packages/i18n/src/messages/es.json @@ -1718,6 +1718,7 @@ "messageSendUploadProgress": "Subiendo {completed} de {total}…", "messageSendFinishing": "Publicando mensaje…", "messageSendFilesBadge": "{count, plural, one {# archivo} other {# archivos}}", + "messageSendCancel": "Cancelar envío", "sendPartialFailed": "El envío se detuvo tras {sent, plural, one {# archivo adjunto} other {# archivos adjuntos}}. {detail}" }, "CoherenceTab": { diff --git a/packages/i18n/src/messages/fr.json b/packages/i18n/src/messages/fr.json index a796261c89..c4c3ea2cd5 100644 --- a/packages/i18n/src/messages/fr.json +++ b/packages/i18n/src/messages/fr.json @@ -1718,6 +1718,7 @@ "messageSendUploadProgress": "Téléversement de {completed} sur {total}…", "messageSendFinishing": "Publication du message…", "messageSendFilesBadge": "{count, plural, one {# fichier} other {# fichiers}}", + "messageSendCancel": "Annuler l’envoi", "sendPartialFailed": "L’envoi s’est arrêté après {sent, plural, one {# pièce jointe} other {# pièces jointes}}. {detail}" }, "CoherenceTab": { diff --git a/packages/i18n/src/messages/pt.json b/packages/i18n/src/messages/pt.json index 04e04bf344..98e79b1b62 100644 --- a/packages/i18n/src/messages/pt.json +++ b/packages/i18n/src/messages/pt.json @@ -1718,6 +1718,7 @@ "messageSendUploadProgress": "A carregar {completed} de {total}…", "messageSendFinishing": "A publicar a mensagem…", "messageSendFilesBadge": "{count, plural, one {# ficheiro} other {# ficheiros}}", + "messageSendCancel": "Cancelar envio", "sendPartialFailed": "O envio parou após {sent} anexo(s). {detail}" }, "CoherenceTab": { From f40dbe978096036fcd8e72b4a2ea63319645fa6e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 01:16:57 +0000 Subject: [PATCH 43/60] fix(human-chat): show format toolbar after selection drag ends Hide the floating bar during pointer-drag selection and reveal it on global pointerup/pointercancel so it does not cover active selection. Co-authored-by: webguru-hypha --- .../human-chat-panel-chat-bar.tsx | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) 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 d58880ebf5..9f1701a8c3 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 @@ -365,6 +365,8 @@ export function HumanChatPanelChatBar({ top: number; left: number; } | null>(null); + /** While true, user is dragging a selection — hide format bar until pointerup. */ + const pointerSelectingRef = useRef(false); const [composerDragDepth, setComposerDragDepth] = useState(0); const isComposerDropActive = composerDragDepth > 0; @@ -374,6 +376,9 @@ export function HumanChatPanelChatBar({ setSelectionBar(null); return; } + if (pointerSelectingRef.current) { + return; + } const start = el.selectionStart ?? 0; const end = el.selectionEnd ?? 0; if (start === end || colonOpen) { @@ -398,6 +403,22 @@ export function HumanChatPanelChatBar({ }); }, [colonOpen]); + useEffect(() => { + const endPointerSelect = () => { + if (!pointerSelectingRef.current) return; + pointerSelectingRef.current = false; + requestAnimationFrame(() => { + updateSelectionBar(); + }); + }; + window.addEventListener('pointerup', endPointerSelect); + window.addEventListener('pointercancel', endPointerSelect); + return () => { + window.removeEventListener('pointerup', endPointerSelect); + window.removeEventListener('pointercancel', endPointerSelect); + }; + }, [updateSelectionBar]); + const autoResize = useCallback(() => { if (textareaRef.current) { textareaRef.current.style.height = 'auto'; @@ -1417,6 +1438,11 @@ export function HumanChatPanelChatBar({