2026-09-19 23:34:38 -04:00
|
|
|
|
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';
|
2023-10-06 13:44:06 +11:00
|
|
|
|
|
|
|
|
|
|
const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 };
|
|
|
|
|
|
const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 };
|
|
|
|
|
|
|
2026-05-15 14:39:16 -04:00
|
|
|
|
export const MessageDeletedContent = as<'div', { children?: never; reason?: string }>(
|
|
|
|
|
|
({ reason, ...props }, ref) => (
|
2023-10-06 13:44:06 +11:00
|
|
|
|
<Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
|
|
|
|
|
|
<Icon size="50" src={Icons.Delete} />
|
2026-05-21 20:49:33 -04:00
|
|
|
|
<i>
|
|
|
|
|
|
{reason ? `This message has been deleted — ${reason}` : 'This message has been deleted'}
|
|
|
|
|
|
</i>
|
2023-10-06 13:44:06 +11:00
|
|
|
|
</Box>
|
2026-05-21 23:30:50 -04:00
|
|
|
|
),
|
2023-10-06 13:44:06 +11:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
));
|
|
|
|
|
|
|
2026-05-23 11:15:49 -04:00
|
|
|
|
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>
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-19 23:34:38 -04:00
|
|
|
|
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]
|
|
|
|
|
|
*/
|
2026-06-01 17:21:11 -04:00
|
|
|
|
export const MessageEditedContent = as<
|
|
|
|
|
|
'span',
|
|
|
|
|
|
{ children?: never; onEditHistoryClick?: () => void }
|
2026-09-19 23:34:38 -04:00
|
|
|
|
>(({ 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 (
|
2026-06-01 17:21:11 -04:00
|
|
|
|
<span ref={ref} {...(props as React.HTMLAttributes<HTMLSpanElement>)}>
|
2026-09-19 23:34:38 -04:00
|
|
|
|
{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>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-06-01 17:21:11 -04:00
|
|
|
|
</span>
|
2026-09-19 23:34:38 -04:00
|
|
|
|
);
|
|
|
|
|
|
});
|
2026-07-18 16:08:40 -04:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
));
|