Feat v0.5.2 chat surface (#32)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m28s
CI/CD / test-sqlite (push) Successful in 2m42s
CI/CD / build-and-deploy (push) Successful in 1m8s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #32.
This commit is contained in:
2026-03-30 15:25:33 +00:00
committed by xcaliber
parent 7155aaf663
commit 6931b125a4
13 changed files with 1513 additions and 23 deletions

772
packages/chat/js/main.js Normal file
View File

@@ -0,0 +1,772 @@
/**
* Chat — Surface Entry Point (v0.1.0)
*
* Messaging surface built on chat-core library:
* sw.api.ext('chat-core') — conversation/message CRUD
* sw.api.ext('chat') — typing indicators
* sw.realtime — live events
* sw.ui.* — primitive components
* sw.shell.Topbar — navigation bar
*/
(async function () {
'use strict';
var mount = document.getElementById('extension-mount');
if (!mount) return;
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
// ── Boot SDK ───────────────────────────────
try {
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
} catch (e) {
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
return;
}
var { html } = window;
var { useState, useEffect, useCallback, useRef, useMemo } = hooks;
var { render } = preact;
// ── SDK modules ────────────────────────────
var api = sw.api.ext('chat-core');
var chatApi = sw.api.ext('chat');
var { Button, Spinner, Avatar, Dialog, Tabs } = sw.ui;
var Topbar = sw.shell.Topbar;
// Import UserPicker directly (not in sw.ui index)
var { UserPicker } = await import(base + '/js/sw/primitives/user-picker.js?v=' + ver);
// ── Helpers ────────────────────────────────
var _timers = {};
function debounce(key, fn, ms) {
clearTimeout(_timers[key]);
_timers[key] = setTimeout(fn, ms);
}
function timeAgo(ts) {
if (!ts) return '';
var d = new Date(ts);
var now = Date.now();
var diff = Math.floor((now - d.getTime()) / 1000);
if (diff < 60) return 'now';
if (diff < 3600) return Math.floor(diff / 60) + 'm';
if (diff < 86400) return Math.floor(diff / 3600) + 'h';
if (diff < 604800) return Math.floor(diff / 86400) + 'd';
return d.toLocaleDateString();
}
function truncate(str, len) {
if (!str) return '';
return str.length > len ? str.slice(0, len) + '\u2026' : str;
}
function currentUserId() {
return sw.auth?.user?.id || '';
}
function currentDisplayName() {
return sw.auth?.user?.display_name || sw.auth?.user?.username || '';
}
// ═══════════════════════════════════════════
// ConversationList — left sidebar
// ═══════════════════════════════════════════
function ConversationList({ selected, onSelect, onNew, conversations, unread }) {
return html`
<div class="chat-sidebar">
<div class="chat-sidebar__header">
<span class="chat-sidebar__title">Conversations</span>
<${Button} size="sm" onClick=${onNew}>New<//>
</div>
<div class="chat-sidebar__list">
${conversations.length === 0 && html`
<div class="chat-sidebar__empty">No conversations yet</div>`}
${conversations.map(c => html`
<div key=${c.id}
class=${'chat-sidebar__item' + (selected === c.id ? ' chat-sidebar__item--active' : '')}
onClick=${() => onSelect(c.id)}>
<div class="chat-sidebar__item-top">
<span class="chat-sidebar__item-title">${c.title || 'Untitled'}</span>
<span class="chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
</div>
<div class="chat-sidebar__item-bottom">
<span class="chat-sidebar__item-preview">
${c.last_message
? truncate(c.last_message.content_type === 'system'
? '\u2022 ' + c.last_message.content
: c.last_message.content, 60)
: 'No messages yet'}
</span>
${(unread[c.id] || 0) > 0 && html`
<span class="chat-sidebar__badge">${unread[c.id]}</span>`}
</div>
</div>`)}
</div>
</div>`;
}
// ═══════════════════════════════════════════
// MessageBubble — single message
// ═══════════════════════════════════════════
function MessageBubble({ msg, isOwn, onEdit, onDelete }) {
var [editing, setEditing] = useState(false);
var [editText, setEditText] = useState('');
var [hover, setHover] = useState(false);
if (msg._deleted) {
return html`
<div class="chat-msg chat-msg--deleted">
<em>This message was deleted</em>
</div>`;
}
if (msg.content_type === 'system') {
return html`
<div class="chat-msg chat-msg--system">
<span>${msg.content}</span>
</div>`;
}
function startEdit() {
setEditText(msg.content);
setEditing(true);
}
function cancelEdit() {
setEditing(false);
setEditText('');
}
function saveEdit() {
if (editText.trim() && editText !== msg.content) {
onEdit(msg.id, editText.trim());
}
setEditing(false);
}
function onEditKeyDown(e) {
if (e.key === 'Escape') cancelEdit();
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveEdit(); }
}
return html`
<div class=${'chat-msg' + (isOwn ? ' chat-msg--own' : '')}
onMouseEnter=${() => setHover(true)}
onMouseLeave=${() => setHover(false)}>
${!isOwn && html`
<${Avatar} name=${msg._display_name || msg.participant_id} size="sm" />`}
<div class="chat-msg__body">
${!isOwn && html`<span class="chat-msg__name">${msg._display_name || msg.participant_id}</span>`}
${editing ? html`
<div class="chat-msg__edit">
<textarea class="chat-msg__edit-input"
value=${editText}
onInput=${e => setEditText(e.target.value)}
onKeyDown=${onEditKeyDown}
rows="2" />
<div class="chat-msg__edit-actions">
<${Button} size="sm" variant="secondary" onClick=${cancelEdit}>Cancel<//>
<${Button} size="sm" onClick=${saveEdit}>Save<//>
</div>
</div>
` : html`
<div class="chat-msg__content">${msg.content}</div>`}
<div class="chat-msg__meta">
<span class="chat-msg__time">${timeAgo(msg.created_at)}</span>
${msg.edited_at && html`<span class="chat-msg__edited">(edited)</span>`}
</div>
</div>
${hover && isOwn && !editing && html`
<div class="chat-msg__actions">
<button class="chat-msg__action" onClick=${startEdit} title="Edit">&#9998;</button>
<button class="chat-msg__action chat-msg__action--danger" onClick=${() => onDelete(msg.id)} title="Delete">&#128465;</button>
</div>`}
</div>`;
}
// ═══════════════════════════════════════════
// MessageThread — center pane
// ═══════════════════════════════════════════
function MessageThread({ conversationId, participants }) {
var [messages, setMessages] = useState([]);
var [loading, setLoading] = useState(false);
var [hasMore, setHasMore] = useState(false);
var [nextCursor, setNextCursor] = useState('');
var [typingUsers, setTypingUsers] = useState({});
var bottomRef = useRef(null);
var listRef = useRef(null);
var userId = currentUserId();
// Build participant lookup
var partMap = useMemo(() => {
var m = {};
(participants || []).forEach(p => { m[p.participant_id] = p.display_name || p.participant_id; });
return m;
}, [participants]);
// Enrich messages with display names
function enrichMessages(msgs) {
return msgs.map(m => ({ ...m, _display_name: partMap[m.participant_id] || m.participant_id }));
}
// Load initial messages
useEffect(() => {
if (!conversationId) { setMessages([]); return; }
setLoading(true);
setMessages([]);
setNextCursor('');
setHasMore(false);
api.get('/messages/' + conversationId + '?limit=50').then(res => {
var data = res || {};
var msgs = (data.messages || []).reverse();
setMessages(enrichMessages(msgs));
setHasMore(!!data.has_more);
setNextCursor(data.next_cursor || '');
setLoading(false);
setTimeout(() => scrollToBottom(), 50);
}).catch(() => setLoading(false));
}, [conversationId, partMap]);
// Load older messages
function loadMore() {
if (!hasMore || !nextCursor || loading) return;
setLoading(true);
api.get('/messages/' + conversationId + '?limit=50&cursor=' + encodeURIComponent(nextCursor)).then(res => {
var data = res || {};
var older = (data.messages || []).reverse();
setMessages(prev => [...enrichMessages(older), ...prev]);
setHasMore(!!data.has_more);
setNextCursor(data.next_cursor || '');
setLoading(false);
}).catch(() => setLoading(false));
}
function scrollToBottom() {
if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'auto' });
}
// Scroll listener for load-more
useEffect(() => {
var el = listRef.current;
if (!el) return;
function onScroll() {
if (el.scrollTop < 80 && hasMore && !loading) loadMore();
}
el.addEventListener('scroll', onScroll);
return () => el.removeEventListener('scroll', onScroll);
}, [hasMore, loading, nextCursor, conversationId]);
// Realtime: new messages, edits, deletes
useEffect(() => {
if (!conversationId) return;
var channel = 'conversation:' + conversationId;
var unsubs = [
sw.realtime.subscribe(channel, 'message', (payload) => {
var msg = { ...payload, _display_name: partMap[payload.participant_id] || payload.participant_id };
setMessages(prev => [...prev, msg]);
setTimeout(() => scrollToBottom(), 50);
// Auto mark read if from someone else
if (payload.participant_id !== userId && payload.id) {
api.post('/read/' + conversationId, { last_read_message_id: payload.id }).catch(() => {});
}
}),
sw.realtime.subscribe(channel, 'message.edited', (payload) => {
setMessages(prev => prev.map(m =>
m.id === payload.id ? { ...m, content: payload.content, edited_at: 'true' } : m
));
}),
sw.realtime.subscribe(channel, 'message.deleted', (payload) => {
setMessages(prev => prev.map(m =>
m.id === payload.id ? { ...m, _deleted: true, content: '' } : m
));
}),
];
// Typing indicators
var typingTimers = {};
unsubs.push(sw.realtime.subscribe(channel, 'typing', (payload) => {
var pid = payload.participant_id;
if (pid === userId) return;
setTypingUsers(prev => ({ ...prev, [pid]: payload.display_name || pid }));
clearTimeout(typingTimers[pid]);
typingTimers[pid] = setTimeout(() => {
setTypingUsers(prev => {
var next = { ...prev };
delete next[pid];
return next;
});
}, 4000);
}));
return () => {
unsubs.forEach(fn => fn());
Object.values(typingTimers).forEach(clearTimeout);
setTypingUsers({});
};
}, [conversationId, partMap, userId]);
// Mark read on first load
useEffect(() => {
if (!conversationId || messages.length === 0) return;
var last = messages[messages.length - 1];
if (last && last.id) {
api.post('/read/' + conversationId, { last_read_message_id: last.id }).catch(() => {});
}
}, [conversationId, messages.length > 0]);
// Edit message
function handleEdit(msgId, newContent) {
api.put('/messages/' + conversationId + '/' + msgId, { content: newContent }).then(() => {
setMessages(prev => prev.map(m =>
m.id === msgId ? { ...m, content: newContent, edited_at: 'true' } : m
));
}).catch(() => {});
}
// Delete message
async function handleDelete(msgId) {
var ok = await sw.ui.confirm('Delete this message?', { destructive: true });
if (!ok) return;
api.del('/messages/' + conversationId + '/' + msgId).then(() => {
setMessages(prev => prev.map(m =>
m.id === msgId ? { ...m, _deleted: true, content: '' } : m
));
}).catch(() => {});
}
// Typing indicator text
var typingNames = Object.values(typingUsers);
var typingText = '';
if (typingNames.length === 1) typingText = typingNames[0] + ' is typing\u2026';
else if (typingNames.length === 2) typingText = typingNames.join(' and ') + ' are typing\u2026';
else if (typingNames.length > 2) typingText = 'Several people are typing\u2026';
if (!conversationId) {
return html`
<div class="chat-thread chat-thread--empty">
<p>Select a conversation or start a new one</p>
</div>`;
}
return html`
<div class="chat-thread">
<div class="chat-thread__messages" ref=${listRef}>
${loading && messages.length === 0 && html`<div class="chat-thread__loading"><${Spinner} /></div>`}
${hasMore && html`
<button class="chat-thread__load-more" onClick=${loadMore} disabled=${loading}>
Load older messages
</button>`}
${messages.map(m => html`
<${MessageBubble}
key=${m.id}
msg=${m}
isOwn=${m.participant_id === userId}
onEdit=${handleEdit}
onDelete=${handleDelete}
/>`)}
<div ref=${bottomRef} />
</div>
${typingText && html`<div class="chat-thread__typing">${typingText}</div>`}
</div>`;
}
// ═══════════════════════════════════════════
// ComposeBar — message input
// ═══════════════════════════════════════════
function ComposeBar({ conversationId }) {
var [text, setText] = useState('');
var [sending, setSending] = useState(false);
var textareaRef = useRef(null);
// Reset on conversation change
useEffect(() => { setText(''); }, [conversationId]);
// Auto-focus
useEffect(() => {
if (textareaRef.current) textareaRef.current.focus();
}, [conversationId]);
// Auto-resize
function autoResize(el) {
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
}
function handleInput(e) {
setText(e.target.value);
autoResize(e.target);
// Typing indicator (debounced)
debounce('typing', () => {
chatApi.post('/typing/' + conversationId, {
display_name: currentDisplayName(),
}).catch(() => {});
}, 3000);
}
async function handleSend() {
var content = text.trim();
if (!content || sending) return;
setSending(true);
try {
await api.post('/messages/' + conversationId, { content: content, content_type: 'text' });
setText('');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.focus();
}
} catch (e) {
console.error('[chat] send failed:', e);
}
setSending(false);
}
function onKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
if (!conversationId) return null;
return html`
<div class="chat-compose">
<textarea class="chat-compose__input"
ref=${textareaRef}
value=${text}
placeholder="Type a message\u2026"
rows="1"
onInput=${handleInput}
onKeyDown=${onKeyDown} />
<${Button} onClick=${handleSend} disabled=${!text.trim() || sending}>Send<//>
</div>`;
}
// ═══════════════════════════════════════════
// ParticipantSidebar — right panel
// ═══════════════════════════════════════════
function ParticipantSidebar({ conversationId, participants, onRefresh, isAdmin }) {
var [addOpen, setAddOpen] = useState(false);
var [presence, setPresence] = useState({});
// Query presence
useEffect(() => {
if (!participants || participants.length === 0) return;
var ids = participants.map(p => p.participant_id).join(',');
sw.api.get('/api/v1/presence?users=' + ids).then(res => {
setPresence(res || {});
}).catch(() => {});
}, [participants]);
async function addUser(user) {
setAddOpen(false);
await api.post('/participants/' + conversationId, {
participant_id: user.id,
display_name: user.display_name || user.username,
}).catch(() => {});
onRefresh();
}
async function removeUser(pid) {
var ok = await sw.ui.confirm('Remove this participant?', { destructive: true });
if (!ok) return;
await api.del('/participants/' + conversationId + '/' + pid).catch(() => {});
onRefresh();
}
return html`
<div class="chat-participants">
<div class="chat-participants__header">
<span>Participants (${(participants || []).length})</span>
${isAdmin && html`<${Button} size="sm" onClick=${() => setAddOpen(true)}>Add<//>` }
</div>
<div class="chat-participants__list">
${(participants || []).map(p => html`
<div key=${p.participant_id} class="chat-participants__item">
<${Avatar} name=${p.display_name || p.participant_id} size="sm" />
<span class="chat-participants__name">
${p.display_name || p.participant_id}
${p.role === 'admin' && html`<span class="chat-participants__badge">admin</span>`}
</span>
<span class=${'chat-participants__status' + (presence[p.participant_id] ? ' chat-participants__status--online' : '')} />
${isAdmin && p.participant_id !== currentUserId() && html`
<button class="chat-participants__remove" onClick=${() => removeUser(p.participant_id)} title="Remove">\u00d7</button>`}
</div>`)}
</div>
<${Dialog} open=${addOpen} title="Add Participant" onClose=${() => setAddOpen(false)}>
<${UserPicker} onSelect=${addUser} placeholder="Search users\u2026" autoFocus />
<//>
</div>`;
}
// ═══════════════════════════════════════════
// NewConversationDialog
// ═══════════════════════════════════════════
function NewConversationDialog({ open, onClose, onCreated }) {
var [type, setType] = useState('group');
var [title, setTitle] = useState('');
var [selected, setSelected] = useState([]);
var [creating, setCreating] = useState(false);
function reset() {
setType('group');
setTitle('');
setSelected([]);
setCreating(false);
}
function addUser(user) {
// Avoid duplicates
if (selected.find(u => u.id === user.id)) return;
// For DM, only allow one user
if (type === 'direct' && selected.length >= 1) {
setSelected([user]);
return;
}
setSelected(prev => [...prev, user]);
}
function removeSelected(id) {
setSelected(prev => prev.filter(u => u.id !== id));
}
async function handleCreate() {
if (selected.length === 0) return;
setCreating(true);
try {
var convTitle = type === 'direct'
? ''
: (title.trim() || selected.map(u => u.display_name || u.username).join(', '));
var participants = selected.map(u => ({
id: u.id,
display_name: u.display_name || u.username,
}));
var res = await api.post('/conversations', {
title: convTitle,
type: type,
participants: participants,
creator_display_name: currentDisplayName(),
});
reset();
onClose();
onCreated(res.id || res.data?.id);
} catch (e) {
console.error('[chat] create failed:', e);
}
setCreating(false);
}
var actions = [
{ label: 'Cancel', variant: 'secondary', onClick: () => { reset(); onClose(); } },
{ label: 'Create', variant: 'primary', onClick: handleCreate, disabled: creating || selected.length === 0 },
];
return html`
<${Dialog} open=${open} title="New Conversation" onClose=${() => { reset(); onClose(); }} actions=${actions}>
<div class="chat-new">
<div class="chat-new__type">
<label>
<input type="radio" name="convType" value="group"
checked=${type === 'group'} onChange=${() => { setType('group'); setSelected([]); }} />
Group
</label>
<label>
<input type="radio" name="convType" value="direct"
checked=${type === 'direct'} onChange=${() => { setType('direct'); setSelected([]); }} />
Direct Message
</label>
</div>
${type === 'group' && html`
<input class="chat-new__title" type="text" value=${title}
placeholder="Conversation title (optional)"
onInput=${e => setTitle(e.target.value)} />`}
<${UserPicker} onSelect=${addUser} placeholder=${type === 'direct' ? 'Search for a user\u2026' : 'Add participants\u2026'} />
${selected.length > 0 && html`
<div class="chat-new__selected">
${selected.map(u => html`
<span key=${u.id} class="chat-new__chip">
${u.display_name || u.username}
<button onClick=${() => removeSelected(u.id)}>\u00d7</button>
</span>`)}
</div>`}
</div>
<//>`;
}
// ═══════════════════════════════════════════
// ChatApp — root component
// ═══════════════════════════════════════════
function ChatApp() {
var [conversations, setConversations] = useState([]);
var [unread, setUnread] = useState({});
var [selectedId, setSelectedId] = useState(null);
var [participants, setParticipants] = useState([]);
var [showParticipants, setShowParticipants] = useState(false);
var [showNew, setShowNew] = useState(false);
var [loading, setLoading] = useState(true);
var userId = currentUserId();
// Load conversations
// Note: SDK auto-unwraps {data: [...]} envelopes → res is the array directly
function loadConversations() {
return api.get('/conversations').then(res => {
setConversations(Array.isArray(res) ? res : (res && res.data) || []);
}).catch(() => {});
}
// Load unread
// Note: SDK returns {data: {id: count}} — NOT auto-unwrapped (data is object, not array)
function loadUnread() {
return api.get('/unread').then(res => {
setUnread((res && res.data) || (typeof res === 'object' && !Array.isArray(res) ? res : {}));
}).catch(() => {});
}
// Initial load
useEffect(() => {
Promise.all([loadConversations(), loadUnread()]).then(() => setLoading(false));
}, []);
// Load participants when conversation changes
useEffect(() => {
if (!selectedId) { setParticipants([]); return; }
api.get('/participants/' + selectedId).then(res => {
setParticipants(Array.isArray(res) ? res : (res && res.data) || []);
}).catch(() => {});
}, [selectedId]);
// Realtime: refresh conversation list on activity
useEffect(() => {
// Subscribe to all active conversations
var unsubs = [];
conversations.forEach(c => {
unsubs.push(sw.realtime.subscribe('conversation:' + c.id, 'message', () => {
loadConversations();
loadUnread();
}));
unsubs.push(sw.realtime.subscribe('conversation:' + c.id, 'participant.added', () => {
loadConversations();
if (c.id === selectedId) {
api.get('/participants/' + selectedId).then(res => {
setParticipants(Array.isArray(res) ? res : (res && res.data) || []);
}).catch(() => {});
}
}));
unsubs.push(sw.realtime.subscribe('conversation:' + c.id, 'participant.removed', () => {
loadConversations();
if (c.id === selectedId) {
api.get('/participants/' + selectedId).then(res => {
setParticipants(Array.isArray(res) ? res : (res && res.data) || []);
}).catch(() => {});
}
}));
});
return () => unsubs.forEach(fn => fn());
}, [conversations.map(c => c.id).join(','), selectedId]);
// Select conversation
function selectConversation(id) {
setSelectedId(id);
setShowParticipants(false);
// Mark read
setUnread(prev => { var next = { ...prev }; delete next[id]; return next; });
}
// After creating a new conversation
function onCreated(id) {
loadConversations().then(() => {
if (id) setSelectedId(id);
});
}
// Is current user admin in selected conversation?
var isAdmin = useMemo(() => {
return participants.some(p => p.participant_id === userId && p.role === 'admin');
}, [participants, userId]);
// Refresh participants
function refreshParticipants() {
if (!selectedId) return;
api.get('/participants/' + selectedId).then(res => {
setParticipants(Array.isArray(res) ? res : (res && res.data) || []);
}).catch(() => {});
}
// Conversation title for topbar
var selectedConv = conversations.find(c => c.id === selectedId);
var threadTitle = selectedConv ? (selectedConv.title || 'Direct Message') : '';
if (loading) {
return html`<div class="chat-loading"><${Spinner} /></div>`;
}
return html`
<div class="chat-app">
<${Topbar} title="Chat">
${selectedId && html`
<span class="chat-topbar__thread-title">${threadTitle}</span>
<${Button} size="sm" variant="secondary"
onClick=${() => setShowParticipants(!showParticipants)}>
${showParticipants ? 'Hide' : 'People'}
<//>`}
<//>
<div class="chat-body">
<${ConversationList}
selected=${selectedId}
onSelect=${selectConversation}
onNew=${() => setShowNew(true)}
conversations=${conversations}
unread=${unread} />
<div class="chat-main">
<${MessageThread}
conversationId=${selectedId}
participants=${participants} />
<${ComposeBar} conversationId=${selectedId} />
</div>
${showParticipants && selectedId && html`
<${ParticipantSidebar}
conversationId=${selectedId}
participants=${participants}
onRefresh=${refreshParticipants}
isAdmin=${isAdmin} />`}
</div>
<${NewConversationDialog}
open=${showNew}
onClose=${() => setShowNew(false)}
onCreated=${onCreated} />
</div>`;
}
// ── Mount ──────────────────────────────────
render(html`<${ChatApp} />`, mount);
})();