1/** 2 * Typing Indicator 3 * Shows when other users are typing in the current channel or DM. 4 * Uses Firebase for signaling - channel-scoped (only users in same channel see it). 5 */ 6 7window.ChatroomTypingIndicator = (function() { 8 'use strict'; 9 10 const STOP_TYPING_DELAY = 3000; // ms after last keystroke before clearing 11 const WRITE_THROTTLE = 600; // min ms between Firebase writes while typing 12 13 let currentUsername = null; 14 let typingRef = null; 15 let typingListener = null; 16 let stopTypingTimer = null; 17 let lastWriteTime = 0; 18 let indicatorEl = null; 19 20 /** 21 * Get the Firebase path for typing in current context (channel or DM) 22 */ 23 function getTypingPath() { 24 const channel = window.Chatroom && window.Chatroom.getCurrentChannel ? window.Chatroom.getCurrentChannel() : null; 25 const dmOtherUser = window.ChatroomDMs && window.ChatroomDMs.getCurrentDM ? window.ChatroomDMs.getCurrentDM() : null; 26 27 if (dmOtherUser && currentUsername) { 28 const conversationId = [currentUsername, dmOtherUser].sort().join('_'); 29 return `chatroom/dms/${conversationId}/typing`; 30 } 31 if (channel) { 32 return `chatroom/channels/${channel}/typing`; 33 } 34 return null; 35 } 36 37 /** 38 * Set our typing state in Firebase 39 */ 40 function setTyping(isTyping) { 41 const path = getTypingPath(); 42 if (!path || !currentUsername || typeof firebase === 'undefined' || !firebase.database) return; 43 44 const ref = firebase.database().ref(path).child(currentUsername); 45 if (isTyping) { 46 ref.set(true); 47 ref.onDisconnect().set(false); 48 } else { 49 ref.set(false); 50 ref.onDisconnect().cancel(); 51 } 52 } 53 54 /** 55 * Called when user types - throttled to avoid excessive writes 56 */ 57 function onUserTyping() { 58 if (!currentUsername) return; 59 60 const path = getTypingPath(); 61 if (!path) return; 62 63 const now = Date.now(); 64 if (now - lastWriteTime < WRITE_THROTTLE) { 65 // Throttled - just reset the stop timer 66 resetStopTypingTimer(); 67 return; 68 } 69 lastWriteTime = now; 70 setTyping(true); 71 resetStopTypingTimer(); 72 } 73 74 /** 75 * Called when user stops typing or sends - clear typing state 76 */ 77 function clearTyping() { 78 if (!currentUsername) return; 79 setTyping(false); 80 if (stopTypingTimer) { 81 clearTimeout(stopTypingTimer); 82 stopTypingTimer = null; 83 } 84 } 85 86 function resetStopTypingTimer() { 87 if (stopTypingTimer) clearTimeout(stopTypingTimer); 88 stopTypingTimer = setTimeout(() => { 89 clearTyping(); 90 stopTypingTimer = null; 91 }, STOP_TYPING_DELAY); 92 } 93 94 /** 95 * Subscribe to typing updates for current channel/DM 96 */ 97 function subscribeToTyping() { 98 unsubscribeFromTyping(); 99 100 const path = getTypingPath(); 101 if (!path || typeof firebase === 'undefined' || !firebase.database) return; 102 103 typingRef = firebase.database().ref(path); 104 typingListener = typingRef.on('value', (snapshot) => { 105 updateDisplay(snapshot.val()); 106 }); 107 } 108 109 /** 110 * Unsubscribe from previous typing ref 111 */ 112 function unsubscribeFromTyping() { 113 if (typingRef && typingListener) { 114 typingRef.off('value', typingListener); 115 } 116 typingRef = null; 117 typingListener = null; 118 } 119 120 function getAvatarEmoji(username) { 121 if (!username) return 'ð¾'; 122 const avatars = ['ð¾', 'ð¶', 'ð', 'ð¦´', 'ð¾', 'ð§¸', 'â', 'ð«', 'â¨', 'ð']; 123 const hash = String(username).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); 124 return avatars[hash % avatars.length]; 125 } 126 127 function escapeHtml(text) { 128 const div = document.createElement('div'); 129 div.textContent = text; 130 return div.innerHTML; 131 } 132 133 /** 134 * Update the typing indicator display (Discord-style: dots + avatar + "X is typing...") 135 */ 136 function updateDisplay(typingData) { 137 ensureIndicatorElement(); 138 if (!indicatorEl) return; 139 140 if (!typingData || typeof typingData !== 'object') { 141 indicatorEl.innerHTML = ''; 142 indicatorEl.style.display = 'none'; 143 return; 144 } 145 146 const typers = Object.entries(typingData) 147 .filter(([username, val]) => val === true && username !== currentUsername); 148 149 if (typers.length === 0) { 150 indicatorEl.innerHTML = ''; 151 indicatorEl.style.display = 'none'; 152 return; 153 } 154 155 const getProfilePic = window.ChatroomProfilePicture && window.ChatroomProfilePicture.getProfilePicture; 156 const avatarsHtml = typers.slice(0, 3).map(([username]) => { 157 const pic = getProfilePic ? getProfilePic(username) : null; 158 const avatarDisplay = pic 159 ? `<img src="${pic}" alt="${escapeHtml(username)}" class="typing-indicator-avatar">` 160 : `<span class="typing-indicator-avatar typing-indicator-avatar-emoji">${getAvatarEmoji(username)}</span>`;
161 return avatarDisplay; 162 }).join(''); 163 164 let text = typers.length === 1 165 ? `${escapeHtml(typers[0][0])} is typing...` 166 : typers.length === 2 167 ? `${escapeHtml(typers[0][0])} and ${escapeHtml(typers[1][0])} are typing...` 168 : `${typers.length} people are typing...`; 169 170 indicatorEl.innerHTML = ` 171 <span class="typing-indicator-dots"> 172 <span class="typing-dot"></span> 173 <span class="typing-dot"></span> 174 <span class="typing-dot"></span> 175 </span> 176 <span class="typing-indicator-avatars">${avatarsHtml}</span> 177 <span class="typing-indicator-text">${text}</span> 178 `; 179 indicatorEl.style.display = 'flex'; 180 } 181 182 function ensureIndicatorElement() { 183 if (indicatorEl) return indicatorEl; 184 185 indicatorEl = document.getElementById('typing-indicator'); 186 if (indicatorEl) return indicatorEl; 187 188 const slot = document.querySelector('.typing-indicator-slot'); 189 if (!slot) return null; 190 191 indicatorEl = document.createElement('div'); 192 indicatorEl.id = 'typing-indicator'; 193 indicatorEl.className = 'typing-indicator'; 194 indicatorEl.setAttribute('aria-live', 'polite'); 195 slot.appendChild(indicatorEl); 196 return indicatorEl; 197 } 198 199 let lastContextPath = null; 200 201 /** 202 * Setup - call when context changes (channel switch, DM open/close) 203 */ 204 function setup() { 205 const path = getTypingPath(); 206 if (path !== lastContextPath) { 207 // Switching context - clear our typing in the OLD path so we don't appear typing there 208 if (lastContextPath && currentUsername && typeof firebase !== 'undefined' && firebase.database) { 209 firebase.database().ref(lastContextPath).child(currentUsername).set(false); 210 } 211 lastContextPath = path; 212 if (stopTypingTimer) { 213 clearTimeout(stopTypingTimer); 214 stopTypingTimer = null; 215 } 216 unsubscribeFromTyping(); 217 } 218 subscribeToTyping(); 219 } 220 221 /** 222 * Initialize typing indicator 223 */ 224 function init() { 225 // Wait for username 226 const checkReady = setInterval(() => { 227 const username = window.__sharedUsername ? window.__sharedUsername.get() : null; 228 if (username && !username.startsWith('guest_')) { 229 currentUsername = username; 230 clearInterval(checkReady); 231 setupListeners(); 232 setup(); 233 startContextPolling(); 234 } 235 }, 100); 236 237 setTimeout(() => { 238 clearInterval(checkReady); 239 if (!currentUsername && window.__sharedUsername) { 240 currentUsername = window.__sharedUsername.get(); 241 if (currentUsername && !currentUsername.startsWith('guest_')) { 242 setupListeners(); 243 setup(); 244 startContextPolling(); 245 } 246 } 247 }, 5000); 248 } 249 250 /** 251 * Poll for context changes (channel/DM switch) and re-setup when it changes 252 */ 253 function startContextPolling() { 254 setInterval(() => { 255 const path = getTypingPath(); 256 if (path !== lastContextPath && currentUsername) { 257 setup(); 258 } 259 }, 400); 260 } 261 262 /** 263 * Hook into message input 264 */ 265 function setupListeners() { 266 const messageInput = document.getElementById('message-input'); 267 if (!messageInput) return; 268 269 messageInput.addEventListener('input', () => { 270 if (messageInput.value.trim().length > 0) { 271 onUserTyping(); 272 } else { 273 clearTyping(); 274 } 275 }); 276 277 messageInput.addEventListener('keydown', (e) => { 278 if (e.key === 'Enter') { 279 const enterToSend = window.ChatroomSettings?.getSetting?.('enterToSend') !== false; 280 const willSend = enterToSend ? !e.shiftKey : e.shiftKey; 281 if (willSend) { 282 clearTyping(); 283 } 284 } 285 }); 286 } 287 288 if (document.readyState === 'loading') { 289 document.addEventListener('DOMContentLoaded', init); 290 } else { 291 init(); 292 } 293 294 return { 295 setup, 296 clearTyping 297 }; 298})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.