Compare commits

...
4 Commits
Author SHA1 Message Date
jaredandClaude Opus 4.8 a3ca951fba docs: mark P4-4 Math/LaTeX done + note outgoing data-mx-maths interop
CI / Trigger Desktop Build (push) Successful in 9s
CI / Build & Quality Checks (push) Successful in 11m55s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:47:06 -04:00
jaredandClaude Opus 4.8 33cb103abb feat(math): emit data-mx-maths on send for cross-client LaTeX
Math rendering already shipped (both the $…$ shorthand and the spec
data-mx-maths form render incoming). But the composer emitted no math, so a
Lotus user's $E=mc^2$ went out as raw text — it rendered on Lotus (both sides
parse $…$) but showed as literal text on Element and other clients.

toMatrixCustomHTML now converts $…$/$$…$$ to
  <span|div data-mx-maths="LATEX"><code>LATEX</code></span|div>
(spec CS-API §11.5), reusing the existing splitMathSegments parser. Math is
extracted BEFORE markdown so LaTeX (_, *, \, {}) isn't mangled, and the emitted
span survives the block-markdown pass via the existing ignoreHTMLParseInlineMD
HTML-tag guard. A new allowMath opt threads through the top-level call sites;
code-line/code-block paths use empty opts so math is off inside code. The plain
body keeps literal $…$ as the fallback.

Scope: inline $…$ + single-line $$…$$. Multi-line block $$ (spans editor
paragraph nodes) deferred — still renders on Lotus via the plain-body path.

New output.test.ts (8 cases): span/div emission, escaping, markdown-bypass,
currency non-match, code-mark + code-block exclusion. 738 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:46:25 -04:00
jaredandClaude Opus 4.8 7ad948e26c docs: document inline media embeds feature
- README: new bullet for inline media embeds (video/audio/post players + facade).
- LOTUS_FEATURES: 'Inline Media Embeds' section (kinds table, facade, provider
  notes, sandbox, setting, CSP, the fixed YouTube-thumbnail web bug).
- LOTUS_TODO: awaiting-verification row (16 providers) + deferred providers
  (Bandcamp / on.soundcloud / Vimeo event) under the feature backlog + the iframe
  onError gap.
