An open source supporter broker powered by high-fives. high-five.atprotofans.com/
at main 285 lines 8.0 kB view raw
1{{define "title"}}High-Five Room{{end}} 2 3{{define "content"}} 4<article> 5 <header> 6 <h2>High-Five Room <span id="connection-status" class="status status-offline">CONNECTING...</span></h2> 7 </header> 8 9 <div class="grid"> 10 <div> 11 <h3>People in the room (<span id="user-count">0</span>)</h3> 12 <ul id="users"> 13 <li><em>Waiting for people to join...</em></li> 14 </ul> 15 </div> 16 <div> 17 <h3>Activity</h3> 18 <ul id="activities"> 19 <li><em>No activity yet...</em></li> 20 </ul> 21 </div> 22 </div> 23 24</article> 25 26<style> 27#users, #activities { 28 max-height: 400px; 29 overflow-y: auto; 30 padding-left: 1rem; 31} 32#users li, #activities li { 33 margin-bottom: 0.5rem; 34} 35#users button { 36 margin-left: 0.5rem; 37 padding: 0.25rem 0.5rem; 38 font-size: 0.875rem; 39} 40.activity-time { 41 color: var(--pico-muted-color); 42 font-size: 0.75rem; 43} 44</style> 45{{end}} 46 47{{define "scripts"}} 48<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js"></script> 49<script> 50const myDID = "{{.Session.DID | js}}"; 51const myHandle = "{{.Session.Handle | js}}"; 52let ws = null; 53let users = new Map(); // DID -> {did, handle} 54let isReconnect = false; 55let activityCount = 0; 56let sessionExpired = false; 57 58function connect() { 59 const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 60 let wsUrl = protocol + '//' + window.location.host + '/ws'; 61 if (isReconnect) { 62 wsUrl += '?reconnect=true'; 63 } 64 ws = new WebSocket(wsUrl); 65 66 ws.onopen = function() { 67 document.getElementById('connection-status').textContent = 'CONNECTED'; 68 document.getElementById('connection-status').className = 'status status-live'; 69 if (!isReconnect) { 70 addActivity('Connected to the high-five room'); 71 } 72 }; 73 74 ws.onmessage = function(event) { 75 const msg = JSON.parse(event.data); 76 handleMessage(msg); 77 }; 78 79 ws.onclose = function() { 80 document.getElementById('connection-status').textContent = 'DISCONNECTED'; 81 document.getElementById('connection-status').className = 'status status-offline'; 82 if (!sessionExpired) { 83 addActivity('Disconnected - reconnecting...'); 84 isReconnect = true; 85 setTimeout(connect, 2000); 86 } 87 }; 88 89 ws.onerror = function(err) { 90 console.error('WebSocket error:', err); 91 }; 92} 93 94function handleMessage(msg) { 95 switch (msg.type) { 96 case 'identity_joined': 97 addUser(msg.payload.did, msg.payload.handle); 98 addActivity('@' + msg.payload.handle + ' joined the room'); 99 break; 100 101 case 'identity_left': 102 removeUser(msg.payload.did); 103 addActivity('@' + msg.payload.handle + ' left the room'); 104 break; 105 106 case 'populate_room': 107 const identities = msg.payload.identities || []; 108 identities.forEach(u => addUser(u.did, u.handle)); 109 break; 110 111 case 'high_five_event': 112 addActivity('@' + msg.payload.from.handle + ' gave @' + msg.payload.to.handle + ' a high-five!'); 113 fireHighFiveConfetti(); 114 break; 115 116 case 'error': 117 addActivity('Error: ' + msg.payload.error); 118 break; 119 120 case 'session_timeout': 121 handleSessionTimeout(msg.payload.message); 122 break; 123 124 default: 125 console.log('Unhandled message type:', msg.type, msg); 126 } 127} 128 129function addUser(did, handle) { 130 if (!did || did === myDID) return; // Don't show self 131 if (users.has(did)) return; 132 133 users.set(did, {did, handle}); 134 renderUsers(); 135} 136 137function removeUser(did) { 138 users.delete(did); 139 renderUsers(); 140} 141 142function renderUsers() { 143 const list = document.getElementById('users'); 144 const count = users.size; 145 document.getElementById('user-count').textContent = count; 146 147 if (count === 0) { 148 list.innerHTML = '<li><em>Waiting for people to join...</em></li>'; 149 return; 150 } 151 152 list.innerHTML = ''; 153 users.forEach((user, did) => { 154 const li = document.createElement('li'); 155 const span = document.createElement('span'); 156 span.textContent = '@' + user.handle; 157 158 const button = document.createElement('button'); 159 button.textContent = 'High Five!'; 160 button.addEventListener('click', () => sendHighFive(did)); 161 162 li.appendChild(span); 163 li.appendChild(document.createTextNode(' ')); 164 li.appendChild(button); 165 list.appendChild(li); 166 }); 167} 168 169function sendHighFive(subjectDID) { 170 if (!ws || ws.readyState !== WebSocket.OPEN) { 171 addActivity('Error: Not connected'); 172 return; 173 } 174 ws.send(JSON.stringify({type: 'high_five', payload: {subject: subjectDID}})); 175} 176 177// Fire confetti with raising hands and tada emoji when a high-five occurs 178function fireHighFiveConfetti() { 179 const emojis = ['\u{1F64C}', '\u{1F389}']; // Raising hands and tada 180 const defaults = { 181 spread: 360, 182 ticks: 100, 183 gravity: 0.5, 184 decay: 0.94, 185 startVelocity: 30, 186 colors: ['#ff6b6b', '#feca57', '#48dbfb', '#ff9ff3', '#54a0ff'] 187 }; 188 189 // Fire emoji confetti from multiple positions 190 function fireEmoji() { 191 confetti({ 192 ...defaults, 193 particleCount: 30, 194 scalar: 2, 195 shapes: ['emoji'], 196 shapeOptions: { 197 emoji: { 198 value: emojis 199 } 200 }, 201 origin: { x: Math.random(), y: Math.random() - 0.2 } 202 }); 203 } 204 205 // Fire regular confetti burst 206 function fireBurst() { 207 confetti({ 208 ...defaults, 209 particleCount: 50, 210 origin: { x: 0.5, y: 0.5 } 211 }); 212 } 213 214 // Sequence of effects 215 fireBurst(); 216 setTimeout(fireEmoji, 100); 217 setTimeout(fireEmoji, 200); 218 setTimeout(fireEmoji, 300); 219} 220 221function handleSessionTimeout(message) { 222 sessionExpired = true; 223 if (ws) { 224 ws.close(); 225 } 226 227 // Update status 228 document.getElementById('connection-status').textContent = 'SESSION EXPIRED'; 229 document.getElementById('connection-status').className = 'status status-offline'; 230 231 // Clear users list 232 users.clear(); 233 renderUsers(); 234 235 // Show session expired modal 236 showSessionExpiredModal(message); 237} 238 239function showSessionExpiredModal(message) { 240 // Create modal overlay 241 const overlay = document.createElement('div'); 242 overlay.id = 'session-expired-overlay'; 243 overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:1000;'; 244 245 // Create modal content 246 const modal = document.createElement('article'); 247 modal.style.cssText = 'max-width:400px;margin:1rem;text-align:center;'; 248 modal.innerHTML = '<header><h3>Session Expired</h3></header>' + 249 '<p>' + escapeHtml(message) + '</p>' + 250 '<footer><a href="/info" role="button">Start New Session</a></footer>'; 251 252 overlay.appendChild(modal); 253 document.body.appendChild(overlay); 254} 255 256function addActivity(message) { 257 const list = document.getElementById('activities'); 258 259 // Remove the "no activity" placeholder 260 if (activityCount === 0) { 261 list.innerHTML = ''; 262 } 263 264 const li = document.createElement('li'); 265 const time = new Date().toLocaleTimeString(); 266 li.innerHTML = '<span class="activity-time">[' + time + ']</span> ' + escapeHtml(message); 267 list.prepend(li); 268 activityCount++; 269 270 // Keep only last 50 activities 271 while (list.children.length > 50) { 272 list.removeChild(list.lastChild); 273 } 274} 275 276function escapeHtml(text) { 277 const div = document.createElement('div'); 278 div.textContent = text; 279 return div.innerHTML; 280} 281 282// Start connection when page loads 283connect(); 284</script> 285{{end}}