Compare commits

...
13 Commits
Author SHA1 Message Date
jaredandClaude Opus 4.8 82eb65b822 fix(scheduled): clear stale send-now error when a row is edited
CI / Trigger Desktop Build (push) Successful in 9s
CI / Build & Quality Checks (push) Successful in 10m50s
handleEdit already clears cancelErrors for the old row; also clear sendErrors
so a prior failed "Send now" doesn't leave a stale inline error after editing.
Cosmetic hygiene, matching the existing cancelErrors handling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:51:15 -04:00
jaredandClaude Opus 4.8 708a15d196 fix(soundboard): let the clip-name field fill its available width
The folds Input container is display:flex with no width, so inside the row's
grow="Yes" wrapper the wrapper grew but the input collapsed to content width.
Set width:100% on the Input so the name field uses the space the row gives it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:48:00 -04:00
jaredandClaude Opus 4.8 120ad2d1b5 feat(scheduled): add "Send now" action to the scheduled-messages tray
Fire a pending scheduled message immediately via MSC4140 action:'send'
(the server dispatches the stored delayed event now, as a normal timeline
event) instead of having to cancel and retype.

- sendScheduledMessageNow(mx, delayId) mirrors cancel/restart with action:'send'
- handleSendNow reuses the per-row busy guard; prunes local state only once the
  server confirms; a failed send shows an inline "Could not send now" error with
  the message still sendable/editable/cancellable
- Send-now IconButton (Icons.Send) added before Edit/Cancel in each row

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:47:52 -04:00
jaredandClaude Opus 4.8 b38df58b68 fix(voice): release mic on cancel/unmount + recorder polish
Address findings from 2 review agents (pause/resume duration model +
meter lifecycle were verified correct):

- Mic-stream leak (HIGH, pre-existing but in this change's blast radius):
  the mic tracks were only stopped inside mr.onstop, which cancel() nulls
  and the unmount effect never triggered — so cancelling or unmounting
  mid-recording/pause left the mic live (OS indicator on). Hold the stream
  in a ref and release its tracks explicitly (stopStream) on cancel and on
  unmount, independent of onstop. Normal stop still releases via onstop.

- Defensive: startMeters now cancels any existing rAF/interval before
  starting, so it can never spawn a second loop.

- a11y/UX: the finish button (checkmark, advances to the preview step) is
  relabeled "Finish recording"/"Finish" so the label matches the check
  glyph (was "Stop recording" with the old pause icon). The three
  recording-control buttons get flexShrink:0 so they don't squish the
  waveform at narrow composer widths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:04:31 -04:00
jaredandClaude Opus 4.8 13304f1571 feat(voice): pause / resume while recording
The voice recorder was a single continuous take — an interruption meant
stopping early or starting over (and the Stop button confusingly used a
Pause icon). Add real pause/resume.

- MediaRecorder.pause()/resume() with a new 'paused' state.
- Duration now accumulates only active-recording time: an accumulate-on-
  pause model (accumulatedMsRef + segmentStartRef) replaces the wall-clock
  startTime, so paused time is excluded from both the live timer and the
  finalized preview duration.
- Extracted startMeters/stopMeters so the waveform rAF + timer interval are
  reused across start/resume; stopMeters keeps the audio graph alive for
  resume while stopAll tears it down.
- Recording view now also renders the 'paused' state: a Pause/Resume toggle
  (Pause vs Play icon), the record dot stops pulsing (dimmed), and the
  waveform/timer freeze. Stop/Cancel/unmount all handle a paused recorder.
- Fixed the mislabeled finish button: it now shows a checkmark (it advances
  to the preview step) instead of a pause icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:58:01 -04:00
jaredandClaude Opus 4.8 87b1c1a702 fix(captions): harden caption editing after review
Address findings from 2 review agents (core edit path was verified
correct — media preservation incl. encrypted, threading, no-op guard):

- Require a filename (utils/room.ts): canEditCaption now also checks the
  MSC2530 `filename` exists. Fixes media from clients that omit filename,
  where the editor prefilled the filename as a caption and clearing it
  wrote an empty body. Such media simply isn't caption-editable (matches
  renderCaption never showing a caption for it).

- Carry m.mentions (MessageEditor): a caption edit now unions typed
  @-mentions with prior mentions like the text-edit path, so mentioning
  someone in a caption edit notifies them.

