Compare commits
7 Commits
44854a1529
..
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| 29d74eda8f | |||
| 57da9a6ce8 | |||
| eb34b04708 | |||
| fd9e4a9802 | |||
| f12175e76f | |||
| b5db617bd2 | |||
| 4ecc173554 |
@@ -62,6 +62,12 @@ Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then gr
|
|||||||
|
|
||||||
## 🔴 Open — Actionable
|
## 🔴 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
|
### 🧨 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.
|
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.
|
||||||
@@ -114,10 +120,38 @@ Genuine Matrix client-spec / MSC features Lotus does **not** yet implement (audi
|
|||||||
|
|
||||||
**Server-gated / advanced (capture, don't build yet):** QR sign-in for a new device (**MSC4108** rendezvous — needs an HS-side endpoint); dehydrated devices (**MSC3814** — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (**MSC3061** `shared_history`, niche); voice broadcast (Element MSC3888, low value — skip).
|
**Server-gated / advanced (capture, don't build yet):** QR sign-in for a new device (**MSC4108** rendezvous — needs an HS-side endpoint); dehydrated devices (**MSC3814** — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (**MSC3061** `shared_history`, niche); voice broadcast (Element MSC3888, low value — skip).
|
||||||
|
|
||||||
|
### Remaining spec/MSC gaps (2026-07 full-surface survey)
|
||||||
|
|
||||||
|
After Phases A–C the client spec is ~complete. What's left, flagged by **what unblocks it**:
|
||||||
|
|
||||||
|
**✅ Buildable NOW (client-only, no server/infra change):**
|
||||||
|
|
||||||
|
- [ ] **Custom room tags / sections** — user-defined room categories in the sidebar via standard `u.*` room tags (beyond the built-in Favourite / Low-Priority). Mirrors the favourite/low-priority category pattern (`RoomNavItem` context-menu + `Home.tsx` categories). _Medium._ The only substantive client-only feature left.
|
||||||
|
|
||||||
|
**🔧 Needs INFRASTRUCTURE (NOT a Synapse-flag flip — you'd have to stand it up):**
|
||||||
|
|
||||||
|
- **Invite by email / 3PID invite** — we invite by Matrix user-ID only (`mx.invite` is user-ID-only). Email invites need an **identity server** (lotusguild runs none). Build only if an identity server is deployed.
|
||||||
|
- QR sign-in for a new device (**MSC4108**) — needs a **rendezvous** endpoint. Dehydrated devices (**MSC3814**) — needs server support. (Also listed above.)
|
||||||
|
|
||||||
|
**🚫 BLOCKED until a Synapse upgrade enables the flag** — re-run `/_matrix/client/versions` `unstable_features` after each upgrade; client work is ready the moment the flag flips. See the **Blocked Features** section below:
|
||||||
|
|
||||||
|
- Live Location Sharing (**MSC3489** + **MSC3672** — both `false`)
|
||||||
|
- Reaction / relation redaction (**MSC3892** — `false`)
|
||||||
|
- Room preview before joining (**MSC3266** — summary endpoint 404s on 1.155)
|
||||||
|
- Thread subscriptions (**MSC4306** — `false`)
|
||||||
|
|
||||||
|
**Niche / low-value (noted, not planned):** E2EE history-key-on-invite (MSC3061), voice broadcast (MSC3888), a native account-deactivation flow (currently delegated to the OIDC provider for OIDC accounts).
|
||||||
|
|
||||||
|
**Already implemented (verified, not gaps):** space reordering (drag — confirmed working in the desktop client), pinning, stickers + picker, room directory, mutual rooms (MSC2666), blurhash, key backup / recovery / SSSS / cross-signing / key export-import, SAS **and** QR verification, ignore list, invite spam-filter, voice messages, polls, threads + per-thread notifs, spaces, OIDC, extended profiles, delayed/scheduled events, authed media, report user/room/message, 3PID contact-info display, disappearing messages, mark-unread, low-priority, room widgets.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Open Feature Backlog
|
## 📋 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)
|
### [ ] 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.
|
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,
|
color,
|
||||||
Spinner,
|
Spinner,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
|
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
|
||||||
|
import { useSaveFile } from '../hooks/useSaveFile';
|
||||||
import { useModalStyle } from '../hooks/useModalStyle';
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
import { PasswordInput } from './password-input';
|
import { PasswordInput } from './password-input';
|
||||||
import { ContainerColor } from '../styles/ContainerColor.css';
|
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||||
@@ -230,6 +230,7 @@ type RecoveryKeyDisplayProps = {
|
|||||||
};
|
};
|
||||||
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
copyToClipboard(recoveryKey);
|
copyToClipboard(recoveryKey);
|
||||||
@@ -239,7 +240,7 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
|||||||
const blob = new Blob([recoveryKey], {
|
const blob = new Blob([recoveryKey], {
|
||||||
type: 'text/plain;charset=us-ascii',
|
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, '*');
|
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import FileSaver from 'file-saver';
|
import { useSaveFile } from '../../hooks/useSaveFile';
|
||||||
import * as css from './PdfViewer.css';
|
import * as css from './PdfViewer.css';
|
||||||
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
@@ -36,6 +36,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||||||
({ className, name, src, requestClose, ...props }, ref) => {
|
({ className, name, src, requestClose, ...props }, ref) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const saveFile = useSaveFile();
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
|
|
||||||
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
|
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
|
||||||
@@ -76,7 +77,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||||||
}, [docState, pageNo, zoom]);
|
}, [docState, pageNo, zoom]);
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
FileSaver.saveAs(src, name);
|
saveFile(src, name);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
|||||||
@@ -31,7 +31,29 @@ import { ImageViewer } from './image-viewer';
|
|||||||
import { PdfViewer } from './Pdf-viewer';
|
import { PdfViewer } from './Pdf-viewer';
|
||||||
import { TextViewer } from './text-viewer';
|
import { TextViewer } from './text-viewer';
|
||||||
import { testMatrixTo } from '../plugins/matrix-to';
|
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 = {
|
type RenderMessageContentProps = {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -276,6 +298,29 @@ export function RenderMessageContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msgType === MsgType.File) {
|
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();
|
return renderFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||||
|
import { useSaveFile } from '../../hooks/useSaveFile';
|
||||||
import * as css from './ImageViewer.css';
|
import * as css from './ImageViewer.css';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
import { usePan } from '../../hooks/usePan';
|
import { usePan } from '../../hooks/usePan';
|
||||||
@@ -17,12 +17,13 @@ export type ImageViewerProps = {
|
|||||||
export const ImageViewer = as<'div', ImageViewerProps>(
|
export const ImageViewer = as<'div', ImageViewerProps>(
|
||||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
const fileContent = await downloadMedia(src);
|
const fileContent = await downloadMedia(src);
|
||||||
FileSaver.saveAs(fileContent, alt);
|
saveFile(fileContent, alt);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
|
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
|
||||||
import React, { ReactNode, useCallback } from 'react';
|
import React, { ReactNode, useCallback } from 'react';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
||||||
|
import { useSaveFile } from '../../hooks/useSaveFile';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
@@ -24,6 +24,7 @@ type FileDownloadButtonProps = {
|
|||||||
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
|
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const [downloadState, download] = useAsyncCallback(
|
const [downloadState, download] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -34,18 +35,19 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
|||||||
: await downloadMedia(mediaUrl);
|
: await downloadMedia(mediaUrl);
|
||||||
|
|
||||||
const fileURL = URL.createObjectURL(fileContent);
|
const fileURL = URL.createObjectURL(fileContent);
|
||||||
FileSaver.saveAs(fileURL, filename);
|
saveFile(fileURL, filename);
|
||||||
return fileURL;
|
return fileURL;
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, filename]),
|
}, [mx, url, useAuthentication, mimeType, encInfo, filename, saveFile]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloading = downloadState.status === AsyncStatus.Loading;
|
const downloading = downloadState.status === AsyncStatus.Loading;
|
||||||
const hasError = downloadState.status === AsyncStatus.Error;
|
const hasError = downloadState.status === AsyncStatus.Error;
|
||||||
|
const succeeded = downloadState.status === AsyncStatus.Success;
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={downloading}
|
disabled={downloading}
|
||||||
onClick={download}
|
onClick={download}
|
||||||
variant={hasError ? 'Critical' : 'SurfaceVariant'}
|
variant={hasError ? 'Critical' : succeeded ? 'Success' : 'SurfaceVariant'}
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-label={
|
aria-label={
|
||||||
@@ -53,13 +55,15 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
|||||||
? 'Downloading...'
|
? 'Downloading...'
|
||||||
: hasError
|
: hasError
|
||||||
? 'Download failed, click to retry'
|
? 'Download failed, click to retry'
|
||||||
: 'Download file'
|
: succeeded
|
||||||
|
? 'Downloaded — click to download again'
|
||||||
|
: 'Download file'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{downloading ? (
|
{downloading ? (
|
||||||
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
||||||
) : (
|
) : (
|
||||||
<Icon size="100" src={Icons.Download} />
|
<Icon size="100" src={succeeded ? Icons.Check : Icons.Download} />
|
||||||
)}
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
as,
|
as,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { IFileInfo } from '../../../../types/matrix/common';
|
import { IFileInfo } from '../../../../types/matrix/common';
|
||||||
|
import { useSaveFile } from '../../../hooks/useSaveFile';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { bytesToSize } from '../../../utils/common';
|
import { bytesToSize } from '../../../utils/common';
|
||||||
@@ -252,6 +252,7 @@ export type DownloadFileProps = {
|
|||||||
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
|
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const [downloadState, download] = useAsyncCallback(
|
const [downloadState, download] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -262,9 +263,9 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
|||||||
: await downloadMedia(mediaUrl);
|
: await downloadMedia(mediaUrl);
|
||||||
|
|
||||||
const fileURL = URL.createObjectURL(fileContent);
|
const fileURL = URL.createObjectURL(fileContent);
|
||||||
FileSaver.saveAs(fileURL, body);
|
saveFile(fileURL, body);
|
||||||
return fileURL;
|
return fileURL;
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, body]),
|
}, [mx, url, useAuthentication, mimeType, encInfo, body, saveFile]),
|
||||||
);
|
);
|
||||||
|
|
||||||
return downloadState.status === AsyncStatus.Error ? (
|
return downloadState.status === AsyncStatus.Error ? (
|
||||||
@@ -277,7 +278,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
|||||||
size="400"
|
size="400"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
downloadState.status === AsyncStatus.Success
|
downloadState.status === AsyncStatus.Success
|
||||||
? FileSaver.saveAs(downloadState.data, body)
|
? saveFile(downloadState.data, body)
|
||||||
: download()
|
: download()
|
||||||
}
|
}
|
||||||
disabled={downloadState.status === AsyncStatus.Loading}
|
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 {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -21,6 +21,7 @@ import { uniqueShortcode } from '../../plugins/soundboard/utils';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import {
|
import {
|
||||||
|
getAudioDurationMs,
|
||||||
playClipLocally,
|
playClipLocally,
|
||||||
resolveClipObjectUrl,
|
resolveClipObjectUrl,
|
||||||
SOUNDBOARD_ACCEPT,
|
SOUNDBOARD_ACCEPT,
|
||||||
@@ -29,6 +30,49 @@ import {
|
|||||||
} from '../../utils/soundboardClips';
|
} from '../../utils/soundboardClips';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
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 = {
|
type ClipDraft = {
|
||||||
url: string;
|
url: string;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -59,9 +103,20 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
|
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
|
||||||
const [busyPreview, setBusyPreview] = useState<string>();
|
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const emojiAnchorRef = useRef<HTMLElement | null>(null);
|
const emojiAnchorRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureEqKeyframes();
|
||||||
|
return () => {
|
||||||
|
audioElRef.current?.pause();
|
||||||
|
audioElRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const existing = useMemo(() => pack.getClips(), [pack]);
|
const existing = useMemo(() => pack.getClips(), [pack]);
|
||||||
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
|
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(
|
const preview = useCallback(
|
||||||
async (id: string, mxc: string, volume: number) => {
|
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);
|
setBusyPreview(id);
|
||||||
try {
|
try {
|
||||||
const url = await resolveClipObjectUrl(mx, mxc);
|
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 {
|
} catch {
|
||||||
/* ignore preview errors */
|
/* ignore preview errors */
|
||||||
} finally {
|
} finally {
|
||||||
setBusyPreview(undefined);
|
setBusyPreview(undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx],
|
[mx, playingKey, stopPlayback],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFiles = useCallback(
|
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).`);
|
throw new Error(`"${file.name}" is too large (max 1 MB).`);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// 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 res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
|
||||||
const mxc = res.content_uri;
|
const mxc = res.content_uri;
|
||||||
if (!mxc) throw new Error('Upload failed.');
|
if (!mxc) throw new Error('Upload failed.');
|
||||||
@@ -126,7 +211,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
body: name,
|
body: name,
|
||||||
emoji: '',
|
emoji: '',
|
||||||
volume: 100,
|
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);
|
setDraft(key, patch, base);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const isPlaying = playingKey === key;
|
||||||
|
const clipSeconds =
|
||||||
|
durations.get(key) ?? (base.info?.duration != null ? base.info.duration / 1000 : undefined);
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={key}
|
key={key}
|
||||||
@@ -197,12 +285,16 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
<IconButton
|
<IconButton
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
variant="Secondary"
|
variant={isPlaying ? 'Primary' : 'Secondary'}
|
||||||
disabled={busyPreview === key}
|
disabled={busyPreview === key}
|
||||||
onClick={() => preview(key, base.url, rowVolume)}
|
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>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="300"
|
size="300"
|
||||||
@@ -227,7 +319,24 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
aria-label="Clip name"
|
aria-label="Clip name"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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} />
|
<Icon size="50" src={Icons.VolumeHigh} />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -237,9 +346,12 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
defaultValue={rowVolume}
|
defaultValue={rowVolume}
|
||||||
disabled={!canEdit || markedDeleted}
|
disabled={!canEdit || markedDeleted}
|
||||||
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
|
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
|
||||||
style={{ flexGrow: 1 }}
|
style={{ flexGrow: 1, minWidth: 0 }}
|
||||||
aria-label="Clip volume"
|
aria-label="Clip volume"
|
||||||
/>
|
/>
|
||||||
|
<Text size="T200" priority="300" style={{ width: toRem(30), textAlign: 'right' }}>
|
||||||
|
{rowVolume}%
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{canEdit && !isUpload && (
|
{canEdit && !isUpload && (
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -308,7 +420,13 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
{existing.map((c) =>
|
{existing.map((c) =>
|
||||||
renderRow(
|
renderRow(
|
||||||
c.shortcode,
|
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,
|
false,
|
||||||
deleted.has(c.shortcode),
|
deleted.has(c.shortcode),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -196,7 +196,8 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
aria-label={`Play ${clip.name}`}
|
aria-label={`Play ${clip.name}`}
|
||||||
style={{
|
style={{
|
||||||
width: toRem(76),
|
width: toRem(76),
|
||||||
height: toRem(76),
|
minHeight: toRem(76),
|
||||||
|
height: 'auto',
|
||||||
padding: config.space.S100,
|
padding: config.space.S100,
|
||||||
borderRadius: config.radii.R400,
|
borderRadius: config.radii.R400,
|
||||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
@@ -215,7 +216,19 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
clip.emoji || '🔊'
|
clip.emoji || '🔊'
|
||||||
)}
|
)}
|
||||||
</Text>
|
</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}
|
{clip.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds';
|
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 { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
@@ -15,6 +15,7 @@ import { useFilePicker } from '../../../hooks/useFilePicker';
|
|||||||
function ExportKeys() {
|
function ExportKeys() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>(
|
const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>(
|
||||||
useCallback(
|
useCallback(
|
||||||
@@ -28,9 +29,9 @@ function ExportKeys() {
|
|||||||
const blob = new Blob([encKeys], {
|
const blob = new Blob([encKeys], {
|
||||||
type: 'text/plain;charset=us-ascii',
|
type: 'text/plain;charset=us-ascii',
|
||||||
});
|
});
|
||||||
FileSaver.saveAs(blob, 'lotus-keys.txt');
|
saveFile(blob, 'lotus-keys.txt');
|
||||||
},
|
},
|
||||||
[mx],
|
[mx, saveFile],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,11 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') handleCardClick();
|
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 }}>
|
<span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -187,7 +191,11 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
<div style={rowStyle}>
|
<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" />
|
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
<div style={initialsStyle} aria-hidden="true">
|
<div style={initialsStyle} aria-hidden="true">
|
||||||
@@ -197,7 +205,7 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
<span style={nameStyle}>{toast.displayName}</span>
|
<span style={nameStyle}>{toast.displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={bodyStyle}>{toast.body}</div>
|
<div style={bodyStyle}>{toast.body}</div>
|
||||||
<div style={roomNameStyle}>{toast.roomName}</div>
|
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -47,6 +47,7 @@ import { nameInitials } from '../../../utils/common';
|
|||||||
import { RoomAvatar } from '../../../components/room-avatar';
|
import { RoomAvatar } from '../../../components/room-avatar';
|
||||||
import {
|
import {
|
||||||
addRoomIdToMDirect,
|
addRoomIdToMDirect,
|
||||||
|
declineInvite,
|
||||||
getMxIdLocalPart,
|
getMxIdLocalPart,
|
||||||
guessDmRoomUserId,
|
guessDmRoomUserId,
|
||||||
rateLimitedActions,
|
rateLimitedActions,
|
||||||
@@ -179,8 +180,16 @@ function InviteCard({
|
|||||||
onNavigate(invite.roomId, invite.isSpace);
|
onNavigate(invite.roomId, invite.isSpace);
|
||||||
}, [mx, invite, userId, onNavigate]),
|
}, [mx, invite, userId, onNavigate]),
|
||||||
);
|
);
|
||||||
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
|
const [leaveState, leave] = useAsyncCallback<void, Error, []>(
|
||||||
useCallback(() => mx.leave(invite.roomId), [mx, invite]),
|
useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await declineInvite(mx, invite.roomId);
|
||||||
|
} catch (e) {
|
||||||
|
// Surface a friendly message; keep the real error in the console.
|
||||||
|
console.warn('Failed to decline invite', invite.roomId, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}, [mx, invite.roomId]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const joining =
|
const joining =
|
||||||
@@ -276,7 +285,7 @@ function InviteCard({
|
|||||||
)}
|
)}
|
||||||
{leaveState.status === AsyncStatus.Error && (
|
{leaveState.status === AsyncStatus.Error && (
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
{leaveState.error.message}
|
Couldn’t decline this invite — please try again.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -481,7 +490,7 @@ function UnknownInvites({
|
|||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const roomIds = invites.map((invite) => invite.roomId);
|
const roomIds = invites.map((invite) => invite.roomId);
|
||||||
|
|
||||||
await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId));
|
await rateLimitedActions(roomIds, (roomId) => declineInvite(mx, roomId));
|
||||||
}, [mx, invites]),
|
}, [mx, invites]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -559,7 +568,7 @@ function SpamInvites({
|
|||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const roomIds = invites.map((invite) => invite.roomId);
|
const roomIds = invites.map((invite) => invite.roomId);
|
||||||
|
|
||||||
await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId));
|
await rateLimitedActions(roomIds, (roomId) => declineInvite(mx, roomId));
|
||||||
}, [mx, invites]),
|
}, [mx, invites]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { MatrixEvent } from 'matrix-js-sdk';
|
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
|
// 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
|
// `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);
|
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 () => {
|
test('setMarkedUnread writes both the stable and unstable keys with the flag', async () => {
|
||||||
const calls: Array<{ type: string; content: unknown }> = [];
|
const calls: Array<{ type: string; content: unknown }> = [];
|
||||||
const mx = {
|
const mx = {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { AccountDataEvent } from '../../../types/matrix/accountData';
|
|||||||
// flag round-trips across the ecosystem.
|
// flag round-trips across the ecosystem.
|
||||||
const UNSTABLE_MARKED_UNREAD = 'com.famedly.marked_unread';
|
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;
|
const stable = room.getAccountData(AccountDataEvent.MarkedUnread)?.getContent()?.unread;
|
||||||
if (typeof stable === 'boolean') return stable;
|
if (typeof stable === 'boolean') return stable;
|
||||||
return room.getAccountData(UNSTABLE_MARKED_UNREAD)?.getContent()?.unread === true;
|
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) => {
|
export const useBindMarkedUnreadAtom = (mx: MatrixClient, anAtom: typeof markedUnreadAtom) => {
|
||||||
const setAtom = useSetAtom(anAtom);
|
const setAtom = useSetAtom(anAtom);
|
||||||
|
|
||||||
@@ -65,12 +81,15 @@ export const useBindMarkedUnreadAtom = (mx: MatrixClient, anAtom: typeof markedU
|
|||||||
const onAccountData: RoomEventHandlerMap[RoomEvent.AccountData] = (_event, room) => {
|
const onAccountData: RoomEventHandlerMap[RoomEvent.AccountData] = (_event, room) => {
|
||||||
syncRoom(room);
|
syncRoom(room);
|
||||||
};
|
};
|
||||||
// Reading a room clears its marked-unread flag (MSC2867): when our own read
|
// Reading a room clears its marked-unread flag (MSC2867): when our own
|
||||||
// receipt lands for a room that's currently marked, clear it.
|
// 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 onReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event, room) => {
|
||||||
const myId = mx.getUserId();
|
const myId = mx.getUserId();
|
||||||
if (!myId || !readMarkedUnread(room)) return;
|
if (!myId || !readMarkedUnread(room)) return;
|
||||||
if (receiptIsMine(event, myId)) {
|
if (myMainReceiptPresent(event, myId)) {
|
||||||
setMarkedUnread(mx, room.roomId, false).catch(() => undefined);
|
setMarkedUnread(mx, room.roomId, false).catch(() => undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -116,6 +116,23 @@ test('PUT with unchanged counts is skipped (same map reference)', () => {
|
|||||||
assert.equal(before, after);
|
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
|
// roomToUnreadAtom: PUT with parent aggregation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
getUnreadInfo,
|
getUnreadInfo,
|
||||||
getUnreadInfos,
|
getUnreadInfos,
|
||||||
isNotificationEvent,
|
isNotificationEvent,
|
||||||
roomHaveUnread,
|
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { roomToParentsAtom } from './roomToParents';
|
import { roomToParentsAtom } from './roomToParents';
|
||||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||||
@@ -139,6 +138,27 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
|
|||||||
}
|
}
|
||||||
if (action.type === 'PUT') {
|
if (action.type === 'PUT') {
|
||||||
const { unreadInfo } = action;
|
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);
|
const currentUnread = get(baseRoomToUnread).get(unreadInfo.roomId);
|
||||||
if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) {
|
if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) {
|
||||||
// Do not update if unread data has not changes
|
// Do not update if unread data has not changes
|
||||||
@@ -256,20 +276,16 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (isMyReceipt) {
|
if (isMyReceipt) {
|
||||||
// Don't blanket-DELETE the room's unread on any receipt: a THREADED
|
// Optimistically clear on our own receipt (upstream cinny behavior).
|
||||||
// receipt (reading one thread) would wipe the room's still-valid
|
// Do NOT recompute from getUnreadInfo here: getUnreadNotificationCount is
|
||||||
// main-timeline badge, and if the room was already read no
|
// server-computed and STALE on the synchronous synthetic receipt echo
|
||||||
// UnreadNotifications PUT follows to restore it. Recompute instead —
|
// (the SDK only zeroes it immediately when the last live event is our own
|
||||||
// DELETE only when the room is genuinely fully read.
|
// message), so recomputing PUTs the stale non-zero count back → the dot
|
||||||
const info = getUnreadInfo(
|
// sticks / resurrects. The RoomEvent.UnreadNotifications listener below
|
||||||
room,
|
// re-asserts the accurate badge (incl. restoring the main badge after a
|
||||||
getMutedThreads(threadNotificationsRef.current, room.roomId),
|
// thread read) once the server acks, and a { 0, 0 } PUT collapses to a
|
||||||
);
|
// DELETE in the reducer.
|
||||||
if (info.total === 0 && info.highlight === 0 && !roomHaveUnread(mx, room)) {
|
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
||||||
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
|
||||||
} else {
|
|
||||||
setUnreadAtom({ type: 'PUT', unreadInfo: info });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
mx.on(RoomEvent.Receipt, handleReceipt);
|
mx.on(RoomEvent.Receipt, handleReceipt);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createStore } from 'jotai';
|
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
|
// The queue lives in an unexported baseAtom; we drive the two write-only setters
|
||||||
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
|
// (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'],
|
['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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
|
import type { IconSrc } from 'folds';
|
||||||
|
|
||||||
export type ToastNotif = {
|
export type ToastNotif = {
|
||||||
id: string;
|
id: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
iconSrc?: IconSrc; // folds Icon src for a "system" toast (shown instead of an avatar/initials)
|
||||||
displayName: string;
|
displayName: string;
|
||||||
body: string;
|
body: string;
|
||||||
roomName: 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
|
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[]>([]);
|
const baseAtom = atom<ToastNotif[]>([]);
|
||||||
|
|
||||||
// Write-only setter used in ClientNonUIFeatures
|
// Write-only setter used in ClientNonUIFeatures
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { declineInvite } from './matrix';
|
||||||
|
|
||||||
|
// declineInvite must only leave when we're still in the room (invite/join/knock)
|
||||||
|
// — re-leaving an already-left remote room is what Synapse 500s on — and must
|
||||||
|
// always forget afterwards (best-effort) so the ghost invite can't linger.
|
||||||
|
const makeMx = (
|
||||||
|
membership: string | undefined,
|
||||||
|
opts: { leaveRejects?: boolean; forgetRejects?: boolean } = {},
|
||||||
|
) => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const mx = {
|
||||||
|
getRoom: () => (membership === undefined ? null : { getMyMembership: () => membership }),
|
||||||
|
leave: async () => {
|
||||||
|
calls.push('leave');
|
||||||
|
if (opts.leaveRejects) throw new Error('leave failed');
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
forget: async () => {
|
||||||
|
calls.push('forget');
|
||||||
|
if (opts.forgetRejects) throw new Error('forget failed');
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any;
|
||||||
|
return { mx, calls };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('declineInvite: invite → leave then forget', async () => {
|
||||||
|
const { mx, calls } = makeMx('invite');
|
||||||
|
await declineInvite(mx, '!r:s');
|
||||||
|
assert.deepEqual(calls, ['leave', 'forget']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declineInvite: already left → skips leave, still forgets (no double-leave 500)', async () => {
|
||||||
|
const { mx, calls } = makeMx('leave');
|
||||||
|
await declineInvite(mx, '!r:s');
|
||||||
|
assert.deepEqual(calls, ['forget']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declineInvite: join → leave then forget', async () => {
|
||||||
|
const { mx, calls } = makeMx('join');
|
||||||
|
await declineInvite(mx, '!r:s');
|
||||||
|
assert.deepEqual(calls, ['leave', 'forget']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declineInvite: no room object → only forget', async () => {
|
||||||
|
const { mx, calls } = makeMx(undefined);
|
||||||
|
await declineInvite(mx, '!r:s');
|
||||||
|
assert.deepEqual(calls, ['forget']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declineInvite: forget failure is swallowed (best-effort)', async () => {
|
||||||
|
const { mx, calls } = makeMx('invite', { forgetRejects: true });
|
||||||
|
await declineInvite(mx, '!r:s'); // resolves despite forget throwing
|
||||||
|
assert.deepEqual(calls, ['leave', 'forget']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declineInvite: genuine leave failure rejects', async () => {
|
||||||
|
const { mx } = makeMx('invite', { leaveRejects: true });
|
||||||
|
await assert.rejects(() => declineInvite(mx, '!r:s'), /leave failed/);
|
||||||
|
});
|
||||||
@@ -417,6 +417,24 @@ export const downloadEncryptedMedia = async (
|
|||||||
return decryptedContent;
|
return decryptedContent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decline (reject) a room invite robustly. Only sends a leave when we're actually
|
||||||
|
* still in the room (invite/join/knock) — re-leaving an already-left *remote* room
|
||||||
|
* is exactly what Synapse 500s on — then forgets the room so a lingering "leave"
|
||||||
|
* ghost can't re-render as a clickable invite. Idempotent; forget is best-effort.
|
||||||
|
*/
|
||||||
|
export const declineInvite = async (mx: MatrixClient, roomId: string): Promise<void> => {
|
||||||
|
const membership = mx.getRoom(roomId)?.getMyMembership();
|
||||||
|
if (
|
||||||
|
membership === Membership.Invite ||
|
||||||
|
membership === Membership.Join ||
|
||||||
|
membership === Membership.Knock
|
||||||
|
) {
|
||||||
|
await mx.leave(roomId);
|
||||||
|
}
|
||||||
|
await mx.forget(roomId).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
export const rateLimitedActions = async <T, R = void>(
|
export const rateLimitedActions = async <T, R = void>(
|
||||||
data: T[],
|
data: T[],
|
||||||
callback: (item: T, index: number) => Promise<R>,
|
callback: (item: T, index: number) => Promise<R>,
|
||||||
|
|||||||
@@ -20,16 +20,22 @@ type RoomOpts = {
|
|||||||
readUpTo?: string | null;
|
readUpTo?: string | null;
|
||||||
threads?: any[];
|
threads?: any[];
|
||||||
threadUnread?: Record<string, number>;
|
threadUnread?: Record<string, number>;
|
||||||
|
markedUnread?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const setup = (opts: RoomOpts) => {
|
const setup = (opts: RoomOpts) => {
|
||||||
const calls: ReceiptCall[] = [];
|
const calls: ReceiptCall[] = [];
|
||||||
|
const accountDataWrites: Array<{ type: string; content: any }> = [];
|
||||||
const room = {
|
const room = {
|
||||||
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
|
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
|
||||||
getEventReadUpTo: () => opts.readUpTo ?? null,
|
getEventReadUpTo: () => opts.readUpTo ?? null,
|
||||||
getThreads: () => opts.threads ?? [],
|
getThreads: () => opts.threads ?? [],
|
||||||
getThreadUnreadNotificationCount: (threadId: string, _type: NotificationCountType) =>
|
getThreadUnreadNotificationCount: (threadId: string, _type: NotificationCountType) =>
|
||||||
opts.threadUnread?.[threadId] ?? 0,
|
opts.threadUnread?.[threadId] ?? 0,
|
||||||
|
getAccountData: (type: string) =>
|
||||||
|
opts.markedUnread && type === 'm.marked_unread'
|
||||||
|
? { getContent: () => ({ unread: true }) }
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
const mx = {
|
const mx = {
|
||||||
getRoom: () => room,
|
getRoom: () => room,
|
||||||
@@ -38,8 +44,12 @@ const setup = (opts: RoomOpts) => {
|
|||||||
calls.push({ eventId: event.getId(), receiptType, unthreaded });
|
calls.push({ eventId: event.getId(), receiptType, unthreaded });
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
|
setRoomAccountData: async (_roomId: string, type: string, content: any) => {
|
||||||
|
accountDataWrites.push({ type, content });
|
||||||
|
return {};
|
||||||
|
},
|
||||||
} as any;
|
} as any;
|
||||||
return { mx, calls };
|
return { mx, calls, accountDataWrites };
|
||||||
};
|
};
|
||||||
|
|
||||||
test('main timeline: unthreaded receipt at the latest event', async () => {
|
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);
|
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 () => {
|
test('sending thread reply is skipped', async () => {
|
||||||
const t = thread('$root', evt('$reply', true)); // isSending → skip
|
const t = thread('$root', evt('$reply', true)); // isSending → skip
|
||||||
const { mx, calls } = setup({
|
const { mx, calls } = setup({
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { MatrixClient, NotificationCountType, ReceiptType } from 'matrix-js-sdk';
|
import { MatrixClient, NotificationCountType, ReceiptType } from 'matrix-js-sdk';
|
||||||
import { getSettings } from '../state/settings';
|
import { getSettings } from '../state/settings';
|
||||||
|
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
|
||||||
|
|
||||||
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
||||||
const { privateReadReceipts } = getSettings();
|
const { privateReadReceipts } = getSettings();
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return;
|
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 =
|
const receiptType =
|
||||||
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read;
|
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read;
|
||||||
|
|
||||||
|
|||||||
@@ -53,3 +53,20 @@ export const playClipLocally = (
|
|||||||
return undefined;
|
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;
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user