+ {timestampParts(comment.text, duration).map((part, n) => + part.time === undefined ? ( + part.text + ) : ( + + ), + )} +
+ {formatDate(comment.created_at)} +Signed into Recordly
You can close this tab and return to the app.
diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..45d02d2a4 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Public client configuration. Never put a Supabase service-role key here. +VITE_SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co +VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_YOUR_KEY diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 45e5f6512..bb3499870 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -52,8 +52,14 @@ jobs: continue-on-error: true run: npm run format:check - - name: Test + # Real recording-import tests require the binary skipped by --ignore-scripts. + - name: Install FFmpeg for integration tests + id: ffmpeg if: ${{ !cancelled() && steps.install.outcome == 'success' }} + run: npm rebuild ffmpeg-static + + - name: Test + if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.ffmpeg.outcome == 'success' }} run: npm test # Main currently has known locale-parity debt covered by PR #710. Keep the diff --git a/.gitignore b/.gitignore index 3784c0cf8..de89bad27 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ electron/native/bin/*/whisper-runtime.json tmp-*.ps1 .tmp-*.ps1 gpu-export-probe.mp4 + +# Browser test output +test-results/ +playwright-report/ diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..8a9f73a4e --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,38 @@ +# Third-party notices + +## Voom + +Recordly Share includes code adapted from the Voom project: + +- Project: [Voom](https://github.com/aritropaul/voom) +- Adapted component: `services/recordly-share/worker` +- License: MIT +- Copyright © 2026 Aritro Paul +- Upstream reference: `c1c0350ed610b0c9bc4a307fcc6a01aab0f926f9` + +Recordly changes include product branding, desktop publishing integration, +authenticated commenting, local-development configuration, and viewer styling. + +The Voom software is provided under the following license: + +> MIT License +> +> Copyright (c) 2026 Aritro Paul +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. diff --git a/components.json b/components.json deleted file mode 100644 index f6dc1d599..000000000 --- a/components.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "tailwind.config.cjs", - "css": "src/index.css", - "baseColor": "stone", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "registries": {} -} diff --git a/design-app-catalog.html b/design-app-catalog.html new file mode 100644 index 000000000..bd1376091 --- /dev/null +++ b/design-app-catalog.html @@ -0,0 +1,7 @@ +
You can close this tab and return to the app.
This recording is password protected.
+`; + return new Response(lockedHtml, { + headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=3600' }, + }); + } + + const desc = video.summary ? escapeHTML(video.summary) : `${formatTimestamp(video.duration)} screen recording`; + + const html = ` + + + +${escapeHTML(video.title)}
+ +`; + + return new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=3600' }, + }); +} + +// --- Cron Cleanup --- + +async function cleanupExpired(env) { + const expired = await env.DB.prepare( + "SELECT id, share_code FROM videos WHERE datetime(expires_at) < datetime('now')" + ).all(); + + for (const video of expired.results || []) { + await Promise.all([ + env.VIDEOS_BUCKET.delete(`videos/${video.share_code}.mp4`), + env.VIDEOS_BUCKET.delete(`thumbnails/${video.share_code}.jpg`), + ]); + await env.DB.batch(deleteVideoStatements(env, video.id)); + } + + // Drop stale password rate-limit rows so the table can't grow unboundedly. + await env.DB.prepare( + "DELETE FROM password_attempts WHERE datetime(attempted_at) < datetime('now', '-1 day')" + ).run(); + await env.DB.prepare( + "DELETE FROM comment_sessions WHERE datetime(expires_at) < datetime('now')" + ).run(); +} + +// --- Password Verification --- + +async function handleVerifyPassword(request, env, shareCode) { + const video = await env.DB.prepare( + "SELECT * FROM videos WHERE share_code = ? AND upload_completed = 1 AND datetime(expires_at) > datetime('now')" + ).bind(shareCode).first(); + + if (!video || !video.password_hash) return errorResponse('Not found', 404); + + // Brute-force protection: 10 attempts per IP per video per 5 minutes. + const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown'; + const recent = await env.DB.prepare( + "SELECT COUNT(*) as cnt FROM password_attempts WHERE video_id = ? AND client_ip = ? AND datetime(attempted_at) > datetime('now', '-5 minutes')" + ).bind(video.id, clientIP).first(); + if (recent && recent.cnt >= 10) return errorResponse('Too many attempts — try again later', 429); + + const body = await request.json(); + const password = body.password || ''; + + // The browser sends the raw password (HTTPS); the app stored it as a salted + // hash of SHA256(password). Legacy rows (pre-salt) hold bare SHA256(password). + const clientHash = await sha256Hex(password); + let matches; + if (video.password_salt) { + matches = timingSafeEqual(await sha256Hex(video.password_salt + clientHash), video.password_hash); + } else { + matches = timingSafeEqual(clientHash, video.password_hash); + // Lazy upgrade: re-store the legacy unsalted hash as salted on success. + if (matches) { + const salt = generateSalt(); + const upgraded = await sha256Hex(salt + clientHash); + await env.DB.prepare('UPDATE videos SET password_hash = ?, password_salt = ? WHERE id = ?') + .bind(upgraded, salt, video.id).run(); + } + } + + if (!matches) { + await env.DB.prepare( + 'INSERT INTO password_attempts (video_id, client_ip) VALUES (?, ?)' + ).bind(video.id, clientIP).run(); + return jsonResponse({ error: 'Incorrect password' }, 403); + } + + // Issue an HMAC session token (never the stored hash). + const authToken = await generateAuthToken(shareCode, video.expires_at, env.API_SECRET); + const expires = new Date(video.expires_at + 'Z'); + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': `voom_auth_${shareCode}=${authToken}; Path=/; Expires=${expires.toUTCString()}; HttpOnly; SameSite=Lax; Secure`, + }, + }); +} + +// --- Reactions --- + +async function handleReact(request, env, shareCode) { + const video = await env.DB.prepare( + "SELECT id, password_hash, expires_at FROM videos WHERE share_code = ? AND upload_completed = 1 AND datetime(expires_at) > datetime('now')" + ).bind(shareCode).first(); + + if (!video) return errorResponse('Not found', 404); + + // Password check + if (video.password_hash) { + const authed = await verifyPasswordAuth(request, env, shareCode, video); + if (!authed) return errorResponse('Unauthorized', 401); + } + + const body = await request.json(); + const { timestamp, emoji } = body; + const allowedEmojis = ['👍', '❤️', '😂', '😮', '🔥', '👏']; + if (!allowedEmojis.includes(emoji)) return errorResponse('Invalid emoji'); + if (typeof timestamp !== 'number' || timestamp < 0) return errorResponse('Invalid timestamp'); + + // Rate limit: 50 reactions per IP per video + const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown'; + const count = await env.DB.prepare( + 'SELECT COUNT(*) as cnt FROM reactions WHERE video_id = ? AND client_ip = ?' + ).bind(video.id, clientIP).first(); + if (count && count.cnt >= 50) return errorResponse('Rate limit exceeded', 429); + + await env.DB.prepare( + 'INSERT INTO reactions (video_id, timestamp, emoji, client_ip) VALUES (?, ?, ?, ?)' + ).bind(video.id, timestamp, emoji, clientIP).run(); + + return jsonResponse({ ok: true }); +} + +async function handleGetReactions(request, env, shareCode) { + const video = await env.DB.prepare( + "SELECT id, password_hash, expires_at FROM videos WHERE share_code = ? AND upload_completed = 1 AND datetime(expires_at) > datetime('now')" + ).bind(shareCode).first(); + + if (!video) return errorResponse('Not found', 404); + + // Same gate as POST /react — listing must not bypass the password. + const authed = await verifyPasswordAuth(request, env, shareCode, video); + if (!authed) return errorResponse('Password required', 401); + + const reactions = await env.DB.prepare( + 'SELECT timestamp, emoji, created_at FROM reactions WHERE video_id = ? ORDER BY created_at DESC LIMIT 500' + ).bind(video.id).all(); + + return jsonResponse({ reactions: reactions.results || [] }); +} + +// --- Comments --- + +async function handleComment(request, env, shareCode) { + const video = await env.DB.prepare( + "SELECT id, password_hash, expires_at FROM videos WHERE share_code = ? AND upload_completed = 1 AND datetime(expires_at) > datetime('now')" + ).bind(shareCode).first(); + + if (!video) return errorResponse('Not found', 404); + + // Password check + if (video.password_hash) { + const authed = await verifyPasswordAuth(request, env, shareCode, video); + if (!authed) return errorResponse('Unauthorized', 401); + } + + const body = await request.json(); + const { timestamp, text, author_name: authorName } = body; + if (typeof timestamp !== 'number' || timestamp < 0) return errorResponse('Invalid timestamp'); + if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) { + return errorResponse('Display name is required'); + } + if (authorName.length > 100) return errorResponse('Display name is too long'); + if (!text || text.trim().length === 0) return errorResponse('Text is required'); + if (text.length > 2000) return errorResponse('Text too long'); + + // Rate limit: 5 comments per IP per 5 minutes + const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown'; + const recent = await env.DB.prepare( + "SELECT COUNT(*) as cnt FROM comments WHERE video_id = ? AND client_ip = ? AND datetime(created_at) > datetime('now', '-5 minutes')" + ).bind(video.id, clientIP).first(); + if (recent && recent.cnt >= 5) return errorResponse('Rate limit exceeded', 429); + + await env.DB.prepare( + 'INSERT INTO comments (video_id, timestamp, author_name, text, client_ip) VALUES (?, ?, ?, ?, ?)' + ).bind(video.id, timestamp, authorName.trim(), text.trim().substring(0, 2000), clientIP).run(); + + return jsonResponse({ ok: true }); +} + +async function handleGetComments(request, env, shareCode) { + const video = await env.DB.prepare( + "SELECT id, password_hash, expires_at FROM videos WHERE share_code = ? AND upload_completed = 1 AND datetime(expires_at) > datetime('now')" + ).bind(shareCode).first(); + + if (!video) return errorResponse('Not found', 404); + + // Same gate as POST /comment — viewer comments can be sensitive. + const authed = await verifyPasswordAuth(request, env, shareCode, video); + if (!authed) return errorResponse('Password required', 401); + + const url = new URL(request.url); + const requestedPage = parseInt(url.searchParams.get('page') || '1', 10); + const requestedLimit = parseInt(url.searchParams.get('limit') || '50', 10); + const page = Number.isSafeInteger(requestedPage) ? Math.max(1, Math.min(requestedPage, Math.floor(Number.MAX_SAFE_INTEGER / 100))) : 1; + const limit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(requestedLimit, 100)) : 50; + const offset = (page - 1) * limit; + + const total = await env.DB.prepare( + 'SELECT COUNT(*) as cnt FROM comments WHERE video_id = ?' + ).bind(video.id).first(); + + const comments = await env.DB.prepare( + 'SELECT timestamp, author_name, text, created_at FROM comments WHERE video_id = ? ORDER BY timestamp ASC LIMIT ? OFFSET ?' + ).bind(video.id, limit, offset).all(); + + return jsonResponse({ + comments: comments.results || [], + total: total ? total.cnt : 0, + page, + limit, + }); +} + +// --- Check Views (authenticated) --- + +async function handleCheckViews(request, env) { + const body = await request.json(); + const { shareCodes } = body; + if (!Array.isArray(shareCodes) || shareCodes.length === 0) return errorResponse('shareCodes required'); + if (shareCodes.length > 90) return errorResponse('Too many shareCodes (max 90)'); + + const placeholders = shareCodes.map(() => '?').join(','); + const results = await env.DB.prepare( + `SELECT share_code, view_count FROM videos WHERE share_code IN (${placeholders})` + ).bind(...shareCodes).all(); + + const views = {}; + for (const row of results.results || []) { + views[row.share_code] = row.view_count || 0; + } + + return jsonResponse({ views }); +} + +// --- Library: list all shared videos (dashboard) --- + +async function handleListVideos(env) { + const rows = await env.DB.prepare( + `SELECT share_code, title, duration, width, height, file_size, created_at, expires_at, + view_count, is_meeting, summary, (password_hash IS NOT NULL) AS is_protected + FROM videos + WHERE upload_completed = 1 + ORDER BY datetime(created_at) DESC` + ).all(); + return jsonResponse({ videos: rows.results || [] }); +} + +// --- OG Image --- + +async function handleOGImage(env, shareCode) { + const video = await env.DB.prepare( + "SELECT * FROM videos WHERE share_code = ? AND upload_completed = 1 AND datetime(expires_at) > datetime('now')" + ) + .bind(shareCode) + .first(); + + if (!video) return new Response('Not found', { status: 404 }); + + // Serve uploaded thumbnail if available \u2014 but never for password-protected + // videos, whose poster frame may itself be sensitive. + if (!video.password_hash) { + const thumb = await env.VIDEOS_BUCKET.get(`thumbnails/${shareCode}.jpg`); + if (thumb) { + return new Response(thumb.body, { + status: 200, + headers: { + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'public, max-age=86400', + }, + }); + } + } + + // Fallback SVG + const locked = !!video.password_hash; + const duration = formatDuration(video.duration); + const date = formatDate(video.created_at); + const rawTitle = locked ? 'Protected video' : video.title; + const title = rawTitle.length > 60 ? rawTitle.substring(0, 57) + '...' : rawTitle; + const res = !locked && video.width > 0 ? `${finiteNonnegative(video.width)}\u00d7${finiteNonnegative(video.height)}` : ''; + + const svg = ``; + + return new Response(svg, { + status: 200, + headers: { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'public, max-age=86400', + 'X-Content-Type-Options': 'nosniff', + }, + }); +} + +// Exported for unit tests only — the worker runtime uses none of these exports. +export { generateShareCode, escapeHTML, parseCookies, timingSafeEqual, sha256Hex, generateSalt, formatVTTTime, expectedSessionToken, dashboardPassword }; diff --git a/services/recordly-share/worker/test/api.test.js b/services/recordly-share/worker/test/api.test.js new file mode 100644 index 000000000..aee4d6ad9 --- /dev/null +++ b/services/recordly-share/worker/test/api.test.js @@ -0,0 +1,506 @@ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { env, SELF } from 'cloudflare:test'; +import worker, { sha256Hex } from '../src/index.js'; + +const AUTH = { Authorization: 'Bearer test-secret' }; +const BASE = 'https://share.test'; + +async function createShare(extra = {}) { + const res = await SELF.fetch(`${BASE}/api/upload`, { + method: 'POST', + headers: { ...AUTH, 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Test Recording', duration: 12.5, width: 1920, height: 1080, fileSize: 1000, ...extra }), + }); + expect(res.status).toBe(200); + return res.json(); +} + +async function completeUpload(shareCode, bytes = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7])) { + const put = await SELF.fetch(`${BASE}/api/upload-data/${shareCode}`, { + method: 'PUT', + headers: { ...AUTH, 'Content-Type': 'video/mp4' }, + body: bytes, + }); + expect(put.status).toBe(200); + const meta = await SELF.fetch(`${BASE}/api/metadata/${shareCode}`, { + method: 'POST', + headers: { ...AUTH, 'Content-Type': 'application/json' }, + body: JSON.stringify({ segments: [{ startTime: 0, endTime: 1, text: 'hello world' }] }), + }); + expect(meta.status).toBe(200); +} + +describe('auth', () => { + it('rejects /api/* without a token', async () => { + const res = await SELF.fetch(`${BASE}/api/health`); + expect(res.status).toBe(401); + }); + + it('rejects a wrong token', async () => { + const res = await SELF.fetch(`${BASE}/api/health`, { headers: { Authorization: 'Bearer wrong' } }); + expect(res.status).toBe(401); + }); + + it('rejects a Bearer header with no token', async () => { + const res = await SELF.fetch(`${BASE}/api/health`, { headers: { Authorization: 'Bearer' } }); + expect(res.status).toBe(401); + }); + + it('accepts the correct token', async () => { + const res = await SELF.fetch(`${BASE}/api/health`, { headers: AUTH }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, app: 'recordly' }); + }); +}); + +describe('comment accounts', () => { + it('allows comments without signing in and uses the supplied display name', async () => { + const { shareCode } = await createShare(); + await completeUpload(shareCode); + + const anonymous = await SELF.fetch(`${BASE}/s/${shareCode}/comment`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + timestamp: 2, + author_name: 'Link Reviewer', + text: 'No account required', + }), + }); + expect(anonymous.status).toBe(200); + + const comments = await SELF.fetch(`${BASE}/s/${shareCode}/comments`); + const data = await comments.json(); + expect(data.comments).toEqual(expect.arrayContaining([ + expect.objectContaining({ + author_name: 'Link Reviewer', + text: 'No account required', + }), + ])); + }); + + it('supports signing back in and invalidates a logged-out session', async () => { + const register = await SELF.fetch(`${BASE}/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'CF-Connecting-IP': '203.0.113.80' }, + body: JSON.stringify({ + displayName: 'Signed-in Reviewer', + email: 'reviewer@example.com', + password: 'correct-horse-battery-staple', + }), + }); + expect(register.status).toBe(200); + + const login = await SELF.fetch(`${BASE}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'CF-Connecting-IP': '203.0.113.81' }, + body: JSON.stringify({ + email: 'reviewer@example.com', + password: 'correct-horse-battery-staple', + }), + }); + expect(login.status).toBe(200); + const cookie = login.headers.get('Set-Cookie').split(';')[0]; + + const logout = await SELF.fetch(`${BASE}/auth/logout`, { + method: 'POST', + headers: { Cookie: cookie }, + }); + expect(logout.status).toBe(200); + + const session = await SELF.fetch(`${BASE}/auth/session`, { headers: { Cookie: cookie } }); + expect(await session.json()).toEqual({ user: null }); + }); +}); + +describe('upload validation', () => { + it('requires a title', async () => { + const res = await SELF.fetch(`${BASE}/api/upload`, { + method: 'POST', + headers: { ...AUTH, 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it('rejects javascript: CTA URLs', async () => { + const res = await SELF.fetch(`${BASE}/api/upload`, { + method: 'POST', + headers: { ...AUTH, 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'x', cta_url: 'javascript:alert(1)' }), + }); + expect(res.status).toBe(400); + }); + + it('accepts https CTA URLs and returns a well-formed share code', async () => { + const data = await createShare({ cta_url: 'https://example.com', cta_text: 'Visit' }); + expect(data.shareCode).toMatch(/^[a-z0-9]{10}$/); + expect(data.shareURL).toContain(`/s/${data.shareCode}`); + }); + + it('uploads and completes a video through the multipart API', async () => { + const { shareCode } = await createShare({ fileSize: 6 }); + const start = await SELF.fetch(`${BASE}/api/upload-multipart/${shareCode}`, { + method: 'POST', + headers: AUTH, + }); + expect(start.status).toBe(200); + const { uploadId } = await start.json(); + + const part = await SELF.fetch( + `${BASE}/api/upload-part/${shareCode}/${encodeURIComponent(uploadId)}/1`, + { + method: 'PUT', + headers: { ...AUTH, 'Content-Type': 'video/mp4' }, + body: new Uint8Array([10, 20, 30, 40, 50, 60]), + }, + ); + expect(part.status).toBe(200); + const uploadedPart = await part.json(); + + const complete = await SELF.fetch( + `${BASE}/api/upload-complete/${shareCode}/${encodeURIComponent(uploadId)}`, + { + method: 'POST', + headers: { ...AUTH, 'Content-Type': 'application/json' }, + body: JSON.stringify({ parts: [uploadedPart] }), + }, + ); + expect(complete.status).toBe(200); + + const meta = await SELF.fetch(`${BASE}/api/metadata/${shareCode}`, { + method: 'POST', + headers: { ...AUTH, 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(meta.status).toBe(200); + + const video = await SELF.fetch(`${BASE}/v/${shareCode}`); + expect(video.status).toBe(200); + expect(Array.from(new Uint8Array(await video.arrayBuffer()))).toEqual([10, 20, 30, 40, 50, 60]); + }); +}); + +describe('share data + view counts', () => { + it('serves data for a completed upload and increments views', async () => { + const { shareCode } = await createShare(); + await completeUpload(shareCode); + + const res1 = await SELF.fetch(`${BASE}/s/${shareCode}/data`); + expect(res1.status).toBe(200); + const d1 = await res1.json(); + expect(d1.video.title).toBe('Test Recording'); + expect(d1.video.view_count).toBe(1); + expect(d1.segments).toHaveLength(1); + + const res2 = await SELF.fetch(`${BASE}/s/${shareCode}/data`); + const d2 = await res2.json(); + expect(d2.video.view_count).toBe(2); + }); + + it('404s for incomplete uploads', async () => { + const { shareCode } = await createShare(); + const res = await SELF.fetch(`${BASE}/s/${shareCode}/data`); + expect(res.status).toBe(404); + }); +}); + +describe('video streaming + ranges', () => { + let shareCode; + const bytes = new Uint8Array(100).map((_, i) => i); + + beforeAll(async () => { + ({ shareCode } = await createShare()); + await completeUpload(shareCode, bytes); + }); + + it('serves the full object without a Range header', async () => { + const res = await SELF.fetch(`${BASE}/v/${shareCode}`); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Length')).toBe('100'); + expect(res.headers.get('Accept-Ranges')).toBe('bytes'); + }); + + it('serves a bounded range with the full size in Content-Range', async () => { + const res = await SELF.fetch(`${BASE}/v/${shareCode}`, { headers: { Range: 'bytes=10-19' } }); + expect(res.status).toBe(206); + expect(res.headers.get('Content-Range')).toBe('bytes 10-19/100'); + expect(res.headers.get('Content-Length')).toBe('10'); + const body = new Uint8Array(await res.arrayBuffer()); + expect(Array.from(body)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); + }); + + it('serves an open-ended range', async () => { + const res = await SELF.fetch(`${BASE}/v/${shareCode}`, { headers: { Range: 'bytes=90-' } }); + expect(res.status).toBe(206); + expect(res.headers.get('Content-Range')).toBe('bytes 90-99/100'); + expect(res.headers.get('Content-Length')).toBe('10'); + }); + + it('serves a suffix range (bytes=-N)', async () => { + const res = await SELF.fetch(`${BASE}/v/${shareCode}`, { headers: { Range: 'bytes=-5' } }); + expect(res.status).toBe(206); + expect(res.headers.get('Content-Range')).toBe('bytes 95-99/100'); + const body = new Uint8Array(await res.arrayBuffer()); + expect(Array.from(body)).toEqual([95, 96, 97, 98, 99]); + }); + + it('returns 416 for an unsatisfiable range', async () => { + const res = await SELF.fetch(`${BASE}/v/${shareCode}`, { headers: { Range: 'bytes=5000-' } }); + expect(res.status).toBe(416); + }); +}); + +describe('password protection', () => { + const password = 'hunter2'; + let clientHash; + + beforeAll(async () => { + clientHash = await sha256Hex(password); // what the desktop app sends at share time + }); + + async function protectedShare() { + const share = await createShare({ password_hash: clientHash }); + await completeUpload(share.shareCode); + return share.shareCode; + } + + it('gates /data behind the password', async () => { + const shareCode = await protectedShare(); + const res = await SELF.fetch(`${BASE}/s/${shareCode}/data`); + expect(res.status).toBe(401); + expect((await res.json()).password_protected).toBe(true); + }); + + it('gates reactions, comments, and the thumbnail behind the password', async () => { + const shareCode = await protectedShare(); + + expect((await SELF.fetch(`${BASE}/s/${shareCode}/reactions`)).status).toBe(401); + expect((await SELF.fetch(`${BASE}/s/${shareCode}/comments`)).status).toBe(401); + expect((await SELF.fetch(`${BASE}/thumb/${shareCode}`)).status).toBe(404); + + // …and unlocks them all with the auth cookie. + const good = await SELF.fetch(`${BASE}/s/${shareCode}/verify-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + expect(good.status).toBe(200); + const cookie = good.headers.get('Set-Cookie').split(';')[0]; + + expect((await SELF.fetch(`${BASE}/s/${shareCode}/reactions`, { headers: { Cookie: cookie } })).status).toBe(200); + expect((await SELF.fetch(`${BASE}/s/${shareCode}/comments`, { headers: { Cookie: cookie } })).status).toBe(200); + }); + + it('rejects password attempts on expired videos', async () => { + const shareCode = await protectedShare(); + await env.DB.prepare( + "UPDATE videos SET expires_at = datetime('now', '-1 day') WHERE share_code = ?" + ).bind(shareCode).run(); + + const res = await SELF.fetch(`${BASE}/s/${shareCode}/verify-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + expect(res.status).toBe(404); + }); + + it('rejects a wrong password, accepts the right one, sets an HttpOnly cookie', async () => { + const shareCode = await protectedShare(); + + const bad = await SELF.fetch(`${BASE}/s/${shareCode}/verify-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: 'wrong' }), + }); + expect(bad.status).toBe(403); + + const good = await SELF.fetch(`${BASE}/s/${shareCode}/verify-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + expect(good.status).toBe(200); + const cookie = good.headers.get('Set-Cookie'); + expect(cookie).toContain(`voom_auth_${shareCode}=`); + expect(cookie).toContain('HttpOnly'); + expect(cookie).toContain('Secure'); + + const authed = await SELF.fetch(`${BASE}/s/${shareCode}/data`, { + headers: { Cookie: cookie.split(';')[0] }, + }); + expect(authed.status).toBe(200); + }); + + it('stores passwords salted — never the bare client hash', async () => { + const shareCode = await protectedShare(); + const row = await env.DB.prepare('SELECT password_hash, password_salt FROM videos WHERE share_code = ?') + .bind(shareCode).first(); + expect(row.password_salt).toMatch(/^[0-9a-f]{32}$/); + expect(row.password_hash).not.toBe(clientHash); + }); + + it('lazily upgrades legacy unsalted rows on successful verify', async () => { + const shareCode = await protectedShare(); + // Regress the row to the legacy (pre-salt) format. + await env.DB.prepare('UPDATE videos SET password_hash = ?, password_salt = NULL WHERE share_code = ?') + .bind(clientHash, shareCode).run(); + + const res = await SELF.fetch(`${BASE}/s/${shareCode}/verify-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + expect(res.status).toBe(200); + + const row = await env.DB.prepare('SELECT password_hash, password_salt FROM videos WHERE share_code = ?') + .bind(shareCode).first(); + expect(row.password_salt).toMatch(/^[0-9a-f]{32}$/); + expect(row.password_hash).not.toBe(clientHash); // re-stored salted + }); + + it('rate-limits brute-force attempts (429 after 10 failures)', async () => { + const shareCode = await protectedShare(); + for (let i = 0; i < 10; i++) { + const res = await SELF.fetch(`${BASE}/s/${shareCode}/verify-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'CF-Connecting-IP': '203.0.113.7' }, + body: JSON.stringify({ password: `wrong-${i}` }), + }); + expect(res.status).toBe(403); + } + const blocked = await SELF.fetch(`${BASE}/s/${shareCode}/verify-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'CF-Connecting-IP': '203.0.113.7' }, + body: JSON.stringify({ password }), + }); + expect(blocked.status).toBe(429); + }); + + it('hides title and thumbnail from the OG page and image', async () => { + const shareCode = await protectedShare(); + const og = await SELF.fetch(`${BASE}/s/${shareCode}`, { headers: { 'User-Agent': 'Twitterbot/1.0' } }); + expect(og.status).toBe(200); + const html = await og.text(); + expect(html).toContain('Protected video'); + expect(html).not.toContain('Test Recording'); + + const img = await SELF.fetch(`${BASE}/og/${shareCode}`); + expect(img.status).toBe(200); + expect(img.headers.get('Content-Type')).toContain('svg'); + const svg = await img.text(); + expect(svg).toContain('Protected video'); + expect(svg).not.toContain('Test Recording'); + }); +}); + +describe('OG page (public video)', () => { + it('escapes the title in meta tags', async () => { + const { shareCode } = await createShare({ title: '' }); + await completeUpload(shareCode); + const res = await SELF.fetch(`${BASE}/s/${shareCode}`, { headers: { 'User-Agent': 'Slackbot 1.0' } }); + const html = await res.text(); + expect(html).not.toContain(''; + const { shareCode } = await createShare({ width: payload, height: payload, duration: 'invalid', fileSize: -1 }); + const row = await env.DB.prepare('SELECT width, height, duration, file_size FROM videos WHERE share_code = ?').bind(shareCode).first(); + expect(row).toMatchObject({ width: 0, height: 0, duration: 0, file_size: 0 }); + await completeUpload(shareCode); + await env.DB.prepare('UPDATE videos SET width = ?, height = ? WHERE share_code = ?').bind(payload, payload, shareCode).run(); + const response = await SELF.fetch(`${BASE}/s/${shareCode}`, { headers: { 'User-Agent': 'Twitterbot/1.0' } }); + expect(await response.text()).not.toContain(''); + }); +}); + + +it('normalizes invalid comment pagination and clamps zero limits', async () => { + const { shareCode } = await createShare(); + await completeUpload(shareCode); + for (const query of ['page=invalid&limit=invalid', 'page=-5&limit=0', 'page=1&limit=-1']) { + const response = await SELF.fetch(`${BASE}/s/${shareCode}/comments?${query}`); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.comments).toEqual([]); + } +}); diff --git a/services/recordly-share/worker/test/helpers.test.js b/services/recordly-share/worker/test/helpers.test.js new file mode 100644 index 000000000..b3cadfc0a --- /dev/null +++ b/services/recordly-share/worker/test/helpers.test.js @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { + generateShareCode, + escapeHTML, + parseCookies, + timingSafeEqual, + sha256Hex, + generateSalt, + formatVTTTime, +} from '../src/index.js'; + +const SHARE_CODE_CHARS = 'abcdefghjkmnpqrstuvwxyz23456789'; +const SHARE_CODE_LENGTH = 10; + +describe('generateShareCode', () => { + it('produces codes of the documented length and charset', () => { + for (let i = 0; i < 50; i++) { + const code = generateShareCode(); + expect(code).toHaveLength(SHARE_CODE_LENGTH); + expect(code).toMatch(/^[a-z0-9]+$/); // must satisfy every route regex + for (const ch of code) expect(SHARE_CODE_CHARS).toContain(ch); + } + }); + + it('excludes ambiguous characters (i, l, o, 0, 1)', () => { + for (const ch of 'ilo01') expect(SHARE_CODE_CHARS).not.toContain(ch); + }); +}); + +describe('escapeHTML', () => { + it('escapes the four double-quote-context metacharacters', () => { + expect(escapeHTML(`V;if(E&&l==="start")s+=D-U;else if(E&&l==="center")s+=(D+M)/2-(U+A)/2;else if(E&&l==="end")s+=M-A;else if(E&&l==="nearest"){let q=D-U,pe=M-A;s+=Math.abs(q)<=Math.abs(pe)?q:pe}if(Z&&i==="start")o+=L-P;else if(Z&&i==="center")o+=(L+O)/2-(P+V)/2;else if(Z&&i==="end")o+=O-V;else if(Z&&i==="nearest"){let q=L-P,pe=O-V;o+=Math.abs(q)<=Math.abs(pe)?q:pe}r.scrollTo({left:o,top:s})}function dn(r,e={}){let{containingElement:t}=e;if(r&&r.isConnected){let n=document.scrollingElement||document.documentElement;if(window.getComputedStyle(n).overflow==="hidden"){let{left:i,top:s}=r.getBoundingClientRect(),o=rr(r,!0);for(let f of o)Dt(f,r);let{left:c,top:u}=r.getBoundingClientRect();if(Math.abs(i-c)>1||Math.abs(s-u)>1){o=t?rr(t,!0):[];for(let f of o)Dt(f,t,{block:"center",inline:"center"});for(let f of rr(r,!0))Dt(f,r)}}else{let{left:i,top:s}=r.getBoundingClientRect();r?.scrollIntoView?.({block:"nearest"});let{left:o,top:c}=r.getBoundingClientRect();(Math.abs(i-o)>1||Math.abs(s-c)>1)&&(t?.scrollIntoView?.({block:"center",inline:"center"}),r.scrollIntoView?.({block:"nearest"}))}}}function Us(r,e){let{collection:t,onLoadMore:n,scrollOffset:l=1,direction:i="end"}=r,s=a.useRef(null),o=Fe(c=>{for(let u of c)u.isIntersecting&&n&&n()});Y(()=>{if(e.current){const c=100*l,u=i==="start"?`${c}% 0px 0px 0px`:`0px ${c}% ${c}% ${c}%`;s.current=new IntersectionObserver(o,{root:Or(e?.current),rootMargin:u}),s.current.observe(e.current)}return()=>{s.current&&s.current.disconnect()}},[t,e,l,i])}function gr(r){const e=a.version.split(".");return parseInt(e[0],10)>=19?r:r?"true":void 0}function _r(r,e=!0){let[t,n]=a.useState(!0),l=t&&e;return Y(()=>{if(l&&r.current&&"getAnimations"in r.current)for(let i of r.current.getAnimations())i instanceof CSSTransition&&i.cancel()},[r,l]),yl(r,l,a.useCallback(()=>n(!1),[])),l}function Zr(r,e){let[t,n]=a.useState(e?"open":"closed");switch(t){case"open":e||n("exiting");break;case"closed":case"exiting":e&&n("open");break}let l=t==="exiting";return yl(r,l,a.useCallback(()=>{n(i=>i==="exiting"?"closed":i)},[])),l}function yl(r,e,t){Y(()=>{if(e&&r.current){if(!("getAnimations"in r.current)){t();return}let n=r.current.getAnimations();if(n.length===0){t();return}let l=!1;return Promise.allSettled(n.map(i=>i.finished)).then(()=>{l||rt.flushSync(()=>{t()})}),()=>{l=!0}}},[r,e,t])}const Ws=a.createContext({}),qs="__avatar_group_child",Gs=Ae({defaultVariants:{color:"default",size:"md"},slots:{base:"avatar",fallback:"avatar__fallback",image:"avatar__image"},variants:{color:{accent:{fallback:"avatar__fallback--accent"},danger:{fallback:"avatar__fallback--danger"},default:{fallback:"avatar__fallback--default"},success:{fallback:"avatar__fallback--success"},warning:{fallback:"avatar__fallback--warning"}},size:{lg:{base:"avatar--lg"},md:{base:"avatar--md"},sm:{base:"avatar--sm"}},variant:{default:{},soft:{base:"avatar--soft"}}}}),Ys=Ae({slots:{menu:"dropdown__menu",popover:"dropdown__popover",root:"dropdown",trigger:"dropdown__trigger"}}),Xs=Ae({defaultVariants:{variant:"default"},slots:{indicator:"menu-item__indicator",item:"menu-item",submenuIndicator:"menu-item__indicator menu-item__indicator--submenu"},variants:{variant:{danger:{item:"menu-item--danger"},default:{item:"menu-item--default"}}}}),Js=Ae({base:"menu-section"}),Qs=Ae({defaultVariants:{color:"accent",size:"md"},slots:{base:"progress-bar",fill:"progress-bar__fill",output:"progress-bar__output",track:"progress-bar__track"},variants:{color:{accent:{base:"progress-bar--accent"},danger:{base:"progress-bar--danger"},default:{base:"progress-bar--default"},success:{base:"progress-bar--success"},warning:{base:"progress-bar--warning"}},size:{lg:{base:"progress-bar--lg"},md:{base:"progress-bar--md"},sm:{base:"progress-bar--sm"}}}}),eo=Ae({defaultVariants:{hideScrollBar:!1,orientation:"vertical",variant:"fade"},slots:{base:"scroll-shadow"},variants:{hideScrollBar:{false:{},true:{base:"scroll-shadow--hide-scrollbar"}},orientation:{horizontal:{base:"scroll-shadow--horizontal"},vertical:{base:"scroll-shadow--vertical"}},variant:{fade:{base:"scroll-shadow--fade"}}}}),to=Ae({slots:{base:"slider",fill:"slider__fill",marks:"slider__marks",output:"slider__output",thumb:"slider__thumb",track:"slider__track"}}),ro=Ae({defaultVariants:{align:"center",variant:"primary"},slots:{base:"tabs",scrollNext:"tabs__list-container__scroll-next",scrollPrev:"tabs__list-container__scroll-prev",scroller:"tabs__list-container__scroller",separator:"tabs__separator",tab:"tabs__tab",tabIndicator:"tabs__indicator",tabList:"tabs__list",tabListContainer:"tabs__list-container",tabPanel:"tabs__panel"},variants:{align:{center:{},end:{base:"tabs--align-end"},start:{base:"tabs--align-start"}},variant:{primary:{},secondary:{base:"tabs--secondary"}}}}),no=Ae({base:"textarea",defaultVariants:{fullWidth:!1,variant:"primary"},variants:{fullWidth:{false:"",true:"textarea--full-width"},variant:{primary:"textarea--primary",secondary:"textarea--secondary"}}}),lo=Ae({slots:{base:"tooltip",trigger:"tooltip__trigger"}}),Br=a.createContext({placement:"bottom"}),ao=a.forwardRef(function(e,t){[e,t]=Se(e,t,Br);let n=e.placement,l={position:"absolute",transform:n==="top"||n==="bottom"?"translateX(-50%)":"translateY(-50%)"};n!=null&&(l[n]="100%");let i=fe({...e,defaultClassName:"react-aria-OverlayArrow",values:{placement:n}});i.style&&Object.keys(i.style).forEach(o=>i.style[o]===void 0&&delete i.style[o]);let s=se(e);return x.createElement(ae.div,{...s,...i,style:{...l,...i.style},ref:t,"data-placement":n})}),io=typeof HTMLElement<"u"&&"inert"in HTMLElement.prototype;function fn(r){return r.dataset.liveAnnouncer==="true"||r.dataset.reactAriaTopLayer!==void 0}let dt=new WeakMap,me=[];function so(r,e){let t=nt(r?.[0]),n=e instanceof t.Element?{root:e}:e,l=n?.root??document.body,i=n?.shouldUseInert&&io,s=new Set(r),o=new Set,c=m=>i&&m instanceof t.HTMLElement?m.inert:m.getAttribute("aria-hidden")==="true",u=(m,v)=>{i&&m instanceof t.HTMLElement?m.inert=v:v?m.setAttribute("aria-hidden","true"):(m.removeAttribute("aria-hidden"),m instanceof t.HTMLElement&&(m.inert=!1))},f=new Set;if(Je()){let m=l.getRootNode();for(let v of r){let $=v.getRootNode();for(;Xi($)&&$!==m;)f.add($),$=$.host.getRootNode()}}let d=m=>{for(let S of m.querySelectorAll("[data-live-announcer], [data-react-aria-top-layer]"))s.add(S);let v=S=>{if(o.has(S)||s.has(S)||S.parentElement&&o.has(S.parentElement)&&S.parentElement.getAttribute("role")!=="row")return NodeFilter.FILTER_REJECT;for(let K of s)if(re(S,K))return NodeFilter.FILTER_SKIP;return NodeFilter.FILTER_ACCEPT},$=bl(ce(m),m,NodeFilter.SHOW_ELEMENT,{acceptNode:v}),w=v(m);if(w===NodeFilter.FILTER_ACCEPT&&p(m),w!==NodeFilter.FILTER_REJECT){let S=$.nextNode();for(;S!=null;)p(S),S=$.nextNode()}},p=m=>{let v=dt.get(m)??0;c(m)&&v===0||(v===0&&u(m,!0),o.add(m),dt.set(m,v+1))};me.length&&me[me.length-1].disconnect(),d(l);let g=new MutationObserver(m=>{for(let v of m)if(v.type==="childList"){if(v.target.isConnected&&![...s,...o].some($=>re($,v.target)))for(let $ of v.addedNodes)($ instanceof HTMLElement||$ instanceof SVGElement)&&fn($)?s.add($):$ instanceof Element&&d($);if(Je()){for(let $ of f)if(!$.isConnected){g.disconnect();break}}}});g.observe(l,{childList:!0,subtree:!0});let b=new Set;if(Je())for(let m of f){let v=new MutationObserver($=>{for(let w of $)if(w.type==="childList"){if(w.target.isConnected&&![...s,...o].some(S=>re(S,w.target)))for(let S of w.addedNodes)(S instanceof HTMLElement||S instanceof SVGElement)&&fn(S)?s.add(S):S instanceof Element&&d(S);if(Je()){for(let S of f)if(!S.isConnected){g.disconnect();break}}}});v.observe(m,{childList:!0,subtree:!0}),b.add(v)}let y={visibleNodes:s,hiddenNodes:o,observe(){g.observe(l,{childList:!0,subtree:!0})},disconnect(){g.disconnect()}};return me.push(y),()=>{if(g.disconnect(),Je())for(let m of b)m.disconnect();for(let m of o){let v=dt.get(m);v!=null&&(v===1?(u(m,!1),dt.delete(m)):dt.set(m,v-1))}y===me[me.length-1]?(me.pop(),me.length&&me[me.length-1].observe()):me.splice(me.indexOf(y),1)}}function oo(r){let e=me[me.length-1];if(e&&!e.visibleNodes.has(r))return e.visibleNodes.add(r),()=>{e.visibleNodes.delete(r)}}const be={top:"top",bottom:"top",left:"left",right:"left"},Ht={top:"bottom",bottom:"top",left:"right",right:"left"},co={top:"left",left:"top"},br={top:"height",left:"width"},vl={width:"totalWidth",height:"totalHeight"},At={};let uo=()=>typeof document<"u"?window.visualViewport:null;function hn(r,e){let t=0,n=0,l=0,i=0,s=0,o=0,c={},u=(e?.scale??1)>1;if(r.tagName==="BODY"||r.tagName==="HTML"){let f=document.documentElement;l=f.clientWidth,i=f.clientHeight,t=e?.width??l,n=e?.height??i,c.top=f.scrollTop||r.scrollTop,c.left=f.scrollLeft||r.scrollLeft,e&&(s=Math.max(0,e.pageTop-(c.top??0)),o=Math.max(0,e.pageLeft-(c.left??0)))}else({width:t,height:n,top:s,left:o}=vt(r,!1)),c.top=r.scrollTop,c.left=r.scrollLeft,l=t,i=n;return Lr()&&(r.tagName==="BODY"||r.tagName==="HTML")&&u&&(c.top=0,c.left=0,s=e?.pageTop??0,o=e?.pageLeft??0),{width:t,height:n,totalWidth:l,totalHeight:i,scroll:c,top:s,left:o}}function fo(r){return{top:r.scrollTop,left:r.scrollLeft,width:r.scrollWidth,height:r.scrollHeight}}function mn(r,e,t,n,l,i,s){let o=l.scroll[r]??0,c=n[br[r]],u=s[r]+n.scroll[be[r]]+i,f=s[r]+n.scroll[be[r]]+c-i,d=e-o+n.scroll[be[r]]+s[r]-n[be[r]],p=e-o+t+n.scroll[be[r]]+s[r]-n[be[r]];return df?Math.max(f-p,u-d):0}function ho(r){let e=window.getComputedStyle(r);return{top:parseInt(e.marginTop,10)||0,bottom:parseInt(e.marginBottom,10)||0,left:parseInt(e.marginLeft,10)||0,right:parseInt(e.marginRight,10)||0}}function pn(r){if(At[r])return At[r];let[e,t]=r.split(" "),n=be[e]||"right",l=co[n];be[t]||(t="center");let i=br[n],s=br[l];return At[r]={placement:e,crossPlacement:t,axis:n,crossAxis:l,size:i,crossSize:s},At[r]}function ar(r,e,t,n,l,i,s,o,c,u,f){let{placement:d,crossPlacement:p,axis:g,crossAxis:b,size:y,crossSize:m}=n,v={};v[b]=r[b]??0,p==="center"?v[b]+=((r[m]??0)-(t[m]??0))/2:p!==b&&(v[b]+=(r[m]??0)-(t[m]??0)),v[b]+=i;const $=r[b]-t[m]+c+u,w=r[b]+r[m]-c-u;if(v[b]=lt(v[b],$,w),d===g){let S=o?f[y]:f[vl[y]];v[Ht[g]]=Math.floor(S-r[g]+l)}else v[g]=Math.floor(r[g]+r[y]+l);return v}function mo(r,e,t,n,l,i,s,o,c,u,f){let d=(r.top!=null?r.top:c[vl.height]-(r.bottom??0)-s)-(c.scroll.top??0),p=u?t.top:0,g={top:Math.max(e.top+p,(f?.offsetTop??e.top)+p),bottom:Math.min(e.top+e.height+p,(f?.offsetTop??0)+(f?.height??0))};return o!=="top"?Math.max(0,g.bottom-d-((l.top??0)+(l.bottom??0)+i)):Math.max(0,d+s-g.top-((l.top??0)+(l.bottom??0)+i))}function gn(r,e,t,n,l,i,s,o){let{placement:c,axis:u,size:f}=i;return c===u?Math.max(0,t[u]-(s.scroll[u]??0)-(r[u]+(o?e[u]:0))-(n[u]??0)-n[Ht[u]]-l):Math.max(0,r[f]+r[u]+(o?e[u]:0)-t[u]-t[f]+(s.scroll[u]??0)-(n[u]??0)-n[Ht[u]]-l)}function po(r,e,t,n,l,i,s,o,c,u,f,d,p,g,b,y,m,v){let $=pn(r),{size:w,crossAxis:S,crossSize:K,placement:T,crossPlacement:C}=$,F=ar(e,o,t,$,f,d,u,p,b,y,c),_=f,j=gn(o,u,e,l,i+f,$,c,m);if(s&&t[w]>j){let E=pn(`${Ht[T]} ${C}`),Z=ar(e,o,t,E,f,d,u,p,b,y,c);gn(o,u,e,l,i+f,E,c,m)>j&&($=E,F=Z,_=f)}let N="bottom";$.axis==="top"?$.placement==="top"?N="top":$.placement==="bottom"&&(N="bottom"):$.crossAxis==="top"&&($.crossPlacement==="top"?N="bottom":$.crossPlacement==="bottom"&&(N="top"));let H=mn(S,F[S],t[K],o,c,i,u);F[S]+=H;let D=mo(F,o,u,p,l,i,t.height,N,c,m,v);g&&g Enter your dashboard password to manage shared recordings. Enter your dashboard password to manage shared recordings. Manage your shared videos and links. Leave feedback at the exact moment you’re watching.
+ {timestampParts(comment.text, duration).map((part, n) =>
+ part.time === undefined ? (
+ part.text
+ ) : (
+
+ ),
+ )}
+ {data.video.title} {data.video.summary} This recording is password protected.
+ This link has expired or the recording has been removed. Ask the creator for a new
+ link.
+ Check your connection and try again. We couldn’t load this video. {getDetail(payload, t)}l[0]i||new yc({collection:t,disabledKeys:n,disabledBehavior:u,ref:l,collator:c,layoutDelegate:s,orientation:o}),[i,s,t,n,l,c,u,o]),{collectionProps:d}=ii({...r,ref:l,selectionManager:e,keyboardDelegate:f});return{listProps:d}}function xc(r,e,t){let{shouldFocusWrap:n=!0,onKeyDown:l,onKeyUp:i,...s}=r;!r["aria-label"]&&r["aria-labelledby"];let o=se(r,{labelable:!0}),{listProps:c}=$c({...s,ref:t,selectionManager:e.selectionManager,collection:e.collection,disabledKeys:e.disabledKeys,shouldFocusWrap:n,linkBehavior:"override"});return li.set(e,{onClose:r.onClose,onAction:r.onAction,shouldUseVirtualFocus:r.shouldUseVirtualFocus}),{menuProps:B(o,{onKeyDown:l,onKeyUp:i},{role:"menu",...c,onKeyDown:u=>{(u.key!=="Escape"||r.shouldUseVirtualFocus)&&c.onKeyDown?.(u)}})}}function si(r){let{id:e,selectionManager:t,key:n,ref:l,shouldSelectOnPressUp:i,shouldUseVirtualFocus:s,focus:o,isDisabled:c,onAction:u,allowsDifferentPressOrigin:f,linkBehavior:d="action"}=r,p=Hr();e=Re(e);let g=E=>{if(E.pointerType==="keyboard"&&Er(E))t.toggleSelection(n);else{if(t.selectionMode==="none")return;if(t.isLink(n)){if(d==="selection"&&l.current){let Z=t.getItemProps(n);p.open(l.current,E,Z.href,Z.routerOptions),t.setSelectedKeys(t.selectedKeys);return}else if(d==="override"||d==="none")return}t.selectionMode==="single"?t.isSelected(n)&&!t.disallowEmptySelection?t.toggleSelection(n):t.replaceSelection(n):E&&E.shiftKey?t.extendSelection(n):t.selectionBehavior==="toggle"||E&&(ht(E)||E.pointerType==="touch"||E.pointerType==="virtual")?t.toggleSelection(n):t.replaceSelection(n)}};a.useEffect(()=>{n===t.focusedKey&&t.isFocused&&(s?va(l.current):o?o():le()!==l.current&&l.current&&bt(l.current))},[l,n,t.focusedKey,t.childFocusStrategy,t.isFocused,s]),c=c||t.isDisabled(n);let b={};!s&&!c?b={tabIndex:n===t.focusedKey?0:-1,onFocus(E){z(E)===l.current&&t.setFocusedKey(n)}}:c&&(b.onMouseDown=E=>{E.preventDefault()}),a.useEffect(()=>{c&&t.focusedKey===n&&t.setFocusedKey(null)},[t,c,n]);let y=t.isLink(n)&&d==="override",m=u&&r.UNSTABLE_itemBehavior==="action",v=t.isLink(n)&&d!=="selection"&&d!=="none",$=!c&&t.canSelectItem(n)&&!y&&!m,w=(u||v)&&!c,S=w&&(t.selectionBehavior==="replace"?!$:!$||t.isEmpty),K=w&&$&&t.selectionBehavior==="replace",T=S||K,C=a.useRef(null),F=T&&$,_=a.useRef(!1),j=a.useRef(!1),N=t.getItemProps(n),H=E=>{u&&(u(),l.current?.dispatchEvent(new CustomEvent("react-aria-item-action",{bubbles:!0}))),v&&l.current&&p.open(l.current,E,N.href,N.routerOptions)},D={ref:l};i?(D.onPressStart=E=>{C.current=E.pointerType,_.current=F,E.pointerType==="keyboard"&&(!T||Fn(E.key))&&g(E)},f?(D.onPressUp=S?void 0:E=>{E.pointerType==="mouse"&&$&&g(E)},D.onPress=S?H:E=>{E.pointerType!=="keyboard"&&E.pointerType!=="mouse"&&$&&g(E)}):D.onPress=E=>{if(S||K&&E.pointerType!=="mouse"){if(E.pointerType==="keyboard"&&!Ln(E.key))return;H(E)}else E.pointerType!=="keyboard"&&$&&g(E)}):(D.onPressStart=E=>{C.current=E.pointerType,_.current=F,j.current=S,$&&(E.pointerType==="mouse"&&!S||E.pointerType==="keyboard"&&(!w||Fn(E.key)))&&g(E)},D.onPress=E=>{(E.pointerType==="touch"||E.pointerType==="pen"||E.pointerType==="virtual"||E.pointerType==="keyboard"&&T&&Ln(E.key)||E.pointerType==="mouse"&&j.current)&&(T?H(E):$&&g(E))});let M=pc(t.collection);if(b["data-collection"]=M,b["data-key"]=n,D.preventFocusOnPress=s,s&&(D=B(D,{onPressStart(E){E.pointerType!=="touch"&&(t.setFocused(!0),t.setFocusedKey(n))},onPress(E){E.pointerType==="touch"&&(t.setFocused(!0),t.setFocusedKey(n))}})),N)for(let E of["onPressStart","onPressEnd","onPressChange","onPress","onPressUp","onClick"])N[E]&&(D[E]=Nr(D[E],N[E]));let{pressProps:L,isPressed:O}=Vr(D),I=K?E=>{C.current==="mouse"&&(E.stopPropagation(),E.preventDefault(),H(E))}:void 0,{longPressProps:X}=Yr({isDisabled:!F,onLongPress(E){E.pointerType==="touch"&&(g(E),t.setSelectionBehavior("toggle"))}}),ne=E=>{C.current==="touch"&&_.current&&E.preventDefault()},te=d!=="none"&&t.isLink(n)?E=>{is.isOpening||E.preventDefault()}:void 0,U=B(b,$||S||s&&!c?L:{},F?X:{},{onDoubleClick:I,onDragStartCapture:ne,onClick:te,id:e},s?{onMouseDown:E=>E.preventDefault()}:void 0),A=E=>{let Z=E;for(;Z&&Z!==l.current;){let q=Z.getAttribute("data-collection");if(q!=null)return q!==M;Z=Z.parentElement}return Fr(E)},P=U.onPointerDown;U.onPointerDown=E=>{let Z=z(E);if(Z&&Z!==l.current&&A(Z)){E.stopPropagation();return}P?.(E)};let V=U.onMouseDown;return U.onMouseDown=E=>{let Z=z(E);if(Z&&Z!==l.current&&A(Z)){E.stopPropagation();return}V?.(E)},{itemProps:U,isPressed:O,isSelected:t.isSelected(n),isFocused:t.isFocused&&t.focusedKey===n,isDisabled:c,allowsSelection:$,hasAction:T}}function Ln(r){return r==="Enter"}function Fn(r){return r===" "}function oi(r,e){return typeof e.getChildren=="function"?e.getChildren(r.key):r.childNodes}function Ec(r){return Sc(r)}function Sc(r,e){for(let t of r)return t}function or(r,e,t){if(e.parentKey===t.parentKey)return e.index-t.index;let n=[...Dn(r,e),e],l=[...Dn(r,t),t],i=n.slice(0,l.length).findIndex((s,o)=>s!==l[o]);return i!==-1?(e=n[i],t=l[i],e.index-t.index):n.findIndex(s=>s===t)>=0?1:(l.findIndex(s=>s===e)>=0,-1)}function Dn(r,e){let t=[],n=e;for(;n?.parentKey!=null;)n=r.getItem(n.parentKey),n&&t.unshift(n);return t}const Nn=new WeakMap;function wc(r){let e=Nn.get(r);if(e!=null)return e;let t=0,n=l=>{for(let i of l)i.type==="section"?n(oi(i,r)):i.type==="item"&&t++};return n(r),Nn.set(r,t),t}function Pc(r,e,t){let{id:n,key:l,closeOnSelect:i,shouldCloseOnSelect:s,isVirtualized:o,"aria-haspopup":c,onPressStart:u,onPressUp:f,onPress:d,onPressChange:p,onPressEnd:g,onClick:b,onHoverStart:y,onHoverChange:m,onHoverEnd:v,onKeyDown:$,onKeyUp:w,onFocus:S,onFocusChange:K,onBlur:T,selectionManager:C=e.selectionManager}=r,F=!!c,_=F&&r["aria-expanded"]==="true",j=r.isDisabled??C.isDisabled(l),N=r.isSelected??C.isSelected(l),H=li.get(e),D=e.collection.getItem(l),M=r.onClose||H.onClose,L=Hr(),O=()=>{if(!F&&(D?.props?.onAction?D.props.onAction():r.onAction&&r.onAction(l),H.onAction)){let J=H.onAction;J(l,D?.value)}},I="menuitem";F||(C.selectionMode==="single"?I="menuitemradio":C.selectionMode==="multiple"&&(I="menuitemcheckbox"));let X=tr(),ne=tr(),te=tr(),U={id:n,"aria-disabled":j||void 0,role:I,"aria-label":r["aria-label"],"aria-labelledby":X,"aria-describedby":[r["aria-describedby"],ne,te].filter(Boolean).join(" ")||void 0,"aria-controls":r["aria-controls"],"aria-haspopup":c,"aria-expanded":r["aria-expanded"]};if(C.selectionMode!=="none"&&!F&&(U["aria-checked"]=N),o){let J=Number(D?.index);U["aria-posinset"]=Number.isNaN(J)?void 0:J+1,U["aria-setsize"]=wc(e.collection)}let A=a.useRef(!1),P=J=>{p?.(J),A.current=J},V=a.useRef(null),E=J=>{J.pointerType!=="keyboard"&&(V.current={pointerType:J.pointerType}),J.pointerType==="mouse"&&(A.current||J.target.click()),f?.(J)},Z=J=>{b?.(J),O(),ss(J,L,D.props.href,D?.props.routerOptions);let Ye=V.current?.pointerType==="keyboard"?V.current?.key==="Enter"||C.selectionMode==="none"||C.isLink(l):C.selectionMode!=="multiple"||C.isLink(l);Ye=s??i??Ye,M&&!F&&Ye&&M(),V.current=null},{itemProps:q,isFocused:pe}=si({id:n,selectionManager:C,key:l,ref:t,shouldSelectOnPressUp:!0,allowsDifferentPressOrigin:!0,linkBehavior:"none",shouldUseVirtualFocus:H.shouldUseVirtualFocus}),{pressProps:Ie,isPressed:Ve}=Vr({onPressStart:u,onPress:d,onPressUp:E,onPressChange:P,onPressEnd:g,isDisabled:j}),{hoverProps:R}=qe({isDisabled:j,onHoverStart(J){!mr()&&!(_&&c)&&(C.setFocused(!0),C.setFocusedKey(l)),y?.(J)},onHoverChange:m,onHoverEnd:v}),{keyboardProps:k}=De({shortcuts:{" ":J=>{V.current={pointerType:"keyboard",key:" "},z(J).click(),Ft("keyboard")},Enter:J=>{V.current={pointerType:"keyboard",key:"Enter"};let Ye=z(J);if(Ye.tagName!=="A"){Ye.click(),Ft("keyboard");return}return Ft("keyboard"),{shouldPreventDefault:!1,shouldContinuePropagation:!1}}},onKeyDown:$,onKeyUp:w}),{focusableProps:oe}=St({onBlur:T,onFocus:S,onFocusChange:K},t),Q=se(D?.props);delete Q.id;let Ct=sl(D?.props);return{menuItemProps:{...U,...B(Q,Ct,F?{onFocus:q.onFocus,"data-collection":q["data-collection"],"data-key":q["data-key"]}:q,Ie,R,k,oe,H.shouldUseVirtualFocus||F?{onMouseDown:J=>J.preventDefault()}:void 0,j?void 0:{onClick:Z}),tabIndex:q.tabIndex!=null&&_&&!H.shouldUseVirtualFocus?-1:q.tabIndex},labelProps:{id:X},descriptionProps:{id:ne},keyboardShortcutProps:{id:te},isFocused:pe,isFocusVisible:pe&&C.isFocused&&mr()&&!_,isSelected:N,isPressed:Ve,isDisabled:j}}function Cc(r){let{heading:e,"aria-label":t}=r,n=Re();return{itemProps:{role:"presentation"},headingProps:e?{id:n,role:"presentation"}:{},groupProps:{role:"group","aria-label":t,"aria-labelledby":e?n:void 0}}}const Tt=2,Mc=50,Ac=1e3,In=Math.PI/12;function Kc(r){let{menuRef:e,submenuRef:t,isOpen:n,isDisabled:l}=r,i=a.useRef(void 0),s=a.useRef(void 0),o=a.useRef(0),c=a.useRef(void 0),u=a.useRef(void 0),f=a.useRef(void 0),d=a.useRef(2),[p,g]=a.useState(!1);Vt({ref:n?t:void 0,onResize:()=>{t.current&&(s.current=t.current.getBoundingClientRect(),f.current=void 0)}});let y=()=>{g(!1),d.current=Tt,i.current=void 0},m=os(),v=Fe($=>{p&&$.preventDefault()});a.useEffect(()=>{p&&e.current?e.current.style.pointerEvents="none":e.current.style.pointerEvents=""},[e,p]),Y(()=>{let $=t.current,w=e.current;if(l||!$||!n||m!=="pointer"||!w){y();return}s.current=$.getBoundingClientRect();let S=K=>{if(K.pointerType==="touch"||K.pointerType==="pen")return;let T=Date.now();if(T-o.currenta?o-a:void 0;return el(t,u,c,f)},tl=e=>{const t=new Set(e.orderSensitiveModifiers);return n=>{const r=[];let a=[];for(let o=0;o0||Object.is(n,0):t==="exceptZero"&&(Object.is(n,-0)||Object.is(n,0)?n=Math.abs(n):r=n>0),r){let a=e.format(-n),o=e.format(n),s=a.replace(o,"").replace(/\u200e|\u061C/,"");return[...s].length!==1&&console.warn("@react-aria/i18n polyfill for NumberFormat signDisplay: Unsupported case"),a.replace(o,"!!!").replace(s,"+").replace("!!!",o)}else return e.format(n)}}function dc(e={}){let{locale:t}=jn();return i.useMemo(()=>new lc(t,e),[t,e])}function fc(e){let{value:t=0,minValue:n=0,maxValue:r=100,valueLabel:a,isIndeterminate:o,formatOptions:s={style:"percent"}}=e,l=ue(e,{labelable:!0}),{labelProps:c,fieldProps:u}=Ta({...e,labelElementType:"span"});t=Yr(t,n,r);let d=r-n,f=d===0?0:(t-n)/d,h=dc(s);if(!o&&!a){let g=s.style==="percent"?f:t;a=h.format(g)}return{progressBarProps:q(l,{...u,"aria-valuenow":o?void 0:t,"aria-valuemin":n,"aria-valuemax":r,"aria-valuetext":o?void 0:a,role:"progressbar"}),labelProps:c}}const La=i.createContext(null),Zd=i.forwardRef(function(t,n){[t,n]=fe(t,n,La);let{value:r=0,minValue:a=0,maxValue:o=100,isIndeterminate:s=!1}=t;r=Yr(r,a,o);let[l,c]=Ca(!t["aria-label"]&&!t["aria-labelledby"]),{progressBarProps:u,labelProps:d}=fc({...t,label:c}),f=o-a,h;s||(f===0?h=0:h=(r-a)/f*100);let g=ge({...t,defaultClassName:"react-aria-ProgressBar",values:{percentage:h,valueText:u["aria-valuetext"],isIndeterminate:s}}),v=ue(t,{global:!0});return I.createElement(le.div,{...q(v,g,u),ref:n,slot:t.slot||void 0},I.createElement(Fn.Provider,{value:{...d,ref:l,elementType:"span"}},g.children))}),Ma=7e3;let Oe=null;function ur(e,t="assertive",n=Ma){Oe?Oe.announce(e,t,n):(Oe=new mc,(typeof IS_REACT_ACT_ENVIRONMENT=="boolean"?IS_REACT_ACT_ENVIRONMENT:typeof jest<"u")?Oe.announce(e,t,n):setTimeout(()=>{Oe?.isAttached()&&Oe?.announce(e,t,n)},100))}class mc{constructor(){this.node=null,this.assertiveLog=null,this.politeLog=null,typeof document<"u"&&(this.node=document.createElement("div"),this.node.dataset.liveAnnouncer="true",Object.assign(this.node.style,{border:0,clip:"rect(0 0 0 0)",clipPath:"inset(50%)",height:"1px",margin:"-1px",overflow:"hidden",padding:0,position:"absolute",width:"1px",whiteSpace:"nowrap"}),this.assertiveLog=this.createLog("assertive"),this.node.appendChild(this.assertiveLog),this.politeLog=this.createLog("polite"),this.node.appendChild(this.politeLog),document.body.prepend(this.node))}isAttached(){return this.node?.isConnected}createLog(t){let n=document.createElement("div");return n.setAttribute("role","log"),n.setAttribute("aria-live",t),n.setAttribute("aria-relevant","additions"),n}destroy(){this.node&&(document.body.removeChild(this.node),this.node=null)}announce(t,n="assertive",r=Ma){if(!this.node)return;let a=document.createElement("div");typeof t=="object"?(a.setAttribute("role","img"),a.setAttribute("aria-labelledby",t["aria-labelledby"])):a.textContent=t,n==="assertive"?this.assertiveLog?.appendChild(a):this.politeLog?.appendChild(a),t!==""&&setTimeout(()=>{a.remove()},r)}clear(t){this.node&&((!t||t==="assertive")&&this.assertiveLog&&(this.assertiveLog.innerHTML=""),(!t||t==="polite")&&this.politeLog&&(this.politeLog.innerHTML=""))}}function Hn(e){let t=e;return t.nativeEvent=e,t.isDefaultPrevented=()=>t.defaultPrevented,t.isPropagationStopped=()=>t.cancelBubble,t.persist=()=>{},t}function Aa(e,t){Object.defineProperty(e,"target",{value:t}),Object.defineProperty(e,"currentTarget",{value:t})}function Ia(e){let t=i.useRef({isFocused:!1,observer:null});return ae(()=>{const n=t.current;return()=>{n.observer&&(n.observer.disconnect(),n.observer=null)}},[]),i.useCallback(n=>{let r=D(n);if(r instanceof HTMLButtonElement||r instanceof HTMLInputElement||r instanceof HTMLTextAreaElement||r instanceof HTMLSelectElement){t.current.isFocused=!0;let a=r,o=s=>{if(t.current.isFocused=!1,a.disabled){let l=Hn(s);e?.(l)}t.current.observer&&(t.current.observer.disconnect(),t.current.observer=null)};a.addEventListener("focusout",o,{once:!0}),t.current.observer=new MutationObserver(()=>{if(t.current.isFocused&&a.disabled){t.current.observer?.disconnect();let s=a===pe()?null:pe();a.dispatchEvent(new FocusEvent("blur",{relatedTarget:s})),a.dispatchEvent(new FocusEvent("focusout",{bubbles:!0,relatedTarget:s}))}}),t.current.observer.observe(a,{attributes:!0,attributeFilter:["disabled"]})}},[e])}let At=!1;function bc(e){for(;e&&!Js(e,{skipVisibilityCheck:!0});)e=e.parentElement;let t=re(e),n=pe(t.document);if(!n||n===e)return;let r=e?.getRootNode(),a=r!=null&&Tn(r)?r:re(e),o=v=>v===e||v!=null&&G(e,v),s=v=>v===n||n!=null&&v!=null&&G(n,v);At=!0;let l=!1,c=v=>{(s(D(v))||l)&&v.stopImmediatePropagation()},u=v=>{(s(D(v))||l)&&(v.stopImmediatePropagation(),!e&&!l&&(l=!0,ce(n),h()))},d=v=>{(o(D(v))||l)&&v.stopImmediatePropagation()},f=v=>{(o(D(v))||l)&&(v.stopImmediatePropagation(),l||(l=!0,ce(n),h()))};a.addEventListener("blur",c,!0),a.addEventListener("focusout",u,!0),a.addEventListener("focusin",f,!0),a.addEventListener("focus",d,!0);let h=()=>{cancelAnimationFrame(g),a.removeEventListener("blur",c,!0),a.removeEventListener("focusout",u,!0),a.removeEventListener("focusin",f,!0),a.removeEventListener("focus",d,!0),At=!1,l=!1},g=requestAnimationFrame(h);return h}let he=null;const nt=new Set;let tt=new Map,Re=!1,wt=!1;const pc={Tab:!0,Escape:!0};function Kt(e,t){for(let n of nt)n(e,t)}function hc(e){return!(e.metaKey||!Ee()&&e.altKey||e.ctrlKey||e.key==="Control"||e.key==="Shift"||e.key==="Meta")}function It(e){Re=!0,!Ce.isOpening&&hc(e)&&(he="keyboard",Kt("keyboard",e))}function Ue(e){he="pointer","pointerType"in e&&e.pointerType,(e.type==="mousedown"||e.type==="pointerdown")&&(Re=!0,Kt("pointer",e))}function Ra(e){!Ce.isOpening&&Gr(e)&&(Re=!0,he="virtual")}function Va(e){if(At)return;let t=D(e),n=re(t),r=X(t);if(t===n){wt=!0;return}t===r||!e.isTrusted||(!Re&&!wt&&(he="virtual",Kt("virtual",e)),Re=!1,wt=!1)}function Fa(){At||(Re=!1,wt=!0)}function Rt(e){if(typeof window>"u"||typeof document>"u")return;const t=re(e),n=X(e);if(tt.get(t))return;let r=t.HTMLElement.prototype.focus;Reflect.defineProperty(t.HTMLElement.prototype,"focus",{configurable:!0,writable:!0,value:function(){Re=!0,r.apply(this,arguments)}}),n.addEventListener("keydown",It,!0),n.addEventListener("keyup",It,!0),n.addEventListener("click",Ra,!0),t.addEventListener("focus",Va,!0),t.addEventListener("blur",Fa,!1),typeof PointerEvent<"u"&&(n.addEventListener("pointerdown",Ue,!0),n.addEventListener("pointermove",Ue,!0),n.addEventListener("pointerup",Ue,!0)),t.addEventListener("beforeunload",()=>{ja(e)},{once:!0}),tt.set(t,{focus:r})}const ja=(e,t)=>{const n=re(e),r=X(e);t&&r.removeEventListener("DOMContentLoaded",t),tt.has(n)&&(Reflect.defineProperty(n.HTMLElement.prototype,"focus",{configurable:!0,writable:!0,value:tt.get(n).focus}),r.removeEventListener("keydown",It,!0),r.removeEventListener("keyup",It,!0),r.removeEventListener("click",Ra,!0),n.removeEventListener("focus",Va,!0),n.removeEventListener("blur",Fa,!1),typeof PointerEvent<"u"&&(r.removeEventListener("pointerdown",Ue,!0),r.removeEventListener("pointermove",Ue,!0),r.removeEventListener("pointerup",Ue,!0)),tt.delete(n))};function vc(e){const t=X(e);let n;return t.readyState!=="loading"?Rt(e):(n=()=>{Rt(e)},t.addEventListener("DOMContentLoaded",n)),()=>ja(e,n)}typeof document<"u"&&vc();function Vt(){return he!=="pointer"}function Et(){return he}function gc(e){he=e,Kt(e,null)}function Gd(){Rt();let[e,t]=i.useState(he);return i.useEffect(()=>{let n=()=>{t(he)};return nt.add(n),()=>{nt.delete(n)}},[]),at()?null:e}const $c=new Set(["checkbox","radio","range","color","file","image","button","submit","reset"]);function yc(e,t,n){let r=n?D(n):void 0,a=X(r),o=re(r);const s=typeof o<"u"?o.HTMLInputElement:HTMLInputElement,l=typeof o<"u"?o.HTMLTextAreaElement:HTMLTextAreaElement,c=typeof o<"u"?o.HTMLElement:HTMLElement,u=typeof o<"u"?o.KeyboardEvent:KeyboardEvent;let d=pe(a);return e=e||d instanceof s&&!$c.has(d.type)||d instanceof l||d instanceof c&&d.isContentEditable,!(e&&t==="keyboard"&&n instanceof u&&!pc[n.key])}function xc(e={}){let{isTextInput:t,autoFocus:n}=e,[r,a]=i.useState(n||Vt());return Ha(o=>{a(o)},[t],{isTextInput:t}),{isFocusVisible:r}}function Ha(e,t,n){Rt(),i.useEffect(()=>{if(n?.enabled===!1)return;let r=(a,o)=>{yc(!!n?.isTextInput,a,o)&&e(Vt())};return nt.add(r),()=>{nt.delete(r)}},t)}function wc(e){if(!e.isConnected)return;const t=X(e);if(Et()==="virtual"){let n=pe(t);Ur(()=>{const r=pe(t);(r===n||r===t.body)&&e.isConnected&&ce(e)})}else ce(e)}function Da(e){let{isDisabled:t,onFocus:n,onBlur:r,onFocusChange:a}=e;const o=i.useCallback(c=>{if(D(c)===c.currentTarget)return r&&r(c),a&&a(!1),!0},[r,a]),s=Ia(o),l=i.useCallback(c=>{let u=D(c);const d=X(u),f=d?pe(d):pe();u===c.currentTarget&&u===f&&(n&&n(c),a&&a(!0),s(c))},[a,n,s]);return{focusProps:{onFocus:!t&&(n||a||r)?l:void 0,onBlur:!t&&(r||a)?o:void 0}}}function ht(e){if(e)return t=>{let n=!0,r={...t,preventDefault(){t.preventDefault()},isDefaultPrevented(){return t.isDefaultPrevented()},stopPropagation(){n=!0},continuePropagation(){n=!1,typeof t.continuePropagation=="function"&&t.continuePropagation()},isPropagationStopped(){return n}};e(r),n&&!(typeof t.isPropagationStopped=="function"&&t.isPropagationStopped())&&t.stopPropagation()}}const Ec=new Set(["shift","alt","control","meta","mod"]),Cc=["Alt","Control","Meta","Shift"];function Tc(e){let t=new Set;return e.alt&&t.add("Alt"),e.shift&&t.add("Shift"),e.ctrl&&t.add("Control"),e.meta&&t.add("Meta"),e.mod&&t.add(Ee()?"Meta":"Control"),t}function kc(e){let t=new Set;return e.altKey&&t.add("Alt"),e.ctrlKey&&t.add("Control"),e.metaKey&&t.add("Meta"),e.shiftKey&&t.add("Shift"),t}function Na(e){return Cc.filter(t=>e.has(t))}function Sc(e){let t=e.split("+").reduce((n,r)=>{let a=r.toLowerCase();return Ec.has(a)?a==="shift"?n.shift=!0:a==="alt"?n.alt=!0:a==="control"?n.ctrl=!0:a==="meta"?n.meta=!0:a==="mod"&&(n.mod=!0):n.key=r,n},{shift:!1,alt:!1,ctrl:!1,meta:!1,mod:!1,key:""});if(t.key==="")throw new Error(`Invalid keyboard shortcut: "${e}". Must include exactly one non-modifier key (e.g. "a", "Enter", "ArrowDown"). Combine any of Shift, Alt, Ctrl, Meta, and Mod.`);return t}function Oa(e){return e.toLowerCase()}const Pc={space:" ",esc:"escape",del:"delete",ins:"insert",left:"arrowleft",right:"arrowright",up:"arrowup",down:"arrowdown",pageup:"pageup",pagedown:"pagedown"};function Lc(e){let t=Oa(e),n=Pc[t];return n??t}function Mc(e){let t=Na(Tc(e)),n=Lc(e.key);return t.length>0?`${t.join("+")}+${n}`:n}function Ac(e){let t=Na(kc(e)),n=Oa(e.key);return(t.length>0?`${t.join("+")}+`:"")+n}function Ic(e){let t=new Map;for(let[n,r]of Object.entries(e)){let a=Sc(n);t.set(Mc(a),r)}return n=>{let r=Ac(n),a=t.get(r),o=a?.(n);o===void 0&&a!==void 0?o={shouldContinuePropagation:!1,shouldPreventDefault:!0}:typeof o=="boolean"&&(o={shouldContinuePropagation:!o,shouldPreventDefault:o}),o?.shouldPreventDefault&&n.preventDefault(),(!a||o?.shouldContinuePropagation)&&n.continuePropagation()}}function Rc(e){let{shortcuts:t,allowRepeats:n=!1,allowComposing:r=!1}=e,a,o;if(t){let s=Ic(t),l=ht(u=>{if(!G(u.currentTarget,D(u))){u.continuePropagation();return}if(u.nativeEvent?.repeat&&!n||u.nativeEvent?.isComposing&&!r){u.continuePropagation();return}s(u)}),c=ht(u=>{if(!G(u.currentTarget,D(u))){u.continuePropagation();return}if(u.nativeEvent?.repeat&&!n||u.nativeEvent?.isComposing&&!r){u.continuePropagation();return}u.continuePropagation()});a=e.onKeyDown?St(e.onKeyDown,l):l,o=e.onKeyUp?St(e.onKeyUp,c):c}else a=ht(e.onKeyDown),o=ht(e.onKeyUp);return{keyboardProps:e.isDisabled?{}:{onKeyDown:a,onKeyUp:o}}}let _a=I.createContext(null);function Vc(e){let t=i.useContext(_a)||{};Zr(t,e);let{ref:n,...r}=t;return r}const Wd=I.forwardRef(function(t,n){let{children:r,...a}=t,o=Bt(n),s={...a,ref:o};return I.createElement(_a.Provider,{value:s},r)});function Dn(e,t){let{focusProps:n}=Da(e),{keyboardProps:r}=Rc(e),a=q(n,r),o=Vc(t),s=e.isDisabled?{}:o,l=i.useRef(e.autoFocus);i.useEffect(()=>{l.current&&t.current&&wc(t.current),l.current=!1},[t]);let c=e.excludeFromTabOrder?-1:0;return e.isDisabled&&(c=void 0),{focusableProps:q({...a,tabIndex:c},s)}}let ze="default",xn="",Ct=new WeakMap;function Fc(e){if(Ot()&&kt()){if(ze==="default"){const t=X(e);xn=t.documentElement.style.webkitUserSelect,t.documentElement.style.webkitUserSelect="none"}ze="disabled"}else if(e instanceof HTMLElement||e instanceof SVGElement){let t="userSelect"in e.style?"userSelect":"webkitUserSelect";Ct.set(e,e.style[t]),e.style[t]="none"}}function dr(e){if(Ot()&&kt()){if(ze!=="disabled")return;ze="restoring",setTimeout(()=>{Ur(()=>{if(ze==="restoring"){const t=X(e);t.documentElement.style.webkitUserSelect==="none"&&(t.documentElement.style.webkitUserSelect=xn||""),xn="",ze="default"}})},300)}else if((e instanceof HTMLElement||e instanceof SVGElement)&&e&&Ct.has(e)){let t=Ct.get(e),n="userSelect"in e.style?"userSelect":"webkitUserSelect";e.style[n]==="none"&&(e.style[n]=t),e.getAttribute("style")===""&&e.removeAttribute("style"),Ct.delete(e)}}const Ba=I.createContext({register:()=>{}});Ba.displayName="PressResponderContext";function jc(e){let t=i.useContext(Ba);if(t){let{register:n,ref:r,...a}=t;e=q(a,e),n()}return Zr(t,e.ref),e}class vt{#e;constructor(t,n,r,a){this.#e=!0;const s=(a?.target??r.currentTarget)?.getBoundingClientRect();let l,c=0,u,d=null;r.clientX!=null&&r.clientY!=null&&(u=r.clientX,d=r.clientY),s&&(u!=null&&d!=null?(l=u-s.left,c=d-s.top):(l=s.width/2,c=s.height/2)),this.type=t,this.pointerType=n,this.target=r.currentTarget,this.shiftKey=r.shiftKey,this.metaKey=r.metaKey,this.ctrlKey=r.ctrlKey,this.altKey=r.altKey,this.x=l,this.y=c,this.key=r.key}continuePropagation(){this.#e=!1}get shouldStopPropagation(){return this.#e}}const fr=Symbol("linkClicked"),mr="react-aria-pressable-style",br="data-react-aria-pressable";function za(e){let{onPress:t,onPressChange:n,onPressStart:r,onPressEnd:a,onPressUp:o,onClick:s,isDisabled:l,isPressed:c,preventFocusOnPress:u,shouldCancelOnPointerExit:d,allowTextSelectionOnPress:f,ref:h,...g}=jc(e),[v,w]=i.useState(!1),$=i.useRef({isPressed:!1,ignoreEmulatedMouseEvents:!1,didFirePressStart:!1,isTriggeringEvent:!1,activePointerId:null,target:null,isOverTarget:!1,pointerType:null,disposables:[]}),{addGlobalListener:C,removeAllGlobalListeners:k}=kn(),T=i.useCallback((p,A)=>{let H=$.current;if(l||H.didFirePressStart)return!1;let E=!0;if(H.isTriggeringEvent=!0,r){let K=new vt("pressstart",A,p);r(K),E=K.shouldStopPropagation}return n&&n(!0),H.isTriggeringEvent=!1,H.didFirePressStart=!0,w(!0),E},[l,r,n]),L=i.useCallback((p,A,H=!0)=>{let E=$.current;if(!E.didFirePressStart)return!1;E.didFirePressStart=!1,E.isTriggeringEvent=!0;let K=!0;if(a){let b=new vt("pressend",A,p);a(b),K=b.shouldStopPropagation}if(n&&n(!1),w(!1),t&&H&&!l){let b=new vt("press",A,p);t(b),K&&=b.shouldStopPropagation}return E.isTriggeringEvent=!1,K},[l,a,n,t]),F=Me(L),j=i.useCallback((p,A)=>{let H=$.current;if(l)return!1;if(o){H.isTriggeringEvent=!0;let E=new vt("pressup",A,p);return o(E),H.isTriggeringEvent=!1,E.shouldStopPropagation}return!0},[l,o]),V=Me(j),x=i.useCallback(p=>{let A=$.current;if(A.isPressed&&A.target){A.didFirePressStart&&A.pointerType!=null&&L(Le(A.target,p),A.pointerType,!1),A.isPressed=!1,A.isOverTarget=!1,A.activePointerId=null,A.pointerType=null,k(),f||dr(A.target);for(let H of A.disposables)H();A.disposables=[]}},[f,k,L]),R=Me(x);i.useEffect(()=>{l&&$.current.isPressed&&R({currentTarget:$.current.target,shiftKey:!1,ctrlKey:!1,metaKey:!1,altKey:!1})},[l]);let M=i.useCallback(p=>{d&&x(p)},[d,x]),z=i.useCallback(p=>{l||s?.(p)},[l,s]),Z=i.useCallback((p,A)=>{if(!l&&s){let H=new MouseEvent("click",p);Aa(H,A),s(Hn(H))}},[l,s]),Q=i.useMemo(()=>{let p=$.current,A={onKeyDown(E){if(en(E.nativeEvent,E.currentTarget)&&G(E.currentTarget,D(E))){pr(D(E),E.key)&&E.preventDefault();let K=!0;!p.isPressed&&!E.repeat&&(p.target=E.currentTarget,p.isPressed=!0,p.pointerType="keyboard",K=T(E,"keyboard"));let b=E.currentTarget,y=O=>{en(O,b)&&!O.repeat&&G(b,D(O))&&p.target&&V(Le(p.target,O),"keyboard")};C(X(E.currentTarget),"keyup",St(y,H),!0),K&&E.stopPropagation(),E.metaKey&&Ee()&&p.metaKeyEvents?.set(E.key,E.nativeEvent)}else E.key==="Meta"&&(p.metaKeyEvents=new Map)},onClick(E){if(!(E&&!G(E.currentTarget,D(E)))&&E&&E.button===0&&!p.isTriggeringEvent&&!Ce.isOpening){let K=!0;if(l&&E.preventDefault(),!p.ignoreEmulatedMouseEvents&&!p.isPressed&&(p.pointerType==="virtual"||Gr(E.nativeEvent))){let b=T(E,"virtual"),y=V(E,"virtual"),O=F(E,"virtual");z(E),K=b&&y&&O}else if(p.isPressed&&p.pointerType!=="keyboard"){let b=p.pointerType||E.nativeEvent.pointerType||"virtual",y=V(Le(E.currentTarget,E),b),O=F(Le(E.currentTarget,E),b,!0);K=y&&O,p.isOverTarget=!1,z(E),R(E)}p.ignoreEmulatedMouseEvents=!1,K&&E.stopPropagation()}}},H=E=>{if(p.isPressed&&p.target&&en(E,p.target)){pr(D(E),E.key)&&E.preventDefault();let K=D(E),b=G(p.target,K);F(Le(p.target,E),"keyboard",b),b&&Z(E,p.target),k(),E.key!=="Enter"&&Nn(p.target)&&G(p.target,K)&&!E[fr]&&(E[fr]=!0,Ce(p.target,E,!1)),p.isPressed=!1,p.metaKeyEvents?.delete(E.key)}else if(E.key==="Meta"&&p.metaKeyEvents?.size){let K=p.metaKeyEvents;p.metaKeyEvents=void 0;for(let b of K.values())p.target?.dispatchEvent(new KeyboardEvent("keyup",b))}};if(typeof PointerEvent<"u"){A.onPointerDown=b=>{if(b.button!==0||!G(b.currentTarget,D(b)))return;if(Zs(b.nativeEvent)){p.pointerType="virtual";return}p.pointerType=b.pointerType;let y=!0;if(!p.isPressed){p.isPressed=!0,p.isOverTarget=!0,p.activePointerId=b.pointerId,p.target=b.currentTarget,f||Fc(p.target),y=T(b,p.pointerType);let O=D(b);"releasePointerCapture"in O&&("hasPointerCapture"in O?O.hasPointerCapture(b.pointerId)&&O.releasePointerCapture(b.pointerId):O.releasePointerCapture(b.pointerId)),C(X(b.currentTarget),"pointerup",E,!1),C(X(b.currentTarget),"pointercancel",K,!1)}y&&b.stopPropagation()},A.onMouseDown=b=>{if(G(b.currentTarget,D(b))&&b.button===0){if(u){let y=bc(b.target);y&&p.disposables.push(y)}b.stopPropagation()}},A.onPointerUp=b=>{!G(b.currentTarget,D(b))||p.pointerType==="virtual"||b.button===0&&!p.isPressed&&V(b,p.pointerType||b.pointerType)},A.onPointerEnter=b=>{b.pointerId===p.activePointerId&&p.target&&!p.isOverTarget&&p.pointerType!=null&&(p.isOverTarget=!0,T(Le(p.target,b),p.pointerType))},A.onPointerLeave=b=>{b.pointerId===p.activePointerId&&p.target&&p.isOverTarget&&p.pointerType!=null&&(p.isOverTarget=!1,F(Le(p.target,b),p.pointerType,!1),M(b))};let E=b=>{if(b.pointerId===p.activePointerId&&p.isPressed&&b.button===0&&p.target){if(G(p.target,D(b))&&p.pointerType!=null){let y=!1,O=setTimeout(()=>{p.isPressed&&p.target instanceof HTMLElement&&(y?R(b):(ce(p.target),p.target.click()))},80);C(b.currentTarget,"click",()=>y=!0,!0),p.disposables.push(()=>clearTimeout(O))}else R(b);p.isOverTarget=!1}},K=b=>{R(b)};A.onDragStart=b=>{G(b.currentTarget,D(b))&&R(b)}}return A},[C,l,u,k,f,M,T,z,Z]);return i.useEffect(()=>{if(!h)return;const p=X(h.current);if(!p||!p.head||p.getElementById(mr))return;const A=p.createElement("style");A.id=mr;let H=ti(p);H&&(A.nonce=H),A.textContent=`
+@layer {
+ [${br}] {
+ touch-action: pan-x pan-y pinch-zoom;
+ }
+}
+ `.trim(),p.head.prepend(A)},[h]),i.useEffect(()=>{let p=$.current;return()=>{f||dr(p.target??void 0);for(let A of p.disposables)A();p.disposables=[]}},[f]),{isPressed:c||v,pressProps:q(g,Q,{[br]:!0})}}function Nn(e){return e.tagName==="A"&&e.hasAttribute("href")}function en(e,t){const{key:n,code:r}=e,a=t,o=a.getAttribute("role");return(n==="Enter"||n===" "||n==="Spacebar"||r==="Space")&&!(a instanceof re(a).HTMLInputElement&&!Ka(a,n)||a instanceof re(a).HTMLTextAreaElement||a.isContentEditable)&&!((o==="link"||!o&&Nn(a))&&n!=="Enter")}function Le(e,t){let n=t.clientX,r=t.clientY;return{currentTarget:e,shiftKey:t.shiftKey,ctrlKey:t.ctrlKey,metaKey:t.metaKey,altKey:t.altKey,clientX:n,clientY:r,key:t.key}}function Hc(e){return e instanceof HTMLInputElement?!1:e instanceof HTMLButtonElement?e.type!=="submit"&&e.type!=="reset":!Nn(e)}function pr(e,t){return Ee()&&t==="Enter"?!1:e instanceof HTMLInputElement?t==="Enter"&&(e.type==="checkbox"||e.type==="radio")?!1:!Ka(e,t):Hc(e)}const Dc=new Set(["checkbox","radio","range","color","file","image","button","submit","reset"]);function Ka(e,t){return e.type==="checkbox"||e.type==="radio"?t===" ":Dc.has(e.type)}function Nc(e,t){let{elementType:n="button",isDisabled:r,onPress:a,onPressStart:o,onPressEnd:s,onPressUp:l,onPressChange:c,preventFocusOnPress:u,allowFocusWhenDisabled:d,onClick:f,href:h,target:g,rel:v,type:w="button"}=e,$;n==="button"?$={type:w,disabled:r,form:e.form,formAction:e.formAction,formEncType:e.formEncType,formMethod:e.formMethod,formNoValidate:e.formNoValidate,formTarget:e.formTarget,name:e.name,value:e.value}:$={role:"button",href:n==="a"&&!r?h:void 0,target:n==="a"?g:void 0,type:n==="input"?w:void 0,disabled:n==="input"?r:void 0,"aria-disabled":!r||n==="input"?void 0:r,rel:n==="a"?v:void 0};let{pressProps:C,isPressed:k}=za({onPressStart:o,onPressEnd:s,onPressChange:c,onPress:a,onPressUp:l,onClick:f,isDisabled:r,preventFocusOnPress:u,ref:t}),{focusableProps:T}=Dn(e,t);d&&(T.tabIndex=r?-1:T.tabIndex);let L=q(T,C,ue(e,{labelable:!0}));return{isPressed:k,buttonProps:q($,L,{"aria-haspopup":e["aria-haspopup"],"aria-expanded":e["aria-expanded"],"aria-controls":e["aria-controls"],"aria-pressed":e["aria-pressed"],"aria-current":e["aria-current"],"aria-disabled":e["aria-disabled"]})}}function Ua(e){let{isDisabled:t,onBlurWithin:n,onFocusWithin:r,onFocusWithinChange:a}=e,o=i.useRef({isFocusWithin:!1}),{addGlobalListener:s,removeAllGlobalListeners:l}=kn(),c=i.useCallback(f=>{G(f.currentTarget,D(f))&&o.current.isFocusWithin&&!G(f.currentTarget,f.relatedTarget)&&(o.current.isFocusWithin=!1,l(),n&&n(f),a&&a(!1))},[n,a,o,l]),u=Ia(c),d=i.useCallback(f=>{if(!G(f.currentTarget,D(f)))return;let h=D(f);const g=X(h),v=pe(g);if(!o.current.isFocusWithin&&v===h){r&&r(f),a&&a(!0),o.current.isFocusWithin=!0,u(f);let w=f.currentTarget;s(g,"focus",$=>{let C=D($);if(o.current.isFocusWithin&&!G(w,C)){let k=new g.defaultView.FocusEvent("blur",{relatedTarget:C});Aa(k,w);let T=Hn(k);c(T)}},{capture:!0})}},[r,a,u,s,c]);return t?{focusWithinProps:{onFocus:void 0,onBlur:void 0}}:{focusWithinProps:{onFocus:d,onBlur:c}}}function Ze(e={}){let{autoFocus:t=!1,isTextInput:n,within:r}=e,a=i.useRef({isFocused:!1,isFocusVisible:t||Vt()}),[o,s]=i.useState(!1),[l,c]=i.useState(()=>a.current.isFocused&&a.current.isFocusVisible),u=i.useCallback(()=>c(a.current.isFocused&&a.current.isFocusVisible),[]),d=i.useCallback(g=>{a.current.isFocused=g,a.current.isFocusVisible=Vt(),s(g),u()},[u]);Ha(g=>{a.current.isFocusVisible=g,u()},[n,o],{enabled:o,isTextInput:n});let{focusProps:f}=Da({isDisabled:r,onFocusChange:d}),{focusWithinProps:h}=Ua({isDisabled:!r,onFocusWithinChange:d});return{isFocused:o,isFocusVisible:l,focusProps:r?h:f}}let wn=!1,gt=0;function Oc(){wn=!0,setTimeout(()=>{wn=!1},500)}function hr(e){e.pointerType==="touch"&&Oc()}function _c(){let e=X(null);if(!(typeof e>"u"))return gt===0&&typeof PointerEvent<"u"&&e.addEventListener("pointerup",hr),gt++,()=>{gt--,!(gt>0)&&typeof PointerEvent<"u"&&e.removeEventListener("pointerup",hr)}}function Fe(e){let{onHoverStart:t,onHoverChange:n,onHoverEnd:r,isDisabled:a}=e,[o,s]=i.useState(!1),l=i.useRef({isHovered:!1,ignoreEmulatedMouseEvents:!1,pointerType:"",target:null}).current;i.useEffect(_c,[]);let{addGlobalListener:c,removeAllGlobalListeners:u}=kn(),{hoverProps:d,triggerHoverEnd:f}=i.useMemo(()=>{let h=(w,$)=>{if(l.pointerType=$,a||$==="touch"||l.isHovered||!G(w.currentTarget,D(w)))return;l.isHovered=!0;let C=w.currentTarget;l.target=C,c(X(D(w)),"pointerover",k=>{l.isHovered&&l.target&&!G(l.target,D(k))&&g(k,k.pointerType)},{capture:!0}),t&&t({type:"hoverstart",target:C,pointerType:$}),n&&n(!0),s(!0)},g=(w,$)=>{let C=l.target;l.pointerType="",l.target=null,!($==="touch"||!l.isHovered||!C)&&(l.isHovered=!1,u(),r&&r({type:"hoverend",target:C,pointerType:$}),n&&n(!1),s(!1))},v={};return typeof PointerEvent<"u"&&(v.onPointerEnter=w=>{wn&&w.pointerType==="mouse"||h(w,w.pointerType)},v.onPointerLeave=w=>{!a&&G(w.currentTarget,D(w))&&g(w,w.pointerType)}),{hoverProps:v,triggerHoverEnd:g}},[t,n,r,a,l,c,u]);return i.useEffect(()=>{a&&f({currentTarget:l.target},l.pointerType)},[a]),{hoverProps:d,isHovered:o}}const Za=i.createContext({}),Ga=st(function(t,n){[t,n]=fe(t,n,Za);let r=t,{isPending:a}=r,{buttonProps:o,isPressed:s}=Nc(t,n);o=zc(o,a);let{focusProps:l,isFocused:c,isFocusVisible:u}=Ze(t),{hoverProps:d,isHovered:f}=Fe({...t,isDisabled:t.isDisabled||a}),h={isHovered:f,isPressed:(r.isPressed||s)&&!a,isFocused:c,isFocusVisible:u,isDisabled:t.isDisabled||!1,isPending:a??!1},g=ge({...t,values:h,defaultClassName:"react-aria-Button"}),v=Ae(o.id),w=Ae(),$=o["aria-labelledby"];a&&($?$=`${$} ${w}`:o["aria-label"]&&($=`${v} ${w}`));let C=i.useRef(a);i.useEffect(()=>{let T={"aria-labelledby":$||v};(!C.current&&c&&a||C.current&&c&&!a)&&ur(T,"assertive"),C.current=a},[a,c,$,v]);let k=ue(t,{global:!0});return delete k.onClick,I.createElement(le.button,{...q(k,g,o,l,d),type:o.type==="submit"&&a?"button":o.type,id:v,ref:n,"aria-labelledby":$,slot:t.slot||void 0,"aria-disabled":a?"true":o["aria-disabled"],"data-disabled":t.isDisabled||void 0,"data-pressed":h.isPressed||void 0,"data-hovered":f||void 0,"data-focused":c||void 0,"data-pending":a||void 0,"data-focus-visible":u||void 0},I.createElement(La.Provider,{value:{id:w}},g.children))}),Bc=/Focus|Blur|Hover|Pointer(Enter|Leave|Over|Out)|Mouse(Enter|Leave|Over|Out)/;function zc(e,t){if(t){for(const n in e)n.startsWith("on")&&!Bc.test(n)&&(e[n]=void 0);e.href=void 0,e.target=void 0}return e}const Kc=typeof document<"u"?I.useInsertionEffect??I.useLayoutEffect:()=>{};function Uc(e,t,n){let[r,a]=i.useState(e||t),o=i.useRef(r),s=i.useRef(e!==void 0),l=e!==void 0;i.useEffect(()=>{s.current,s.current=l},[l]);let c=l?e:r;Kc(()=>{o.current=c});let[,u]=i.useReducer(()=>({}),{}),d=i.useCallback((f,...h)=>{let g=typeof f=="function"?f(o.current):f;Object.is(o.current,g)||(o.current=g,a(g),u(),n?.(g,...h))},[n]);return[c,d]}const we=e=>e?"true":void 0;function $e(e,t){return Vn(e,(n,r)=>{const a=typeof t=="function"?t(r)??"":t??"";return _l(a,n??"")??""})}const ee=(e,t,n)=>typeof e=="function"?e({...n??{},className:t}):t,qd=e=>m.jsx("svg",{"aria-hidden":"true",fill:"none",height:16,role:"presentation",viewBox:"0 0 16 16",width:16,xmlns:"http://www.w3.org/2000/svg",...e,children:m.jsx("path",{clipRule:"evenodd",d:"M2.97 5.47a.75.75 0 0 1 1.06 0L8 9.44l3.97-3.97a.75.75 0 1 1 1.06 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 0 1 0-1.06",fill:"currentColor",fillRule:"evenodd"})}),Yd=e=>m.jsx("svg",{"aria-hidden":"true",fill:"none",height:16,role:"presentation",viewBox:"0 0 16 16",width:16,xmlns:"http://www.w3.org/2000/svg",...e,children:m.jsx("path",{clipRule:"evenodd",d:"M13.03 10.53a.75.75 0 0 1-1.06 0L8 6.56l-3.97 3.97a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1 0 1.06",fill:"currentColor",fillRule:"evenodd"})}),Xd=e=>m.jsx("svg",{"aria-hidden":"true",fill:"none",height:16,role:"presentation",viewBox:"0 0 16 16",width:16,xmlns:"http://www.w3.org/2000/svg",...e,children:m.jsx("path",{clipRule:"evenodd",d:"M10.53 2.97a.75.75 0 0 1 0 1.06L6.56 8l3.97 3.97a.75.75 0 1 1-1.06 1.06l-4.5-4.5a.75.75 0 0 1 0-1.06l4.5-4.5a.75.75 0 0 1 1.06 0",fill:"currentColor",fillRule:"evenodd"})}),Qd=e=>m.jsx("svg",{"aria-hidden":"true",fill:"none",height:16,role:"presentation",viewBox:"0 0 16 16",width:16,xmlns:"http://www.w3.org/2000/svg",...e,children:m.jsx("path",{clipRule:"evenodd",d:"M5.47 2.97a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 1 1-1.06-1.06L9.44 8 5.47 4.03a.75.75 0 0 1 0-1.06Z",fill:"currentColor",fillRule:"evenodd"})}),Zc=({height:e=9,width:t=9,...n})=>m.jsx("svg",{"aria-hidden":"true",fill:"none",height:e,role:"presentation",viewBox:"0 0 7 7",width:t,xmlns:"http://www.w3.org/2000/svg",...n,children:m.jsx("path",{d:"M1.20592 6.84333L0.379822 6.01723L4.52594 1.8672H1.37819L1.38601 0.731812H6.48742V5.83714H5.34421L5.35203 2.6933L1.20592 6.84333Z",fill:"currentColor"})}),Gc=e=>m.jsx("svg",{"aria-hidden":"true",fill:"none",height:16,role:"presentation",viewBox:"0 0 16 16",width:16,xmlns:"http://www.w3.org/2000/svg",...e,children:m.jsx("path",{clipRule:"evenodd",d:"M3.47 3.47a.75.75 0 0 1 1.06 0L8 6.94l3.47-3.47a.75.75 0 1 1 1.06 1.06L9.06 8l3.47 3.47a.75.75 0 1 1-1.06 1.06L8 9.06l-3.47 3.47a.75.75 0 0 1-1.06-1.06L6.94 8 3.47 4.53a.75.75 0 0 1 0-1.06Z",fill:"currentColor",fillRule:"evenodd"})}),Ft=e=>m.jsx("svg",{"aria-hidden":"true",fill:"none",height:16,role:"presentation",viewBox:"0 0 16 16",width:16,xmlns:"http://www.w3.org/2000/svg",...e,children:m.jsx("path",{clipRule:"evenodd",d:"M8 13.5a5.5 5.5 0 1 0 0-11a5.5 5.5 0 0 0 0 11M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14m1-9.5a1 1 0 1 1-2 0a1 1 0 0 1 2 0m-.25 3a.75.75 0 0 0-1.5 0V11a.75.75 0 0 0 1.5 0z",fill:"currentColor",fillRule:"evenodd"})}),Wa=e=>m.jsx("svg",{"aria-hidden":"true",fill:"none",height:16,role:"presentation",viewBox:"0 0 16 16",width:16,xmlns:"http://www.w3.org/2000/svg",...e,children:m.jsx("path",{clipRule:"evenodd",d:"M7.134 2.994L2.217 11.5a1 1 0 0 0 .866 1.5h9.834a1 1 0 0 0 .866-1.5L8.866 2.993a1 1 0 0 0-1.732 0m3.03-.75c-.962-1.665-3.366-1.665-4.329 0L.918 10.749c-.963 1.666.24 3.751 2.165 3.751h9.834c1.925 0 3.128-2.085 2.164-3.751zM8 5a.75.75 0 0 1 .75.75v2a.75.75 0 0 1-1.5 0v-2A.75.75 0 0 1 8 5m1 5.75a1 1 0 1 1-2 0a1 1 0 0 1 2 0",fill:"currentColor",fillRule:"evenodd"})}),qa=e=>m.jsx("svg",{"aria-hidden":"true",fill:"none",height:16,role:"presentation",viewBox:"0 0 16 16",width:16,xmlns:"http://www.w3.org/2000/svg",...e,children:m.jsx("path",{clipRule:"evenodd",d:"M8 13.5a5.5 5.5 0 1 0 0-11a5.5 5.5 0 0 0 0 11M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14m1-4.5a1 1 0 1 1-2 0a1 1 0 0 1 2 0M8.75 5a.75.75 0 0 0-1.5 0v2.5a.75.75 0 0 0 1.5 0z",fill:"currentColor",fillRule:"evenodd"})}),Ya=e=>m.jsx("svg",{"aria-hidden":"true",fill:"none",height:16,role:"presentation",viewBox:"0 0 16 16",width:16,xmlns:"http://www.w3.org/2000/svg",...e,children:m.jsx("path",{clipRule:"evenodd",d:"M13.5 8a5.5 5.5 0 1 1-11 0a5.5 5.5 0 0 1 11 0M15 8A7 7 0 1 1 1 8a7 7 0 0 1 14 0m-3.9-1.55a.75.75 0 1 0-1.2-.9L7.419 8.858L6.03 7.47a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.13-.08z",fill:"currentColor",fillRule:"evenodd"})}),Xa=i.createContext({}),it=i.createContext({}),Wc={variant:"default"},vr=({children:e,className:t,status:n,...r})=>{const a=I.useMemo(()=>Bl({status:n}),[n]),o=I.useMemo(()=>({slots:a,status:n}),[a,n]);return m.jsx(it,{value:o,children:m.jsx(Xa,{value:Wc,children:m.jsx(J.div,{className:a?.base({className:t}),"data-slot":"alert-root",...r,children:e})})})},qc=({children:e,className:t,...n})=>{const{slots:r,status:a}=i.use(it),o=()=>{switch(a){case"accent":return m.jsx(Ft,{"data-slot":"alert-default-icon"});case"success":return m.jsx(Ya,{"data-slot":"alert-default-icon"});case"warning":return m.jsx(Wa,{"data-slot":"alert-default-icon"});case"danger":return m.jsx(qa,{"data-slot":"alert-default-icon"});default:return m.jsx(Ft,{"data-slot":"alert-default-icon"})}};return m.jsx(J.div,{className:ee(r?.indicator,t),"data-slot":"alert-indicator",...n,children:e??o()})},Yc=({children:e,className:t,...n})=>{const{slots:r}=i.use(it);return m.jsx(J.div,{className:ee(r?.content,t),"data-slot":"alert-content",...n,children:e})},Xc=({children:e,className:t,...n})=>{const{slots:r}=i.use(it);return m.jsx(J.p,{className:ee(r?.title,t),"data-slot":"alert-title",...n,children:e})},Qc=({children:e,className:t,...n})=>{const{slots:r}=i.use(it);return m.jsx(J.span,{className:ee(r?.description,t),"data-slot":"alert-description",...n,children:e})},$t=Object.assign(vr,{Root:vr,Indicator:qc,Content:Yc,Title:Xc,Description:Qc}),Jc=Symbol.for("react-aria.i18n.locale"),eu=Symbol.for("react-aria.i18n.strings");let yt;class Ut{constructor(t,n="en-US"){this.strings=Object.fromEntries(Object.entries(t).filter(([,r])=>r)),this.defaultLocale=n}getStringForLocale(t,n){let a=this.getStringsForLocale(n)[t];if(!a)throw new Error(`Could not find intl message ${t} in ${n} locale`);return a}getStringsForLocale(t){let n=this.strings[t];return n||(n=tu(t,this.strings,this.defaultLocale),this.strings[t]=n),n}static getGlobalDictionaryForPackage(t){if(typeof window>"u")return null;let n=window[Jc];if(yt===void 0){let a=window[eu];if(!a)return null;yt={};for(let o in a)yt[o]=new Ut({[n]:a[o]},n)}let r=yt?.[t];if(!r)throw new Error(`Strings for package "${t}" were not included by LocalizedStringProvider. Please add it to the list passed to createLocalizedStringDictionary.`);return r}}function tu(e,t,n="en-US"){if(t[e])return t[e];let r=nu(e),a=ru(e);if(a&&t[`${r}-${a}`])return t[`${r}-${a}`];if(t[r])return t[r];for(let o in t)if(o.startsWith(r+"-"))return t[o];return t[n]}function nu(e){return Intl.Locale?new Intl.Locale(e).language:e.split("-")[0]}function ru(e){if(Intl.Locale)return new Intl.Locale(e).script}const gr=new Map,$r=new Map;class au{constructor(t,n){this.locale=t,this.strings=n}format(t,n){let r=this.strings.getStringForLocale(t,this.locale);return typeof r=="function"?r(n,this):r}plural(t,n,r="cardinal"){let a=n["="+t];if(a)return typeof a=="function"?a():a;let o=this.locale+":"+r,s=gr.get(o);s||(s=new Intl.PluralRules(this.locale,{type:r}),gr.set(o,s));let l=s.select(t);return a=n[l]||n.other,typeof a=="function"?a():a}number(t){let n=$r.get(this.locale);return n||(n=new Intl.NumberFormat(this.locale),$r.set(this.locale,n)),n.format(t)}select(t,n){let r=t[n]||t.other;return typeof r=="function"?r():r}}const yr=new WeakMap;function ou(e){let t=yr.get(e);return t||(t=new Ut(e),yr.set(e,t)),t}function su(e,t){return t&&Ut.getGlobalDictionaryForPackage(t)||ou(e)}function Qa(e,t){let{locale:n}=jn(),r=su(e,t);return i.useMemo(()=>new au(n,r),[n,r])}const iu=i.createContext({});function lu(){return i.useContext(iu)??{}}var tn={exports:{}},nn={};var xr;function cu(){if(xr)return nn;xr=1;var e=ls();function t(f,h){return f===h&&(f!==0||1/f===1/h)||f!==f&&h!==h}var n=typeof Object.is=="function"?Object.is:t,r=e.useState,a=e.useEffect,o=e.useLayoutEffect,s=e.useDebugValue;function l(f,h){var g=h(),v=r({inst:{value:g,getSnapshot:h}}),w=v[0].inst,$=v[1];return o(function(){w.value=g,w.getSnapshot=h,c(w)&&$({inst:w})},[f,g,h]),a(function(){return c(w)&&$({inst:w}),f(function(){c(w)&&$({inst:w})})},[f]),s(g),g}function c(f){var h=f.getSnapshot;f=f.value;try{var g=h();return!n(f,g)}catch{return!0}}function u(f,h){return h()}var d=typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"?u:l;return nn.useSyncExternalStore=e.useSyncExternalStore!==void 0?e.useSyncExternalStore:d,nn}var wr;function uu(){return wr||(wr=1,tn.exports=cu()),tn.exports}var Ja=uu();const Jd=i.createContext(null),du=i.createContext(null),On=i.createContext({}),eo=st(function(t,n){[t,n]=fe(t,n,On);let{elementType:r="span",...a}=t,o=le[r];return I.createElement(o,{className:"react-aria-Text",...a,ref:n})}),Er=({children:e,className:t,slot:n,style:r,variant:a,...o})=>{const s=i.useMemo(()=>Zl({variant:a}),[a]);return m.jsx(Ga,{"aria-label":"Close",className:$e(t,s),"data-slot":"close-button",slot:n,style:r,type:"button",...o,children:l=>typeof e=="function"?e(l):e??m.jsx(Gc,{"data-slot":"close-button-icon"})})},fu=Object.assign(Er,{Root:Er}),mu=i.createContext({}),bu=i.createContext(null),to={badInput:!1,customError:!1,patternMismatch:!1,rangeOverflow:!1,rangeUnderflow:!1,stepMismatch:!1,tooLong:!1,tooShort:!1,typeMismatch:!1,valueMissing:!1,valid:!0},no={...to,customError:!0,valid:!1},Qe={isInvalid:!1,validationDetails:to,validationErrors:[]},pu=i.createContext({}),Cr="__reactAriaFormValidationState";function hu(e){if(e[Cr]){let{realtimeValidation:t,displayValidation:n,updateValidation:r,resetValidation:a,commitValidation:o}=e[Cr];return{realtimeValidation:t,displayValidation:n,updateValidation:r,resetValidation:a,commitValidation:o}}return vu(e)}function vu(e){let{isInvalid:t,validationState:n,name:r,value:a,builtinValidation:o,validate:s,validationBehavior:l="aria"}=e;n&&(t||=n==="invalid");let c=t!==void 0?{isInvalid:t,validationErrors:[],validationDetails:no}:null,u=i.useMemo(()=>{if(!s||a==null)return null;let M=gu(s,a);return Tr(M)},[s,a]);o?.validationDetails.valid&&(o=void 0);let d=i.useContext(pu),f=i.useMemo(()=>r?Array.isArray(r)?r.flatMap(M=>En(d[M])):En(d[r]):[],[d,r]),[h,g]=i.useState(d),[v,w]=i.useState(!1);d!==h&&(g(d),w(!1));let $=i.useMemo(()=>Tr(v?[]:f),[v,f]),C=i.useRef(Qe),[k,T]=i.useState(Qe),L=i.useRef(Qe),F=()=>{if(!j)return;V(!1);let M=u||o||C.current;rn(M,L.current)||(L.current=M,T(M))},[j,V]=i.useState(!1);return i.useEffect(F),{realtimeValidation:c||$||u||o||Qe,displayValidation:l==="native"?c||$||k:c||$||u||o||k,updateValidation(M){l==="aria"&&!rn(k,M)?T(M):C.current=M},resetValidation(){let M=Qe;rn(M,L.current)||(L.current=M,T(M)),l==="native"&&V(!1),w(!0)},commitValidation(){l==="native"&&V(!0),w(!0)}}}function En(e){return e?Array.isArray(e)?e:[e]:[]}function gu(e,t){if(typeof e=="function"){let n=e(t);if(n&&typeof n!="boolean")return En(n)}return[]}function Tr(e){return e.length?{isInvalid:!0,validationErrors:e,validationDetails:no}:null}function rn(e,t){return e===t?!0:!!e&&!!t&&e.isInvalid===t.isInvalid&&e.validationErrors.length===t.validationErrors.length&&e.validationErrors.every((n,r)=>n===t.validationErrors[r])&&Object.entries(e.validationDetails).every(([n,r])=>t.validationDetails[n]===r)}const $u=i.createContext(null);function yu(e){let{description:t,errorMessage:n,isInvalid:r,validationState:a}=e,{labelProps:o,fieldProps:s}=Ta(e),l=un([!!t,!!n,r,a]),c=un([!!t,!!n,r,a]);return s=q(s,{"aria-describedby":[l,c,e["aria-describedby"]].filter(Boolean).join(" ")||void 0}),{labelProps:o,fieldProps:s,descriptionProps:{id:l},errorMessageProps:{id:c}}}function xu(e,t,n){let{validationBehavior:r,focus:a}=e;ae(()=>{if(r==="native"&&n?.current&&"setCustomValidity"in n.current&&!n.current.disabled){let u=t.realtimeValidation.isInvalid?t.realtimeValidation.validationErrors.join(" ")||"Invalid value.":"";n.current.setCustomValidity(u),n.current.hasAttribute("title")||(n.current.title=""),t.realtimeValidation.isInvalid||t.updateValidation(Eu(n.current))}});let o=i.useRef(!1),s=Me(()=>{o.current||t.resetValidation()}),l=Me(u=>{t.displayValidation.isInvalid||t.commitValidation();let d=n?.current?.form;!u.defaultPrevented&&n&&d&&Cu(d)===n.current&&(a?a():n.current?.focus(),gc("keyboard")),u.preventDefault()}),c=Me(()=>{t.commitValidation()});i.useEffect(()=>{let u=n?.current;if(!u)return;let d=u.form,f=d?.reset;return d&&(d.reset=()=>{o.current=!window.event||window.event.type==="message"&&D(window.event)instanceof MessagePort,f?.call(d),o.current=!1}),u.addEventListener("invalid",l),u.addEventListener("change",c),d?.addEventListener("reset",s),()=>{u.removeEventListener("invalid",l),u.removeEventListener("change",c),d?.removeEventListener("reset",s),d&&(d.reset=f)}},[n,r])}function wu(e){let t=e.validity;return{badInput:t.badInput,customError:t.customError,patternMismatch:t.patternMismatch,rangeOverflow:t.rangeOverflow,rangeUnderflow:t.rangeUnderflow,stepMismatch:t.stepMismatch,tooLong:t.tooLong,tooShort:t.tooShort,typeMismatch:t.typeMismatch,valueMissing:t.valueMissing,valid:t.valid}}function Eu(e){return{isInvalid:!e.validity.valid,validationDetails:wu(e),validationErrors:e.validationMessage?[e.validationMessage]:[]}}function Cu(e){for(let t=0;tYour library
RecordlyLibraryYour recordings
RecordlyShared recordingYour library
+ Your recordings
+ {
+ e.currentTarget.style.visibility = 'hidden';
+ }}
+ />
+
Start the conversation
+ Recording
+ Summary
+ Chapters
+ {video.title}
+ {title}
+ Recording unavailable
+ Couldn’t load the recording
+
+ Recordly
+
+ );
+}
+
+export function Header({ title, library = false }: { title?: string; library?: boolean }) {
+ return (
+ at 0:05, 1:02:03 and 9:59.';
+ const parts = timestampParts(text, 100);
+ assert.equal(parts.map((p) => p.text).join(''), text);
+ assert.deepEqual(
+ parts.filter((p) => p.time !== undefined),
+ [{ text: '0:05', time: 5 }],
+ );
+ assert.deepEqual(timestampParts('1:02:03', 4000), [{ text: '1:02:03', time: 3723 }]);
+ assert.deepEqual(timestampParts('0:05', 0), [{ text: '0:05' }]);
+});
+
+test('reaction clusters retain every emoji and timestamp, separating distant moments', () => {
+ const items = [
+ { timestamp: 12, emoji: '🔥', created_at: '3' },
+ { timestamp: 2, emoji: '❤️', created_at: '1' },
+ { timestamp: 2.1, emoji: '👍', created_at: '2' },
+ ];
+ const groups = clusterReactions(items, 18, 350);
+ assert.deepEqual(groups.map(group => group.members.map(item => item.emoji)), [['❤️', '👍'], ['🔥']]);
+ assert.equal(groups[0].members[0].timestamp, 2);
+ assert.equal(items[0].timestamp, 12);
+});
+
+test('shared timeline groups overlapping comments and reactions into one numbered marker', () => {
+ const groups = clusterTimeline([
+ { timestamp: 2, kind: 'comment', label: 'Feedback' },
+ { timestamp: 2.1, kind: 'reaction', label: 'Fire', emoji: '🔥' },
+ { timestamp: 16, kind: 'reaction', label: 'Surprised', emoji: '😮' },
+ ], 18, 350);
+ assert.deepEqual(groups.map(group => group.members.length), [2, 1]);
+ assert.deepEqual(groups[0].members.map(item => item.kind), ['comment', 'reaction']);
+});
diff --git a/services/recordly-share/worker/web/src/scripts/shareModel.ts b/services/recordly-share/worker/web/src/scripts/shareModel.ts
new file mode 100644
index 000000000..c586df8a1
--- /dev/null
+++ b/services/recordly-share/worker/web/src/scripts/shareModel.ts
@@ -0,0 +1,42 @@
+import type { Comment, Reaction } from './api';
+
+/** Keep dense timestamp markers usable at every track width. */
+function clusterMarkers
{getTitle(payload, t)}
- v{payload.version.replace(/^v/, "")}
+