/** * — Global chat panel in the header. * * Shows a chat icon + unread badge. Click toggles a right-side sliding panel * with channel selector, message feed, and composer. Works on every page. */ import { getSession } from "./rstack-identity"; const POLL_CLOSED_MS = 30_000; // badge poll when panel closed const POLL_OPEN_MS = 10_000; // message poll when panel open (REST fallback) interface Channel { id: string; name: string; description: string } interface Message { id: string; channelId: string; authorId: string; authorName: string; content: string; createdAt: number; editedAt: number | null; reactions: Record; } export class RStackChatWidget extends HTMLElement { #shadow: ShadowRoot; #open = false; #channels: Channel[] = []; #activeChannelId = ""; #messages: Message[] = []; #unreadCount = 0; #composerText = ""; #sending = false; #loading = false; #badgeTimer: ReturnType | null = null; #msgTimer: ReturnType | null = null; constructor() { super(); this.#shadow = this.attachShadow({ mode: "open" }); } get #space(): string { return document.body?.getAttribute("data-space-slug") || ""; } get #token(): string | null { return getSession()?.accessToken ?? null; } get #basePath(): string { return `/${this.#space}/rchats`; } // ── Lifecycle ── connectedCallback() { // Restore persisted state try { this.#open = localStorage.getItem("rspace_chat_open") === "1"; this.#activeChannelId = localStorage.getItem(`rspace_chat_channel_${this.#space}`) || ""; } catch {} this.#render(); this.#fetchUnreadCount(); this.#badgeTimer = setInterval(() => this.#fetchUnreadCount(), POLL_CLOSED_MS); if (this.#open) this.#openPanel(); document.addEventListener("auth-change", this.#onAuthChange); } disconnectedCallback() { if (this.#badgeTimer) clearInterval(this.#badgeTimer); if (this.#msgTimer) clearInterval(this.#msgTimer); document.removeEventListener("auth-change", this.#onAuthChange); } #onAuthChange = () => { this.#unreadCount = 0; this.#messages = []; this.#channels = []; this.#render(); this.#fetchUnreadCount(); }; // ── Data fetching ── async #fetchUnreadCount() { if (!this.#space) return; const since = this.#getLastRead(); try { const res = await fetch(`${this.#basePath}/api/unread-count?since=${since}`); if (res.ok) { const data = await res.json(); this.#unreadCount = data.count || 0; this.#render(); } } catch {} } async #fetchChannels() { if (!this.#space) return; try { const headers: Record = {}; if (this.#token) headers.Authorization = `Bearer ${this.#token}`; const res = await fetch(`${this.#basePath}/api/channels`, { headers }); if (res.ok) { const data = await res.json(); this.#channels = data.channels || []; if (!this.#activeChannelId && this.#channels.length > 0) { this.#activeChannelId = this.#channels[0].id; } } } catch {} } async #fetchMessages() { if (!this.#space || !this.#activeChannelId) return; try { const headers: Record = {}; if (this.#token) headers.Authorization = `Bearer ${this.#token}`; const res = await fetch(`${this.#basePath}/api/channels/${this.#activeChannelId}/messages`, { headers }); if (res.ok) { const data = await res.json(); this.#messages = (data.messages || []).slice(-100); // last 100 this.#markChannelRead(); this.#render(); this.#scrollToBottom(); } } catch {} } async #sendMessage() { if (!this.#composerText.trim() || !this.#token || this.#sending) return; this.#sending = true; this.#render(); try { // Auto-join channel await fetch(`${this.#basePath}/api/channels/${this.#activeChannelId}/join`, { method: "POST", headers: { Authorization: `Bearer ${this.#token}` }, }).catch(() => {}); const res = await fetch(`${this.#basePath}/api/channels/${this.#activeChannelId}/messages`, { method: "POST", headers: { Authorization: `Bearer ${this.#token}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: this.#composerText.trim() }), }); if (res.ok) { this.#composerText = ""; await this.#fetchMessages(); } } catch {} this.#sending = false; this.#render(); // Re-focus composer const input = this.#shadow.querySelector(".composer-input"); input?.focus(); } // ── Panel toggle ── async #openPanel() { this.#open = true; try { localStorage.setItem("rspace_chat_open", "1"); } catch {} this.#loading = true; this.#render(); await this.#fetchChannels(); if (this.#activeChannelId) { await this.#fetchMessages(); } this.#loading = false; this.#render(); this.#scrollToBottom(); // Start message polling if (this.#msgTimer) clearInterval(this.#msgTimer); this.#msgTimer = setInterval(() => this.#fetchMessages(), POLL_OPEN_MS); } #closePanel() { this.#open = false; try { localStorage.setItem("rspace_chat_open", "0"); } catch {} if (this.#msgTimer) { clearInterval(this.#msgTimer); this.#msgTimer = null; } this.#render(); } #togglePanel() { if (this.#open) this.#closePanel(); else this.#openPanel(); } async #switchChannel(id: string) { this.#activeChannelId = id; try { localStorage.setItem(`rspace_chat_channel_${this.#space}`, id); } catch {} this.#messages = []; this.#render(); await this.#fetchMessages(); } // ── Last-read tracking ── #getLastRead(): number { try { const key = `rspace_chat_last_read_${this.#space}`; return parseInt(localStorage.getItem(key) || "0", 10) || 0; } catch { return 0; } } #markChannelRead() { try { const key = `rspace_chat_last_read_${this.#space}`; localStorage.setItem(key, String(Date.now())); } catch {} this.#unreadCount = 0; } // ── Helpers ── #scrollToBottom() { requestAnimationFrame(() => { const feed = this.#shadow.querySelector(".message-feed"); if (feed) feed.scrollTop = feed.scrollHeight; }); } #timeAgo(ts: number): string { const diff = Date.now() - ts; const mins = Math.floor(diff / 60_000); if (mins < 1) return "now"; if (mins < 60) return `${mins}m`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h`; return `${Math.floor(hrs / 24)}d`; } #escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } // ── Render ── #render() { const space = this.#space; if (!space) { this.#shadow.innerHTML = ""; return; } const badge = this.#unreadCount > 0 ? `${this.#unreadCount > 99 ? "99+" : this.#unreadCount}` : ""; let panelHTML = ""; if (this.#open) { // Channel selector const channelOptions = this.#channels.map(ch => `` ).join(""); // Messages let feedHTML: string; if (this.#loading) { feedHTML = `
Loading...
`; } else if (this.#messages.length === 0) { feedHTML = `
No messages yet. Start the conversation!
`; } else { feedHTML = this.#messages.map(m => { const reactions = Object.entries(m.reactions || {}) .filter(([, users]) => users.length > 0) .map(([emoji, users]) => `${emoji} ${users.length}`) .join(""); return `
${this.#escapeHtml(m.authorName)} ${this.#timeAgo(m.createdAt)}
${this.#escapeHtml(m.content)}
${reactions ? `
${reactions}
` : ""}
`; }).join(""); } // Composer const session = getSession(); const composerHTML = session ? `
` : ``; panelHTML = `
${feedHTML}
${composerHTML} Open rChats →
`; } this.#shadow.innerHTML = `
${panelHTML}
`; this.#bindEvents(); } #bindEvents() { // Toggle button this.#shadow.querySelector(".chat-btn")?.addEventListener("click", (e) => { e.stopPropagation(); this.#togglePanel(); }); // Close button this.#shadow.querySelector(".close-btn")?.addEventListener("click", (e) => { e.stopPropagation(); this.#closePanel(); }); // Channel selector this.#shadow.querySelector(".channel-select")?.addEventListener("change", (e) => { const val = (e.target as HTMLSelectElement).value; if (val !== this.#activeChannelId) this.#switchChannel(val); }); // Composer input const input = this.#shadow.querySelector(".composer-input"); if (input) { // Sync text on input input.addEventListener("input", () => { this.#composerText = input.value; // Auto-resize input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 80) + "px"; }); // Enter to send, Shift+Enter newline input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); this.#sendMessage(); } }); } // Send button this.#shadow.querySelector(".composer-send")?.addEventListener("click", (e) => { e.stopPropagation(); this.#sendMessage(); }); // Stop panel clicks from propagating this.#shadow.querySelector(".panel")?.addEventListener("pointerdown", (e) => e.stopPropagation()); // Close on outside click if (this.#open) { const closeOnOutside = () => { if (this.#open) this.#closePanel(); }; document.addEventListener("pointerdown", closeOnOutside, { once: true }); } } static define(tag = "rstack-chat-widget") { if (!customElements.get(tag)) customElements.define(tag, RStackChatWidget); } } // ============================================================================ // STYLES // ============================================================================ const STYLES = ` :host { display: inline-flex; align-items: center; } .widget-wrapper { position: relative; } .chat-btn { position: relative; background: none; border: none; color: var(--rs-text-muted, #94a3b8); cursor: pointer; padding: 6px; border-radius: 8px; display: flex; align-items: center; justify-content: center; transition: color 0.15s, background 0.15s; } .chat-btn:hover, .chat-btn:active { color: var(--rs-text-primary, #e2e8f0); background: var(--rs-bg-hover, rgba(255,255,255,0.05)); } .badge { position: absolute; top: 2px; right: 2px; min-width: 16px; height: 16px; border-radius: 8px; background: #ef4444; color: white; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; line-height: 1; pointer-events: none; } /* ── Panel ── */ .panel { position: fixed; top: 56px; right: 0; bottom: 0; width: 360px; z-index: 200; background: var(--rs-bg-surface, #1e293b); border-left: 1px solid var(--rs-border, rgba(255,255,255,0.1)); box-shadow: -4px 0 20px rgba(0,0,0,0.25); display: flex; flex-direction: column; transform: translateX(100%); transition: transform 0.2s ease; } .panel.open { transform: translateX(0); } .panel-header { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.1)); flex-shrink: 0; } .channel-select { flex: 1; background: var(--rs-bg-hover, rgba(255,255,255,0.05)); color: var(--rs-text-primary, #e2e8f0); border: 1px solid var(--rs-border, rgba(255,255,255,0.1)); border-radius: 6px; padding: 6px 8px; font-size: 0.8rem; cursor: pointer; outline: none; } .channel-select:focus { border-color: var(--rs-accent, #06b6d4); } .channel-select option { background: var(--rs-bg-surface, #1e293b); color: var(--rs-text-primary, #e2e8f0); } .close-btn { background: none; border: none; color: var(--rs-text-muted, #94a3b8); font-size: 1.25rem; cursor: pointer; padding: 4px 8px; border-radius: 6px; line-height: 1; transition: color 0.15s, background 0.15s; } .close-btn:hover { color: var(--rs-text-primary, #e2e8f0); background: var(--rs-bg-hover, rgba(255,255,255,0.05)); } /* ── Message feed ── */ .message-feed { flex: 1; overflow-y: auto; padding: 8px 0; } .empty { padding: 32px 16px; text-align: center; color: var(--rs-text-muted, #94a3b8); font-size: 0.8rem; } .msg { padding: 6px 12px; transition: background 0.1s; } .msg:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.03)); } .msg-header { display: flex; align-items: baseline; gap: 6px; } .msg-author { font-size: 0.75rem; font-weight: 600; color: var(--rs-accent, #06b6d4); } .msg-time { font-size: 0.65rem; color: var(--rs-text-muted, #64748b); } .msg-content { font-size: 0.8rem; color: var(--rs-text-primary, #e2e8f0); line-height: 1.4; white-space: pre-wrap; word-break: break-word; margin-top: 1px; } .msg-reactions { display: flex; gap: 4px; margin-top: 3px; flex-wrap: wrap; } .reaction { font-size: 0.7rem; background: var(--rs-bg-hover, rgba(255,255,255,0.05)); border: 1px solid var(--rs-border, rgba(255,255,255,0.1)); border-radius: 10px; padding: 1px 6px; color: var(--rs-text-muted, #94a3b8); } /* ── Composer ── */ .composer { display: flex; align-items: flex-end; gap: 6px; padding: 8px 12px; border-top: 1px solid var(--rs-border, rgba(255,255,255,0.1)); flex-shrink: 0; } .composer-input { flex: 1; background: var(--rs-bg-hover, rgba(255,255,255,0.05)); color: var(--rs-text-primary, #e2e8f0); border: 1px solid var(--rs-border, rgba(255,255,255,0.1)); border-radius: 8px; padding: 8px 10px; font-size: 0.8rem; font-family: inherit; resize: none; outline: none; min-height: 36px; max-height: 80px; line-height: 1.4; } .composer-input::placeholder { color: var(--rs-text-muted, #64748b); } .composer-input:focus { border-color: var(--rs-accent, #06b6d4); } .composer-send { background: var(--rs-accent, #06b6d4); border: none; color: white; border-radius: 8px; padding: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: opacity 0.15s; flex-shrink: 0; } .composer-send:hover { opacity: 0.85; } .composer-send:disabled { opacity: 0.5; cursor: not-allowed; } .composer-signin { padding: 10px 12px; text-align: center; color: var(--rs-text-muted, #64748b); font-size: 0.8rem; border-top: 1px solid var(--rs-border, rgba(255,255,255,0.1)); flex-shrink: 0; } /* ── Full page link ── */ .full-link { display: block; padding: 8px 12px; text-align: center; font-size: 0.75rem; color: var(--rs-accent, #06b6d4); text-decoration: none; border-top: 1px solid var(--rs-border, rgba(255,255,255,0.1)); flex-shrink: 0; transition: background 0.15s; } .full-link:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.05)); } /* ── Mobile ── */ @media (max-width: 640px) { .panel { width: 100%; left: 0; } } `;