Feat v0.5.2 chat surface (#32)
All checks were successful
All checks were successful
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:
62
CHANGELOG.md
62
CHANGELOG.md
@@ -2,6 +2,68 @@
|
|||||||
|
|
||||||
All notable changes to Switchboard Core are documented here.
|
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.
|
||||||
|
- **Chat dark mode theming**: Replaced non-existent CSS variable names with
|
||||||
|
actual theme tokens from `variables.css`. Fixes invisible text and wrong
|
||||||
|
backgrounds in dark mode.
|
||||||
|
- **Dialog dropdown clipping**: UserPicker autocomplete dropdown in dialogs
|
||||||
|
(New Conversation, Add Participant) no longer clipped by `overflow: auto`
|
||||||
|
on `.sw-dialog__body`.
|
||||||
|
- **User menu at scaled UI**: Menu primitive now divides anchor coordinates
|
||||||
|
and viewport dimensions by `sw.shell.getScale()` to compensate for CSS
|
||||||
|
`transform: scale()` on `#surfaceInner`. Fixes menu clipping at 110%+.
|
||||||
|
|
||||||
|
### 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
|
## v0.5.1 — Chat Core Library
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
21
ROADMAP.md
21
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. |
|
| 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. |
|
| 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 |
|
| Step | Status | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| Conversation list | | Sidebar: conversation list with last message preview, unread badge, timestamp. Create conversation button. |
|
| Conversation list | done | 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. |
|
| Message thread | done | Main pane: message history with participant avatars, timestamps, content. Scroll-to-load-more for long threads. |
|
||||||
| Compose bar | | Text input with Enter-to-send, Shift+Enter for newline. Markdown support via existing inline renderer. |
|
| Compose bar | done | Text input with Enter-to-send, Shift+Enter for newline. Auto-resize textarea. |
|
||||||
| Participant sidebar | | Collapsible right panel: participant list with online status (via kernel presence hub). Add/remove participants using `sw.ui.Dialog`. |
|
| 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 | | Direct messages (two participants) and group conversations. Type field on conversation. |
|
| 1:1 and group | done | 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. |
|
| Typing indicators | done | `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. |
|
| Read receipts | done | 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 editing | done | Edit own messages. `edited_at` timestamp displayed. Edit via `PUT /messages/:id`. |
|
||||||
| Message deletion | | Soft-delete own messages. Replaced with "message deleted" placeholder. |
|
| Message deletion | done | Soft-delete own messages. Replaced with "message deleted" placeholder. |
|
||||||
|
|
||||||
### v0.5.3 — Chat Polish + Integration Testing (planned)
|
### v0.5.3 — Chat Polish + Integration Testing (planned)
|
||||||
|
|
||||||
@@ -386,6 +386,7 @@ participation is post-MVP.
|
|||||||
| Item | Description |
|
| Item | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| "Requires Review" filter | Dropdown filter option or dedicated button for `pending_review` packages. Useful when the package list grows long. |
|
| "Requires Review" filter | Dropdown filter option or dedicated button for `pending_review` packages. Useful when the package list grows long. |
|
||||||
|
| Extreme UI scale polish | At 150%+ scale: user menu items overflow viewport (need scroll), topbar elements clipped. Ensure all surfaces and menus are usable at scale extremes (80%–200%). |
|
||||||
|
|
||||||
### v0.5.4 — Package Updates (planned)
|
### v0.5.4 — Package Updates (planned)
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
# docker compose up --build
|
# docker compose up --build
|
||||||
# open http://localhost:3000
|
# open http://localhost:3000
|
||||||
#
|
#
|
||||||
# Data persists in ./data/ between restarts.
|
# Data persists in the `sb_data` named volume between restarts.
|
||||||
# To reset: rm -rf ./data/
|
# To reset: docker compose down -v
|
||||||
#
|
#
|
||||||
# For Postgres / multi-replica / k8s deployment see k8s/ and Dockerfile.
|
# For Postgres / multi-replica / k8s deployment see k8s/ and Dockerfile.
|
||||||
|
|
||||||
@@ -37,7 +37,10 @@ services:
|
|||||||
# Dev seed users — ignored if ENVIRONMENT=production
|
# Dev seed users — ignored if ENVIRONMENT=production
|
||||||
SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user}
|
SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- sb_data:/data
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sb_data:
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def _now():
|
|||||||
# Exported API (lib.require("chat-core"))
|
# 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.
|
"""Create a conversation and add initial participants.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -74,6 +74,7 @@ def create(title, type="group", participants=None, creator_id=""):
|
|||||||
type: "direct" or "group" (default "group")
|
type: "direct" or "group" (default "group")
|
||||||
participants: list of dicts with {id, type?, display_name?, role?}
|
participants: list of dicts with {id, type?, display_name?, role?}
|
||||||
creator_id: user ID of the creator (added as admin)
|
creator_id: user ID of the creator (added as admin)
|
||||||
|
creator_display_name: display name of the creator
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with conversation data
|
dict with conversation data
|
||||||
@@ -95,7 +96,7 @@ def create(title, type="group", participants=None, creator_id=""):
|
|||||||
"conversation_id": cid,
|
"conversation_id": cid,
|
||||||
"participant_id": _str(creator_id),
|
"participant_id": _str(creator_id),
|
||||||
"participant_type": "user",
|
"participant_type": "user",
|
||||||
"display_name": "",
|
"display_name": _str(creator_display_name),
|
||||||
"role": "admin",
|
"role": "admin",
|
||||||
"joined_at": "",
|
"joined_at": "",
|
||||||
})
|
})
|
||||||
@@ -430,8 +431,9 @@ def _handle_create_conversation(req, user_id):
|
|||||||
title = _str(body.get("title", ""))
|
title = _str(body.get("title", ""))
|
||||||
conv_type = _str(body.get("type", "group"))
|
conv_type = _str(body.get("type", "group"))
|
||||||
participants_list = body.get("participants", [])
|
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)
|
return _resp(201, conv)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
538
packages/chat/css/main.css
Normal file
538
packages/chat/css/main.css
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
/* ═══════════════════════════════════════════
|
||||||
|
Chat Surface — Styles (v0.1.0)
|
||||||
|
Uses variables.css theme tokens:
|
||||||
|
--bg, --bg-surface, --bg-raised, --bg-hover, --bg-secondary
|
||||||
|
--text, --text-2, --text-3
|
||||||
|
--accent, --accent-dim, --border, --border-light
|
||||||
|
--input-bg, --danger, --danger-bg, --success
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── Layout ─────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar ────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-sidebar {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 280px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-3);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar__item {
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar__item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar__item--active {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar__item-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar__item-preview {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar__badge {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-on-color);
|
||||||
|
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-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-thread__load-more:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-thread__typing {
|
||||||
|
padding: 4px 16px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
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-3);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg--deleted {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg--deleted em {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg__body {
|
||||||
|
max-width: 65%;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg--own .chat-msg__body {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-on-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg__name {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-2);
|
||||||
|
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-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg--own .chat-msg__time {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg__edited {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
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-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
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-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg__action:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg__action--danger:hover {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-compose__input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: none;
|
||||||
|
line-height: 1.4;
|
||||||
|
max-height: 160px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-compose__input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Participant Sidebar ────────────────── */
|
||||||
|
|
||||||
|
.chat-participants {
|
||||||
|
width: 240px;
|
||||||
|
min-width: 240px;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-participants__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
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(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-participants__status {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-participants__status--online {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-participants__remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-3);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-participants__remove:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-new__selected {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-new__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dialog UserPicker overflow fix ─────── */
|
||||||
|
/* Allow the autocomplete dropdown to overflow the dialog body.
|
||||||
|
Applies to both New Conversation and Add Participant dialogs. */
|
||||||
|
|
||||||
|
.sw-dialog__body:has(.sw-user-picker) {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-dialog:has(.sw-user-picker) {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
772
packages/chat/js/main.js
Normal file
772
packages/chat/js/main.js
Normal 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">✎</button>
|
||||||
|
<button class="chat-msg__action chat-msg__action--danger" onClick=${() => onDelete(msg.id)} title="Delete">🗑</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);
|
||||||
|
|
||||||
|
})();
|
||||||
30
packages/chat/manifest.json
Normal file
30
packages/chat/manifest.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"id": "chat",
|
||||||
|
"title": "Chat",
|
||||||
|
"type": "full",
|
||||||
|
"tier": "starlark",
|
||||||
|
"route": "/s/chat",
|
||||||
|
"auth": "authenticated",
|
||||||
|
"layout": "single",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"icon": "\ud83d\udcac",
|
||||||
|
"description": "Chat surface — conversations, messaging, typing indicators, read receipts.",
|
||||||
|
"author": "switchboard",
|
||||||
|
|
||||||
|
"permissions": ["db.write", "realtime.publish"],
|
||||||
|
|
||||||
|
"depends": ["chat-core"],
|
||||||
|
|
||||||
|
"api_routes": [
|
||||||
|
{"method": "POST", "path": "/typing/*"}
|
||||||
|
],
|
||||||
|
|
||||||
|
"settings": {
|
||||||
|
"enter_to_send": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Enter to Send",
|
||||||
|
"description": "Press Enter to send messages (Shift+Enter for newline). When off, Enter inserts a newline.",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
packages/chat/script.star
Normal file
39
packages/chat/script.star
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Chat Surface — Starlark Backend (v0.1.0)
|
||||||
|
#
|
||||||
|
# Thin surface layer on top of chat-core library.
|
||||||
|
# Only handles typing indicator broadcast — all CRUD is
|
||||||
|
# delegated to chat-core's own API routes.
|
||||||
|
#
|
||||||
|
# Modules: json, realtime
|
||||||
|
|
||||||
|
def _resp(status, data):
|
||||||
|
return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
|
||||||
|
|
||||||
|
|
||||||
|
def on_request(req):
|
||||||
|
path = req["path"]
|
||||||
|
method = req["method"]
|
||||||
|
user_id = req.get("user_id", "")
|
||||||
|
|
||||||
|
# POST /typing/:conversation_id — broadcast typing indicator
|
||||||
|
if method == "POST" and path.startswith("/typing/"):
|
||||||
|
cid = path[len("/typing/"):]
|
||||||
|
if not cid:
|
||||||
|
return _resp(400, {"error": "conversation_id required"})
|
||||||
|
|
||||||
|
body = json.decode(req.get("body", "{}"))
|
||||||
|
display_name = str(body.get("display_name", ""))
|
||||||
|
|
||||||
|
realtime.publish(
|
||||||
|
"conversation:" + cid,
|
||||||
|
"typing",
|
||||||
|
{
|
||||||
|
"participant_id": user_id,
|
||||||
|
"display_name": display_name,
|
||||||
|
"conversation_id": cid,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return _resp(200, {"ok": True})
|
||||||
|
|
||||||
|
return _resp(404, {"error": "not found"})
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -191,7 +192,21 @@ func installBundledPackage(pkgPath, packagesDir string, stores store.Stores, run
|
|||||||
|
|
||||||
// Sync permissions — use a minimal gin.Context for the functions that need it
|
// Sync permissions — use a minimal gin.Context for the functions that need it
|
||||||
fakeCtx := newBackgroundGinContext()
|
fakeCtx := newBackgroundGinContext()
|
||||||
SyncManifestPermissions(fakeCtx, stores, pkgID, manifest)
|
declaredPerms := SyncManifestPermissions(fakeCtx, stores, pkgID, manifest)
|
||||||
|
|
||||||
|
// Bundled packages are trusted — auto-grant all declared permissions
|
||||||
|
// and restore active status (SyncManifestPermissions sets pending_review).
|
||||||
|
// Use direct SQL with NULL granted_by to satisfy FK constraint on users(id).
|
||||||
|
if len(declaredPerms) > 0 {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := database.DB.ExecContext(ctx,
|
||||||
|
`UPDATE extension_permissions SET granted = 1, granted_by = NULL, granted_at = ? WHERE package_id = ? AND granted = 0`,
|
||||||
|
now, pkgID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[bundled] Failed to auto-grant permissions for %s: %v", pkgID, err)
|
||||||
|
}
|
||||||
|
_ = stores.Packages.SetStatus(ctx, pkgID, "active")
|
||||||
|
}
|
||||||
|
|
||||||
// Sync triggers from manifest
|
// Sync triggers from manifest
|
||||||
SyncManifestTriggers(ctx, stores, triggers.GlobalEngine(), pkgID, manifest)
|
SyncManifestTriggers(ctx, stores, triggers.GlobalEngine(), pkgID, manifest)
|
||||||
|
|||||||
@@ -17,13 +17,20 @@ export function Menu({ items = [], anchor, open, direction = 'down-right', onSel
|
|||||||
const selectableItems = items.filter(i => !i.divider && !i.disabled);
|
const selectableItems = items.filter(i => !i.divider && !i.disabled);
|
||||||
|
|
||||||
// Position relative to anchor
|
// Position relative to anchor
|
||||||
|
// When the surface uses CSS transform: scale(), getBoundingClientRect()
|
||||||
|
// returns scaled coordinates but position:fixed uses viewport coordinates.
|
||||||
|
// Dividing by the scale factor converts back to true viewport pixels.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !anchor) return;
|
if (!open || !anchor) return;
|
||||||
const rect = typeof anchor.getBoundingClientRect === 'function'
|
const scale = window.sw?.shell?.getScale?.() || 1;
|
||||||
|
const raw = typeof anchor.getBoundingClientRect === 'function'
|
||||||
? anchor.getBoundingClientRect()
|
? anchor.getBoundingClientRect()
|
||||||
: anchor;
|
: anchor;
|
||||||
const vw = window.innerWidth;
|
const rect = scale !== 1
|
||||||
const vh = window.innerHeight;
|
? { top: raw.top / scale, bottom: raw.bottom / scale, left: raw.left / scale, right: raw.right / scale }
|
||||||
|
: raw;
|
||||||
|
const vw = window.innerWidth / scale;
|
||||||
|
const vh = window.innerHeight / scale;
|
||||||
|
|
||||||
let top, left;
|
let top, left;
|
||||||
const [vDir, hDir] = direction.split('-');
|
const [vDir, hDir] = direction.split('-');
|
||||||
|
|||||||
@@ -209,13 +209,26 @@ export function createAuth() {
|
|||||||
// Suppress _on401Failure redirect during boot — boot handles 401 itself.
|
// Suppress _on401Failure redirect during boot — boot handles 401 itself.
|
||||||
_booting = true;
|
_booting = true;
|
||||||
|
|
||||||
|
// Defensive timeout: boot MUST complete within 8s.
|
||||||
|
// If stale tokens cause a network hang or unexpected deadlock,
|
||||||
|
// clear tokens and let the login page render.
|
||||||
|
let timedOut = false;
|
||||||
|
const bootTimeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
console.warn('[sw.auth] Boot timed out — clearing stale tokens');
|
||||||
|
_clearTokens();
|
||||||
|
_booting = false;
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
// Fetch permissions (validates the access token)
|
// Fetch permissions (validates the access token)
|
||||||
try {
|
try {
|
||||||
await _fetchPermissions();
|
await _fetchPermissions();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (timedOut) return;
|
||||||
if (e.status === 401) {
|
if (e.status === 401) {
|
||||||
// Token expired — try refresh
|
// Token expired — try refresh
|
||||||
const ok = await auth.refresh();
|
const ok = await auth.refresh();
|
||||||
|
if (timedOut) return;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
try { await _fetchPermissions(); }
|
try { await _fetchPermissions(); }
|
||||||
catch (_) { _clearTokens(); }
|
catch (_) { _clearTokens(); }
|
||||||
@@ -226,6 +239,7 @@ export function createAuth() {
|
|||||||
console.warn('[sw.auth] Boot: permissions fetch failed (keeping tokens):', e.message);
|
console.warn('[sw.auth] Boot: permissions fetch failed (keeping tokens):', e.message);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(bootTimeout);
|
||||||
_booting = false;
|
_booting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ async function _buildError(resp, method, path) {
|
|||||||
*/
|
*/
|
||||||
export function createRestClient(getToken, onRefresh, on401Failure) {
|
export function createRestClient(getToken, onRefresh, on401Failure) {
|
||||||
|
|
||||||
|
// Auth endpoints must never trigger the 401→refresh→retry loop.
|
||||||
|
// Doing so causes a deadlock: refresh() calls POST /auth/refresh via
|
||||||
|
// _request, which 401s and calls onRefresh(), which returns the same
|
||||||
|
// unresolved refresh promise — both await each other forever.
|
||||||
|
const _authPaths = ['/api/v1/auth/login', '/api/v1/auth/refresh', '/api/v1/auth/register'];
|
||||||
|
|
||||||
async function _request(path, method, body, opts) {
|
async function _request(path, method, body, opts) {
|
||||||
const _opts = opts || {};
|
const _opts = opts || {};
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
@@ -62,8 +68,9 @@ export function createRestClient(getToken, onRefresh, on401Failure) {
|
|||||||
|
|
||||||
let resp = await fetch(BASE + path, fetchOpts);
|
let resp = await fetch(BASE + path, fetchOpts);
|
||||||
|
|
||||||
// 401 — try refresh once
|
// 401 — try refresh once (skip for auth endpoints to prevent deadlock)
|
||||||
if (resp.status === 401) {
|
const isAuthPath = _authPaths.some(p => path === p || path.startsWith(p + '?'));
|
||||||
|
if (resp.status === 401 && !isAuthPath && !_opts.skipRefresh) {
|
||||||
const refreshed = await onRefresh();
|
const refreshed = await onRefresh();
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
// Retry with new token
|
// Retry with new token
|
||||||
|
|||||||
Reference in New Issue
Block a user