Compare commits
7
Commits
015495c77d
...
8e02cef658
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e02cef658 | ||
|
|
bc608b377a | ||
|
|
f2673effe4 | ||
|
|
f03c0ef960 | ||
|
|
8cc8dfd796 | ||
|
|
386a297997 | ||
|
|
36369926ca |
+24
-6
@@ -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
@@ -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 (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).
|
||||
- [~] **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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => {
|
||||
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];
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user