north-star / main_old / files / src / features / session / qr.ts
Branch main_old · default branch · all branches
qr.ts
| 1 | import QRCode from 'qrcode'; |
| 2 | |
| 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 | |
| 65 | export const renderInviteQrCard = async ({ |
| 66 | container, |
| 67 | title, |
| 68 | url, |
| 69 | statusText, |
| 70 | roomId |
| 71 | }: { |
| 72 | container: HTMLElement | null; |
| 73 | title: string; |
| 74 | url: string; |
| 75 | statusText: string; |
| 76 | roomId: string; |
| 77 | }) => { |
| 78 | if (!container) return; |
| 79 | |
| 80 | extendCountdown(container); |
| 81 | |
| 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'); |
| 85 | card.setAttribute('role', 'button'); |
| 86 | card.setAttribute('tabindex', '0'); |
| 87 | card.setAttribute('aria-expanded', 'true'); |
| 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 | |
| 106 | const collapsedLabel = document.createElement('p'); |
| 107 | collapsedLabel.className = 'room-qr-collapsed-label'; |
| 108 | collapsedLabel.textContent = 'Invite Code'; |
| 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 | |
| 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 | }; |