- Double caption: revert to the editor replacing the content while editing
  (as text edits do) instead of rendering the media + its caption above an
  editor prefilled with the same caption — removes the confusing duplicate.

- Removed-caption "(edited)" marker (RenderMessageContent): when a media
  message is edited but has no caption (e.g. the caption was removed),
  render the standalone "(edited)" affordance so Edit History stays
  reachable (previously it lived only inside the caption and vanished).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:01:02 -04:00
jaredandClaude Opus 4.8 125af8446f feat(captions): edit image/video captions after sending
Captions could be attached at upload but never changed — canEditEvent
only allowed m.text/emote/notice, so a typo in an image caption meant
delete + re-upload. Add caption editing for image/video messages.

- utils/room.ts: canEditCaption (own image/video RoomMessage, no
  non-thread relation) + canEditEventOrCaption. canEditEvent unchanged.
- Message.tsx: gate the Edit affordance (quick-actions + menu) on
  canEditEventOrCaption; label it "Edit caption" for media; keep the media
  rendered above the editor while editing.
- MessageEditor.tsx: for a media message, seed the editor from the caption
  (not the filename), allow an empty caption (removes it), and build the
  m.replace so m.new_content spreads the original media content
  (url/info/encrypted file/filename/msgtype) and only sets body +
  format/formatted_body. Outer content is the full media (not a "* text"
  fallback) so non-edit-aware clients still render the media. No-op guard
  when the caption is unchanged. Placeholder "Add a caption…".

Rendering + Edit History need no changes: getEditedEvent's m.new_content
flows to renderCaption, and the word-diff already diffs body (the
caption). Encrypted media keeps its file/key (no re-upload).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:50:42 -04:00
jaredandClaude Opus 4.8 9a4796c167 fix(gif): still previews + a11y for recent GIFs
Address findings from 2 review agents on the recent-GIFs row:

- Motion/perf: the Recent row rendered up to 16 full animated GIFs at
  once (autoplaying). Capture a small still image at pick time
  (fixed_width_small_still / *_still) into RecentGif.previewUrl and render
  that for the thumbnail, so recents no longer autoplay. Pre-existing
  recents without a preview fall back to the animated url. Re-send still
  uses the animated url, so the sent m.image is unchanged.

- a11y: the recent buttons all had the identical label "Send recent GIF".
  Give them positional labels ("Send recent GIF N of M") and wrap the grid
  in a role="group" labelled by the "Recent" section heading, so the row
  is a distinguishable, announced group.

Correctness review found no bugs (write-before-unmount, term gating,
dedupe, re-send fidelity all verified).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:34:51 -04:00
jaredandClaude Opus 4.8 5b58e5fe43 feat(gif): recently-used GIFs in the picker
The GIF picker was a bare Giphy search grid with no memory of what you've
sent, so re-sending a go-to reaction GIF meant re-typing the search every
time. Add a "Recent" row at the top of the picker (default view; hidden
while searching) for one-click re-sending.

- New persisted state state/recentGifs.ts: recentGifsAtom (localStorage,
  cinny_recent_gifs_v1, getOnInit) + pure addRecentGif (dedupe-by-url
  move-to-front, cap 16, ignore empty url), with 5 unit tests.
- GifPicker records every sent GIF (from search or the Recent row) to the
  front, and renders a 3-up thumbnail grid of recents above the search
  grid when there are recents and no active search term. Section label
  matches the picker's existing `// GIF_SEARCH` treatment (lotusTerminal)
  or a muted label otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:27:18 -04:00
jaredandClaude Opus 4.8 eaf6853910 fix(nav): harden draft indicator after review
Address findings from 2 review agents on the room-nav draft indicator:

- Icon semantics (a11y): Icons.Message read as message activity and
  collided with two existing bubble uses in the same row (call-chat
  toggle, mark-unread), especially next to the unread badge. Replace it
  with the composer's shared DraftDot (a small color.Success.Main dot),
  so both draft surfaces share one visual language. Rendered as a
  role="img" span with aria-label "Unsent draft" (reliably announced,
  unlike a bare aria-labelled svg).

- Precise thread-key filter: hydration skipped any draftKey containing
  '::', which would also skip an IPv6-literal server name in a roomId.
  Match '::$' (thread root is an event id) so only real thread drafts are
  skipped.

