diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b119bb..a4b7e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,59 @@ All notable changes to Switchboard Core are documented here. +## v0.5.2 β€” Chat Surface + +### Added + +- **`chat` surface package**: Full messaging UI built on `chat-core` library. + Route `/s/chat`, icon `πŸ’¬`, depends on `chat-core`. +- **Conversation list sidebar**: Conversations sorted by recent activity, last + message preview, relative timestamps, unread count badges. "New" button + for conversation creation. +- **Message thread**: Paginated message history with cursor-based load-more on + scroll-to-top. Participant avatars, display names, timestamps. Scroll-to- + bottom on new messages. +- **Compose bar**: Auto-resizing textarea, Enter-to-send, Shift+Enter for + newline. Typing indicator broadcast on keypress (3s debounce). +- **Participant sidebar**: Collapsible right panel with participant list, online + status via presence hub, role badges. Add participant via `sw.ui.Dialog` + + `sw.ui.UserPicker`. Remove participant with confirmation. +- **Create conversation dialog**: Group or Direct Message type selector. Multi- + user picker with chip display for group, single picker for DM. Auto-title + from participant names. +- **Typing indicators**: Realtime broadcast via `POST /typing/:id` Starlark + endpoint. Frontend subscribes to `conversation:{id}` channel, shows + "X is typing..." with 4s auto-expire. Handles multiple typers. +- **Read receipts**: Auto mark-read on conversation focus and new incoming + messages. Unread counts in conversation list, cleared on selection. +- **Message editing**: Inline edit mode on own messages with textarea. Save on + Enter, cancel on Escape. "(edited)" label on edited messages. Realtime + propagation to other participants. +- **Message deletion**: Confirm dialog before delete. "This message was deleted" + placeholder. Admin can delete any message. Realtime propagation. + +### Fixed + +- **Stale token login deadlock**: REST client's 401β†’refreshβ†’retry loop caused + a deadlock when the refresh endpoint itself returned 401 (stale DB). Auth + endpoints (`/auth/login`, `/auth/refresh`, `/auth/register`) are now excluded + from the retry loop. Defensive 8s timeout on `auth.boot()` ensures login + page always renders even on unexpected hangs. +- **Bundled package permissions**: Bundled packages were installed with + `pending_review` status and `granted=0` permissions, requiring manual admin + approval. Bundled installer now auto-grants all declared permissions and + sets status to `active` on install. +- **Creator display name**: `chat-core.create()` now accepts + `creator_display_name` so the conversation creator shows a readable name + in the participant sidebar instead of a raw UUID. + +### Changed + +- **Named Docker volume**: `docker-compose.yml` switched from bind mount + (`./data:/data`) to named volume (`sb_data:/data`). Fixes volume mount + failures when Docker daemon runs on a remote host (DinD, k8s pod). + Deterministic wipe via `docker compose down -v`. + ## v0.5.1 β€” Chat Core Library ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 0959089..686701b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,19 +358,19 @@ participation is post-MVP. | Admin filter Dropdown | βœ… | Replaced button group with themed `Dropdown` primitive for type filter. Keyboard nav, consistent theming via CSS vars. | | Unicode security gate | βœ… | `ScanSource()` detects variation selectors, bidi overrides, zero-width chars, tag characters. `Verdict()` blocks GlassWorm payloads and Trojan Source (CVE-2021-42574). Two enforcement points: install (422) + Starlark exec. 23 new tests. | -### v0.5.2 β€” Chat Surface (planned) +### v0.5.2 β€” Chat Surface | Step | Status | Description | |------|--------|-------------| -| Conversation list | | Sidebar: conversation list with last message preview, unread badge, timestamp. Create conversation button. | -| Message thread | | Main pane: message history with participant avatars, timestamps, content. Virtual scroll for long threads. | -| Compose bar | | Text input with Enter-to-send, Shift+Enter for newline. Markdown support via existing inline renderer. | -| Participant sidebar | | Collapsible right panel: participant list with online status (via kernel presence hub). Add/remove participants using `sw.ui.Dialog`. | -| 1:1 and group | | Direct messages (two participants) and group conversations. Type field on conversation. | -| Typing indicators | | `realtime.publish("conversation:{id}", "typing", {participant_id})` on keypress with debounce. Surface shows "X is typing…" with auto-expire. | -| Read receipts | | Mark-read on scroll/focus via `chat.mark_read()`. Unread count in conversation list. | -| Message editing | | Edit own messages. `edited_at` timestamp displayed. Edit via `PUT /messages/:id`. | -| Message deletion | | Soft-delete own messages. Replaced with "message deleted" placeholder. | +| Conversation list | done | Sidebar: conversation list with last message preview, unread badge, timestamp. Create conversation button. | +| Message thread | done | Main pane: message history with participant avatars, timestamps, content. Scroll-to-load-more for long threads. | +| Compose bar | done | Text input with Enter-to-send, Shift+Enter for newline. Auto-resize textarea. | +| Participant sidebar | done | Collapsible right panel: participant list with online status (via kernel presence hub). Add/remove participants using `sw.ui.Dialog`. | +| 1:1 and group | done | Direct messages (two participants) and group conversations. Type field on conversation. | +| Typing indicators | done | `realtime.publish("conversation:{id}", "typing", {participant_id})` on keypress with debounce. Surface shows "X is typing…" with auto-expire. | +| Read receipts | done | Mark-read on scroll/focus via `chat.mark_read()`. Unread count in conversation list. | +| Message editing | done | Edit own messages. `edited_at` timestamp displayed. Edit via `PUT /messages/:id`. | +| Message deletion | done | Soft-delete own messages. Replaced with "message deleted" placeholder. | ### v0.5.3 β€” Chat Polish + Integration Testing (planned) diff --git a/VERSION b/VERSION index 4b9fcbe..cb0c939 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.1 +0.5.2 diff --git a/docker-compose.yml b/docker-compose.yml index be37571..bb1419f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,8 +7,8 @@ # docker compose up --build # open http://localhost:3000 # -# Data persists in ./data/ between restarts. -# To reset: rm -rf ./data/ +# Data persists in the `sb_data` named volume between restarts. +# To reset: docker compose down -v # # For Postgres / multi-replica / k8s deployment see k8s/ and Dockerfile. @@ -37,7 +37,10 @@ services: # Dev seed users β€” ignored if ENVIRONMENT=production SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user} volumes: - - ./data:/data + - sb_data:/data ports: - "3000:80" restart: unless-stopped + +volumes: + sb_data: diff --git a/packages/chat-core/script.star b/packages/chat-core/script.star index 3bc9845..5b0be12 100644 --- a/packages/chat-core/script.star +++ b/packages/chat-core/script.star @@ -66,7 +66,7 @@ def _now(): # Exported API (lib.require("chat-core")) # ═══════════════════════════════════════════════ -def create(title, type="group", participants=None, creator_id=""): +def create(title, type="group", participants=None, creator_id="", creator_display_name=""): """Create a conversation and add initial participants. Args: @@ -74,6 +74,7 @@ def create(title, type="group", participants=None, creator_id=""): type: "direct" or "group" (default "group") participants: list of dicts with {id, type?, display_name?, role?} creator_id: user ID of the creator (added as admin) + creator_display_name: display name of the creator Returns: dict with conversation data @@ -95,7 +96,7 @@ def create(title, type="group", participants=None, creator_id=""): "conversation_id": cid, "participant_id": _str(creator_id), "participant_type": "user", - "display_name": "", + "display_name": _str(creator_display_name), "role": "admin", "joined_at": "", }) @@ -430,8 +431,9 @@ def _handle_create_conversation(req, user_id): title = _str(body.get("title", "")) conv_type = _str(body.get("type", "group")) participants_list = body.get("participants", []) + creator_name = _str(body.get("creator_display_name", "")) - conv = create(title, conv_type, participants_list, user_id) + conv = create(title, conv_type, participants_list, user_id, creator_name) return _resp(201, conv) diff --git a/packages/chat/css/main.css b/packages/chat/css/main.css new file mode 100644 index 0000000..446b48c --- /dev/null +++ b/packages/chat/css/main.css @@ -0,0 +1,521 @@ +/* ═══════════════════════════════════════════ + Chat Surface β€” Styles (v0.1.0) + ═══════════════════════════════════════════ */ + +/* ── Layout ─────────────────────────────── */ + +.chat-app { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; + background: var(--bg-primary, #fff); + color: var(--text-primary, #1a1a1a); +} + +.chat-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; +} + +.chat-body { + display: flex; + flex: 1; + min-height: 0; +} + +.chat-main { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} + +/* ── Topbar extras ──────────────────────── */ + +.chat-topbar__thread-title { + font-weight: 600; + font-size: 14px; + margin-right: 8px; + color: var(--text-secondary, #666); +} + +/* ── Sidebar ────────────────────────────── */ + +.chat-sidebar { + width: 280px; + min-width: 280px; + border-right: 1px solid var(--border, #e2e2e2); + display: flex; + flex-direction: column; + background: var(--bg-secondary, #fafafa); +} + +.chat-sidebar__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border, #e2e2e2); +} + +.chat-sidebar__title { + font-weight: 600; + font-size: 14px; +} + +.chat-sidebar__list { + flex: 1; + overflow-y: auto; +} + +.chat-sidebar__empty { + padding: 24px 16px; + text-align: center; + color: var(--text-muted, #999); + font-size: 13px; +} + +.chat-sidebar__item { + padding: 10px 16px; + cursor: pointer; + border-bottom: 1px solid var(--border-light, #f0f0f0); + transition: background 0.1s; +} + +.chat-sidebar__item:hover { + background: var(--bg-hover, #f0f0f0); +} + +.chat-sidebar__item--active { + background: var(--bg-active, #e8f0fe); +} + +.chat-sidebar__item-top { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 2px; +} + +.chat-sidebar__item-title { + font-weight: 600; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + margin-right: 8px; +} + +.chat-sidebar__item-time { + font-size: 11px; + color: var(--text-muted, #999); + white-space: nowrap; +} + +.chat-sidebar__item-bottom { + display: flex; + align-items: center; + gap: 6px; +} + +.chat-sidebar__item-preview { + font-size: 12px; + color: var(--text-secondary, #666); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.chat-sidebar__badge { + background: var(--primary, #2563eb); + color: #fff; + font-size: 11px; + font-weight: 600; + min-width: 18px; + height: 18px; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 5px; + flex-shrink: 0; +} + +/* ── Message Thread ─────────────────────── */ + +.chat-thread { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.chat-thread--empty { + align-items: center; + justify-content: center; + color: var(--text-muted, #999); +} + +.chat-thread__messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.chat-thread__loading { + display: flex; + justify-content: center; + padding: 24px; +} + +.chat-thread__load-more { + align-self: center; + background: none; + border: 1px solid var(--border, #e2e2e2); + border-radius: 4px; + padding: 4px 12px; + font-size: 12px; + color: var(--text-secondary, #666); + cursor: pointer; + margin-bottom: 8px; +} + +.chat-thread__load-more:hover { + background: var(--bg-hover, #f0f0f0); +} + +.chat-thread__typing { + padding: 4px 16px 8px; + font-size: 12px; + color: var(--text-muted, #999); + font-style: italic; +} + +/* ── Message Bubble ─────────────────────── */ + +.chat-msg { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 4px 0; + position: relative; +} + +.chat-msg--own { + flex-direction: row-reverse; +} + +.chat-msg--system { + justify-content: center; + padding: 2px 0; +} + +.chat-msg--system span { + font-size: 12px; + color: var(--text-muted, #999); + font-style: italic; +} + +.chat-msg--deleted { + justify-content: center; + padding: 2px 0; +} + +.chat-msg--deleted em { + font-size: 12px; + color: var(--text-muted, #999); +} + +.chat-msg__body { + max-width: 65%; + background: var(--bg-secondary, #f5f5f5); + border-radius: 12px; + padding: 8px 12px; +} + +.chat-msg--own .chat-msg__body { + background: var(--primary, #2563eb); + color: #fff; +} + +.chat-msg__name { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary, #666); + display: block; + margin-bottom: 2px; +} + +.chat-msg__content { + font-size: 14px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} + +.chat-msg__meta { + display: flex; + gap: 6px; + align-items: center; + margin-top: 2px; +} + +.chat-msg__time { + font-size: 10px; + color: var(--text-muted, #999); +} + +.chat-msg--own .chat-msg__time { + color: rgba(255, 255, 255, 0.7); +} + +.chat-msg__edited { + font-size: 10px; + color: var(--text-muted, #999); + font-style: italic; +} + +.chat-msg--own .chat-msg__edited { + color: rgba(255, 255, 255, 0.7); +} + +/* ── Message Actions ────────────────────── */ + +.chat-msg__actions { + display: flex; + gap: 2px; + position: absolute; + top: 0; + right: 0; + background: var(--bg-primary, #fff); + border: 1px solid var(--border, #e2e2e2); + border-radius: 6px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + padding: 2px; +} + +.chat-msg--own .chat-msg__actions { + right: auto; + left: 0; +} + +.chat-msg__action { + background: none; + border: none; + padding: 4px 6px; + cursor: pointer; + border-radius: 4px; + font-size: 14px; + line-height: 1; + color: var(--text-secondary, #666); +} + +.chat-msg__action:hover { + background: var(--bg-hover, #f0f0f0); +} + +.chat-msg__action--danger:hover { + background: var(--danger-bg, #fee); + color: var(--danger, #dc2626); +} + +/* ── Message Edit ───────────────────────── */ + +.chat-msg__edit { + display: flex; + flex-direction: column; + gap: 6px; +} + +.chat-msg__edit-input { + width: 100%; + min-width: 200px; + border: 1px solid var(--border, #e2e2e2); + border-radius: 6px; + padding: 6px 8px; + font-size: 14px; + font-family: inherit; + resize: vertical; + background: var(--bg-primary, #fff); + color: var(--text-primary, #1a1a1a); +} + +.chat-msg__edit-actions { + display: flex; + gap: 6px; + justify-content: flex-end; +} + +/* ── Compose Bar ────────────────────────── */ + +.chat-compose { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--border, #e2e2e2); + background: var(--bg-primary, #fff); +} + +.chat-compose__input { + flex: 1; + border: 1px solid var(--border, #e2e2e2); + border-radius: 8px; + padding: 8px 12px; + font-size: 14px; + font-family: inherit; + resize: none; + line-height: 1.4; + max-height: 160px; + background: var(--bg-primary, #fff); + color: var(--text-primary, #1a1a1a); +} + +.chat-compose__input:focus { + outline: none; + border-color: var(--primary, #2563eb); + box-shadow: 0 0 0 2px var(--primary-light, rgba(37, 99, 235, 0.15)); +} + +/* ── Participant Sidebar ────────────────── */ + +.chat-participants { + width: 240px; + min-width: 240px; + border-left: 1px solid var(--border, #e2e2e2); + display: flex; + flex-direction: column; + background: var(--bg-secondary, #fafafa); +} + +.chat-participants__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border, #e2e2e2); + font-weight: 600; + font-size: 13px; +} + +.chat-participants__list { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.chat-participants__item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 16px; +} + +.chat-participants__name { + flex: 1; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-participants__badge { + font-size: 10px; + color: var(--primary, #2563eb); + font-weight: 600; + margin-left: 4px; +} + +.chat-participants__status { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted, #ccc); + flex-shrink: 0; +} + +.chat-participants__status--online { + background: var(--success, #16a34a); +} + +.chat-participants__remove { + background: none; + border: none; + color: var(--text-muted, #999); + cursor: pointer; + font-size: 16px; + padding: 0 4px; + line-height: 1; +} + +.chat-participants__remove:hover { + color: var(--danger, #dc2626); +} + +/* ── New Conversation Dialog ────────────── */ + +.chat-new { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 320px; +} + +.chat-new__type { + display: flex; + gap: 16px; +} + +.chat-new__type label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + cursor: pointer; +} + +.chat-new__title { + border: 1px solid var(--border, #e2e2e2); + border-radius: 6px; + padding: 8px 10px; + font-size: 14px; + font-family: inherit; + background: var(--bg-primary, #fff); + color: var(--text-primary, #1a1a1a); +} + +.chat-new__selected { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.chat-new__chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--bg-active, #e8f0fe); + color: var(--primary, #2563eb); + font-size: 12px; + font-weight: 500; + padding: 3px 8px; + border-radius: 12px; +} + +.chat-new__chip button { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 14px; + padding: 0; + line-height: 1; +} diff --git a/packages/chat/js/main.js b/packages/chat/js/main.js new file mode 100644 index 0000000..3633a98 --- /dev/null +++ b/packages/chat/js/main.js @@ -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 = '

SDK boot failed: ' + e.message + '

'; + 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` +
+
+ Conversations + <${Button} size="sm" onClick=${onNew}>New +
+
+ ${conversations.length === 0 && html` +
No conversations yet
`} + ${conversations.map(c => html` +
onSelect(c.id)}> +
+ ${c.title || 'Untitled'} + ${timeAgo(c.updated_at || c.created_at)} +
+
+ + ${c.last_message + ? truncate(c.last_message.content_type === 'system' + ? '\u2022 ' + c.last_message.content + : c.last_message.content, 60) + : 'No messages yet'} + + ${(unread[c.id] || 0) > 0 && html` + ${unread[c.id]}`} +
+
`)} +
+
`; + } + + + // ═══════════════════════════════════════════ + // 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` +
+ This message was deleted +
`; + } + + if (msg.content_type === 'system') { + return html` +
+ ${msg.content} +
`; + } + + 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` +
setHover(true)} + onMouseLeave=${() => setHover(false)}> + ${!isOwn && html` + <${Avatar} name=${msg._display_name || msg.participant_id} size="sm" />`} +
+ ${!isOwn && html`${msg._display_name || msg.participant_id}`} + ${editing ? html` +
+