Compare commits

...
7 Commits
Author SHA1 Message Date
jaredandClaude Opus 4.8 8e02cef658 docs(todo): prettier formatting (markdown emphasis _x_ not *x*)
CI / Build & Quality Checks (push) Successful in 11m4s
CI / Trigger Desktop Build (push) Successful in 7s
Fixes the prettier CI gate that failed on bc608b37 — check:prettier runs
`prettier --check .` over the whole repo (incl. markdown), and prettier's
markdown style uses `_italic_`. Prior doc commits slipped `*italic*` through
because I only ran prettier on changed src files, not the .md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 01:30:55 -04:00
jaredandClaude Opus 4.8 bc608b377a docs(todo): record inline-embed bug hunt — fixes + deferred items
CI / Build & Quality Checks (push) Failing after 6m27s
CI / Trigger Desktop Build (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 23:36:31 -04:00
jaredandClaude Opus 4.8 f2673effe4 fix(embeds): parsing over/under-match + broken thumbnails + wide layout
Bugs found by a 3-agent audit of the inline-embed system (core posture —
sandbox, postMessage origin+source, XSS, noreferrer, oEmbed — verified sound);
fixes reviewed by 2 agents on the staged diff (both SHIP).

Parsing (videoEmbed.ts, + tests):
- Twitch/Kick/SoundCloud/Streamable reserved-path exclusion — their own utility
  pages (twitch.tv/directory, kick.com/browse, soundcloud.com/discover/…,
  streamable.com/login, bare /videos) no longer render as broken player embeds.
- SoundCloud: `/<artist>/<tab>` profile-tab listings excluded; `/<artist>/sets/<slug>`
  real sets still detected.
- Vimeo: unlisted-hash capture constrained to lowercase-hex, so a normal video's
  trailing segment (/likes, /settings, a slug) isn't captured as a bogus `h=`
  param that Vimeo then rejects.

Rendering (UrlPreviewCard.tsx, RenderMessageContent.tsx):
- Spotify/Steam/Discord/IMDb route og:image through mxcUrlToHttp like every other
  card — a raw og:image is an mxc:// URI (broken <img> on standard Synapse) or an
  off-homeserver request that defeats the click-to-play facade.
- `wide` card class now follows the RESOLVED embed (incl. the og:url short-link
  fallback), so an og:url-resolved player gets the wide layout, not a cramped one.
- Twitter host detection (isTwitter/isTwitterTweet) aligned with getTweetId —
  mobile.twitter.com and legacy /statuses/ now route to the Twitter card/embed.
- De-dupe preview URLs so a message repeating a link doesn't render sibling
  cards with identical React keys.

Gates: tsc 0, eslint 0, prettier clean, 910 tests, build ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 23:35:59 -04:00
jaredandClaude Opus 4.8 f03c0ef960 test: cover cryptoDiagLog + closedLobbyCategories
Test-coverage batch 2 (2-agent reviewed, both SHIP; isolation verified — Node
runs each test file in its own process, so the console patch can't leak).

- cryptoDiagLog.test.ts: the E2EE KE-cluster diagnostics tool — KE-signature
  capture vs ignore, most-specific-first match order, KE-3/KE-4 rows, Error /
  object / circular-arg serialization (String() fallback never throws), the
  200-entry ring-buffer eviction, getCryptoDiagEntries copy semantics,
  install idempotency, and buildCryptoDiagReport's client metadata + LOCKED
  PII-safe key set (no field can silently leak) + no-client/throwing-getter
  fallbacks. Silences console pass-through so the ring-buffer test stays quiet.
- closedLobbyCategories.test.ts: mirrors closedNavCategories — id join,
  hydrate, PUT/DELETE, idempotent PUT, no-op DELETE, array persistence,
  per-user key namespacing.

Also: mark the EC in-call mobile UI audit done in LOTUS_TODO (stale entry;
shipped as element-call:lotus e36aef8a).

Gates: tsc 0, eslint 0, prettier clean, 911 tests, build ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:38:02 -04:00
jaredandClaude Opus 4.8 8cc8dfd796 docs(todo): record CI hardening (concurrency + hard gates) + follow-ups
CI / Build & Quality Checks (push) Failing after 22m15s
CI / Trigger Desktop Build (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:10:07 -04:00
jaredandClaude Opus 4.8 386a297997 ci: concurrency (cancel superseded) + promote typecheck/eslint/prettier gates
Reviewed by 2 agents + a focused deploy-script review (all SHIP).

- Add top-level `concurrency: cancel-in-progress`. A superseded lotus push
  cancels its in-flight run, freeing the shared act_runner (web CI otherwise
  queues behind long Tauri desktop builds); since `trigger-desktop` is
  `needs: build`, only the newest commit kicks a desktop build.
- Promote typecheck / eslint / prettier from `continue-on-error` to hard gates
  (tree held clean: tsc 0, eslint 0 errors, prettier formatted). eslint gates
  on errors only; existing no-explicit-any warnings stay informational.
- Mark the bundle-size report informational (audit already is).

Cancelling superseded runs is deploy-safe only because lotus_deploy.sh now
re-resolves origin/lotus each poll iteration (companion change in the matrix
repo); the comment documents the coupling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:09:10 -04:00
jaredandClaude Opus 4.8 36369926ca test: cover dom + emoji pure helpers; fix syntaxErrorPosition regex
Test-coverage expansion (2-agent reviewed, both SHIP). The named candidates
(roomToUnread, markedUnread, serverAcl, plaintextCaches, recent*) were already
tested, so this targets genuinely-untested pure logic.

- dom.test.ts: getThumbnailDimensions (scaling math incl. just-over-cap
  boundaries), tryDecodeURIComponent, syntaxErrorPosition, and the three
  scroll-view geometry helpers (via duck-typed element mocks — no jsdom).
- emoji.test.ts: getHexcodeForEmoji (astral codepoints, 4-digit zero-pad,
  FE0F/FE0E/200D stripping on and off, keycap sequences, degenerate inputs)
  and the pre-load `undefined` contract for getShortcode(s)For.

Fix (found while writing the tests): syntaxErrorPosition required whitespace
AFTER the digits (`/position\s(\d+)\s/`), but real V8/Node JSON.parse errors
put the number at end-of-string ("... at position N"), so it returned
undefined for every real error and the three dev-tools JSON editors silently
pointed their cursor at position 0. Dropped the trailing `\s`; tests now assert
extraction at end-of-string.

Gates: tsc 0, eslint 0, prettier clean, 891 tests, build ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:49:51 -04:00
11 changed files with 742 additions and 43 deletions
+24 -6
View File
@@ -6,6 +6,18 @@ on:
pull_request:
branches: [lotus]
# Only the newest commit per ref needs to build: a superseded push cancels its
# in-flight run. This keeps the shared act_runner free (web CI otherwise queues
# behind long Tauri desktop builds) and — since `trigger-desktop` is `needs:
# build` — means only the latest lotus commit ever kicks a desktop build,
# instead of one per rapid push. Cancelling a superseded run is deploy-safe
# ONLY because lotus_deploy.sh re-resolves origin/lotus each poll iteration and
# retargets its CI gate to HEAD — otherwise a run cancelled mid-poll would
# strand the newest commit undeployed. Keep those two in sync.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build & Quality Checks
@@ -53,26 +65,32 @@ jobs:
- name: Unit tests
run: npm test
# ── Quality checks (informational — pre-existing issues exist) ───────
# ── Quality gates (hard — a failure fails the job and blocks deploy) ──
# The tree is held clean (typecheck 0, eslint 0 errors, prettier
# formatted), so these gate real regressions instead of relying on local
# runs. NOTE: an upstream-stable merge (the lotus-build.sh path) could
# introduce upstream type/lint/format issues; that path deploys without
# CI, but a subsequent normal push would surface the failure here — fix
# forward (or briefly re-soften a gate) rather than let it deploy broken.
# eslint gates on errors only (existing `no-explicit-any` warnings stay
# informational — `check:eslint` has no --max-warnings).
- name: TypeScript
run: npm run typecheck
continue-on-error: true
- name: ESLint
run: npm run check:eslint
continue-on-error: true
- name: Prettier
run: npm run check:prettier
continue-on-error: true
# ── Security ─────────────────────────────────────────────────────────
# ── Security (informational — findings shouldn't block a deploy) ─────
- name: Audit (high/critical)
run: npm audit --audit-level=high --omit=dev
continue-on-error: true
# ── Bundle size report ───────────────────────────────────────────────
# ── Bundle size report (informational — never blocks a deploy) ───────
- name: Report bundle sizes
continue-on-error: true
run: |
echo "### Bundle sizes" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
+24 -3
View File
@@ -212,7 +212,7 @@ Intentional desktop deltas (disclosed, non-regressive): volume sliders below lab
**NOT done — needs a real device / product decisions (open):**
- [ ] **Runtime mobile QA** — none of the above is validated on an actual phone (static analysis only). Needs device/devtools walk-through per LOTUS_TESTING §E.
- [ ] **Element Call fork in-call mobile UI**the EC iframe (video grid, EC control bar, spotlight) is `LotusGuild/element-call` at `/root/code/element-call` (we own it); the cinny-side audit couldn't reach it. The actual on-phone call experience is unaudited.
- [x] **Element Call fork in-call mobile UI**DONE (`element-call:lotus` `e36aef8a`, 3-agent survey + 2-agent review). Fixed the EC iframe's own phone UI: footer control row wraps so hangup can't clip (320500px), portrait 1:1 self-PiP safe-area inset, 44px camera-flip + reaction-picker targets, settings-tab horizontal scroll, landscape spotlight filmstrip. All mobile-gated (EC is mobile-first CSS). Rides to users on the next fork republish (P6-2). Runtime on-device QA still pending (needs a phone).
- [ ] **M2 — touch discoverability** — message quick-reactions/actions are hover-gated; long-press is the fallback but is **unreliable on iOS Safari** (deep audit). A visible touch affordance is needed but the naive fix hides unread badges / clutters messages (member-profile-style redesign).
- [~] **Sub-44px touch-target sweep** — primary controls DONE via a shared `MobileTouchTarget` `@media` class (`P1`, `8a1168bc`): in-call bar ×7, call-status bar ×4, thread "N replies" chip, knock Approve/Deny, ACL remove. Secondary batch DONE (`r2`, `72e7447d`): image-viewer close/zoom±/zoom%/download, embed-player Close/Collapse/Fullscreen/View-post, read-receipt "seen by" pill. **Deferred (rationale, not built):** PiP fullscreen/resize handles — enlarging four 24px corners to 44px would swallow a ~160px mobile PiP and block "Return to call" (needs a design rethink, not a blunt bump); presence dot is a non-interactive status indicator (no target needed).
- [x] **Avatar-decoration `prefers-reduced-motion`** — DONE (`P2`, `c3e1fbff`): renders just the avatar (no animated APNG overlay) under the preference; no static-frame asset to freeze to.
@@ -233,6 +233,15 @@ The inline embed system (`videoEmbed.ts`) covers 16 providers; three more were *
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.
**✅ Embed bug hunt (2026-07, 3 survey agents + 2-agent review) — `f2673eff`.** Core posture verified **sound** (iframe sandbox, `useIframeAutoHeight` postMessage origin+source trust, no XSS/`dangerouslySetInnerHTML`, `rel="noreferrer"` on all 21 links, oEmbed no-SSRF, the whole facade→iframe/abort/observer lifecycle). Fixed: Twitch/Kick/SoundCloud/Streamable reserved-path over-match (utility pages rendered as broken players), Vimeo hash over-capture (`[0-9a-f]{6,}`), Spotify/Steam/Discord/IMDb `og:image` now via `mxcUrlToHttp` (was a broken raw `mxc://` `<img>` + a pre-click 3p-request facade bypass), `wide` class follows the og:url-resolved embed, Twitter host alignment (`mobile.twitter.com`/`/statuses/`), URL de-dupe.
**Deferred / surfaced from the hunt (not fixed — decide before doing):**
- **Security-vs-functionality tradeoff (needs a call):** drop `allow-popups-to-escape-sandbox` and/or `clipboard-write` from `EMBED_SANDBOX`/`allow=` on embed iframes — real hardening against a _compromised_ provider (phishing popup / clipboard hijack), but risks breaking a legit provider popup/copy on the trusted major providers we embed. Low marginal value; not shipped blindly.
- **Defense-in-depth:** `encodeURIComponent` the Bluesky authority + Apple Music path/search interpolated into the embed `src` (not currently exploitable — host is fixed and value comes from `URL.pathname`; React escapes the attribute).
- **Out of embed scope (real, low-sev):** `LotusDenoiseFeature` (`ClientNonUIFeatures.tsx`) has a `window` `message` listener with **no origin/source check** → any frame/window can post `{type:'lotus-denoise-status', error}` and pop a forged **"System"** toast (text only, no XSS). Validate `event.source`.
- **Lifecycle Lows (cosmetic/latent):** a re-fetch flips a playing embed back to the spinner (latent — url is keyed); auto-height retained across close→reopen; `extractEmbedHeight` generic `.height` fallback accepts any allowed-origin message; `TweetEmbed` theme is a one-time `matchMedia` snapshot (no live theme switch); host-normalization gaps (`vt.tiktok.com` misses `StackOnMobile`, `m.instagram.com`, `www.youtu.be`).
### 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`.
@@ -302,8 +311,20 @@ Also flag-gated: `lotusTransparent`/`lotusTheme`, `lotusDenoiseSource=1` (in-sou
```
edit → commit → git push origin lotus
→ Gitea Actions: tsc --noEmit, eslint, prettier (~3 min)
→ lotus_deploy.sh on LXC 106 polls CI → npm ci && npm run build → rsync → live (~11 min)
→ Gitea Actions (.gitea/workflows/ci.yml): npm ci → build + npm test + tsc + eslint + prettier (ALL hard gates) → audit + bundle-size (informational)
→ lotus_deploy.sh on LXC 106 polls the "Build & Quality Checks" status → npm ci && npm run build → rsync → live (~11 min)
```
Before marking a feature complete: `npx tsc --noEmit` (0 errors) · `npx eslint src/` (0 new) · `npx prettier --check src/` · `npm test` (Node runner via tsx, hard CI gate — colocated `*.test.ts`) · update `README.md`/`landing/index.html` for Lotus-custom features · visually verify on `chat.lotusguild.org`.
**CI hardening (2026-07, reviewed):**
- [x] **Concurrency**`cancel-in-progress` on cinny `ci.yml` and cinny-desktop `release.yml` (`386a2979` / `c5461ce`): a superseded lotus push cancels its in-flight web CI and collapses queued ~30-min Tauri desktop builds to just the newest. Safe for deploys because `lotus_deploy.sh` now **follows origin/lotus HEAD** each poll iteration + resets to the gated SHA (`matrix` `c15a489`) — closes the latched-SHA freeze race.
- [x] **Hard quality gates** — typecheck/eslint/prettier promoted from `continue-on-error` to blocking (tree held clean). eslint gates on errors only; `no-explicit-any` warnings stay informational.
**CI follow-ups (open):**
- [ ] **Dedicated `desktop-linux` runner** (infra) — concurrency only collapses _burst_ stacking; a single in-flight `build-linux` (Tauri, `ubuntu-latest`) still shares the runner with web CI and can queue a web CI/deploy up to ~30 min. Fix = register a 2nd Linux act_runner labelled `desktop-linux` (root, network, RAM for a Tauri build; do NOT also label it `ubuntu-latest`) and point only `build-linux: runs-on` at it. Relabeling without a matching runner hangs the job forever.
- [ ] **Debounce the desktop trigger**`trigger-desktop` fires a full desktop build on _every_ lotus commit; consider tag/`workflow_dispatch`/schedule-gating to decouple desktop cadence from web commits (biggest remaining runner-load source).
- [ ] **Verify Gitea ≥ 1.24** actually honors workflow `concurrency` (older silently ignores it → safe no-op, but the change is then inert — confirm on a test burst).
- [ ] **Deferred (chosen-not-now):** build-once/deploy-the-artifact (kill the CI-then-deploy double build); CI-gate the `lotus-build.sh` upstream-merge path (currently builds+deploys+then pushes, bypassing CI).
+3 -2
View File
@@ -88,8 +88,9 @@ export function RenderMessageContent({
}: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => {
// Cap previews per message so a link-dump doesn't spawn dozens of preview
// fetches + iframes at once.
const filteredUrls = urls.filter((url) => !testMatrixTo(url)).slice(0, 6);
// fetches + iframes at once. De-dupe first: a message linking the same URL
// twice would otherwise render sibling cards with identical React keys.
const filteredUrls = [...new Set(urls.filter((url) => !testMatrixTo(url)))].slice(0, 6);
if (filteredUrls.length === 0) return undefined;
return (
<UrlPreviewHolder>
@@ -116,11 +116,15 @@ function isGitHubRepo(url: string): boolean {
}
}
// Keep these hosts + the /status(es) pattern in sync with getTweetId
// (videoEmbed.ts): otherwise a mobile.twitter.com / legacy /statuses/ tweet has
// an extractable id but never routes to the Twitter card or "View post" embed.
const TWITTER_HOSTS = new Set(['twitter.com', 'x.com', 'mobile.twitter.com']);
function isTwitter(url: string): boolean {
try {
const { hostname } = new URL(url);
const h = hostname.replace(/^www\./, '');
return h === 'twitter.com' || h === 'x.com';
return TWITTER_HOSTS.has(hostname.replace(/^www\./, ''));
} catch {
return false;
}
@@ -129,9 +133,8 @@ function isTwitter(url: string): boolean {
function isTwitterTweet(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, '');
if (h !== 'twitter.com' && h !== 'x.com') return false;
return /\/status\/\d+/.test(pathname);
if (!TWITTER_HOSTS.has(hostname.replace(/^www\./, ''))) return false;
return /\/status(?:es)?\/\d+/.test(pathname);
} catch {
return false;
}
@@ -1499,9 +1502,17 @@ function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
}
function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const artworkUrl = (prev['og:image'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined;
// Route through the homeserver like every other card — a raw og:image would
// be an mxc:// URI (broken <img>) on a standard HS, or an off-HS request that
// defeats the click-to-play facade on a nonstandard one.
const artworkUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
: null;
const spotifyType = getSpotifyType(url) ?? 'track';
const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1);
@@ -1552,9 +1563,14 @@ function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
}
function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const thumbnailUrl = (prev['og:image'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const thumbnailUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 480, 270, 'scale', false)
: null;
return (
<>
@@ -1671,9 +1687,14 @@ function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const iconUrl = (prev['og:image'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const iconUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
: null;
return (
<>
@@ -1834,9 +1855,14 @@ function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlRespon
}
function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const posterUrl = (prev['og:image'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const posterUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 120, 180, 'scale', false)
: null;
return (
<>
@@ -2075,28 +2101,34 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
// Interactive embeds (players, tweets) render in a wider, responsive card so
// player chrome / tweet content isn't cramped or clipped.
const embed = parseMediaEmbed(url, window.location.hostname);
const wide = !!embed || isTwitterTweet(url);
// Short "copy-link" links carry no id, so the embed is only resolvable from
// the homeserver's canonical og:url. Resolve it here so `wide` reflects the
// ACTUALLY rendered card — an og:url-resolved MediaEmbedCard must still get
// the wide layout, not the cramped narrow one.
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
if (embed) return embed;
const ogUrl = prev['og:url'];
return typeof ogUrl === 'string' && ogUrl !== url
? parseMediaEmbed(ogUrl, window.location.hostname)
: null;
};
// Twitter/Twitch/TikTok(fallback) cards render header/thumbnail beside content
// in the card flex row; stack them on phones (no-op for the single-column
// embed cards). Desktop keeps the row layout.
const stackOnMobile = isTwitter(url) || isTwitch(url) || isTikTok(url);
const cardClass =
const buildCardClass = (wide: boolean): string | undefined =>
[wide && previewCss.UrlPreviewWide, stackOnMobile && previewCss.StackOnMobile]
.filter(Boolean)
.join(' ') || undefined;
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
const renderContent = (
prev: IPreviewUrlResponse,
resolvedEmbed: MediaEmbed | null,
): React.ReactNode => {
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile.
// Short "copy-link" share URLs (e.g. vm.tiktok.com, tiktok.com/t/…, youtu.be
// redirects) don't carry the id, so fall back to the canonical og:url that
// the homeserver already resolved when fetching the preview.
const ogUrl = prev['og:url'];
const resolvedEmbed =
embed ??
(typeof ogUrl === 'string' && ogUrl !== url
? parseMediaEmbed(ogUrl, window.location.hostname)
: null);
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play
// tile. `resolvedEmbed` (computed by the caller via resolveEmbed) already
// folds in the og:url fallback for short "copy-link" share URLs.
if (resolvedEmbed) {
return <MediaEmbedCard url={url} prev={prev} embed={resolvedEmbed} />;
}
@@ -2189,17 +2221,23 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
// Don't render the card wrapper when content is empty (loaded but nothing to show)
if (previewStatus.status === AsyncStatus.Success) {
const content = renderContent(previewStatus.data);
const prev = previewStatus.data;
const resolvedEmbed = resolveEmbed(prev);
const content = renderContent(prev, resolvedEmbed);
if (content === null) return null;
// `wide` follows the resolved embed (incl. the og:url fallback), so a short
// link that resolves to a player still gets the wide layout.
const wide = !!resolvedEmbed || isTwitterTweet(url);
return (
<UrlPreview {...props} ref={ref} className={cardClass}>
<UrlPreview {...props} ref={ref} className={buildCardClass(wide)}>
{content}
</UrlPreview>
);
}
// Loading/idle: no preview data yet, so base `wide` on the url-only embed.
return (
<UrlPreview {...props} ref={ref} className={cardClass}>
<UrlPreview {...props} ref={ref} className={buildCardClass(!!embed || isTwitterTweet(url))}>
<Box grow="Yes" alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" size="400" />
</Box>
+63
View File
@@ -0,0 +1,63 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { getHexcodeForEmoji, getShortcodeFor, getShortcodesFor } from './emoji';
describe('getHexcodeForEmoji', () => {
it('converts a single astral codepoint to an uppercase hexcode', () => {
// 😀 = U+1F600
assert.equal(getHexcodeForEmoji('😀'), '1F600');
});
it('zero-pads BMP codepoints to at least four hex digits', () => {
// ☺ = U+263A ; # = U+0023 (must pad "23" -> "0023")
assert.equal(getHexcodeForEmoji('☺'), '263A');
assert.equal(getHexcodeForEmoji('#'), '0023');
});
it('strips the FE0F variation selector by default', () => {
// ❤️ = U+2764 U+FE0F
assert.equal(getHexcodeForEmoji('❤️'), '2764');
});
it('keeps the variation selector when strip is false', () => {
assert.equal(getHexcodeForEmoji('❤️', false), '2764-FE0F');
});
it('strips ZWJ (200D) joiners from a sequence by default', () => {
// 👨‍👩‍👧 = 1F468 200D 1F469 200D 1F467
assert.equal(getHexcodeForEmoji('👨‍👩‍👧'), '1F468-1F469-1F467');
});
it('keeps ZWJ joiners when strip is false', () => {
assert.equal(getHexcodeForEmoji('👨‍👩‍👧', false), '1F468-200D-1F469-200D-1F467');
});
it('strips the FE0E text-presentation selector too', () => {
// ▶ = U+25B6 ; ▶︎ = U+25B6 U+FE0E (text presentation)
assert.equal(getHexcodeForEmoji('▶︎'), '25B6');
assert.equal(getHexcodeForEmoji('▶︎', false), '25B6-FE0E');
});
it('handles a keycap sequence (padding + selector strip together)', () => {
// #️⃣ = U+0023 U+FE0F U+20E3 -> "0023" + (FE0F stripped) + "20E3"
assert.equal(getHexcodeForEmoji('#️⃣'), '0023-20E3');
});
it('handles degenerate inputs (empty string, plain ASCII per codepoint)', () => {
assert.equal(getHexcodeForEmoji(''), '');
assert.equal(getHexcodeForEmoji('ab'), '0061-0062');
});
});
describe('getShortcodesFor / getShortcodeFor before emoji data is loaded', () => {
// These gracefully degrade to `undefined` until loadEmojiData() has populated
// the shortcode maps — the contract that lets tooltips/aria-labels render
// eagerly without pulling the emojibase runtime into the eager graph.
it('returns undefined for getShortcodesFor', () => {
assert.equal(getShortcodesFor('1F600'), undefined);
});
it('returns undefined for getShortcodeFor', () => {
assert.equal(getShortcodeFor('1F600'), undefined);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStore } from 'jotai';
import { enableMapSet } from 'immer';
import { makeClosedLobbyCategoriesAtom, makeLobbyCategoryId } from './closedLobbyCategories';
// makeClosedLobbyCategoriesAtom(userId) builds a Set<string> atom whose reducer uses
// immer produce (PUT add / DELETE delete) and persists to a per-user localStorage
// key `closedLobbyCategories<userId>`. The reducers produce over a Set, so enable
// immer's Map/Set plugin (the app does this once at startup).
// makeLobbyCategoryId joins args with '|'. (Mirrors closedNavCategories.test.ts.)
enableMapSet();
type Store = Record<string, string>;
const installLocalStorage = (): Store => {
const data: Store = {};
const ls = {
getItem: (k: string) => (k in data ? data[k] : null),
setItem: (k: string, v: string) => {
data[k] = String(v);
},
removeItem: (k: string) => {
delete data[k];
},
};
(globalThis as { localStorage?: unknown }).localStorage = ls;
(globalThis as { window?: unknown }).window = {
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
return data;
};
test('makeLobbyCategoryId joins args with "|"', () => {
assert.equal(makeLobbyCategoryId('space', 'catA'), 'space|catA');
assert.equal(makeLobbyCategoryId('only'), 'only');
assert.equal(makeLobbyCategoryId(), '');
});
test('starts empty when nothing is stored', () => {
installLocalStorage();
const store = createStore();
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
assert.equal(store.get(lobbyAtom).size, 0);
});
test('hydrates the Set from the per-user localStorage key', () => {
const data = installLocalStorage();
data['closedLobbyCategories@u:server'] = JSON.stringify(['x', 'y']);
const store = createStore();
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
assert.deepEqual(Array.from(store.get(lobbyAtom)).sort(), ['x', 'y']);
});
test('PUT adds a category and DELETE removes it', () => {
installLocalStorage();
const store = createStore();
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
assert.deepEqual(Array.from(store.get(lobbyAtom)), ['cat1']);
store.set(lobbyAtom, { type: 'DELETE', categoryId: 'cat1' });
assert.equal(store.get(lobbyAtom).has('cat1'), false);
});
test('PUT of an existing category is idempotent', () => {
installLocalStorage();
const store = createStore();
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
assert.equal(store.get(lobbyAtom).size, 1);
});
test('DELETE of an absent category is a no-op', () => {
installLocalStorage();
const store = createStore();
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
store.set(lobbyAtom, { type: 'DELETE', categoryId: 'missing' });
assert.equal(store.get(lobbyAtom).size, 0);
});
test('writes persist to localStorage as an array', () => {
const data = installLocalStorage();
const store = createStore();
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
assert.deepEqual(JSON.parse(data['closedLobbyCategories@u:server']), ['cat1']);
});
test('the storage key is namespaced per user', () => {
const data = installLocalStorage();
const store = createStore();
const aAtom = makeClosedLobbyCategoriesAtom('@a:server');
const bAtom = makeClosedLobbyCategoriesAtom('@b:server');
store.set(aAtom, { type: 'PUT', categoryId: 'only-a' });
assert.deepEqual(JSON.parse(data['closedLobbyCategories@a:server']), ['only-a']);
assert.equal(data['closedLobbyCategories@b:server'], undefined);
assert.equal(store.get(bAtom).size, 0);
});
+180
View File
@@ -0,0 +1,180 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { MatrixClient } from 'matrix-js-sdk';
import { buildCryptoDiagReport, getCryptoDiagEntries, installCryptoDiagLog } from './cryptoDiagLog';
// installCryptoDiagLog() replaces console.warn/error with capturing wrappers that
// ALWAYS pass through to the original. Silence the originals here (before install
// captures them) so the ring-buffer test doesn't spam hundreds of lines into the
// test output — the capture logic itself still runs. The module's `entries` buffer
// is module-global and accumulates across tests in this file, so assertions use
// deltas or the absolute cap rather than assuming an empty buffer.
console.warn = () => undefined;
console.error = () => undefined;
installCryptoDiagLog();
const mockClient = (partial: Partial<Record<string, unknown>>): MatrixClient =>
partial as unknown as MatrixClient;
test('captures a KE-signature line and ignores non-matching output', () => {
const before = getCryptoDiagEntries().length;
console.error('POST /keys/upload 400 M_UNKNOWN: One time key already exists');
console.warn('just a normal warning with nothing to capture');
const after = getCryptoDiagEntries();
assert.equal(after.length, before + 1, 'only the matching line is captured');
const last = after[after.length - 1];
assert.equal(last.ke, 'KE-1');
assert.equal(last.signature, 'already exists');
assert.equal(last.level, 'error');
assert.match(last.message, /already exists/);
assert.match(last.ts, /^\d{4}-\d\d-\d\dT/); // ISO-8601 UTC
});
test('matches the first (tightest) signature when several apply', () => {
const before = getCryptoDiagEntries().length;
// Matches both KE-1 'already exists' (index 0) and KE-2 'MissingKey' (index 3);
// find() returns the first, so the tightest/most-specific label is recorded.
console.error('MissingKey: the session key already exists somehow');
const e = getCryptoDiagEntries();
assert.equal(e.length, before + 1);
assert.equal(e[e.length - 1].ke, 'KE-1');
assert.equal(e[e.length - 1].signature, 'already exists');
});
test('serializes Error and object args into the captured message', () => {
const b1 = getCryptoDiagEntries().length;
console.error(new Error('boom: io.element.call.encryption_keys arrived encrypted'));
const afterErr = getCryptoDiagEntries();
assert.equal(afterErr.length, b1 + 1);
assert.match(afterErr[afterErr.length - 1].message, /^Error: boom/);
assert.equal(afterErr[afterErr.length - 1].ke, 'KE-2');
const b2 = getCryptoDiagEntries().length;
console.warn('missing key at index', { index: 7 });
const afterObj = getCryptoDiagEntries();
assert.equal(afterObj.length, b2 + 1);
assert.match(afterObj[afterObj.length - 1].message, /\{"index":7\}/);
assert.equal(afterObj[afterObj.length - 1].ke, 'KE-2');
});
test('ring-buffers to at most 200 entries, evicting the oldest', () => {
// Push well past the cap with uniquely-tagged KE-4 lines (matches /delayed event/i).
for (let i = 0; i < 250; i += 1) {
console.warn(`delayed event tag=${i};`);
}
const e = getCryptoDiagEntries();
assert.equal(e.length, 200, 'buffer is capped at MAX_ENTRIES');
assert.match(e[e.length - 1].message, /tag=249;/, 'newest is retained');
assert.ok(!e.some((x) => x.message.includes('tag=0;')), 'the oldest pushes were evicted');
});
test('getCryptoDiagEntries returns a copy, not the live buffer', () => {
const a = getCryptoDiagEntries();
const b = getCryptoDiagEntries();
assert.notEqual(a, b, 'each call returns a fresh array');
const len = a.length;
a.push({ ts: 'x', level: 'warn', ke: 'X', signature: 'x', message: 'x' });
assert.equal(getCryptoDiagEntries().length, len, 'mutating the copy does not affect the buffer');
});
// NOTE: these run after the ring-buffer test has filled the buffer to its cap,
// so a capture evicts the oldest and length stays at 200 — assert on the NEWEST
// entry (always the line just pushed) rather than a length delta.
test('captures the KE-3 and KE-4 signatures', () => {
console.error('DecryptionError: unable to decrypt event');
const e1 = getCryptoDiagEntries();
assert.equal(e1[e1.length - 1].ke, 'KE-3');
assert.equal(e1[e1.length - 1].signature, 'DecryptionError');
// underscore, not a space, so it matches the tighter 'update_delayed_event'
// row (index 5) rather than the looser 'delayed event' row.
console.warn('msc4157.update_delayed_event timed out');
const e2 = getCryptoDiagEntries();
assert.equal(e2[e2.length - 1].ke, 'KE-4');
assert.equal(e2[e2.length - 1].signature, 'update_delayed_event');
});
test('serializes an unserializable (circular) arg via the String() fallback without throwing', () => {
const circular: Record<string, unknown> = {};
circular.self = circular; // JSON.stringify throws → stringifyArg falls back to String()
// Pair with a matching string so the line is captured; the object exercises
// the catch branch. This must not throw.
console.error('DecryptionError from', circular);
const e = getCryptoDiagEntries();
assert.equal(e[e.length - 1].ke, 'KE-3');
assert.match(e[e.length - 1].message, /\[object Object\]/);
});
test('installCryptoDiagLog is idempotent — a second call does not re-wrap console', () => {
const wrappedWarn = console.warn;
installCryptoDiagLog(); // guarded no-op
// Reference unchanged ⇒ not re-wrapped ⇒ a line is captured once, not doubled.
assert.equal(console.warn, wrappedWarn, 'console.warn is not re-wrapped');
console.error('DecryptionError single-capture check');
const e = getCryptoDiagEntries();
assert.match(e[e.length - 1].message, /single-capture check/, 'still captured');
});
test('buildCryptoDiagReport captures client metadata in a fixed, PII-safe shape', () => {
const mx = mockClient({
getDeviceId: () => 'DEVICE123',
getUserId: () => '@alice:example.org',
getSyncState: () => 'SYNCING',
getCrypto: () => ({}),
getSdkVersion: () => '99.9.9',
});
const report = JSON.parse(buildCryptoDiagReport(mx));
assert.equal(report.kind, 'lotus-crypto-diag');
assert.equal(report.deviceId, 'DEVICE123');
assert.equal(report.userId, '@alice:example.org');
assert.equal(report.syncState, 'SYNCING');
assert.equal(report.cryptoReady, true);
assert.equal(report.sdkVersion, '99.9.9');
assert.equal(report.maxEntries, 200);
assert.equal(report.entryCount, getCryptoDiagEntries().length);
const sum = Object.values<number>(report.countsByKe).reduce((a, b) => a + b, 0);
assert.equal(sum, report.entryCount, 'countsByKe sums to entryCount');
// Locked field set: nothing beyond these documented keys ever leaks.
assert.deepEqual(Object.keys(report).sort(), [
'countsByKe',
'cryptoReady',
'deviceId',
'entries',
'entryCount',
'generatedAt',
'kind',
'maxEntries',
'sdkVersion',
'syncState',
'userId',
]);
});
test('buildCryptoDiagReport degrades gracefully with no client', () => {
const report = JSON.parse(buildCryptoDiagReport());
assert.equal(report.deviceId, null);
assert.equal(report.userId, null);
assert.equal(report.syncState, null);
assert.equal(report.cryptoReady, false);
// sdkVersion falls back to the declared package.json pin — a version string.
assert.equal(typeof report.sdkVersion, 'string');
assert.ok(report.sdkVersion.length > 0);
});
test('sdkVersion falls back to the declared pin when the client getter throws', () => {
const mx = mockClient({
getDeviceId: () => null,
getUserId: () => null,
getSyncState: () => null,
getCrypto: () => undefined,
getSdkVersion: () => {
throw new Error('not ready');
},
});
const report = JSON.parse(buildCryptoDiagReport(mx));
assert.equal(typeof report.sdkVersion, 'string');
assert.ok(report.sdkVersion.length > 0);
});
+158
View File
@@ -0,0 +1,158 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
canFitInScrollView,
getThumbnailDimensions,
isInScrollView,
isIntersectingScrollView,
syntaxErrorPosition,
tryDecodeURIComponent,
} from './dom';
// The scroll-view helpers only read numeric layout properties off their
// elements, so a plain duck-typed object stands in for an HTMLElement.
type ElLike = {
offsetTop?: number;
scrollTop?: number;
offsetHeight?: number;
clientHeight?: number;
};
const el = (props: ElLike): HTMLElement => props as unknown as HTMLElement;
describe('getThumbnailDimensions', () => {
it('leaves dimensions within the 400x300 cap untouched', () => {
assert.deepEqual(getThumbnailDimensions(200, 150), [200, 150]);
assert.deepEqual(getThumbnailDimensions(400, 300), [400, 300]);
assert.deepEqual(getThumbnailDimensions(100, 100), [100, 100]);
});
it('scales down by height when taller than 300', () => {
// 200x600 -> width * (300/600) = 100, height clamped to 300
assert.deepEqual(getThumbnailDimensions(200, 600), [100, 300]);
});
it('scales down by width when wider than 400', () => {
// 800x200 -> height * (400/800) = 100, width clamped to 400
assert.deepEqual(getThumbnailDimensions(800, 200), [400, 100]);
});
it('applies the height clamp first, then the width clamp', () => {
// 800x600 -> height clamp: 400x300 (width already at cap, no further change)
assert.deepEqual(getThumbnailDimensions(800, 600), [400, 300]);
// 1200x600 -> height clamp: 600x300 -> width clamp: 400x200
assert.deepEqual(getThumbnailDimensions(1200, 600), [400, 200]);
});
it('floors fractional results', () => {
// 300x700 -> width * (300/700) = 128.57 -> floored to 128
assert.deepEqual(getThumbnailDimensions(300, 700), [128, 300]);
});
it('scales on a just-over-boundary input (strict > comparisons)', () => {
// one over the height cap -> scales; one over the width cap -> scales
assert.deepEqual(getThumbnailDimensions(400, 301), [398, 300]);
assert.deepEqual(getThumbnailDimensions(401, 300), [400, 299]);
});
});
describe('tryDecodeURIComponent', () => {
it('decodes a valid encoded component', () => {
assert.equal(tryDecodeURIComponent('a%20b'), 'a b');
assert.equal(tryDecodeURIComponent('%C3%A9'), 'é');
});
it('returns the input unchanged when it has no escapes', () => {
assert.equal(tryDecodeURIComponent('hello'), 'hello');
});
it('returns the raw input on a malformed sequence instead of throwing', () => {
assert.equal(tryDecodeURIComponent('%'), '%');
assert.equal(tryDecodeURIComponent('%E0%A4%A'), '%E0%A4%A');
});
});
describe('syntaxErrorPosition', () => {
it('extracts the position when the number ends the message (real V8/Node shape)', () => {
// Real JSON.parse errors read "... at position N" with N at end-of-string.
assert.equal(
syntaxErrorPosition(new SyntaxError('Unexpected end of JSON input at position 10')),
10,
);
});
it('extracts the position when it is followed by more text', () => {
// Newer V8 appends "(line N column M)" after the number.
assert.equal(
syntaxErrorPosition(new SyntaxError('bad token in JSON at position 6 (line 1 column 7)')),
6,
);
assert.equal(syntaxErrorPosition(new SyntaxError('bad at position 42 more')), 42);
});
it('returns undefined when the message has no position', () => {
assert.equal(syntaxErrorPosition(new SyntaxError('Unexpected end of input')), undefined);
});
});
describe('isIntersectingScrollView', () => {
// Viewport spans 0..100 (offsetTop 0 + scrollTop 0, height 100).
const view = el({ offsetTop: 0, scrollTop: 0, offsetHeight: 100 });
it('is true for a child fully inside the view', () => {
assert.equal(isIntersectingScrollView(view, el({ offsetTop: 20, clientHeight: 30 })), true);
});
it('is true for a child straddling the top edge', () => {
// -10..20 -> bottom (20) is within 0..100
assert.equal(isIntersectingScrollView(view, el({ offsetTop: -10, clientHeight: 30 })), true);
});
it('is true for a child taller than and spanning the whole view', () => {
// -20..180 -> top above, bottom below
assert.equal(isIntersectingScrollView(view, el({ offsetTop: -20, clientHeight: 200 })), true);
});
it('is false for a child entirely above or below the view', () => {
assert.equal(isIntersectingScrollView(view, el({ offsetTop: -50, clientHeight: 20 })), false);
assert.equal(isIntersectingScrollView(view, el({ offsetTop: 200, clientHeight: 20 })), false);
});
it('respects the strict pixel boundaries (> vs >=)', () => {
// child bottom sits exactly on scrollTop (0) -> not intersecting (childBottom > scrollTop is strict)
assert.equal(isIntersectingScrollView(view, el({ offsetTop: -10, clientHeight: 10 })), false);
// child top sits exactly on scrollBottom (100) -> not intersecting (childTop < scrollBottom is strict)
assert.equal(isIntersectingScrollView(view, el({ offsetTop: 100, clientHeight: 20 })), false);
});
it('accounts for the view scrollTop offset', () => {
// View 0..100 in layout, scrolled by 100 -> logical window 100..200.
const scrolled = el({ offsetTop: 0, scrollTop: 100, offsetHeight: 100 });
assert.equal(
isIntersectingScrollView(scrolled, el({ offsetTop: 120, clientHeight: 10 })),
true,
);
assert.equal(
isIntersectingScrollView(scrolled, el({ offsetTop: 20, clientHeight: 10 })),
false,
);
});
});
describe('isInScrollView', () => {
const view = el({ offsetTop: 0, scrollTop: 0, offsetHeight: 100 });
it('is true only when the child is fully within the view', () => {
assert.equal(isInScrollView(view, el({ offsetTop: 10, offsetHeight: 50 })), true);
// straddles the bottom edge -> not fully in
assert.equal(isInScrollView(view, el({ offsetTop: 80, offsetHeight: 50 })), false);
});
});
describe('canFitInScrollView', () => {
it('is true when the child is shorter than the view', () => {
const view = el({ offsetHeight: 100 });
assert.equal(canFitInScrollView(view, el({ offsetHeight: 60 })), true);
assert.equal(canFitInScrollView(view, el({ offsetHeight: 100 })), false);
assert.equal(canFitInScrollView(view, el({ offsetHeight: 140 })), false);
});
});
+5 -1
View File
@@ -230,7 +230,11 @@ export const tryDecodeURIComponent = (encodedURIComponent: string): string => {
};
export const syntaxErrorPosition = (error: SyntaxError): number | undefined => {
const match = error.message.match(/position\s(\d+)\s/);
// The number may sit at the very end of the message — real V8/Node JSON
// errors read "... at position 7" with no trailing character — so do NOT
// require whitespace after the digits (that made this return undefined for
// every real error, silently pointing the editors' cursor at position 0).
const match = error.message.match(/position\s(\d+)/);
if (!match) return undefined;
const posStr = match[1];
+14
View File
@@ -57,6 +57,10 @@ test('Vimeo (incl. unlisted hash + channel/group/album forms)', () => {
assert.equal(getVimeoParts('https://vimeo.com/channels/staffpicks/76979871')?.id, '76979871');
assert.equal(getVimeoParts('https://vimeo.com/groups/motion/videos/12345')?.id, '12345');
assert.equal(getVimeoParts('https://vimeo.com/album/99/video/54321')?.id, '54321');
// a normal video with a trailing sub-path segment must NOT capture it as a hash
assert.equal(getVimeoParts('https://vimeo.com/123456789/likes')?.hash, undefined);
assert.equal(getVimeoParts('https://vimeo.com/123456789/settings')?.hash, undefined);
assert.equal(getVimeoParts('https://vimeo.com/123456789/likes')?.id, '123456789');
});
test('extractEmbedHeight: Instagram / Reddit / Twitter shapes', () => {
@@ -113,6 +117,7 @@ test('Dailymotion + Streamable', () => {
assert.equal(getDailymotionId('https://dai.ly/x8abcde'), 'x8abcde');
assert.equal(getStreamableId('https://streamable.com/abc12'), 'abc12');
assert.equal(getStreamableId('https://streamable.com/e/abc12'), null); // already an embed path
assert.equal(getStreamableId('https://streamable.com/login'), null); // reserved page
});
test('Twitch: channel / video / clip', () => {
@@ -132,6 +137,10 @@ test('Twitch: channel / video / clip', () => {
type: 'clip',
value: 'CoolSlug',
});
// reserved utility pages are not channels
assert.equal(getTwitchTarget('https://twitch.tv/directory'), null);
assert.equal(getTwitchTarget('https://twitch.tv/settings'), null);
assert.equal(getTwitchTarget('https://twitch.tv/videos'), null); // bare /videos, not a channel
});
test('Spotify target + height', () => {
@@ -153,6 +162,9 @@ test('SoundCloud track detection', () => {
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile
// on.soundcloud.com short links intentionally not handled (need oEmbed resolve)
assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), false);
assert.equal(isSoundCloudTrack('https://soundcloud.com/discover/xyz'), false); // site section
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/sets'), false); // profile-tab listing
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/sets/my-set'), true); // a real set
});
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
@@ -287,6 +299,8 @@ test('Bluesky / Loom / Kick', () => {
assert.equal(getKickChannel('https://kick.com/somestreamer'), 'somestreamer');
assert.equal(getKickChannel('https://kick.com/streamer/videos/123'), null); // VOD → no embed
assert.equal(getKickChannel('https://kick.com/browse'), null); // nav page, not a channel
assert.equal(getKickChannel('https://kick.com/following'), null);
assert.ok(
parseMediaEmbed('https://kick.com/streamer', 'h')?.embedUrl.includes(
'player.kick.com/streamer?autoplay=true',
+106 -6
View File
@@ -66,8 +66,10 @@ export function getVimeoParts(url: string): { id: string; hash?: string } | null
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
// Canonical /{id} or unlisted /{id}/{hash}
let m = pathname.match(/^\/(\d+)(?:\/([0-9a-zA-Z]+))?/);
// Canonical /{id} or unlisted /{id}/{hash}. The hash is a lowercase-hex token
// (constrain it so a normal video's trailing segment — /likes, /settings, a
// review slug — isn't captured as a bogus `h=` param that Vimeo then rejects).
let m = pathname.match(/^\/(\d+)(?:\/([0-9a-f]{6,}))?/);
if (m) return { id: m[1], hash: m[2] };
// channels/groups/album share a trailing numeric video id
m = pathname.match(/\/(?:channels\/[^/]+|groups\/[^/]+\/videos|album\/[^/]+\/video)\/(\d+)/);
@@ -192,12 +194,26 @@ export function getDailymotionId(url: string): string | null {
// --- Streamable -----------------------------------------------------------
// Streamable's own utility/first-path pages that are not video ids.
const STREAMABLE_RESERVED = new Set([
'e',
'login',
'signup',
'settings',
'account',
'dashboard',
'help',
'terms',
'privacy',
'about',
]);
export function getStreamableId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname.replace(/^www\./, '') !== 'streamable.com') return null;
const m = pathname.match(/^\/([A-Za-z0-9]+)/);
return m && m[1] !== 'e' ? m[1] : null;
return m && !STREAMABLE_RESERVED.has(m[1].toLowerCase()) ? m[1] : null;
} catch {
return null;
}
@@ -210,6 +226,32 @@ export type TwitchTarget =
| { type: 'video'; value: string }
| { type: 'clip'; value: string };
// Twitch's own reserved first-path segments — single-segment paths that are
// utility pages, not channels, and must NOT be embedded as `channel=<x>`.
const TWITCH_RESERVED = new Set([
'directory',
'videos',
'settings',
'subscriptions',
'following',
'followers',
'friends',
'inventory',
'wallet',
'drops',
'prime',
'turbo',
'downloads',
'jobs',
'store',
'search',
'dashboard',
'popout',
'p',
'u',
'team',
]);
export function getTwitchTarget(url: string): TwitchTarget | null {
try {
const { hostname, pathname } = new URL(url);
@@ -219,7 +261,9 @@ export function getTwitchTarget(url: string): TwitchTarget | null {
if (h === 'twitch.tv' || h === 'm.twitch.tv') {
if (parts[0] === 'videos' && parts[1]) return { type: 'video', value: parts[1] };
if (parts[1] === 'clip' && parts[2]) return { type: 'clip', value: parts[2] };
if (parts.length === 1 && parts[0]) return { type: 'channel', value: parts[0] };
if (parts.length === 1 && parts[0] && !TWITCH_RESERVED.has(parts[0].toLowerCase())) {
return { type: 'channel', value: parts[0] };
}
}
} catch {
/* ignore */
@@ -248,6 +292,38 @@ export function getSpotifyEmbedTarget(url: string): { type: SpotifyType; id: str
// --- SoundCloud -----------------------------------------------------------
// SoundCloud's own site sections (first segment) that are never `<artist>`.
const SOUNDCLOUD_RESERVED = new Set([
'discover',
'you',
'stream',
'search',
'upload',
'settings',
'notifications',
'messages',
'tags',
'charts',
'people',
'pages',
'terms',
'pro',
]);
// Profile tabs — `/<artist>/<tab>` is a listing, not a single track (a real set
// is the deeper `/<artist>/sets/<slug>`, which has length >= 3 and is allowed).
const SOUNDCLOUD_PROFILE_TABS = new Set([
'tracks',
'sets',
'albums',
'reposts',
'likes',
'following',
'followers',
'comments',
'popular-tracks',
'toptracks',
]);
export function isSoundCloudTrack(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
@@ -260,7 +336,11 @@ export function isSoundCloudTrack(url: string): boolean {
.replace(/^\/+|\/+$/g, '')
.split('/')
.filter(Boolean);
return parts.length >= 2;
if (parts.length < 2) return false;
if (SOUNDCLOUD_RESERVED.has(parts[0].toLowerCase())) return false;
// `/<artist>/<tab>` profile-tab listing (not a playable single track/set).
if (parts.length === 2 && SOUNDCLOUD_PROFILE_TABS.has(parts[1].toLowerCase())) return false;
return true;
} catch {
return false;
}
@@ -391,6 +471,22 @@ export function getLoomId(url: string): string | null {
// --- Kick (live channels only; VODs/clips have no clean iframe) ------------
// Kick's own reserved first-path segments (nav pages, not channels).
const KICK_RESERVED = new Set([
'browse',
'following',
'category',
'categories',
'search',
'messages',
'subscriptions',
'settings',
'wallet',
'help',
'clips',
'dashboard',
]);
export function getKickChannel(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
@@ -399,7 +495,11 @@ export function getKickChannel(url: string): string | null {
.replace(/^\/+|\/+$/g, '')
.split('/')
.filter(Boolean);
return parts.length === 1 && /^[A-Za-z0-9_]+$/.test(parts[0]) ? parts[0] : null;
return parts.length === 1 &&
/^[A-Za-z0-9_]+$/.test(parts[0]) &&
!KICK_RESERVED.has(parts[0].toLowerCase())
? parts[0]
: null;
} catch {
return null;
}