Files
cinny/src/app/components/message/content/FallbackContent.tsx
T

279 lines
8.7 KiB
TypeScript
Raw Normal View History

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 };
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} />
<i>
{reason ? `This message has been deleted — ${reason}` : 'This message has been deleted'}
</i>
2023-10-06 13:44:06 +11:00
</Box>
),
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>
));
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>
));