feat(messages): word diff of the last edit on "(edited)" hover (#144)
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
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
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
This commit is contained in:
@@ -26,6 +26,8 @@ import {
|
||||
VerificationRequestContent,
|
||||
VideoContent,
|
||||
} from './message';
|
||||
import { DiffToken } from '../utils/wordDiff';
|
||||
import { EditDiffContext } from './message/content/EditDiffContext';
|
||||
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
|
||||
import { Image, MediaControl, Video } from './media';
|
||||
import { ImageViewer } from './image-viewer';
|
||||
@@ -74,8 +76,17 @@ type RenderMessageContentProps = {
|
||||
onOpenImageViewer?: () => void;
|
||||
/** [Gitea #159] The event, for the undecryptable placeholder's reason + retry. */
|
||||
mEvent?: MatrixEvent;
|
||||
/** [Gitea #144] Word diff of the last edit, shown on "(edited)" hover. */
|
||||
editDiff?: DiffToken[];
|
||||
};
|
||||
export function RenderMessageContent({
|
||||
export function RenderMessageContent({ editDiff, ...props }: RenderMessageContentProps) {
|
||||
return (
|
||||
<EditDiffContext.Provider value={editDiff}>
|
||||
<RenderMessageContentBody {...props} />
|
||||
</EditDiffContext.Provider>
|
||||
);
|
||||
}
|
||||
function RenderMessageContentBody({
|
||||
displayName,
|
||||
msgType,
|
||||
ts,
|
||||
@@ -91,7 +102,7 @@ export function RenderMessageContent({
|
||||
eventId,
|
||||
onOpenImageViewer,
|
||||
mEvent,
|
||||
}: RenderMessageContentProps) {
|
||||
}: Omit<RenderMessageContentProps, 'editDiff'>) {
|
||||
const renderUrlsPreview = (urls: string[]) => {
|
||||
// Cap previews per message so a link-dump doesn't spawn dozens of preview
|
||||
// fetches + iframes at once. De-dupe first: a message linking the same URL
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { DiffToken } from '../../../utils/wordDiff';
|
||||
|
||||
/** [Gitea #144] Word diff of the message's last edit, for the "(edited)" hover. */
|
||||
export const EditDiffContext = createContext<DiffToken[] | undefined>(undefined);
|
||||
export const useEditDiff = () => useContext(EditDiffContext);
|
||||
@@ -1,5 +1,20 @@
|
||||
import { Box, Icon, Icons, Text, as, color, config } from 'folds';
|
||||
import React from 'react';
|
||||
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 };
|
||||
@@ -66,29 +81,157 @@ export const MessageVerificationRequestContent = as<'div', { children?: never }>
|
||||
),
|
||||
);
|
||||
|
||||
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) =>
|
||||
onEditHistoryClick ? (
|
||||
>(({ 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>)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditHistoryClick}
|
||||
aria-label="View edit history"
|
||||
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
|
||||
>
|
||||
<Text as="span" size="T200" priority="300">
|
||||
{' (edited)'}
|
||||
</Text>
|
||||
</button>
|
||||
{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>
|
||||
) : (
|
||||
<Text as="span" size="T200" priority="300" {...props} ref={ref}>
|
||||
{' (edited)'}
|
||||
</Text>
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
type TranslatedStatus = 'downloading' | 'translating' | 'done' | 'error';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user