north-star / main_old / files / src / features / session / qr.ts
Branch main_old · default branch · all branches
qr.ts
4e701dc7 2026-03-16 Pavel Safronov | 1 | import QRCode from 'qrcode'; |
36d64b37 2026-03-15 Pavel Safronov | 2 | |
1e87ff1c 2026-03-16 Pavel Safronov | 3 | const QR_AUTO_COLLAPSE_MS = 2 * 60 * 1000; |
| 4 | ||
| 5 | type QrContainerState = { | |
| 6 | deadlineMs: number; | |
| 7 | rafId: number | null; | |
| 8 | }; | |
| 9 | ||
| 10 | const qrContainerState = new WeakMap<HTMLElement, QrContainerState>(); | |
| 11 | ||
| 12 | const getQrCards = (container: HTMLElement) => Array.from(container.querySelectorAll<HTMLElement>('.room-qr-card')); | |
| 13 | ||
| 14 | const setCardsCollapsed = (container: HTMLElement, collapsed: boolean) => { | |
| 15 | getQrCards(container).forEach((qrCard) => { | |
| 16 | qrCard.classList.toggle('is-collapsed', collapsed); | |
| 17 | qrCard.setAttribute('aria-expanded', String(!collapsed)); | |
| 18 | }); | |
| 19 | }; | |
| 20 | ||
| 21 | const runCountdownTick = (container: HTMLElement) => { | |
| 22 | const state = qrContainerState.get(container); | |
| 23 | if (!state) return; | |
| 24 | ||
| 25 | if (Date.now() >= state.deadlineMs) { | |
| 26 | setCardsCollapsed(container, true); | |
| 27 | state.rafId = null; | |
| 28 | return; | |
| 29 | } | |
| 30 | ||
| 31 | state.rafId = requestAnimationFrame(() => { | |
| 32 | runCountdownTick(container); | |
| 33 | }); | |
| 34 | }; | |
| 35 | ||
| 36 | const ensureCountdownLoop = (container: HTMLElement) => { | |
| 37 | const state = qrContainerState.get(container); | |
| 38 | if (!state) return; | |
| 39 | if (state.rafId !== null) return; | |
| 40 | state.rafId = requestAnimationFrame(() => { | |
| 41 | runCountdownTick(container); | |
| 42 | }); | |
| 43 | }; | |
| 44 | ||
| 45 | const extendCountdown = (container: HTMLElement) => { | |
| 46 | const existing = qrContainerState.get(container); | |
| 47 | if (existing) { | |
| 48 | existing.deadlineMs = Date.now() + QR_AUTO_COLLAPSE_MS; | |
| 49 | ensureCountdownLoop(container); | |
| 50 | return; | |
| 51 | } | |
| 52 | ||
| 53 | qrContainerState.set(container, { | |
| 54 | deadlineMs: Date.now() + QR_AUTO_COLLAPSE_MS, | |
| 55 | rafId: null | |
| 56 | }); | |
| 57 | ensureCountdownLoop(container); | |
| 58 | }; | |
| 59 | ||
| 60 | export const bumpQrAutoCollapseCountdown = (container: HTMLElement | null) => { | |
| 61 | if (!container) return; | |
| 62 | extendCountdown(container); | |
| 63 | }; | |
| 64 | ||
36d64b37 2026-03-15 Pavel Safronov | 65 | export const renderInviteQrCard = async ({ |
| 66 | container, | |
| 67 | title, | |
| 68 | url, | |
| 69 | statusText, | |
| 70 | roomId | |
b4e7f682 2026-03-16 Pavel Safronov | 71 | }: { |
| 72 | container: HTMLElement | null; | |
| 73 | title: string; | |
| 74 | url: string; | |
| 75 | statusText: string; | |
| 76 | roomId: string; | |
36d64b37 2026-03-15 Pavel Safronov | 77 | }) => { |
| 78 | if (!container) return; | |
| 79 | ||
1e87ff1c 2026-03-16 Pavel Safronov | 80 | extendCountdown(container); |
| 81 | ||
36d64b37 2026-03-15 Pavel Safronov | 82 | const card = document.createElement('div'); |
| 83 | card.className = 'room-qr-card'; | |
| 84 | card.setAttribute('aria-label', title ? `${title} invite QR code` : 'Invite QR code'); | |
1e87ff1c 2026-03-16 Pavel Safronov | 85 | card.setAttribute('role', 'button'); |
| 86 | card.setAttribute('tabindex', '0'); | |
| 87 | card.setAttribute('aria-expanded', 'true'); | |
36d64b37 2026-03-15 Pavel Safronov | 88 | |
| 89 | if (title) { | |
| 90 | const label = document.createElement('p'); | |
| 91 | label.className = 'room-qr-title'; | |
| 92 | label.textContent = title; | |
| 93 | card.appendChild(label); | |
| 94 | } | |
| 95 | ||
| 96 | const qrImage = document.createElement('img'); | |
| 97 | qrImage.className = 'room-qr'; | |
| 98 | qrImage.alt = title ? `${title}: scan to join room ${roomId}` : `Scan to join room ${roomId}`; | |
| 99 | card.appendChild(qrImage); | |
| 100 | ||
| 101 | const status = document.createElement('p'); | |
| 102 | status.className = 'room-link-status'; | |
| 103 | status.textContent = statusText; | |
| 104 | card.appendChild(status); | |
| 105 | ||
1e87ff1c 2026-03-16 Pavel Safronov | 106 | const collapsedLabel = document.createElement('p'); |
| 107 | collapsedLabel.className = 'room-qr-collapsed-label'; | |
f495a654 2026-03-16 Pavel Safronov | 108 | collapsedLabel.textContent = 'Invite Code'; |
1e87ff1c 2026-03-16 Pavel Safronov | 109 | card.appendChild(collapsedLabel); |
| 110 | ||
| 111 | const toggleCollapsed = () => { | |
| 112 | extendCountdown(container); | |
| 113 | const nextCollapsed = !card.classList.contains('is-collapsed'); | |
| 114 | setCardsCollapsed(container, nextCollapsed); | |
| 115 | }; | |
| 116 | ||
| 117 | card.addEventListener('click', () => { | |
| 118 | toggleCollapsed(); | |
| 119 | }); | |
| 120 | ||
| 121 | card.addEventListener('keydown', (event) => { | |
| 122 | if (event.key !== 'Enter' && event.key !== ' ') return; | |
| 123 | event.preventDefault(); | |
| 124 | toggleCollapsed(); | |
| 125 | }); | |
| 126 | ||
36d64b37 2026-03-15 Pavel Safronov | 127 | container.appendChild(card); |
| 128 | ||
| 129 | try { | |
| 130 | const qrDataUrl = await QRCode.toDataURL(url, { | |
| 131 | width: 400, | |
| 132 | margin: 4, | |
| 133 | errorCorrectionLevel: 'H', | |
| 134 | color: { | |
| 135 | dark: '#0f172a', | |
| 136 | light: '#ffffff' | |
| 137 | } | |
| 138 | }); | |
| 139 | qrImage.src = qrDataUrl; | |
| 140 | } catch (err) { | |
| 141 | console.error('Failed to generate invite QR code', err); | |
| 142 | status.textContent = 'Failed to create QR'; | |
| 143 | status.classList.add('error'); | |
| 144 | } | |
| 145 | }; |