Compare commits
2
Commits
b69099a862
...
2bdb2eb4cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bdb2eb4cb | ||
|
|
4fe9c87010 |
@@ -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';
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
isMembershipChanged,
|
||||
reactionOrEditEvent,
|
||||
} from '../../utils/room';
|
||||
import { getLastEditDiff } from '../../utils/editDiff';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { MessageLayout, settingsAtom } from '../../state/settings';
|
||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||
@@ -1267,6 +1268,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
ts={mEvent.getTs()}
|
||||
edited={!!editedEvent}
|
||||
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
|
||||
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
|
||||
getContent={getContent}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
@@ -1396,6 +1398,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
ts={mEvent.getTs()}
|
||||
edited={!!editedEvent}
|
||||
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
|
||||
onEditHistoryClick={
|
||||
editedEvent ? () => setEditHistoryEvent(mEvent) : undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MatrixEvent, RoomStateEvent } from 'matrix-js-sdk';
|
||||
import { MatrixRTCSessionManagerEvents } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
|
||||
/**
|
||||
* [Gitea #148] Whether any of the given rooms has a live MatrixRTC call.
|
||||
* Derived from the same session memberships the sidebar voice rows use;
|
||||
* recomputed only when a session starts/ends or a call-member state event
|
||||
* lands in one of the rooms — no per-render scans.
|
||||
*/
|
||||
export function useAnyRoomLiveCall(roomIds: string[]): boolean {
|
||||
const mx = useMatrixClient();
|
||||
const idSet = useMemo(() => new Set(roomIds), [roomIds]);
|
||||
const compute = () =>
|
||||
roomIds.some((id) => {
|
||||
const room = mx.getRoom(id);
|
||||
return !!room && mx.matrixRTC.getRoomSession(room).memberships.length > 0;
|
||||
});
|
||||
const [live, setLive] = useState(compute);
|
||||
|
||||
useEffect(() => {
|
||||
setLive(compute());
|
||||
const onSession = (roomId: string) => {
|
||||
if (idSet.has(roomId)) setLive(compute());
|
||||
};
|
||||
const onState = (event: MatrixEvent) => {
|
||||
if (event.getType() !== StateEvent.GroupCallMemberPrefix) return;
|
||||
const roomId = event.getRoomId();
|
||||
if (roomId && idSet.has(roomId)) setLive(compute());
|
||||
};
|
||||
mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onSession);
|
||||
mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onSession);
|
||||
mx.on(RoomStateEvent.Events, onState);
|
||||
return () => {
|
||||
mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onSession);
|
||||
mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onSession);
|
||||
mx.off(RoomStateEvent.Events, onState);
|
||||
};
|
||||
// compute closes over roomIds; idSet changes exactly when roomIds does
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mx, idSet]);
|
||||
|
||||
return live;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { color, toRem } from 'folds';
|
||||
|
||||
/** [Gitea #148] Bottom-right live-call dot on a space tab. */
|
||||
export const LiveDot = style({
|
||||
position: 'absolute',
|
||||
right: toRem(-2),
|
||||
bottom: toRem(-2),
|
||||
width: toRem(10),
|
||||
height: toRem(10),
|
||||
borderRadius: '50%',
|
||||
backgroundColor: color.Critical.Main,
|
||||
boxShadow: `0 0 0 ${toRem(2)} ${color.Background.Container}`,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
});
|
||||
@@ -47,6 +47,8 @@ import {
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { useAnyRoomLiveCall } from '../../../hooks/useSpaceLiveCall';
|
||||
import { LiveDot } from './SpaceTabs.css';
|
||||
import {
|
||||
getOriginBaseUrl,
|
||||
getSpaceLobbyPath,
|
||||
@@ -450,6 +452,15 @@ function SpaceTab({
|
||||
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
// [Gitea #148] Subtle live-call dot for a space you're not looking at.
|
||||
const roomToParentsForLive = useAtomValue(roomToParentsAtom);
|
||||
const liveChildren = useSpaceChildren(
|
||||
allRoomsAtom,
|
||||
space.roomId,
|
||||
useRecursiveChildScopeFactory(mx, roomToParentsForLive),
|
||||
);
|
||||
const liveCall = useAnyRoomLiveCall(liveChildren);
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
@@ -497,6 +508,14 @@ function SpaceTab({
|
||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||
</SidebarItemBadge>
|
||||
)}
|
||||
{liveCall && !selected && (
|
||||
<span
|
||||
className={LiveDot}
|
||||
role="img"
|
||||
aria-label="A call is live in this space"
|
||||
title="A call is live in this space"
|
||||
/>
|
||||
)}
|
||||
{menuAnchor && (
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { EventTimelineSet, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { getEventEdits, trimReplyFromBody } from './room';
|
||||
import { DiffToken, wordDiff } from './wordDiff';
|
||||
|
||||
const isPlainText = (content: Record<string, unknown> | undefined): content is { body: string } =>
|
||||
!!content &&
|
||||
typeof content.body === 'string' &&
|
||||
content.format === undefined &&
|
||||
content.formatted_body === undefined;
|
||||
|
||||
/**
|
||||
* [Gitea #144] Word diff of the *last* edit to `mEvent`: the newest edit's body
|
||||
* against the one it replaced (an earlier edit, or the original). Only for
|
||||
* plain-text bodies — formatted edits return `undefined` and the caller falls
|
||||
* back to the history viewer.
|
||||
*/
|
||||
export function getLastEditDiff(
|
||||
mEvent: MatrixEvent,
|
||||
timelineSet: EventTimelineSet,
|
||||
): DiffToken[] | undefined {
|
||||
const eventId = mEvent.getId();
|
||||
if (!eventId) return undefined;
|
||||
const relations = getEventEdits(timelineSet, eventId, mEvent.getType());
|
||||
if (!relations) return undefined;
|
||||
const edits = relations
|
||||
.getRelations()
|
||||
.filter((e) => e.getSender() === mEvent.getSender())
|
||||
.sort((m1, m2) => m2.getTs() - m1.getTs());
|
||||
const latest = edits[0];
|
||||
if (!latest) return undefined;
|
||||
const after = latest.getContent()['m.new_content'] as Record<string, unknown> | undefined;
|
||||
const before = (edits[1]?.getContent()['m.new_content'] ?? mEvent.getContent()) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!isPlainText(after) || !isPlainText(before)) return undefined;
|
||||
return wordDiff(trimReplyFromBody(before.body), trimReplyFromBody(after.body));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { countChanges, wordDiff, WORD_DIFF_LIMIT } from './wordDiff';
|
||||
|
||||
const render = (tokens: ReturnType<typeof wordDiff>) =>
|
||||
tokens
|
||||
?.map((t) =>
|
||||
t.op === 'same'
|
||||
? t.text
|
||||
: `${t.op === 'add' ? '+' : '-'}[${t.text.trim()}]${t.text.endsWith(' ') ? ' ' : ''}`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
describe('wordDiff', () => {
|
||||
it('returns undefined for identical text', () => {
|
||||
assert.equal(wordDiff('hello world', 'hello world'), undefined);
|
||||
});
|
||||
|
||||
it('marks a replaced word', () => {
|
||||
assert.equal(render(wordDiff('see you at 5', 'see you at 6')), 'see you at -[5]+[6]');
|
||||
});
|
||||
|
||||
it('marks insertions and deletions in place', () => {
|
||||
assert.equal(
|
||||
render(wordDiff('the quick fox', 'the quick brown fox jumps')),
|
||||
'the quick +[brown] fox +[jumps]',
|
||||
);
|
||||
assert.equal(render(wordDiff('a b c d', 'a d')), 'a -[b c] d');
|
||||
});
|
||||
|
||||
it('ignores whitespace-only changes', () => {
|
||||
assert.equal(wordDiff('a b', 'a b'), undefined);
|
||||
assert.equal(wordDiff('a b\n', 'a b'), undefined);
|
||||
});
|
||||
|
||||
it('handles fully different text', () => {
|
||||
assert.deepEqual(wordDiff('one', 'two'), [
|
||||
{ op: 'del', text: 'one' },
|
||||
{ op: 'add', text: 'two' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives up past the word limit', () => {
|
||||
const long = Array.from({ length: WORD_DIFF_LIMIT }, (_, i) => `w${i}`).join(' ');
|
||||
assert.equal(wordDiff(long, `${long} extra`), undefined);
|
||||
});
|
||||
|
||||
it('counts changed words', () => {
|
||||
const d = wordDiff('a b c', 'a x y z c');
|
||||
assert.ok(d);
|
||||
assert.deepEqual(countChanges(d), { added: 3, removed: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* [Gitea #144] Word-level diff for the "(edited)" hover. Plain text only; the
|
||||
* caller decides what to do with formatted bodies. LCS over word tokens, with
|
||||
* whitespace preserved on the token that precedes it so the rendered result
|
||||
* reads like the original message.
|
||||
*/
|
||||
|
||||
export type DiffOp = 'same' | 'add' | 'del';
|
||||
export type DiffToken = { op: DiffOp; text: string };
|
||||
|
||||
/** Above this many words (before + after) we give up: O(n·m) memory and an unreadable tooltip. */
|
||||
export const WORD_DIFF_LIMIT = 400;
|
||||
|
||||
const tokenize = (text: string): string[] => text.match(/\S+\s*|\s+/g) ?? [];
|
||||
const word = (token: string) => token.trim();
|
||||
|
||||
/**
|
||||
* Diff `before` → `after` into runs. Returns `undefined` when the texts are
|
||||
* identical or too long to diff meaningfully (caller should fall back to the
|
||||
* full history viewer).
|
||||
*/
|
||||
export function wordDiff(before: string, after: string): DiffToken[] | undefined {
|
||||
if (before === after) return undefined;
|
||||
const a = tokenize(before);
|
||||
const b = tokenize(after);
|
||||
if (a.length + b.length > WORD_DIFF_LIMIT) return undefined;
|
||||
|
||||
// LCS table on trimmed words so whitespace-only changes don't count as edits.
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const lcs: Uint16Array[] = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
|
||||
for (let i = n - 1; i >= 0; i -= 1) {
|
||||
for (let j = m - 1; j >= 0; j -= 1) {
|
||||
lcs[i][j] =
|
||||
word(a[i]) === word(b[j]) ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const out: DiffToken[] = [];
|
||||
const push = (op: DiffOp, text: string) => {
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.op === op) last.text += text;
|
||||
else out.push({ op, text });
|
||||
};
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (word(a[i]) === word(b[j])) {
|
||||
push('same', b[j]);
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
push('del', a[i]);
|
||||
i += 1;
|
||||
} else {
|
||||
push('add', b[j]);
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
while (i < n) push('del', a[i++]);
|
||||
while (j < m) push('add', b[j++]);
|
||||
|
||||
// Only whitespace moved around: nothing worth showing.
|
||||
if (out.every((t) => t.op === 'same' || t.text.trim() === '')) return undefined;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Number of changed words in a diff (for a compact summary). */
|
||||
export const countChanges = (tokens: DiffToken[]): { added: number; removed: number } => {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
tokens.forEach((t) => {
|
||||
const words = t.text.trim() === '' ? 0 : t.text.trim().split(/\s+/).length;
|
||||
if (t.op === 'add') added += words;
|
||||
if (t.op === 'del') removed += words;
|
||||
});
|
||||
return { added, removed };
|
||||
};
|
||||
Reference in New Issue
Block a user