649 lines
16 KiB
TypeScript
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
// ── 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">×</button>
|
|
</div>
|
|
<div class="message-feed">${feedHTML}</div>
|
|
${composerHTML}
|
|
<a class="full-link" href="/${this.#escapeHtml(space)}/rchats">Open rChats →</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;
|
|
}
|
|
}
|
|
`;
|