From 7fd8d7c4eb62c807cd3dc471e9c280d039e7bfa4 Mon Sep 17 00:00:00 2001 From: Adam Jeniski Date: Tue, 17 Feb 2026 18:54:08 -0500 Subject: [PATCH] update --- .gitignore | 40 +- api/plan.md | 22 - api/src/ajet/chat/api/handlers/messages.clj | 52 ++- auth-gw/plan.md | 89 ---- cli/plan.md | 71 --- docs/prd/shared.md | 2 +- docs/prd/web-sm.md | 22 +- mobile/plan.md | 13 - plan.md | 489 -------------------- shared/src/ajet/chat/shared/api_client.clj | 3 +- tui-sm/plan.md | 28 -- web-sm/plan.md | 29 -- web-sm/src/ajet/chat/web/components.clj | 80 ++-- web-sm/src/ajet/chat/web/handlers.clj | 157 +++++-- web-sm/src/ajet/chat/web/layout.clj | 25 +- web-sm/src/ajet/chat/web/routes.clj | 30 +- web-sm/src/ajet/chat/web/sse.clj | 15 +- 17 files changed, 293 insertions(+), 874 deletions(-) delete mode 100644 api/plan.md delete mode 100644 auth-gw/plan.md delete mode 100644 cli/plan.md delete mode 100644 mobile/plan.md delete mode 100644 plan.md delete mode 100644 tui-sm/plan.md delete mode 100644 web-sm/plan.md diff --git a/.gitignore b/.gitignore index bac7275..48f41bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,44 @@ +# Clojure / Java .cpcache/ -.nrepl-port target/ *.db +*.jar +classes/ +.nrepl-port +.rebel_readline_history + +# Clojure tooling .clj-kondo/.cache/ .lsp/ +.calva/ + +# Editors / IDEs +.idea/ +*.iml +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment / secrets +.env +.env.local +.env.*.local + +# Node (e2e, tooling) +node_modules/ + +# Playwright / browser tooling +.playwright-mcp/ + +# Debug / generated dev files +plan.md +*/plan.md + +# Build artifacts +pom.xml +pom.xml.asc diff --git a/api/plan.md b/api/plan.md deleted file mode 100644 index b5d1671..0000000 --- a/api/plan.md +++ /dev/null @@ -1,22 +0,0 @@ -# API Service Plan - -## Stack -- http-kit (HTTP server) -- reitit (routing) + Ring middleware -- next.jdbc + HoneySQL (via shared/) -- PostgreSQL everywhere (dev + prod) - -## Responsibilities -- Stateless REST API: all reads & writes for messages, channels, users -- Only service (besides Auth GW) with a direct PG connection -- Publishes events to NATS after DB writes -- No live connections — pure request/response -- Sits behind auth gateway (all requests pre-authenticated) - -## TODO -- [ ] Define reitit route table -- [ ] http-kit server setup -- [ ] Design message/channel/user API endpoints -- [ ] Ring middleware: error handling, content negotiation, request logging -- [ ] NATS publish on write (message sent, channel created, etc.) -- [ ] Database migrations diff --git a/api/src/ajet/chat/api/handlers/messages.clj b/api/src/ajet/chat/api/handlers/messages.clj index 1660289..9005fac 100644 --- a/api/src/ajet/chat/api/handlers/messages.clj +++ b/api/src/ajet/chat/api/handlers/messages.clj @@ -42,9 +42,10 @@ (defn- get-message-row [ds message-id] (or (db/execute-one! ds - {:select [:*] - :from [:messages] - :where [:= :id [:cast message-id :uuid]]}) + {:select [:m.* :u.username :u.display-name :u.avatar-url] + :from [[:messages :m]] + :join [[:users :u] [:= :m.user-id :u.id]] + :where [:= :m.id [:cast message-id :uuid]]}) (throw (ex-info "Message not found" {:type :ajet.chat/not-found})))) (defn- nats-subject-for-channel @@ -140,19 +141,19 @@ (check-channel-member! ds channel-id user-id) - (let [base-where [:= :channel-id [:cast channel-id :uuid]] + (let [base-where [:= :m.channel-id [:cast channel-id :uuid]] ;; Cursor-based pagination using subquery on created_at where-clause (cond before [:and base-where - [:< :created-at + [:< :m.created-at {:select [:created-at] :from [:messages] :where [:= :id [:cast before :uuid]]}]] after [:and base-where - [:> :created-at + [:> :m.created-at {:select [:created-at] :from [:messages] :where [:= :id [:cast after :uuid]]}]] @@ -160,10 +161,11 @@ :else base-where) order-dir (if after :asc :desc) messages (db/execute! ds - {:select [:*] - :from [:messages] + {:select [:m.* :u.username :u.display-name :u.avatar-url] + :from [[:messages :m]] + :join [[:users :u] [:= :m.user-id :u.id]] :where where-clause - :order-by [[:created-at order-dir]] + :order-by [[:m.created-at order-dir]] :limit limit}) ;; Always return newest-last ordering messages (if (= order-dir :desc) @@ -213,19 +215,22 @@ (when resolved-parent-id (create-thread-notifications! tx message-id resolved-parent-id user-id))) - ;; Publish event + ;; Publish event with user info for real-time display (let [channel (get-channel-row ds channel-id) subject (nats-subject-for-channel channel) - message (get-message-row ds message-id)] + message (get-message-row ds message-id) + user (db/execute-one! ds + {:select [:username :display-name :avatar-url] + :from [:users] + :where [:= :id [:cast user-id :uuid]]})] (publish-event! nats subject :message/created - {:message-id message-id - :channel-id channel-id - :user-id user-id - :body-md body-md - :parent-id resolved-parent-id - :community-id (when (:community-id channel) - (str (:community-id channel)))}) + (merge message + {:username (:username user) + :display-name (:display-name user) + :avatar-url (:avatar-url user) + :community-id (when (:community-id channel) + (str (:community-id channel)))})) (mw/json-response 201 message))))) @@ -360,12 +365,13 @@ (check-channel-member! ds (str (:channel-id message)) user-id) - ;; Get root message + all replies + ;; Get root message + all replies (with user info) (let [replies (db/execute! ds - {:select [:*] - :from [:messages] - :where [:= :parent-id [:cast message-id :uuid]] - :order-by [[:created-at :asc]]})] + {:select [:m.* :u.username :u.display-name :u.avatar-url] + :from [[:messages :m]] + :join [[:users :u] [:= :m.user-id :u.id]] + :where [:= :m.parent-id [:cast message-id :uuid]] + :order-by [[:m.created-at :asc]]})] (mw/json-response {:root message :replies replies})))) diff --git a/auth-gw/plan.md b/auth-gw/plan.md deleted file mode 100644 index 97c2848..0000000 --- a/auth-gw/plan.md +++ /dev/null @@ -1,89 +0,0 @@ -# Auth Gateway Plan - -## Overview -Clojure-based edge gateway. All external traffic enters here. -Authenticates requests, then proxies to internal services. - -## Stack -- http-kit (HTTP server + async reverse proxy) -- reitit (route matching to determine target service) -- Ring middleware (CORS, rate limiting, logging) -- next.jdbc + HoneySQL (via shared/) for session/token DB lookups - -## Responsibilities -- TLS termination (or sit behind nginx for TLS in prod) -- Token validation / session lookup (custom DB reads) -- Authorization (permission checks per route) -- Rate limiting (per-user, per-IP) -- Route authenticated requests to internal services: - - `/api/*` → API service - - `/` + web routes → Web session manager - - `/ws/tui/*` → TUI session manager - - `/ws/mobile/*` → Mobile session manager (future) -- Session creation on login (custom DB writes) -- Audit logging - -## Auth Flow - -### OAuth Login (v1) -1. Client redirects to `/auth/oauth/:provider` (e.g., `/auth/oauth/github`) -2. Auth GW redirects to provider's OAuth authorization URL -3. Provider redirects back to `/auth/oauth/:provider/callback` with auth code -4. Auth GW exchanges code for access token, fetches user profile from provider -5. Auth GW creates/links user + `oauth_accounts` row, creates session in DB -6. Auth GW sets session cookie and redirects to app - -### Login Page -Auth GW owns the login page — Web SM never sees unauthenticated users. -Renders a minimal page with OAuth provider buttons (GitHub, Gitea) and -redirects to the appropriate SSO flow. After OAuth callback completes, -Auth GW sets the session cookie and redirects back to the app. - -### Token Format -- Session tokens: 32 random bytes (256 bits of entropy), encoded as base64url -- Stored in DB as bcrypt hash (`token_hash` column in `sessions` table) -- Client sends raw base64url token; Auth GW hashes and looks up the match - -### Session Lifecycle -- Rolling expiry: session TTL extends on each authenticated request -- Cookie attributes (web clients): `HttpOnly`, `Secure`, `SameSite=Strict` -- CLI/mobile: raw token in `Authorization: Bearer ` header - -### Session Validation -1. Client sends request with token (Authorization header or cookie) -2. Auth GW hashes token with bcrypt, looks up `token_hash` in `sessions` -3. If valid: extend expiry, attach user context headers (`X-User-Id`, `X-User-Role`), proxy to target service -4. If invalid/expired: 401 response - -### First-User Bootstrap -On first server setup (no communities exist in DB): -1. Auth GW login page shows a community creation form alongside OAuth buttons -2. First user authenticates via OAuth, creates the initial community, becomes its owner -3. After initial community exists, new users must be invited by an existing member (invite-only) - -### Future: Email-Based Local Auth -- Email + magic link or email + password (requires email infrastructure) -- Will add `credentials` table when implemented -- OAuth remains primary, local auth is an alternative for self-hosted deployments - -## Request Routing -Auth GW generates a unique `X-Trace-Id` header on each inbound request. -All downstream services propagate the trace ID through the call graph. - -## Protocols Handled -- **HTTPS**: standard request/response proxy -- **SSE**: authenticate on initial connection, then pass through to session managers - -## TODO -- [ ] http-kit reverse proxy setup -- [ ] Route table: path prefix → internal service host:port -- [ ] Token/session DB schema (in shared/) -- [ ] Token validation middleware -- [ ] OAuth flow: GitHub provider (redirect → callback → session creation) -- [ ] OAuth flow: Gitea provider -- [ ] `oauth_accounts` table: link OAuth identities to users -- [ ] SSE connection authentication -- [ ] Rate limiting middleware -- [ ] Logout endpoint (delete session) -- [ ] CORS configuration -- [ ] Audit logging diff --git a/cli/plan.md b/cli/plan.md deleted file mode 100644 index eecc1d4..0000000 --- a/cli/plan.md +++ /dev/null @@ -1,71 +0,0 @@ -# CLI / TUI Client Plan - -## Overview -Single executable with two interfaces, distributed via bbin/babashka: -- **CLI mode**: one-off commands via args (send message, read history, check notifications) -- **TUI mode**: full interactive terminal app (clojure-tui) - -Shared codebase — CLI and TUI use the same API client, auth, and config. - -## Stack -- Babashka for fast startup + distribution via bbin -- clojure-tui (~/repos/clojure-tui) for TUI rendering -- Shared API client library (HTTP) -- Connects to TUI session manager via SSE for real-time updates -- HTTP POSTs to TUI SM for client→server signals (typing indicators) - -## Modes - -### CLI Mode (`ajet-chat [args]`) -Stateless, one-shot commands. Fast startup via Babashka. -``` -ajet-chat send #general "hey everyone" -ajet-chat read #general # last N messages -ajet-chat read #general --since 1h # messages from last hour -ajet-chat dm @alice "hey" # send a DM -ajet-chat dm @alice # read DM history with alice -ajet-chat dms # list DM conversations -ajet-chat notifications # unread notifications -ajet-chat channels # list channels -ajet-chat status # connection/auth status -``` - -### TUI Mode (`ajet-chat` or `ajet-chat tui`) -Full interactive app. Launches when invoked with no command (or explicit `tui`). -- Channel sidebar, message list, input -- Real-time updates via TUI session manager (SSE) -- Keyboard navigation, scrollback, search -- Presence indicators, typing notifications -- Inline image rendering (see below) - -## Shared Components -- **API client**: HTTP client for REST calls to API (via auth gateway) -- **Auth/config**: token storage, server URL, credentials (~/.config/ajet-chat/) -- **Message formatting**: rendering messages to terminal (shared between CLI output and TUI views) -- **Notification model**: unread tracking, mention detection - -## Image Rendering -Inline image display in the terminal via [timg](https://github.com/hzeller/timg) (optional dependency). -- If `timg` is on PATH, shell out to render images inline in both CLI and TUI modes -- timg handles terminal protocol detection automatically (kitty, iTerm2, sixel, unicode block fallback) -- Works over SSH -- If `timg` is not available, fall back to: - 1. Native kitty/sixel escape sequences for compatible terminals - 2. No inline images (show a placeholder with filename/URL) -- In TUI mode: coordinate timg output with clojure-tui's terminal state (suspend raw mode, render, resume) - -## Distribution -- Packaged as a bbin-installable babashka script/jar -- `bbin install io.github.ajet/ajet-chat-cli` (or similar) -- Single `ajet-chat` binary on PATH - -## TODO -- [ ] Set up babashka-compatible project structure -- [ ] CLI arg parsing (babashka.cli or tools.cli) -- [x] API client module (shared between CLI and TUI) — `ajet.chat.shared.api-client` in shared/ -- [ ] Auth flow: login, token storage, refresh -- [ ] CLI commands: send, read, notifications, channels, status -- [ ] TUI layout (channels sidebar, messages, input) -- [ ] TUI real-time subscription via session manager -- [ ] TUI keyboard navigation and commands -- [ ] bbin packaging and install diff --git a/docs/prd/shared.md b/docs/prd/shared.md index 884e49f..b294099 100644 --- a/docs/prd/shared.md +++ b/docs/prd/shared.md @@ -142,7 +142,7 @@ chat.notifications.{user-id} — per-user notification delivery | AC-7 | Community CRUD (create, update, list, get) | P0 | | AC-8 | Channel category CRUD (create, reorder, assign channels) | P1 | | AC-9 | Webhook management (create, delete, list) | P1 | -| AC-10 | Cursor-based pagination support (`?after=&limit=N`) | P0 | +| AC-10 | Bidirectional cursor-based pagination (`?before=&after=&limit=N`) | P0 | | AC-11 | Retry with exponential backoff on 5xx/timeout | P1 | | AC-12 | Request timeout (configurable, default 10s) | P0 | | AC-13 | Structured error responses (`ex-info` with status + body) | P0 | diff --git a/docs/prd/web-sm.md b/docs/prd/web-sm.md index 5d5fd82..664cee3 100644 --- a/docs/prd/web-sm.md +++ b/docs/prd/web-sm.md @@ -54,7 +54,7 @@ Browser ←─ SSE ──→ Web SM ──→ NATS (subscribe events) | Community Strip | `#community-strip` | Vertical icon strip (far left). Community avatars/initials. Click to switch. `+` button to create/join. | | Sidebar | `#sidebar` | Channel list with collapsible categories. DM section below separator. Search at bottom. | | Channel Header | `#channel-header` | Channel name, topic, settings icon. | -| Message List | `#message-list` | Paginated message display. "Load older" button at top. New message divider. | +| Message List | `#message-list` | Paginated message display. "Load older" button at top (only when more may exist). Shows "Beginning of conversation" otherwise. Cursor-based backwards pagination via `oldestMessageId` signal. | | Message Input | `#message-input` | Auto-expanding textarea, @mention autocomplete, image paste, typing indicator display. | | Thread Panel | `#thread-panel` | Slide-in panel on right when viewing a thread. Shows root message + replies. | | Member List | `#member-list` | Optional right panel showing channel members + presence. Toggle via header icon. | @@ -157,7 +157,7 @@ Each message renders as: ``` - Auto-expanding textarea (grows with content, max ~10 lines then scroll) -- Enter to send, Shift+Enter for newline +- Enter sends message and immediately clears input (client-side), Shift+Enter for newline - `@` triggers user mention autocomplete (search by username/display name) - `#` triggers channel autocomplete - `/` at start triggers slash command autocomplete @@ -269,13 +269,15 @@ All user actions are HTTP POSTs (Datastar form submissions): | Action | Endpoint | Payload | |--------|----------|---------| -| Send message | `POST /web/messages` | `{channel_id, body_md, parent_id?}` | +| Send message | `POST /web/messages` | Signals: `messageText`, `activeChannel` | +| Load older messages | `POST /web/messages/older` | Signals: `activeChannel`, `oldestMessageId` (cursor) | | Edit message | `POST /web/messages/:id/edit` | `{body_md}` | | Delete message | `POST /web/messages/:id/delete` | — | | Add reaction | `POST /web/reactions` | `{message_id, emoji}` | | Remove reaction | `POST /web/reactions/remove` | `{message_id, emoji}` | -| Switch channel | `POST /web/navigate` | `{channel_id}` | -| Switch community | `POST /web/navigate` | `{community_id}` | +| Switch channel | `POST /web/navigate` | Signals: `navChannel` | +| Switch community | `POST /web/navigate` | Signals: `navCommunity` | +| Switch to DMs | `POST /web/navigate` | Signals: `navTarget='dms'` | | Mark channel read | `POST /web/read` | `{channel_id, message_id}` | | Upload image | `POST /web/upload` | Multipart (image file) | | Typing indicator | `POST /web/typing` | `{channel_id}` | @@ -286,6 +288,12 @@ All user actions are HTTP POSTs (Datastar form submissions): All of these proxy to the API internally and return Datastar fragment responses. +**Navigation signals:** Navigation uses dedicated signals (`navCommunity`, `navChannel`, `navTarget`) set immediately before `@post` to avoid reading stale `activeCommunity`/`activeChannel` values. The navigate handler returns `datastar-patch-signals` events to sync `activeCommunity`, `activeChannel`, and `oldestMessageId` after navigation. + +**Message input:** Enter sends the message and immediately clears the input (client-side `$messageText = ''`). Shift+Enter inserts a newline. The input does not wait for the server response to clear. + +**Backwards pagination:** The `oldestMessageId` signal tracks the cursor for loading older messages. On initial load and after each navigation, it's set to the ID of the oldest message in the current batch. Clicking "Load older messages" (or scrolling to the top) POSTs to `/web/messages/older`, which fetches messages before the cursor, prepends them to `#messages-container`, and updates the cursor signal. When no older messages exist, the button is replaced with "Beginning of conversation". The button is only shown when a full page (50 messages) was loaded, indicating there may be more. + ## 6. Pages & Routes (Server-Rendered) | Route | Description | @@ -524,7 +532,7 @@ New notifications (beyond badge counts) show as brief toast popups: | ID | Test | Description | |----|------|-------------| -| WEB-T24 | Send message | Typing and pressing Enter sends message, appears in list | +| WEB-T24 | Send message | Typing and pressing Enter sends message, input clears immediately (client-side), message appears in list via SSE | | WEB-T25 | Shift+Enter newline | Shift+Enter adds newline, does not send | | WEB-T26 | @mention autocomplete | Typing @ shows user dropdown, selecting inserts mention | | WEB-T27 | #channel autocomplete | Typing # shows channel dropdown | @@ -544,7 +552,7 @@ New notifications (beyond badge counts) show as brief toast popups: | WEB-T41 | Leave channel | Right-click → leave → channel removed from sidebar | | WEB-T42 | Collapse category | Click category header → channels hidden → click again → shown | | WEB-T43 | Mark channel read | Opening channel marks it as read, badge clears | -| WEB-T44 | Paginated loading | Scroll to "Load older" → click → older messages prepended | +| WEB-T44 | Paginated loading | Click "Load older" or scroll to top → older messages prepended to `#messages-container`, `oldestMessageId` cursor updated. When no older messages, button replaced with "Beginning of conversation". Button hidden when initial load returns < 50 messages. | | WEB-T45 | Create community | Click + on community strip → wizard → community created | ### 11.4 Profile & Settings diff --git a/mobile/plan.md b/mobile/plan.md deleted file mode 100644 index 0bcb627..0000000 --- a/mobile/plan.md +++ /dev/null @@ -1,13 +0,0 @@ -# Mobile App Plan - -## Status: TBD - -## Options to Evaluate -- **ClojureDart** — Clojure compiling to Dart/Flutter -- **React Native + API** — JS/TS frontend consuming the API -- **Progressive Web App** — Reuse the web frontend with mobile optimizations - -## TODO -- [ ] Evaluate ClojureDart maturity -- [ ] Decide on approach -- [ ] Scaffold project diff --git a/plan.md b/plan.md deleted file mode 100644 index bc4d353..0000000 --- a/plan.md +++ /dev/null @@ -1,489 +0,0 @@ -# ajet-chat — Team Messaging Platform - -## Overview -Clojure monorepo for a team messaging app with multiple clients. -PostgreSQL everywhere (dev + prod) via next.jdbc + HoneySQL. - -## Architecture -``` -ajet-chat/ -├── shared/ — Common DB layer, schemas, protocols, EventBus (NATS), API client SDK -├── auth-gw/ — Auth gateway: Clojure edge proxy, authn/authz, custom DB reads/writes -├── api/ — Stateless REST API: all reads & writes, publishes to event bus -├── web-sm/ — Web session manager: Hiccup + Datastar SSE for browsers -├── tui-sm/ — TUI session manager: SSE for terminal clients -├── cli/ — Terminal client: CLI args (one-off) + TUI (interactive), babashka/bbin -└── mobile/ — Mobile client + session manager (deferred) -``` - -Modules reference each other via `:local/root` in deps.edn. - -## Unified Deps -All server modules (auth-gw, api, web-sm, tui-sm): -- org.clojure/clojure 1.12.0 -- http-kit/http-kit 2.8.0 -- metosin/reitit 0.7.2 -- ring/ring-core 1.13.0 -- ajet/chat-shared (local) — EventBus (NATS via io.nats/jnats), schemas, protocols - -PG access (api + auth-gw only): -- next.jdbc + HoneySQL + PostgreSQL driver (via shared/) -- migratus (for DB migrations) - -Additional per-module: -- web-sm: hiccup 2.0.0-RC4, dev.data-star.clojure/sdk 1.0.0-RC5 -- cli: babashka, clojure-tui (local) - -**Dependency matrix:** -``` - PG NATS API(HTTP) -API ✓ pub — -Auth GW ✓ — — -Web SM — sub ✓ (internal) -TUI SM — sub ✓ (internal) -CLI — — ✓ (external, via Auth GW) -``` - -## System Architecture -``` - Internet - │ - ▼ - ┌──────────────────────────────────────────────────────┐ - │ nginx (TLS termination, prod only) │ - └──────────────────────┬───────────────────────────────┘ - │ - ┌──────────────────────▼───────────────────────────────┐ - │ Auth Gateway (Clojure, http-kit) │ - │ • Session token + API token validation (DB reads) │ - │ • Session creation on login (DB writes) │ - │ • Rate limiting, CORS, audit logging │ - │ • Routes authed requests to internal services │ - │ • SSE connection auth │ - └──┬──────────────┬───────────────┬──────────┬─────────┘ - │ │ │ │ - │ /api/* │ / (web) │ /ws/tui │ /ws/mobile - ▼ ▼ ▼ ▼ - ┌───────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ - │ API │ │ Web SM │ │ TUI SM │ │Mobile SM │ - │(REST) │ │(Datastar │ │(SSE) │ │(future) │ - │ │ │ SSE) │ │ │ │ │ - └──┬────┘ └──┬──┬────┘ └──┬──┬────┘ └──┬──┬────┘ - │ │ │ │ │ │ │ │ - │ │ │ browsers │ terminals │ phones - │ │ │ │ │ - │ │ pub │ sub │ sub │ sub - │ ▼ ▼ ▼ ▼ - │ ┌────────────────────────────────────────────┐ - │ │ NATS │ - │ │ pub/sub event bus · community-scoped │ - │ │ subjects · JetStream for replay │ - │ └────────────────────────────────────────────┘ - │ r/w - ▼ - ┌──────────────────────────────────────────────────────┐ - │ PostgreSQL │ - │ data store · full-text search │ - │ ◄── Auth GW: session reads/writes │ - └──────────────────────────────────────────────────────┘ -``` - -### Auth Gateway -Clojure-based edge proxy (http-kit). All external traffic enters here. -- Custom DB reads: session token + API token validation -- Custom DB writes: session creation, rate limit counters, audit log -- Authenticates HTTPS and SSE connections -- Proxies to internal services after authentication -- In prod: sits behind nginx for TLS termination - -### Auth Strategy -- **OAuth for v1** — all user login via OAuth providers (GitHub, Gitea) - - Auth GW handles OAuth callback, creates/links user, creates session - - No local passwords in v1 - - Email-based session auth for local-only accounts planned for later (requires email infrastructure) -- **Session tokens** for interactive users (web, TUI, mobile) - - 32 random bytes (256 bits), encoded as base64url, stored as bcrypt hash in DB - - Rolling expiry: TTL extends on each authenticated request - - Cookie for web (`HttpOnly`, `Secure`, `SameSite=Strict`), `Authorization: Bearer` header for CLI/mobile -- **API tokens** for programmatic access - - Long-lived, scoped tokens for bots, integrations, scripts - - Created and managed via admin UI in each client -- **Incoming webhooks** for external service integration - - Each webhook is bound to a specific channel + has a unique URL + secret token - - External services POST JSON to `/hook//` - - Auth GW validates the webhook token (no session needed) - - API creates the message in the bound channel, broadcasts via EventBus - - Webhook messages display with a bot name/avatar (configurable per webhook) - - Use cases: git push notifications, CI/CD status, server alerts, monitoring - - Managed via slash commands: - `/webhook create #channel `, `/webhook list`, `/webhook revoke ` -- **Slash commands** — universal interaction model across all clients - - Typed in the message input (web, TUI) or as CLI args - - API parses and executes, returns result or side-effect - - Regular user commands: `/join #channel`, `/leave`, `/topic new topic`, `/msg @user`, etc. - - Admin commands (role-gated: owner/admin only): - `/ban @user [reason]`, `/kick @user`, `/mute @user [duration]`, - `/token create [scopes]`, `/token revoke `, - `/role @user admin|member`, `/purge #channel [count]` - - `/help` lists available commands (filtered by user's role) - - `/help ` shows detailed usage for a specific command - - All backed by the same API routes — clients just send the command string, - API handles parsing, permission checks, and execution - -### First-User Bootstrap -On first server setup (no communities exist in DB): -1. Auth GW login page shows a community creation form alongside OAuth buttons -2. First user authenticates via OAuth, creates the initial community, becomes owner -3. After initial community exists, new users must be invited (invite-only) - -### Service Ports (Dev) -Hardcoded per service for now. Config files / env vars can be layered later. -- Auth GW generates a unique `X-Trace-Id` on each inbound request; all downstream services propagate it - -### API -Pure stateless REST (http-kit + reitit). Only service (besides Auth GW) with a PG connection. -All data reads and writes. Publishes events to NATS after writes. -No live connections. Scales trivially. - -### Session Managers -Each manages live connections for its client type (http-kit + reitit): -- **Web SM**: serves Hiccup pages, holds Datastar SSE connections, pushes HTML fragments -- **TUI SM**: holds SSE connections for terminal clients (HTTP POSTs for client→server signals) -- **Mobile SM**: same pattern for mobile (future) - -**Data flow for session managers:** -- **All data reads** (channel list, message history, user info, full message after notification): via API -- **Live events** (new messages, typing, presence): via NATS subscription -- **Ephemeral writes** (typing indicators): SM publishes directly to NATS — no API round-trip - -SMs call the API directly (bypass Auth GW) and propagate the user's token -from the original request. The API validates the token the same way regardless -of caller — no separate service tokens needed. -All data reads go through the API — keeps auth and permission checks centralized. -Independently scalable. - -**SM subscription model:** -When a user connects, their SM subscribes to: -1. Community event subject for their active community (`chat.events.{community-id}`) -2. All their DM channel IDs for DM events (`chat.dm.{channel-id}`) -3. Per-user notification subject (`chat.notifications.{user-id}`) - -### EventBus — NATS -Dedicated pub/sub via [NATS](https://nats.io). Lightweight (~20MB), single binary, -starts in milliseconds. Runs as a Docker container alongside Postgres and MinIO. - -```clojure -(defprotocol EventBus - (publish! [this topic event]) - (subscribe! [this topic handler]) - (unsubscribe! [this topic handler])) -``` - -- API publishes to NATS after DB writes (e.g., `chat.events.{community-id}`) -- Session managers subscribe to NATS for real-time events — no PG connection needed -- SMs publish ephemeral events (typing) directly to NATS — no API round-trip -- SMs fetch full data from the API when needed (e.g., full message after receiving a notification ID) -- NATS subjects are community-scoped — natural sharding boundary -- JetStream (NATS persistence layer) enables replay for SSE reconnection (`Last-Event-ID`) -- Clojure client: `io.nats/jnats` (official Java client) - -**NATS subject hierarchy:** -``` -chat.events.{community-id} — channel message CRUD, channel events, reactions -chat.dm.{channel-id} — DM message events (global, no community) -chat.typing.{community-id}.{channel-id} — channel typing indicators -chat.typing.dm.{channel-id} — DM typing indicators -chat.presence.{community-id} — heartbeat/presence events -chat.notifications.{user-id} — per-user notification delivery -``` - -**Event envelope (hybrid fat events):** -```json -{ - "type": "message.created", - "community_id": "uuid or null", - "channel_id": "uuid", - "actor_id": "uuid", - "timestamp": "ISO-8601", - "data": { /* enough to render the common case */ } -} -``` -- `data` includes body, author ID, timestamp — enough to render without a follow-up API call -- Does NOT include deeply nested or volatile data (e.g. full author profile — SMs cache separately) -- Schema evolves additively only: add fields, never remove. Consumers ignore unknown fields. - -## Data Model -Slack-like with planned voice channel support. - -### Core Entities -``` -Community - └── Channel (text / voice*, public / private) - └── Message - ├── Thread reply (1 level deep) - └── Reaction (emoji per user) - -Conversation (global, not community-scoped) - └── Message (same table, via shared container) - -User - └── CommunityMembership (user <> community, role) - └── ChannelMembership (user <> channel) - └── ChannelMembership (user <> channel, includes DMs) - -APIUser (bot/integration accounts, managed via admin UI in each client) -``` -*voice channels: planned, not in v1 - -### DMs — Global, Not Community-Scoped -DMs are just channels with `community_id = NULL` and type `dm` or `group_dm`. -No extra tables needed — `channels`, `channel_members`, and `messages` all work as-is. - -- **DM**: 1:1 channel between two users (`type = 'dm'`, `community_id = NULL`) -- **Group DM**: channel with 2+ users (`type = 'group_dm'`, `community_id = NULL`) -- Starting a DM auto-creates a channel if one doesn't exist -- DM history persists across communities — same channel everywhere -- UI shows one community at a time; DM sidebar is always visible (global) - -### URL Routing -- **Community-scoped routes**: `/c//channels/...` — channels, messages, members -- **Global DM routes**: `/dm/...` — DM channels, DM messages -- **Other global routes**: `/api/users/...`, `/api/notifications/...` -- UI shows one community at a time; DM sidebar is always visible (global) -- Users can be members of multiple communities - -### API Pagination -Cursor-based pagination using entity UUIDs as cursors: -- Forward: `?after=&limit=N` -- Backward: `?before=&limit=N` -- Applies to messages, channels, notifications, and any other list endpoints - -### Messages -- **Body**: Markdown (rendered to HTML in web, terminal escape codes in CLI) -- **Attachments**: file/image uploads stored in MinIO (S3-compatible) -- **Pings/mentions**: `@user`, `@channel`, `@here` — parsed from markdown, stored as structured refs -- **Threads**: replies link to a parent message. 1 level deep (no nested threads) -- **Reactions**: emoji reactions per message. Any unicode emoji, one per user per emoji. - - `PUT /api/messages/:id/reactions/:emoji` — toggle on (idempotent) - - `DELETE /api/messages/:id/reactions/:emoji` — toggle off - - API publishes reaction events to EventBus; SMs push live updates to clients - - When fetching messages, reactions are returned aggregated: `[{emoji, count, reacted_by_me}]` - -### Key Tables -All `id` columns are UUIDs (generated server-side, `java.util.UUID/randomUUID`). -``` -communities (id:uuid, name, slug, created_at) -users (id:uuid, username, display_name, email, avatar_url, created_at) -oauth_accounts (id:uuid, user_id:uuid, provider [github/gitea/...], provider_user_id, created_at) -api_users (id:uuid, name, community_id:uuid, scopes, created_at) -sessions (id:uuid, user_id:uuid, token_hash, expires_at, created_at) -api_tokens (id:uuid, api_user_id:uuid, token_hash, scopes, expires_at, created_at) - -channels (id:uuid, community_id:uuid?, name, type [text/voice/dm/group_dm], visibility [public/private], topic, created_at) - -community_members (community_id:uuid, user_id:uuid, role [owner/admin/member], nickname, avatar_url) -channel_members (channel_id:uuid, user_id:uuid, joined_at) -messages (id:uuid, channel_id:uuid, user_id:uuid, parent_id:uuid [null=top-level], body_md, created_at, edited_at) -attachments (id:uuid, message_id:uuid, filename, content_type, size_bytes, storage_key) -webhooks (id:uuid, community_id:uuid, channel_id:uuid, name, avatar_url, token_hash, created_by:uuid, created_at) -reactions (message_id:uuid, user_id:uuid, emoji:text, created_at) PK: (message_id, user_id, emoji) -mentions (id:uuid, message_id:uuid, target_type [user/channel/here], target_id:uuid) -notifications (id:uuid, user_id:uuid, type [mention/dm/thread_reply/invite/system], source_id:uuid, read:bool, created_at) -``` - -Note: DMs are channels with `community_id = NULL`. Community channels always have -`community_id` set. Queries for community channels filter by `community_id`; -DM queries filter by `type IN ('dm', 'group_dm') AND community_id IS NULL`. - -### Presence (Heartbeats) -- Clients send `POST /api/heartbeat` every 60 seconds -- API returns 200 immediately, writes async (fire-and-forget) -- Updates `last_seen_at` on user record (or dedicated presence table) -- User is "online" if last heartbeat < 2 min ago, "offline" otherwise -- Typing indicators: live via NATS, not batched, not persisted - - Client sends typing event to their session manager (via HTTP POST) - - SM publishes to NATS (`chat.typing.{community}.{channel}`) — does not go through API - - `typing:start` — sent on keypress, delivered immediately to relevant clients (same channel/DM) - - `typing:stop` — sent explicitly when user clears their input box or sends a message - - Auto-expire: if no new keypress for 15 seconds, client stops sending `typing:start`; - receiving clients timeout and hide the indicator after 15s of silence -- **Session manager presence delivery:** - - SMs collect heartbeat events from NATS into a buffer - - Once per minute, flush a batched presence update to each connected client - - Filter per client: only include users relevant to that client - (same channels, active DMs — not the entire community) - - Presence update is a diff: only users whose status changed (online→offline or vice versa) - - Clients never receive raw heartbeat events — only batched, filtered presence snapshots - -### Notifications -Persisted per-user. Syncs across all clients — unread on web shows unread on CLI and TUI. -- **Triggers** (API creates notification rows on): - - @mention (user, channel, here) - - DM received - - Thread reply to a message you authored or participated in - - Channel invite - - System events (role change, ban, etc.) -- `notifications` table stores one row per user per event, with `read:bool` -- `source_id` points to the originating entity (message, channel, etc.) based on `type` -- **Read/unread state**: clients mark notifications read via API (`POST /api/notifications/read`) -- **Real-time**: EventBus publishes notification events; session managers deliver immediately -- **Query**: `GET /api/notifications?unread=true` — any client can fetch current unread count/list -- `mentions` table remains for structured @mention data; `notifications` is the user-facing delivery layer - -## Database Strategy -- **PostgreSQL everywhere** (dev + prod). Dev Postgres runs in Docker. -- Shared DB layer in `shared/` uses next.jdbc + HoneySQL -- Avoid raw SQL strings; use HoneySQL maps everywhere -- Schema migrations: **migratus** (SQL migration files, works with next.jdbc) -- PostgreSQL is a pure data store — no pub/sub responsibility - -## File Storage -S3-compatible object storage via **MinIO**. Same S3 API everywhere — portable to AWS later. - -- **Dev:** MinIO container (alongside Postgres in docker-compose.dev.yml) -- **Prod self-hosted:** MinIO container in Docker Compose alongside other services -- **Prod AWS (future):** swap endpoint URL to real S3, zero code changes -- API handles uploads: validates, stores in MinIO, writes `attachments` row with `storage_key` -- `storage_key` format: `///` -- Clojure S3 client: amazonica or cognitect aws-api (both work with MinIO via endpoint override) -- Avatars, webhook bot icons also stored in MinIO - -## Development Workflow -REPL-driven development. Infrastructure (Postgres, MinIO) runs in Docker. -Clojure services run locally via nREPL — not in containers. - -- **REPL-first**: start services from the REPL, hot-reload code, eval as you go -- **CIDER middleware**: nrepl, cider-nrepl, refactor-nrepl in `:dev` alias -- **Docker for infra only**: `docker-compose.dev.yml` runs Postgres + MinIO + NATS -- **EventBus**: NATS — same as prod, no mock/shim -- **Dev without auth gateway**: services run directly, auth bypassed or mocked - -### REPL Options -1. **Single REPL** — start one JVM with all modules loaded. Good for early dev. - `clj -A:dev:api:web-sm:tui-sm:auth-gw` — all services in one process, shared Postgres + NATS. -2. **Multiple REPLs** — one per service. Better isolation, closer to prod topology. - Each connects its own nREPL on a different port. - -### Dev Aliases -``` -clj -M:dev:api # API service + CIDER nREPL -clj -M:dev:web-sm # Web SM + CIDER nREPL -clj -M:dev:tui-sm # TUI SM + CIDER nREPL -clj -M:dev:auth-gw # Auth gateway + CIDER nREPL -``` - -Each `:dev` alias includes nrepl + cider-nrepl + refactor-nrepl. -Services expose `(start!)` / `(stop!)` / `(reset!)` functions for REPL control. - -## Testing - -Three tiers, escalating in scope and infrastructure. - -### Unit Tests — no external deps -Fast, pure-function tests. No Docker, no DB, no HTTP. -- Test runner: **Kaocha** (`:test/unit` alias) -- What's tested: validation, parsing, formatting, HoneySQL query builders, - markdown processing, permission logic, slash command parsing, data transforms -- DB layer tested via protocol mocks/stubs — verify queries are built correctly - without executing them -- Run: `clj -M:test/unit` — takes seconds, runs in CI on every push - -### Integration Tests — dockerized infra -Test service internals against real Postgres + MinIO + NATS. -- `docker-compose.test.yml` spins up Postgres + MinIO + NATS with test-specific - ports and a fresh DB (no data carried between runs) -- Tests run on the host JVM, connecting to Docker infra -- What's tested: DB migrations, repository functions (actual SQL round-trips), - EventBus (real NATS pub/sub), file upload/download, API route handlers - with a real DB behind them -- Each test namespace gets a transaction that rolls back — tests don't leak state -- Run: `docker compose -f docker-compose.test.yml up -d && clj -M:test/integration` -- CI: Docker services started as job services, tests run in the pipeline - -### E2E Tests — full stack in Docker -Production-like topology. Everything containerized. -- `docker-compose.test.yml` with additional service containers: - nginx + auth-gw + api + web-sm + tui-sm (built from uberjars) -- Tests run from **outside** the stack as a client would -- What's tested: full request lifecycle (auth → API → DB → event → SM → client), - SSE connections, webhook delivery, multi-user scenarios, - cross-service event propagation -- HTTP client for API + SM tests (SSE consumer for streaming) -- Playwright for web UI tests (page loads, live updates, reactions, typing indicators) -- Run: `docker compose -f docker-compose.test.yml --profile e2e up -d && clj -M:test/e2e` - -### Test Aliases -``` -clj -M:test/unit # unit tests — no Docker needed -clj -M:test/integration # integration — requires docker-compose.test.yml -clj -M:test/e2e # end-to-end — requires full stack in Docker -clj -M:test/all # all tiers sequentially -``` - -### Docker Compose Files -``` -docker-compose.dev.yml # dev: Postgres + MinIO + NATS -docker-compose.test.yml # test: Postgres + MinIO + NATS (fresh DB per run) - # --profile e2e adds all service containers -docker-compose.yml # prod: full stack -``` - -## Deployment (Prod) -- **Docker Compose** for production -- Each service as a container: auth-gw, api, web-sm, tui-sm -- PostgreSQL + MinIO + NATS + nginx as infrastructure containers -- docker-compose.yml defines the full topology -- Uberjars built per service - -## Key Decisions Made -- [x] Monorepo with :local/root deps -- [x] http-kit + reitit + Ring for all server modules (no Pedestal) -- [x] Hiccup + Datastar for web frontend (no ClojureScript) -- [x] Datastar Clojure SDK (dev.data-star.clojure/sdk) with http-kit adapter -- [x] Single `ajet-chat` executable: CLI mode (one-off) + TUI mode (interactive) -- [x] clojure-tui for TUI rendering, babashka/bbin for distribution -- [x] PostgreSQL everywhere (dev + prod), no SQLite -- [x] Persistent writes through API, event bus for real-time fan-out -- [x] EventBus: NATS — dedicated pub/sub, community-scoped subjects, JetStream for replay -- [x] API is stateless REST — no live connections -- [x] Session managers per client type — each independently scalable -- [x] Session managers: NATS for events, API for all data reads — no direct PG connection -- [x] SMs publish ephemeral events (typing) directly to NATS — bypasses API for latency -- [x] Auth gateway in Clojure (http-kit) — custom DB reads/writes for auth -- [x] Auth: OAuth-only for v1 (GitHub, Gitea), email-based local auth later -- [x] Auth: session tokens + API tokens for programmatic access -- [x] Slash commands as universal interaction model (regular + admin, role-gated) -- [x] No separate admin module — admin is just elevated slash commands -- [x] Incoming webhooks for external integrations (git, CI, monitoring) -- [x] MinIO for file/attachment storage (S3-compatible, portable to AWS) -- [x] Presence via heartbeats: 1 min intervals, async DB write, instant 200 response -- [x] Slack-like data model: communities > channels > messages > threads (1 deep) -- [x] Global DMs: channels with `community_id = NULL`, type `dm`/`group_dm` — no extra tables -- [x] Multi-community: users can join multiple communities, UI shows one at a time -- [x] Path-based community routing: `/c//...` for community, `/dm/...` for global DMs -- [x] Voice channels: LiveKit SFU, TUI opens browser for audio (not v1) -- [x] Search: PG tsvector + GIN index, Meilisearch upgrade path -- [x] Messages: markdown + file uploads + @mentions/pings -- [x] Mobile: deferred, focus on web + CLI first -- [x] nginx for TLS termination in prod -- [x] Migratus for DB migrations -- [x] Docker Compose for prod; docker-compose.dev.yml for dev infra (Postgres + MinIO + NATS) -- [x] REPL-driven dev: nrepl + cider-nrepl + refactor-nrepl -- [x] Clojure services run locally via REPL, not in containers -- [x] Three-tier testing: unit (no deps) → integration (Docker infra) → E2E (full stack) -- [x] Kaocha test runner, separate aliases per tier -- [x] E2E: Playwright for web, HTTP/SSE client for API/SM - -## Planned (not v1) - -### Voice Channels — LiveKit SFU -- Self-hosted LiveKit (Go binary, Docker container) as the SFU -- Web/mobile: full WebRTC audio via LiveKit JS/native SDKs -- TUI: voice channel state display (who's in, mute status) + opens browser link for audio -- LiveKit Java SDK for Clojure interop (room management, token generation) - -### Search — PostgreSQL Full-Text Search -- PostgreSQL tsvector + GIN index (dev and prod) -- Zero extra infrastructure -- Supports stemming, ranking, phrase search -- Upgrade path: swap in Meilisearch behind the same API endpoint if needed later diff --git a/shared/src/ajet/chat/shared/api_client.clj b/shared/src/ajet/chat/shared/api_client.clj index 2b0ce19..a446b29 100644 --- a/shared/src/ajet/chat/shared/api_client.clj +++ b/shared/src/ajet/chat/shared/api_client.clj @@ -169,10 +169,11 @@ ;;; --------------------------------------------------------------------------- (defn get-messages - "Fetch messages for a channel. opts: :before :limit" + "Fetch messages for a channel. opts: :before :after :limit" [ctx channel-id & [opts]] (let [qp (cond-> {} (:before opts) (assoc "before" (str (:before opts))) + (:after opts) (assoc "after" (str (:after opts))) (:limit opts) (assoc "limit" (str (:limit opts))))] (request! ctx :get (str "/api/channels/" channel-id "/messages") (when (seq qp) {:query-params qp})))) diff --git a/tui-sm/plan.md b/tui-sm/plan.md deleted file mode 100644 index dd1d322..0000000 --- a/tui-sm/plan.md +++ /dev/null @@ -1,28 +0,0 @@ -# TUI Session Manager Plan - -## Overview -Server-side session manager for terminal clients (CLI + TUI). -Holds live SSE connections, delivers real-time events. - -## Stack -- http-kit (HTTP server + SSE) -- reitit (routing) + Ring middleware -- NATS (via shared/) for event pub/sub — no direct PG connection -- Reads data from API (internal HTTP calls) - -## Responsibilities -- Manage live SSE connections for terminal clients -- Subscribe to NATS for real-time events (messages, presence, typing) -- Publish ephemeral events (typing indicators) to NATS — no API round-trip -- Fetch full data from API when notification contains only IDs -- Sits behind auth gateway (all requests pre-authenticated) - -## TODO -- [ ] http-kit server setup with SSE -- [ ] Connection tracking (atom of connected SSE clients) -- [ ] HTTP POST endpoints for client→server signals (typing indicators) -- [ ] NATS subscription for chat events -- [ ] NATS publish for typing indicators -- [x] Internal API client for data fetches — `ajet.chat.shared.api-client` in shared/ -- [ ] Event filtering: deliver only relevant events per client -- [ ] Presence batching: buffer heartbeat events, flush 1x/min diff --git a/web-sm/plan.md b/web-sm/plan.md deleted file mode 100644 index c9e4f1e..0000000 --- a/web-sm/plan.md +++ /dev/null @@ -1,29 +0,0 @@ -# Web Session Manager Plan - -## Stack -- http-kit (HTTP server + SSE) -- reitit (routing) + Ring middleware -- Hiccup for HTML templating -- Datastar Clojure SDK (dev.data-star.clojure/sdk) for SSE-driven reactivity -- No ClojureScript — server-rendered with Datastar enhancement - -## Responsibilities -- Web session manager: manages live browser connections -- Serves Hiccup-rendered pages -- Holds Datastar SSE connections, pushes HTML fragments on events -- Subscribes to NATS for real-time events — no direct PG connection -- Publishes ephemeral events (typing indicators) to NATS — no API round-trip -- Fetches full data from API (internal HTTP calls), not directly from DB -- Sits behind auth gateway (all requests pre-authenticated) - -## TODO -- [ ] http-kit server setup with Datastar SDK (http-kit adapter) -- [ ] Design page layout / Hiccup components -- [ ] Integrate Datastar (CDN or vendor the JS) -- [ ] Chat view: channel list, message list, input -- [ ] NATS subscription for chat events -- [ ] NATS publish for typing indicators -- [x] Internal API client for data fetches — `ajet.chat.shared.api-client` in shared/ -- [ ] SSE endpoint: push Datastar fragments on events -- [ ] Connection tracking (atom of connected SSE clients) -- [ ] ~~Login page~~ — Auth GW owns the login page; Web SM never sees unauthenticated users diff --git a/web-sm/src/ajet/chat/web/components.clj b/web-sm/src/ajet/chat/web/components.clj index 9d4dd13..38390b1 100644 --- a/web-sm/src/ajet/chat/web/components.clj +++ b/web-sm/src/ajet/chat/web/components.clj @@ -57,9 +57,8 @@ [{:keys [communities active-id unread-count]}] (list ;; Home / DMs button - [:div {:class "w-12 h-12 rounded-2xl bg-surface0 flex items-center justify-center - cursor-pointer hover:rounded-xl hover:bg-blue transition-all duration-200 mb-2 relative" - :data-on-click "@post('/web/navigate', {headers: {'X-Target': 'dms'}})"} + [:div {:class "w-12 h-12 rounded-2xl bg-surface0 flex items-center justify-center cursor-pointer hover:rounded-xl hover:bg-blue transition-all duration-200 mb-2 relative" + "data-on:click" "$navTarget = 'dms'; $navCommunity = ''; $navChannel = ''; @post('/web/navigate')"} [:svg {:class "w-6 h-6 text-text" :viewBox "0 0 24 24" :fill "currentColor"} [:path {:d "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"}]] (when (and unread-count (pos? unread-count)) @@ -81,14 +80,13 @@ "rounded-xl bg-blue text-base" "bg-surface0 text-subtext0 hover:rounded-xl hover:bg-blue hover:text-base")) :title (:name comm) - :data-on-click (str "@post('/web/navigate', {headers: {'X-Community-Id': '" cid "'}})")} + "data-on:click" (str "$navCommunity = '" cid "'; $navChannel = ''; $navTarget = ''; @post('/web/navigate')")} (community-initial comm)])) ;; Add community button - [:div {:class "w-12 h-12 rounded-2xl bg-surface0 flex items-center justify-center - cursor-pointer hover:rounded-xl hover:bg-green transition-all duration-200 mt-2" + [:div {:class "w-12 h-12 rounded-2xl bg-surface0 flex items-center justify-center cursor-pointer hover:rounded-xl hover:bg-green transition-all duration-200 mt-2" :title "Create Community" - :data-on-click "window.location.href='/setup'"} + "data-on:click" "window.location.href='/setup'"} [:span {:class "text-green text-2xl font-light"} "+"]])) ;;; --------------------------------------------------------------------------- @@ -191,7 +189,7 @@ (if active "bg-surface0 text-text" "text-overlay1 hover:text-subtext1 hover:bg-hover")) - :data-on-click (str "@post('/web/navigate', {headers: {'X-Channel-Id': '" cid "'}})")} + "data-on:click" (str "$navChannel = '" cid "'; $navCommunity = ''; $navTarget = ''; @post('/web/navigate')")} [:span {:class "mr-1.5 text-overlay0 text-xs"} prefix] [:span {:class "truncate"} (:name ch)] ;; Unread badge placeholder @@ -210,7 +208,7 @@ (if active "bg-surface0 text-text" "text-overlay1 hover:text-subtext1 hover:bg-hover")) - :data-on-click (str "@post('/web/navigate', {headers: {'X-Channel-Id': '" dm-id "'}})")} + "data-on:click" (str "$navChannel = '" dm-id "'; $navCommunity = ''; $navTarget = ''; @post('/web/navigate')")} [:div {:class "w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold mr-2 flex-shrink-0" :style (str "background-color: " (avatar-color dm-id))} (user-initials name)] @@ -237,7 +235,7 @@ ;; Search button [:button {:class "text-overlay0 hover:text-text" :title "Search" - :data-on-click "$searchOpen = !$searchOpen"} + "data-on:click" "$searchOpen = !$searchOpen"} [:svg {:class "w-5 h-5" :viewBox "0 0 24 24" :fill "none" :stroke "currentColor" :stroke-width "2"} [:circle {:cx "11" :cy "11" :r "8"}] [:line {:x1 "21" :y1 "21" :x2 "16.65" :y2 "16.65"}]]] @@ -311,14 +309,14 @@ (for [r reactions] [:button {:key (str (:emoji r) "-" (count (:users r))) :class "flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-surface0 border border-surface1 hover:border-blue transition-colors" - :data-on-click (str "@post('/web/reactions', {headers: {'X-Message-Id': '" msg-id "', 'X-Emoji': '" (:emoji r) "'}})")} + "data-on:click" (str "@post('/web/reactions', {headers: {'X-Message-Id': '" msg-id "', 'X-Emoji': '" (:emoji r) "'}})")} [:span (:emoji r)] [:span {:class "text-subtext0"} (count (:users r))]])]) ;; Thread indicator (when (and thread-count (pos? thread-count)) [:button {:class "flex items-center gap-1 text-xs text-blue hover:underline mt-1" - :data-on-click (str "$threadOpen = true; $threadMessageId = '" msg-id "'")} + "data-on:click" (str "$threadOpen = true; $threadMessageId = '" msg-id "'")} [:span (str thread-count " " (if (= 1 thread-count) "reply" "replies"))]])] ;; Hover action toolbar @@ -326,36 +324,38 @@ ;; Add reaction [:button {:class "p-1.5 hover:bg-hover rounded-l text-overlay0 hover:text-text" :title "Add Reaction" - :data-on-click (str "$emojiOpen = !$emojiOpen; $threadMessageId = '" msg-id "'")} + "data-on:click" (str "$emojiOpen = !$emojiOpen; $threadMessageId = '" msg-id "'")} "\uD83D\uDE00"] ;; Reply in thread [:button {:class "p-1.5 hover:bg-hover text-overlay0 hover:text-text" :title "Reply in Thread" - :data-on-click (str "$threadOpen = true; $threadMessageId = '" msg-id "'")} + "data-on:click" (str "$threadOpen = true; $threadMessageId = '" msg-id "'")} "\uD83D\uDCAC"] ;; Edit (own messages only) (when is-own [:button {:class "p-1.5 hover:bg-hover text-overlay0 hover:text-text" :title "Edit" - :data-on-click (str "let el = document.querySelector('#msg-" msg-id " .message-body');" + "data-on:click" (str "let el = document.querySelector('#msg-" msg-id " .message-body');" "el.contentEditable = 'true'; el.focus();")} "\u270F\uFE0F"]) ;; Delete (own messages only) (when is-own [:button {:class "p-1.5 hover:bg-hover rounded-r text-overlay0 hover:text-red" :title "Delete" - :data-on-click (str "if(confirm('Delete this message?')) @post('/web/messages/" msg-id "/delete')")} + "data-on:click" (str "if(confirm('Delete this message?')) @post('/web/messages/" msg-id "/delete')")} "\uD83D\uDDD1\uFE0F"])]])) (defn message-list "Scrollable list of messages with a 'Load older' trigger at top." [messages current-user] (list - ;; Load older sentinel + ;; Load older sentinel — only show when a full page was returned (may have more) [:div {:id "load-older-sentinel" :class "flex justify-center py-2"} - [:button {:class "text-xs text-overlay0 hover:text-subtext0 px-3 py-1 rounded bg-surface0 hover:bg-hover transition-colors" - :data-on-click "@post('/web/messages', {headers: {'X-Load-Older': 'true'}})"} - "Load older messages"]] + (if (>= (count messages) 50) + [:button {:class "text-xs text-overlay0 hover:text-subtext0 px-3 py-1 rounded bg-surface0 hover:bg-hover transition-colors" + "data-on:click" "@post('/web/messages/older')"} + "Load older messages"] + [:span {:class "text-xs text-overlay0"} "Beginning of conversation"])] ;; Messages [:div {:id "messages-container"} @@ -379,7 +379,7 @@ ;; Upload button [:button {:class "p-3 text-overlay0 hover:text-text flex-shrink-0" :title "Upload Image" - :data-on-click "document.getElementById('file-upload-input').click()"} + "data-on:click" "document.getElementById('file-upload-input').click()"} [:svg {:class "w-5 h-5" :viewBox "0 0 24 24" :fill "none" :stroke "currentColor" :stroke-width "2"} [:path {:d "M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"}]]] ;; Hidden file input @@ -387,25 +387,27 @@ :id "file-upload-input" :class "hidden" :accept "image/jpeg,image/png,image/gif,image/webp" - :data-on-change (str "@post('/web/upload', {headers: {'X-Channel-Id': '" (or channel-id "") "'}})")}] + "data-on:change" (str "@post('/web/upload', {headers: {'X-Channel-Id': '" (or channel-id "") "'}})")}] ;; Textarea [:textarea {:id "message-textarea" :class "flex-1 bg-transparent text-text placeholder-overlay0 px-1 py-3 resize-none outline-none text-sm max-h-40" :placeholder (str "Message #" ch-name) :rows "1" :data-bind "messageText" - :data-on-keydown (str "if(evt.key === 'Enter' && !evt.shiftKey) {" + "data-on:keydown" (str "if(evt.key === 'Enter' && !evt.shiftKey) {" " evt.preventDefault();" " if($messageText.trim()) {" - " @post('/web/messages', {headers: {'X-Channel-Id': '" (or channel-id "") "'}});" + " @post('/web/messages');" + " $messageText = '';" " }" "}") - :data-on-input (str "@post('/web/typing', {headers: {'X-Channel-Id': '" (or channel-id "") "'}})")}] + "data-on:input" (str "@post('/web/typing', {headers: {'X-Channel-Id': '" (or channel-id "") "'}})")}] ;; Send button [:button {:class "p-3 text-blue hover:text-text flex-shrink-0" :title "Send" - :data-on-click (str "if($messageText.trim()) {" - " @post('/web/messages', {headers: {'X-Channel-Id': '" (or channel-id "") "'}});" + "data-on:click" (str "if($messageText.trim()) {" + " @post('/web/messages');" + " $messageText = '';" "}")} [:svg {:class "w-5 h-5" :viewBox "0 0 24 24" :fill "currentColor"} [:path {:d "M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"}]]]]])) @@ -422,7 +424,7 @@ [:div {:class "h-12 px-4 flex items-center border-b border-surface1 flex-shrink-0"} [:span {:class "font-semibold text-text text-sm"} "Thread"] [:button {:class "ml-auto text-overlay0 hover:text-text" - :data-on-click "$threadOpen = false"} + "data-on:click" "$threadOpen = false"} [:svg {:class "w-5 h-5" :viewBox "0 0 24 24" :fill "none" :stroke "currentColor" :stroke-width "2"} [:line {:x1 "18" :y1 "6" :x2 "6" :y2 "18"}] [:line {:x1 "6" :y1 "6" :x2 "18" :y2 "18"}]]]] @@ -444,15 +446,15 @@ :placeholder "Reply in thread..." :rows "1" :data-bind "threadReply" - :data-signals-thread-reply "" - :data-on-keydown (str "if(evt.key === 'Enter' && !evt.shiftKey) {" + "data-signals:threadReply" "" + "data-on:keydown" (str "if(evt.key === 'Enter' && !evt.shiftKey) {" " evt.preventDefault();" " if($threadReply.trim()) {" " @post('/web/messages', {headers: {'X-Parent-Id': $threadMessageId}});" " }" "}")}] [:button {:class "p-2 text-blue hover:text-text flex-shrink-0" - :data-on-click (str "if($threadReply.trim()) {" + "data-on:click" (str "if($threadReply.trim()) {" " @post('/web/messages', {headers: {'X-Parent-Id': $threadMessageId}});" "}")} [:svg {:class "w-4 h-4" :viewBox "0 0 24 24" :fill "currentColor"} @@ -473,13 +475,13 @@ [:div {:id (str "toast-" (or id (random-uuid))) :class "toast-enter bg-surface0 border border-surface1 rounded-lg shadow-xl p-3 max-w-sm min-w-[280px]" :style (str "border-left: 3px solid " color) - :data-on-click "this.remove()"} + "data-on:click" "this.remove()"} [:div {:class "flex items-start gap-2"} [:div {:class "flex-1 min-w-0"} [:div {:class "text-sm font-medium text-text truncate"} (or title "Notification")] [:div {:class "text-xs text-subtext0 mt-0.5 truncate"} (or body "")]] [:button {:class "text-overlay0 hover:text-text flex-shrink-0 text-sm" - :data-on-click "this.parentElement.parentElement.remove()"} + "data-on:click" "this.parentElement.parentElement.remove()"} "\u2715"]]])) ;;; --------------------------------------------------------------------------- @@ -504,11 +506,11 @@ (for [emoji emoji-grid] [:button {:key emoji :class "emoji-btn text-xl w-10 h-10 flex items-center justify-center rounded hover:bg-hover" - :data-on-click (str "@post('/web/reactions', {headers: {'X-Message-Id': $threadMessageId, 'X-Emoji': '" emoji "'}});" + "data-on:click" (str "@post('/web/reactions', {headers: {'X-Message-Id': $threadMessageId, 'X-Emoji': '" emoji "'}});" " $emojiOpen = false")} emoji])] [:button {:class "mt-2 w-full text-xs text-overlay0 hover:text-subtext0 py-1" - :data-on-click "$emojiOpen = false"} + "data-on:click" "$emojiOpen = false"} "Close"]]) ;;; --------------------------------------------------------------------------- @@ -521,7 +523,7 @@ [:div {:class "flex items-start justify-center pt-20"} ;; Backdrop [:div {:class "absolute inset-0 bg-black bg-opacity-50" - :data-on-click "$searchOpen = false"}] + "data-on:click" "$searchOpen = false"}] ;; Modal [:div {:class "relative bg-mantle border border-surface1 rounded-xl shadow-2xl w-full max-w-2xl z-10"} ;; Search input @@ -533,10 +535,10 @@ :class "flex-1 bg-transparent text-text placeholder-overlay0 py-3 outline-none text-sm" :placeholder "Search messages..." :data-bind "searchQuery" - :data-on-keydown "if(evt.key === 'Enter') @post('/web/search')" + "data-on:keydown" "if(evt.key === 'Enter') @post('/web/search')" :autofocus true}] [:button {:class "text-overlay0 hover:text-text ml-2" - :data-on-click "$searchOpen = false"} + "data-on:click" "$searchOpen = false"} [:span {:class "text-xs border border-surface1 rounded px-1.5 py-0.5"} "ESC"]]] ;; Search results container [:div {:id "search-results" :class "max-h-96 overflow-y-auto p-4"} @@ -555,7 +557,7 @@ ts (format-timestamp (or (:created-at msg) (:created_at msg)))] [:div {:key msg-id :class "flex items-start gap-3 p-2 rounded hover:bg-hover cursor-pointer" - :data-on-click (str "$searchOpen = false;" + "data-on:click" (str "$searchOpen = false;" " document.getElementById('msg-" msg-id "')?.scrollIntoView({behavior: 'smooth'})")} [:div {:class "w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0" :style (str "background-color: " (avatar-color (:user-id msg)))} diff --git a/web-sm/src/ajet/chat/web/handlers.clj b/web-sm/src/ajet/chat/web/handlers.clj index 5c9037b..be5d517 100644 --- a/web-sm/src/ajet/chat/web/handlers.clj +++ b/web-sm/src/ajet/chat/web/handlers.clj @@ -39,21 +39,44 @@ [request header-name] (get-in request [:headers (str/lower-case header-name)])) -(defn- datastar-fragment-response - "Return an SSE response with a Datastar patch-elements event. - Used for POST handlers that need to return a UI update." - [hiccup-fragment & [{:keys [selector mode] :or {mode "morph"}}]] - (let [html-str (str (h/html hiccup-fragment)) +(defn- fragment-event + "Build a single datastar-patch-elements SSE event string for one fragment. + Newlines in the rendered HTML are replaced with spaces to avoid breaking + SSE data lines (each data: line must be a single line in the SSE protocol)." + [hiccup-fragment & [{:keys [selector mode] :or {mode "outer"}}]] + (let [html-str (str/replace (str (h/html hiccup-fragment)) "\n" " ") lines (cond-> [] selector (conj (str "selector " selector)) true (conj (str "mode " mode)) true (conj (str "elements " html-str)))] - {:status 200 - :headers {"Content-Type" "text/event-stream" - "Cache-Control" "no-cache, no-store, must-revalidate"} - :body (str "event: datastar-patch-elements\n" - (apply str (map #(str "data: " % "\n") lines)) - "\n")})) + (str "event: datastar-patch-elements\n" + (apply str (map #(str "data: " % "\n") lines)) + "\n"))) + +(defn- datastar-fragment-response + "Return an SSE response with a Datastar patch-elements event. + Used for POST handlers that need to return a UI update." + [hiccup-fragment & [{:keys [selector mode] :or {mode "outer"}}]] + {:status 200 + :headers {"Content-Type" "text/event-stream" + "Cache-Control" "no-cache, no-store, must-revalidate"} + :body (fragment-event hiccup-fragment {:selector selector :mode mode})}) + +(defn- datastar-multi-fragment-response + "Return an SSE response with multiple datastar-patch-elements events. + Each fragment is morphed independently by its element id. + Optionally includes a patch-signals event to sync client state." + ([fragments] + (datastar-multi-fragment-response fragments nil)) + ([fragments signals-json] + {:status 200 + :headers {"Content-Type" "text/event-stream" + "Cache-Control" "no-cache, no-store, must-revalidate"} + :body (str (apply str (map fragment-event fragments)) + (when signals-json + (str "event: datastar-patch-signals\n" + "data: signals " signals-json "\n" + "\n")))})) (defn- datastar-signals-response "Return an SSE response with a Datastar patch-signals event." @@ -140,6 +163,47 @@ (api/delete-message ctx message-id)) (empty-sse-response))) +;;; --------------------------------------------------------------------------- +;;; Load Older Messages Handler +;;; --------------------------------------------------------------------------- + +(defn load-older-handler + "POST /web/messages/older -- fetch older messages using before cursor. + Reads oldestMessageId and activeChannel from Datastar signals. + Prepends older messages into #messages-container and updates the cursor signal." + [request] + (let [ctx (build-api-ctx request) + channel-id (get-signal request "activeChannel") + cursor (get-signal request "oldestMessageId") + user (api/get-me ctx)] + (if (or (str/blank? channel-id) (str/blank? cursor)) + (empty-sse-response) + (let [messages (api/get-messages ctx channel-id + {:before cursor :limit 50}) + new-oldest (when (seq messages) (str (:id (first messages))))] + (if (empty? messages) + ;; No more messages — replace the load-older button with a notice + (datastar-fragment-response + [:div {:id "load-older-sentinel" :class "flex justify-center py-2"} + [:span {:class "text-xs text-overlay0"} "Beginning of conversation"]]) + ;; Prepend older messages and update cursor + (let [batch-html (str (h/html + [:div {:id (str "older-batch-" new-oldest)} + (for [msg messages] + (c/message-component msg user))])) + html-str (str/replace batch-html "\n" " ")] + {:status 200 + :headers {"Content-Type" "text/event-stream" + "Cache-Control" "no-cache, no-store, must-revalidate"} + :body (str "event: datastar-patch-elements\n" + "data: selector #messages-container\n" + "data: mode prepend\n" + "data: elements " html-str "\n" + "\n" + "event: datastar-patch-signals\n" + "data: signals {oldestMessageId: '" new-oldest "'}\n" + "\n")})))))) + ;;; --------------------------------------------------------------------------- ;;; Reaction Handlers ;;; --------------------------------------------------------------------------- @@ -175,46 +239,53 @@ (defn navigate-handler "POST /web/navigate -- switch community or channel. Updates connection tracking and re-subscribes NATS. - Returns the updated sidebar + message list + channel header." + Returns the updated sidebar + message list + channel header. + Navigation intent is determined from explicit request headers only + (not Datastar signals) to avoid stale state." [request] (let [ctx (build-api-ctx request) user-id (:user-id request) connections (get-in request [:system :connections]) nats (get-in request [:system :nats]) - target (get-header request "x-target") - community-id (or (get-header request "x-community-id") - (get-signal request "activeCommunity")) - channel-id (or (get-header request "x-channel-id") - (get-signal request "activeChannel"))] + ;; Read navigation intent from dedicated nav signals (set right before @post) + ;; These are distinct from activeCommunity/activeChannel to avoid stale state + target (let [v (get-signal request "navTarget")] (when-not (str/blank? v) v)) + community-id (let [v (get-signal request "navCommunity")] (when-not (str/blank? v) v)) + channel-id (let [v (get-signal request "navChannel")] (when-not (str/blank? v) v))] (cond ;; Navigate to DMs view (= target "dms") - (let [dms (api/get-dms ctx) + (let [communities (api/get-communities ctx) + dms (api/get-dms ctx) dm (first dms) dm-id (when dm (str (:id dm))) messages (when dm-id (api/get-messages ctx dm-id {:limit 50})) - user (api/get-me ctx)] + user (api/get-me ctx) + oldest (when (seq messages) (str (:id (first messages))))] ;; Re-subscribe NATS (no community context for DMs) (when user-id (sse/resubscribe! connections nats user-id nil dm-id)) - ;; Return updated UI fragments - (datastar-fragment-response - [:div {:id "app-content"} - ;; Update sidebar with DM list + ;; Return updated UI fragments — each matches an existing DOM element + (datastar-multi-fragment-response + [[:div {:id "community-strip" + :class "w-[72px] flex-shrink-0 bg-mantle flex flex-col items-center py-3 gap-2 overflow-y-auto"} + (c/community-strip {:communities communities + :active-id nil + :unread-count 0})] [:div {:id "sidebar" :class "w-60 flex-shrink-0 bg-mantle flex flex-col border-r border-surface1"} (c/dm-sidebar {:dms dms :active-dm dm :user user})] - ;; Update channel header [:div {:id "channel-header"} (c/channel-header (or dm {:name "Direct Messages"}))] - ;; Update messages [:div {:id "message-list" :class "flex-1 overflow-y-auto px-4 py-2"} - (c/message-list (reverse (or messages [])) user)] - ;; Update input + (c/message-list (or messages []) user)] [:div {:id "message-input-area" :class "px-4 pb-4"} - (c/message-input dm)]])) + (c/message-input dm)]] + (str "{activeCommunity: '', activeChannel: '" (or dm-id "") "'" + ", oldestMessageId: '" (or oldest "") "'}"))) + ;; Navigate to a specific channel channel-id @@ -224,14 +295,14 @@ channels (when cid (api/get-channels ctx cid)) messages (api/get-messages ctx channel-id {:limit 50}) categories (when cid (api/get-categories ctx cid)) - user (api/get-me ctx)] + user (api/get-me ctx) + oldest (when (seq messages) (str (:id (first messages))))] ;; Re-subscribe NATS (when user-id (sse/resubscribe! connections nats user-id cid channel-id)) ;; Return updated UI fragments - (datastar-fragment-response - [:div {:id "app-content"} - [:div {:id "sidebar" + (datastar-multi-fragment-response + [[:div {:id "sidebar" :class "w-60 flex-shrink-0 bg-mantle flex flex-col border-r border-surface1"} (c/sidebar {:community community :channels channels @@ -242,9 +313,12 @@ (c/channel-header channel)] [:div {:id "message-list" :class "flex-1 overflow-y-auto px-4 py-2"} - (c/message-list (reverse (or messages [])) user)] + (c/message-list (or messages []) user)] [:div {:id "message-input-area" :class "px-4 pb-4"} - (c/message-input channel)]])) + (c/message-input channel)]] + (str "{activeCommunity: '" (or cid "") "', activeChannel: '" channel-id "'" + ", oldestMessageId: '" (or oldest "") "'}"))) + ;; Navigate to a community (pick first channel) community-id @@ -254,14 +328,14 @@ ch-id (when channel (str (:id channel))) messages (when ch-id (api/get-messages ctx ch-id {:limit 50})) categories (api/get-categories ctx community-id) - user (api/get-me ctx)] + user (api/get-me ctx) + oldest (when (seq messages) (str (:id (first messages))))] ;; Re-subscribe NATS (when user-id (sse/resubscribe! connections nats user-id community-id ch-id)) ;; Return updated UI - (datastar-fragment-response - [:div {:id "app-content"} - [:div {:id "community-strip" + (datastar-multi-fragment-response + [[:div {:id "community-strip" :class "w-[72px] flex-shrink-0 bg-mantle flex flex-col items-center py-3 gap-2 overflow-y-auto"} (c/community-strip {:communities (api/get-communities ctx) :active-id community-id @@ -277,9 +351,12 @@ (c/channel-header channel)] [:div {:id "message-list" :class "flex-1 overflow-y-auto px-4 py-2"} - (c/message-list (reverse (or messages [])) user)] + (c/message-list (or messages []) user)] [:div {:id "message-input-area" :class "px-4 pb-4"} - (c/message-input channel)]])) + (c/message-input channel)]] + (str "{activeCommunity: '" community-id "', activeChannel: '" (or ch-id "") "'" + ", oldestMessageId: '" (or oldest "") "'}"))) + :else (empty-sse-response)))) diff --git a/web-sm/src/ajet/chat/web/layout.clj b/web-sm/src/ajet/chat/web/layout.clj index fdc9e8a..257f7cb 100644 --- a/web-sm/src/ajet/chat/web/layout.clj +++ b/web-sm/src/ajet/chat/web/layout.clj @@ -120,7 +120,8 @@ [{:keys [user communities community channels channel messages categories unread-count dm-view? dms]}] (let [community-id (when community (str (:id community))) - channel-id (when channel (str (:id channel)))] + channel-id (when channel (str (:id channel))) + oldest-msg-id (when (seq messages) (str (:id (first messages))))] (base-page {:title (str (when channel (str "#" (:name channel) " - ")) (when community (:name community)) @@ -136,7 +137,11 @@ " threadOpen: false," " threadMessageId: ''," " commandText: ''," - " unreadCount: " (or unread-count 0) "}") + " unreadCount: " (or unread-count 0) "," + " navCommunity: ''," + " navChannel: ''," + " navTarget: ''," + " oldestMessageId: '" (or oldest-msg-id "") "'}") ;; Auto-connect to SSE on page load :data-init (str "@get('/sse/events', {openWhenHidden: true})")}] ;; Main 4-pane layout @@ -168,9 +173,7 @@ ;; Messages [:div {:id "message-list" :class "flex-1 overflow-y-auto px-4 py-2" - :data-on-scroll (str "if(evt.target.scrollTop === 0) {" - " @post('/web/messages', {headers: {'X-Load-Older': 'true'}})" - "}")} + "data-on:scroll" "if(evt.target.scrollTop === 0) { @post('/web/messages/older') }"} (c/message-list messages user)] ;; Message input @@ -181,7 +184,7 @@ ;; Pane 4: Thread panel (hidden by default) [:div {:id "thread-panel" :class "w-96 flex-shrink-0 bg-mantle border-l border-surface1 flex-col hidden" - :data-class-hidden "!$threadOpen"}]] + "data-class:hidden" "!$threadOpen"}]] ;; Notification toast container [:div {:id "toast-container" @@ -190,13 +193,13 @@ ;; Search modal (hidden by default) [:div {:id "search-modal" :class "fixed inset-0 z-40 hidden" - :data-class-hidden "!$searchOpen"} + "data-class:hidden" "!$searchOpen"} (c/search-modal)] ;; Emoji picker (hidden by default) [:div {:id "emoji-picker" :class "fixed z-30 hidden" - :data-class-hidden "!$emojiOpen"} + "data-class:hidden" "!$emojiOpen"} (c/emoji-picker)]))) ;;; --------------------------------------------------------------------------- @@ -212,14 +215,14 @@ [:div {:class "w-full max-w-md p-8 bg-mantle rounded-xl border border-surface1"} [:h1 {:class "text-2xl font-bold text-text mb-2 text-center"} "Welcome to ajet chat"] [:p {:class "text-subtext0 text-center mb-8"} "Create your first community to get started."] - [:form {:data-on-submit "@post('/web/communities')"} + [:form {"data-on:submit" "@post('/web/communities')"} [:div {:class "mb-4"} [:label {:class "block text-sm font-medium text-subtext1 mb-1" :for "community-name"} "Community Name"] [:input {:type "text" :id "community-name" :name "name" :data-bind "communityName" - :data-signals-community-name "" + "data-signals:communityName" "" :required true :placeholder "My Team" :class "w-full px-3 py-2 bg-surface0 border border-surface1 rounded-lg text-text @@ -230,7 +233,7 @@ :id "community-slug" :name "slug" :data-bind "communitySlug" - :data-signals-community-slug "" + "data-signals:communitySlug" "" :required true :pattern "[a-z0-9][a-z0-9-]*[a-z0-9]" :placeholder "my-team" diff --git a/web-sm/src/ajet/chat/web/routes.clj b/web-sm/src/ajet/chat/web/routes.clj index 79f51b4..9bdc79d 100644 --- a/web-sm/src/ajet/chat/web/routes.clj +++ b/web-sm/src/ajet/chat/web/routes.clj @@ -6,6 +6,7 @@ 2. SSE endpoint (GET /sse/events) -- Datastar SSE stream 3. Signal handlers (POST /web/*) -- browser actions proxied to API" (:require [clojure.tools.logging :as log] + [clojure.data.json :as json] [reitit.ring :as ring] [ring.middleware.params :refer [wrap-params]] [ring.middleware.multipart-params :refer [wrap-multipart-params]] @@ -26,6 +27,27 @@ (fn [request] (handler (assoc request :system system)))) +(defn wrap-json-body + "Parse JSON request bodies and merge into :params. + Datastar v1 sends signals as application/json POST bodies." + [handler] + (fn [request] + (if (and (some-> (get-in request [:headers "content-type"]) + (.contains "application/json")) + (:body request)) + (try + (let [body-str (if (string? (:body request)) + (:body request) + (slurp (:body request))) + parsed (when-not (clojure.string/blank? body-str) + (json/read-str body-str :key-fn keyword))] + (handler (-> request + (assoc :body-params (or parsed {})) + (update :params merge (or parsed {}))))) + (catch Exception _ + (handler request))) + (handler request)))) + (defn wrap-user-context "Extract Auth GW headers into request keys for convenience." [handler] @@ -103,7 +125,7 @@ :community community :channels channels :channel channel - :messages (reverse (or messages [])) + :messages (or messages []) :categories categories :unread-count (:count unread 0)})})) @@ -128,7 +150,7 @@ :community community :channels channels :channel channel - :messages (reverse (or messages [])) + :messages (or messages []) :categories categories :unread-count (:count unread 0)})})) @@ -150,7 +172,7 @@ :community nil :channels [] :channel active-dm - :messages (reverse (or messages [])) + :messages (or messages []) :categories [] :unread-count (:count unread 0) :dm-view? true @@ -182,6 +204,7 @@ ;; Signal handlers (POST actions from browser) ["/web" ["/messages" {:post {:handler handlers/send-message-handler}}] + ["/messages/older" {:post {:handler handlers/load-older-handler}}] ["/messages/:id/edit" {:post {:handler handlers/edit-message-handler}}] ["/messages/:id/delete" {:post {:handler handlers/delete-message-handler}}] ["/reactions" {:post {:handler handlers/add-reaction-handler}}] @@ -206,6 +229,7 @@ :headers {"Content-Type" "text/html; charset=utf-8"} :body "

