Compare commits
13
Commits
b6413d763d
...
82eb65b822
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82eb65b822 | ||
|
|
708a15d196 | ||
|
|
120ad2d1b5 | ||
|
|
b38df58b68 | ||
|
|
13304f1571 | ||
|
|
87b1c1a702 | ||
|
|
125af8446f | ||
|
|
9a4796c167 | ||
|
|
5b58e5fe43 | ||
|
|
eaf6853910 | ||
|
|
c44ef8d795 | ||
|
|
90e6901a60 | ||
|
|
961789fd71 |
+9
-2
@@ -660,6 +660,7 @@ The indicator is hidden once the server confirms the event (when the internal st
|
||||
|
||||
- API: `GET /_matrix/client/v1/rooms/{roomId}/relations/{eventId}/m.replace`
|
||||
- E2EE fix: the "Original" entry uses `getClearContent()` to retrieve the decrypted content rather than the encrypted payload
|
||||
- **Word-level diff**: a "Highlight changes" toggle (on by default) renders each edit as a word diff against the previous version — added words highlighted (green), removed words struck-through (red) — using semantic `<ins>`/`<del>`. Toggle off to see full text (formatted messages render rich there; the diff is plain-text only). Diff logic is the pure, unit-tested `diffWords` (LCS) in `src/app/utils/textDiff.ts`.
|
||||
- Accessible from the message context menu
|
||||
|
||||
### Inline GIF Preview
|
||||
@@ -673,6 +674,7 @@ The indicator is hidden once the server confirms the event (when the internal st
|
||||
- Giphy-powered picker accessible from the composer toolbar
|
||||
- The button is only shown when `gifApiKey` is set in `config.json`
|
||||
- Selected GIFs are sent as `m.image` events
|
||||
- **Recently used**: a "Recent" row at the top of the picker (shown on the default view, hidden while searching) surfaces the GIFs you last sent for one-click re-sending — no re-searching. Persisted in localStorage (`cinny_recent_gifs_v1`), deduped by url, most-recent-first, capped at 16, via the pure/unit-tested `addRecentGif` (`src/app/state/recentGifs.ts`).
|
||||
- Picker UI is styled with TDS variables when the TDS theme is active
|
||||
- Located at `src/app/components/GifPicker.tsx`
|
||||
|
||||
@@ -685,6 +687,7 @@ Context menu → **Forward** allows forwarding a message to any room the user is
|
||||
- Composer drafts are stored in `localStorage` keyed by `roomId`
|
||||
- Draft is cleared on successful send
|
||||
- The Jotai atom is the primary source of truth; `localStorage` is only read on room mount
|
||||
- **Room-nav draft indicator**: a subtle green dot (the composer's shared `DraftDot`, `color.Success.Main`) appears on a room's nav item when it has an unsent message draft (and isn't the open room), so you can see at a glance where you left half-written messages. The dot reuses the composer draft affordance's vocabulary (`role="img"`, aria-label "Unsent draft"). It reacts to the shared draft atom via a memoized `selectAtom(…, hasMsgDraft)` (re-renders only when the flag flips; the atom is written on room-leave, not per keystroke). `useHydrateMsgDrafts` (mounted in `ClientNonUIFeatures`) pre-fills the draft atoms from `draft-msg-*` localStorage on startup so indicators are correct after a reload. Emptiness check shared via the pure, unit-tested `hasMsgDraft` (`src/app/utils/draft.ts`), also used by the composer's `DraftIndicator`.
|
||||
|
||||
### Message Search Date Range
|
||||
|
||||
@@ -712,7 +715,9 @@ KaTeX-rendered math in messages, two paths:
|
||||
|
||||
### Image / Video Captions
|
||||
|
||||
Images and videos can be sent with a caption. The caption and media are sent as a single event.
|
||||
Images and videos can be sent with a caption. The caption and media are sent as a single event (caption = the event `body` when it differs from `filename`).
|
||||
|
||||
- **Edit caption**: an image/video you sent shows an **Edit caption** action (quick-actions pencil + message menu). It opens the message editor seeded with the current caption; saving sends an `m.replace` whose `m.new_content` preserves the media (`url`/`info`/encrypted `file`/`filename`) and only changes `body`/`formatted_body`. An empty caption removes it (`body` falls back to `filename`). Gated by `canEditCaption` (`utils/room.ts`) to your own image/video messages that carry an MSC2530 `filename`. Caption edits carry `m.mentions` (an @-mention in a caption notifies) and appear in Edit History (diffed by the word-diff) — even after a caption is removed, the "(edited)" marker remains so history stays reachable. No re-upload — encrypted media keeps its original file/key.
|
||||
|
||||
### Location Sharing
|
||||
|
||||
@@ -735,7 +740,8 @@ Redacted events display "This message has been deleted" along with the redaction
|
||||
|
||||
- Implements MSC4140 delayed events for scheduling messages to be sent at a future time
|
||||
- `ScheduleMessageModal.tsx` provides the date/time picker UI
|
||||
- A collapsible "Scheduled" tray in the room shows all pending scheduled messages with individual edit and cancel buttons
|
||||
- A collapsible "Scheduled" tray in the room shows all pending scheduled messages with individual send-now, edit, and cancel buttons
|
||||
- **Send now**: the tray's send button fires a pending message immediately via MSC4140 `action: 'send'` (the server dispatches the stored delayed event now, as a normal timeline event — no cancel+retype). The row is pruned only once the server confirms; a failed send leaves an inline "Could not send now" error with the message still sendable/editable/cancellable.
|
||||
- **Edit / reschedule**: the tray's edit button re-opens `ScheduleMessageModal` (seeded with the existing body + send-time) to change the text and/or time. Since MSC4140 has no in-place edit, this is implemented as schedule-new-then-cancel-old; the old copy is only removed once the server confirms cancellation, so a failed cancel leaves a visible, cancellable copy rather than losing the message. Edits go through the plain-text composer (rich content becomes `m.text`).
|
||||
- Utilities in `src/app/utils/scheduledMessages.ts`
|
||||
|
||||
@@ -854,6 +860,7 @@ player.kick).
|
||||
|
||||
- **Waveform scrubbing** — voice messages carry an MSC1767 waveform (`org.matrix.msc1767.audio.waveform`, sent by the recorder). Playback renders it as bars that fill with the accent as the clip plays (TDS green under Lotus Terminal), and the waveform **is** the seek control — click, drag, or keyboard (arrows ±5s, Home/End) to scrub (`role="slider"`, ARIA value text). Threaded through `MAudio` (`MsgTypeRenderers.tsx`) + passed directly by the gallery. Regular audio with no waveform keeps the plain seek bar.
|
||||
- **Playback speed** — a cycle button (`[0.75, 1, 1.5, 2]×`); a `useEffect` sets `audioElement.playbackRate` and re-applies it on (re)load (the browser resets it).
|
||||
- **Recording pause / resume** — `VoiceMessageRecorder.tsx` supports a `paused` state via `MediaRecorder.pause()/resume()`, so you can pause mid-recording and continue without a gap. The duration timer accumulates only active-recording time (paused time is excluded), the waveform/meters freeze while paused and the record dot stops pulsing, and the finish button (which advances to the preview/review step) is a checkmark distinct from the Pause control.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
||||
import { IGif } from '@giphy/js-types';
|
||||
import { Box, color, config } from 'folds';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { addRecentGif, RecentGif, recentGifsAtom } from '../state/recentGifs';
|
||||
|
||||
const PICKER_WIDTH = 312;
|
||||
const PICKER_WIDTH_CSS = `min(${PICKER_WIDTH}px, calc(100vw - 16px))`;
|
||||
@@ -15,22 +17,135 @@ type GifPickerInnerProps = {
|
||||
lotusTerminal: boolean;
|
||||
};
|
||||
|
||||
// Small monospace section header matching the picker's `// GIF_SEARCH` treatment
|
||||
// (lotusTerminal) / a muted label otherwise.
|
||||
const RECENT_LABEL_ID = 'gif-picker-recent-label';
|
||||
|
||||
function SectionLabel({
|
||||
text,
|
||||
lotusTerminal,
|
||||
id,
|
||||
}: {
|
||||
text: string;
|
||||
lotusTerminal: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
if (lotusTerminal) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
style={{
|
||||
padding: '4px 2px',
|
||||
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.1em',
|
||||
color: 'var(--lt-accent-orange)',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{`// ${text.toUpperCase()}`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
style={{
|
||||
padding: '2px 2px 4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: color.Surface.OnContainer,
|
||||
opacity: 0.6,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentGifs({
|
||||
recents,
|
||||
lotusTerminal,
|
||||
onPick,
|
||||
}: {
|
||||
recents: RecentGif[];
|
||||
lotusTerminal: boolean;
|
||||
onPick: (gif: RecentGif) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<SectionLabel text="Recent" lotusTerminal={lotusTerminal} id={RECENT_LABEL_ID} />
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby={RECENT_LABEL_ID}
|
||||
style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4 }}
|
||||
>
|
||||
{recents.map((g, i) => (
|
||||
<button
|
||||
key={g.url}
|
||||
type="button"
|
||||
aria-label={`Send recent GIF ${i + 1} of ${recents.length}`}
|
||||
onClick={() => onPick(g)}
|
||||
style={{
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
height: 72,
|
||||
borderRadius: lotusTerminal ? '4px' : '8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* A still preview (falls back to the animated url for pre-existing
|
||||
recents) so the Recent row doesn't autoplay many GIFs at once. */}
|
||||
<img
|
||||
src={g.previewUrl ?? g.url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInnerProps) {
|
||||
const { fetchGifs, searchKey } = React.useContext(SearchContext);
|
||||
const { fetchGifs, searchKey, term } = React.useContext(SearchContext);
|
||||
const [recents, setRecents] = useAtom(recentGifsAtom);
|
||||
|
||||
const sendGif = useCallback(
|
||||
(gif: RecentGif) => {
|
||||
setRecents((prev) => addRecentGif(prev, gif));
|
||||
onSelect(gif.url, gif.width, gif.height);
|
||||
requestClose();
|
||||
},
|
||||
[onSelect, requestClose, setRecents],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(gif: IGif, e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
const r = gif.images.downsized ?? gif.images.original;
|
||||
const { url } = r;
|
||||
const width = Number(r.width) || 200;
|
||||
const height = Number(r.height) || 200;
|
||||
onSelect(url, width, height);
|
||||
requestClose();
|
||||
const previewUrl =
|
||||
gif.images.fixed_width_small_still?.url ??
|
||||
gif.images.downsized_still?.url ??
|
||||
gif.images.original_still?.url;
|
||||
sendGif({
|
||||
url: r.url,
|
||||
width: Number(r.width) || 200,
|
||||
height: Number(r.height) || 200,
|
||||
previewUrl,
|
||||
});
|
||||
},
|
||||
[onSelect, requestClose],
|
||||
[sendGif],
|
||||
);
|
||||
|
||||
const showRecents = recents.length > 0 && !(term ?? '').trim();
|
||||
|
||||
return (
|
||||
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }}>
|
||||
{lotusTerminal && (
|
||||
@@ -57,6 +172,9 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
<div
|
||||
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
|
||||
>
|
||||
{showRecents && (
|
||||
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={sendGif} />
|
||||
)}
|
||||
<Grid
|
||||
key={searchKey}
|
||||
fetchGifs={fetchGifs}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { MsgType } from 'matrix-js-sdk';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { Opts } from 'linkifyjs';
|
||||
import { config } from 'folds';
|
||||
import { config, Text } from 'folds';
|
||||
import {
|
||||
AudioContent,
|
||||
DownloadFile,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
MAudio,
|
||||
MBadEncrypted,
|
||||
MEmote,
|
||||
MessageEditedContent,
|
||||
MFile,
|
||||
MImage,
|
||||
MLocation,
|
||||
@@ -119,6 +120,15 @@ export function RenderMessageContent({
|
||||
/>
|
||||
);
|
||||
}
|
||||
// No caption, but the media was edited (e.g. a caption was removed): keep the
|
||||
// "(edited)" affordance so Edit History stays reachable.
|
||||
if (edited) {
|
||||
return (
|
||||
<Text style={{ marginTop: config.space.S200 }} size="T200">
|
||||
<MessageEditedContent onEditHistoryClick={onEditHistoryClick} />
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Box, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds'
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
|
||||
type RecorderState = 'idle' | 'recording' | 'preview';
|
||||
type RecorderState = 'idle' | 'recording' | 'paused' | 'preview';
|
||||
|
||||
interface VoiceRecorderProps {
|
||||
onSend: (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => void;
|
||||
@@ -41,11 +41,15 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const rawSamplesRef = useRef<number[]>([]);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
// Active-recording duration excluding paused time: accumulated ms from prior
|
||||
// segments + (now - segmentStart) for the current segment.
|
||||
const accumulatedMsRef = useRef<number>(0);
|
||||
const segmentStartRef = useRef<number>(0);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
@@ -54,18 +58,67 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [previewPlaying, setPreviewPlaying] = useState(false);
|
||||
|
||||
const stopAll = useCallback(() => {
|
||||
// Start the waveform (rAF) + duration (interval) meters against the live
|
||||
// analyser. Reused by startRecording and resumeRecording.
|
||||
const startMeters = useCallback(() => {
|
||||
const analyser = analyserRef.current;
|
||||
if (!analyser) return;
|
||||
// Guard against ever running two loops.
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||||
const tick = () => {
|
||||
if (!analyserRef.current) return;
|
||||
analyserRef.current.getByteFrequencyData(buf);
|
||||
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
|
||||
rawSamplesRef.current.push(avg);
|
||||
setWaveformBars((prev) => [...prev.slice(1), Math.round((avg / 255) * 100)]);
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
timerRef.current = setInterval(() => {
|
||||
setDurationMs(accumulatedMsRef.current + (Date.now() - segmentStartRef.current));
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
// Stop the meters (rAF + interval) without tearing down the audio graph, so a
|
||||
// paused recording can resume.
|
||||
const stopMeters = useCallback(() => {
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Release the microphone. The mic tracks are independent of the MediaRecorder
|
||||
// and the AudioContext — neither mr.stop() nor audioCtx.close() releases them —
|
||||
// so they must be stopped explicitly (else the OS mic indicator stays on).
|
||||
const stopStream = useCallback(() => {
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}, []);
|
||||
|
||||
const stopAll = useCallback(() => {
|
||||
stopMeters();
|
||||
if (audioCtxRef.current) {
|
||||
audioCtxRef.current.close();
|
||||
audioCtxRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
}, []);
|
||||
}, [stopMeters]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
// Unmounting mid-recording/pause must release the mic — stopAll() only
|
||||
// closes the AudioContext and cancels the meters, not the mic tracks.
|
||||
const mr = mediaRecorderRef.current;
|
||||
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
|
||||
mr.ondataavailable = null;
|
||||
mr.onstop = null;
|
||||
mr.stop();
|
||||
}
|
||||
stopStream();
|
||||
stopAll();
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
},
|
||||
@@ -83,9 +136,11 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
|
||||
const mr = new MediaRecorder(stream, { mimeType });
|
||||
mediaRecorderRef.current = mr;
|
||||
streamRef.current = stream;
|
||||
chunksRef.current = [];
|
||||
rawSamplesRef.current = [];
|
||||
startTimeRef.current = Date.now();
|
||||
accumulatedMsRef.current = 0;
|
||||
segmentStartRef.current = Date.now();
|
||||
|
||||
const audioCtx = new AudioContext();
|
||||
audioCtxRef.current = audioCtx;
|
||||
@@ -95,24 +150,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
source.connect(analyser);
|
||||
analyserRef.current = analyser;
|
||||
|
||||
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||||
const tick = () => {
|
||||
if (!analyserRef.current) return;
|
||||
analyserRef.current.getByteFrequencyData(buf);
|
||||
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
|
||||
rawSamplesRef.current.push(avg);
|
||||
|
||||
setWaveformBars((prev) => {
|
||||
const next = [...prev.slice(1), Math.round((avg / 255) * 100)];
|
||||
return next;
|
||||
});
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setDurationMs(Date.now() - startTimeRef.current);
|
||||
}, 100);
|
||||
startMeters();
|
||||
|
||||
mr.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
@@ -120,8 +158,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
|
||||
mr.onstop = () => {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||
previewDurationRef.current = Date.now() - startTimeRef.current;
|
||||
setPreviewBlob(blob);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
@@ -135,33 +173,59 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
} catch {
|
||||
onError?.('Microphone access denied');
|
||||
}
|
||||
}, [onError]);
|
||||
}, [onError, startMeters]);
|
||||
|
||||
const pauseRecording = useCallback(() => {
|
||||
const mr = mediaRecorderRef.current;
|
||||
if (mr?.state !== 'recording') return;
|
||||
accumulatedMsRef.current += Date.now() - segmentStartRef.current;
|
||||
setDurationMs(accumulatedMsRef.current);
|
||||
stopMeters();
|
||||
mr.pause();
|
||||
setState('paused');
|
||||
}, [stopMeters]);
|
||||
|
||||
const resumeRecording = useCallback(() => {
|
||||
const mr = mediaRecorderRef.current;
|
||||
if (mr?.state !== 'paused') return;
|
||||
segmentStartRef.current = Date.now();
|
||||
mr.resume();
|
||||
startMeters();
|
||||
setState('recording');
|
||||
}, [startMeters]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
const mr = mediaRecorderRef.current;
|
||||
const activeMs = mr?.state === 'recording' ? Date.now() - segmentStartRef.current : 0;
|
||||
previewDurationRef.current = accumulatedMsRef.current + activeMs;
|
||||
stopAll();
|
||||
if (mediaRecorderRef.current?.state === 'recording') {
|
||||
mediaRecorderRef.current.stop();
|
||||
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
|
||||
mr.stop();
|
||||
}
|
||||
}, [stopAll]);
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
stopAll();
|
||||
const mr = mediaRecorderRef.current;
|
||||
if (mr?.state === 'recording') {
|
||||
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
|
||||
mr.ondataavailable = null;
|
||||
// onstop (which would release the mic) is cleared, so release it here.
|
||||
mr.onstop = null;
|
||||
mr.stop();
|
||||
}
|
||||
stopStream();
|
||||
setPreviewBlob(null);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
rawSamplesRef.current = [];
|
||||
accumulatedMsRef.current = 0;
|
||||
segmentStartRef.current = 0;
|
||||
setWaveformBars(Array(WAVEFORM_BARS).fill(0));
|
||||
setDurationMs(0);
|
||||
setState('idle');
|
||||
}, [stopAll]);
|
||||
}, [stopAll, stopStream]);
|
||||
|
||||
const sendVoice = useCallback(() => {
|
||||
if (!previewBlob) return;
|
||||
@@ -187,10 +251,11 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'recording') {
|
||||
if (state === 'recording' || state === 'paused') {
|
||||
const paused = state === 'paused';
|
||||
return (
|
||||
<Box
|
||||
data-voice-recorder="recording"
|
||||
data-voice-recorder={paused ? 'paused' : 'recording'}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{
|
||||
@@ -209,13 +274,15 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
borderRadius: '50%',
|
||||
background: lotusTerminal ? 'var(--lt-accent-orange)' : color.Critical.Main,
|
||||
flexShrink: 0,
|
||||
animation: 'pttLivePulse 900ms ease-in-out infinite',
|
||||
// Pulse only while actively recording; hold steady (dimmed) when paused.
|
||||
animation: paused ? 'none' : 'pttLivePulse 900ms ease-in-out infinite',
|
||||
opacity: paused ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
size="T200"
|
||||
role="timer"
|
||||
aria-label={`Recording duration ${formatDuration(durationMs)}`}
|
||||
aria-label={`${paused ? 'Paused' : 'Recording'}, duration ${formatDuration(durationMs)}`}
|
||||
style={{
|
||||
minWidth: toRem(32),
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
@@ -249,16 +316,29 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={paused ? resumeRecording : pauseRecording}
|
||||
aria-label={paused ? 'Resume recording' : 'Pause recording'}
|
||||
variant="SurfaceVariant"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
title={paused ? 'Resume' : 'Pause'}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Icon src={paused ? Icons.Play : Icons.Pause} size="100" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={stopRecording}
|
||||
aria-label="Stop recording"
|
||||
aria-label="Finish recording"
|
||||
variant="Primary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Stop recording"
|
||||
title="Finish"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Icon src={Icons.Pause} size="100" />
|
||||
<Icon src={Icons.Check} size="100" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={cancelRecording}
|
||||
@@ -267,6 +347,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Cancel"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
|
||||
@@ -317,6 +317,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
||||
readOnly={!canEdit || markedDeleted}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => commit({ body: e.target.value })}
|
||||
aria-label="Clip name"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react';
|
||||
import React, {
|
||||
MouseEventHandler,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { MatrixClient, Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Avatar,
|
||||
@@ -27,6 +34,7 @@ import {
|
||||
import { useFocusWithin, useHover } from 'react-aria';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import isToday from 'dayjs/plugin/isToday';
|
||||
import isYesterday from 'dayjs/plugin/isYesterday';
|
||||
@@ -40,6 +48,9 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import { markedUnreadAtom, setMarkedUnread } from '../../state/room/markedUnread';
|
||||
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||
import { hasMsgDraft } from '../../utils/draft';
|
||||
import { DraftDot } from '../room/DraftIndicator.css';
|
||||
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
@@ -678,6 +689,15 @@ function RoomNavItem_({
|
||||
const roomName = useLocalRoomName(room);
|
||||
const hasLocalName = useHasLocalRoomName(room.roomId);
|
||||
|
||||
// Whether this room has an unsent message draft. selectAtom maps to a boolean
|
||||
// so the row only re-renders when that flips (the draft atom itself is written
|
||||
// on room-leave, not per keystroke).
|
||||
const hasDraftAtom = useMemo(
|
||||
() => selectAtom(roomIdToMsgDraftAtomFamily(room.roomId), hasMsgDraft),
|
||||
[room.roomId],
|
||||
);
|
||||
const hasDraft = useAtomValue(hasDraftAtom);
|
||||
|
||||
const latestEvent = useRoomLatestRenderedEvent(room);
|
||||
const dmPreview = (() => {
|
||||
if (!direct || !latestEvent) return null;
|
||||
@@ -813,6 +833,9 @@ function RoomNavItem_({
|
||||
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
{hasDraft && !selected && (
|
||||
<span className={DraftDot} role="img" aria-label="Unsent draft" />
|
||||
)}
|
||||
</Box>
|
||||
{dmPreview && (
|
||||
<Box as="span" alignItems="Center" gap="100" style={{ minWidth: 0 }}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useAtomValue } from 'jotai';
|
||||
import { Box, Text, config } from 'folds';
|
||||
|
||||
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||
import { toPlainText } from '../../components/editor';
|
||||
import { hasMsgDraft } from '../../utils/draft';
|
||||
import { DraftDot, DraftDotPulse, DraftIndicatorBase } from './DraftIndicator.css';
|
||||
|
||||
const PULSE_DURATION = 600;
|
||||
@@ -27,7 +27,7 @@ type DraftIndicatorProps = {
|
||||
export function DraftIndicator({ roomId }: DraftIndicatorProps) {
|
||||
const draft = useAtomValue(roomIdToMsgDraftAtomFamily(roomId));
|
||||
// Real content, not just an empty paragraph.
|
||||
const hasDraft = toPlainText(draft, false).trim().length > 0;
|
||||
const hasDraft = hasMsgDraft(draft);
|
||||
|
||||
const [pulse, setPulse] = useState(false);
|
||||
const hadDraft = useRef(false);
|
||||
|
||||
@@ -602,6 +602,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
setCharCount(0);
|
||||
setMsgDraft([]);
|
||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||
setReplyDraft(undefined);
|
||||
sendTypingStatus(false);
|
||||
@@ -614,6 +615,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
replyDraft,
|
||||
sendTypingStatus,
|
||||
setReplyDraft,
|
||||
setMsgDraft,
|
||||
isMarkdown,
|
||||
commands,
|
||||
setToast,
|
||||
@@ -687,11 +689,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
});
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
setMsgDraft([]);
|
||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||
setReplyDraft(undefined);
|
||||
sendTypingStatus(false);
|
||||
},
|
||||
[setScheduledMessages, roomId, draftKey, editor, setReplyDraft, sendTypingStatus],
|
||||
[
|
||||
setScheduledMessages,
|
||||
roomId,
|
||||
draftKey,
|
||||
editor,
|
||||
setReplyDraft,
|
||||
setMsgDraft,
|
||||
sendTypingStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IContent } from 'matrix-js-sdk';
|
||||
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
|
||||
import { cancelScheduledMessage } from '../../utils/scheduledMessages';
|
||||
import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages';
|
||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||
|
||||
interface ScheduledMessagesTrayProps {
|
||||
@@ -36,6 +36,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
||||
const [cancelErrors, setCancelErrors] = useState<Set<string>>(new Set());
|
||||
const [sendErrors, setSendErrors] = useState<Set<string>>(new Set());
|
||||
const [editing, setEditing] = useState<ScheduledMessage | null>(null);
|
||||
|
||||
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
|
||||
@@ -109,6 +110,47 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
[mx, roomId, cancelling, setScheduledMessages],
|
||||
);
|
||||
|
||||
const handleSendNow = useCallback(
|
||||
async (msg: ScheduledMessage) => {
|
||||
if (cancelling.has(msg.delayId)) return;
|
||||
setCancelling((prev) => new Set(prev).add(msg.delayId));
|
||||
setSendErrors((prev) => {
|
||||
if (!prev.has(msg.delayId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(msg.delayId);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await sendScheduledMessageNow(mx, msg.delayId);
|
||||
// Only prune once the server confirms the send. The delayed event is now
|
||||
// consumed and appears in the timeline; dropping it before confirmation
|
||||
// could hide a still-live event that never actually sent.
|
||||
setScheduledMessages((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(roomId) ?? [];
|
||||
const remaining = current.filter((m) => m.delayId !== msg.delayId);
|
||||
if (remaining.length === 0) {
|
||||
next.delete(roomId);
|
||||
} else {
|
||||
next.set(roomId, remaining);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Keep the item (still sendable/editable/cancellable) and surface an
|
||||
// inline error; the delayed event is still scheduled on the server.
|
||||
setSendErrors((prev) => new Set(prev).add(msg.delayId));
|
||||
} finally {
|
||||
setCancelling((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(msg.delayId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[mx, roomId, cancelling, setScheduledMessages],
|
||||
);
|
||||
|
||||
// Editing = cancel-old + schedule-new (MSC4140 has no in-place edit). The modal has
|
||||
// already scheduled the NEW message by the time this fires; add it, then cancel the
|
||||
// old one — removing the old from state only once the server confirms, so a failed
|
||||
@@ -133,6 +175,12 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
next.delete(oldMsg.delayId);
|
||||
return next;
|
||||
});
|
||||
setSendErrors((prev) => {
|
||||
if (!prev.has(oldMsg.delayId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(oldMsg.delayId);
|
||||
return next;
|
||||
});
|
||||
setEditing(null);
|
||||
cancelScheduledMessage(mx, oldMsg.delayId)
|
||||
.then(() => {
|
||||
@@ -246,6 +294,19 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{formatSendAt(msg.sendAt)}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
aria-label={`Send scheduled message now: ${rowDesc}`}
|
||||
disabled={cancelling.has(msg.delayId)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSendNow(msg);
|
||||
}}
|
||||
>
|
||||
<Icon src={Icons.Send} size="50" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
@@ -281,6 +342,14 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
Could not cancel this message. Try again.
|
||||
</Text>
|
||||
)}
|
||||
{sendErrors.has(msg.delayId) && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||
>
|
||||
Could not send now. Try again.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import React, { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import parse from 'html-react-parser';
|
||||
import Linkify from 'linkify-react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
OverlayCenter,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Switch,
|
||||
Text,
|
||||
color,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
@@ -28,6 +30,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { timeDayMonYear, timeHourMinute } from '../../../utils/time';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { diffWords } from '../../../utils/textDiff';
|
||||
|
||||
type RawEditEvent = {
|
||||
type: string;
|
||||
@@ -87,11 +90,76 @@ function getVersionContent(evt: MatrixEvent): ReactNode {
|
||||
return renderContent(newContent ?? content);
|
||||
}
|
||||
|
||||
const asText = (source: Record<string, unknown> | null | undefined): string => {
|
||||
const body = source?.body;
|
||||
return typeof body === 'string' ? body : '';
|
||||
};
|
||||
|
||||
// Plain-text (body) of the pre-edit message — mirrors getOriginalContent, but
|
||||
// returns the raw string for diffing rather than a rendered node.
|
||||
function getOriginalText(evt: MatrixEvent): string {
|
||||
const raw =
|
||||
(evt.getClearContent() as Record<string, unknown> | null) ??
|
||||
(evt.event as { content?: Record<string, unknown> }).content ??
|
||||
{};
|
||||
return asText(raw);
|
||||
}
|
||||
|
||||
// Plain-text (body) of an edit's new content.
|
||||
function getVersionText(evt: MatrixEvent): string {
|
||||
const content = evt.getContent();
|
||||
const newContent = content['m.new_content'] as Record<string, unknown> | undefined;
|
||||
return asText(newContent ?? content);
|
||||
}
|
||||
|
||||
// Renders a word-level diff of prev -> next: added words highlighted, removed
|
||||
// words struck-through, using semantic <ins>/<del> elements. Plain-text only
|
||||
// (formatting isn't diffed — see the "Highlight changes" toggle).
|
||||
function DiffText({ prev, next }: { prev: string; next: string }) {
|
||||
const segments = useMemo(() => diffWords(prev, next), [prev, next]);
|
||||
if (segments.length === 0) return <>(no text)</>;
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) => {
|
||||
const key = `${i}-${seg.type}`;
|
||||
if (seg.type === 'added') {
|
||||
return (
|
||||
<ins
|
||||
key={key}
|
||||
style={{
|
||||
background: color.Success.Container,
|
||||
color: color.Success.OnContainer,
|
||||
border: `${config.borderWidth.B300} solid ${color.Success.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `0 ${config.space.S100}`,
|
||||
textDecoration: 'none',
|
||||
boxDecorationBreak: 'clone',
|
||||
WebkitBoxDecorationBreak: 'clone',
|
||||
}}
|
||||
>
|
||||
{seg.text}
|
||||
</ins>
|
||||
);
|
||||
}
|
||||
if (seg.type === 'removed') {
|
||||
return (
|
||||
<del key={key} style={{ color: color.Critical.Main }}>
|
||||
{seg.text}
|
||||
</del>
|
||||
);
|
||||
}
|
||||
return <span key={key}>{seg.text}</span>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProps) {
|
||||
const mx = useMatrixClient();
|
||||
const modalStyle = useModalStyle(560);
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
const [showDiff, setShowDiff] = useState(true);
|
||||
|
||||
const eventId = mEvent.getId();
|
||||
const roomId = room.roomId;
|
||||
@@ -171,6 +239,10 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
|
||||
const originalTs = mEvent.getTs();
|
||||
|
||||
// Ordered plain-text of every version: [original, ...each edit]. Edit i diffs
|
||||
// against versionTexts[i] (the version immediately before it).
|
||||
const versionTexts = [getOriginalText(mEvent), ...edits.map(getVersionText)];
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
@@ -200,6 +272,12 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
Edit History
|
||||
</Text>
|
||||
</Box>
|
||||
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||
<Text size="T200" priority="300">
|
||||
Highlight changes
|
||||
</Text>
|
||||
<Switch variant="Primary" value={showDiff} onChange={setShowDiff} />
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
@@ -258,7 +336,11 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
size="T300"
|
||||
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||
>
|
||||
{getVersionContent(editEvt)}
|
||||
{showDiff ? (
|
||||
<DiffText prev={versionTexts[index]} next={versionTexts[index + 1]} />
|
||||
) : (
|
||||
getVersionContent(editEvt)
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -50,7 +50,8 @@ import {
|
||||
UsernameBold,
|
||||
} from '../../../components/message';
|
||||
import {
|
||||
canEditEvent,
|
||||
canEditCaption,
|
||||
canEditEventOrCaption,
|
||||
getEventEdits,
|
||||
getMemberAvatarMxc,
|
||||
getMemberName,
|
||||
@@ -1072,13 +1073,13 @@ export const Message = React.memo(
|
||||
<Icon src={Icons.ThreadPlus} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
{canEditEvent(mx, mEvent) && onEditId && (
|
||||
{canEditEventOrCaption(mx, mEvent) && onEditId && (
|
||||
<IconButton
|
||||
onClick={() => onEditId(mEvent.getId())}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Edit message"
|
||||
aria-label={canEditCaption(mx, mEvent) ? 'Edit caption' : 'Edit message'}
|
||||
>
|
||||
<Icon src={Icons.Pencil} size="100" />
|
||||
</IconButton>
|
||||
@@ -1247,7 +1248,7 @@ export const Message = React.memo(
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{canEditEvent(mx, mEvent) && onEditId && (
|
||||
{canEditEventOrCaption(mx, mEvent) && onEditId && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Pencil} />}
|
||||
@@ -1264,7 +1265,7 @@ export const Message = React.memo(
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
Edit Message
|
||||
{canEditCaption(mx, mEvent) ? 'Edit Caption' : 'Edit Message'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from 'folds';
|
||||
import { Editor, Transforms } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
|
||||
import { IContent, IMentions, MatrixEvent, MsgType, RelationType, Room } from 'matrix-js-sdk';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import {
|
||||
AUTOCOMPLETE_PREFIXES,
|
||||
@@ -75,6 +75,15 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
// message is being edited (a11y, P3-4).
|
||||
const editSenderId = mEvent.getSender();
|
||||
const editSenderName = editSenderId ? getMemberName(room, editSenderId) : '';
|
||||
// Image/video messages carry an optional caption in `body` (a caption exists
|
||||
// when body !== filename). Editing such a message edits the caption while
|
||||
// preserving the media, rather than replacing the content with text.
|
||||
const editMsgType = mEvent.getContent().msgtype;
|
||||
const isMediaCaption = editMsgType === MsgType.Image || editMsgType === MsgType.Video;
|
||||
const editFilename =
|
||||
typeof mEvent.getContent().filename === 'string'
|
||||
? (mEvent.getContent().filename as string)
|
||||
: undefined;
|
||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
@@ -118,6 +127,71 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
}),
|
||||
);
|
||||
|
||||
// Media caption edit: preserve the media, change only body/formatted_body.
|
||||
// An empty caption is valid (it removes the caption → body falls back to
|
||||
// the filename).
|
||||
if (isMediaCaption) {
|
||||
const evtId = mEvent.getId()!;
|
||||
const evtTimeline = room.getTimelineForEvent(evtId);
|
||||
const editedEvent =
|
||||
evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
|
||||
const orig: IContent = {
|
||||
...(editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()),
|
||||
};
|
||||
delete orig['m.relates_to'];
|
||||
delete orig['m.new_content'];
|
||||
|
||||
const filename =
|
||||
typeof orig.filename === 'string' ? orig.filename : (editFilename ?? '');
|
||||
const hasFormatting = !customHtmlEqualsPlainText(customHtml, plainText);
|
||||
|
||||
const mediaContent: IContent = { ...orig };
|
||||
if (plainText) {
|
||||
mediaContent.body = plainText;
|
||||
if (hasFormatting) {
|
||||
mediaContent.format = 'org.matrix.custom.html';
|
||||
mediaContent.formatted_body = customHtml;
|
||||
} else {
|
||||
delete mediaContent.format;
|
||||
delete mediaContent.formatted_body;
|
||||
}
|
||||
} else {
|
||||
// Caption removed.
|
||||
mediaContent.body = filename;
|
||||
delete mediaContent.format;
|
||||
delete mediaContent.formatted_body;
|
||||
}
|
||||
|
||||
// No-op guard: caption text/markup unchanged.
|
||||
if (
|
||||
mediaContent.body === orig.body &&
|
||||
mediaContent.formatted_body === orig.formatted_body
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Carry mentions typed into the caption (union with prior mentions), so
|
||||
// an @-mention in a caption edit notifies — mirrors the text path.
|
||||
const [, , prevMentions] = getPrevBodyAndFormattedBody();
|
||||
const mentionData = getMentions(mx, roomId, editor);
|
||||
prevMentions?.user_ids?.forEach((id) => mentionData.users.add(id));
|
||||
mediaContent['m.mentions'] = getMentionContent(
|
||||
Array.from(mentionData.users),
|
||||
mentionData.room,
|
||||
);
|
||||
|
||||
const content: IContent = {
|
||||
...mediaContent,
|
||||
'm.new_content': mediaContent,
|
||||
'm.relates_to': {
|
||||
event_id: evtId,
|
||||
rel_type: RelationType.Replace,
|
||||
},
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return mx.sendMessage(roomId, content as any);
|
||||
}
|
||||
|
||||
const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody();
|
||||
|
||||
if (plainText === '') return undefined;
|
||||
@@ -165,7 +239,17 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return mx.sendMessage(roomId, content as any);
|
||||
}, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody]),
|
||||
}, [
|
||||
mx,
|
||||
editor,
|
||||
roomId,
|
||||
room,
|
||||
mEvent,
|
||||
isMarkdown,
|
||||
getPrevBodyAndFormattedBody,
|
||||
isMediaCaption,
|
||||
editFilename,
|
||||
]),
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
@@ -220,10 +304,19 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
useEffect(() => {
|
||||
const [body, customHtml] = getPrevBodyAndFormattedBody();
|
||||
|
||||
// For media, seed from the caption only: an empty caption is `body ===
|
||||
// filename`, so don't prefill the filename into the editor.
|
||||
let seedText = typeof body === 'string' ? body : '';
|
||||
let seedHtml = typeof customHtml === 'string' ? customHtml : undefined;
|
||||
if (isMediaCaption && seedText === editFilename) {
|
||||
seedText = '';
|
||||
seedHtml = undefined;
|
||||
}
|
||||
|
||||
const initialValue =
|
||||
typeof customHtml === 'string'
|
||||
? htmlToEditorInput(customHtml, isMarkdown)
|
||||
: plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown);
|
||||
seedHtml !== undefined
|
||||
? htmlToEditorInput(seedHtml, isMarkdown)
|
||||
: plainToEditorInput(seedText, isMarkdown);
|
||||
|
||||
Transforms.select(editor, {
|
||||
anchor: Editor.start(editor, []),
|
||||
@@ -232,7 +325,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
|
||||
editor.insertFragment(initialValue);
|
||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||
}, [editor, getPrevBodyAndFormattedBody, isMarkdown]);
|
||||
}, [editor, getPrevBodyAndFormattedBody, isMarkdown, isMediaCaption, editFilename]);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveState.status === AsyncStatus.Success) {
|
||||
@@ -268,8 +361,14 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
)}
|
||||
<CustomEditor
|
||||
editor={editor}
|
||||
placeholder="Edit message..."
|
||||
ariaLabel={editSenderId ? `Editing message from ${editSenderName}` : 'Edit message'}
|
||||
placeholder={isMediaCaption ? 'Add a caption…' : 'Edit message...'}
|
||||
ariaLabel={
|
||||
isMediaCaption
|
||||
? `Editing caption for ${editSenderName}'s attachment`
|
||||
: editSenderId
|
||||
? `Editing message from ${editSenderName}`
|
||||
: 'Edit message'
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
bottom={
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useStore } from 'jotai';
|
||||
import { Descendant } from 'slate';
|
||||
import { roomIdToMsgDraftAtomFamily } from '../state/room/roomInputDrafts';
|
||||
import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft';
|
||||
|
||||
/**
|
||||
* On startup, pre-fill the per-room message-draft atoms from their localStorage
|
||||
* persistence (`draft-msg-<roomId>`). RoomInput only restores a draft into its
|
||||
* atom when that room's composer mounts, so without this a background room's
|
||||
* persisted draft wouldn't reflect in the room-nav draft indicator until the
|
||||
* room is opened. Runs once; only touches the same atoms RoomInput restores from
|
||||
* (identical content), so composer restore is unaffected.
|
||||
*
|
||||
* Thread drafts (key contains `::`) are skipped — the nav indicator is room-level.
|
||||
*/
|
||||
export function useHydrateMsgDrafts(): void {
|
||||
const store = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
let keys: string[];
|
||||
try {
|
||||
keys = Object.keys(localStorage);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
keys.forEach((key) => {
|
||||
if (!key.startsWith(DRAFT_MSG_KEY_PREFIX)) return;
|
||||
const draftKey = key.slice(DRAFT_MSG_KEY_PREFIX.length);
|
||||
// Skip thread drafts (`<roomId>::<$threadRootEventId>`) — room-level only.
|
||||
// Match `::$` specifically so an IPv6-literal server name (e.g. `[::1]`)
|
||||
// in a roomId isn't mistaken for a thread key.
|
||||
if (!draftKey || draftKey.includes('::$')) return;
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return;
|
||||
const nodes = JSON.parse(stored) as Descendant[];
|
||||
if (Array.isArray(nodes) && hasMsgDraft(nodes)) {
|
||||
store.set(roomIdToMsgDraftAtomFamily(draftKey), nodes);
|
||||
}
|
||||
} catch {
|
||||
// Ignore a malformed stored draft.
|
||||
}
|
||||
});
|
||||
}, [store]);
|
||||
}
|
||||
@@ -25,11 +25,8 @@ import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { allInvitesAtom } from '../../state/room-list/inviteList';
|
||||
|
||||
// Grace period after the initial sync settles before invite notifications arm, so
|
||||
// the async invite-atom population lands first and isn't mistaken for new invites.
|
||||
const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
|
||||
import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pathUtils';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import {
|
||||
@@ -67,6 +64,10 @@ import {
|
||||
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
||||
} from '../../utils/threadNotifications';
|
||||
|
||||
// Grace period after the initial sync settles before invite notifications arm, so
|
||||
// the async invite-atom population lands first and isn't mistaken for new invites.
|
||||
const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
|
||||
|
||||
function isInQuietHours(start: string, end: string): boolean {
|
||||
const now = new Date();
|
||||
const [sh, sm] = start.split(':').map(Number);
|
||||
@@ -865,9 +866,15 @@ function KeyboardShortcutsFeature() {
|
||||
return <KeyboardShortcutsDialog />;
|
||||
}
|
||||
|
||||
function MsgDraftHydrator(): null {
|
||||
useHydrateMsgDrafts();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
return (
|
||||
<>
|
||||
<MsgDraftHydrator />
|
||||
<SystemEmojiFeature />
|
||||
<PageZoomFeature />
|
||||
<FaviconUpdater />
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// The module evaluates atomWithStorage(..., { getOnInit: true }), which reads
|
||||
// localStorage at load time. node has none, so install a no-op mock, then import
|
||||
// dynamically (a static import would hoist above the mock and evaluate too early).
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
|
||||
const { addRecentGif } = await import('./recentGifs');
|
||||
|
||||
const gif = (url: string, width = 100, height = 100) => ({ url, width, height });
|
||||
|
||||
test('addRecentGif prepends a new gif', () => {
|
||||
const out = addRecentGif([gif('a'), gif('b')], gif('c'));
|
||||
assert.deepEqual(
|
||||
out.map((g) => g.url),
|
||||
['c', 'a', 'b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('addRecentGif de-dupes by url, moving the entry to the front', () => {
|
||||
const out = addRecentGif([gif('a'), gif('b'), gif('c')], gif('b'));
|
||||
assert.deepEqual(
|
||||
out.map((g) => g.url),
|
||||
['b', 'a', 'c'],
|
||||
);
|
||||
});
|
||||
|
||||
test('addRecentGif ignores an empty url', () => {
|
||||
assert.deepEqual(addRecentGif([gif('a')], gif('')), [gif('a')]);
|
||||
});
|
||||
|
||||
test('addRecentGif caps the list at 16, dropping the oldest', () => {
|
||||
const sixteen = Array.from({ length: 16 }, (_, i) => gif(`u${i}`));
|
||||
const out = addRecentGif(sixteen, gif('new'));
|
||||
assert.equal(out.length, 16);
|
||||
assert.equal(out[0].url, 'new');
|
||||
assert.equal(
|
||||
out.some((g) => g.url === 'u15'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('addRecentGif does not mutate its input', () => {
|
||||
const input = [gif('a'), gif('b')];
|
||||
const before = input.map((g) => g.url);
|
||||
addRecentGif(input, gif('c'));
|
||||
assert.deepEqual(
|
||||
input.map((g) => g.url),
|
||||
before,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { atom } from 'jotai';
|
||||
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
|
||||
|
||||
export type RecentGif = {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
/** A small still image used for the picker thumbnail (so recents don't all autoplay). */
|
||||
previewUrl?: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'cinny_recent_gifs_v1';
|
||||
const MAX_RECENT_GIFS = 16;
|
||||
|
||||
// getOnInit reads localStorage synchronously so the Recent row is present on the
|
||||
// first render of the GIF picker (no flash of the empty default).
|
||||
const internalAtom = atomWithStorage<RecentGif[]>(
|
||||
STORAGE_KEY,
|
||||
[],
|
||||
createJSONStorage(() => localStorage),
|
||||
{ getOnInit: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* Global atom: the most recently sent GIFs, newest first, deduped by url, capped
|
||||
* at MAX_RECENT_GIFS. Backed by localStorage (device-local convenience).
|
||||
*/
|
||||
export const recentGifsAtom = atom(
|
||||
(get): RecentGif[] => get(internalAtom),
|
||||
(_get, set, updater: RecentGif[] | ((prev: RecentGif[]) => RecentGif[])) => {
|
||||
set(internalAtom, (prev) => {
|
||||
const prevList = Array.isArray(prev) ? prev : [];
|
||||
return typeof updater === 'function' ? updater(prevList) : updater;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Prepend a GIF: ignores an empty url, de-dupes by url (moving an existing entry
|
||||
* to the front), and caps the list at `max`. Pure — returns a new array.
|
||||
*/
|
||||
export const addRecentGif = (
|
||||
prev: RecentGif[],
|
||||
gif: RecentGif,
|
||||
max = MAX_RECENT_GIFS,
|
||||
): RecentGif[] => {
|
||||
if (!gif.url) return prev;
|
||||
const withoutDupe = prev.filter((g) => g.url !== gif.url);
|
||||
return [gif, ...withoutDupe].slice(0, max);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Descendant } from 'slate';
|
||||
import { hasMsgDraft } from './draft';
|
||||
|
||||
const paragraph = (text: string): Descendant =>
|
||||
({ type: 'paragraph', children: [{ text }] }) as unknown as Descendant;
|
||||
|
||||
test('hasMsgDraft is false for an empty array', () => {
|
||||
assert.equal(hasMsgDraft([]), false);
|
||||
});
|
||||
|
||||
test('hasMsgDraft is false for an empty paragraph (empty editor)', () => {
|
||||
assert.equal(hasMsgDraft([paragraph('')]), false);
|
||||
});
|
||||
|
||||
test('hasMsgDraft is false for whitespace-only content', () => {
|
||||
assert.equal(hasMsgDraft([paragraph(' \n ')]), false);
|
||||
});
|
||||
|
||||
test('hasMsgDraft is true when there is real text', () => {
|
||||
assert.equal(hasMsgDraft([paragraph('hello')]), true);
|
||||
});
|
||||
|
||||
test('hasMsgDraft is true across multiple paragraphs with text', () => {
|
||||
assert.equal(hasMsgDraft([paragraph(''), paragraph(' hi ')]), true);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Descendant } from 'slate';
|
||||
import { toPlainText } from '../components/editor/output';
|
||||
|
||||
/** localStorage key prefix for a persisted composer draft (`draft-msg-<draftKey>`). */
|
||||
export const DRAFT_MSG_KEY_PREFIX = 'draft-msg-';
|
||||
|
||||
/**
|
||||
* Whether a Slate composer draft holds real (non-whitespace) text, as opposed to
|
||||
* an empty editor (which is still a non-empty array of empty paragraphs). Shared
|
||||
* by the composer's DraftIndicator and the room-nav draft indicator.
|
||||
*/
|
||||
export function hasMsgDraft(draft: Descendant[]): boolean {
|
||||
try {
|
||||
return toPlainText(draft, false).trim().length > 0;
|
||||
} catch {
|
||||
// A corrupted/foreign draft shape must never crash a render that calls this.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -507,6 +507,31 @@ export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the current user can edit the *caption* of a media message. Captions
|
||||
* are authored for image/video only (see msgContent.ts): the caption is the
|
||||
* event `body` when it differs from `filename`. Editing only changes the caption
|
||||
* — the media (url/info/file) is preserved.
|
||||
*/
|
||||
export const canEditCaption = (mx: MatrixClient, mEvent: MatrixEvent) => {
|
||||
const content = mEvent.getContent();
|
||||
const relationType = content['m.relates_to']?.rel_type;
|
||||
return (
|
||||
mEvent.getSender() === mx.getUserId() &&
|
||||
(!relationType || relationType === RelationType.Thread) &&
|
||||
mEvent.getType() === MessageEvent.RoomMessage &&
|
||||
(content.msgtype === MsgType.Image || content.msgtype === MsgType.Video) &&
|
||||
// Require the MSC2530 filename so a caption has a well-defined empty state
|
||||
// (body === filename) — media from clients that omit filename isn't caption-
|
||||
// editable (and renderCaption never shows a caption for it anyway).
|
||||
typeof content.filename === 'string'
|
||||
);
|
||||
};
|
||||
|
||||
/** Editable as a text message, or has an editable media caption. */
|
||||
export const canEditEventOrCaption = (mx: MatrixClient, mEvent: MatrixEvent) =>
|
||||
canEditEvent(mx, mEvent) || canEditCaption(mx, mEvent);
|
||||
|
||||
export const getLatestEditableEvt = (
|
||||
timeline: EventTimeline,
|
||||
canEdit: (mEvent: MatrixEvent) => boolean,
|
||||
|
||||
@@ -53,3 +53,19 @@ export async function restartScheduledMessage(mx: MatrixClient, delayId: string)
|
||||
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a scheduled message immediately via MSC4140 (`action: 'send'`) — the
|
||||
* server dispatches the stored delayed event now, as a normal timeline event,
|
||||
* instead of waiting for its timeout.
|
||||
*/
|
||||
export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string): Promise<void> {
|
||||
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
|
||||
await mx.http.authedRequest(
|
||||
Method.Post,
|
||||
path,
|
||||
undefined,
|
||||
{ action: 'send' },
|
||||
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { diffWords, DiffSegment } from './textDiff';
|
||||
|
||||
// Helper: reconstruct the old / new text from segments to prove correctness.
|
||||
const oldText = (segs: DiffSegment[]) =>
|
||||
segs
|
||||
.filter((s) => s.type !== 'added')
|
||||
.map((s) => s.text)
|
||||
.join('');
|
||||
const newText = (segs: DiffSegment[]) =>
|
||||
segs
|
||||
.filter((s) => s.type !== 'removed')
|
||||
.map((s) => s.text)
|
||||
.join('');
|
||||
|
||||
test('identical text yields a single equal segment', () => {
|
||||
const out = diffWords('hello world', 'hello world');
|
||||
assert.deepEqual(out, [{ type: 'equal', text: 'hello world' }]);
|
||||
});
|
||||
|
||||
test('pure insertion marks only the new words as added', () => {
|
||||
const out = diffWords('hello world', 'hello there world');
|
||||
assert.equal(oldText(out), 'hello world');
|
||||
assert.equal(newText(out), 'hello there world');
|
||||
assert.deepEqual(
|
||||
out.filter((s) => s.type === 'added').map((s) => s.text.trim()),
|
||||
['there'],
|
||||
);
|
||||
assert.equal(
|
||||
out.some((s) => s.type === 'removed'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('pure deletion marks only the dropped words as removed', () => {
|
||||
const out = diffWords('hello there world', 'hello world');
|
||||
assert.equal(
|
||||
out.some((s) => s.type === 'added'),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(
|
||||
out.filter((s) => s.type === 'removed').map((s) => s.text.trim()),
|
||||
['there'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a word replacement is a removed run followed by an added run', () => {
|
||||
const out = diffWords('hello world', 'hi world');
|
||||
// Reconstructs both sides.
|
||||
assert.equal(oldText(out), 'hello world');
|
||||
assert.equal(newText(out), 'hi world');
|
||||
const removed = out.filter((s) => s.type === 'removed').map((s) => s.text.trim());
|
||||
const added = out.filter((s) => s.type === 'added').map((s) => s.text.trim());
|
||||
assert.deepEqual(removed, ['hello']);
|
||||
assert.deepEqual(added, ['hi']);
|
||||
});
|
||||
|
||||
test('whitespace and newlines are preserved in reconstruction', () => {
|
||||
const a = ' line one\nline two ';
|
||||
const b = ' line one\nline three ';
|
||||
const out = diffWords(a, b);
|
||||
assert.equal(oldText(out), a);
|
||||
assert.equal(newText(out), b);
|
||||
});
|
||||
|
||||
test('empty <-> non-empty', () => {
|
||||
assert.deepEqual(diffWords('', 'new text'), [{ type: 'added', text: 'new text' }]);
|
||||
assert.deepEqual(diffWords('old text', ''), [{ type: 'removed', text: 'old text' }]);
|
||||
assert.deepEqual(diffWords('', ''), []);
|
||||
});
|
||||
|
||||
test('diffWords does not mutate its inputs', () => {
|
||||
const a = 'alpha beta';
|
||||
const b = 'alpha gamma';
|
||||
diffWords(a, b);
|
||||
assert.equal(a, 'alpha beta');
|
||||
assert.equal(b, 'alpha gamma');
|
||||
});
|
||||
|
||||
test('very large inputs fall back to a coarse whole-block diff', () => {
|
||||
const big = Array.from({ length: 5000 }, (_, i) => `w${i}`).join(' ');
|
||||
const out = diffWords(big, `${big} extra`);
|
||||
// Coarse fallback: one removed block + one added block (no per-word LCS).
|
||||
assert.deepEqual(
|
||||
out.map((s) => s.type),
|
||||
['removed', 'added'],
|
||||
);
|
||||
assert.equal(oldText(out), big);
|
||||
assert.equal(newText(out), `${big} extra`);
|
||||
});
|
||||
|
||||
test('word-level granularity (not character-level)', () => {
|
||||
const out = diffWords('cat', 'cats');
|
||||
// "cat" and "cats" are different tokens → full removed + added, no partial.
|
||||
assert.deepEqual(
|
||||
out.map((s) => s.type),
|
||||
['removed', 'added'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// Word-level text diff for the Edit History viewer. Pure and dependency-free.
|
||||
|
||||
export type DiffSegment = {
|
||||
type: 'equal' | 'added' | 'removed';
|
||||
text: string;
|
||||
};
|
||||
|
||||
// Split into a sequence of tokens where each token is either a run of
|
||||
// whitespace or a run of non-whitespace, so word boundaries and the original
|
||||
// spacing/newlines are both preserved when segments are re-joined.
|
||||
const tokenize = (text: string): string[] => text.match(/\s+|\S+/g) ?? [];
|
||||
|
||||
// Above this many tokens per side the O(n*m) LCS table gets expensive/large, so
|
||||
// we fall back to a coarse whole-block "replaced" diff. Message bodies this long
|
||||
// are rare; the exact word diff isn't worth a multi-million-cell allocation.
|
||||
const MAX_DIFF_TOKENS = 2000;
|
||||
|
||||
const coarseDiff = (oldText: string, newText: string): DiffSegment[] => {
|
||||
const segments: DiffSegment[] = [];
|
||||
if (oldText) segments.push({ type: 'removed', text: oldText });
|
||||
if (newText) segments.push({ type: 'added', text: newText });
|
||||
return segments;
|
||||
};
|
||||
|
||||
/**
|
||||
* Word-level diff of `oldText` → `newText` via a classic LCS. Returns segments
|
||||
* in reading order: `equal` runs, `removed` runs (present only in old), and
|
||||
* `added` runs (present only in new). Consecutive same-type tokens are merged.
|
||||
* Pure — never mutates its inputs. Whitespace is preserved.
|
||||
*/
|
||||
export function diffWords(oldText: string, newText: string): DiffSegment[] {
|
||||
const a = tokenize(oldText);
|
||||
const b = tokenize(newText);
|
||||
if (a.length > MAX_DIFF_TOKENS || b.length > MAX_DIFF_TOKENS) {
|
||||
return coarseDiff(oldText, newText);
|
||||
}
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
|
||||
// lcs[i][j] = length of the longest common subsequence of a[i..] and b[j..].
|
||||
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
||||
for (let i = n - 1; i >= 0; i -= 1) {
|
||||
for (let j = m - 1; j >= 0; j -= 1) {
|
||||
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const raw: { type: DiffSegment['type']; text: string }[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
raw.push({ type: 'equal', text: a[i] });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
raw.push({ type: 'removed', text: a[i] });
|
||||
i += 1;
|
||||
} else {
|
||||
raw.push({ type: 'added', text: b[j] });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
while (i < n) {
|
||||
raw.push({ type: 'removed', text: a[i] });
|
||||
i += 1;
|
||||
}
|
||||
while (j < m) {
|
||||
raw.push({ type: 'added', text: b[j] });
|
||||
j += 1;
|
||||
}
|
||||
|
||||
// Merge consecutive same-type tokens into segments.
|
||||
const segments: DiffSegment[] = [];
|
||||
raw.forEach((tok) => {
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last.type === tok.type) last.text += tok.text;
|
||||
else segments.push({ type: tok.type, text: tok.text });
|
||||
});
|
||||
return segments;
|
||||
}
|
||||
Reference in New Issue
Block a user