This commit is contained in:
2026-02-17 18:54:08 -05:00
parent f7e2755a91
commit 7fd8d7c4eb
17 changed files with 293 additions and 874 deletions
+39 -1
View File
@@ -1,6 +1,44 @@
# Clojure / Java
.cpcache/ .cpcache/
.nrepl-port
target/ target/
*.db *.db
*.jar
classes/
.nrepl-port
.rebel_readline_history
# Clojure tooling
.clj-kondo/.cache/ .clj-kondo/.cache/
.lsp/ .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
-22
View File
@@ -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
+29 -23
View File
@@ -42,9 +42,10 @@
(defn- get-message-row [ds message-id] (defn- get-message-row [ds message-id]
(or (db/execute-one! ds (or (db/execute-one! ds
{:select [:*] {:select [:m.* :u.username :u.display-name :u.avatar-url]
:from [:messages] :from [[:messages :m]]
:where [:= :id [:cast message-id :uuid]]}) :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})))) (throw (ex-info "Message not found" {:type :ajet.chat/not-found}))))
(defn- nats-subject-for-channel (defn- nats-subject-for-channel
@@ -140,19 +141,19 @@
(check-channel-member! ds channel-id user-id) (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 ;; Cursor-based pagination using subquery on created_at
where-clause (cond where-clause (cond
before before
[:and base-where [:and base-where
[:< :created-at [:< :m.created-at
{:select [:created-at] {:select [:created-at]
:from [:messages] :from [:messages]
:where [:= :id [:cast before :uuid]]}]] :where [:= :id [:cast before :uuid]]}]]
after after
[:and base-where [:and base-where
[:> :created-at [:> :m.created-at
{:select [:created-at] {:select [:created-at]
:from [:messages] :from [:messages]
:where [:= :id [:cast after :uuid]]}]] :where [:= :id [:cast after :uuid]]}]]
@@ -160,10 +161,11 @@
:else base-where) :else base-where)
order-dir (if after :asc :desc) order-dir (if after :asc :desc)
messages (db/execute! ds messages (db/execute! ds
{:select [:*] {:select [:m.* :u.username :u.display-name :u.avatar-url]
:from [:messages] :from [[:messages :m]]
:join [[:users :u] [:= :m.user-id :u.id]]
:where where-clause :where where-clause
:order-by [[:created-at order-dir]] :order-by [[:m.created-at order-dir]]
:limit limit}) :limit limit})
;; Always return newest-last ordering ;; Always return newest-last ordering
messages (if (= order-dir :desc) messages (if (= order-dir :desc)
@@ -213,19 +215,22 @@
(when resolved-parent-id (when resolved-parent-id
(create-thread-notifications! tx message-id resolved-parent-id user-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) (let [channel (get-channel-row ds channel-id)
subject (nats-subject-for-channel channel) 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 (publish-event! nats subject :message/created
{:message-id message-id (merge message
:channel-id channel-id {:username (:username user)
:user-id user-id :display-name (:display-name user)
:body-md body-md :avatar-url (:avatar-url user)
:parent-id resolved-parent-id :community-id (when (:community-id channel)
:community-id (when (:community-id channel) (str (:community-id channel)))}))
(str (:community-id channel)))})
(mw/json-response 201 message))))) (mw/json-response 201 message)))))
@@ -360,12 +365,13 @@
(check-channel-member! ds (str (:channel-id message)) user-id) (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 (let [replies (db/execute! ds
{:select [:*] {:select [:m.* :u.username :u.display-name :u.avatar-url]
:from [:messages] :from [[:messages :m]]
:where [:= :parent-id [:cast message-id :uuid]] :join [[:users :u] [:= :m.user-id :u.id]]
:order-by [[:created-at :asc]]})] :where [:= :m.parent-id [:cast message-id :uuid]]
:order-by [[:m.created-at :asc]]})]
(mw/json-response {:root message (mw/json-response {:root message
:replies replies})))) :replies replies}))))
-89
View File
@@ -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 <token>` 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
-71
View File
@@ -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 <command> [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
+1 -1
View File
@@ -142,7 +142,7 @@ chat.notifications.{user-id} — per-user notification delivery
| AC-7 | Community CRUD (create, update, list, get) | P0 | | AC-7 | Community CRUD (create, update, list, get) | P0 |
| AC-8 | Channel category CRUD (create, reorder, assign channels) | P1 | | AC-8 | Channel category CRUD (create, reorder, assign channels) | P1 |
| AC-9 | Webhook management (create, delete, list) | P1 | | AC-9 | Webhook management (create, delete, list) | P1 |
| AC-10 | Cursor-based pagination support (`?after=<uuid>&limit=N`) | P0 | | AC-10 | Bidirectional cursor-based pagination (`?before=<uuid>&after=<uuid>&limit=N`) | P0 |
| AC-11 | Retry with exponential backoff on 5xx/timeout | P1 | | AC-11 | Retry with exponential backoff on 5xx/timeout | P1 |
| AC-12 | Request timeout (configurable, default 10s) | P0 | | AC-12 | Request timeout (configurable, default 10s) | P0 |
| AC-13 | Structured error responses (`ex-info` with status + body) | P0 | | AC-13 | Structured error responses (`ex-info` with status + body) | P0 |
+15 -7
View File
@@ -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. | | 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. | | Sidebar | `#sidebar` | Channel list with collapsible categories. DM section below separator. Search at bottom. |
| Channel Header | `#channel-header` | Channel name, topic, settings icon. | | 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. | | 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. | | 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. | | 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) - 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 user mention autocomplete (search by username/display name)
- `#` triggers channel autocomplete - `#` triggers channel autocomplete
- `/` at start triggers slash command autocomplete - `/` at start triggers slash command autocomplete
@@ -269,13 +269,15 @@ All user actions are HTTP POSTs (Datastar form submissions):
| Action | Endpoint | Payload | | 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}` | | Edit message | `POST /web/messages/:id/edit` | `{body_md}` |
| Delete message | `POST /web/messages/:id/delete` | — | | Delete message | `POST /web/messages/:id/delete` | — |
| Add reaction | `POST /web/reactions` | `{message_id, emoji}` | | Add reaction | `POST /web/reactions` | `{message_id, emoji}` |
| Remove reaction | `POST /web/reactions/remove` | `{message_id, emoji}` | | Remove reaction | `POST /web/reactions/remove` | `{message_id, emoji}` |
| Switch channel | `POST /web/navigate` | `{channel_id}` | | Switch channel | `POST /web/navigate` | Signals: `navChannel` |
| Switch community | `POST /web/navigate` | `{community_id}` | | 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}` | | Mark channel read | `POST /web/read` | `{channel_id, message_id}` |
| Upload image | `POST /web/upload` | Multipart (image file) | | Upload image | `POST /web/upload` | Multipart (image file) |
| Typing indicator | `POST /web/typing` | `{channel_id}` | | 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. 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) ## 6. Pages & Routes (Server-Rendered)
| Route | Description | | Route | Description |
@@ -524,7 +532,7 @@ New notifications (beyond badge counts) show as brief toast popups:
| ID | Test | Description | | 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-T25 | Shift+Enter newline | Shift+Enter adds newline, does not send |
| WEB-T26 | @mention autocomplete | Typing @ shows user dropdown, selecting inserts mention | | WEB-T26 | @mention autocomplete | Typing @ shows user dropdown, selecting inserts mention |
| WEB-T27 | #channel autocomplete | Typing # shows channel dropdown | | 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-T41 | Leave channel | Right-click → leave → channel removed from sidebar |
| WEB-T42 | Collapse category | Click category header → channels hidden → click again → shown | | 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-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 | | WEB-T45 | Create community | Click + on community strip → wizard → community created |
### 11.4 Profile & Settings ### 11.4 Profile & Settings
-13
View File
@@ -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
-489
View File
@@ -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/<webhook-id>/<token>`
- 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 <name>`, `/webhook list`, `/webhook revoke <id>`
- **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 <name> [scopes]`, `/token revoke <id>`,
`/role @user admin|member`, `/purge #channel [count]`
- `/help` lists available commands (filtered by user's role)
- `/help <command>` 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/<slug>/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=<uuid>&limit=N`
- Backward: `?before=<uuid>&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: `<community-id>/<channel-id>/<message-id>/<filename>`
- 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/<slug>/...` 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
+2 -1
View File
@@ -169,10 +169,11 @@
;;; --------------------------------------------------------------------------- ;;; ---------------------------------------------------------------------------
(defn get-messages (defn get-messages
"Fetch messages for a channel. opts: :before :limit" "Fetch messages for a channel. opts: :before :after :limit"
[ctx channel-id & [opts]] [ctx channel-id & [opts]]
(let [qp (cond-> {} (let [qp (cond-> {}
(:before opts) (assoc "before" (str (:before opts))) (:before opts) (assoc "before" (str (:before opts)))
(:after opts) (assoc "after" (str (:after opts)))
(:limit opts) (assoc "limit" (str (:limit opts))))] (:limit opts) (assoc "limit" (str (:limit opts))))]
(request! ctx :get (str "/api/channels/" channel-id "/messages") (request! ctx :get (str "/api/channels/" channel-id "/messages")
(when (seq qp) {:query-params qp})))) (when (seq qp) {:query-params qp}))))
-28
View File
@@ -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
-29
View File
@@ -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
+41 -39
View File
@@ -57,9 +57,8 @@
[{:keys [communities active-id unread-count]}] [{:keys [communities active-id unread-count]}]
(list (list
;; Home / DMs button ;; Home / DMs button
[:div {:class "w-12 h-12 rounded-2xl bg-surface0 flex items-center justify-center [: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"
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')"}
:data-on-click "@post('/web/navigate', {headers: {'X-Target': 'dms'}})"}
[:svg {:class "w-6 h-6 text-text" :viewBox "0 0 24 24" :fill "currentColor"} [: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"}]] [: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)) (when (and unread-count (pos? unread-count))
@@ -81,14 +80,13 @@
"rounded-xl bg-blue text-base" "rounded-xl bg-blue text-base"
"bg-surface0 text-subtext0 hover:rounded-xl hover:bg-blue hover:text-base")) "bg-surface0 text-subtext0 hover:rounded-xl hover:bg-blue hover:text-base"))
:title (:name comm) :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)])) (community-initial comm)]))
;; Add community button ;; Add community button
[:div {:class "w-12 h-12 rounded-2xl bg-surface0 flex items-center justify-center [: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"
cursor-pointer hover:rounded-xl hover:bg-green transition-all duration-200 mt-2"
:title "Create Community" :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"} "+"]])) [:span {:class "text-green text-2xl font-light"} "+"]]))
;;; --------------------------------------------------------------------------- ;;; ---------------------------------------------------------------------------
@@ -191,7 +189,7 @@
(if active (if active
"bg-surface0 text-text" "bg-surface0 text-text"
"text-overlay1 hover:text-subtext1 hover:bg-hover")) "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 "mr-1.5 text-overlay0 text-xs"} prefix]
[:span {:class "truncate"} (:name ch)] [:span {:class "truncate"} (:name ch)]
;; Unread badge placeholder ;; Unread badge placeholder
@@ -210,7 +208,7 @@
(if active (if active
"bg-surface0 text-text" "bg-surface0 text-text"
"text-overlay1 hover:text-subtext1 hover:bg-hover")) "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" [: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))} :style (str "background-color: " (avatar-color dm-id))}
(user-initials name)] (user-initials name)]
@@ -237,7 +235,7 @@
;; Search button ;; Search button
[:button {:class "text-overlay0 hover:text-text" [:button {:class "text-overlay0 hover:text-text"
:title "Search" :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"} [: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"}] [:circle {:cx "11" :cy "11" :r "8"}]
[:line {:x1 "21" :y1 "21" :x2 "16.65" :y2 "16.65"}]]] [:line {:x1 "21" :y1 "21" :x2 "16.65" :y2 "16.65"}]]]
@@ -311,14 +309,14 @@
(for [r reactions] (for [r reactions]
[:button {:key (str (:emoji r) "-" (count (:users r))) [: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" :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 (:emoji r)]
[:span {:class "text-subtext0"} (count (:users r))]])]) [:span {:class "text-subtext0"} (count (:users r))]])])
;; Thread indicator ;; Thread indicator
(when (and thread-count (pos? thread-count)) (when (and thread-count (pos? thread-count))
[:button {:class "flex items-center gap-1 text-xs text-blue hover:underline mt-1" [: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"))]])] [:span (str thread-count " " (if (= 1 thread-count) "reply" "replies"))]])]
;; Hover action toolbar ;; Hover action toolbar
@@ -326,36 +324,38 @@
;; Add reaction ;; Add reaction
[:button {:class "p-1.5 hover:bg-hover rounded-l text-overlay0 hover:text-text" [:button {:class "p-1.5 hover:bg-hover rounded-l text-overlay0 hover:text-text"
:title "Add Reaction" :title "Add Reaction"
:data-on-click (str "$emojiOpen = !$emojiOpen; $threadMessageId = '" msg-id "'")} "data-on:click" (str "$emojiOpen = !$emojiOpen; $threadMessageId = '" msg-id "'")}
"\uD83D\uDE00"] "\uD83D\uDE00"]
;; Reply in thread ;; Reply in thread
[:button {:class "p-1.5 hover:bg-hover text-overlay0 hover:text-text" [:button {:class "p-1.5 hover:bg-hover text-overlay0 hover:text-text"
:title "Reply in Thread" :title "Reply in Thread"
:data-on-click (str "$threadOpen = true; $threadMessageId = '" msg-id "'")} "data-on:click" (str "$threadOpen = true; $threadMessageId = '" msg-id "'")}
"\uD83D\uDCAC"] "\uD83D\uDCAC"]
;; Edit (own messages only) ;; Edit (own messages only)
(when is-own (when is-own
[:button {:class "p-1.5 hover:bg-hover text-overlay0 hover:text-text" [:button {:class "p-1.5 hover:bg-hover text-overlay0 hover:text-text"
:title "Edit" :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();")} "el.contentEditable = 'true'; el.focus();")}
"\u270F\uFE0F"]) "\u270F\uFE0F"])
;; Delete (own messages only) ;; Delete (own messages only)
(when is-own (when is-own
[:button {:class "p-1.5 hover:bg-hover rounded-r text-overlay0 hover:text-red" [:button {:class "p-1.5 hover:bg-hover rounded-r text-overlay0 hover:text-red"
:title "Delete" :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"])]])) "\uD83D\uDDD1\uFE0F"])]]))
(defn message-list (defn message-list
"Scrollable list of messages with a 'Load older' trigger at top." "Scrollable list of messages with a 'Load older' trigger at top."
[messages current-user] [messages current-user]
(list (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"} [: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" (if (>= (count messages) 50)
:data-on-click "@post('/web/messages', {headers: {'X-Load-Older': 'true'}})"} [:button {:class "text-xs text-overlay0 hover:text-subtext0 px-3 py-1 rounded bg-surface0 hover:bg-hover transition-colors"
"Load older messages"]] "data-on:click" "@post('/web/messages/older')"}
"Load older messages"]
[:span {:class "text-xs text-overlay0"} "Beginning of conversation"])]
;; Messages ;; Messages
[:div {:id "messages-container"} [:div {:id "messages-container"}
@@ -379,7 +379,7 @@
;; Upload button ;; Upload button
[:button {:class "p-3 text-overlay0 hover:text-text flex-shrink-0" [:button {:class "p-3 text-overlay0 hover:text-text flex-shrink-0"
:title "Upload Image" :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"} [: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"}]]] [: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 ;; Hidden file input
@@ -387,25 +387,27 @@
:id "file-upload-input" :id "file-upload-input"
:class "hidden" :class "hidden"
:accept "image/jpeg,image/png,image/gif,image/webp" :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
[:textarea {:id "message-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" :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) :placeholder (str "Message #" ch-name)
:rows "1" :rows "1"
:data-bind "messageText" :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();" " evt.preventDefault();"
" if($messageText.trim()) {" " 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 ;; Send button
[:button {:class "p-3 text-blue hover:text-text flex-shrink-0" [:button {:class "p-3 text-blue hover:text-text flex-shrink-0"
:title "Send" :title "Send"
:data-on-click (str "if($messageText.trim()) {" "data-on:click" (str "if($messageText.trim()) {"
" @post('/web/messages', {headers: {'X-Channel-Id': '" (or channel-id "") "'}});" " @post('/web/messages');"
" $messageText = '';"
"}")} "}")}
[:svg {:class "w-5 h-5" :viewBox "0 0 24 24" :fill "currentColor"} [: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"}]]]]])) [: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"} [: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"] [:span {:class "font-semibold text-text text-sm"} "Thread"]
[:button {:class "ml-auto text-overlay0 hover:text-text" [: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"} [: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 "18" :y1 "6" :x2 "6" :y2 "18"}]
[:line {:x1 "6" :y1 "6" :x2 "18" :y2 "18"}]]]] [:line {:x1 "6" :y1 "6" :x2 "18" :y2 "18"}]]]]
@@ -444,15 +446,15 @@
:placeholder "Reply in thread..." :placeholder "Reply in thread..."
:rows "1" :rows "1"
:data-bind "threadReply" :data-bind "threadReply"
:data-signals-thread-reply "" "data-signals:threadReply" ""
:data-on-keydown (str "if(evt.key === 'Enter' && !evt.shiftKey) {" "data-on:keydown" (str "if(evt.key === 'Enter' && !evt.shiftKey) {"
" evt.preventDefault();" " evt.preventDefault();"
" if($threadReply.trim()) {" " if($threadReply.trim()) {"
" @post('/web/messages', {headers: {'X-Parent-Id': $threadMessageId}});" " @post('/web/messages', {headers: {'X-Parent-Id': $threadMessageId}});"
" }" " }"
"}")}] "}")}]
[:button {:class "p-2 text-blue hover:text-text flex-shrink-0" [: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}});" " @post('/web/messages', {headers: {'X-Parent-Id': $threadMessageId}});"
"}")} "}")}
[:svg {:class "w-4 h-4" :viewBox "0 0 24 24" :fill "currentColor"} [: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))) [: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]" :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) :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 items-start gap-2"}
[:div {:class "flex-1 min-w-0"} [:div {:class "flex-1 min-w-0"}
[:div {:class "text-sm font-medium text-text truncate"} (or title "Notification")] [:div {:class "text-sm font-medium text-text truncate"} (or title "Notification")]
[:div {:class "text-xs text-subtext0 mt-0.5 truncate"} (or body "")]] [:div {:class "text-xs text-subtext0 mt-0.5 truncate"} (or body "")]]
[:button {:class "text-overlay0 hover:text-text flex-shrink-0 text-sm" [: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"]]])) "\u2715"]]]))
;;; --------------------------------------------------------------------------- ;;; ---------------------------------------------------------------------------
@@ -504,11 +506,11 @@
(for [emoji emoji-grid] (for [emoji emoji-grid]
[:button {:key emoji [:button {:key emoji
:class "emoji-btn text-xl w-10 h-10 flex items-center justify-center rounded hover:bg-hover" :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")} " $emojiOpen = false")}
emoji])] emoji])]
[:button {:class "mt-2 w-full text-xs text-overlay0 hover:text-subtext0 py-1" [: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"]]) "Close"]])
;;; --------------------------------------------------------------------------- ;;; ---------------------------------------------------------------------------
@@ -521,7 +523,7 @@
[:div {:class "flex items-start justify-center pt-20"} [:div {:class "flex items-start justify-center pt-20"}
;; Backdrop ;; Backdrop
[:div {:class "absolute inset-0 bg-black bg-opacity-50" [:div {:class "absolute inset-0 bg-black bg-opacity-50"
:data-on-click "$searchOpen = false"}] "data-on:click" "$searchOpen = false"}]
;; Modal ;; Modal
[:div {:class "relative bg-mantle border border-surface1 rounded-xl shadow-2xl w-full max-w-2xl z-10"} [:div {:class "relative bg-mantle border border-surface1 rounded-xl shadow-2xl w-full max-w-2xl z-10"}
;; Search input ;; Search input
@@ -533,10 +535,10 @@
:class "flex-1 bg-transparent text-text placeholder-overlay0 py-3 outline-none text-sm" :class "flex-1 bg-transparent text-text placeholder-overlay0 py-3 outline-none text-sm"
:placeholder "Search messages..." :placeholder "Search messages..."
:data-bind "searchQuery" :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}] :autofocus true}]
[:button {:class "text-overlay0 hover:text-text ml-2" [: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"]]] [:span {:class "text-xs border border-surface1 rounded px-1.5 py-0.5"} "ESC"]]]
;; Search results container ;; Search results container
[:div {:id "search-results" :class "max-h-96 overflow-y-auto p-4"} [: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)))] ts (format-timestamp (or (:created-at msg) (:created_at msg)))]
[:div {:key msg-id [:div {:key msg-id
:class "flex items-start gap-3 p-2 rounded hover:bg-hover cursor-pointer" :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'})")} " 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" [: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)))} :style (str "background-color: " (avatar-color (:user-id msg)))}
+117 -40
View File
@@ -39,21 +39,44 @@
[request header-name] [request header-name]
(get-in request [:headers (str/lower-case header-name)])) (get-in request [:headers (str/lower-case header-name)]))
(defn- datastar-fragment-response (defn- fragment-event
"Return an SSE response with a Datastar patch-elements event. "Build a single datastar-patch-elements SSE event string for one fragment.
Used for POST handlers that need to return a UI update." Newlines in the rendered HTML are replaced with spaces to avoid breaking
[hiccup-fragment & [{:keys [selector mode] :or {mode "morph"}}]] SSE data lines (each data: line must be a single line in the SSE protocol)."
(let [html-str (str (h/html hiccup-fragment)) [hiccup-fragment & [{:keys [selector mode] :or {mode "outer"}}]]
(let [html-str (str/replace (str (h/html hiccup-fragment)) "\n" " ")
lines (cond-> [] lines (cond-> []
selector (conj (str "selector " selector)) selector (conj (str "selector " selector))
true (conj (str "mode " mode)) true (conj (str "mode " mode))
true (conj (str "elements " html-str)))] true (conj (str "elements " html-str)))]
{:status 200 (str "event: datastar-patch-elements\n"
:headers {"Content-Type" "text/event-stream" (apply str (map #(str "data: " % "\n") lines))
"Cache-Control" "no-cache, no-store, must-revalidate"} "\n")))
:body (str "event: datastar-patch-elements\n"
(apply str (map #(str "data: " % "\n") lines)) (defn- datastar-fragment-response
"\n")})) "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 (defn- datastar-signals-response
"Return an SSE response with a Datastar patch-signals event." "Return an SSE response with a Datastar patch-signals event."
@@ -140,6 +163,47 @@
(api/delete-message ctx message-id)) (api/delete-message ctx message-id))
(empty-sse-response))) (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 ;;; Reaction Handlers
;;; --------------------------------------------------------------------------- ;;; ---------------------------------------------------------------------------
@@ -175,46 +239,53 @@
(defn navigate-handler (defn navigate-handler
"POST /web/navigate -- switch community or channel. "POST /web/navigate -- switch community or channel.
Updates connection tracking and re-subscribes NATS. 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] [request]
(let [ctx (build-api-ctx request) (let [ctx (build-api-ctx request)
user-id (:user-id request) user-id (:user-id request)
connections (get-in request [:system :connections]) connections (get-in request [:system :connections])
nats (get-in request [:system :nats]) nats (get-in request [:system :nats])
target (get-header request "x-target") ;; Read navigation intent from dedicated nav signals (set right before @post)
community-id (or (get-header request "x-community-id") ;; These are distinct from activeCommunity/activeChannel to avoid stale state
(get-signal request "activeCommunity")) target (let [v (get-signal request "navTarget")] (when-not (str/blank? v) v))
channel-id (or (get-header request "x-channel-id") community-id (let [v (get-signal request "navCommunity")] (when-not (str/blank? v) v))
(get-signal request "activeChannel"))] channel-id (let [v (get-signal request "navChannel")] (when-not (str/blank? v) v))]
(cond (cond
;; Navigate to DMs view ;; Navigate to DMs view
(= target "dms") (= target "dms")
(let [dms (api/get-dms ctx) (let [communities (api/get-communities ctx)
dms (api/get-dms ctx)
dm (first dms) dm (first dms)
dm-id (when dm (str (:id dm))) dm-id (when dm (str (:id dm)))
messages (when dm-id (api/get-messages ctx dm-id {:limit 50})) 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) ;; Re-subscribe NATS (no community context for DMs)
(when user-id (when user-id
(sse/resubscribe! connections nats user-id nil dm-id)) (sse/resubscribe! connections nats user-id nil dm-id))
;; Return updated UI fragments ;; Return updated UI fragments — each matches an existing DOM element
(datastar-fragment-response (datastar-multi-fragment-response
[:div {:id "app-content"} [[:div {:id "community-strip"
;; Update sidebar with DM list :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" [:div {:id "sidebar"
:class "w-60 flex-shrink-0 bg-mantle flex flex-col border-r border-surface1"} :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})] (c/dm-sidebar {:dms dms :active-dm dm :user user})]
;; Update channel header
[:div {:id "channel-header"} [:div {:id "channel-header"}
(c/channel-header (or dm {:name "Direct Messages"}))] (c/channel-header (or dm {:name "Direct Messages"}))]
;; Update messages
[:div {:id "message-list" [:div {:id "message-list"
:class "flex-1 overflow-y-auto px-4 py-2"} :class "flex-1 overflow-y-auto px-4 py-2"}
(c/message-list (reverse (or messages [])) user)] (c/message-list (or messages []) user)]
;; Update input
[:div {:id "message-input-area" :class "px-4 pb-4"} [: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 ;; Navigate to a specific channel
channel-id channel-id
@@ -224,14 +295,14 @@
channels (when cid (api/get-channels ctx cid)) channels (when cid (api/get-channels ctx cid))
messages (api/get-messages ctx channel-id {:limit 50}) messages (api/get-messages ctx channel-id {:limit 50})
categories (when cid (api/get-categories ctx cid)) 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 ;; Re-subscribe NATS
(when user-id (when user-id
(sse/resubscribe! connections nats user-id cid channel-id)) (sse/resubscribe! connections nats user-id cid channel-id))
;; Return updated UI fragments ;; Return updated UI fragments
(datastar-fragment-response (datastar-multi-fragment-response
[:div {:id "app-content"} [[:div {:id "sidebar"
[:div {:id "sidebar"
:class "w-60 flex-shrink-0 bg-mantle flex flex-col border-r border-surface1"} :class "w-60 flex-shrink-0 bg-mantle flex flex-col border-r border-surface1"}
(c/sidebar {:community community (c/sidebar {:community community
:channels channels :channels channels
@@ -242,9 +313,12 @@
(c/channel-header channel)] (c/channel-header channel)]
[:div {:id "message-list" [:div {:id "message-list"
:class "flex-1 overflow-y-auto px-4 py-2"} :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"} [: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) ;; Navigate to a community (pick first channel)
community-id community-id
@@ -254,14 +328,14 @@
ch-id (when channel (str (:id channel))) ch-id (when channel (str (:id channel)))
messages (when ch-id (api/get-messages ctx ch-id {:limit 50})) messages (when ch-id (api/get-messages ctx ch-id {:limit 50}))
categories (api/get-categories ctx community-id) 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 ;; Re-subscribe NATS
(when user-id (when user-id
(sse/resubscribe! connections nats user-id community-id ch-id)) (sse/resubscribe! connections nats user-id community-id ch-id))
;; Return updated UI ;; Return updated UI
(datastar-fragment-response (datastar-multi-fragment-response
[:div {:id "app-content"} [[:div {:id "community-strip"
[:div {:id "community-strip"
:class "w-[72px] flex-shrink-0 bg-mantle flex flex-col items-center py-3 gap-2 overflow-y-auto"} :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) (c/community-strip {:communities (api/get-communities ctx)
:active-id community-id :active-id community-id
@@ -277,9 +351,12 @@
(c/channel-header channel)] (c/channel-header channel)]
[:div {:id "message-list" [:div {:id "message-list"
:class "flex-1 overflow-y-auto px-4 py-2"} :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"} [: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 :else
(empty-sse-response)))) (empty-sse-response))))
+14 -11
View File
@@ -120,7 +120,8 @@
[{:keys [user communities community channels channel messages categories unread-count [{:keys [user communities community channels channel messages categories unread-count
dm-view? dms]}] dm-view? dms]}]
(let [community-id (when community (str (:id community))) (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 (base-page
{:title (str (when channel (str "#" (:name channel) " - ")) {:title (str (when channel (str "#" (:name channel) " - "))
(when community (:name community)) (when community (:name community))
@@ -136,7 +137,11 @@
" threadOpen: false," " threadOpen: false,"
" threadMessageId: ''," " threadMessageId: '',"
" commandText: ''," " 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 ;; Auto-connect to SSE on page load
:data-init (str "@get('/sse/events', {openWhenHidden: true})")}] :data-init (str "@get('/sse/events', {openWhenHidden: true})")}]
;; Main 4-pane layout ;; Main 4-pane layout
@@ -168,9 +173,7 @@
;; Messages ;; Messages
[:div {:id "message-list" [:div {:id "message-list"
:class "flex-1 overflow-y-auto px-4 py-2" :class "flex-1 overflow-y-auto px-4 py-2"
:data-on-scroll (str "if(evt.target.scrollTop === 0) {" "data-on:scroll" "if(evt.target.scrollTop === 0) { @post('/web/messages/older') }"}
" @post('/web/messages', {headers: {'X-Load-Older': 'true'}})"
"}")}
(c/message-list messages user)] (c/message-list messages user)]
;; Message input ;; Message input
@@ -181,7 +184,7 @@
;; Pane 4: Thread panel (hidden by default) ;; Pane 4: Thread panel (hidden by default)
[:div {:id "thread-panel" [:div {:id "thread-panel"
:class "w-96 flex-shrink-0 bg-mantle border-l border-surface1 flex-col hidden" :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 ;; Notification toast container
[:div {:id "toast-container" [:div {:id "toast-container"
@@ -190,13 +193,13 @@
;; Search modal (hidden by default) ;; Search modal (hidden by default)
[:div {:id "search-modal" [:div {:id "search-modal"
:class "fixed inset-0 z-40 hidden" :class "fixed inset-0 z-40 hidden"
:data-class-hidden "!$searchOpen"} "data-class:hidden" "!$searchOpen"}
(c/search-modal)] (c/search-modal)]
;; Emoji picker (hidden by default) ;; Emoji picker (hidden by default)
[:div {:id "emoji-picker" [:div {:id "emoji-picker"
:class "fixed z-30 hidden" :class "fixed z-30 hidden"
:data-class-hidden "!$emojiOpen"} "data-class:hidden" "!$emojiOpen"}
(c/emoji-picker)]))) (c/emoji-picker)])))
;;; --------------------------------------------------------------------------- ;;; ---------------------------------------------------------------------------
@@ -212,14 +215,14 @@
[:div {:class "w-full max-w-md p-8 bg-mantle rounded-xl border border-surface1"} [: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"] [: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."] [: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"} [:div {:class "mb-4"}
[:label {:class "block text-sm font-medium text-subtext1 mb-1" :for "community-name"} "Community Name"] [:label {:class "block text-sm font-medium text-subtext1 mb-1" :for "community-name"} "Community Name"]
[:input {:type "text" [:input {:type "text"
:id "community-name" :id "community-name"
:name "name" :name "name"
:data-bind "communityName" :data-bind "communityName"
:data-signals-community-name "" "data-signals:communityName" ""
:required true :required true
:placeholder "My Team" :placeholder "My Team"
:class "w-full px-3 py-2 bg-surface0 border border-surface1 rounded-lg text-text :class "w-full px-3 py-2 bg-surface0 border border-surface1 rounded-lg text-text
@@ -230,7 +233,7 @@
:id "community-slug" :id "community-slug"
:name "slug" :name "slug"
:data-bind "communitySlug" :data-bind "communitySlug"
:data-signals-community-slug "" "data-signals:communitySlug" ""
:required true :required true
:pattern "[a-z0-9][a-z0-9-]*[a-z0-9]" :pattern "[a-z0-9][a-z0-9-]*[a-z0-9]"
:placeholder "my-team" :placeholder "my-team"
+27 -3
View File
@@ -6,6 +6,7 @@
2. SSE endpoint (GET /sse/events) -- Datastar SSE stream 2. SSE endpoint (GET /sse/events) -- Datastar SSE stream
3. Signal handlers (POST /web/*) -- browser actions proxied to API" 3. Signal handlers (POST /web/*) -- browser actions proxied to API"
(:require [clojure.tools.logging :as log] (:require [clojure.tools.logging :as log]
[clojure.data.json :as json]
[reitit.ring :as ring] [reitit.ring :as ring]
[ring.middleware.params :refer [wrap-params]] [ring.middleware.params :refer [wrap-params]]
[ring.middleware.multipart-params :refer [wrap-multipart-params]] [ring.middleware.multipart-params :refer [wrap-multipart-params]]
@@ -26,6 +27,27 @@
(fn [request] (fn [request]
(handler (assoc request :system system)))) (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 (defn wrap-user-context
"Extract Auth GW headers into request keys for convenience." "Extract Auth GW headers into request keys for convenience."
[handler] [handler]
@@ -103,7 +125,7 @@
:community community :community community
:channels channels :channels channels
:channel channel :channel channel
:messages (reverse (or messages [])) :messages (or messages [])
:categories categories :categories categories
:unread-count (:count unread 0)})})) :unread-count (:count unread 0)})}))
@@ -128,7 +150,7 @@
:community community :community community
:channels channels :channels channels
:channel channel :channel channel
:messages (reverse (or messages [])) :messages (or messages [])
:categories categories :categories categories
:unread-count (:count unread 0)})})) :unread-count (:count unread 0)})}))
@@ -150,7 +172,7 @@
:community nil :community nil
:channels [] :channels []
:channel active-dm :channel active-dm
:messages (reverse (or messages [])) :messages (or messages [])
:categories [] :categories []
:unread-count (:count unread 0) :unread-count (:count unread 0)
:dm-view? true :dm-view? true
@@ -182,6 +204,7 @@
;; Signal handlers (POST actions from browser) ;; Signal handlers (POST actions from browser)
["/web" ["/web"
["/messages" {:post {:handler handlers/send-message-handler}}] ["/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/edit" {:post {:handler handlers/edit-message-handler}}]
["/messages/:id/delete" {:post {:handler handlers/delete-message-handler}}] ["/messages/:id/delete" {:post {:handler handlers/delete-message-handler}}]
["/reactions" {:post {:handler handlers/add-reaction-handler}}] ["/reactions" {:post {:handler handlers/add-reaction-handler}}]
@@ -206,6 +229,7 @@
:headers {"Content-Type" "text/html; charset=utf-8"} :headers {"Content-Type" "text/html; charset=utf-8"}
:body "<html><body><h1>404 Not Found</h1></body></html>"})}) :body "<html><body><h1>404 Not Found</h1></body></html>"})})
{:middleware [[wrap-system system] {:middleware [[wrap-system system]
wrap-json-body
wrap-params wrap-params
wrap-keyword-params wrap-keyword-params
wrap-multipart-params wrap-multipart-params
+8 -7
View File
@@ -14,6 +14,7 @@
:last-seen <Instant>}}" :last-seen <Instant>}}"
(:require [org.httpkit.server :as hk] (:require [org.httpkit.server :as hk]
[clojure.tools.logging :as log] [clojure.tools.logging :as log]
[clojure.string :as str]
[hiccup2.core :as h] [hiccup2.core :as h]
[ajet.chat.shared.api-client :as api] [ajet.chat.shared.api-client :as api]
[ajet.chat.shared.eventbus :as eventbus] [ajet.chat.shared.eventbus :as eventbus]
@@ -34,7 +35,7 @@
Datastar SSE format: Datastar SSE format:
event: datastar-patch-elements event: datastar-patch-elements
data: selector #target-id data: selector #target-id
data: mode morph data: mode outer
data: elements <div>...</div>" data: elements <div>...</div>"
[event-type data-lines] [event-type data-lines]
(let [event-name (case event-type (let [event-name (case event-type
@@ -64,8 +65,8 @@
([ch hiccup-fragment] ([ch hiccup-fragment]
(send-patch! ch hiccup-fragment {})) (send-patch! ch hiccup-fragment {}))
([ch hiccup-fragment {:keys [selector mode] ([ch hiccup-fragment {:keys [selector mode]
:or {mode "morph"}}] :or {mode "outer"}}]
(let [html-str (str (h/html hiccup-fragment)) (let [html-str (str/replace (str (h/html hiccup-fragment)) "\n" " ")
lines (cond-> [] lines (cond-> []
selector (conj [:selector selector]) selector (conj [:selector selector])
true (conj [:mode mode]) true (conj [:mode mode])
@@ -75,7 +76,7 @@
(defn- send-append! (defn- send-append!
"Send a Datastar patch-elements event in append mode." "Send a Datastar patch-elements event in append mode."
[ch selector hiccup-fragment] [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 (send-sse! ch (sse-event :patch-elements
[[:selector selector] [[:selector selector]
[:mode "append"] [:mode "append"]
@@ -142,7 +143,7 @@
(for [r reactions] (for [r reactions]
[:button {:key (str (:emoji r)) [: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" :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 (:emoji r)]
[:span {:class "text-subtext0"} (count (:users r))]])] [:span {:class "text-subtext0"} (count (:users r))]])]
{:selector (str "#reactions-" msg-id)}) {:selector (str "#reactions-" msg-id)})
@@ -189,7 +190,7 @@
(send-append! ch "#channel-list" (send-append! ch "#channel-list"
[:div {:id (str "sidebar-channel-" cid) [: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" :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 "mr-1.5 text-overlay0 text-xs"} prefix]
[:span {:class "truncate"} (:name channel)] [:span {:class "truncate"} (:name channel)]
[:span {:id (str "unread-badge-" cid) [:span {:id (str "unread-badge-" cid)
@@ -464,7 +465,7 @@
(defn send-fragment-to-user! (defn send-fragment-to-user!
"Send a Hiccup fragment to a specific 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 [conn-state (get @connections user-id)]
(when-let [ch (:sse-channel conn-state)] (when-let [ch (:sse-channel conn-state)]
(send-patch! ch hiccup-fragment {:selector selector :mode mode})))) (send-patch! ch hiccup-fragment {:selector selector :mode mode}))))