From 3f7cbae8239845fef610683a3faf9e2ebdd0195e Mon Sep 17 00:00:00 2001 From: Anuraj Jit Saikia Date: Wed, 30 Sep 2026 10:51:00 +0530 Subject: [PATCH 1/2] feat(join): accept pasted invite links and text as meeting codes parseMeetingCode extracts the code from full links, host-less lobby paths, spaced or wrapped codes, zero-width characters, and whole invite messages. Create and join errors now show separately, and relative times no longer mislabel edge cases. --- client/src/pages/LandingPage.test.tsx | 130 +++++++++++++++++++ client/src/pages/LandingPage.tsx | 176 +++++++++++++++++--------- client/src/utils/format-time.test.ts | 50 ++++++++ client/src/utils/format-time.ts | 36 ++++-- client/src/utils/meeting-code.test.ts | 64 ++++++++++ client/src/utils/meeting-code.ts | 107 ++++++++++++++++ 6 files changed, 490 insertions(+), 73 deletions(-) create mode 100644 client/src/pages/LandingPage.test.tsx create mode 100644 client/src/utils/format-time.test.ts create mode 100644 client/src/utils/meeting-code.test.ts create mode 100644 client/src/utils/meeting-code.ts diff --git a/client/src/pages/LandingPage.test.tsx b/client/src/pages/LandingPage.test.tsx new file mode 100644 index 0000000..7e1e546 --- /dev/null +++ b/client/src/pages/LandingPage.test.tsx @@ -0,0 +1,130 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { ThemeProvider } from '@mui/material/styles'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import theme from '../theme/theme'; + +const { navigateMock, postMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), + postMock: vi.fn(), +})); + +vi.mock('react-router', async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => navigateMock, +})); + +vi.mock('../context/AuthContext', () => ({ + useAuth: () => ({ + user: { name: 'Ada Lovelace', avatar: '' }, + login: vi.fn(), + logout: vi.fn(), + }), +})); + +vi.mock('../api/axios', () => ({ + default: { post: postMock }, +})); + +vi.mock('../components/EtherealShadow', () => ({ default: () => null })); +vi.mock('../components/UpcomingMeetings', () => ({ default: () => null })); +vi.mock('../components/ScheduleMeetingDialog', () => ({ default: () => null })); + +import LandingPage from './LandingPage'; + +function renderLanding() { + return render( + + + + + , + ); +} + +beforeAll(() => { + window.matchMedia ||= vi.fn().mockImplementation((query) => ({ + matches: false, media: query, onchange: null, + addListener: vi.fn(), removeListener: vi.fn(), + addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), + })); +}); + +beforeEach(() => { + navigateMock.mockClear(); + postMock.mockReset(); +}); + +describe('LandingPage join and create', () => { + it('joins from a pasted invite link instead of keeping the query string', () => { + renderLanding(); + fireEvent.change(screen.getByRole('textbox', { name: 'Meeting code or link' }), { + target: { value: 'https://ameet.raja-dev.me/lobby/abc-defg-hij?guest=1' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Join' })); + expect(navigateMock).toHaveBeenCalledWith('/lobby/abc-defg-hij'); + }); + + it('joins when the code is typed as three words', () => { + renderLanding(); + fireEvent.change(screen.getByRole('textbox', { name: 'Meeting code or link' }), { + target: { value: 'abc defg hij' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Join' })); + expect(navigateMock).toHaveBeenCalledWith('/lobby/abc-defg-hij'); + }); + + it('explains a bad code and stays on the page', () => { + renderLanding(); + fireEvent.change(screen.getByRole('textbox', { name: 'Meeting code or link' }), { + target: { value: 'not a meeting' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Join' })); + expect(navigateMock).not.toHaveBeenCalled(); + expect(screen.getByRole('alert')).toHaveTextContent(/meeting code/i); + }); + + it('tells the user when a new meeting cannot be created', async () => { + postMock.mockRejectedValue(new Error('offline')); + renderLanding(); + fireEvent.click(screen.getByRole('button', { name: 'New meeting' })); + expect(await screen.findByRole('alert')).toHaveTextContent(/could not start/i); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it('keeps a failed create beside New meeting when the join field changes', async () => { + postMock.mockRejectedValue(new Error('offline')); + renderLanding(); + fireEvent.click(screen.getByRole('button', { name: 'New meeting' })); + expect(await screen.findByRole('alert')).toHaveTextContent(/could not start/i); + + const code = screen.getByRole('textbox', { name: 'Meeting code or link' }); + const newMeeting = screen.getByRole('button', { name: 'New meeting' }); + fireEvent.change(code, { target: { value: 'not a meeting' } }); + fireEvent.click(screen.getByRole('button', { name: 'Join' })); + + expect(screen.getByText(/could not start/i)).toBeInTheDocument(); + expect(screen.getByText(/meeting code/i)).toBeInTheDocument(); + expect(code).toHaveAttribute('aria-describedby', 'join-code-error'); + expect(newMeeting).toHaveAttribute('aria-describedby', 'create-meeting-error'); + expect(code).not.toHaveAccessibleDescription(/could not start/i); + + fireEvent.change(code, { target: { value: 'not a meetin' } }); + + expect(screen.queryByText(/meeting code/i)).not.toBeInTheDocument(); + expect(screen.getByText(/could not start/i)).toBeInTheDocument(); + expect(code).not.toHaveAttribute('aria-describedby'); + expect(newMeeting).toHaveAttribute('aria-describedby', 'create-meeting-error'); + expect(code).not.toHaveAccessibleDescription(/could not start/i); + }); + + it('opens the room after a meeting is created', async () => { + postMock.mockResolvedValue({ data: { roomId: 'abc-defg-hij' } }); + renderLanding(); + fireEvent.click(screen.getByRole('button', { name: 'New meeting' })); + await waitFor(() => expect(navigateMock).toHaveBeenCalledWith( + '/room/abc-defg-hij', + { state: { fromCreate: true } }, + )); + }); +}); diff --git a/client/src/pages/LandingPage.tsx b/client/src/pages/LandingPage.tsx index fbb9554..d304b35 100644 --- a/client/src/pages/LandingPage.tsx +++ b/client/src/pages/LandingPage.tsx @@ -27,6 +27,7 @@ import EtherealShadow from "../components/EtherealShadow"; import ScheduleMeetingDialog from "../components/ScheduleMeetingDialog"; import UpcomingMeetings from "../components/UpcomingMeetings"; import type { RoomMetadataDto, ScheduledMeetingDto } from "@a-meet/contracts"; +import { parseMeetingCode } from "../utils/meeting-code"; // Warm graphite + ember + dusty sage. Smoke, not glow. Shared with the lobby. import { DK } from "../theme/theme"; @@ -55,6 +56,16 @@ const primaryBtn = { }, }; +const fieldErrorSx = { + maxWidth: 440, + mt: 1, + textAlign: "center", + fontFamily: DK.font, + fontSize: 13.5, + lineHeight: 1.45, + color: "#ffb4a8", +}; + // framer-motion entrance: a staggered rise for the hero column. const container = { hidden: {}, @@ -69,6 +80,8 @@ export default function LandingPage() { const { user, login, logout } = useAuth(); const navigate = useNavigate(); const [joinCode, setJoinCode] = useState(""); + const [joinError, setJoinError] = useState(""); + const [createError, setCreateError] = useState(""); const [creating, setCreating] = useState(false); const [scheduleOpen, setScheduleOpen] = useState(false); const [editingMeeting, setEditingMeeting] = useState(null); @@ -77,6 +90,8 @@ export default function LandingPage() { async function handleNewMeeting() { setCreating(true); + setCreateError(""); + setJoinError(""); try { const { data } = await api.post("/rooms"); // The creator instant-joins (Google-Meet behaviour) — straight into the @@ -84,6 +99,8 @@ export default function LandingPage() { // RoomGuard uses to allow this; anyone opening the shared link later (even // this same account in another browser) has no marker and hits the lobby. navigate(`/room/${data.roomId}`, { state: { fromCreate: true } }); + } catch { + setCreateError("Could not start the meeting. Check your connection and try again."); } finally { setCreating(false); } @@ -107,11 +124,14 @@ export default function LandingPage() { function handleJoin(e: FormEvent) { e.preventDefault(); - let code = joinCode.trim(); - if (!code) return; - if (code.includes("/")) code = code.split("/").filter(Boolean).pop() ?? ""; - code = code.toLowerCase().replace(/\s+/g, ""); - if (code) navigate(`/lobby/${encodeURIComponent(code)}`); + const code = parseMeetingCode(joinCode); + if (!code) { + setJoinError("That does not look like a meeting code. Use a code like abc-defg-hij, or paste the invite link."); + return; + } + setJoinError(""); + setCreateError(""); + navigate(`/lobby/${code}`); } return ( @@ -374,72 +394,104 @@ export default function LandingPage() { alignItems: "center", justifyContent: "center", }}> - - - {/* Join-by-code pill */} - - setJoinCode(e.target.value)} - autoComplete="off" - sx={{ - fontFamily: DK.font, - fontSize: 15, - color: DK.ink, - "& input::placeholder": { color: DK.faint, opacity: 1 }, - }} - /> + {createError && ( + + {createError} + + )} + + + {/* Join-by-code pill */} + + - Join - + + { + setJoinCode(e.target.value); + if (joinError) setJoinError(""); + }} + autoComplete="off" + inputProps={{ + autoCapitalize: "none", + autoCorrect: "off", + spellCheck: false, + "aria-label": "Meeting code or link", + "aria-invalid": joinError ? true : undefined, + "aria-describedby": joinError ? "join-code-error" : undefined, + }} + sx={{ + fontFamily: DK.font, + fontSize: 15, + color: DK.ink, + "& input::placeholder": { color: DK.faint, opacity: 1 }, + }} + /> + + + {joinError && ( + + {joinError} + + )} diff --git a/client/src/utils/format-time.test.ts b/client/src/utils/format-time.test.ts new file mode 100644 index 0000000..48cb972 --- /dev/null +++ b/client/src/utils/format-time.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { dayLabel, formatMeetingTime, relativeTime, timeLabel } from './format-time'; + +const now = new Date(2026, 5, 2, 15, 0, 0); + +describe('relativeTime', () => { + it('says a meeting inside the next minute is starting now', () => { + expect(relativeTime(new Date(2026, 5, 2, 15, 0, 30), now)).toBe('Starting now'); + }); + + it('does not call a meeting that already started "Starting now"', () => { + expect(relativeTime(new Date(2026, 5, 2, 14, 59, 30), now)).toBe('Just started'); + }); + + it('keeps sub-hour meetings in minutes', () => { + expect(relativeTime(new Date(2026, 5, 2, 15, 20, 0), now)).toBe('in 20 min'); + expect(relativeTime(new Date(2026, 5, 2, 14, 40, 0), now)).toBe('20 min ago'); + }); + + it('counts whole hours so 90 minutes is not reported as 2 hours', () => { + expect(relativeTime(new Date(2026, 5, 2, 16, 30, 0), now)).toBe('in 1 hour'); + expect(relativeTime(new Date(2026, 5, 2, 13, 30, 0), now)).toBe('1 hour ago'); + expect(relativeTime(new Date(2026, 5, 2, 17, 0, 0), now)).toBe('in 2 hours'); + }); + + it('counts whole days so 36 hours is not reported as 2 days', () => { + expect(relativeTime(new Date(2026, 5, 4, 3, 0, 0), now)).toBe('in 1 day'); + expect(relativeTime(new Date(2026, 5, 5, 15, 0, 0), now)).toBe('in 3 days'); + expect(relativeTime(new Date(2026, 4, 30, 15, 0, 0), now)).toBe('3 days ago'); + }); + + it('returns an empty hint for an unreadable time', () => { + expect(relativeTime('not-a-date', now)).toBe(''); + }); +}); + +describe('meeting clock labels', () => { + it('labels today, tomorrow, and another day', () => { + expect(dayLabel(new Date(2026, 5, 2, 9, 0, 0), now)).toBe('Today'); + expect(dayLabel(new Date(2026, 5, 3, 9, 0, 0), now)).toBe('Tomorrow'); + expect(dayLabel(new Date(2026, 5, 1, 9, 0, 0), now)).toBe('Yesterday'); + }); + + it('combines the day label with the local time', () => { + const at = new Date(2026, 5, 2, 15, 0, 0); + const clock = at.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + expect(timeLabel(at)).toBe(clock); + expect(formatMeetingTime(at, now)).toBe(`Today, ${clock}`); + }); +}); diff --git a/client/src/utils/format-time.ts b/client/src/utils/format-time.ts index e523c0c..8821e60 100644 --- a/client/src/utils/format-time.ts +++ b/client/src/utils/format-time.ts @@ -37,19 +37,33 @@ export function formatMeetingTime(date: DateInput, now = new Date()): string { return `${dayLabel(date, now)}, ${timeLabel(date)}`; } -// Relative hint: "in 20 min" / "in 2 hours" / "3 days ago" / "Starting now". +// Relative hint: "in 20 min" / "in 1 hour" / "3 days ago" / "Starting now". +// Hours and days are whole units already elapsed or remaining, so 90 minutes +// reads "in 1 hour" (not "in 2 hours") and a meeting that already started is +// never described as "Starting now". export function relativeTime(date: DateInput, now = new Date()): string { - const ms = new Date(date).getTime() - now.getTime(); + const target = new Date(date).getTime(); + if (Number.isNaN(target)) return ''; + const ms = target - now.getTime(); const abs = Math.abs(ms); - if (abs < 60_000) return 'Starting now'; - const mins = Math.round(abs / 60_000); - const hrs = Math.round(abs / 3_600_000); - const days = Math.round(abs / MS_PER_DAY); - let phrase; - if (mins < 60) phrase = `${mins} min`; - else if (hrs < 24) phrase = `${hrs} hour${hrs === 1 ? '' : 's'}`; - else phrase = `${days} day${days === 1 ? '' : 's'}`; - return ms >= 0 ? `in ${phrase}` : `${phrase} ago`; + const ahead = ms >= 0; + const wrap = (phrase: string) => (ahead ? `in ${phrase}` : `${phrase} ago`); + + if (abs < 60_000) return ahead ? 'Starting now' : 'Just started'; + + if (abs < 3_600_000) { + const minutes = Math.max(1, Math.round(abs / 60_000)); + if (minutes < 60) return wrap(`${minutes} min`); + return wrap('1 hour'); + } + + if (abs < MS_PER_DAY) { + const hours = Math.floor(abs / 3_600_000); + return wrap(`${hours} hour${hours === 1 ? '' : 's'}`); + } + + const days = Math.floor(abs / MS_PER_DAY); + return wrap(`${days} day${days === 1 ? '' : 's'}`); } // Value for a native , which wants local wall-clock diff --git a/client/src/utils/meeting-code.test.ts b/client/src/utils/meeting-code.test.ts new file mode 100644 index 0000000..0c0b66c --- /dev/null +++ b/client/src/utils/meeting-code.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { buildInviteText } from './calendar-invite'; +import { parseMeetingCode } from './meeting-code'; + +describe('parseMeetingCode', () => { + it('accepts a canonical code and ignores case and surrounding space', () => { + expect(parseMeetingCode('abc-defg-hij')).toBe('abc-defg-hij'); + expect(parseMeetingCode(' ABC-DEFG-HIJ ')).toBe('abc-defg-hij'); + }); + + it('accepts the three blocks separated by spaces', () => { + expect(parseMeetingCode('abc defg hij')).toBe('abc-defg-hij'); + expect(parseMeetingCode('abc defg hij')).toBe('abc-defg-hij'); + }); + + it('pulls the code out of a lobby or room link, dropping the query and hash', () => { + expect(parseMeetingCode('https://ameet.raja-dev.me/lobby/abc-defg-hij?guest=1')).toBe('abc-defg-hij'); + expect(parseMeetingCode('https://ameet.raja-dev.me/room/ABC-DEFG-HIJ#join')).toBe('abc-defg-hij'); + expect(parseMeetingCode('http://localhost:5173/lobby/abc-defg-hij/')).toBe('abc-defg-hij'); + expect(parseMeetingCode('ameet.raja-dev.me/lobby/abc-defg-hij')).toBe('abc-defg-hij'); + expect(parseMeetingCode('abc-defg-hij?guest=1')).toBe('abc-defg-hij'); + }); + + it('accepts an invite link wrapped in angle brackets', () => { + expect(parseMeetingCode('')).toBe('abc-defg-hij'); + }); + + it('accepts a quoted invite link', () => { + expect(parseMeetingCode('"https://ameet.raja-dev.me/lobby/abc-defg-hij"')).toBe('abc-defg-hij'); + expect(parseMeetingCode("'https://ameet.raja-dev.me/room/abc-defg-hij'")).toBe('abc-defg-hij'); + }); + + it('accepts a parenthesized invite link', () => { + expect(parseMeetingCode('(https://ameet.raja-dev.me/lobby/abc-defg-hij)')).toBe('abc-defg-hij'); + }); + + it('accepts an invite link with a leading zero-width space', () => { + expect(parseMeetingCode('\u200Bhttps://ameet.raja-dev.me/lobby/abc-defg-hij')).toBe('abc-defg-hij'); + }); + + it('accepts a host-less lobby or room path', () => { + expect(parseMeetingCode('lobby/abc-defg-hij')).toBe('abc-defg-hij'); + expect(parseMeetingCode('room/ABC-DEFG-HIJ')).toBe('abc-defg-hij'); + expect(parseMeetingCode('/lobby/abc-defg-hij')).toBe('abc-defg-hij'); + }); + + it('extracts the room code from a Copy-invite paste', () => { + const invite = buildInviteText({ + title: 'Team standup', + when: 'Tue, Mar 24 · 3:00 PM', + joinUrl: 'https://ameet.raja-dev.me/lobby/abc-defg-hij', + }); + expect(parseMeetingCode(invite)).toBe('abc-defg-hij'); + }); + + it('rejects text that is not a meeting code', () => { + expect(parseMeetingCode('')).toBeNull(); + expect(parseMeetingCode(' ')).toBeNull(); + expect(parseMeetingCode('abcdefghij')).toBeNull(); + expect(parseMeetingCode('not a meeting')).toBeNull(); + expect(parseMeetingCode('https://example.com/lobby/nope')).toBeNull(); + expect(parseMeetingCode('javascript:alert(1)')).toBeNull(); + }); +}); diff --git a/client/src/utils/meeting-code.ts b/client/src/utils/meeting-code.ts new file mode 100644 index 0000000..cb1910f --- /dev/null +++ b/client/src/utils/meeting-code.ts @@ -0,0 +1,107 @@ +// Google Meet-style room codes are `xxx-xxxx-xxx` (lowercase letters). The join +// field accepts that code, the same three blocks separated by spaces, or a +// pasted lobby/room link. Anything else is rejected so we never navigate to a +// lobby URL that the server will only answer with "check your meeting code". + +const ROOM_CODE = /^[a-z]{3}-[a-z]{4}-[a-z]{3}$/; + +function normalizeCode(value: string): string | null { + const compact = value + .trim() + .replace(/[?#].*$/, '') + .toLowerCase() + .replace(/[\u200B-\u200D\uFEFF]/g, '') + .replace(/^[<(["'`]+|[>)\]"'`,.;:!?]+$/g, ''); + if (ROOM_CODE.test(compact)) return compact; + const groups = compact.split(/[\s_-]+/).filter(Boolean); + if (groups.length === 3 && groups.every((part) => /^[a-z]+$/.test(part))) { + const joined = groups.join('-'); + if (ROOM_CODE.test(joined)) return joined; + } + return null; +} + +function stripDecorations(value: string): string { + return value + .trim() + .replace(/[\u200B-\u200D\uFEFF]/g, '') + .replace(/^[<(["'`]+/, '') + .replace(/[>)\]"'`,.;:!?]+$/, '') + .trim(); +} + +function isUrlAttempt(value: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(value) + || /^[\w.-]+\.[a-z]{2,}(:\d+)?\//i.test(value) + || /^(localhost|\d{1,3}(?:\.\d{1,3}){3})(:\d+)?\//i.test(value); +} + +function isPathAttempt(value: string): boolean { + return value.startsWith('/') || /^(lobby|room)\//i.test(value); +} + +function codeFromUrl(value: string): string | null { + let href = value; + if (value.startsWith('/')) href = `http://local.invalid${value}`; + else if (/^(lobby|room)\//i.test(value)) href = `http://local.invalid/${value}`; + else if (!/^[a-z][a-z0-9+.-]*:/i.test(value)) href = `https://${value}`; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + + const segments = url.pathname.split('/').filter(Boolean); + for (let i = segments.length - 1; i >= 0; i -= 1) { + const rawSegment = segments[i] ?? ''; + let segment: string; + try { + segment = decodeURIComponent(rawSegment); + } catch { + segment = rawSegment; + } + const code = normalizeCode(segment); + if (code) return code; + } + return null; +} + +/** + * When the join field is a multi-line invite (or other prose), pull out the + * first lobby/room link token, or else the first standalone room-code token. + */ +function extractEmbeddedMeetingCode(text: string): string | null { + const tokens = text.split(/\s+/).map(stripDecorations).filter(Boolean); + + for (const token of tokens) { + if (isUrlAttempt(token) || isPathAttempt(token)) { + const code = codeFromUrl(token); + if (code) return code; + } + } + + for (const token of tokens) { + const code = normalizeCode(token); + if (code) return code; + } + + return null; +} + +/** Meeting code embedded in `raw`, or null when it is not a usable code or link. */ +export function parseMeetingCode(raw: string): string | null { + // Wrappers and zero-width spaces are stripped before the URL check. Otherwise + // ``, a quoted link, or a leading U+200B is treated as + // a bare code and rejected, and `lobby/code` never reaches the path parser. + const stripped = stripDecorations(raw); + if (!stripped) return null; + if (isUrlAttempt(stripped) || isPathAttempt(stripped)) return codeFromUrl(stripped); + const asCode = normalizeCode(stripped); + if (asCode) return asCode; + + // Copy-invite paste is title / When / Join URL — whole-field parse fails, so + // scan tokens for the lobby/room link (or a bare xxx-xxxx-xxx). + return extractEmbeddedMeetingCode(stripped); +} From a2f3cdb50ed37394f1769071603f2547a3402f83 Mon Sep 17 00:00:00 2001 From: Anuraj Jit Saikia Date: Wed, 30 Sep 2026 10:51:00 +0530 Subject: [PATCH 2/2] fix(room): show the meeting link when copying fails and fix the transcript download race When the clipboard is unavailable, a dialog shows the link for manual copying instead of a clipped toast. The transcript download no longer races the object-URL revoke. The Mongo test hook timeout is raised to 180s for cold binary downloads. --- .../src/components/ShareLinkFallback.test.tsx | 36 +++++++++++ client/src/components/ShareLinkFallback.tsx | 39 ++++++++++++ client/src/pages/RoomPage.tsx | 14 +++-- client/src/utils/copy-text.test.ts | 61 +++++++++++++++++++ client/src/utils/copy-text.ts | 35 +++++++++++ client/src/utils/transcript.test.js | 39 +++++++++++- client/src/utils/transcript.ts | 8 ++- client/tsconfig.json | 8 +++ server/test/helpers/mongo.js | 8 ++- 9 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 client/src/components/ShareLinkFallback.test.tsx create mode 100644 client/src/components/ShareLinkFallback.tsx create mode 100644 client/src/utils/copy-text.test.ts create mode 100644 client/src/utils/copy-text.ts diff --git a/client/src/components/ShareLinkFallback.test.tsx b/client/src/components/ShareLinkFallback.test.tsx new file mode 100644 index 0000000..8a6671d --- /dev/null +++ b/client/src/components/ShareLinkFallback.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import { describe, expect, it, vi } from 'vitest'; +import theme from '../theme/theme'; +import ShareLinkFallback from './ShareLinkFallback'; + +const link = 'https://ameet.example/lobby/abc-defg-hij'; + +function renderFallback(value: string | null, onClose = vi.fn()) { + render( + + + , + ); + return onClose; +} + +describe('ShareLinkFallback', () => { + it('shows the full joining link when automatic copy did not work', () => { + renderFallback(link); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Meeting link' })).toHaveValue(link); + expect(screen.getByText(/blocked automatic copy/i)).toBeInTheDocument(); + }); + + it('stays closed when there is no link to show', () => { + renderFallback(null); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('closes when the user dismisses it', () => { + const onClose = renderFallback(link); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/client/src/components/ShareLinkFallback.tsx b/client/src/components/ShareLinkFallback.tsx new file mode 100644 index 0000000..a7da35e --- /dev/null +++ b/client/src/components/ShareLinkFallback.tsx @@ -0,0 +1,39 @@ +import type { FocusEvent } from 'react'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from '@mui/material'; + +interface ShareLinkFallbackProps { + link: string | null; + onClose: () => void; +} + +export default function ShareLinkFallback({ link, onClose }: ShareLinkFallbackProps) { + return ( + + Copy this meeting link + + + The browser blocked automatic copy. Select the joining link below. + + ) => event.currentTarget.select(), + }, + }} + /> + + + + + + ); +} diff --git a/client/src/pages/RoomPage.tsx b/client/src/pages/RoomPage.tsx index 432f0c8..318b826 100644 --- a/client/src/pages/RoomPage.tsx +++ b/client/src/pages/RoomPage.tsx @@ -47,6 +47,8 @@ import type { import { playSound, isSoundEnabled, toggleSound } from '../services/sounds'; import { appLogger } from '../utils/logger'; import { downloadTranscript, mergeTranscriptEntries } from '../utils/transcript'; +import { copyText } from '../utils/copy-text'; +import ShareLinkFallback from '../components/ShareLinkFallback'; const REACTION_EMOJIS = ['👍', '❤️', '😂', '😮', '👏', '🎉']; const TRANSCRIPT_CONSENT_KEY = 'ameet:transcription-consent-v2'; @@ -130,6 +132,7 @@ export default function RoomPage() { const [soundEnabled, setSoundEnabled] = useState(() => isSoundEnabled()); const [isHost, setIsHost] = useState(false); const [leaveDialogOpen, setLeaveDialogOpen] = useState(false); + const [manualLink, setManualLink] = useState(null); // Bottom-left transient flashes: join/leave, chat previews, copy confirmation. const [notes, setNotes] = useState([]); const noteIdRef = useRef(0); @@ -587,12 +590,13 @@ export default function RoomPage() { async function handleCopyLink() { const link = `${window.location.origin}/lobby/${roomId}`; - try { - await navigator.clipboard.writeText(link); + if (await copyText(link)) { + setManualLink(null); pushNote({ kind: 'event', variant: 'info', text: 'Meeting link copied' }); - } catch { - pushNote({ kind: 'event', variant: 'info', text: 'Press the link button to copy' }); + return; } + // The info toast clips long text, so a failed copy opens the link itself. + setManualLink(link); } // --- Camera tiles (shared by presentation rail) --- @@ -1487,6 +1491,8 @@ export default function RoomPage() { + setManualLink(null)} /> + {/* Host leave confirmation — non-hosts leave immediately (no dialog) */} boolean) { + const exec = vi.fn(impl); + Object.defineProperty(document, 'execCommand', { configurable: true, value: exec }); + return exec; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + Reflect.deleteProperty(document, 'execCommand'); +}); + +describe('copyText', () => { + it('writes with the async clipboard when that is allowed', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + const exec = stubExec(() => true); + + await expect(copyText(link)).resolves.toBe(true); + + expect(writeText).toHaveBeenCalledWith(link); + expect(exec).not.toHaveBeenCalled(); + }); + + it('copies from a temporary field when the clipboard API rejects', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('denied')); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + const exec = stubExec((command) => { + expect(command).toBe('copy'); + expect(document.querySelector('textarea')?.value).toBe(link); + return true; + }); + + await expect(copyText(link)).resolves.toBe(true); + expect(exec).toHaveBeenCalledOnce(); + expect(document.querySelector('textarea')).toBeNull(); + }); + + it('reports failure when neither copy path works', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }); + stubExec(() => false); + + await expect(copyText(link)).resolves.toBe(false); + expect(document.querySelector('textarea')).toBeNull(); + }); + + it('reports failure and removes the field when the fallback copy throws', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }); + stubExec(() => { + throw new Error('blocked'); + }); + + await expect(copyText(link)).resolves.toBe(false); + expect(document.querySelector('textarea')).toBeNull(); + }); +}); diff --git a/client/src/utils/copy-text.ts b/client/src/utils/copy-text.ts new file mode 100644 index 0000000..a5e089b --- /dev/null +++ b/client/src/utils/copy-text.ts @@ -0,0 +1,35 @@ +function copyWithCommand(text: string): boolean { + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.position = 'fixed'; + area.style.top = '0'; + area.style.left = '0'; + area.style.opacity = '0'; + document.body.appendChild(area); + area.focus(); + area.select(); + const command = document.execCommand; + try { + if (typeof command !== 'function') return false; + return command.call(document, 'copy'); + } catch { + return false; + } finally { + area.remove(); + } +} + +/** True when `text` reached the clipboard. Falls back when the async API is missing or denied. */ +export async function copyText(text: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // Permission denied or a non-secure context. The synchronous path below + // still works from the same user gesture in those browsers. + } + return copyWithCommand(text); +} diff --git a/client/src/utils/transcript.test.js b/client/src/utils/transcript.test.js index 6463795..ec8419f 100644 --- a/client/src/utils/transcript.test.js +++ b/client/src/utils/transcript.test.js @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest'; -import { formatTranscript, mergeTranscriptEntries } from './transcript'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { downloadTranscript, formatTranscript, mergeTranscriptEntries } from './transcript'; describe('transcript utilities', () => { it('merges snapshots and live entries into canonical server order', () => { @@ -20,4 +20,39 @@ describe('transcript utilities', () => { expect(output).toContain('Design review'); expect(output).toContain('Raja: Ship the transcript.'); }); + + it('leaves the transcript file readable until the browser can start the download', () => { + vi.useFakeTimers(); + const createObjectURL = vi.fn(() => 'blob:transcript'); + const revokeObjectURL = vi.fn(); + vi.stubGlobal('URL', class extends URL { + static createObjectURL = createObjectURL; + static revokeObjectURL = revokeObjectURL; + }); + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function mockClick() { + expect(document.body.contains(this)).toBe(true); + expect(this.download).toBe('a-meet-abc-defg-hij-transcript.txt'); + expect(this.href).toContain('blob:transcript'); + expect(revokeObjectURL).not.toHaveBeenCalled(); + }); + + downloadTranscript({ + roomId: 'abc-defg-hij', + entries: [], + }); + + expect(click).toHaveBeenCalledOnce(); + expect(document.querySelector('a[download]')).toBeNull(); + expect(revokeObjectURL).not.toHaveBeenCalled(); + vi.advanceTimersByTime(0); + expect(revokeObjectURL).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1000); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:transcript'); + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); diff --git a/client/src/utils/transcript.ts b/client/src/utils/transcript.ts index 3db5d9c..86b1103 100644 --- a/client/src/utils/transcript.ts +++ b/client/src/utils/transcript.ts @@ -51,6 +51,10 @@ export function formatTranscript({ entries, roomId, meetingTitle }: FormatTransc return `${lines.join('\n')}\n`; } +// Keep the blob URL alive past the click. Revoking on a 0ms timer races the +// browser starting the download, and Firefox/Safari then save nothing. +const TRANSCRIPT_URL_REVOKE_MS = 1000; + export function downloadTranscript(options: FormatTranscriptOptions): void { const text = formatTranscript(options); const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); @@ -58,6 +62,8 @@ export function downloadTranscript(options: FormatTranscriptOptions): void { const anchor = document.createElement('a'); anchor.href = url; anchor.download = `a-meet-${options.roomId}-transcript.txt`; + document.body.appendChild(anchor); anchor.click(); - setTimeout(() => URL.revokeObjectURL(url), 0); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), TRANSCRIPT_URL_REVOKE_MS); } diff --git a/client/tsconfig.json b/client/tsconfig.json index 7e14eae..fff174f 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -22,6 +22,13 @@ "src/utils/pending-producers.ts", "src/utils/calendar-invite.ts", "src/utils/format-time.ts", + "src/utils/format-time.test.ts", + "src/utils/meeting-code.ts", + "src/utils/meeting-code.test.ts", + "src/utils/copy-text.ts", + "src/utils/copy-text.test.ts", + "src/components/ShareLinkFallback.tsx", + "src/components/ShareLinkFallback.test.tsx", "src/utils/logger.ts", "src/utils/logger.test.ts", "src/api/axios.ts", @@ -71,6 +78,7 @@ "src/components/VideoTile.test.tsx", "src/pages/CheckMeetingCode.tsx", "src/pages/LandingPage.tsx", + "src/pages/LandingPage.test.tsx", "src/pages/LobbyPage.tsx", "src/pages/LobbyPage.test.tsx", "src/pages/RoomPage.tsx", diff --git a/server/test/helpers/mongo.js b/server/test/helpers/mongo.js index 83abe4f..b0360c2 100644 --- a/server/test/helpers/mongo.js +++ b/server/test/helpers/mongo.js @@ -18,8 +18,10 @@ import { MongoMemoryServer } from 'mongodb-memory-server'; * `mongod` binary into its cache (~/.cache/mongodb-binaries) from * fastdl.mongodb.org, then reuses it on later runs. That fetch is a *runtime* * step performed by the test, not an npm postinstall — so it is unaffected by - * CI's `npm ci --ignore-scripts` and keeps installs fast. The first CI run on - * a cold cache pays the one-time download. + * CI's `npm ci --ignore-scripts` and keeps installs fast. The first run on a + * cold cache pays the one-time download (~120MB). Vitest's default hook + * timeout is 10s, which aborts that download and fails the suite, so this + * hook allows 3 minutes. A warm cache still finishes in well under a second. */ export function useMongoMemoryServer() { let mongod; @@ -27,7 +29,7 @@ export function useMongoMemoryServer() { beforeAll(async () => { mongod = await MongoMemoryServer.create(); await mongoose.connect(mongod.getUri()); - }); + }, 180_000); afterEach(async () => { // Drop all documents between tests so state never leaks across cases,