PageSourceSearch

https://obsidianscout.com/js/chat.js

js obsidianscout.com collected 2026-10-03 20:09:53 UTC 46,452 bytes, 1,084 lines download raw bytes

1document.addEventListener("DOMContentLoaded", async () => {
2    Obsidianscout.initTheme();
3    const me = await Obsidianscout.requireAuth();
4    if (!me) {
5        return;
6    }
7
8    Obsidianscout.setUserBadge(me);
9    Obsidianscout.setActiveNav();
10    Obsidianscout.adjustNavForRole(me);
11    Obsidianscout.wireLogout();
12    Obsidianscout.wireThemeToggle();
13
14    // Start with no group selected — will be set after loadGroups() resolves
15    let currentGroup = "";
16    const urlParams = new URLSearchParams(window.location.search);
17    const initialGroupParam = urlParams.get("group");
18    if (initialGroupParam && initialGroupParam.trim() !== "") {
19        currentGroup = initialGroupParam.trim();
20    }
21
22    if (navigator.serviceWorker) {
23        navigator.serviceWorker.addEventListener("message", (event) => {
24            if (event.data && event.data.type === "SWITCH_GROUP" && event.data.groupName) {
25                switchGroup(event.data.groupName);
26            }
27        });
28    }
29
30    let pollInterval = null;
31    let knownGroups = [];
32    let isChatEnabled = true;
33
34    // DOM Elements
35    const chatDisabledContainer = document.getElementById("chat-disabled-container");
36    const chatActiveContainer = document.getElementById("chat-active-container");
37    const groupListContainer = document.getElementById("group-list-container");
38    const btnCreateGroup = document.getElementById("btn-create-group");
39    const currentGroupTitle = document.getElementById("current-group-title");
40    const btnChannelSettings = document.getElementById("btn-channel-settings");
41    const messageContainer = document.getElementById("message-container");
42    const chatMessageInput = document.getElementById("chat-message-input");
43    const btnSendMessage = document.getElementById("btn-send-message");
44    const btnBackChannels = document.getElementById("btn-back-channels");
45
46    // Modal elements
47    const channelSettingsModal = document.getElementById("channel-settings-modal");
48    const channelSettingsTitle = document.getElementById("channel-settings-title");
49    const btnCloseChannelSettings = document.getElementById("btn-close-channel-settings");
50    const btnCancelChannelSettings = document.getElementById("btn-cancel-channel-settings");
51    const btnSaveChannelSettings = document.getElementById("btn-save-channel-settings");
52    const btnModalClearChannel = document.getElementById("btn-modal-clear-channel");
53    const btnModalDeleteChannel = document.getElementById("btn-modal-delete-channel");
54    const channelRolesContainer = document.getElementById("channel-roles-container");
55    const channelMembersContainer = document.getElementById("channel-members-container");
56    const channelMemberSearch = document.getElementById("channel-member-search");
57    const channelDeleteHint = document.getElementById("channel-delete-hint");
58
59    let settingsTargetGroup = "";
60    let teamMembersList = [];
61    let selectedGroupRoles = [];
62    let selectedGroupUserIds = [];
63
64    // Autocomplete state
65    let mentionDropdown = null;
66    let mentionOptions = [];
67    let activeMentionIndex = 0;
68    let mentionTriggerIndex = -1;
69    let groupUnreads = {};
70
71    // Emoji reaction list
72    const emojis = ["👍", "❤️", "🔥", "😂", "😮", "😢"];
73
74    // Check settings first
75    async function checkChatSettings() {
76        try {
77            const settingsResponse = await Obsidianscout.request("/api/settings?local=true");
78            isChatEnabled = settingsResponse.settings.chatEnabled;
79            if (!isChatEnabled) {
80                chatDisabledContainer.classList.remove("hidden");
81                chatActiveContainer.classList.add("hidden");
82                stopPolling();
83                return false;
84            } else {
85                chatDisabledContainer.classList.add("hidden");
86                chatActiveContainer.classList.remove("hidden");
87                return true;
88            }
89        } catch (e) {
90            console.error("Failed to fetch settings", e);
91            return false;
92        }
93    }
94
95    async function loadGroups() {
96        try {
97            const groups = await Obsidianscout.request("/api/chat/groups");
98            knownGroups = groups && groups.length > 0 ? groups : [];
99            // If the currently selected group is not accessible, reset to first available
100            if (knownGroups.length > 0 && !knownGroups.includes(currentGroup)) {
101                currentGroup = knownGroups[0];
102            }
103            renderGroups();
104            loadGroupUnreads();
105        } catch (e) {
106            console.error("Failed to load groups", e);
107        }
108    }
109
110    async function loadGroupUnreads() {
111        try {
112            const status = await Obsidianscout.request("/api/chat/unread-status");
113            if (status && status.groups) {
114                groupUnreads = {};
115                status.groups.forEach(g => {
116                    groupUnreads[g.groupName] = {
117                        unreadCount: g.unreadCount,
118                        mentionCount: g.mentionCount
119                    };
120                });
121                renderGroups();
122            }
123        } catch (e) {
124            console.error("Failed to load group unread status:", e);
125        }
126    }
127
128    function renderGroups() {
129        groupListContainer.innerHTML = "";
130        const userRole = (me?.role || "").toUpperCase();
131        const isAdmin = userRole === "ADMIN" || userRole === "SUPERADMIN";
132
133        knownGroups.forEach(group => {
134            const item = document.createElement("div");
135            item.className = `group-item ${group === currentGroup ? "active" : ""}`;
136            
137            const labelSpan = document.createElement("span");
138            labelSpan.textContent = `# ${group}`;
139            labelSpan.style.overflow = "hidden";
140            labelSpan.style.textOverflow = "ellipsis";
141            labelSpan.style.whiteSpace = "nowrap";
142            item.appendChild(labelSpan);
143
144            const actionsContainer = document.createElement("div");
145            actionsContainer.className = "group-item-actions";
146
147            if (group !== currentGroup && groupUnreads[group]) {
148                const info = groupUnreads[group];
149                if (info.mentionCount > 0) {
150                    const badge = document.createElement("span");
151                    badge.className = "group-badge";
152                    badge.textContent = info.mentionCount;
153                    actionsContainer.appendChild(badge);
154                } else if (info.unreadCount > 0) {
155                    const dot = document.createElement("span");
156                    dot.className = "group-dot";
157                    actionsContainer.appendChild(dot);
158                }
159            }
160
161            if (isAdmin) {
162                const settingsBtn = document.createElement("button");
163                settingsBtn.className = "delete-group-btn";
164                settingsBtn.title = Obsidianscout.t("chat.channel_settings", "Channel Settings");
165                settingsBtn.setAttribute("aria-label", `Settings for #${group}`);
166                settingsBtn.innerHTML = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 
1660 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`;
167                settingsBtn.addEventListener("click", (e) => {
168                    e.stopPropagation();
169                    openChannelSettingsModal(group);
170                });
171                actionsContainer.appendChild(settingsBtn);
172
173                if (knownGroups.length > 1) {
174                    const delBtn = document.createElement("button");
175                    delBtn.className = "delete-group-btn";
176                    delBtn.title = Obsidianscout.t("chat.delete_channel", "Delete Channel");
177                    delBtn.setAttribute("aria-label", `Delete channel #${group}`);
178                    delBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>`;
179                    delBtn.addEventListener("click", (e) => {
180                        e.stopPropagation();
181                        confirmDeleteGroup(group);
182                    });
183                    actionsContainer.appendChild(delBtn);
184                }
185            }
186
187            item.appendChild(actionsContainer);
188
189            item.addEventListener("click", () => {
190                switchGroup(group);
191            });
192            groupListContainer.appendChild(item);
193        });
194    }
195
196    async function confirmDeleteGroup(group) {
197        if (knownGroups.length <= 1) {
198            alert(Obsidianscout.t("chat.cannot_delete_last", "Cannot delete the only remaining channel. At least one channel must exist."));
199            return;
200        }
201        const confirmMsg = Obsidianscout.t("chat.delete_channel_confirm", "Are you sure you want to delete the channel '#{group}'? All messages in this channel will be permanently deleted.").replace("{group}", group);
202        if (!confirm(confirmMsg)) return;
203
204        try {
205            await Obsidianscout.request(`/api/chat/groups/${encodeURIComponent(group)}`, {
206                method: "DELETE"
207            });
208            const remainingGroups = knownGroups.filter(g => g !== group);
209            if (currentGroup === group) {
210                const nextGroup = remainingGroups.length > 0 ? remainingGroups[0] : null;
211                if (nextGroup) switchGroup(nextGroup);
212            }
213            await loadGroups();
214            if (channelSettingsModal && !channelSettingsModal.classList.contains("hidden")) {
215                closeChannelSettingsModal();
216            }
217        } catch (e) {
218            console.error("Failed to delete channel:", e);
219            alert(Obsidianscout.t("chat.error_delete_channel", "Failed to delete channel."));
220        }
221    }
222
223    async function confirmClearGroup(group) {
224        const confirmMsg = Obsidianscout.t("chat.clear_messages_confirm", "Are you sure you want to clear all messages in '#{group}'? This action cannot be undone.").replace("{group}", group);
225        if (!confirm(confirmMsg)) return;
226
227        try {
228            await Obsidianscout.request(`/api/chat/groups/${encodeURIComponent(group)}/clear`, {
229                method: "POST"
230            });
231            if (currentGroup === group) {
232                lastMessagesHash = "";
233                await loadMessages();
234            }
235            if (channelSettingsModal && !channelSettingsModal.classList.contains("hidden")) {
236                closeChannelSettingsModal();
237            }
238            Obsidianscout.showToast(Obsidianscout.t("chat.messages_cleared", "Channel messages cleared."), "success");
239        } catch (e) {
240            console.error("Failed to clear channel messages:", e);
241            alert(Obsidianscout.t("chat.error_clear_channel", "Failed to clear channel messages."));
242        }
243    }
244
245    // Channel Settings Modal
246    const availableRoles = [
247        { id: "ADMIN", label: "Admin" },
248        { id: "ANALYTICS", label: "Analytics" },
249        { id: "SCOUT", label: "Scout" }
250    ];
251
252    async function openChannelSettingsModal(group) {
253        settingsTargetGroup = group;
254        channelSettingsTitle.textContent = `# ${group} - ${Obsidianscout.t("chat.channel_settings", "Channel Settings")}`;
255
256        if (knownGroups.length <= 1) {
257            btnModalDeleteChannel.disabled = true;
258            btnModalDeleteChannel.style.opacity = "0.5";
259            btnModalDeleteChannel.style.cursor = "not-allowed";
260            if (channelDeleteHint) channelDeleteHint.style.display = "block";
261        } else {
262            btnModalDeleteChannel.disabled = false;
263            btnModalDeleteChannel.style.opacity = "1";
264            btnModalDeleteChannel.style.cursor = "pointer";
265            if (channelDeleteHint) channelDeleteHint.style.display = "none";
266        }
267
268        channelRolesContainer.innerHTML = `<div style="font-size: 12px; color: var(--muted); font-style: italic;">${Obsidianscout.t("chat.loading", "Loading...")}</div>`;
269        channelMembersContainer.innerHTML = `<div style="font-size: 12px; color: var(--muted); font-style: italic;">${Obsidianscout.t("chat.loading", "Loading...")}</div>`;
270        channelMemberSearch.value = "";
271        channelSettingsModal.classList.remove("hidden");
272
273        try {
274            const [details, members] = await Promise.all([
275                Obsidianscout.request(`/api/chat/groups/${encodeURIComponent(group)}/details`),
276                Obsidianscout.request("/api/chat/team-members")
277            ]);
278            teamMembersList = members || [];
279            selectedGroupRoles = details.allowedRoles ? [...details.allowedRoles] : [];
280            selectedGroupUserIds = details.allowedUserIds ? [...details.allowedUserIds] : [];
281            renderChannelRoles();
282            renderChannelMembers();
283        } catch (e) {
284            console.error("Failed to load channel details", e);
285            channelRolesContainer.innerHTML = `<div style="color: #ef4444; font-size: 12px;">Failed to load channel settings</div>`;
286            channelMembersContainer.innerHTML = ``;
287        }
288    }
289
290    function closeChannelSettingsModal() {
291        channelSettingsModal.classList.add("hidden");
292    }
293
294    function renderChannelRoles() {
295        channelRolesContainer.innerHTML = "";
296        availableRoles.forEach(r => {
297            const label = document.createElement("label");
298            label.style.cssText = "display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--ink); padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); cursor: pointer; user-select: none;";
299            
300            const checkbox = document.createElement("input");
301            checkbox.type = "checkbox";
302            checkbox.value = r.id;
303            checkbox.checked = selectedGroupRoles.includes(r.id);
304            checkbox.addEventListener("change", () => {
305                if (checkbox.checked) {
306                    if (!selectedGroupRoles.includes(r.id)) selectedGroupRoles.push(r.id);
307                } else {
308                    selectedGroupRoles = selectedGroupRoles.filter(x => x !== r.id);
309                }
310            });
311
312            label.appendChild(checkbox);
313            label.appendChild(document.createTextNode(r.label));
314            channelRolesContainer.appendChild(label);
315        });
316    }
317
318    function renderChannelMembers() {
319        channelMembersContainer.innerHTML = "";
320        const query = channelMemberSearch.value.trim().toLowerCase();
321        const filtered = teamMembersList.filter(m => {
322            if (!query) return true;
323            return m.username.toLowerCase().includes(query) || m.role.toLowerCase().includes(query);
324        });
325
326        if (filtered.length === 0) {
327            channelMembersContainer.innerHTML = `<div style="font-size: 12px; color: var(--muted); font-style: italic; padding: 4px;">No members match search</div>`;
328            return;
329        }
330
331        filtered.forEach(m => {
332            const label = document.createElement("label");
333            label.style.cssText = "display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: var(--ink); padding: 4px 6px; border-radius: 4px; cursor: pointer; user-select: none;";
334            
335            const left = document.createElement("div");
336            left.style.cssText = "display: flex; align-items: center; gap: 8px;";
337
338            const checkbox = document.createElement("input");
339            checkbox.type = "checkbox";
340            checkbox.value = m.userId;
341            checkbox.checked = selectedGroupUserIds.includes(m.userId);
342            checkbox.addEventListener("change", () => {
343                if (checkbox.checked) {
344                    if (!selectedGroupUserIds.includes(m.userId)
344) selectedGroupUserIds.push(m.userId);
345                } else {
346                    selectedGroupUserIds = selectedGroupUserIds.filter(x => x !== m.userId);
347                }
348            });
349
350            const nameSpan = document.createElement("span");
351            nameSpan.textContent = m.username;
352
353            left.appendChild(checkbox);
354            left.appendChild(nameSpan);
355
356            const roleBadge = document.createElement("span");
357            roleBadge.style.cssText = "font-size: 10px; color: var(--muted); background: var(--surface-3); padding: 2px 6px; border-radius: 4px;";
358            roleBadge.textContent = m.role;
359
360            label.appendChild(left);
361            label.appendChild(roleBadge);
362            channelMembersContainer.appendChild(label);
363        });
364    }
365
366    if (channelMemberSearch) {
367        channelMemberSearch.addEventListener("input", renderChannelMembers);
368    }
369    if (btnCloseChannelSettings) {
370        btnCloseChannelSettings.addEventListener("click", closeChannelSettingsModal);
371    }
372    if (btnCancelChannelSettings) {
373        btnCancelChannelSettings.addEventListener("click", closeChannelSettingsModal);
374    }
375    if (btnModalClearChannel) {
376        btnModalClearChannel.addEventListener("click", () => {
377            if (settingsTargetGroup) confirmClearGroup(settingsTargetGroup);
378        });
379    }
380    if (btnModalDeleteChannel) {
381        btnModalDeleteChannel.addEventListener("click", () => {
382            if (settingsTargetGroup) confirmDeleteGroup(settingsTargetGroup);
383        });
384    }
385    if (btnSaveChannelSettings) {
386        btnSaveChannelSettings.addEventListener("click", async () => {
387            if (!settingsTargetGroup) return;
388
389            const isRestricted = selectedGroupRoles.length > 0 || selectedGroupUserIds.length > 0;
390            const hasAdminRole = selectedGroupRoles.includes("ADMIN") || selectedGroupRoles.includes("SUPERADMIN");
391            const hasAdminUser = selectedGroupUserIds.some(uid => {
392                const member = teamMembersList.find(m => m.userId === uid);
393                if (!member) return false;
394                const roleUpper = (member.role || "").toUpperCase();
395                return roleUpper === "ADMIN" || roleUpper === "SUPERADMIN";
396            });
397
398            if (isRestricted && !hasAdminRole && !hasAdminUser) {
399                Obsidianscout.showToast(Obsidianscout.t("chat.admin_required", "A channel must include either the Admin role or at least one Admin team member."), "error");
400                return;
401            }
402
403            Obsidianscout.setButtonLoading(btnSaveChannelSettings, true);
404            try {
405                await Obsidianscout.request(`/api/chat/groups/${encodeURIComponent(settingsTargetGroup)}/permissions`, {
406                    method: "PUT",
407                    json: {
408                        allowedRoles: selectedGroupRoles,
409                        allowedUserIds: selectedGroupUserIds
410                    }
411                });
412                closeChannelSettingsModal();
413                Obsidianscout.showToast(Obsidianscout.t("chat.permissions_saved", "Permissions saved successfully."), "success");
414                await loadGroups();
415            } catch (e) {
416                console.error("Failed to save channel permissions", e);
417                alert(Obsidianscout.t("chat.error_save_permissions", "Failed to save permissions."));
418            } finally {
419                Obsidianscout.setButtonLoading(btnSaveChannelSettings, false);
420            }
421        });
422    }
423    if (btnChannelSettings) {
424        btnChannelSettings.addEventListener("click", () => {
425            if (currentGroup) openChannelSettingsModal(currentGroup);
426        });
427    }
428
429    // Switch groups
430    function switchGroup(group) {
431        currentGroup = group;
432        currentGroupTitle.textContent = `# ${group}`;
433        const userRole = (me?.role || "").toUpperCase();
434        const isAdmin = userRole === "ADMIN" || userRole === "SUPERADMIN";
435        if (btnChannelSettings) {
436            btnChannelSettings.style.display = isAdmin ? "inline-flex" : "none";
437        }
438        renderGroups();
439        lastMessagesHash = "";
440        
441        // Show loading state immediately & clear out old group's chat
442        messageContainer.innerHTML = `<div style="text-align: center; color: var(--muted); margin-top: 40px; font-style: italic;">${Obsidianscout.t("chat.loading", "Loading...")}</div>`;
443        
444        // Load messages immediately & reset polling timer
445        loadMessages();
446        startPolling();
447
448        // Slide in chat on mobile
449        chatActiveContainer.classList.add("show-chat");
450    }
451
452    let lastMessagesHash = "";
453
454    // Messages loader
455    async function loadMessages() {
456        if (!isChatEnabled) return;
457        const targetGroup = currentGroup;
458        try {
459            const messages = await Obsidianscout.request(`/api/chat/messages?group=${encodeURIComponent(targetGroup)}`);
460            if (targetGroup === currentGroup) {
461                const currentHash = JSON.stringify(messages);
462                if (currentHash !== lastMessagesHash) {
463                    lastMessagesHash = currentHash;
464                    renderMessages(messages);
465                    try {
466                        await Obsidianscout.request("/api/chat/read", {
467                            method: "POST",
468                            json: { groupName: targetGroup }
469                        });
470                        window.dispatchEvent(new CustomEvent("obsidianscout:chat-read", { detail: { groupName: targetGroup } }));
471                    } catch (readErr) {
472                        console.error("Failed to mark group as read:", readErr);
473                    }
474                }
475            }
476        } catch (e) {
477            console.error("Failed to load messages", e);
478        }
479    }
480
481    let editingMessageId = null;
482
483    function renderMessages(messages) {
484        // Save scroll height to detect if we should stick scroll to bottom
485        const isScrolledToBottom = messageContainer.scrollHeight - messageContainer.clientHeight <= messageContainer.scrollTop + 50;
486
487        // Track open reaction pickers or active editing so we don't destroy them mid-action if rendering happens
488        const activePickerMsgId = document.querySelector(".reaction-picker-popover")?.closest(".message-bubble")?.dataset.msgId;
489        const activeDropdownMsgId = document.querySelector(".message-actions-dropdown")?.closest(".message-bubble")?.dataset.msgId;
490        if (activePickerMsgId || activeDropdownMsgId || editingMessageId) {
491            return; // Skip rendering this poll tick to preserve active interactions
492        }
493
494        messageContainer.innerHTML = "";
495
496        if (messages.length === 0) {
497            const noMsgText = Obsidianscout.t("chat.no_messages", "No messages yet. Say hello!");
498            messageContainer.innerHTML = `<div style="text-align: center; color: var(--muted); margin-top: 40px; font-style: italic;">${noMsgText}</div>`;
499            return;
500        }
501
502        const isAdmin = me.role === "ADMIN" || me.role === "SUPERADMIN";
503
504        messages.forEach(msg => {
505            const isMe = msg.userId === me.userId || (msg.username && msg.username.toLowerCase() === me.username.toLowerCase());
506            const initials = (msg.username || "?").slice(0, 2).toUpperCase();
507            
508            // Get avatar color
509            let hue = 0;
510            for (let i = 0; i < (msg.username || "").length; i++) {
511                hue = (hue + msg.username.charCodeAt(i) * 37) % 360;
512            }
513
514            const row = document.createElement("div");
515            row.className = `message-row ${isMe ? "me" : ""}`;
516
517            // Avatar
518            const avatar = document.createElement("div");
519            avatar.className = "avatar-placeholder";
520            if (msg.profilePicture) {
521                avatar.style.backgroundImage = `url(${msg.profilePicture})`;
522                avatar.style.backgroundSize = "cover";
523                avatar.style.backgroundPosition = "center";
524                avatar.textContent = "";
525            } else {
526                avatar.textContent = initials;
527                avatar.style.background = `hsl(${hue}, 60%, 45%)`;
528                avatar.style.backgroundImage = "none";
529            }
530            row.appendChild(avatar);
531
532            // Message Bubble
533            const bubble = document.createElement("div");
534            bubble.className = "message-bubble";
535            bubble.dataset.msgId = msg.id;
536
537            const meta = document.createElement("div");
538            meta.className = "message-meta";
539
540            const sender = document.createElement("span");
541            sender.className = "message-sender";
542            sender.textContent = msg.username;
543            meta.appendChild(sender);
544
545            const time = document.createElement("span");
546            time.className = "message-time";
547            // format timestamp to readable time
548            try {
549                const date = new Date(msg.createdAt);
550                time.textContent = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
551            } catch(err) {
552                time.textContent = msg.createdAt;
553            }
554            meta.appendChild(time);
555
556            if (msg.isEdited) {
557                const editedLabel = document.createElement("span");
558                editedLabel.className = "message-edited";
559                editedLabel.textContent = Obsidianscout.t("chat.edited", "(edited)");
560                if (msg.updatedAt) {
561                    try {
562                        const updatedDate = new Date(msg.updatedAt);
563                        editedLabel.title = updatedDate.toLocaleString();
564                    } catch (_) {}
565                }
566                meta.appendChild(editedLabel);
567            }
568
569            // Message actions button (for author or admin)
570            if (isMe || isAdmin) {
571                const actionsWrapper = document.createElement("div");
572                actionsWrapper.className = "message-actions-wrapper";
573
574                const actionsBtn = document.createElement("button");
575                actionsBtn.className = "message-actions-btn";
576                actionsBtn.innerHTML = "•••";
577                actionsBtn.title = Obsidianscout.t("chat.actions", "Message Actions");
578                actionsBtn.addEventListener("click", (e) => {
579                    e.stopPropagation();
580                    toggleMessageActionsDropdown(actionsWrapper, msg, isMe, isAdmin);
581                });
582
583                actionsWrapper.appendChild(actionsBtn);
584                meta.appendChild(actionsWrapper);
585            }
586
587            bubble.appendChild(meta);
588
589            const text = document.createElement("div");
590            text.className = "message-text";
591            text.textContent = msg.content;
592            bubble.appendChild(text);
593
594            // Reactions
595            const reactionSection = document.createElement("div");
596            reactionSection.className = "reaction-bar";
597
598            // Loop through existing reactions
599            Object.entries(msg.reactions).forEach(([emoji, users]) => {
600                if (users.length === 0) return;
601                const pill = document.createElement("span");
602                const hasReacted = users.includes(me.username);
603                pill.className = `reaction-pill ${hasReacted ? "active" : ""} ${isMe ? "readonly" : ""}`;
604                pill.innerHTML = `<span>${emoji}</span><span>${users.length}</span>`;
605                pill.title = users.join(", ");
606                if (!isMe) {
607                    pill.addEventListener("click", () => {
608                        toggleReaction(msg.id, emoji);
609                    });
610                }
611                reactionSection.appendChild(pill);
612            });
613
614            // Add reaction button - only show if it is not my message
615            if (!isMe) {
616                const addReactBtn = document.createElement("button");
617                addReactBtn.className = "add-reaction-btn";
618                addReactBtn.textContent = "+";
619                addReactBtn.addEventListener("click", (e) => {
620                    e.stopPropagation();
621                    showReactionPicker(addReactBtn, msg.id);
622                });
623                reactionSection.appendChild(addReactBtn);
624            }
625
626            bubble.appendChild(reactionSection);
627            row.appendChild(bubble);
628
629            messageContainer.appendChild(row);
630        });
631
632        // Auto-scroll to bottom if scrolled to bottom or if active picker wasn't open
633        if (isScrolledToBottom && !activePickerMsgId && !editingMessageId) {
634            messageContainer.scrollTop = messageContainer.scrollHeight;
635        }
636    }
637
638    function toggleMessageActionsDropdown(wrapper, msg, isMe, isAdmin) {
639        // Remove any open dropdowns
640        document.querySelectorAll(".message-actions-dropdown").forEach(d => d.remove());
641
642        const dropdown = document.createElement("div");
643        dropdown.className = "message-actions-dropdown";
644
645        if (isMe) {
646            const editBtn = document.createElement("button");
647            editBtn.className = "message-action-item";
648            editBtn.innerHTML = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink: 0;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg> <span>${Obsidianscout.t("chat.edit", "Edit")}</span>`;
649            editBtn.addEventListener("click", (e) => {
650                e.stopPropagation();
651                dropdown.remove();
652                startEditMessage(msg.id, msg.content, wrapper.closest(".message-bubble"));
653            });
654            dropdown.appendChild(editBtn);
655        }
656
657        if (isMe || isAdmin) {
658            const delBtn = document.createElement("button");
659            delBtn.className = "message-action-item danger";
660            delBtn.innerHTML = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink: 0;"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg> <span>${Obsidianscout.t("chat.delete", "Delete")}</span>`;
661            delBtn.addEventListener("click", (e) => {
662                e.stopPropagation();
663                dropdown.remove();
664                confirmDeleteMessage(msg.id);
665            });
666            dropdown.appendChild(delBtn);
667        }
668
669        wrapper.appendChild(dropdown);
670
671        const closeDropdownHandler = (evt) => {
672            if (!wrapper.contains(evt.target)) {
673                dropdown.remove();
674                document.removeEventListener("click", closeDropdownHandler);
675            }
676        };
677        setTimeout(() => {
678            document.addEventListener("click", closeDropdownHandler);
679        }, 10);
680    }
681
682    function startEditMessage(msgId, currentContent, bubble) {
683        if (!bubble) return;
684        editingMessageId = msgId;
685        const textEl = bubble.querySelector(".message-text");
686        if (!textEl) return;
687        textEl.style.display = "none";
688
689        // Remove any previous edit container inside this bubble
690        bubble.querySelector(".message-edit-container")?.remove();
691
692        const editContainer = document.createElement("div");
693        editContainer.className = "message-edit-container";
694
695        const textarea = document.createElement("textarea");
696        textarea.className = "message-edit-textarea";
697        textarea.value = currentContent;
698
699        const actionsRow = document.createElement("div");
700        actionsRow.className = "message-edit-actions";
701
702        const hint = document.createElement("span");
703        hint.className = "message-edit-hint";
704        hint.textContent = "Enter to save • Esc to cancel";
705
706        const cancelBtn = document.createElement("button");
707        cancelBtn.className = "btn ghost";
708        cancelBtn.textContent = Obsidianscout.t("chat.cancel", "Cancel");
709        cancelBtn.addEventListener("click", () => {
710            editingMessageId = null;
711            editContainer.remove();
712            textEl.style.display = "";
713        });
714
715        const saveBtn = document.createElement("button");
716        saveBtn.className = "btn primary";
717        saveBtn.textContent = Obsidianscout.t("chat.save", "Save");
718
719        const submitEdit = async () => {
720            const newText = textarea.value.trim();
721            if (!newText) return;
722            if (newText === currentContent) {
723                editingMessageId = null;
724                editContainer.remove();
725                textEl.style.display = "";
726                return;
727            }
728            Obsidianscout.setButtonLoading(saveBtn, true);
729            try {
730                await Obsidianscout.request(`/api/chat/messages/${msgId}`, {
731                    method: "PUT",
732                    json: { content: newText }
733                });
734                editingMessageId = null;
735                lastMessagesHash = "";
736                loadMessages();
737            } catch (err) {
738                console.error("Failed to edit message", err);
739                Obsidianscout.showToast(Obsidianscout.t("chat.error_edit", "Failed to edit message"), "error");
740                Obsidianscout.setButtonLoading(saveBtn, false);
741            }
742        };
743
744        saveBtn.addEventListener("click", submitEdit);
745
746        textarea.addEventListener("keydown", (e) => {
747            if (e.key === "Enter" && !e.shiftKey) {
748                e.preventDefault();
749                submitEdit();
750            } else if (e.key === "Escape") {
751                e.preventDefault();
752                editingMessageId = null;
753                editContainer.remove();
754                textEl.style.display = "";
755            }
756        });
757
758        actionsRow.appendChild(hint);
759        actionsRow.appendChild(cancelBtn);
760        actionsRow.appendChild(saveBtn);
761
762        editContainer.appendChild(textarea);
763        editContainer.appendChild(actionsRow);
764
765        textEl.after(editContainer);
766        textarea.focus();
767        textarea.setSelectionRange(textarea.value.length, textarea.value.length);
768    }
769
770    async function confirmDeleteMessage(msgId) {
771        const confirmMsg = Obsidianscout.t("chat.delete_confirm", "Are you sure you want to delete this message? This cannot be undone.");
772        if (!confirm(confirmMsg)) {
773            return;
774        }
775        try {
776            await Obsidianscout.request(`/api/chat/messages/${msgId}`, {
777                method: "DELETE"
778            });
779            lastMessagesHash = "";
780            loadMessages();
781        } catch (err) {
782            console.error("Failed to delete message", err);
783            Obsidianscout.showToast(Obsidianscout.t("chat.error_delete", "Failed to delete message"), "error");
784        }
785    }
786
787    // Reaction Picker popover
788    function showReactionPicker(button, msgId) {
789        // Remove existing pickers
790        document.querySelectorAll(".reaction-picker-popover").forEach(el => el.remove());
791
792        const picker = document.createElement("div");
793        picker.className = "reaction-picker-popover";
794
795        emojis.forEach(emoji => {
796            const emojiEl = document.createElement("span");
797            emojiEl.className = "picker-emoji";
798            emojiEl.textContent = emoji;
799            emojiEl.addEventListener("click", () => {
800                toggleReaction(msgId, emoji);
801                picker.remove();
802            });
803            picker.appendChild(emojiEl);
804        });
805
806        button.parentElement.appendChild(picker);
807
808        // Click outside closes picker
809        const clickOutsideHandler = () => {
810            picker.remove();
811            document.removeEventListener("click", clickOutsideHandler);
812        };
813        setTimeout(() => {
814            document.addEventListener("click", clickOutsideHandler);
815        }, 10);
816    }
817
818    // Toggle reaction endpoint call
819    async function toggleReaction(msgId, emoji) {
820        try {
821            await Obsidianscout.request(`/api/chat/messages/${msgId}/react`, {
822                method: "POST",
823                json: { emoji }
824            });
825            loadMessages();
826        } catch (e) {
827            console.error("Failed to toggle reaction", e);
828        }
829    }
830
831    // Autocomplete Mentions Logic
832    async function loadMentionOptions() {
833        try {
834            const teamUsers = await Obsidianscout.request("/api/chat/team-users");
835            const filteredUsers = teamUsers.filter(u => u.toLowerCase() !== "deleted user");
836            mentionOptions = ["everyone", "channel", ...filteredUsers];
837        } catch (e) {
838            console.error("Failed to load team users for mentions:", e);
839            mentionOptions = ["everyone", "channel"];
840        }
841    }
842
843    function handleChatInput() {
844        const cursor = chatMessageInput.selectionStart;
845        const value = chatMessageInput.value;
846        const textUpToCursor = value.slice(0, cursor);
847
848        // Find the nearest preceding '@' that is at start of line or preceded by a space/newline
849        let atIndex = -1;
850        for (let i = cursor - 1; i >= 0; i--) {
851            if (textUpToCursor[i] === '@') {
852                if (i === 0 || textUpToCursor[i - 1] === ' ' || textUpToCursor[i - 1] === '\n') {
853                    atIndex = i;
854                    break;
855                }
856            }
857        }
858
859        if (atIndex !== -1) {
860            const query = textUpToCursor.slice(atIndex + 1);
861            if (!query.includes('\n')) {
862                const filtered = mentionOptions.filter(opt => 
863                    opt.toLowerCase().includes(query.toLowerCase())
864                );
865
866                if (filtered.length > 0) {
867                    mentionTriggerIndex = atIndex;
868                    showMentionDropdown(filtered, query);
869                    return;
870                }
871            }
872        }
873
874        closeMentionDropdown();
875    }
876
877    function showMentionDropdown(filtered, query) {
878        if (!mentionDropdown) {
879            mentionDropdown = document.createElement("div");
880            mentionDropdown.className = "mention-dropdown";
881            const inputArea = document.querySelector(".chat-input-area");
882            inputArea.appendChild(mentionDropdown);
883        }
884
885        mentionDropdown.innerHTML = "";
886
887        if (activeMentionIndex >= filtered.length) {
888            activeMentionIndex = 0;
889        }
890
891        filtered.forEach((opt, idx) => {
892            const item = document.createElement("div");
893            const isActive = idx === activeMentionIndex;
894            item.className = `mention-item ${isActive ? "active" : ""} ${["everyone", "channel"].includes(opt) ? "special-mention" : ""}`;
895            item.textContent = `@${opt}`;
896            
897            item.addEventListener("click", () => {
898                insertMention(opt);
899            });
900
901            mentionDropdown.appendChild(item);
902        });
903
904        const activeItem = mentionDropdown.children[activeMentionIndex];
905        if (activeItem) {
906            activeItem.scrollIntoView({ block: "nearest" });
907        }
908    }
909
910    function closeMentionDropdown() {
911        if (mentionDropdown) {
912            mentionDropdown.remove();
913            mentionDropdown = null;
914        }
915        activeMentionIndex = 0;
916        mentionTriggerIndex = -1;
917    }
918
919    function insertMention(opt) {
920        if (mentionTriggerIndex === -1) return;
921
922        const value = chatMessageInput.value;
923        const cursor = chatMessageInput.selectionStart;
924
925        const before = value.slice(0, mentionTriggerIndex);
926        const after = value.slice(cursor);
927        
928        const mentionText = `@${opt} `;
929        chatMessageInput.value = before + mentionText + after;
930
931        const newCursorPos = mentionTriggerIndex + mentionText.length;
932        chatMessageInput.setSelectionRange(newCursorPos, newCursorPos);
933        chatMessageInput.focus();
934
935        closeMentionDropdown();
936    }
937
938    function handleChatKeyDown(e) {
939        if (mentionDropdown) {
940            const items = mentionDropdown.querySelectorAll(".mention-item");
941            if (items.length > 0) {
942                if (e.key === "ArrowDown") {
943                    e.preventDefault();
944                    activeMentionIndex = (activeMentionIndex + 1) % items.length;
945                    handleChatInput();
946                } else if (e.key === "ArrowUp") {
947                    e.preventDefault();
948                    activeMentionIndex = (activeMentionIndex - 1 + items.length) % items.length;
949                    handleChatInput();
950                } else if (e.key === "Enter" || e.key === "Tab") {
951                    e.preventDefault();
952                    const query = chatMessageInput.value.slice(mentionTriggerIndex + 1, chatMessageInput.selectionStart);
953                    const filtered = mentionOptions.filter(opt => 
954                        opt.toLowerCase().includes(query.toLowerCase())
955                    );
956                    if (filtered[activeMentionIndex]) {
957                        insertMention(filtered[activeMentionIndex]);
958                    }
959                } else if (e.key === "Escape") {
960                    e.preventDefault();
961                    closeMentionDropdown();
962                }
963            }
964        } else {
965            if (e.key === "Enter" && !e.shiftKey) {
966                e.preventDefault();
967                sendMessage();
968            }
969        }
970    }
971
972    // Send Message
973    async function sendMessage() {
974        const text = chatMessageInput.value.trim();
975        if (!text) return;
976
977        Obsidianscout.setButtonLoading(btnSendMessage, true);
978        chatMessageInput.value = "";
979        try {
980            await Obsidianscout.request("/api/chat/messages", {
981                method: "POST",
982                json: {
983                    groupName: currentGroup,
984                    content: text
985                }
986            });
987            loadMessages();
988        } catch (e) {
989            console.error("Failed to send message", e);
990            Obsidianscout.showToast(Obsidianscout.t("chat.error_send", "Failed to send message"), "error");
991        } finally {
992            Obsidianscout.setButtonLoading(btnSendMessage, false);
993        }
994    }
995
996    btnSendMessage.addEventListener("click", sendMessage);
997    chatMessageInput.addEventListener("keydown", handleChatKeyDown);
998    chatMessageInput.addEventListener("input", handleChatInput);
999
1000    if (btnBackChannels) {
1001        btnBackChannels.addEventListener("click", () => {
1002            chatActiveContainer.classList.remove("show-chat");
1003        });
1004    }
1005
1006    document.addEventListener("click", (e) => {
1007        if (mentionDropdown && !e.target.closest(".chat-input-area")) {
1008            closeMentionDropdown();
1009        }
1010    });
1011
1012    // Create custom group
1013    btnCreateGroup.addEventListener("click", async () => {
1014        const promptText = Obsidianscout.t("chat.create_group_prompt", "Enter new group name (e.g. strategy, scouting):");
1015        const name = prompt(promptText);
1016        if (!name) return;
1017        const sanitized = name.toLowerCase().replace(/[^a-z0-9_-]/g, "").trim();
1018        if (!sanitized) {
1019            alert(Obsidianscout.t("chat.create_group_invalid", "Invalid group name!"));
1020            return;
1021        }
1022        try {
1023            await Obsidianscout.request("/api/chat/groups", {
1024                method: "POST",
1025                json: { groupName: sanitized }
1026            });
1027        } catch (e) {
1028            console.error("Failed to save group to server", e);
1029        }
1030        if (!knownGroups.includes(sanitized)) {
1031            knownGroups.push(sanitized);
1032        }
1033        switchGroup(sanitized);
1034    });
1035
1036    // Polling helpers
1037    function startPolling() {
1038        stopPolling();
1039        pollInterval = setInterval(() => {
1040            loadMessages();
1041            loadGroupUnreads();
1042        }, 2000);
1043    }
1044
1045    function stopPolling() {
1046        if (pollInterval) {
1047            clearInterval(pollInterval);
1048            pollInterval = null;
1049        }
1050    }
1051
1052    // Initialize
1053    const isEnabled = await checkChatSettings();
1054    if (isEnabled) {
1055        await Promise.all([
1056            loadGroups(),
1057            loadMentionOptions()
1058        ]);
1059        
1060        // After loadGroups(), currentGroup is already set to the first accessible channel.
1061        // Honour a URL ?group= param only if the server actually returned that group for this user.
1062        const urlParamsInit = new URLSearchParams(window.location.search);
1063        const requestedGroup = urlParamsInit.get('group');
1064        if (requestedGroup && knownGroups.includes(requestedGroup)) {
1065            currentGroup = requestedGroup;
1066        } else if (!knownGroups.includes(currentGroup)) {
1067            currentGroup = knownGroups[0] || "";
1068        }
1069
1070        if (currentGroup) {
1071            switchGroup(currentGroup);
1072        }
1073
1074        if ('serviceWorker' in navigator) {
1075            navigator.serviceWorker.addEventListener('message', (event) => {
1076                if (event.data && event.data.type === 'SWITCH_GROUP') {
1077                    if (knownGroups.includes(event.data.groupName)) {
1078                        switchGroup(event.data.groupName);
1079                    }
1080                }
1081            });
1082        }
1083    }
1084});

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.