- LOTUS_TESTING: section Q (facade, TikTok, post self-resize + close, new
  Bluesky/Loom/Kick + toggle + cap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:13:01 -04:00
jaredandClaude Opus 4.8 07b0c410ab fix(notifications): don't badge/notify stale device-verification requests
In-room verification requests arrive as m.room.message with
msgtype 'm.key.verification.request', so isNotificationEvent() counted them
(type is m.room.message). A stale/old request at the tail of a DM therefore
re-lit the room's unread dot — and could fire a toast/OS notification — on every
fresh sync (app update / CTRL+F5 cache clear); opening the room only cleared it
via the local read-receipt echo, so it returned on the next reload.

Exclude that msgtype from isNotificationEvent so verification control messages
never drive unread/notifications (mirrors the existing member/redaction/edit
exclusions). The rest of the verification flow already uses distinct event types
that aren't notification events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:10:33 -04:00
10 changed files with 248 additions and 3 deletions
+61 -1
View File
@@ -706,8 +706,9 @@ KaTeX-rendered math in messages, two paths:
- **Spec path (CS-API §11.5):** `<span/div data-mx-maths="…">` in `formatted_body` renders the attribute's LaTeX (block for div, inline for span); on render failure the element's child fallback content shows instead
- **Plain-text path:** `$…$` (inline) and `$$…$$` (block) with conservative rules — escape-aware (`\$`), currency-guarded (`$5 and $10` stays text), never inside `code`/`pre`
- **Outgoing interop:** on send, the composer converts `$…$`/`$$…$$` to spec `<span/div data-mx-maths>` HTML in `formatted_body` (extracted before markdown so LaTeX isn't mangled; off inside code), so math renders on Element and every other client — not just Lotus. The plain `body` keeps literal `$…$` as the fallback
- KaTeX + its CSS load lazily on first math encountered — zero cost to the main bundle
- Files: `src/app/utils/mathParse.ts` (+14 tests), `components/math/KaTeX.tsx`, `plugins/react-custom-html-parser.tsx`
- Files: `src/app/utils/mathParse.ts` (+14 tests), `components/math/KaTeX.tsx`, `plugins/react-custom-html-parser.tsx` (render), `components/editor/output.ts` (+ `output.test.ts`, outgoing)
### Image / Video Captions
@@ -767,6 +768,65 @@ Redacted events display "This message has been deleted" along with the redaction
Generic (non-domain-specific) cards display a Google S2 favicon. Empty or unparseable preview responses are suppressed entirely rather than showing a blank card.
### Inline Media Embeds
Media links play/render **in place** instead of opening a browser tab. A pure
resolver, `parseMediaEmbed(url, host)` in `src/app/utils/videoEmbed.ts`, maps a
URL to `{ provider, kind, embedUrl }`; `MediaEmbedCard` / `TikTokEmbedCard` /
`TwitterCard` in `UrlPreviewCard.tsx` render it. Four render `kind`s:
| kind | shape | providers |
| ----------- | ------------------------ | ----------------------------------------------------------------- |
| `landscape` | 16:9 video player | YouTube, Vimeo, Dailymotion, Streamable, Twitch, Loom, Kick (live) |
| `portrait` | 9:16 video player | YouTube Shorts, TikTok |
| `audio` | fixed-height audio player | Spotify, SoundCloud, Apple Music, Tidal |
| `rich` | self-resizing post embed | X/Twitter, Instagram, Reddit, Bluesky |
**Privacy-friendly facade.** The tile first shows the homeserver's cached
`og:image` thumbnail + a play button; the third-party `<iframe>` is only mounted
on click, so nothing hits Google/Meta/etc. until the user opts in. A **Close**
button collapses a playing embed back to the facade, and video players carry a
Fullscreen control. Cookie-less/DNT variants are used where offered
(`youtube-nocookie.com`, Vimeo `dnt=1`).
**Provider notes.**
- **TikTok** — short "copy-link" URLs (`vm.tiktok.com`, `tiktok.com/t/…`) carry no
video id and the homeserver preview is bot-walled, so `TikTokEmbedCard`
resolves the id client-side via TikTok's CORS-enabled **oEmbed** API on click
(`AbortController`-guarded), then plays the `player/v1` embed.
- **Reddit / Instagram / Bluesky / X** — post embeds self-size via `postMessage`;
`useIframeAutoHeight` listens scoped to each provider's origin **and** our own
iframe, parsing each provider's height shape (Instagram `MEASURE`, Reddit
`resize.embed`, Twitter `twttr.private.resize`). `redd.it` short links resolve to
the subreddit-less `embed.reddit.com/comments/{id}/` route.
- **Vimeo** unlisted-video privacy hashes (`vimeo.com/{id}/{hash}`) and
channel/group/album forms are parsed; **YouTube** handles `/watch`, `youtu.be`,
`/embed`, `/live`, `/shorts`, and `m.`/`music.youtube.com`.
**Defense-in-depth.** Every embed iframe carries a `sandbox` that omits
`allow-top-navigation` (so a compromised embed can't redirect the whole app —
phishing guard) on top of the CSP `frame-src` allowlist. Previews are also capped
at 6 per message.
**Setting.** `inlineMediaEmbeds` (Settings → General → "Inline Media Players",
default **on**). Off → media links fall back to plain link tiles.
**Latent web bug fixed along the way.** YouTube thumbnails now come from the
homeserver `og:image` instead of `img.youtube.com` — which was silently broken on
the web build (nginx `img-src` has no YouTube host) — removing a pre-click Google
request as a bonus.
**CSP.** Desktop Tauri `frame-src` (`cinny-desktop` `tauri.conf.json`) and the web
nginx `frame-src` allowlist enumerate every embed host (youtube-nocookie,
player.vimeo, geo.dailymotion, streamable, player/clips.twitch, open.spotify,
w.soundcloud, embed.music.apple, embed.tidal, www.tiktok + connect-src for its
oEmbed, platform.twitter, www.instagram, embed.reddit, embed.bsky.app, www.loom,
player.kick).
**Files:** `src/app/utils/videoEmbed.ts` (resolver + parsers, unit-tested),
`src/app/components/url-preview/{UrlPreviewCard,UrlPreview.css}.tsx`.
### Poll Creation
- `PollCreator.tsx` creates stable `m.poll.start` events
+37
View File
@@ -662,6 +662,43 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view,
---
## Q. Inline Media Embeds — video / audio / post players (needs the web deploy live)
The whole feature is behind **Settings → General → "Inline Media Players"** (default **on**). Everything loads from the homeserver's cached thumbnail first; the third-party player only mounts on **Play**. Test on the **web** build first, then re-check the video ones on **desktop (Tauri)** since the CSP differs. On any failure, grab the **browser console** (F12) — a blocked embed shows as a CSP `frame-src` violation naming the host.
### Q1. Facade + one of each kind plays in place
Paste each of these into a room and confirm a media tile (not a plain link) with a thumbnail + play button, and that clicking Play mounts the player **inline**:
- **16:9 video:** a YouTube `watch` link, a Vimeo link, a Dailymotion link, a Streamable link, a Twitch VOD/clip, a Loom `share` link.
- **9:16 portrait:** a YouTube **Shorts** link (renders tall, not letterboxed).
- **Audio player:** a Spotify track, a SoundCloud track, an Apple Music album, a Tidal album/track.
- **Post embed:** an X/Twitter post, an Instagram post, a Reddit post.
**Expected:** ✅ tile shows the thumbnail; **no** request to the third party until you press Play (check DevTools → Network); the player then plays inline. ❌ tell me any that stay a plain link, show a blank frame, or hit the network before you click.
### Q2. TikTok (the tricky one) + portrait fill
1. Paste a **full** TikTok URL and a **short** copy-link (`vm.tiktok.com/…` or `tiktok.com/t/…`).
2. Press Play on each.
**Expected:** both resolve to a clean **9:16** player that **fills the box** (no big empty band on the right). The short link shows a brief spinner while it resolves via oEmbed, then plays. ❌ tell me if a short link shows only the TikTok logo/♫ and never a play button, or if the player has dead space beside it.
### Q3. Post self-resize + Close / Fullscreen controls
1. Play a **Reddit**, **Instagram**, and **X/Twitter** post embed.
2. Watch the card height as the embed loads.
**Expected:** the card **grows to fit** the post (no clipped/scrollbarless content, no giant empty box). A **Close** button (✕) collapses the player back to the thumbnail; video players also show a **⛶ Fullscreen** control that works. Keyboard: Tab to the play button → it shows a visible **focus ring**.
### Q4. New providers (unverified) + the toggle + the cap
- **Bluesky / Loom / Kick** — these are freshly added and unverified live. Paste a `bsky.app/profile/…/post/…`, a `loom.com/share/…`, and a live `kick.com/{channel}` link. ✅ good if each plays/renders inline; ❌ if any is a broken frame (for **Bluesky** especially, note whether a **handle** URL resolves or only a DID one does — grab the console).
- **Toggle off:** Settings → General → **Inline Media Players** off → every media link reverts to a plain link tile (no player).
- **Cap:** paste a message with **8+** media links → at most **6** preview cards render (the rest are suppressed), and the page stays responsive.
---
## Priority if you're short on time
1. **O1 + O2** (threads + per-thread notifications) — the largest new surface; the main-timeline change is user-visible.
+19 -2
View File
@@ -57,6 +57,7 @@ Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then gr
| Desktop proactive update notifications (P5-40) | J1 |
| OIDC/SSO login (P4-6, needs an MSC3861 server — pick mozilla.org on login) | OIDC |
| Windows native WinRT toast quick-reply / click-to-open (D6, AUMID) | rich-toast (§backlog) |
| Inline media embeds (16 providers: video/audio/post + click-to-play facade) | Q1 / Q2 / Q3 / Q4 |
---
@@ -152,9 +153,15 @@ After Phases AC the client spec is ~complete. What's left, flagged by **what
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)
### [x] P4-4 · Math / LaTeX Rendering — DONE
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.
Rendering shipped (KaTeX, `$…$`/`$$…$$` + spec `data-mx-maths`, lazy-loaded,
`<pre>/<code>`-guarded) — see LOTUS_FEATURES.md. **Outgoing cross-client interop
added (2026-07):** the composer now emits spec `data-mx-maths` HTML on send
(`editor/output.ts`, reusing `splitMathSegments`), so math a Lotus user types
renders on Element and every other client, not just Lotus. Deferred: multi-line
block `$$…$$` (spans editor paragraph nodes) still renders on Lotus via the
plain-body `$…$` path only.
### [~] P5-20 · Quick Reply from Browser Notification (partial)
@@ -172,6 +179,16 @@ Phase 1 shipped: `io.lotus.set_deafen` (LiveKit-source deafen/screenshare-audio-
Comprehensive audit of all LOTUS_FEATURES.md features for mobile PWA usability + responsiveness. Method: 44px touch targets, no horizontal overflow, full-screen modals/drawers on mobile, composer not obscured by keyboard.
### [ ] Inline media embeds — remaining providers (LOW PRIORITY)
The inline embed system (`videoEmbed.ts`) covers 16 providers; three more were **deliberately deferred** (verified against 2026 docs by review agents):
- **Bandcamp** (highest-value audio add) — needs an **oEmbed** round-trip: the player URL requires numeric `album`/`track` item ids that aren't in the page URL (`bandcamp.com/oembed` is the resolver; mirror the `TikTokEmbedCard` on-click oEmbed pattern). CSP `frame-src`: `bandcamp.com`. Classify `kind: 'audio'`.
- **SoundCloud `on.soundcloud.com` short links** — the `w.soundcloud` widget resolver does **not** follow the redirect; needs the same on-click oEmbed resolve (`soundcloud.com/oembed`, CORS-enabled) to get the canonical URL. (Canonical `soundcloud.com/{user}/{track}` links already work.)
- **Vimeo `event/{id}` (live events) + `ondemand/…`** — event embed host is `vimeo.com` (**not** `player.vimeo.com`, so it needs a new CSP `frame-src` host); on-demand is paywalled and doesn't embed for non-purchasers. Low ROI — only do the event case if `vimeo.com` is widened for another reason.
Also open (from the quality review): a real `onError`/error-state fallback for iframes that fail to load (deleted post / region lock / X login-wall) — cross-origin frames don't fire `onError` reliably, so this needs a load-timeout heuristic; the Close button + badge link are the current escape hatch.
### Deferred / dropped (decided — kept for context)
- **[DEFERRED] P5-51** Federated "Identity Contexts" (session isolation) — multi-sprint, touches auth/crypto/storage core; smaller intermediate step = plain multi-account switch. **[DROPPED] P5-52** per-room sync governor — js-sdk can't truly per-room filter `/sync`; only a cosmetic hide. **[DEFERRED] P5-53** local scripting plugin — prefer a declarative automation-rules feature (no arbitrary code). **[DEFERRED] Audit-3** profile banner — MSC4427 open/unmerged; revisit on merge. **[WON'T FIX] P5-50** Windows HW media pipeline (WebRTC decode lives in WebView2; not injectable). **[MOVED] P5-9** LFG → LotusBot `!lfg`.
+1
View File
@@ -41,6 +41,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- Deleted messages show a placeholder instead of disappearing
- Code blocks highlight syntax for JS/TS, Python, and Rust
- Rich link preview cards for YouTube, GitHub, Twitter/X, Reddit, Spotify, Twitch, Steam, Wikipedia, Discord, npm, Stack Overflow, and IMDb
- Inline media embeds — play videos and posts in place instead of opening a browser tab: YouTube/Shorts, Vimeo, Dailymotion, Streamable, Twitch, Loom, and Kick as video players; TikTok, X/Twitter, Instagram, Reddit, and Bluesky as inline posts; Spotify, SoundCloud, Apple Music, and Tidal as a built-in audio player. A privacy-friendly facade shows the homeserver's cached thumbnail and only loads the third-party player when you press play. Toggle at Settings → General → "Inline Media Players" (on by default)
### Calls & Voice
+68
View File
@@ -0,0 +1,68 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Descendant } from 'slate';
import { toMatrixCustomHTML } from './output';
import { BlockType } from './types';
// Loose Slate node builders for the test.
const txt = (text: string, marks: Record<string, unknown> = {}) =>
({ text, ...marks }) as unknown as Descendant;
const el = (type: BlockType, children: unknown[]) =>
({ type, children }) as unknown as Descendant;
const OPTS = {
allowTextFormatting: true,
allowInlineMarkdown: false,
allowBlockMarkdown: false,
allowMath: true,
};
test('inline $…$ → data-mx-maths span, surrounding prose preserved', () => {
assert.equal(
toMatrixCustomHTML(txt('a $x^2$ b'), OPTS),
'a <span data-mx-maths="x^2"><code>x^2</code></span> b',
);
});
test('single-line block $$…$$ → data-mx-maths div', () => {
assert.equal(
toMatrixCustomHTML(txt('$$E=mc^2$$'), OPTS),
'<div data-mx-maths="E=mc^2"><code>E=mc^2</code></div>',
);
});
test('LaTeX special chars are escaped in attribute + fallback', () => {
assert.equal(
toMatrixCustomHTML(txt('$a<b$'), OPTS),
'<span data-mx-maths="a&lt;b"><code>a&lt;b</code></span>',
);
});
test('math bypasses markdown — underscores are not italicised', () => {
// Without pre-markdown extraction, `_` would become <em>.
assert.equal(
toMatrixCustomHTML(txt('$a_b$'), { ...OPTS, allowInlineMarkdown: true }),
'<span data-mx-maths="a_b"><code>a_b</code></span>',
);
});
test('currency ($5 and $10) stays literal', () => {
assert.equal(toMatrixCustomHTML(txt('$5 and $10'), OPTS), '$5 and $10');
});
test('no math conversion inside an inline code mark', () => {
const out = toMatrixCustomHTML(txt('$x$', { code: true }), OPTS);
assert.ok(!out.includes('data-mx-maths'));
assert.ok(out.includes('<code>$x$</code>'));
});
test('no math conversion inside a code block', () => {
const block = el(BlockType.CodeBlock, [el(BlockType.CodeLine, [txt('$x$')])]);
const out = toMatrixCustomHTML(block, OPTS);
assert.ok(!out.includes('data-mx-maths'));
assert.ok(out.includes('$x$'));
});
test('a message with no math is unchanged', () => {
assert.equal(toMatrixCustomHTML(txt('just hello'), OPTS), 'just hello');
});
+28
View File
@@ -12,14 +12,42 @@ import {
import { findAndReplace } from '../../utils/findAndReplace';
import { sanitizeForRegex } from '../../utils/regex';
import { isUserId } from '../../utils/matrix';
import { splitMathSegments } from '../../utils/mathParse';
export type OutputOptions = {
allowTextFormatting?: boolean;
allowInlineMarkdown?: boolean;
allowBlockMarkdown?: boolean;
allowMath?: boolean;
};
// Spec `data-mx-maths` markup (CS-API §11.5): the attribute holds the LaTeX; the
// <code> child is the fallback for clients that don't render math. sanitizeText
// escapes & < > " ' — correct for both the attribute value and the <code> text.
const mathToCustomHtml = (latex: string, block: boolean): string => {
const esc = sanitizeText(latex);
const tag = block ? 'div' : 'span';
return `<${tag} data-mx-maths="${esc}"><code>${esc}</code></${tag}>`;
};
const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
// Convert `$…$`/`$$…$$` to `data-mx-maths` so math renders on other clients.
// Extracted BEFORE markdown so LaTeX (`_`, `*`, `\`, `{}`) isn't mangled; never
// applied inside inline code. Non-math text recurses with allowMath off so it
// still gets the normal marks + inline-markdown treatment.
if (opts.allowMath && !node.code) {
const segments = splitMathSegments(node.text);
if (segments.some((seg) => seg.type !== 'text')) {
return segments
.map((seg) =>
seg.type === 'text'
? textToCustomHtml({ ...node, text: seg.value }, { ...opts, allowMath: false })
: mathToCustomHtml(seg.value, seg.type === 'block')
)
.join('');
}
}
let string = sanitizeText(node.text);
if (opts.allowTextFormatting) {
if (node.bold) string = `<strong>${string}</strong>`;
+2
View File
@@ -514,6 +514,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
allowTextFormatting: true,
allowBlockMarkdown: isMarkdown,
allowInlineMarkdown: isMarkdown,
allowMath: true,
}),
);
let msgType = MsgType.Text;
@@ -616,6 +617,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
allowTextFormatting: true,
allowBlockMarkdown: isMarkdown,
allowInlineMarkdown: isMarkdown,
allowMath: true,
}),
);
if (plainText === '') return null;
@@ -117,6 +117,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
allowTextFormatting: true,
allowBlockMarkdown: isMarkdown,
allowInlineMarkdown: isMarkdown,
allowMath: true,
}),
);
+22
View File
@@ -357,6 +357,28 @@ test('isNotificationEvent accepts message/sticker but rejects member, redacted,
),
false,
);
// Device-verification requests (m.room.message + this msgtype) are crypto
// control messages — must NOT count as unread/notify (stale-request nag fix).
assert.equal(
isNotificationEvent(
mockEvent({
getType: () => 'm.room.message',
getContent: () => ({ msgtype: 'm.key.verification.request' }),
}),
),
false,
);
// Regression guard: a normal text message still notifies.
assert.equal(
isNotificationEvent(
mockEvent({
getType: () => 'm.room.message',
getContent: () => ({ msgtype: 'm.text', body: 'hi' }),
}),
),
true,
);
});
// --- roomHaveNotification / getUnreadInfo --------------------------------
+9
View File
@@ -195,12 +195,21 @@ const NOTIFICATION_EVENT_TYPES = [
'm.room.member',
'm.sticker',
];
// In-room device-verification requests are sent as m.room.message with this
// msgtype (the rest of the flow — start/accept/key/mac/done/cancel — uses its own
// event types, already excluded above). They're crypto control messages, not chat.
const VERIFICATION_REQUEST_MSGTYPE = 'm.key.verification.request';
export const isNotificationEvent = (mEvent: MatrixEvent) => {
const eType = mEvent.getType();
if (!NOTIFICATION_EVENT_TYPES.includes(eType)) {
return false;
}
if (eType === 'm.room.member') return false;
// Don't badge/notify a verification request — otherwise a stale one at the tail
// of a DM re-lights the room's unread dot on every fresh sync (cache clear).
if (eType === 'm.room.message' && mEvent.getContent().msgtype === VERIFICATION_REQUEST_MSGTYPE) {
return false;
}
if (mEvent.isRedacted()) return false;
if (mEvent.getRelation()?.rel_type === 'm.replace') return false;