404 Not Found

"})}) {:middleware [[wrap-system system] + wrap-json-body wrap-params wrap-keyword-params wrap-multipart-params diff --git a/web-sm/src/ajet/chat/web/sse.clj b/web-sm/src/ajet/chat/web/sse.clj index bef7d5c..8fd5e5b 100644 --- a/web-sm/src/ajet/chat/web/sse.clj +++ b/web-sm/src/ajet/chat/web/sse.clj @@ -14,6 +14,7 @@ :last-seen }}" (:require [org.httpkit.server :as hk] [clojure.tools.logging :as log] + [clojure.string :as str] [hiccup2.core :as h] [ajet.chat.shared.api-client :as api] [ajet.chat.shared.eventbus :as eventbus] @@ -34,7 +35,7 @@ Datastar SSE format: event: datastar-patch-elements data: selector #target-id - data: mode morph + data: mode outer data: elements
...
" [event-type data-lines] (let [event-name (case event-type @@ -64,8 +65,8 @@ ([ch hiccup-fragment] (send-patch! ch hiccup-fragment {})) ([ch hiccup-fragment {:keys [selector mode] - :or {mode "morph"}}] - (let [html-str (str (h/html hiccup-fragment)) + :or {mode "outer"}}] + (let [html-str (str/replace (str (h/html hiccup-fragment)) "\n" " ") lines (cond-> [] selector (conj [:selector selector]) true (conj [:mode mode]) @@ -75,7 +76,7 @@ (defn- send-append! "Send a Datastar patch-elements event in append mode." [ch selector hiccup-fragment] - (let [html-str (str (h/html hiccup-fragment))] + (let [html-str (str/replace (str (h/html hiccup-fragment)) "\n" " ")] (send-sse! ch (sse-event :patch-elements [[:selector selector] [:mode "append"] @@ -142,7 +143,7 @@ (for [r reactions] [:button {:key (str (:emoji r)) :class "flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-surface0 border border-surface1 hover:border-blue transition-colors" - :data-on-click (str "@post('/web/reactions', {headers: {'X-Message-Id': '" msg-id "', 'X-Emoji': '" (:emoji r) "'}})")} + "data-on:click" (str "@post('/web/reactions', {headers: {'X-Message-Id': '" msg-id "', 'X-Emoji': '" (:emoji r) "'}})")} [:span (:emoji r)] [:span {:class "text-subtext0"} (count (:users r))]])] {:selector (str "#reactions-" msg-id)}) @@ -189,7 +190,7 @@ (send-append! ch "#channel-list" [:div {:id (str "sidebar-channel-" cid) :class "flex items-center px-2 py-1 rounded cursor-pointer text-sm text-overlay1 hover:text-subtext1 hover:bg-hover" - :data-on-click (str "@post('/web/navigate', {headers: {'X-Channel-Id': '" cid "'}})")} + "data-on:click" (str "$navChannel = '" cid "'; $navCommunity = ''; $navTarget = ''; @post('/web/navigate')")} [:span {:class "mr-1.5 text-overlay0 text-xs"} prefix] [:span {:class "truncate"} (:name channel)] [:span {:id (str "unread-badge-" cid) @@ -464,7 +465,7 @@ (defn send-fragment-to-user! "Send a Hiccup fragment to a specific user." - [connections user-id hiccup-fragment & [{:keys [selector mode] :or {mode "morph"}}]] + [connections user-id hiccup-fragment & [{:keys [selector mode] :or {mode "outer"}}]] (when-let [conn-state (get @connections user-id)] (when-let [ch (:sse-channel conn-state)] (send-patch! ch hiccup-fragment {:selector selector :mode mode}))))