Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e02cef658 | ||
|
|
bc608b377a | ||
|
|
f2673effe4 | ||
|
|
f03c0ef960 | ||
|
|
8cc8dfd796 | ||
|
|
386a297997 | ||
|
|
36369926ca |
+24
-6
@@ -6,6 +6,18 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [lotus]
|
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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build & Quality Checks
|
name: Build & Quality Checks
|
||||||
@@ -53,26 +65,32 @@ jobs:
|
|||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: npm test
|
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
|
- name: TypeScript
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: ESLint
|
- name: ESLint
|
||||||
run: npm run check:eslint
|
run: npm run check:eslint
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Prettier
|
- name: Prettier
|
||||||
run: npm run check:prettier
|
run: npm run check:prettier
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
# ── Security ─────────────────────────────────────────────────────────
|
# ── Security (informational — findings shouldn't block a deploy) ─────
|
||||||
- name: Audit (high/critical)
|
- name: Audit (high/critical)
|
||||||
run: npm audit --audit-level=high --omit=dev
|
run: npm audit --audit-level=high --omit=dev
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
# ── Bundle size report ───────────────────────────────────────────────
|
# ── Bundle size report (informational — never blocks a deploy) ───────
|
||||||
- name: Report bundle sizes
|
- name: Report bundle sizes
|
||||||
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
echo "### Bundle sizes" >> $GITHUB_STEP_SUMMARY
|
echo "### Bundle sizes" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|||||||
+24
-3
@@ -212,7 +212,7 @@ Intentional desktop deltas (disclosed, non-regressive): volume sliders below lab
|
|||||||
**NOT done — needs a real device / product decisions (open):**
|
**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.
|
- [ ] **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 (320–500px), 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).
|
- [ ] **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).
|
- [~] **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.
|
- [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.
|
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 / 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`.
|
- **[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
|
edit → commit → git push origin lotus
|
||||||
→ Gitea Actions: tsc --noEmit, eslint, prettier (~3 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 CI → npm ci && npm run build → rsync → live (~11 min)
|
→ 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`.
|
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).
|
||||||
|
|||||||
@@ -88,8 +88,9 @@ export function RenderMessageContent({
|
|||||||
}: RenderMessageContentProps) {
|
}: RenderMessageContentProps) {
|
||||||
const renderUrlsPreview = (urls: string[]) => {
|
const renderUrlsPreview = (urls: string[]) => {
|
||||||
// Cap previews per message so a link-dump doesn't spawn dozens of preview
|
// Cap previews per message so a link-dump doesn't spawn dozens of preview
|
||||||
// fetches + iframes at once.
|
// fetches + iframes at once. De-dupe first: a message linking the same URL
|
||||||
const filteredUrls = urls.filter((url) => !testMatrixTo(url)).slice(0, 6);
|
// 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;
|
if (filteredUrls.length === 0) return undefined;
|
||||||
return (
|
return (
|
||||||
<UrlPreviewHolder>
|
<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 {
|
function isTwitter(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const { hostname } = new URL(url);
|
const { hostname } = new URL(url);
|
||||||
const h = hostname.replace(/^www\./, '');
|
return TWITTER_HOSTS.has(hostname.replace(/^www\./, ''));
|
||||||
return h === 'twitter.com' || h === 'x.com';
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -129,9 +133,8 @@ function isTwitter(url: string): boolean {
|
|||||||
function isTwitterTweet(url: string): boolean {
|
function isTwitterTweet(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
const h = hostname.replace(/^www\./, '');
|
if (!TWITTER_HOSTS.has(hostname.replace(/^www\./, ''))) return false;
|
||||||
if (h !== 'twitter.com' && h !== 'x.com') return false;
|
return /\/status(?:es)?\/\d+/.test(pathname);
|
||||||
return /\/status\/\d+/.test(pathname);
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1499,9 +1502,17 @@ function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SpotifyCard({ 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 title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
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 spotifyType = getSpotifyType(url) ?? 'track';
|
||||||
const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1);
|
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 }) {
|
function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1671,9 +1687,14 @@ function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }
|
|||||||
|
|
||||||
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1834,9 +1855,14 @@ function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlRespon
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
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 (
|
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
|
// Interactive embeds (players, tweets) render in a wider, responsive card so
|
||||||
// player chrome / tweet content isn't cramped or clipped.
|
// player chrome / tweet content isn't cramped or clipped.
|
||||||
const embed = parseMediaEmbed(url, window.location.hostname);
|
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
|
// 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
|
// in the card flex row; stack them on phones (no-op for the single-column
|
||||||
// embed cards). Desktop keeps the row layout.
|
// embed cards). Desktop keeps the row layout.
|
||||||
const stackOnMobile = isTwitter(url) || isTwitch(url) || isTikTok(url);
|
const stackOnMobile = isTwitter(url) || isTwitch(url) || isTikTok(url);
|
||||||
const cardClass =
|
const buildCardClass = (wide: boolean): string | undefined =>
|
||||||
[wide && previewCss.UrlPreviewWide, stackOnMobile && previewCss.StackOnMobile]
|
[wide && previewCss.UrlPreviewWide, stackOnMobile && previewCss.StackOnMobile]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ') || undefined;
|
.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/
|
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
|
||||||
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile.
|
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play
|
||||||
// Short "copy-link" share URLs (e.g. vm.tiktok.com, tiktok.com/t/…, youtu.be
|
// tile. `resolvedEmbed` (computed by the caller via resolveEmbed) already
|
||||||
// redirects) don't carry the id, so fall back to the canonical og:url that
|
// folds in the og:url fallback for short "copy-link" share URLs.
|
||||||
// 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);
|
|
||||||
if (resolvedEmbed) {
|
if (resolvedEmbed) {
|
||||||
return <MediaEmbedCard url={url} prev={prev} embed={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)
|
// Don't render the card wrapper when content is empty (loaded but nothing to show)
|
||||||
if (previewStatus.status === AsyncStatus.Success) {
|
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;
|
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 (
|
return (
|
||||||
<UrlPreview {...props} ref={ref} className={cardClass}>
|
<UrlPreview {...props} ref={ref} className={buildCardClass(wide)}>
|
||||||
{content}
|
{content}
|
||||||
</UrlPreview>
|
</UrlPreview>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loading/idle: no preview data yet, so base `wide` on the url-only embed.
|
||||||
return (
|
return (
|
||||||
<UrlPreview {...props} ref={ref} className={cardClass}>
|
<UrlPreview {...props} ref={ref} className={buildCardClass(!!embed || isTwitterTweet(url))}>
|
||||||
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
||||||
<Spinner variant="Secondary" size="400" />
|
<Spinner variant="Secondary" size="400" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -230,7 +230,11 @@ export const tryDecodeURIComponent = (encodedURIComponent: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const syntaxErrorPosition = (error: SyntaxError): number | undefined => {
|
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;
|
if (!match) return undefined;
|
||||||
|
|
||||||
const posStr = match[1];
|
const posStr = match[1];
|
||||||
|
|||||||
@@ -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/channels/staffpicks/76979871')?.id, '76979871');
|
||||||
assert.equal(getVimeoParts('https://vimeo.com/groups/motion/videos/12345')?.id, '12345');
|
assert.equal(getVimeoParts('https://vimeo.com/groups/motion/videos/12345')?.id, '12345');
|
||||||
assert.equal(getVimeoParts('https://vimeo.com/album/99/video/54321')?.id, '54321');
|
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', () => {
|
test('extractEmbedHeight: Instagram / Reddit / Twitter shapes', () => {
|
||||||
@@ -113,6 +117,7 @@ test('Dailymotion + Streamable', () => {
|
|||||||
assert.equal(getDailymotionId('https://dai.ly/x8abcde'), 'x8abcde');
|
assert.equal(getDailymotionId('https://dai.ly/x8abcde'), 'x8abcde');
|
||||||
assert.equal(getStreamableId('https://streamable.com/abc12'), 'abc12');
|
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/e/abc12'), null); // already an embed path
|
||||||
|
assert.equal(getStreamableId('https://streamable.com/login'), null); // reserved page
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Twitch: channel / video / clip', () => {
|
test('Twitch: channel / video / clip', () => {
|
||||||
@@ -132,6 +137,10 @@ test('Twitch: channel / video / clip', () => {
|
|||||||
type: 'clip',
|
type: 'clip',
|
||||||
value: 'CoolSlug',
|
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', () => {
|
test('Spotify target + height', () => {
|
||||||
@@ -153,6 +162,9 @@ test('SoundCloud track detection', () => {
|
|||||||
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile
|
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile
|
||||||
// on.soundcloud.com short links intentionally not handled (need oEmbed resolve)
|
// on.soundcloud.com short links intentionally not handled (need oEmbed resolve)
|
||||||
assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), false);
|
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', () => {
|
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/somestreamer'), 'somestreamer');
|
||||||
assert.equal(getKickChannel('https://kick.com/streamer/videos/123'), null); // VOD → no embed
|
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(
|
assert.ok(
|
||||||
parseMediaEmbed('https://kick.com/streamer', 'h')?.embedUrl.includes(
|
parseMediaEmbed('https://kick.com/streamer', 'h')?.embedUrl.includes(
|
||||||
'player.kick.com/streamer?autoplay=true',
|
'player.kick.com/streamer?autoplay=true',
|
||||||
|
|||||||
+106
-6
@@ -66,8 +66,10 @@ export function getVimeoParts(url: string): { id: string; hash?: string } | null
|
|||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
||||||
// Canonical /{id} or unlisted /{id}/{hash}
|
// Canonical /{id} or unlisted /{id}/{hash}. The hash is a lowercase-hex token
|
||||||
let m = pathname.match(/^\/(\d+)(?:\/([0-9a-zA-Z]+))?/);
|
// (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] };
|
if (m) return { id: m[1], hash: m[2] };
|
||||||
// channels/groups/album share a trailing numeric video id
|
// channels/groups/album share a trailing numeric video id
|
||||||
m = pathname.match(/\/(?:channels\/[^/]+|groups\/[^/]+\/videos|album\/[^/]+\/video)\/(\d+)/);
|
m = pathname.match(/\/(?:channels\/[^/]+|groups\/[^/]+\/videos|album\/[^/]+\/video)\/(\d+)/);
|
||||||
@@ -192,12 +194,26 @@ export function getDailymotionId(url: string): string | null {
|
|||||||
|
|
||||||
// --- Streamable -----------------------------------------------------------
|
// --- 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 {
|
export function getStreamableId(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
if (hostname.replace(/^www\./, '') !== 'streamable.com') return null;
|
if (hostname.replace(/^www\./, '') !== 'streamable.com') return null;
|
||||||
const m = pathname.match(/^\/([A-Za-z0-9]+)/);
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -210,6 +226,32 @@ export type TwitchTarget =
|
|||||||
| { type: 'video'; value: string }
|
| { type: 'video'; value: string }
|
||||||
| { type: 'clip'; 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 {
|
export function getTwitchTarget(url: string): TwitchTarget | null {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
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 (h === 'twitch.tv' || h === 'm.twitch.tv') {
|
||||||
if (parts[0] === 'videos' && parts[1]) return { type: 'video', value: parts[1] };
|
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[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 {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -248,6 +292,38 @@ export function getSpotifyEmbedTarget(url: string): { type: SpotifyType; id: str
|
|||||||
|
|
||||||
// --- SoundCloud -----------------------------------------------------------
|
// --- 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 {
|
export function isSoundCloudTrack(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
@@ -260,7 +336,11 @@ export function isSoundCloudTrack(url: string): boolean {
|
|||||||
.replace(/^\/+|\/+$/g, '')
|
.replace(/^\/+|\/+$/g, '')
|
||||||
.split('/')
|
.split('/')
|
||||||
.filter(Boolean);
|
.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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -391,6 +471,22 @@ export function getLoomId(url: string): string | null {
|
|||||||
|
|
||||||
// --- Kick (live channels only; VODs/clips have no clean iframe) ------------
|
// --- 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 {
|
export function getKickChannel(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
@@ -399,7 +495,11 @@ export function getKickChannel(url: string): string | null {
|
|||||||
.replace(/^\/+|\/+$/g, '')
|
.replace(/^\/+|\/+$/g, '')
|
||||||
.split('/')
|
.split('/')
|
||||||
.filter(Boolean);
|
.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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user