Compare commits

...

5 Commits

Author SHA1 Message Date
jared 57da9a6ce8 feat(soundboard): clip duration, playing indicator, volume layout, name wrap
CI / Build & Quality Checks (push) Successful in 10m37s
CI / Trigger Desktop Build (push) Successful in 16s
Editor (SoundboardPackEditor): show each clip's length in seconds (stored on
upload via getAudioDurationMs, and captured on preview for existing clips); the
preview button now toggles play/stop with a 'now playing' equalizer indicator;
reworked the volume control into a fixed cell with a % readout so the slider's
max no longer collides with the delete button.

Call soundboard: clip names wrap (up to 3 lines, word-break) instead of being
truncated with an ellipsis; cards grow to fit.

TODO: logged the basic audio-editor / video->audio-extractor as a large project.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:44:09 -04:00
jared eb34b04708 feat(audio): play m.file audio messages inline like m.audio
Audio frequently arrives as m.file (bridges, other clients, or when the browser
reported a non-audio/* mime on upload) and only got a download button. Detect
audio in the m.file branch (by info.mimetype or filename extension) and render
the existing MAudio inline player, falling back to the file card otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:44:09 -04:00
jared fd9e4a9802 feat(download): show a toast + button check when a file is saved
The desktop (Tauri) app has no native download UI, so FileSaver.saveAs saved
files silently — no visual or audio confirmation. Users re-clicked because
nothing said it worked (one report: 5 copies of the same file). Add a small
useSaveFile() hook that saves AND raises a 'Downloaded <filename>' toast, and
route every download call site through it (file attachments, image viewer, PDF
viewer, plus the recovery-key / key-backup exports). The file-message download
button also shows a green check on success.

Toast system extended with an optional iconSrc so system toasts render an icon
instead of an avatar/initials, and an empty roomName is no longer rendered.

Tests: createDownloadToast covered; 701/701 pass; typecheck + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:30:57 -04:00
jared f12175e76f fix(unread): stop stuck/resurrecting read indicators
CI / Build & Quality Checks (push) Successful in 10m53s
CI / Trigger Desktop Build (push) Successful in 9s
handleReceipt recomputed unread from getUnreadNotificationCount, which is
server-computed and stale on the synchronous synthetic receipt echo (the SDK
only zeroes it immediately when the last event is our own message). Reading
someone else's message therefore PUT the stale non-zero count back -> dot stuck
or resurrected on the ack-sync ordering race. Restore upstream cinny's
optimistic DELETE on our own receipt; the UnreadNotifications listener re-asserts
the accurate badge on the server ack.

Also collapse a {total:0,highlight:0} PUT to a DELETE in the reducer (a present
map entry lights the dot via hasUnread=!!unread, so phantom {0,0} PUTs from the
UnreadNotifications listener left stuck dots).

Mark-as-Unread (MSC2867): clear the flag directly in markAsRead (opening an
already-read room sends no receipt, so the receipt-driven auto-clear never
fired), and gate the receipt auto-clear to main/unthreaded receipts so reading
one thread no longer wipes the whole-room flag.

Tests: 700/700 pass; typecheck + prod build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:07:21 -04:00
jared b5db617bd2 docs: log unread/read-receipt flakiness bug (investigating)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:49:52 -04:00
21 changed files with 441 additions and 57 deletions
+10
View File
@@ -62,6 +62,12 @@ Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then gr
## 🔴 Open — Actionable
### ✅ Unread/read-receipt flakiness (reported 2026-07) — FIXED (pending prod QA)
Room unread dots were inconsistent: reading a message sometimes cleared the dot, sometimes left it stuck, sometimes it resurrected. Root cause (confirmed by tracing + diffing upstream cinny `dev`): **our own "N4" change.** `handleReceipt` recomputed via `getUnreadInfo`, which reads `room.getUnreadNotificationCount()` — server-computed and **stale on the synchronous synthetic receipt echo** (SDK only zeroes it immediately when the last event is your own message) → it PUT the stale non-zero count back → stuck/resurrecting. Compounded by `hasUnread = !!unread` lighting the dot on any present map entry, incl. phantom `{0,0}` PUTs from our `UnreadNotifications` listener. Plus a Mark-as-Unread (MSC2867) flag that never cleared on opening an already-read room (no receipt → no auto-clear).
**Fix:** `roomToUnread.ts``handleReceipt` reverts to upstream's optimistic `DELETE` on own receipt; reducer collapses `{0,0}` PUT → DELETE. `notifications.ts markAsRead` clears the marked-unread flag directly. `markedUnread.ts onReceipt` gated to main/unthreaded receipts (`myMainReceiptPresent`). Unit tests added; 700/700 pass, typecheck + build clean. Deploy + manual QA (read → dot clears & stays; thread read; mark-unread → open → clears; reconnect no resurrect).
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED
Observed live in prod 2026-06-30 during a 2-person **Element Call** (E2EE). These span client rust-crypto (`matrix-js-sdk@41.7.0`) ↔ Synapse ↔ EC MatrixRTC E2EE and are **interrelated** — do NOT spot-fix. **Capture first:** run **Settings → Developer Tools → Crypto Diagnostics** during the next affected call + a synapse-side trace before any fix. (Full runbook was in `LOTUS_E2EE_INVESTIGATION.md`, now in git history.) None are caused by the EC fork work.
@@ -142,6 +148,10 @@ After Phases AC the client spec is ~complete. What's left, flagged by **what
## 📋 Open Feature Backlog
### [ ] Basic in-app audio editor / video→audio extractor (LARGE PROJECT)
A minimal audio editor for soundboard clips and voice content. Scope: (1) **trim/clip** an audio file to a chosen start/end (waveform scrubber, in/out handles); (2) **upload a video file → strip and discard the video track, keep only the audio** (extract audio, then the source video is dropped — never uploaded/stored); (3) minimal edits only (trim, maybe gain/normalize, fade in/out) — not a full DAW. Likely Web Audio API (`AudioContext.decodeAudioData` → trim `AudioBuffer` → re-encode) + `MediaRecorder`/an encoder for output; video demux via a `<video>`+`MediaElementSource` capture or ffmpeg.wasm (weigh bundle cost). Feeds the soundboard uploader (`utils/soundboardClips.ts`, `SoundboardPackEditor`) and attachments. Design under TDS + native-cinny law. Big build — plan a dedicated session; evaluate ffmpeg.wasm size/CSP (wasm) before committing.
### [ ] P4-4 · Math / LaTeX Rendering (LOW PRIORITY)
Render `$…$` / `$$…$$` via KaTeX; graceful fallback to raw text. **Sanitizer must be patched**`src/app/utils/sanitize.ts` (sanitize-html, `disallowedTagsMode:'discard'`) strips all MathML: add `<math><mi><mo><mn><mrow><mfrac><msqrt><mroot><msub><msup><msubsup><munder><mover><mtable><mtr><mtd>…` + `annotation` to `permittedHtmlTags`, and `xmlns`/`display`/`mathvariant` to `permittedTagToAttributes`. Parser: split text nodes on `/(\$\$.*?\$\$|\$.*?\$)/g` in `react-custom-html-parser.tsx``<KaTeX>`. Lazy-import `katex/dist/katex.min.css` only when a math block renders. Verify KaTeX bundle-size impact.
@@ -13,9 +13,9 @@ import {
color,
Spinner,
} from 'folds';
import FileSaver from 'file-saver';
import to from 'await-to-js';
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
import { useSaveFile } from '../hooks/useSaveFile';
import { useModalStyle } from '../hooks/useModalStyle';
import { PasswordInput } from './password-input';
import { ContainerColor } from '../styles/ContainerColor.css';
@@ -230,6 +230,7 @@ type RecoveryKeyDisplayProps = {
};
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const [show, setShow] = useState(false);
const saveFile = useSaveFile();
const handleCopy = () => {
copyToClipboard(recoveryKey);
@@ -239,7 +240,7 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const blob = new Blob([recoveryKey], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'recovery-key.txt');
saveFile(blob, 'recovery-key.txt');
};
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
+3 -2
View File
@@ -19,7 +19,7 @@ import {
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import FileSaver from 'file-saver';
import { useSaveFile } from '../../hooks/useSaveFile';
import * as css from './PdfViewer.css';
import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom';
@@ -36,6 +36,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
({ className, name, src, requestClose, ...props }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const saveFile = useSaveFile();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
@@ -76,7 +77,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
}, [docState, pageNo, zoom]);
const handleDownload = () => {
FileSaver.saveAs(src, name);
saveFile(src, name);
};
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+46 -1
View File
@@ -31,7 +31,29 @@ import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common';
import { IAudioContent, IFileContent, IImageContent } from '../../types/matrix/common';
// Audio is frequently sent as m.file (bridges/other clients, or when the browser
// reported a non-audio/* mime on upload). Detect that so we can play it inline
// like m.audio instead of showing only a download button.
const AUDIO_EXT_MIME: Record<string, string> = {
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
aac: 'audio/aac',
oga: 'audio/ogg',
ogg: 'audio/ogg',
opus: 'audio/ogg',
wav: 'audio/wav',
flac: 'audio/flac',
weba: 'audio/webm',
};
const resolveInlineAudioMime = (content: IFileContent): string | undefined => {
const mime = content.info?.mimetype;
if (typeof mime === 'string' && mime.startsWith('audio')) return mime;
const name = content.filename ?? content.body ?? '';
const ext = name.split('.').pop()?.toLowerCase();
return ext ? AUDIO_EXT_MIME[ext] : undefined;
};
type RenderMessageContentProps = {
displayName: string;
@@ -276,6 +298,29 @@ export function RenderMessageContent({
}
if (msgType === MsgType.File) {
// If an m.file is actually audio, play it inline (like m.audio) instead of
// only offering a download. MAudio falls back to renderFile if playback fails.
const audioMime = resolveInlineAudioMime(getContent<IFileContent>());
if (audioMime) {
const fileContent = getContent<IFileContent>();
const audioContent = {
...fileContent,
info: { ...(fileContent.info ?? {}), mimetype: audioMime },
} as unknown as IAudioContent;
return (
<>
<MAudio
content={audioContent}
renderAsFile={renderFile}
renderAudioContent={(props) => (
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
)}
outlined={outlineAttachment}
/>
{renderCaption()}
</>
);
}
return renderFile();
}
@@ -1,8 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import FileSaver from 'file-saver';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import { useSaveFile } from '../../hooks/useSaveFile';
import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
@@ -17,12 +17,13 @@ export type ImageViewerProps = {
export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const { t } = useTranslation();
const saveFile = useSaveFile();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const handleDownload = async () => {
const fileContent = await downloadMedia(src);
FileSaver.saveAs(fileContent, alt);
saveFile(fileContent, alt);
};
return (
+9 -5
View File
@@ -1,8 +1,8 @@
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver';
import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useSaveFile } from '../../hooks/useSaveFile';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
@@ -24,6 +24,7 @@ type FileDownloadButtonProps = {
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const saveFile = useSaveFile();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
@@ -34,18 +35,19 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
: await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, filename);
saveFile(fileURL, filename);
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, filename]),
}, [mx, url, useAuthentication, mimeType, encInfo, filename, saveFile]),
);
const downloading = downloadState.status === AsyncStatus.Loading;
const hasError = downloadState.status === AsyncStatus.Error;
const succeeded = downloadState.status === AsyncStatus.Success;
return (
<IconButton
disabled={downloading}
onClick={download}
variant={hasError ? 'Critical' : 'SurfaceVariant'}
variant={hasError ? 'Critical' : succeeded ? 'Success' : 'SurfaceVariant'}
size="300"
radii="300"
aria-label={
@@ -53,13 +55,15 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
? 'Downloading...'
: hasError
? 'Download failed, click to retry'
: succeeded
? 'Downloaded — click to download again'
: 'Download file'
}
>
{downloading ? (
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
) : (
<Icon size="100" src={Icons.Download} />
<Icon size="100" src={succeeded ? Icons.Check : Icons.Download} />
)}
</IconButton>
);
@@ -14,10 +14,10 @@ import {
TooltipProvider,
as,
} from 'folds';
import FileSaver from 'file-saver';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FocusTrap from 'focus-trap-react';
import { IFileInfo } from '../../../../types/matrix/common';
import { useSaveFile } from '../../../hooks/useSaveFile';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { bytesToSize } from '../../../utils/common';
@@ -252,6 +252,7 @@ export type DownloadFileProps = {
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const saveFile = useSaveFile();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
@@ -262,9 +263,9 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
: await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, body);
saveFile(fileURL, body);
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, body]),
}, [mx, url, useAuthentication, mimeType, encInfo, body, saveFile]),
);
return downloadState.status === AsyncStatus.Error ? (
@@ -277,7 +278,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
size="400"
onClick={() =>
downloadState.status === AsyncStatus.Success
? FileSaver.saveAs(downloadState.data, body)
? saveFile(downloadState.data, body)
: download()
}
disabled={downloadState.status === AsyncStatus.Loading}
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
@@ -21,6 +21,7 @@ import { uniqueShortcode } from '../../plugins/soundboard/utils';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import {
getAudioDurationMs,
playClipLocally,
resolveClipObjectUrl,
SOUNDBOARD_ACCEPT,
@@ -29,6 +30,49 @@ import {
} from '../../utils/soundboardClips';
import { stopPropagation } from '../../utils/keyboard';
// Injected once: the little "now playing" equalizer bars animation.
const EQ_STYLE_ID = 'lotus-soundboard-eq-keyframes';
function ensureEqKeyframes() {
if (typeof document === 'undefined' || document.getElementById(EQ_STYLE_ID)) return;
const style = document.createElement('style');
style.id = EQ_STYLE_ID;
style.textContent = `
@keyframes lotusSbEq { 0%,100% { transform: scaleY(0.3); } 50% { transform: scaleY(1); } }
@media (prefers-reduced-motion: reduce) { @keyframes lotusSbEq { 0%,100% { transform: scaleY(0.6); } } }
`;
document.head.appendChild(style);
}
function PlayingBars() {
return (
<Box alignItems="Center" gap="100" style={{ height: toRem(14) }} aria-hidden>
{[0, 1, 2].map((i) => (
<span
key={i}
style={{
display: 'inline-block',
width: toRem(3),
height: toRem(14),
borderRadius: toRem(2),
background: color.Primary.Main,
transformOrigin: 'center bottom',
animation: `lotusSbEq 0.7s ease-in-out ${i * 0.15}s infinite`,
}}
/>
))}
</Box>
);
}
// Short clip length shown while adjusting a sound: "3.2s", or "1:04" if ≥ 60s.
const formatClipSeconds = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds < 0) return '';
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
type ClipDraft = {
url: string;
body: string;
@@ -59,9 +103,20 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
const [error, setError] = useState<string>();
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
const [busyPreview, setBusyPreview] = useState<string>();
const [playingKey, setPlayingKey] = useState<string>();
const [durations, setDurations] = useState<Map<string, number>>(new Map()); // shortcode -> seconds
const audioElRef = useRef<HTMLAudioElement | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const emojiAnchorRef = useRef<HTMLElement | null>(null);
useEffect(() => {
ensureEqKeyframes();
return () => {
audioElRef.current?.pause();
audioElRef.current = null;
};
}, []);
const existing = useMemo(() => pack.getClips(), [pack]);
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
@@ -78,19 +133,47 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
});
};
const stopPlayback = useCallback(() => {
audioElRef.current?.pause();
audioElRef.current = null;
setPlayingKey(undefined);
}, []);
const preview = useCallback(
async (id: string, mxc: string, volume: number) => {
// Clicking the clip that's already playing stops it (toggle).
if (audioElRef.current && playingKey === id) {
stopPlayback();
return;
}
stopPlayback(); // stop any other clip first
setBusyPreview(id);
try {
const url = await resolveClipObjectUrl(mx, mxc);
playClipLocally(url, volume / 100);
const audio = playClipLocally(url, volume / 100);
if (audio) {
audioElRef.current = audio;
setPlayingKey(id);
audio.addEventListener('loadedmetadata', () => {
if (Number.isFinite(audio.duration)) {
setDurations((prev) => new Map(prev).set(id, audio.duration));
}
});
const clear = () => {
if (audioElRef.current === audio) audioElRef.current = null;
setPlayingKey((k) => (k === id ? undefined : k));
};
audio.addEventListener('ended', clear);
audio.addEventListener('pause', clear);
audio.addEventListener('error', clear);
}
} catch {
/* ignore preview errors */
} finally {
setBusyPreview(undefined);
}
},
[mx],
[mx, playingKey, stopPlayback],
);
const handleFiles = useCallback(
@@ -112,6 +195,8 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
throw new Error(`"${file.name}" is too large (max 1 MB).`);
}
// eslint-disable-next-line no-await-in-loop
const durationMs = await getAudioDurationMs(file);
// eslint-disable-next-line no-await-in-loop
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
const mxc = res.content_uri;
if (!mxc) throw new Error('Upload failed.');
@@ -126,7 +211,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
body: name,
emoji: '',
volume: 100,
info: { mimetype: file.type || undefined, size: file.size },
info: { mimetype: file.type || undefined, size: file.size, duration: durationMs },
},
]);
}
@@ -182,6 +267,9 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
setDraft(key, patch, base);
}
};
const isPlaying = playingKey === key;
const clipSeconds =
durations.get(key) ?? (base.info?.duration != null ? base.info.duration / 1000 : undefined);
return (
<Box
key={key}
@@ -197,12 +285,16 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
<IconButton
size="300"
radii="300"
variant="Secondary"
variant={isPlaying ? 'Primary' : 'Secondary'}
disabled={busyPreview === key}
onClick={() => preview(key, base.url, rowVolume)}
aria-label={`Preview ${rowBody}`}
aria-label={isPlaying ? `Stop ${rowBody}` : `Preview ${rowBody}`}
>
{busyPreview === key ? <Spinner size="100" /> : <Icon size="100" src={Icons.Play} />}
{busyPreview === key ? (
<Spinner size="100" />
) : (
<Icon size="100" src={isPlaying ? Icons.Pause : Icons.Play} filled={isPlaying} />
)}
</IconButton>
<IconButton
size="300"
@@ -227,7 +319,24 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
aria-label="Clip name"
/>
</Box>
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(120) }}>
<Box
alignItems="Center"
justifyContent="End"
gap="100"
shrink="No"
style={{ width: toRem(52) }}
>
{isPlaying ? (
<PlayingBars />
) : (
clipSeconds !== undefined && (
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap' }}>
{formatClipSeconds(clipSeconds)}
</Text>
)
)}
</Box>
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(148) }}>
<Icon size="50" src={Icons.VolumeHigh} />
<input
type="range"
@@ -237,9 +346,12 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
defaultValue={rowVolume}
disabled={!canEdit || markedDeleted}
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
style={{ flexGrow: 1 }}
style={{ flexGrow: 1, minWidth: 0 }}
aria-label="Clip volume"
/>
<Text size="T200" priority="300" style={{ width: toRem(30), textAlign: 'right' }}>
{rowVolume}%
</Text>
</Box>
{canEdit && !isUpload && (
<IconButton
@@ -308,7 +420,13 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
{existing.map((c) =>
renderRow(
c.shortcode,
{ url: c.url, body: c.body ?? c.shortcode, emoji: c.emoji ?? '', volume: c.volume },
{
url: c.url,
body: c.body ?? c.shortcode,
emoji: c.emoji ?? '',
volume: c.volume,
info: c.info,
},
false,
deleted.has(c.shortcode),
),
+15 -2
View File
@@ -196,7 +196,8 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
aria-label={`Play ${clip.name}`}
style={{
width: toRem(76),
height: toRem(76),
minHeight: toRem(76),
height: 'auto',
padding: config.space.S100,
borderRadius: config.radii.R400,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
@@ -215,7 +216,19 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
clip.emoji || '🔊'
)}
</Text>
<Text size="T200" truncate style={{ maxWidth: '100%' }}>
<Text
size="T200"
style={{
maxWidth: '100%',
textAlign: 'center',
wordBreak: 'break-word',
lineHeight: 1.15,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{clip.name}
</Text>
</Box>
@@ -1,6 +1,6 @@
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds';
import FileSaver from 'file-saver';
import { useSaveFile } from '../../../hooks/useSaveFile';
import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile';
import { SequenceCardStyle } from '../styles.css';
@@ -15,6 +15,7 @@ import { useFilePicker } from '../../../hooks/useFilePicker';
function ExportKeys() {
const mx = useMatrixClient();
const alive = useAlive();
const saveFile = useSaveFile();
const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>(
useCallback(
@@ -28,9 +29,9 @@ function ExportKeys() {
const blob = new Blob([encKeys], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'lotus-keys.txt');
saveFile(blob, 'lotus-keys.txt');
},
[mx],
[mx, saveFile],
),
);
+11 -3
View File
@@ -171,7 +171,11 @@ function ToastCard({ toast }: ToastCardProps) {
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleCardClick();
}}
aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`}
aria-label={
toast.roomName
? `Notification from ${toast.displayName} in ${toast.roomName}`
: `${toast.displayName}: ${toast.body}`
}
>
<span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}>
<IconButton
@@ -187,7 +191,11 @@ function ToastCard({ toast }: ToastCardProps) {
</IconButton>
</span>
<div style={rowStyle}>
{toast.avatarUrl ? (
{toast.iconSrc ? (
<div style={initialsStyle} aria-hidden="true">
<Icon size="100" src={toast.iconSrc} />
</div>
) : toast.avatarUrl ? (
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
) : (
<div style={initialsStyle} aria-hidden="true">
@@ -197,7 +205,7 @@ function ToastCard({ toast }: ToastCardProps) {
<span style={nameStyle}>{toast.displayName}</span>
</div>
<div style={bodyStyle}>{toast.body}</div>
<div style={roomNameStyle}>{toast.roomName}</div>
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { useCallback } from 'react';
import { useSetAtom } from 'jotai';
import { Icons } from 'folds';
import FileSaver from 'file-saver';
import { createDownloadToast, toastQueueAtom } from '../state/toast';
/**
* Save a blob/URL to disk AND surface a "Downloaded <filename>" toast.
*
* The desktop (Tauri) app has no native download UI, so `FileSaver.saveAs` saved
* files silently — users re-clicked because nothing confirmed success. This gives
* uniform, visible feedback across web + desktop for every download call site.
*/
export const useSaveFile = () => {
const setToast = useSetAtom(toastQueueAtom);
return useCallback(
(data: Blob | string, filename: string) => {
FileSaver.saveAs(data, filename);
setToast(createDownloadToast(filename, Icons.Check));
},
[setToast],
);
};
+23 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixEvent } from 'matrix-js-sdk';
import { receiptIsMine, setMarkedUnread } from './markedUnread';
import { myMainReceiptPresent, receiptIsMine, setMarkedUnread } from './markedUnread';
// MSC2867 mark-as-unread: reading a room (our own receipt) clears the flag, so
// `receiptIsMine` must detect only OUR receipt and ignore others'. And a write
@@ -33,6 +33,28 @@ test('receiptIsMine: tolerates empty / malformed content', () => {
assert.equal(receiptIsMine(receiptEvent({ $x: {} }), ME), false);
});
// myMainReceiptPresent gates the auto-clear to main-timeline reads, so reading a
// single thread does not wipe the whole-room marked-unread flag.
test('myMainReceiptPresent: true for an unthreaded receipt (no thread_id)', () => {
const event = receiptEvent({ $abc: { 'm.read': { [ME]: { ts: 1 } } } });
assert.equal(myMainReceiptPresent(event, ME), true);
});
test('myMainReceiptPresent: true for a thread_id "main" receipt', () => {
const event = receiptEvent({ $abc: { 'm.read': { [ME]: { ts: 1, thread_id: 'main' } } } });
assert.equal(myMainReceiptPresent(event, ME), true);
});
test('myMainReceiptPresent: false for a thread-scoped receipt', () => {
const event = receiptEvent({ $abc: { 'm.read': { [ME]: { ts: 1, thread_id: '$root:server' } } } });
assert.equal(myMainReceiptPresent(event, ME), false);
});
test('myMainReceiptPresent: false when only another user has a main receipt', () => {
const event = receiptEvent({ $abc: { 'm.read': { [OTHER]: { ts: 1 } } } });
assert.equal(myMainReceiptPresent(event, ME), false);
});
test('setMarkedUnread writes both the stable and unstable keys with the flag', async () => {
const calls: Array<{ type: string; content: unknown }> = [];
const mx = {
+23 -4
View File
@@ -9,7 +9,7 @@ import { AccountDataEvent } from '../../../types/matrix/accountData';
// flag round-trips across the ecosystem.
const UNSTABLE_MARKED_UNREAD = 'com.famedly.marked_unread';
const readMarkedUnread = (room: Room): boolean => {
export const readMarkedUnread = (room: Room): boolean => {
const stable = room.getAccountData(AccountDataEvent.MarkedUnread)?.getContent()?.unread;
if (typeof stable === 'boolean') return stable;
return room.getAccountData(UNSTABLE_MARKED_UNREAD)?.getContent()?.unread === true;
@@ -41,6 +41,22 @@ export const receiptIsMine = (event: MatrixEvent, userId: string): boolean => {
);
};
// True only when OUR receipt in this event is for the main timeline — either
// unthreaded (no thread_id) or thread_id "main". A receipt scoped to a specific
// thread (thread_id === <threadRootId>) must NOT clear the whole-room marked
// flag, since only that one thread was read.
export const myMainReceiptPresent = (event: MatrixEvent, userId: string): boolean => {
const content = event.getContent();
return Object.keys(content).some((eventId) =>
Object.keys(content[eventId] ?? {}).some((receiptType) => {
const receipt = content[eventId][receiptType]?.[userId];
if (!receipt) return false;
const threadId = (receipt as { thread_id?: string }).thread_id;
return threadId === undefined || threadId === 'main';
}),
);
};
export const useBindMarkedUnreadAtom = (mx: MatrixClient, anAtom: typeof markedUnreadAtom) => {
const setAtom = useSetAtom(anAtom);
@@ -65,12 +81,15 @@ export const useBindMarkedUnreadAtom = (mx: MatrixClient, anAtom: typeof markedU
const onAccountData: RoomEventHandlerMap[RoomEvent.AccountData] = (_event, room) => {
syncRoom(room);
};
// Reading a room clears its marked-unread flag (MSC2867): when our own read
// receipt lands for a room that's currently marked, clear it.
// Reading a room clears its marked-unread flag (MSC2867): when our own
// MAIN-timeline read receipt lands for a room that's currently marked, clear
// it. Gated to main/unthreaded receipts so reading a single thread doesn't
// wipe the whole-room flag. (This also fires for receipts from our other
// devices; the local read path clears via markAsRead in notifications.ts.)
const onReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event, room) => {
const myId = mx.getUserId();
if (!myId || !readMarkedUnread(room)) return;
if (receiptIsMine(event, myId)) {
if (myMainReceiptPresent(event, myId)) {
setMarkedUnread(mx, room.roomId, false).catch(() => undefined);
}
};
+17
View File
@@ -116,6 +116,23 @@ test('PUT with unchanged counts is skipped (same map reference)', () => {
assert.equal(before, after);
});
test('PUT of { total: 0, highlight: 0 } removes the room (collapses to DELETE)', () => {
const store = createStore();
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
// A phantom zero-count PUT (e.g. UnreadNotifications after the server zeroes
// counts) must clear the entry, not leave a stuck dot.
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 0, 0) });
assert.equal(get(store).has('!r:s'), false);
});
test('PUT of { 0, 0 } on an absent room is a no-op (same map reference)', () => {
const store = createStore();
const before = get(store);
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 0, 0) });
assert.equal(before, get(store));
assert.equal(get(store).has('!r:s'), false);
});
// ---------------------------------------------------------------------------
// roomToUnreadAtom: PUT with parent aggregation
// ---------------------------------------------------------------------------
+30 -14
View File
@@ -24,7 +24,6 @@ import {
getUnreadInfo,
getUnreadInfos,
isNotificationEvent,
roomHaveUnread,
} from '../../utils/room';
import { roomToParentsAtom } from './roomToParents';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
@@ -139,6 +138,27 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
}
if (action.type === 'PUT') {
const { unreadInfo } = action;
// A { total: 0, highlight: 0 } entry is still a *present* map key, and the
// nav dot lights on any present entry — so a phantom zero-count PUT (e.g.
// the UnreadNotifications listener firing once the server zeroes counts)
// would leave a stuck dot. Collapse it to a DELETE so a fully-read room
// actually clears. Done before the unreadEqual short-circuit so an
// already-stuck { 0, 0 } gets removed too.
if (unreadInfo.total === 0 && unreadInfo.highlight === 0) {
if (get(baseRoomToUnread).has(unreadInfo.roomId)) {
set(
baseRoomToUnread,
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
deleteUnreadInfo(
draftRoomToUnread,
getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
unreadInfo.roomId,
),
),
);
}
return;
}
const currentUnread = get(baseRoomToUnread).get(unreadInfo.roomId);
if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) {
// Do not update if unread data has not changes
@@ -256,20 +276,16 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
),
);
if (isMyReceipt) {
// Don't blanket-DELETE the room's unread on any receipt: a THREADED
// receipt (reading one thread) would wipe the room's still-valid
// main-timeline badge, and if the room was already read no
// UnreadNotifications PUT follows to restore it. Recompute instead —
// DELETE only when the room is genuinely fully read.
const info = getUnreadInfo(
room,
getMutedThreads(threadNotificationsRef.current, room.roomId),
);
if (info.total === 0 && info.highlight === 0 && !roomHaveUnread(mx, room)) {
// Optimistically clear on our own receipt (upstream cinny behavior).
// Do NOT recompute from getUnreadInfo here: getUnreadNotificationCount is
// server-computed and STALE on the synchronous synthetic receipt echo
// (the SDK only zeroes it immediately when the last live event is our own
// message), so recomputing PUTs the stale non-zero count back → the dot
// sticks / resurrects. The RoomEvent.UnreadNotifications listener below
// re-asserts the accurate badge (incl. restoring the main badge after a
// thread read) once the server acks, and a { 0, 0 } PUT collapses to a
// DELETE in the reducer.
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
} else {
setUnreadAtom({ type: 'PUT', unreadInfo: info });
}
}
};
mx.on(RoomEvent.Receipt, handleReceipt);
+13 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStore } from 'jotai';
import { toastQueueAtom, dismissToastAtom, ToastNotif } from './toast';
import { toastQueueAtom, dismissToastAtom, ToastNotif, createDownloadToast } from './toast';
// The queue lives in an unexported baseAtom; we drive the two write-only setters
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
@@ -85,3 +85,15 @@ test('dismissToastAtom for an unknown id is a no-op', () => {
['a'],
);
});
test('createDownloadToast: filename in body, no room navigation, unique ids', () => {
const a = createDownloadToast('photo.jpg');
assert.equal(a.displayName, 'Downloaded');
assert.equal(a.body, 'photo.jpg');
// roomId empty + an onClick present → clicking dismisses without navigating to a room.
assert.equal(a.roomId, '');
assert.equal(a.roomName, '');
assert.equal(typeof a.onClick, 'function');
const b = createDownloadToast('photo.jpg');
assert.notEqual(a.id, b.id);
});
+15
View File
@@ -1,8 +1,10 @@
import { atom } from 'jotai';
import type { IconSrc } from 'folds';
export type ToastNotif = {
id: string;
avatarUrl?: string;
iconSrc?: IconSrc; // folds Icon src for a "system" toast (shown instead of an avatar/initials)
displayName: string;
body: string;
roomName: string;
@@ -12,6 +14,19 @@ export type ToastNotif = {
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
};
// Build a "download complete" system toast. Kept folds-free here (the icon src is
// passed in) so this stays a pure, testable builder. roomId is empty + onClick is
// set so a click only dismisses (never navigates to a room).
export const createDownloadToast = (filename: string, iconSrc?: IconSrc): ToastNotif => ({
id: `download-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
displayName: 'Downloaded',
body: filename,
roomName: '',
roomId: '',
iconSrc,
onClick: () => undefined,
});
const baseAtom = atom<ToastNotif[]>([]);
// Write-only setter used in ClientNonUIFeatures
+32 -1
View File
@@ -20,16 +20,22 @@ type RoomOpts = {
readUpTo?: string | null;
threads?: any[];
threadUnread?: Record<string, number>;
markedUnread?: boolean;
};
const setup = (opts: RoomOpts) => {
const calls: ReceiptCall[] = [];
const accountDataWrites: Array<{ type: string; content: any }> = [];
const room = {
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
getEventReadUpTo: () => opts.readUpTo ?? null,
getThreads: () => opts.threads ?? [],
getThreadUnreadNotificationCount: (threadId: string, _type: NotificationCountType) =>
opts.threadUnread?.[threadId] ?? 0,
getAccountData: (type: string) =>
opts.markedUnread && type === 'm.marked_unread'
? { getContent: () => ({ unread: true }) }
: undefined,
};
const mx = {
getRoom: () => room,
@@ -38,8 +44,12 @@ const setup = (opts: RoomOpts) => {
calls.push({ eventId: event.getId(), receiptType, unthreaded });
return {};
},
setRoomAccountData: async (_roomId: string, type: string, content: any) => {
accountDataWrites.push({ type, content });
return {};
},
} as any;
return { mx, calls };
return { mx, calls, accountDataWrites };
};
test('main timeline: unthreaded receipt at the latest event', async () => {
@@ -107,6 +117,27 @@ test('everything read: no receipts sent', async () => {
assert.equal(calls.length, 0);
});
test('marked-unread + already fully read: clears the flag even though no receipt is sent', async () => {
const { mx, calls, accountDataWrites } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'b', // nothing newer → no receipt
markedUnread: true,
});
await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 0); // no receipt (the stuck-dot case)
// ...but the marked-unread flag is cleared directly (both keys, unread:false)
assert.ok(accountDataWrites.some((w) => w.type === 'm.marked_unread' && w.content.unread === false));
});
test('not marked-unread: markAsRead does not touch account data', async () => {
const { mx, accountDataWrites } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'a',
});
await markAsRead(mx, '!r:server', false);
assert.equal(accountDataWrites.length, 0);
});
test('sending thread reply is skipped', async () => {
const t = thread('$root', evt('$reply', true)); // isSending → skip
const { mx, calls } = setup({
+8
View File
@@ -1,11 +1,19 @@
import { MatrixClient, NotificationCountType, ReceiptType } from 'matrix-js-sdk';
import { getSettings } from '../state/settings';
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
const { privateReadReceipts } = getSettings();
const room = mx.getRoom(roomId);
if (!room) return;
// Reading a room clears an explicit "mark as unread" (MSC2867). The binder's
// receipt-driven auto-clear does NOT fire when the room is already fully read
// (no receipt is sent below in that case), so clear it directly here.
if (readMarkedUnread(room)) {
setMarkedUnread(mx, roomId, false).catch(() => undefined);
}
const receiptType =
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read;
+17
View File
@@ -53,3 +53,20 @@ export const playClipLocally = (
return undefined;
}
};
/** Read an audio file's duration in milliseconds from its metadata (no playback). */
export const getAudioDurationMs = (file: Blob): Promise<number | undefined> =>
new Promise((resolve) => {
const url = URL.createObjectURL(file);
const audio = new Audio();
audio.preload = 'metadata';
const done = (ms: number | undefined) => {
URL.revokeObjectURL(url);
resolve(ms);
};
audio.addEventListener('loadedmetadata', () =>
done(Number.isFinite(audio.duration) ? Math.round(audio.duration * 1000) : undefined),
);
audio.addEventListener('error', () => done(undefined));
audio.src = url;
});