PageSourceSearch

https://pupwife.com/projects/chatroom/scripts/typingIndicator.js

js pupwife.com collected 2026-09-30 15:07:11 UTC 9,708 bytes, 298 lines download raw bytes

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.