fuzzyworld

north-star / files / src / features / session / qr.ts

qr.ts

4.0 KB · history · blame

1import QRCode from 'qrcode';
2
3const QR_AUTO_COLLAPSE_MS = 2 * 60 * 1000;
4
5type QrContainerState = {
6 deadlineMs: number;
7 rafId: number | null;
8};
9
10const qrContainerState = new WeakMap<HTMLElement, QrContainerState>();
11
12const getQrCards = (container: HTMLElement) => Array.from(container.querySelectorAll<HTMLElement>('.room-qr-card'));
13
14const 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
21const 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
36const 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
45const 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
60export const bumpQrAutoCollapseCountdown = (container: HTMLElement | null) => {
61 if (!container) return;
62 extendCountdown(container);
63};
64
65export 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};