Files
cinny/src/app/components/message/content/FallbackContent.tsx
T
jaredandClaude Opus 5 2bdb2eb4cb
CI / Build & Quality Checks (push) Successful in 1m40s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Canceled after 1m37s
feat(messages): word diff of the last edit on "(edited)" hover (#144)
Hovering or focusing "(edited)" shows a tooltip with only the most recent
edit as a word diff — removed words struck, added words bold — plus a
+N/−N summary. Clicking still opens the full history viewer. On touch, a
long-press on the label shows the same diff as a popout (a plain tap opens
the viewer; the message's own long-press action sheet is not triggered).

utils/wordDiff.ts is a unit-tested LCS over words that ignores whitespace-
only changes and gives up past 400 words. Only plain-text bodies are
diffed; formatted edits fall back to the viewer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:34:38 -04:00

279 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Box,
Icon,
Icons,
PopOut,
RectCords,
Text,
Tooltip,
TooltipProvider,
as,
color,
config,
} from 'folds';
import React, { useEffect, useState } from 'react';
import { useLongPress } from '../../../hooks/useLongPress';
import { DiffToken, countChanges } from '../../../utils/wordDiff';
import { useEditDiff } from './EditDiffContext';
const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 };
const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 };
export const MessageDeletedContent = as<'div', { children?: never; reason?: string }>(
({ reason, ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Delete} />
<i>
{reason ? `This message has been deleted — ${reason}` : 'This message has been deleted'}
</i>
</Box>
),
);
export const MessageUnsupportedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Warning} />
<i>Unsupported message</i>
</Box>
));
export const MessageFailedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Warning} />
<i>Failed to load message</i>
</Box>
));
export const MessageBadEncryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Lock} />
<i>Unable to decrypt message</i>
</Box>
));
export const MessageNotDecryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Lock} />
<i>This message is not decrypted yet</i>
</Box>
));
export const MessageBrokenContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Warning} />
<i>Broken message</i>
</Box>
));
export const MessageEmptyContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Warning} />
<i>Empty message</i>
</Box>
));
export const MessageVerificationRequestContent = as<'div', { children?: never }>(
({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Lock} />
<i>Device verification request — open another Matrix client to accept</i>
</Box>
),
);
const editDiffStyle: React.CSSProperties = {
maxWidth: '40ch',
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
maxHeight: '12lh',
overflow: 'hidden',
};
/** The last edit as a word diff: removed words struck, added words bold. */
export function EditDiffBody({ tokens, hint }: { tokens: DiffToken[]; hint: string }) {
const { added, removed } = countChanges(tokens);
return (
<Box direction="Column" gap="100" style={editDiffStyle}>
<Text size="T200" priority="300">
Last edit
{added > 0 && ` · +${added}`}
{removed > 0 && ` · −${removed}`}
</Text>
<Text as="span" size="T300" dir="auto">
{tokens.map((t, i) => {
if (t.op === 'add')
return (
<ins key={i} style={{ textDecoration: 'none', fontWeight: 'bold' }}>
{t.text}
</ins>
);
if (t.op === 'del')
return (
<del key={i} style={{ opacity: config.opacity.P300 }}>
{t.text}
</del>
);
return <React.Fragment key={i}>{t.text}</React.Fragment>;
})}
</Text>
<Text size="T200" priority="300">
{hint}
</Text>
</Box>
);
}
const editedButtonStyle: React.CSSProperties = {
cursor: 'pointer',
background: 'none',
border: 'none',
padding: 0,
};
/**
* "(edited)" label. With an edit-history handler it is a button that opens
* the viewer; when the surrounding message also provides a last-edit diff
* (see `EditDiffContext`) hover/focus shows it as a tooltip and a touch
* long-press shows it as a popout. [Gitea #144]
*/
export const MessageEditedContent = as<
'span',
{ children?: never; onEditHistoryClick?: () => void }
>(({ onEditHistoryClick, ...props }, ref) => {
const diff = useEditDiff();
const [pressAnchor, setPressAnchor] = useState<RectCords | undefined>();
const { coarse, handlers, suppressContextMenu } = useLongPress((x, y) => {
if (diff) setPressAnchor({ x, y, width: 1, height: 1 });
});
useEffect(() => {
if (!pressAnchor) return undefined;
const close = () => setPressAnchor(undefined);
const t = window.setTimeout(close, 6000);
window.addEventListener('touchstart', close, { passive: true });
window.addEventListener('scroll', close, { passive: true, capture: true });
return () => {
window.clearTimeout(t);
window.removeEventListener('touchstart', close);
window.removeEventListener('scroll', close, { capture: true });
};
}, [pressAnchor]);
if (!onEditHistoryClick) {
return (
<Text as="span" size="T200" priority="300" {...props} ref={ref}>
{' (edited)'}
</Text>
);
}
const button = (tipRef?: React.RefCallback<HTMLElement>) => (
<button
ref={tipRef}
type="button"
onClick={() => {
if (suppressContextMenu.current) return;
onEditHistoryClick();
}}
onContextMenu={(evt) => {
if (suppressContextMenu.current) evt.preventDefault();
}}
{...handlers}
// Keep the message's own long-press (action sheet) from firing too.
onTouchStart={(evt) => {
evt.stopPropagation();
handlers.onTouchStart?.(evt);
}}
aria-label={diff ? 'View edit history — hover for the last change' : 'View edit history'}
style={editedButtonStyle}
>
<Text as="span" size="T200" priority="300">
{' (edited)'}
</Text>
</button>
);
return (
<span ref={ref} {...(props as React.HTMLAttributes<HTMLSpanElement>)}>
{diff ? (
<TooltipProvider
position="Top"
align="Start"
delay={300}
tooltip={
<Tooltip>
<EditDiffBody
tokens={diff}
hint={coarse ? 'Tap for full history' : 'Click for full history'}
/>
</Tooltip>
}
>
{(tipRef) => button(tipRef)}
</TooltipProvider>
) : (
button()
)}
{diff && pressAnchor && (
<PopOut
anchor={pressAnchor}
position="Top"
align="Start"
content={
<Tooltip>
<EditDiffBody
tokens={diff}
hint={coarse ? 'Tap for full history' : 'Click for full history'}
/>
</Tooltip>
}
/>
)}
</span>
);
});
type TranslatedStatus = 'downloading' | 'translating' | 'done' | 'error';
const translatedLabel = (
status: TranslatedStatus,
fromLangName: string,
downloadProgress?: number,
): string => {
if (status === 'downloading') {
const pct =
typeof downloadProgress === 'number' ? ` ${Math.round(downloadProgress * 100)}%` : '';
return `Downloading translation model…${pct}`;
}
if (status === 'translating') return 'Translating…';
if (status === 'error') return 'Translation failed — show original';
return `Translated from ${fromLangName} · Show original`;
};
// Inline chip shown beside a translated message body. Clicking it toggles back
// to the original text (the same per-event toggle the message menu drives).
export const MessageTranslatedContent = as<
'span',
{
children?: never;
fromLangName: string;
status: TranslatedStatus;
downloadProgress?: number;
onToggle: () => void;
}
>(({ fromLangName, status, downloadProgress, onToggle, ...props }, ref) => (
<span ref={ref} {...(props as React.HTMLAttributes<HTMLSpanElement>)}>
<button
type="button"
onClick={onToggle}
aria-pressed={status === 'done'}
aria-label="Toggle message translation"
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
>
<Text as="span" size="T200" priority="300">
{` · ${translatedLabel(status, fromLangName, downloadProgress)}`}
</Text>
</button>
</span>
));