1/** 2 * TeamsPanel â Sliding Teams browser panel (follows OneDrivePanel pattern). 3 * 4 * Browse joined Teams, channels, and chats. Send messages directly 5 * from within smrtBuild â including links to project items. 6 * 7 * Usage: 8 * import { TeamsPanel } from '../components/TeamsPanel.js'; 9 * await TeamsPanel.init(); 10 * TeamsPanel.toggle(); 11 * 12 * â ï¸ DESIGN BIBLE AUDIT â SKIP THIS FILE. 13 * This component intentionally mirrors Microsoft Teams UI for user familiarity. 14 * innerHTML usage here is by design (matching Microsoft's chrome). Do not refactor 15 * to JsRender templates or flag as a Design Bible violation. 16 */ 17 18import { msGraph } from '../graph/auth.js'; 19import { teams } from '../graph/teams.js'; 20import { fileScopes } from '../graph/msal-config.js'; 21import { loadTemplates } from '../utils/templates.js'; 22import { safeAsync } from '../utils/async.js'; 23import { timeAgo, getInitials, formatFileSize } from '../utils/jsrender-helpers.js'; 24import { escapeHtml, escapeAttr } from '../utils/dom.js'; 25import { sanitizeMessageHtml } from '../utils/html-sanitizer.js'; 26import { getMicrosoftFileColor } from '../utils/file-icons-microsoft.js'; 27import { initPassiveDrop } from '../utils/passive-drop.js'; 28import { showAttachmentActionMenu, closeAttachmentActionMenu } from '../utils/external-attachment-actions.js'; 29import { pubsub } from '../utils/pubsub.js'; 30import { captureDurableSettlementOwner, canContinueDurableSettlement } from '../utils/durable-settlement-owner.js'; 31import { setActiveChat, getUnreadChatIds, markChatRead, isMuted, toggleMute } from '../graph/teams-live.js'; 32 33let _initialized = false; 34let _open = false; 35let _pinned = false; 36let _activeTab = 'teams'; // 'teams' | 'chats' 37 38// Live updates arrive over SignalR (Graph change notification â API â SignalR). There is 39// deliberately NO polling here: looping GETs against Teams violate Graph's Terms of Use, and 40// the old 30s setInterval was removed when the webhook pipeline landed. 41let _liveSubscriptions = []; 42let _escapeHandler = null; 43let _createUiAbort = null; 44 45// Navigation state for Teams tab: teams â channels â messages 46let _teamsList = []; 47let _selectedTeam = null; // { id, displayName } 48let _channelsList = []; 49let _selectedChannel = null; // { id, displayName } 50let _channelMessages = []; 51 52// Navigation state for Chats tab 53let _chatsList = []; 54let _selectedChat = null; // { id, topic, chatType, members } 55let _chatMessages = []; 56 57// Attachments staged in the composer. Nothing touches the network until Send â same as the 58// real Teams client. Entries are { file, uploading, progress } so a chip can show its own 59// spinner/percentage without mutating the File object. 60let _stagedFiles = []; 61let _uploading = false; 62let _passiveDropDestroy = null; 63 64// Teams purple for branded elements â CSS variable defined in tailwind.css 65const TEAMS_PURPLE = 'var(--color-ms-teams)'; 66 67// Matches the API's OneDriveService cap so a file can't sail through here and be rejected 68// elsewhere in the app. Teams itself allows far more; this is our house limit. 69const MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024; 70 71const TEAMS_ICON_SM = `<svg viewBox="0 0 24 24" class="w-3.5 h-3.5 shrink-0" fill="${TEAMS_PURPLE}" aria-hidden="true"><path d="M17.5 3.5a1.75 1.75 0 1 1 0 3.5 1.75 1.75 0 0 1 0-3.5ZM11.5 2.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5ZM21 9h-3.5c.3.6.5 1.3.5 2v5.5c0 .6-.1 1.1-.4 1.5h2.4A1.5 1.5 0 0 0 21.5 16.5V10.5A1.5 1.5 0 0 0 20 9h1ZM14.5 9h-9A1.5 1.5 0 0 0 4 10.5v6A1.5 1.5 0 0 0 5.5 18h9a1.5 1.5 0 0 0 1.5-1.5v-6A1.5 1.5 0 0 0 14.5 9Z"/></svg>`; 72 73export const TeamsPanel = { 74 75 async init() { 76 if (_initialized) return; 77 78 await loadTemplates('components/teams-panel'); 79 80 // Move panel + backdrop out of hidden container into body 81 const panel = document.getElementById('teams-panel'); 82 const backdrop = document.getElementById('teams-backdrop'); 83 if (panel) document.body.appendChild(panel); 84 if (backdrop) document.body.appendChild(backdrop); 85 86 _initialized = true; 87 this._bindEvents(); 88 this._bindLiveUpdates(); 89 this._setActiveTab('teams'); 90 }, 91 92 /** 93 * Subscribe to live Teams events from the coordinator (graph/teams-live.js). 94 * 95 * Both topics matter: 'teams:message' is republished by the coordinator after it fetches 96 * the message body, and it fires in EVERY tab â the coordinator handles the cross-tab relay, 97 * because only the CrossTabSync leader tab holds the SignalR connection. 98 */ 99 _bindLiveUpdates() { 100 _liveSubscriptions = [ 101 pubsub.subscribe('teams:message', (data) => this._onLiveMessage(data)), 102 pubsub.subscribe('teams:chatsReconciled', (data) => this._onChatsReconciled(data)), 103 ]; 104 }, 105 106 /** 107 * Tear down everything init() and _bindEvents() attached.
108 * Called from router.js performLogout() per the cleanup contract. 109 */ 110 destroy() { 111 _createUiAbort?.abort(); 112 _createUiAbort = null; 113 for (const unsub of _liveSubscriptions) { 114 try { unsub?.(); } catch { /* ignore */ } 115 } 116 _liveSubscriptions = []; 117 118 if (_escapeHandler) { 119 document.removeEventListener('keydown', _escapeHandler); 120 _escapeHandler = null; 121 } 122 123 // Drag handlers live on the panel element, and the overlay initPassiveDrop injects 124 // would outlive it. The util hands back its own teardown; use it. 125 _passiveDropDestroy?.(); 126 _passiveDropDestroy = null; 127 128 // Menu state lives in a shared module and it portals to <body>, so neither the element 129 // nor its document listeners go away with the panel. close() below only runs if _open. 130 closeAttachmentActionMenu(); 131 132 if (_open) this.close(); 133 134 // init() reparents these into <body>; without removing them, a re-login would run 135 // init() again and append a SECOND #teams-panel, leaving duplicate ids in the DOM. 136 document.getElementById('teams-panel')?.remove(); 137 document.getElementById('teams-backdrop')?.remove(); 138 139 // Undo the pin's push on main content, in case we're destroyed while pinned. 140 const mainContent = document.getElementById('content'); 141 if (mainContent) mainContent.style.marginRight = ''; 142 _pinned = false; 143 144 _initialized = false; 145 _activeTab = 'teams'; 146 _teamsList = []; 147 _selectedTeam = null; 148 _channelsList = []; 149 _selectedChannel = null; 150 _channelMessages = []; 151 _chatsList = []; 152 _selectedChat = null; 153 _chatMessages = []; 154 _stagedFiles = []; 155 _uploading = false; 156 this._pendingMessage = null; 157 }, 158 159 toggle() { 160 if (_open) this.close(); 161 else this.open(); 162 }, 163 164 async open() { 165 if (!_initialized) await this.init(); 166 167 const panel = document.getElementById('teams-panel'); 168 const backdrop = document.getElementById('teams-backdrop'); 169 if (!panel) return; 170 171 panel.classList.remove('translate-x-full'); 172 if (backdrop && !_pinned) { 173 backdrop.classList.remove('opacity-0', 'pointer-events-none'); 174 backdrop.classList.add('opacity-100', 'pointer-events-auto'); 175 } 176 _open = true; 177 _createUiAbort?.abort(); 178 _createUiAbort = new AbortController(); 179 const openSignal = _createUiAbort.signal; 180 181 // Check connection and load 182 const { connected, email } = await msGraph.checkConnection(); 183 if (openSignal.aborted || !_open || document.getElementById('teams-panel') !== panel) return; 184 const emailEl = document.getElementById('teams-account-email'); 185 const content = document.getElementById('teams-content'); 186 const notConnected = document.getElementById('teams-not-connected'); 187 const tabBar = document.getElementById('teams-tab-bar'); 188 const breadcrumbs = document.getElementById('teams-breadcrumbs'); 189 const compose = document.getElementById('teams-compose'); 190 191 if (!connected) { 192 if (content) content.classList.add('hidden'); 193 if (tabBar) tabBar.classList.add('hidden'); 194 if (breadcrumbs) breadcrumbs.classList.add('hidden'); 195 if (compose) compose.classList.add('hidden'); 196 if (notConnected) notConnected.classList.remove('hidden'); 197 if (emailEl) emailEl.textContent = ''; 198 return; 199 } 200 201 // Connected 202 if (notConnected) notConnected.classList.add('hidden'); 203 if (content) content.classList.remove('hidden'); 204 if (tabBar) tabBar.classList.remove('hidden'); 205 if (emailEl) emailEl.textContent = email || ''; 206 207 // Load initial data if empty 208 if (_activeTab === 'teams' && !_teamsList.length && !_selectedTeam) { 209 await this._loadTeams(); 210 } else if (_activeTab === 'chats' && !_chatsList.length && !_selectedChat) { 211 await this._loadChats(); 212 } 213 }, 214 215 close() { 216 _createUiAbort?.abort(); 217 _createUiAbort = null; 218 const panel = document.getElementById('teams-panel'); 219 const backdrop = document.getElementById('teams-backdrop'); 220 if (!panel) return; 221 222 panel.classList.add('translate-x-full'); 223 if (backdrop) { 224 backdrop.classList.remove('opacity-100', 'pointer-events-auto'); 225 backdrop.classList.add('opacity-0', 'pointer-events-none'); 226 } 227 // The attachment menu portals to <body>, so it would be left floating over the app 228 // once the panel it belongs to slides away. 229 closeAttachmentActionMenu(); 230 _open = false;
231 // Panel is closed, so nothing is being "actively read" â future messages should badge. 232 setActiveChat(null); 233 234 // Reset pin state 235 if (_pinned) { 236 _pinned = false; 237 const mainContent = document.getElementById('content'); 238 if (mainContent) mainContent.style.marginRight = ''; 239 const pinBtn = document.getElementById('teams-pin-btn'); 240 if (pinBtn) { 241 pinBtn.classList.remove('bg-theme-secondary'); 242 pinBtn.title = 'Pin open'; 243 const icon = pinBtn.querySelector('[data-lucide]'); 244 if (icon) { icon.dataset.lucide = 'pin'; lucide.createIcons({ root: pinBtn }); } 245 } 246 } 247 }, 248 249 /** 250 * Open the panel pre-loaded with a message to send. 251 * For "Share via Teams" flows from elsewhere in the app. 252 * @param {string} prefillText â Text to pre-fill in the compose box 253 */ 254 async openWithMessage(prefillText) { 255 await this.open(); 256 this._pendingMessage = prefillText; 257 }, 258 259 // âââ Internal ââââââââââââââââââââââââââââââââââââââââ 260 261 /** 262 * Open the shared attachment action menu for a chip in the message list. 263 * 264 * The menu portals to <body> (via createPortalPopover) because #teams-content is an 265 * overflow-y-auto scroll container that would clip it, and it flips above the chip when 266 * there's no room below â a file near the newest message sits right on top of the composer. 267 * z-popover (1060) is required: the panel itself is z-fixed (1035), so the menu's default 268 * z-dropdown (1000) would render behind it. 269 * 270 * The panel-lifetime signal closes any selected Submittal picker/modal when this origin 271 * closes; an accepted create still finishes its captured durable follow-ups. 272 */ 273 _openAttachmentMenu(card) { 274 const panel = document.getElementById('teams-panel'); 275 const uiSignal = _createUiAbort?.signal; 276 const settlementOwner = captureDurableSettlementOwner(); 277 const canContinueDurable = () => canContinueDurableSettlement(settlementOwner); 278 const canPublish = () => !!uiSignal && !uiSignal.aborted && _open 279 && card.isConnected && panel?.isConnected 280 && document.getElementById('teams-panel') === panel && canContinueDurable(); 281 if (!canPublish()) return; 282 // Every action here reads the file out of OneDrive, which needs fileScopes â the panel 283 // itself only holds teamsScopes. Warm it now, on a real click, so the action doesn't have 284 // to raise an interactive popup from deeper in the chain (where Chrome blocks it). Same 285 // trick the paperclip uses in _bindComposeEvents. 286 msGraph.getTokenSilent(fileScopes).catch(() => { /* consent deferred to the action */ }); 287 288 const ref = { 289 source: 'teams', 290 msgId: card.dataset.msgId, 291 attachId: card.dataset.attachId, 292 filename: card.dataset.filename, 293 contentUrl: card.dataset.contentUrl, 294 }; 295 showAttachmentActionMenu(ref, card, { 296 zClass: 'z-popover', 297 signal: uiSignal, 298 canPublish, 299 canContinueDurable, 300 }); 301 }, 302 303 _bindEvents() { 304 // Close button 305 document.getElementById('teams-close-btn')?.addEventListener('click', () => this.close()); 306 307 // Refresh button 308 document.getElementById('teams-refresh-btn')?.addEventListener('click', () => this._refreshMessages()); 309 310 // Pin button 311 document.getElementById('teams-pin-btn')?.addEventListener('click', () => this._togglePin()); 312 313 // Mute toggle â suppresses the arrival toast only. The unread badge still updates, 314 // because muting notifications shouldn't hide that you have unread messages. 315 document.getElementById('teams-mute-btn')?.addEventListener('click', () => { 316 const muted = toggleMute(); 317 this._renderMuteButton(); 318 window.showToast?.( 319 muted ? 'Teams toasts muted' : 'Teams toasts on', 320 'info', 321 { description: muted ? 'The unread badge still updates.' : 'Youâll see a toast for new messages.' } 322 ); 323 }); 324 this._renderMuteButton(); 325 326 // Backdrop click (only when not pinned) 327 document.getElementById('teams-backdrop')?.addEventListener('click', () => { 328 if (!_pinned) this.close(); 329 }); 330 331 // Escape key â keep the reference so destroy() can actually remove it 332 _escapeHandler = (e) => { 333 if (e.key === 'Escape' && _open) this.close(); 334 }; 335 document.addEventListener('keydown', _escapeHandler); 336 337 // Connect button 338 document.getElementById('teams-connect-btn')?.addEventListener('click', async () => { 339 await safeAsync(async () => { 340 const user = await msGraph.signIn(); 341 if (user) { 342 window.showToast?.('Microsoft 365 Connected', 'success', { description: user.userPrincipalName || user.mail }); 343 this.open(); 344 } else { 345 window.showToast?.('Sign-in unavailable', 'error', { description: 'Microsoft 365 is not configured for this tenant' }); 346 } 347 }, { label: 'Teams', toast: 'Connection Failed' }); 348 }); 349 350 // Tab clicks 351 document.querySelectorAll('.teams-tab-btn').forEach(tab => { 352 tab.addEventListener('click', () => { 353 this._setActiveTab(tab.dataset.tab); 354 }); 355 }); 356 357 // Content area clicks (delegated) 358 document.getElementById('teams-content')?.addEventListener('click', (e) => { 359 // Attachment options chevron. Delegated because _renderMessages replaces this 360 // container's innerHTML wholesale â a per-chip listener would die on every render. 361 const attachMenuBtn = e.target.closest('.ext-attach-menu-btn'); 362 if (attachMenuBtn) { 363 // The chip's anchor wraps the filename; keep the chevron from following it. 364 e.preventDefault(); 365 e.stopPropagation(); 366 const card = attachMenuBtn.closest('.ext-attach-card'); 367 if (card) this._openAttachmentMenu(card); 368 return; 369 } 370 371 // Team item 372 const teamItem = e.target.closest('[data-team-id]'); 373 if (teamItem) { 374 const teamId = teamItem.dataset.teamId; 375 const team = _teamsList.find(t => t.id === teamId); 376 if (team) this._selectTeam(team); 377 return; 378 } 379 380 // Channel item 381 const channelItem = e.target.closest('[data-channel-id]'); 382 if (channelItem) { 383 const channelId = channelItem.dataset.channelId; 384 const channel = _channelsList.find(c => c.id === channelId); 385 if (channel) this._selectChannel(channel); 386 return; 387 } 388 389 // Chat item 390 const chatItem = e.target.closest('[data-chat-id]'); 391 if (chatItem) { 392 const chatId = chatItem.dataset.chatId; 393 const chat = _chatsList.find(c => c.id === chatId); 394 if (chat) this._selectChat(chat); 395 return; 396 } 397 }); 398 399 // Breadcrumb clicks 400 document.getElementById('teams-breadcrumbs')?.addEventListener('click', (e) => { 401 const crumb = e.target.closest('[data-nav]'); 402 if (!crumb) return; 403 404 const nav = crumb.dataset.nav; 405 if (nav === 'root') { 406 if (_activeTab === 'teams') { 407 _selectedTeam = null; 408 _selectedChannel = null; 409 _channelMessages = []; 410 this._loadTeams(); 411 } else { 412 _selectedChat = null; 413 _chatMessages = []; 414 this._loadChats(); 415 } 416 } else if (nav === 'team') { 417 _selectedChannel = null; 418 _channelMessages = []; 419 this._loadChannels(_selectedTeam); 420 } 421 422 this._updateBreadcrumbs(); 423 this._updateCompose(); 424 }); 425 426 // Compose input 427 const input = document.getElementById('teams-compose-input'); 428 const sendBtn = document.getElementById('teams-send-btn'); 429 430 input?.addEventListener('input', () => { 431 this._updateSendState(); 432 // Auto-resize 433 input.style.height = 'auto'; 434 input.style.height = Math.min(input.scrollHeight, 120) + 'px'; 435 }); 436 437 input?.addEventListener('keydown', (e) => { 438 if (e.key === 'Enter' && !e.shiftKey) { 439 e.preventDefault(); 440 if (!sendBtn?.disabled) this._sendMessage(); 441 } 442 }); 443 444 sendBtn?.addEventListener('click', () => this._sendMessage()); 445 446 // ââ Attachments ââ 447 const attachBtn = document.getElementById('teams-attach-btn'); 448 const fileInput = document.getElementById('teams-file-input'); 449 450 attachBtn?.addEventListener('click', () => { 451 // Open the picker synchronously. Chrome only honours a programmatic click on a 452 // file input while the originating gesture is still live, so this must not sit 453 // behind an await. 454 fileInput?.click(); 455 456 // Warm the file-scope token in the background. Attaching switches us from 457 // teamsScopes to fileScopes, which may need incremental consent â if it does, 458 // this silent attempt just fails and the popup happens on Send instead, where a 459 // fresh click gesture still allows it. What must never happen is a popup firing 460 // from deep inside the upload chain, because the browser blocks that. 461 msGraph.getTokenSilent(fileScopes).catch(() => { /* consent deferred to Send */ }); 462 }); 463 464 fileInput?.addEventListener('change', (e) => { 465 this._stageFiles(e.target.files); 466 // Reset so re-picking the same file still fires 'change'. 467 e.target.value = ''; 468 }); 469 470 document.getElementById('teams-compose-attachments')?.addEventListener('click', (e) => { 471 const btn = e.target.closest('.teams-staged-remove'); 472 if (!btn || btn.disabled) return; 473 474 const index = Number(btn.dataset.index); 475 if (Number.isNaN(index)) return; 476 477 _stagedFiles.splice(index, 1); 478 this._renderStagedFiles(); 479 this._updateSendState(); 480 }); 481 482 // Drop anywhere on the panel, like Teams. initPassiveDrop handles the overlay, the 483 // drag-depth counting and its own teardown. 484 // 485 // Target the panel, not #teams-content: the overlay is position:absolute inset:0, and 486 // inside a scroll container that pins it to the top of the scrollable area â so on a 487 // thread scrolled to the newest message (i.e. always) it would render off-screen. 488 // 489 // The panel is `fixed` via a Tailwind class, and initPassiveDrop only leaves an 490 // element's position alone when the INLINE style is already set. Stating it inline 491 // first stops it being overwritten with `relative`, which would break the layout. 492 const panel = document.getElementById('teams-panel'); 493 if (panel) { 494 panel.style.position = 'fixed'; 495 _passiveDropDestroy = initPassiveDrop(panel, (files) => this._stageFiles(files), { 496 label: 'Drop files to send', 497 icon: 'paperclip', 498 }); 499 } 500 }, 501 502 // âââ Tab Management âââââââââââââââââââââââââââââââââ 503
504 _setActiveTab(tab) { 505 _activeTab = tab; 506 507 document.querySelectorAll('.teams-tab-btn').forEach(el => { 508 el.classList.toggle('active', el.dataset.tab === tab); 509 }); 510 511 this._updateBreadcrumbs(); 512 this._updateCompose(); 513 514 // Load data for the active tab 515 if (tab === 'teams') { 516 if (_selectedChannel) { 517 this._renderMessages(_channelMessages); 518 } else if (_selectedTeam) { 519 this._renderChannels(_channelsList); 520 } else { 521 this._loadTeams(); 522 } 523 } else { 524 if (_selectedChat) { 525 this._renderMessages(_chatMessages); 526 } else { 527 this._loadChats(); 528 } 529 } 530 }, 531 532 // âââ Teams Tab: Teams â Channels â Messages âââââââââ 533 534 async _loadTeams() { 535 _selectedTeam = null; 536 _selectedChannel = null; 537 this._updateBreadcrumbs(); 538 this._updateCompose(); 539 this._showLoading('Loading teams...'); 540 541 try { 542 _teamsList = await teams.getJoinedTeams(); 543 this._renderTeams(_teamsList); 544 } catch (err) { 545 console.error('[Teams] Load teams failed:', err); 546 this._showError('Failed to load teams', err.message); 547 } 548 }, 549 550 async _selectTeam(team) { 551 _selectedTeam = team; 552 _selectedChannel = null; 553 this._updateBreadcrumbs(); 554 this._updateCompose(); 555 await this._loadChannels(team); 556 }, 557 558 async _loadChannels(team) { 559 this._showLoading('Loading channels...'); 560 561 try { 562 _channelsList = await teams.getChannels(team.id); 563 this._renderChannels(_channelsList); 564 } catch (err) { 565 console.error('[Teams] Load channels failed:', err); 566 this._showError('Failed to load channels', err.message); 567 } 568 }, 569 570 async _selectChannel(channel) { 571 _selectedChannel = channel; 572 this._updateBreadcrumbs(); 573 this._updateCompose(); 574 await this._loadChannelMessages(); 575 }, 576 577 async _loadChannelMessages() { 578 this._showLoading('Loading messages...'); 579 580 try { 581 const result = await teams.getChannelMessages(_selectedTeam.id, _selectedChannel.id); 582 // Filter out system messages (joins, leaves, etc.) 583 _channelMessages = result.messages.filter(m => m.messageType === 'message'); 584 this._renderMessages(_channelMessages); 585 } catch (err) { 586 console.error('[Teams] Load messages failed:', err); 587 this._showError('Failed to load messages', err.message); 588 } 589 }, 590 591 // âââ Chats Tab ââââââââââââââââââââââââââââââââââââââ 592 593 async _loadChats() { 594 _selectedChat = null; 595 setActiveChat(null); 596 this._updateBreadcrumbs(); 597 this._updateCompose(); 598 this._showLoading('Loading chats...'); 599 600 try { 601 _chatsList = await teams.getChats(); 602 this._renderChats(_chatsList); 603 } catch (err) { 604 console.error('[Teams] Load chats failed:', err); 605 this._showError('Failed to load chats', err.message); 606 } 607 }, 608 609 async _selectChat(chat) { 610 _selectedChat = chat; 611 // A file staged for one conversation must not follow you into another. 612 this._clearStagedFiles(); 613 // Opening a chat marks it read locally and drops it from the header badge count. 614 setActiveChat(chat.id); 615 this._updateBreadcrumbs(); 616 this._updateCompose(); 617 this._showLoading('Loading messages...'); 618 619 try { 620 const result = await teams.getChatMessages(chat.id); 621 _chatMessages = result.messages.filter(m => m.messageType === 'message'); 622 this._renderMessages(_chatMessages); 623 } catch (err) { 624 console.error('[Teams] Load chat messages failed:', err); 625 this._showError('Failed to load messages', err.message); 626 } 627 }, 628 629 // âââ Send Message âââââââââââââââââââââââââââââââââââ 630 631 async _sendMessage() { 632 const input = document.getElementById('teams-compose-input'); 633 const sendBtn = document.getElementById('teams-send-btn'); 634 const content = input?.value.trim() ?? ''; 635 const inChat = _activeTab === 'chats' && !!_selectedChat; 636 637 // A message can be text, files, or both â but files alone only make sense in a chat. 638 if (!content && !(inChat && _stagedFiles.length)) return; 639 640 sendBtn.disabled = true; 641 input.disabled = true; 642 643 try { 644 if (_activeTab === 'teams' && _selectedTeam && _selectedChannel) { 645 await teams.sendChannelMessage(_selectedTeam.id, _selectedChannel.i
645d, content); 646 input.value = ''; 647 input.style.height = 'auto'; 648 window.showToast?.('Message sent', 'success', { description: `Sent to #${_selectedChannel.displayName}` }); 649 // Reload messages 650 await this._loadChannelMessages(); 651 } else if (inChat) { 652 // Uploads happen here, not at pick time â nothing hits OneDrive unless the 653 // user actually sends. If any upload throws, we never post, and the chips 654 // stay staged so they can retry. 655 const attachments = _stagedFiles.length ? await this._uploadStagedFiles() : null; 656 657 await teams.sendChatMessage(_selectedChat.id, content, false, attachments); 658 input.value = ''; 659 input.style.height = 'auto'; 660 this._clearStagedFiles(); 661 window.showToast?.('Message sent', 'success', { description: 'Sent to chat' }); 662 // Reload messages 663 const result = await teams.getChatMessages(_selectedChat.id); 664 _chatMessages = result.messages.filter(m => m.messageType === 'message'); 665 this._renderMessages(_chatMessages); 666 } 667 } catch (err) { 668 console.error('[Teams] Send failed:', err); 669 window.showToast?.('Send Failed', 'error', { description: err.message }); 670 } finally { 671 _uploading = false; 672 // Drop any half-finished spinners left behind by a failed upload. 673 this._renderStagedFiles(); 674 input.disabled = false; 675 this._updateSendState(); 676 input.focus(); 677 } 678 }, 679 680 // âââ Attachments ââââââââââââââââââââââââââââââââââââ 681 682 /** 683 * Add picked/dropped files to the composer. No network â uploading is deferred to Send. 684 * @param {FileList|File[]} fileList 685 */ 686 _stageFiles(fileList) { 687 // Chats only (see _updateCompose). A drop on a channel thread is silently ignored 688 // rather than staged into something that can't send it. 689 if (!(_activeTab === 'chats' && _selectedChat)) return; 690 691 const incoming = Array.from(fileList ?? []); 692 if (!incoming.length) return; 693 694 const oversized = []; 695 const accepted = []; 696 697 for (const file of incoming) { 698 if (file.size > MAX_ATTACHMENT_BYTES) { 699 oversized.push(file.name); 700 continue; 701 } 702 // Same name AND same size is a double-pick, not a second file. 703 const alreadyStaged = _stagedFiles.some( 704 e => e.file.name === file.name && e.file.size === file.size 705 ); 706 if (!alreadyStaged) accepted.push({ file, uploading: false, progress: 0 }); 707 } 708 709 if (oversized.length) { 710 window.showToast?.('File too large', 'error', { 711 description: `${oversized.join(', ')} â the limit is ${formatFileSize(MAX_ATTACHMENT_BYTES)}.`, 712 }); 713 } 714 715 if (!accepted.length) return; 716 717 _stagedFiles = [..._stagedFiles, ...accepted]; 718 this._renderStagedFiles(); 719 this._updateSendState(); 720 }, 721 722 _clearStagedFiles() { 723 if (!_stagedFiles.length) return; 724 _stagedFiles = []; 725 this._renderStagedFiles(); 726 this._updateSendState(); 727 }, 728 729 _renderStagedFiles() { 730 const strip = document.getElementById('teams-compose-attachments'); 731 if (!strip) return; 732 733 if (!_stagedFiles.length) { 734 strip.innerHTML = ''; 735 strip.classList.add('hidden'); 736 return; 737 } 738 739 strip.classList.remove('hidden'); 740 strip.innerHTML = _stagedFiles.map((entry, index) => { 741 const { icon, color } = getMicrosoftFileColor(entry.file.name, false); 742 743 // Only the chunked path (files ⥠4MB) reports real progress â a single PUT gives 744 // us nothing to report, so a small file just spins. 745 const meta = entry.uploading 746 ? (entry.progress > 0 ? `Uploading ${entry.progress}%` : 'Uploading...') 747 : formatFileSize(entry.file.size); 748 749 const leading = entry.uploading 750 ? `<div class="w-4 h-4 border-2 rounded-full animate-spin shrink-0" style="border-color: ${TEAMS_PURPLE}; border-top-color: transparent;"></div>` 751 : `<i data-lucide="${icon}" class="w-4 h-4 shrink-0 ${color}"></i>`; 752 753 return ` 754 <div class="flex items-center gap-2 pl-2 pr-1 py-1.5 rounded-lg bg-theme-secondary border border-theme max-w-full"> 755 ${leading} 756 <div class="min-w-0"> 757 <p class="text-xs font-medium text-theme-primary truncate max-w-40">${escapeHtml(entry.file.name)}</p> 758 <p class="text-2xs text-theme-secondary">${escapeHtml(meta)}</p> 759 </div> 760 <button type="button" 761 class="teams-staged-remove p-1 rounded-md hover:bg-theme-primary transition-colors shrink-0 disabled:opacity-40" 762 data-index="${index}" 763 ${entry.uploading ? 'disabled' : ''} 764 aria-label="Remove ${escapeAttr(entry.file.name)}"> 765 <i data-lucide="x" class="w-3 h-3 text-theme-secondary pointer-events-none"></i> 766 </button> 767 </div>`; 768 }).join(''); 769 770 lucide.createIcons({ root: strip }); 771 }, 772 773 /** 774 * Upload every staged file and return the attachment descriptors for sendChatMessage. 775 * Throws on the first failure so we never post a message referencing a file that isn't there. 776 * @returns {Promise<Array<{ id, name, contentUrl }>>} 777 */ 778 async _uploadStagedFiles() { 779 // Take the interactive consent hit HERE. We're still inside the Send click's user 780 // gesture, so MSAL's popup is allowed; buried further down the upload chain it gets 781 // blocked and the whole thing dies silently on a user's first attachment. 782 await msGraph.getToken(fileScopes); 783 784 // Everyone in the chat except us â we already own the file. 785 const myEmail = (msGraph.getConnectedEmail() ?? '').toLowerCase(); 786 const recipients = (_selectedChat?.members ?? []) 787 .map(m => m.email) 788 .filter(email => email && email.toLowerCase() !== myEmail); 789 790 _uploading = true; 791 this._updateSendState(); 792 793 const attachments = []; 794 let anyUnshared = false;
795 796 try { 797 for (const entry of _stagedFiles) { 798 entry.uploading = true; 799 entry.progress = 0; 800 this._renderStagedFiles(); 801 802 const attachment = await teams.uploadChatAttachment(entry.file, recipients, { 803 onProgress: (pct) => { 804 entry.progress = pct; 805 this._renderStagedFiles(); 806 }, 807 }); 808 809 entry.uploading = false; 810 if (!attachment.shared) anyUnshared = true; 811 attachments.push(attachment); 812 } 813 } finally { 814 _uploading = false; 815 // On a throw the failing entry is still flagged as uploading â leave that stale 816 // and its chip keeps spinning with no way to remove it. 817 for (const entry of _stagedFiles) entry.uploading = false; 818 } 819 820 if (anyUnshared) { 821 window.showToast?.('Attachment sharing failed', 'warning', { 822 description: 'The file uploaded, but access could not be granted automatically. Share it from OneDrive if they cannot open it.', 823 }); 824 } 825 826 return attachments; 827 }, 828 829 /** 830 * Send stays clickable whenever there's text OR a staged file â an attachment-only 831 * message is valid â and goes dead while an upload is in flight. 832 */ 833 _updateSendState() { 834 const input = document.getElementById('teams-compose-input'); 835 const sendBtn = document.getElementById('teams-send-btn'); 836 const attachBtn = document.getElementById('teams-attach-btn'); 837 if (!sendBtn) return; 838 839 const hasSomething = !!input?.value.trim() || _stagedFiles.length > 0; 840 sendBtn.disabled = _uploading || !hasSomething; 841 if (attachBtn) attachBtn.disabled = _uploading; 842 }, 843 844 // âââ Rendering ââââââââââââââââââââââââââââââââââââââ 845 846 _renderTeams(teamsList) { 847 const container = document.getElementById('teams-content'); 848 if (!container) return; 849 850 if (!teamsList.length) { 851 container.innerHTML = ` 852 <div class="text-center py-12 px-4"> 853 <i data-lucide="users" class="w-8 h-8 text-theme-secondary mx-auto mb-3"></i> 854 <p class="text-sm text-theme-secondary">No teams found</p> 855 <p class="text-xs text-theme-secondary mt-1">Join a team in Microsoft Teams to see it here.</p> 856 </div>`; 857 lucide.createIcons({ root: container }); 858 return; 859 } 860 861 let html = ''; 862 for (const team of teamsList) { 863 const initials = getInitials(team.displayName); 864 html += ` 865 <div class="teams-item flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-surface-2/50 transition-colors border-b border-theme-30" 866 data-team-id="${team.id}"> 867 <div class="w-9 h-9 rounded-lg flex items-center justify-center text-white text-xs font-bold shrink-0" style="background-color: ${getAvatarColor(team.displayName)};"> 868 ${initials} 869 </div> 870 <div class="flex-1 min-w-0"> 871 <p class="text-sm font-medium text-theme-primary truncate">${escapeHtml(team.displayName)}</p> 872 ${team.description ? `<p class="text-xs text-theme-secondary truncate mt-0.5">${escapeHtml(team.description)}</p>` : ''} 873 </div> 874 <i data-lucide="chevron-right" class="w-4 h-4 text-theme-secondary shrink-0"></i> 875 </div>`; 876 } 877 878 container.innerHTML = html; 879 lucide.createIcons({ root: container }); 880 }, 881 882 _renderChannels(channelsList) { 883 const container = document.getElementById('teams-content'); 884 if (!container) return; 885 886 if (!channelsList.length) { 887 container.innerHTML = ` 888 <div class="text-center py-12 px-4"> 889 <i data-lucide="hash" class="w-8 h-8 text-theme-secondary mx-auto mb-3"></i> 890 <p class="text-sm text-theme-secondary">No channels found</p> 891 </div>`; 892 lucide.createIcons({ root: container }); 893 return; 894 } 895 896 let html = ''; 897 for (const ch of channelsList) { 898 const isPrivate = ch.membershipType === 'private'; 899 const icon = isPrivate ? 'lock' : 'hash'; 900 901 html += ` 902 <div class="teams-item flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-surface-2/50 transition-colors border-b border-theme-30" 903 data-channel-id="${ch.id}"> 904 <div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style="background-color: rgba(91, 95, 199, 0.1);"> 905 <i data-lucide="${icon}" class="w-4 h-4" style="color: ${TEAMS_PURPLE};"></i> 906 </div> 907 <div class="flex-1 min-w-0"> 908 <p class="text-sm font-medium text-theme-primary truncate">${escapeHtml(ch.displayName)}</p> 909 ${ch.description ? `<p class="text-xs text-theme-secondary truncate mt-0.5">${escapeHtml(ch.description)}</p>` : ''} 910 </div> 911 <i data-lucide="chevron-right" class="w-4 h-4 text-theme-secondary shrink-0"></i> 912 </div>`; 913 } 914 915 container.innerHTML = html; 916 lucide.createIcons({ root: container }); 917 }, 918 919 _renderChats(chatsList) { 920 const container = document.getElementById('teams-content'); 921 if (!container) return; 922 923 if (!chatsList.length) { 924 container.innerHTML = ` 925 <div class="text-center py-12 px-4"> 926 <i data-lucide="message-circle" class="w-8 h-8 text-theme-secondary mx-auto mb-3"></i> 927 <p class="text-sm text-theme-secondary">No chats found</p> 928 </div>`; 929 lucide.createIcons({ root: container }); 930 return; 931 } 932 933 const unread = getUnreadChatIds(); 934 935 let html = ''; 936 for (const chat of chatsList) { 937 const chatName = getChatDisplayName(chat); 938 const initials = getInitials(chatName); 939 const avatarColor = getAvatarColor(chatName); 940 const timeStr = chat.lastUpdated ? timeAgo(chat.lastUpdated) : ''; 941 const isUnread = unread.has(chat.id); 942 943 html += ` 944 <div class="teams-item flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-surface-2/50 transition-colors border-b border-theme-30" 945 data-chat-id="${chat.id}"> 946 <div class="w-9 h-9 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0" style="background-color: ${avatarColor};"> 947 ${initials} 948 </div> 949 <div class="flex-1 min-w-0"> 950 <p class="text-sm ${isUnread ? 'font-bold' : 'font-medium'} text-theme-primary truncate">${escapeHtml(chatName)}</p> 951 <p class="text-xs text-theme-secondary mt-0.5">${chat.chatType === 'oneOnOne' ? 'Chat' : chat.chatType === 'meeting' ? 'Meeting chat' : 'Group chat'}</p> 952 </div> 953 <div class="flex flex-col items-end shrink-0">
954 <span class="text-2xs ${isUnread ? 'text-theme-primary font-semibold' : 'text-theme-secondary'}">${timeStr}</span> 955 ${isUnread 956 ? `<span class="w-2.5 h-2.5 rounded-full mt-1.5" style="background-color: ${TEAMS_PURPLE};" aria-label="Unread"></span>` 957 : `<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-theme-secondary mt-1"></i>`} 958 </div> 959 </div>`; 960 } 961 962 container.innerHTML = html; 963 lucide.createIcons({ root: container }); 964 }, 965 966 /** 967 * @param {Array} messages 968 * @param {{ keepScroll?: boolean }} [options] â keepScroll preserves the reader's position 969 * for background appends, only auto-scrolling if they were already near the bottom. 970 */ 971 _renderMessages(messages, { keepScroll = false } = {}) { 972 const container = document.getElementById('teams-content'); 973 if (!container) return; 974 975 // Capture BEFORE the rebuild wipes scroll state. 976 const prevScrollTop = container.scrollTop; 977 const wasNearBottom = 978 container.scrollHeight - container.scrollTop - container.clientHeight < 80; 979 980 if (!messages.length) { 981 container.innerHTML = ` 982 <div class="text-center py-12 px-4"> 983 <i data-lucide="message-square" class="w-8 h-8 text-theme-secondary mx-auto mb-3"></i> 984 <p class="text-sm text-theme-secondary">No messages yet</p> 985 <p class="text-xs text-theme-secondary mt-1">Be the first to send a message!</p> 986 </div>`; 987 lucide.createIcons({ root: container }); 988 return; 989 } 990 991 // Render messages in chronological order (oldest first) 992 const sorted = [...messages].sort((a, b) => 993 new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 994 ); 995 996 let html = '<div class="flex flex-col gap-0.5 py-2">'; 997 let lastSender = null; 998 999 // Attachment actions are chats-only, same scope as sending them (see _updateCompose). 1000 const inChat = _activeTab === 'chats' && !!_selectedChat; 1001 1002 for (const msg of sorted) { 1003 const isNewSender = msg.sender !== lastSender; 1004 lastSender = msg.sender; 1005 const initials = getInitials(msg.sender); 1006 const time = formatMessageTime(msg.createdAt); 1007 const body = msg.contentType === 'html' ? sanitizeMessageHtml(msg.content) : escapeHtml(msg.content); 1008 const attachmentsHtml = renderMessageAttachments(msg.attachments, msg.id, inChat); 1009 1010 // A file-only message has an empty body once the <attachment> placeholder is 1011 // sanitized away â don't leave an empty div padding the bubble. 1012 const bodyBlock = body.trim() 1013 ? `<div class="text-sm text-theme-primary break-words teams-msg-body">${body}</div>` 1014 : ''; 1015 1016 if (isNewSender) { 1017 html += ` 1018 <div class="flex items-start gap-2.5 px-4 pt-3 pb-1"> 1019 <div class="w-7 h-7 rounded-full flex items-center justify-center text-white text-2xs font-bold shrink-0 mt-0.5" style="background-color: ${getAvatarColor(msg.sender)};"> 1020 ${initials} 1021 </div> 1022 <div class="flex-1 min-w-0"> 1023 <div class="flex items-baseline gap-2"> 1024 <span class="text-sm font-semibold text-theme-primary">${escapeHtml(msg.sender)}</span> 1025 <span class="text-2xs text-theme-secondary">${time}</span> 1026 </div> 1027 <div class="mt-0.5">${bodyBlock}${attachmentsHtml}</div> 1028 </div> 1029 </div>`; 1030 } else { 1031 html += ` 1032 <div class="flex items-start gap-2.5 px-4 py-0.5 hover:bg-surface-2/30 transition-colors group"> 1033 <div class="w-7 shrink-0 flex justify-center">
1034 <span class="text-2xs text-theme-secondary opacity-0 group-hover:opacity-100 transition-opacity leading-5">${time}</span> 1035 </div> 1036 <div class="flex-1 min-w-0"> 1037 ${bodyBlock}${attachmentsHtml} 1038 </div> 1039 </div>`; 1040 } 1041 } 1042 1043 html += '</div>'; 1044 container.innerHTML = html; 1045 // Attachment chips carry Lucide icons â this render path didn't need it before. 1046 lucide.createIcons({ root: container }); 1047 1048 // Jump to newest on a normal load; on a background append only follow along if the 1049 // user was already at the bottom, otherwise hold their place. 1050 if (!keepScroll || wasNearBottom) { 1051 container.scrollTop = container.scrollHeight; 1052 } else { 1053 container.scrollTop = prevScrollTop; 1054 } 1055 1056 // If we have a pending message, put it in the compose box 1057 if (this._pendingMessage) { 1058 const input = document.getElementById('teams-compose-input'); 1059 if (input) { 1060 input.value = this._pendingMessage; 1061 input.dispatchEvent(new Event('input')); 1062 input.focus(); 1063 } 1064 this._pendingMessage = null; 1065 } 1066 }, 1067 1068 // âââ UI Helpers âââââââââââââââââââââââââââââââââââââ 1069 1070 _showLoading(text) { 1071 const container = document.getElementById('teams-content'); 1072 if (!container) return; 1073 1074 container.innerHTML = ` 1075 <div class="flex items-center justify-center py-12"> 1076 <div class="w-5 h-5 border-2 border-t-transparent rounded-full animate-spin" style="border-color: ${TEAMS_PURPLE}; border-top-color: transparent;"></div> 1077 <span class="text-xs text-theme-secondary ml-2.5">${escapeHtml(text)}</span> 1078 </div>`; 1079 }, 1080 1081 _showError(title, detail) { 1082 const container = document.getElementById('teams-content'); 1083 if (!container) return; 1084 1085 container.innerHTML = ` 1086 <div class="text-center py-12 px-4"> 1087 <i data-lucide="alert-circle" class="w-8 h-8 text-sb-error mx-auto mb-3"></i> 1088 <p class="text-sm font-medium text-theme-primary">${escapeHtml(title)}</p> 1089 ${detail ? `<p class="text-xs text-theme-secondary mt-1">${escapeHtml(detail)}</p>` : ''} 1090 </div>`; 1091 lucide.createIcons({ root: container }); 1092 }, 1093 1094 _updateBreadcrumbs() { 1095 const container = document.getElementById('teams-breadcrumbs'); 1096 const itemsEl = document.getElementById('teams-breadcrumb-items'); 1097 if (!container || !itemsEl) return; 1098 1099 const inMessages = (_activeTab === 'teams' && _selectedChannel) || (_activeTab === 'chats' && _selectedChat); 1100 const inChannels = _activeTab === 'teams' && _selectedTeam && !_selectedChannel; 1101 1102 if (!inMessages && !inChannels) { 1103 container.classList.add('hidden'); 1104 return; 1105 } 1106 1107 container.classList.remove('hidden'); 1108 let html = ''; 1109 1110 if (_activeTab === 'teams') { 1111 // Root â Team Name â Channel Name 1112 html += `<button class="text-st-primary font-semibold hover:underline flex items-center gap-1" data-nav="root"> 1113 ${TEAMS_ICON_SM} Teams 1114 </button>`; 1115 1116 if (_selectedTeam) { 1117 html += `<i data-lucide="chevron-right" class="w-3 h-3 text-theme-secondary"></i>`; 1118 if (_selectedChannel) { 1119 html += `<button class="btn-st-text-link truncate max-w-24" data-nav="team">${escapeHtml(_selectedTeam.displayName)}</button>`; 1120 html += `<i data-lucide="chevron-right" class="w-3 h-3 text-theme-secondary"></i>`; 1121 html += `<span class="text-theme-primary font-medium truncate max-w-32"># ${escapeHtml(_selectedChannel.displayName)}</span>`; 1122 } else { 1123 html += `<span class="text-theme-primary font-medium truncate max-w-32">${escapeHtml(_selectedTeam.displayName)}</span>`;
1124 } 1125 } 1126 } else { 1127 html += `<button class="text-st-primary font-semibold hover:underline flex items-center gap-1" data-nav="root"> 1128 <i data-lucide="message-circle" class="w-3.5 h-3.5"></i> Chats 1129 </button>`; 1130 1131 if (_selectedChat) { 1132 html += `<i data-lucide="chevron-right" class="w-3 h-3 text-theme-secondary"></i>`; 1133 html += `<span class="text-theme-primary font-medium truncate max-w-40">${escapeHtml(getChatDisplayName(_selectedChat))}</span>`; 1134 } 1135 } 1136 1137 itemsEl.innerHTML = html; 1138 lucide.createIcons({ root: itemsEl }); 1139 }, 1140 1141 _updateCompose() { 1142 const compose = document.getElementById('teams-compose'); 1143 if (!compose) return; 1144 1145 const inMessages = (_activeTab === 'teams' && _selectedChannel) || (_activeTab === 'chats' && _selectedChat); 1146 const inChat = _activeTab === 'chats' && !!_selectedChat; 1147 1148 // Attachments are chats-only: a channel's files belong in the team's SharePoint 1149 // library, which is a different upload path we haven't built. Hide the paperclip 1150 // rather than let it fail, and drop anything staged before we got here. 1151 const attachBtn = document.getElementById('teams-attach-btn'); 1152 if (attachBtn) attachBtn.classList.toggle('hidden', !inChat); 1153 if (!inChat && _stagedFiles.length) this._clearStagedFiles(); 1154 1155 if (inMessages) { 1156 compose.classList.remove('hidden'); 1157 lucide.createIcons({ root: compose }); 1158 1159 // Update placeholder 1160 const input = document.getElementById('teams-compose-input'); 1161 if (input) { 1162 if (_activeTab === 'teams' && _selectedChannel) { 1163 input.placeholder = `Message #${_selectedChannel.displayName}...`; 1164 } else if (_selectedChat) { 1165 input.placeholder = `Message ${getChatDisplayName(_selectedChat)}...`; 1166 } 1167 } 1168 } else { 1169 compose.classList.add('hidden'); 1170 } 1171 1172 // Show/hide refresh button (manual refresh only â messages arrive via SignalR) 1173 const refreshBtn = document.getElementById('teams-refresh-btn'); 1174 if (refreshBtn) { 1175 refreshBtn.classList.toggle('hidden', !inMessages); 1176 if (inMessages) lucide.createIcons({ root: refreshBtn }); 1177 } 1178 }, 1179 1180 _renderMuteButton() { 1181 const btn = document.getElementById('teams-mute-btn'); 1182 if (!btn) return; 1183 1184 const muted = isMuted(); 1185 btn.title = muted ? 'Unmute message toasts' : 'Mute message toasts'; 1186 btn.setAttribute('aria-label', btn.title); 1187 btn.classList.toggle('bg-theme-secondary', muted); 1188 1189 const icon = btn.querySelector('[data-lucide]'); 1190 if (icon) { 1191 icon.dataset.lucide = muted ? 'bell-off' : 'bell'; 1192 lucide.createIcons({ root: btn }); 1193 } 1194 }, 1195 1196 // âââ Live updates (SignalR-driven â no timers) ââââââââ 1197 1198 /** 1199 * A Teams message arrived. The coordinator already fetched the body and handled the 1200 * badge/toast; our only job is the open thread. 1201 */ 1202 _onLiveMessage({ chatId, message, changeType }) { 1203 // Not looking at this chat â just freshen the list ordering / unread dots if it's showing. 1204 if (!(_activeTab === 'chats' && _selectedChat?.id === chatId)) { 1205 if (_activeTab === 'chats' && !_selectedChat && _chatsList.length) { 1206 const entry = _chatsList.find(c => c.id === chatId); 1207 if (entry) { 1208 entry.lastMessageTime = new Date().toISOString(); 1209 entry.lastUpdated = entry.lastMessageTime; 1210 _chatsList.sort((a, b) => 1211 new Date(b.lastMessageTime || 0) - new Date(a.lastMessageTime || 0)); 1212 this._renderChats(_chatsList); 1213 } else { 1214 // A chat we've never seen â reload the list so it appears. 1215 this._loadChats(); 1216 } 1217 } 1218 return; 1219 } 1220 1221 // A delete (or a body we couldn't fetch) needs a re-read to be accurate. 1222 if (changeType === 'deleted' || !message) { 1223 this._refreshMessages(true); 1224 return; 1225 } 1226 1227 if (message.messageType !== 'message') return; 1228 if (_chatMessages.some(m => m.id === message.id)) return; 1229 1230 _chatMessages.push(message); 1231 // keepScroll so a message landing while the user reads back through history doesn't 1232 // yank them to the bottom mid-sentence. 1233 this._renderMessages(_chatMessages, { keepScroll: true }); 1234 markChatRead(chatId); 1235 }, 1236 1237 /** 1238 * Post-reconnect catch-up finished. Refresh the chat list view if that's what's on
1238screen. 1239 */ 1240 _onChatsReconciled({ chats }) { 1241 if (!Array.isArray(chats) || !chats.length) return; 1242 _chatsList = chats; 1243 if (_open && _activeTab === 'chats' && !_selectedChat) { 1244 this._renderChats(_chatsList); 1245 } 1246 }, 1247 1248 // âââ Pin (keep panel open while navigating pages) ââââ 1249 1250 _togglePin() { 1251 _pinned = !_pinned; 1252 const pinBtn = document.getElementById('teams-pin-btn'); 1253 const backdrop = document.getElementById('teams-backdrop'); 1254 const mainContent = document.getElementById('content'); 1255 1256 // Visual feedback on the pin button 1257 if (pinBtn) { 1258 if (_pinned) { 1259 pinBtn.classList.add('bg-theme-secondary'); 1260 pinBtn.title = 'Unpin panel'; 1261 // Swap to pin-off icon 1262 const icon = pinBtn.querySelector('[data-lucide]'); 1263 if (icon) { icon.dataset.lucide = 'pin-off'; lucide.createIcons({ root: pinBtn }); } 1264 } else { 1265 pinBtn.classList.remove('bg-theme-secondary'); 1266 pinBtn.title = 'Pin open'; 1267 const icon = pinBtn.querySelector('[data-lucide]'); 1268 if (icon) { icon.dataset.lucide = 'pin'; lucide.createIcons({ root: pinBtn }); } 1269 } 1270 } 1271 1272 // When pinned: hide backdrop, push main content over 1273 if (_pinned) { 1274 if (backdrop) { 1275 backdrop.classList.remove('opacity-100', 'pointer-events-auto'); 1276 backdrop.classList.add('opacity-0', 'pointer-events-none'); 1277 } 1278 if (mainContent) mainContent.style.marginRight = '32rem'; // max-w-lg = 32rem 1279 } else { 1280 if (backdrop && _open) { 1281 backdrop.classList.remove('opacity-0', 'pointer-events-none'); 1282 backdrop.classList.add('opacity-100', 'pointer-events-auto'); 1283 } 1284 if (mainContent) mainContent.style.marginRight = ''; 1285 } 1286 }, 1287 1288 async _refreshMessages(silent = false) { 1289 // Spin the refresh icon when manual 1290 const refreshBtn = document.getElementById('teams-refresh-btn'); 1291 const icon = refreshBtn?.querySelector('[data-lucide]'); 1292 if (!silent && icon) icon.classList.add('animate-spin'); 1293 1294 try { 1295 if (_activeTab === 'teams' && _selectedTeam && _selectedChannel) { 1296 const result = await teams.getChannelMessages(_selectedTeam.id, _selectedChannel.id); 1297 _channelMessages = result.messages.filter(m => m.messageType === 'message'); 1298 this._renderMessages(_channelMessages); 1299 } else if (_activeTab === 'chats' && _selectedChat) { 1300 const result = await teams.getChatMessages(_selectedChat.id); 1301 _chatMessages = result.messages.filter(m => m.messageType === 'message'); 1302 this._renderMessages(_chatMessages); 1303 } 1304 } catch (err) { 1305 if (!silent) { 1306 console.error('[Teams] Refresh failed:', err); 1307 window.showToast?.('Refresh failed', 'error', { description: err.message }); 1308 } 1309 } finally { 1310 if (!silent && icon) { 1311 setTimeout(() => icon.classList.remove('animate-spin'), 400); 1312 } 1313 } 1314 }, 1315}; 1316 1317// âââ Helpers ââââââââââââââââââââââââââââââââââââââââââââ 1318 1319/** 1320 * File chips under a message. 1321 * 1322 * A Teams file message carries no visible text at all â its body is literally 1323 * `<attachment id="..."></attachment>`, which sanitizeMessageHtml flattens to an empty 1324 * string. Without these chips such a message renders as a blank bubble. 1325 * 1326 * Only `reference` attachments (real files) are rendered. Adaptive cards and other 1327 * attachment types are skipped rather than shown as something they aren't. 1328 * 1329 * The chevron opens the shared attachment action menu (Save to Project Folder · create an 1330 * RFI/CHG/SI/Submittal from the file · Download) â the same menu the Outlook and Gmail
1331 * attachment chips use. It's a SIBLING of the anchor, not a child: a <button> inside an <a> 1332 * is invalid HTML and the anchor would swallow the click. 1333 * 1334 * No file size is shown â Graph doesn't send one on an inbound chatMessageAttachment, and 1335 * resolving each file just to display it would cost a Graph call per file per render. 1336 * 1337 * @param {Array} attachments 1338 * @param {string} messageId 1339 * @param {boolean} withActions â chats only. A channel's files live in the team's SharePoint 1340 * library, which the attachment feature deliberately doesn't cover. 1341 */ 1342function renderMessageAttachments(attachments, messageId, withActions) { 1343 const files = (attachments ?? []).filter(a => a.contentType === 'reference' && a.contentUrl); 1344 if (!files.length) return ''; 1345 1346 const chips = files.map(file => { 1347 const { icon, color } = getMicrosoftFileColor(file.name, false); 1348 const menuBtn = withActions 1349 ? `<button type="button" 1350 class="ext-attach-menu-btn p-1 rounded hover:bg-theme-primary transition-colors shrink-0 flex items-center justify-center" 1351 aria-label="Attachment options" aria-haspopup="menu" aria-expanded="false" title="Options"> 1352 <i data-lucide="chevron-down" class="w-3.5 h-3.5 text-theme-secondary pointer-events-none"></i> 1353 </button>` 1354 : ''; 1355 1356 return ` 1357 <div class="ext-attach-card flex items-center gap-1 pl-2.5 pr-1 py-1.5 rounded-lg border border-theme bg-theme-secondary hover:bg-surface-2/30 transition-colors max-w-xs" 1358 data-source="teams" 1359 data-msg-id="${escapeAttr(messageId ?? '')}" 1360 data-attach-id="${escapeAttr(file.id ?? '')}" 1361 data-filename="${escapeAttr(file.name ?? '')}" 1362 data-content-url="${escapeAttr(file.contentUrl)}"> 1363 <a href="${escapeAttr(file.contentUrl)}" target="_blank" rel="noopener noreferrer" 1364 class="flex items-center gap-2 min-w-0 flex-1" 1365 title="${escapeAttr(file.name)}"> 1366 <i data-lucide="${icon}" class="w-4 h-4 shrink-0 ${color}"></i> 1367 <span class="text-xs font-medium text-theme-primary truncate">${escapeHtml(file.name)}</span> 1368 <i data-lucide="external-link" class="w-3 h-3 text-theme-secondary shrink-0 ml-auto"></i> 1369 </a> 1370 ${menuBtn} 1371 </div>`; 1372 }).join(''); 1373 1374 return `<div class="flex flex-col gap-1.5 mt-1.5">${chips}</div>`; 1375} 1376 1377 1378/** 1379 * Get a deterministic color for avatar based on name string. 1380 */ 1381function getAvatarColor(name) { 1382 const colors = ['#5B5FC7', '#0078D4', '#00A36C', '#C239B3', '#D83B01', '#008272', '#4F6BED', '#CA5010']; 1383 let hash = 0; 1384 for (const ch of (name || '')) hash = ((hash << 5) - hash + ch.charCodeAt(0)) | 0; 1385 return colors[Math.abs(hash) % colors.length]; 1386} 1387 1388/** 1389 * Get display name for a chat (topic, or member names for 1:1/group chats). 1390 */ 1391function getChatDisplayName(chat) { 1392 if (chat.topic) return chat.topic; 1393 1394 // Use member names for unnamed chats 1395 if (chat.members?.length) { 1396 // Get connected user's email for filtering 1397 const myEmail = msGraph.getConnectedEmail() || ''; 1398 const otherMembers = chat.members.filter(m => 1399 m.displayName && !myEmail.toLowerCase().includes(m.displayName.toLowerCase().split(' ')[0]) 1400 ); 1401 1402 if (otherMembers.length > 0) { 1403 return otherMembers.map(m => m.displayName).join(', '); 1404 } 1405 1406 return chat.members.map(m => m.displayName).join(', '); 1407 } 1408 1409 return chat.chatType === 'oneOnOne' ? 'Direct Message' : 'Group Chat'; 1410} 1411 1412 1413function formatMessageTime(isoStr) { 1414 if (!isoStr) return ''; 1415 const d = new Date(isoStr); 1416 return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); 1417} 1418
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.