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.