PageSourceSearch

https://smrtbld.ai/js/components/TeamsPanel.js

js smrtbld.ai collected 2026-10-05 15:07:08 UTC 62,186 bytes, 1,418 lines download raw bytes

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.