rspace-online/shared/components/rstack-chat-widget.ts

649 lines
16 KiB
TypeScript

/**
* <rstack-chat-widget> — 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<string, string[]>;
}
export class RStackChatWidget extends HTMLElement {
#shadow: ShadowRoot;
#open = false;
#channels: Channel[] = [];
#activeChannelId = "";
#messages: Message[] = [];
#unreadCount = 0;
#composerText = "";
#sending = false;
#loading = false;
#badgeTimer: ReturnType<typeof setInterval> | null = null;
#msgTimer: ReturnType<typeof setInterval> | 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<string, string> = {};
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<string, string> = {};
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<HTMLTextAreaElement>(".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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ── Render ──
#render() {
const space = this.#space;
if (!space) { this.#shadow.innerHTML = ""; return; }
const badge = this.#unreadCount > 0
? `<span class="badge">${this.#unreadCount > 99 ? "99+" : this.#unreadCount}</span>`
: "";
let panelHTML = "";
if (this.#open) {
// Channel selector
const channelOptions = this.#channels.map(ch =>
`<option value="${this.#escapeHtml(ch.id)}" ${ch.id === this.#activeChannelId ? "selected" : ""}>#${this.#escapeHtml(ch.name)}</option>`
).join("");
// Messages
let feedHTML: string;
if (this.#loading) {
feedHTML = `<div class="empty">Loading...</div>`;
} else if (this.#messages.length === 0) {
feedHTML = `<div class="empty">No messages yet. Start the conversation!</div>`;
} else {
feedHTML = this.#messages.map(m => {
const reactions = Object.entries(m.reactions || {})
.filter(([, users]) => users.length > 0)
.map(([emoji, users]) => `<span class="reaction">${emoji} ${users.length}</span>`)
.join("");
return `<div class="msg">
<div class="msg-header">
<span class="msg-author">${this.#escapeHtml(m.authorName)}</span>
<span class="msg-time">${this.#timeAgo(m.createdAt)}</span>
</div>
<div class="msg-content">${this.#escapeHtml(m.content)}</div>
${reactions ? `<div class="msg-reactions">${reactions}</div>` : ""}
</div>`;
}).join("");
}
// Composer
const session = getSession();
const composerHTML = session
? `<div class="composer">
<textarea class="composer-input" placeholder="Type a message..." rows="1">${this.#escapeHtml(this.#composerText)}</textarea>
<button class="composer-send" ${this.#sending ? "disabled" : ""}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13"/><path d="M22 2L15 22L11 13L2 9L22 2Z"/></svg>
</button>
</div>`
: `<div class="composer-signin">Sign in to chat</div>`;
panelHTML = `
<div class="panel open">
<div class="panel-header">
<select class="channel-select">${channelOptions}</select>
<button class="close-btn" title="Close">&times;</button>
</div>
<div class="message-feed">${feedHTML}</div>
${composerHTML}
<a class="full-link" href="/${this.#escapeHtml(space)}/rchats">Open rChats &rarr;</a>
</div>`;
}
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<div class="widget-wrapper">
<button class="chat-btn" aria-label="Chat">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
${badge}
</button>
${panelHTML}
</div>
`;
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<HTMLTextAreaElement>(".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;
}
}
`;