- Defensive hasMsgDraft: guard toPlainText so a corrupted/foreign draft
  value can't throw during a nav render.

- Clear the draft atom on send: the send / scheduled-send handlers reset
  the editor and localStorage but left the jotai draft atom set, so the
  composer DraftIndicator could show a stale dot after sending a restored
  draft. Add setMsgDraft([]) to both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:48:35 -04:00
jaredandClaude Opus 4.8 c44ef8d795 feat(nav): draft indicator on room-nav items
Composer drafts persist per room, but nothing in the room list showed
which OTHER rooms had an unsent draft. Add a subtle chat-bubble icon on a
room's nav item when it has a message draft (and isn't the open room), so
half-written messages elsewhere are visible at a glance.

- Shared pure helper hasMsgDraft (utils/draft.ts, unit-tested) replaces
  the inline emptiness check; the composer DraftIndicator now reuses it.
- RoomNavItem reads a memoized selectAtom(draftAtom, hasMsgDraft) so a row
  re-renders only when its draft flag flips (the draft atom is written on
  room-leave, not per keystroke). Uses Icons.Message (pencil is reserved
  for the custom-name marker), muted, aria-label "Unsent draft".
- useHydrateMsgDrafts (mounted in ClientNonUIFeatures) pre-fills the
  per-room draft atoms from draft-msg-* localStorage on startup, so
  indicators are correct after a page reload, not only after revisiting a
  room. Thread drafts (key contains ::) are skipped; room-level only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:37:21 -04:00
jaredandClaude Opus 4.8 90e6901a60 fix(edit-history): harden diff after review
Address findings from 2 review agents on the edit-history diff:

- Perf: diffWords is O(n*m); cap at 2000 tokens/side and fall back to a
  coarse whole-block replaced diff above that, so a very large multi-edit
  message can't freeze the main thread. Memoize the per-row diff in
  DiffText. (Added a unit test for the coarse fallback.)

- Perceivability (a11y/design): the added-word <ins> highlight was
  color-fill only, which is faint against the modal surface in the lotus
  themes. Add a Success.ContainerLine border + horizontal padding (so the
  rounded corners read as a chip) + box-decoration-break: clone for clean
  wrapping, so the "added" cue survives low fill contrast.

- Consistency: a media/no-body edit now renders "(no text)" in diff mode
  too (matched the toggle-off view; was blank).

- Softened the code comment's screen-reader claim (bare <ins>/<del> aren't
  announced by default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:11:16 -04:00
jaredandClaude Opus 4.8 961789fd71 feat(edit-history): word-level diff view
The Edit History modal listed each version's full text with no indication
of what changed. Add a word-level diff: each edit highlights the words
added (green) and removed (struck-through red) relative to the previous
version, so a one-word fix is obvious at a glance.

- New pure, dependency-free diffWords (LCS over word/whitespace tokens) in
  utils/textDiff.ts, with 8 unit tests (insert/delete/replace, whitespace
  preserved, empty, no-mutation, word-not-char granularity).
- EditHistoryModal renders each edit via a DiffText component using
  semantic <ins>/<del> (screen-reader-meaningful) styled with folds
  Success/Critical tokens. A "Highlight changes" header toggle (default
  on) switches back to full text, which keeps the rich formatted render;
  the Original row is always the plain baseline.
- Diff is plain-text (body) only by design; formatted markup isn't diffed
  (the toggle restores the rich view), and media/no-body edits diff as
  empty strings gracefully.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:02:59 -04:00
22 changed files with 999 additions and 70 deletions
+9 -2
View File
@@ -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.
---
+125 -7
View File
@@ -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}
+11 -1
View File
@@ -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;
};
+117 -36
View File
@@ -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
+24 -1
View File
@@ -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 }}>
+2 -2
View File
@@ -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);
+12 -1
View File
@@ -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>
))}
+6 -5
View File
@@ -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>
)}
+107 -8
View File
@@ -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={
+46
View File
@@ -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]);
}
+11 -4
View File
@@ -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 />
+56
View File
@@ -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,
);
});
+50
View File
@@ -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);
};
+27
View File
@@ -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);
});
+19
View File
@@ -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;
}
}
+25
View File
@@ -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,
+16
View File
@@ -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' },
);
}
+100
View File
@@ -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'],
);
});
+81
View File
@@ -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;
}