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
-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]}]
(list
;; Home / DMs button
[:div {:class "w-12 h-12 rounded-2xl bg-surface0 flex items-center justify-center
cursor-pointer hover:rounded-xl hover:bg-blue transition-all duration-200 mb-2 relative"
:data-on-click "@post('/web/navigate', {headers: {'X-Target': 'dms'}})"}
[:div {:class "w-12 h-12 rounded-2xl bg-surface0 flex items-center justify-center cursor-pointer hover:rounded-xl hover:bg-blue transition-all duration-200 mb-2 relative"
"data-on:click" "$navTarget = 'dms'; $navCommunity = ''; $navChannel = ''; @post('/web/navigate')"}
[:svg {:class "w-6 h-6 text-text" :viewBox "0 0 24 24" :fill "currentColor"}
[:path {:d "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"}]]
(when (and unread-count (pos? unread-count))
@@ -81,14 +80,13 @@
"rounded-xl bg-blue text-base"
"bg-surface0 text-subtext0 hover:rounded-xl hover:bg-blue hover:text-base"))
:title (:name comm)
:data-on-click (str "@post('/web/navigate', {headers: {'X-Community-Id': '" cid "'}})")}
"data-on:click" (str "$navCommunity = '" cid "'; $navChannel = ''; $navTarget = ''; @post('/web/navigate')")}
(community-initial comm)]))
;; Add community button
[:div {:class "w-12 h-12 rounded-2xl bg-surface0 flex items-center justify-center
cursor-pointer hover:rounded-xl hover:bg-green transition-all duration-200 mt-2"
[:div {:class "w-12 h-12 rounded-2xl bg-surface0 flex items-center justify-center cursor-pointer hover:rounded-xl hover:bg-green transition-all duration-200 mt-2"
:title "Create Community"
:data-on-click "window.location.href='/setup'"}
"data-on:click" "window.location.href='/setup'"}
[:span {:class "text-green text-2xl font-light"} "+"]]))
;;; ---------------------------------------------------------------------------
@@ -191,7 +189,7 @@
(if active
"bg-surface0 text-text"
"text-overlay1 hover:text-subtext1 hover:bg-hover"))
:data-on-click (str "@post('/web/navigate', {headers: {'X-Channel-Id': '" cid "'}})")}
"data-on:click" (str "$navChannel = '" cid "'; $navCommunity = ''; $navTarget = ''; @post('/web/navigate')")}
[:span {:class "mr-1.5 text-overlay0 text-xs"} prefix]
[:span {:class "truncate"} (:name ch)]
;; Unread badge placeholder
@@ -210,7 +208,7 @@
(if active
"bg-surface0 text-text"
"text-overlay1 hover:text-subtext1 hover:bg-hover"))
:data-on-click (str "@post('/web/navigate', {headers: {'X-Channel-Id': '" dm-id "'}})")}
"data-on:click" (str "$navChannel = '" dm-id "'; $navCommunity = ''; $navTarget = ''; @post('/web/navigate')")}
[:div {:class "w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold mr-2 flex-shrink-0"
:style (str "background-color: " (avatar-color dm-id))}
(user-initials name)]
@@ -237,7 +235,7 @@
;; Search button
[:button {:class "text-overlay0 hover:text-text"
:title "Search"
:data-on-click "$searchOpen = !$searchOpen"}
"data-on:click" "$searchOpen = !$searchOpen"}
[:svg {:class "w-5 h-5" :viewBox "0 0 24 24" :fill "none" :stroke "currentColor" :stroke-width "2"}
[:circle {:cx "11" :cy "11" :r "8"}]
[:line {:x1 "21" :y1 "21" :x2 "16.65" :y2 "16.65"}]]]
@@ -311,14 +309,14 @@
(for [r reactions]
[:button {:key (str (:emoji r) "-" (count (:users r)))
:class "flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-surface0 border border-surface1 hover:border-blue transition-colors"
:data-on-click (str "@post('/web/reactions', {headers: {'X-Message-Id': '" msg-id "', 'X-Emoji': '" (:emoji r) "'}})")}
"data-on:click" (str "@post('/web/reactions', {headers: {'X-Message-Id': '" msg-id "', 'X-Emoji': '" (:emoji r) "'}})")}
[:span (:emoji r)]
[:span {:class "text-subtext0"} (count (:users r))]])])
;; Thread indicator
(when (and thread-count (pos? thread-count))
[:button {:class "flex items-center gap-1 text-xs text-blue hover:underline mt-1"
:data-on-click (str "$threadOpen = true; $threadMessageId = '" msg-id "'")}
"data-on:click" (str "$threadOpen = true; $threadMessageId = '" msg-id "'")}
[:span (str thread-count " " (if (= 1 thread-count) "reply" "replies"))]])]
;; Hover action toolbar
@@ -326,36 +324,38 @@
;; Add reaction
[:button {:class "p-1.5 hover:bg-hover rounded-l text-overlay0 hover:text-text"
:title "Add Reaction"
:data-on-click (str "$emojiOpen = !$emojiOpen; $threadMessageId = '" msg-id "'")}
"data-on:click" (str "$emojiOpen = !$emojiOpen; $threadMessageId = '" msg-id "'")}
"\uD83D\uDE00"]
;; Reply in thread
[:button {:class "p-1.5 hover:bg-hover text-overlay0 hover:text-text"
:title "Reply in Thread"
:data-on-click (str "$threadOpen = true; $threadMessageId = '" msg-id "'")}
"data-on:click" (str "$threadOpen = true; $threadMessageId = '" msg-id "'")}
"\uD83D\uDCAC"]
;; Edit (own messages only)
(when is-own
[:button {:class "p-1.5 hover:bg-hover text-overlay0 hover:text-text"
:title "Edit"
:data-on-click (str "let el = document.querySelector('#msg-" msg-id " .message-body');"
"data-on:click" (str "let el = document.querySelector('#msg-" msg-id " .message-body');"
"el.contentEditable = 'true'; el.focus();")}
"\u270F\uFE0F"])
;; Delete (own messages only)
(when is-own
[:button {:class "p-1.5 hover:bg-hover rounded-r text-overlay0 hover:text-red"
:title "Delete"
:data-on-click (str "if(confirm('Delete this message?')) @post('/web/messages/" msg-id "/delete')")}
"data-on:click" (str "if(confirm('Delete this message?')) @post('/web/messages/" msg-id "/delete')")}
"\uD83D\uDDD1\uFE0F"])]]))
(defn message-list
"Scrollable list of messages with a 'Load older' trigger at top."
[messages current-user]
(list
;; Load older sentinel
;; Load older sentinel — only show when a full page was returned (may have more)
[:div {:id "load-older-sentinel" :class "flex justify-center py-2"}
[:button {:class "text-xs text-overlay0 hover:text-subtext0 px-3 py-1 rounded bg-surface0 hover:bg-hover transition-colors"
:data-on-click "@post('/web/messages', {headers: {'X-Load-Older': 'true'}})"}
"Load older messages"]]
(if (>= (count messages) 50)
[:button {:class "text-xs text-overlay0 hover:text-subtext0 px-3 py-1 rounded bg-surface0 hover:bg-hover transition-colors"
"data-on:click" "@post('/web/messages/older')"}
"Load older messages"]
[:span {:class "text-xs text-overlay0"} "Beginning of conversation"])]
;; Messages
[:div {:id "messages-container"}
@@ -379,7 +379,7 @@
;; Upload button
[:button {:class "p-3 text-overlay0 hover:text-text flex-shrink-0"
:title "Upload Image"
:data-on-click "document.getElementById('file-upload-input').click()"}
"data-on:click" "document.getElementById('file-upload-input').click()"}
[:svg {:class "w-5 h-5" :viewBox "0 0 24 24" :fill "none" :stroke "currentColor" :stroke-width "2"}
[:path {:d "M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"}]]]
;; Hidden file input
@@ -387,25 +387,27 @@
:id "file-upload-input"
:class "hidden"
:accept "image/jpeg,image/png,image/gif,image/webp"
:data-on-change (str "@post('/web/upload', {headers: {'X-Channel-Id': '" (or channel-id "") "'}})")}]
"data-on:change" (str "@post('/web/upload', {headers: {'X-Channel-Id': '" (or channel-id "") "'}})")}]
;; Textarea
[:textarea {:id "message-textarea"
:class "flex-1 bg-transparent text-text placeholder-overlay0 px-1 py-3 resize-none outline-none text-sm max-h-40"
:placeholder (str "Message #" ch-name)
:rows "1"
:data-bind "messageText"
:data-on-keydown (str "if(evt.key === 'Enter' && !evt.shiftKey) {"
"data-on:keydown" (str "if(evt.key === 'Enter' && !evt.shiftKey) {"
" evt.preventDefault();"
" if($messageText.trim()) {"
" @post('/web/messages', {headers: {'X-Channel-Id': '" (or channel-id "") "'}});"
" @post('/web/messages');"
" $messageText = '';"
" }"
"}")
:data-on-input (str "@post('/web/typing', {headers: {'X-Channel-Id': '" (or channel-id "") "'}})")}]
"data-on:input" (str "@post('/web/typing', {headers: {'X-Channel-Id': '" (or channel-id "") "'}})")}]
;; Send button
[:button {:class "p-3 text-blue hover:text-text flex-shrink-0"
:title "Send"
:data-on-click (str "if($messageText.trim()) {"
" @post('/web/messages', {headers: {'X-Channel-Id': '" (or channel-id "") "'}});"
"data-on:click" (str "if($messageText.trim()) {"
" @post('/web/messages');"
" $messageText = '';"
"}")}
[:svg {:class "w-5 h-5" :viewBox "0 0 24 24" :fill "currentColor"}
[:path {:d "M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"}]]]]]))
@@ -422,7 +424,7 @@
[:div {:class "h-12 px-4 flex items-center border-b border-surface1 flex-shrink-0"}
[:span {:class "font-semibold text-text text-sm"} "Thread"]
[:button {:class "ml-auto text-overlay0 hover:text-text"
:data-on-click "$threadOpen = false"}
"data-on:click" "$threadOpen = false"}
[:svg {:class "w-5 h-5" :viewBox "0 0 24 24" :fill "none" :stroke "currentColor" :stroke-width "2"}
[:line {:x1 "18" :y1 "6" :x2 "6" :y2 "18"}]
[:line {:x1 "6" :y1 "6" :x2 "18" :y2 "18"}]]]]
@@ -444,15 +446,15 @@
:placeholder "Reply in thread..."
:rows "1"
:data-bind "threadReply"
:data-signals-thread-reply ""
:data-on-keydown (str "if(evt.key === 'Enter' && !evt.shiftKey) {"
"data-signals:threadReply" ""
"data-on:keydown" (str "if(evt.key === 'Enter' && !evt.shiftKey) {"
" evt.preventDefault();"
" if($threadReply.trim()) {"
" @post('/web/messages', {headers: {'X-Parent-Id': $threadMessageId}});"
" }"
"}")}]
[:button {:class "p-2 text-blue hover:text-text flex-shrink-0"
:data-on-click (str "if($threadReply.trim()) {"
"data-on:click" (str "if($threadReply.trim()) {"
" @post('/web/messages', {headers: {'X-Parent-Id': $threadMessageId}});"
"}")}
[:svg {:class "w-4 h-4" :viewBox "0 0 24 24" :fill "currentColor"}
@@ -473,13 +475,13 @@
[:div {:id (str "toast-" (or id (random-uuid)))
:class "toast-enter bg-surface0 border border-surface1 rounded-lg shadow-xl p-3 max-w-sm min-w-[280px]"
:style (str "border-left: 3px solid " color)
:data-on-click "this.remove()"}
"data-on:click" "this.remove()"}
[:div {:class "flex items-start gap-2"}
[:div {:class "flex-1 min-w-0"}
[:div {:class "text-sm font-medium text-text truncate"} (or title "Notification")]
[:div {:class "text-xs text-subtext0 mt-0.5 truncate"} (or body "")]]
[:button {:class "text-overlay0 hover:text-text flex-shrink-0 text-sm"
:data-on-click "this.parentElement.parentElement.remove()"}
"data-on:click" "this.parentElement.parentElement.remove()"}
"\u2715"]]]))
;;; ---------------------------------------------------------------------------
@@ -504,11 +506,11 @@
(for [emoji emoji-grid]
[:button {:key emoji
:class "emoji-btn text-xl w-10 h-10 flex items-center justify-center rounded hover:bg-hover"
:data-on-click (str "@post('/web/reactions', {headers: {'X-Message-Id': $threadMessageId, 'X-Emoji': '" emoji "'}});"
"data-on:click" (str "@post('/web/reactions', {headers: {'X-Message-Id': $threadMessageId, 'X-Emoji': '" emoji "'}});"
" $emojiOpen = false")}
emoji])]
[:button {:class "mt-2 w-full text-xs text-overlay0 hover:text-subtext0 py-1"
:data-on-click "$emojiOpen = false"}
"data-on:click" "$emojiOpen = false"}
"Close"]])
;;; ---------------------------------------------------------------------------
@@ -521,7 +523,7 @@
[:div {:class "flex items-start justify-center pt-20"}
;; Backdrop
[:div {:class "absolute inset-0 bg-black bg-opacity-50"
:data-on-click "$searchOpen = false"}]
"data-on:click" "$searchOpen = false"}]
;; Modal
[:div {:class "relative bg-mantle border border-surface1 rounded-xl shadow-2xl w-full max-w-2xl z-10"}
;; Search input
@@ -533,10 +535,10 @@
:class "flex-1 bg-transparent text-text placeholder-overlay0 py-3 outline-none text-sm"
:placeholder "Search messages..."
:data-bind "searchQuery"
:data-on-keydown "if(evt.key === 'Enter') @post('/web/search')"
"data-on:keydown" "if(evt.key === 'Enter') @post('/web/search')"
:autofocus true}]
[:button {:class "text-overlay0 hover:text-text ml-2"
:data-on-click "$searchOpen = false"}
"data-on:click" "$searchOpen = false"}
[:span {:class "text-xs border border-surface1 rounded px-1.5 py-0.5"} "ESC"]]]
;; Search results container
[:div {:id "search-results" :class "max-h-96 overflow-y-auto p-4"}
@@ -555,7 +557,7 @@
ts (format-timestamp (or (:created-at msg) (:created_at msg)))]
[:div {:key msg-id
:class "flex items-start gap-3 p-2 rounded hover:bg-hover cursor-pointer"
:data-on-click (str "$searchOpen = false;"
"data-on:click" (str "$searchOpen = false;"
" document.getElementById('msg-" msg-id "')?.scrollIntoView({behavior: 'smooth'})")}
[:div {:class "w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"
:style (str "background-color: " (avatar-color (:user-id msg)))}
+117 -40
View File
@@ -39,21 +39,44 @@
[request header-name]
(get-in request [:headers (str/lower-case header-name)]))
(defn- datastar-fragment-response
"Return an SSE response with a Datastar patch-elements event.
Used for POST handlers that need to return a UI update."
[hiccup-fragment & [{:keys [selector mode] :or {mode "morph"}}]]
(let [html-str (str (h/html hiccup-fragment))
(defn- fragment-event
"Build a single datastar-patch-elements SSE event string for one fragment.
Newlines in the rendered HTML are replaced with spaces to avoid breaking
SSE data lines (each data: line must be a single line in the SSE protocol)."
[hiccup-fragment & [{:keys [selector mode] :or {mode "outer"}}]]
(let [html-str (str/replace (str (h/html hiccup-fragment)) "\n" " ")
lines (cond-> []
selector (conj (str "selector " selector))
true (conj (str "mode " mode))
true (conj (str "elements " html-str)))]
{:status 200
:headers {"Content-Type" "text/event-stream"
"Cache-Control" "no-cache, no-store, must-revalidate"}
:body (str "event: datastar-patch-elements\n"
(apply str (map #(str "data: " % "\n") lines))
"\n")}))
(str "event: datastar-patch-elements\n"
(apply str (map #(str "data: " % "\n") lines))
"\n")))
(defn- datastar-fragment-response
"Return an SSE response with a Datastar patch-elements event.
Used for POST handlers that need to return a UI update."
[hiccup-fragment & [{:keys [selector mode] :or {mode "outer"}}]]
{:status 200
:headers {"Content-Type" "text/event-stream"
"Cache-Control" "no-cache, no-store, must-revalidate"}
:body (fragment-event hiccup-fragment {:selector selector :mode mode})})
(defn- datastar-multi-fragment-response
"Return an SSE response with multiple datastar-patch-elements events.
Each fragment is morphed independently by its element id.
Optionally includes a patch-signals event to sync client state."
([fragments]
(datastar-multi-fragment-response fragments nil))
([fragments signals-json]
{:status 200
:headers {"Content-Type" "text/event-stream"
"Cache-Control" "no-cache, no-store, must-revalidate"}
:body (str (apply str (map fragment-event fragments))
(when signals-json
(str "event: datastar-patch-signals\n"
"data: signals " signals-json "\n"
"\n")))}))
(defn- datastar-signals-response
"Return an SSE response with a Datastar patch-signals event."
@@ -140,6 +163,47 @@
(api/delete-message ctx message-id))
(empty-sse-response)))
;;; ---------------------------------------------------------------------------
;;; Load Older Messages Handler
;;; ---------------------------------------------------------------------------
(defn load-older-handler
"POST /web/messages/older -- fetch older messages using before cursor.
Reads oldestMessageId and activeChannel from Datastar signals.
Prepends older messages into #messages-container and updates the cursor signal."
[request]
(let [ctx (build-api-ctx request)
channel-id (get-signal request "activeChannel")
cursor (get-signal request "oldestMessageId")
user (api/get-me ctx)]
(if (or (str/blank? channel-id) (str/blank? cursor))
(empty-sse-response)
(let [messages (api/get-messages ctx channel-id
{:before cursor :limit 50})
new-oldest (when (seq messages) (str (:id (first messages))))]
(if (empty? messages)
;; No more messages — replace the load-older button with a notice
(datastar-fragment-response
[:div {:id "load-older-sentinel" :class "flex justify-center py-2"}
[:span {:class "text-xs text-overlay0"} "Beginning of conversation"]])
;; Prepend older messages and update cursor
(let [batch-html (str (h/html
[:div {:id (str "older-batch-" new-oldest)}
(for [msg messages]
(c/message-component msg user))]))
html-str (str/replace batch-html "\n" " ")]
{:status 200
:headers {"Content-Type" "text/event-stream"
"Cache-Control" "no-cache, no-store, must-revalidate"}
:body (str "event: datastar-patch-elements\n"
"data: selector #messages-container\n"
"data: mode prepend\n"
"data: elements " html-str "\n"
"\n"
"event: datastar-patch-signals\n"
"data: signals {oldestMessageId: '" new-oldest "'}\n"
"\n")}))))))
;;; ---------------------------------------------------------------------------
;;; Reaction Handlers
;;; ---------------------------------------------------------------------------
@@ -175,46 +239,53 @@
(defn navigate-handler
"POST /web/navigate -- switch community or channel.
Updates connection tracking and re-subscribes NATS.
Returns the updated sidebar + message list + channel header."
Returns the updated sidebar + message list + channel header.
Navigation intent is determined from explicit request headers only
(not Datastar signals) to avoid stale state."
[request]
(let [ctx (build-api-ctx request)
user-id (:user-id request)
connections (get-in request [:system :connections])
nats (get-in request [:system :nats])
target (get-header request "x-target")
community-id (or (get-header request "x-community-id")
(get-signal request "activeCommunity"))
channel-id (or (get-header request "x-channel-id")
(get-signal request "activeChannel"))]
;; Read navigation intent from dedicated nav signals (set right before @post)
;; These are distinct from activeCommunity/activeChannel to avoid stale state
target (let [v (get-signal request "navTarget")] (when-not (str/blank? v) v))
community-id (let [v (get-signal request "navCommunity")] (when-not (str/blank? v) v))
channel-id (let [v (get-signal request "navChannel")] (when-not (str/blank? v) v))]
(cond
;; Navigate to DMs view
(= target "dms")
(let [dms (api/get-dms ctx)
(let [communities (api/get-communities ctx)
dms (api/get-dms ctx)
dm (first dms)
dm-id (when dm (str (:id dm)))
messages (when dm-id (api/get-messages ctx dm-id {:limit 50}))
user (api/get-me ctx)]
user (api/get-me ctx)
oldest (when (seq messages) (str (:id (first messages))))]
;; Re-subscribe NATS (no community context for DMs)
(when user-id
(sse/resubscribe! connections nats user-id nil dm-id))
;; Return updated UI fragments
(datastar-fragment-response
[:div {:id "app-content"}
;; Update sidebar with DM list
;; Return updated UI fragments — each matches an existing DOM element
(datastar-multi-fragment-response
[[:div {:id "community-strip"
:class "w-[72px] flex-shrink-0 bg-mantle flex flex-col items-center py-3 gap-2 overflow-y-auto"}
(c/community-strip {:communities communities
:active-id nil
:unread-count 0})]
[:div {:id "sidebar"
:class "w-60 flex-shrink-0 bg-mantle flex flex-col border-r border-surface1"}
(c/dm-sidebar {:dms dms :active-dm dm :user user})]
;; Update channel header
[:div {:id "channel-header"}
(c/channel-header (or dm {:name "Direct Messages"}))]
;; Update messages
[:div {:id "message-list"
:class "flex-1 overflow-y-auto px-4 py-2"}
(c/message-list (reverse (or messages [])) user)]
;; Update input
(c/message-list (or messages []) user)]
[:div {:id "message-input-area" :class "px-4 pb-4"}
(c/message-input dm)]]))
(c/message-input dm)]]
(str "{activeCommunity: '', activeChannel: '" (or dm-id "") "'"
", oldestMessageId: '" (or oldest "") "'}")))
;; Navigate to a specific channel
channel-id
@@ -224,14 +295,14 @@
channels (when cid (api/get-channels ctx cid))
messages (api/get-messages ctx channel-id {:limit 50})
categories (when cid (api/get-categories ctx cid))
user (api/get-me ctx)]
user (api/get-me ctx)
oldest (when (seq messages) (str (:id (first messages))))]
;; Re-subscribe NATS
(when user-id
(sse/resubscribe! connections nats user-id cid channel-id))
;; Return updated UI fragments
(datastar-fragment-response
[:div {:id "app-content"}
[:div {:id "sidebar"
(datastar-multi-fragment-response
[[:div {:id "sidebar"
:class "w-60 flex-shrink-0 bg-mantle flex flex-col border-r border-surface1"}
(c/sidebar {:community community
:channels channels
@@ -242,9 +313,12 @@
(c/channel-header channel)]
[:div {:id "message-list"
:class "flex-1 overflow-y-auto px-4 py-2"}
(c/message-list (reverse (or messages [])) user)]
(c/message-list (or messages []) user)]
[:div {:id "message-input-area" :class "px-4 pb-4"}
(c/message-input channel)]]))
(c/message-input channel)]]
(str "{activeCommunity: '" (or cid "") "', activeChannel: '" channel-id "'"
", oldestMessageId: '" (or oldest "") "'}")))
;; Navigate to a community (pick first channel)
community-id
@@ -254,14 +328,14 @@
ch-id (when channel (str (:id channel)))
messages (when ch-id (api/get-messages ctx ch-id {:limit 50}))
categories (api/get-categories ctx community-id)
user (api/get-me ctx)]
user (api/get-me ctx)
oldest (when (seq messages) (str (:id (first messages))))]
;; Re-subscribe NATS
(when user-id
(sse/resubscribe! connections nats user-id community-id ch-id))
;; Return updated UI
(datastar-fragment-response
[:div {:id "app-content"}
[:div {:id "community-strip"
(datastar-multi-fragment-response
[[:div {:id "community-strip"
:class "w-[72px] flex-shrink-0 bg-mantle flex flex-col items-center py-3 gap-2 overflow-y-auto"}
(c/community-strip {:communities (api/get-communities ctx)
:active-id community-id
@@ -277,9 +351,12 @@
(c/channel-header channel)]
[:div {:id "message-list"
:class "flex-1 overflow-y-auto px-4 py-2"}
(c/message-list (reverse (or messages [])) user)]
(c/message-list (or messages []) user)]
[:div {:id "message-input-area" :class "px-4 pb-4"}
(c/message-input channel)]]))
(c/message-input channel)]]
(str "{activeCommunity: '" community-id "', activeChannel: '" (or ch-id "") "'"
", oldestMessageId: '" (or oldest "") "'}")))
:else
(empty-sse-response))))
+14 -11
View File
@@ -120,7 +120,8 @@
[{:keys [user communities community channels channel messages categories unread-count
dm-view? dms]}]
(let [community-id (when community (str (:id community)))
channel-id (when channel (str (:id channel)))]
channel-id (when channel (str (:id channel)))
oldest-msg-id (when (seq messages) (str (:id (first messages))))]
(base-page
{:title (str (when channel (str "#" (:name channel) " - "))
(when community (:name community))
@@ -136,7 +137,11 @@
" threadOpen: false,"
" threadMessageId: '',"
" commandText: '',"
" unreadCount: " (or unread-count 0) "}")
" unreadCount: " (or unread-count 0) ","
" navCommunity: '',"
" navChannel: '',"
" navTarget: '',"
" oldestMessageId: '" (or oldest-msg-id "") "'}")
;; Auto-connect to SSE on page load
:data-init (str "@get('/sse/events', {openWhenHidden: true})")}]
;; Main 4-pane layout
@@ -168,9 +173,7 @@
;; Messages
[:div {:id "message-list"
:class "flex-1 overflow-y-auto px-4 py-2"
:data-on-scroll (str "if(evt.target.scrollTop === 0) {"
" @post('/web/messages', {headers: {'X-Load-Older': 'true'}})"
"}")}
"data-on:scroll" "if(evt.target.scrollTop === 0) { @post('/web/messages/older') }"}
(c/message-list messages user)]
;; Message input
@@ -181,7 +184,7 @@
;; Pane 4: Thread panel (hidden by default)
[:div {:id "thread-panel"
:class "w-96 flex-shrink-0 bg-mantle border-l border-surface1 flex-col hidden"
:data-class-hidden "!$threadOpen"}]]
"data-class:hidden" "!$threadOpen"}]]
;; Notification toast container
[:div {:id "toast-container"
@@ -190,13 +193,13 @@
;; Search modal (hidden by default)
[:div {:id "search-modal"
:class "fixed inset-0 z-40 hidden"
:data-class-hidden "!$searchOpen"}
"data-class:hidden" "!$searchOpen"}
(c/search-modal)]
;; Emoji picker (hidden by default)
[:div {:id "emoji-picker"
:class "fixed z-30 hidden"
:data-class-hidden "!$emojiOpen"}
"data-class:hidden" "!$emojiOpen"}
(c/emoji-picker)])))
;;; ---------------------------------------------------------------------------
@@ -212,14 +215,14 @@
[:div {:class "w-full max-w-md p-8 bg-mantle rounded-xl border border-surface1"}
[:h1 {:class "text-2xl font-bold text-text mb-2 text-center"} "Welcome to ajet chat"]
[:p {:class "text-subtext0 text-center mb-8"} "Create your first community to get started."]
[:form {:data-on-submit "@post('/web/communities')"}
[:form {"data-on:submit" "@post('/web/communities')"}
[:div {:class "mb-4"}
[:label {:class "block text-sm font-medium text-subtext1 mb-1" :for "community-name"} "Community Name"]
[:input {:type "text"
:id "community-name"
:name "name"
:data-bind "communityName"
:data-signals-community-name ""
"data-signals:communityName" ""
:required true
:placeholder "My Team"
:class "w-full px-3 py-2 bg-surface0 border border-surface1 rounded-lg text-text
@@ -230,7 +233,7 @@
:id "community-slug"
:name "slug"
:data-bind "communitySlug"
:data-signals-community-slug ""
"data-signals:communitySlug" ""
:required true
:pattern "[a-z0-9][a-z0-9-]*[a-z0-9]"
:placeholder "my-team"
+27 -3
View File
@@ -6,6 +6,7 @@
2. SSE endpoint (GET /sse/events) -- Datastar SSE stream
3. Signal handlers (POST /web/*) -- browser actions proxied to API"
(:require [clojure.tools.logging :as log]
[clojure.data.json :as json]
[reitit.ring :as ring]
[ring.middleware.params :refer [wrap-params]]
[ring.middleware.multipart-params :refer [wrap-multipart-params]]
@@ -26,6 +27,27 @@
(fn [request]
(handler (assoc request :system system))))
(defn wrap-json-body
"Parse JSON request bodies and merge into :params.
Datastar v1 sends signals as application/json POST bodies."
[handler]
(fn [request]
(if (and (some-> (get-in request [:headers "content-type"])
(.contains "application/json"))
(:body request))
(try
(let [body-str (if (string? (:body request))
(:body request)
(slurp (:body request)))
parsed (when-not (clojure.string/blank? body-str)
(json/read-str body-str :key-fn keyword))]
(handler (-> request
(assoc :body-params (or parsed {}))
(update :params merge (or parsed {})))))
(catch Exception _
(handler request)))
(handler request))))
(defn wrap-user-context
"Extract Auth GW headers into request keys for convenience."
[handler]
@@ -103,7 +125,7 @@
:community community
:channels channels
:channel channel
:messages (reverse (or messages []))
:messages (or messages [])
:categories categories
:unread-count (:count unread 0)})}))
@@ -128,7 +150,7 @@
:community community
:channels channels
:channel channel
:messages (reverse (or messages []))
:messages (or messages [])
:categories categories
:unread-count (:count unread 0)})}))
@@ -150,7 +172,7 @@
:community nil
:channels []
:channel active-dm
:messages (reverse (or messages []))
:messages (or messages [])
:categories []
:unread-count (:count unread 0)
:dm-view? true
@@ -182,6 +204,7 @@
;; Signal handlers (POST actions from browser)
["/web"
["/messages" {:post {:handler handlers/send-message-handler}}]
["/messages/older" {:post {:handler handlers/load-older-handler}}]
["/messages/:id/edit" {:post {:handler handlers/edit-message-handler}}]
["/messages/:id/delete" {:post {:handler handlers/delete-message-handler}}]
["/reactions" {:post {:handler handlers/add-reaction-handler}}]
@@ -206,6 +229,7 @@
:headers {"Content-Type" "text/html; charset=utf-8"}
:body "<html><body><h1>404 Not Found</h1></body></html>"})})
{:middleware [[wrap-system system]
wrap-json-body
wrap-params
wrap-keyword-params
wrap-multipart-params
+8 -7
View File
@@ -14,6 +14,7 @@
:last-seen <Instant>}}"
(:require [org.httpkit.server :as hk]
[clojure.tools.logging :as log]
[clojure.string :as str]
[hiccup2.core :as h]
[ajet.chat.shared.api-client :as api]
[ajet.chat.shared.eventbus :as eventbus]
@@ -34,7 +35,7 @@
Datastar SSE format:
event: datastar-patch-elements
data: selector #target-id
data: mode morph
data: mode outer
data: elements <div>...</div>"
[event-type data-lines]
(let [event-name (case event-type
@@ -64,8 +65,8 @@
([ch hiccup-fragment]
(send-patch! ch hiccup-fragment {}))
([ch hiccup-fragment {:keys [selector mode]
:or {mode "morph"}}]
(let [html-str (str (h/html hiccup-fragment))
:or {mode "outer"}}]
(let [html-str (str/replace (str (h/html hiccup-fragment)) "\n" " ")
lines (cond-> []
selector (conj [:selector selector])
true (conj [:mode mode])
@@ -75,7 +76,7 @@
(defn- send-append!
"Send a Datastar patch-elements event in append mode."
[ch selector hiccup-fragment]
(let [html-str (str (h/html hiccup-fragment))]
(let [html-str (str/replace (str (h/html hiccup-fragment)) "\n" " ")]
(send-sse! ch (sse-event :patch-elements
[[:selector selector]
[:mode "append"]
@@ -142,7 +143,7 @@
(for [r reactions]
[:button {:key (str (:emoji r))
:class "flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-surface0 border border-surface1 hover:border-blue transition-colors"
:data-on-click (str "@post('/web/reactions', {headers: {'X-Message-Id': '" msg-id "', 'X-Emoji': '" (:emoji r) "'}})")}
"data-on:click" (str "@post('/web/reactions', {headers: {'X-Message-Id': '" msg-id "', 'X-Emoji': '" (:emoji r) "'}})")}
[:span (:emoji r)]
[:span {:class "text-subtext0"} (count (:users r))]])]
{:selector (str "#reactions-" msg-id)})
@@ -189,7 +190,7 @@
(send-append! ch "#channel-list"
[:div {:id (str "sidebar-channel-" cid)
:class "flex items-center px-2 py-1 rounded cursor-pointer text-sm text-overlay1 hover:text-subtext1 hover:bg-hover"
:data-on-click (str "@post('/web/navigate', {headers: {'X-Channel-Id': '" cid "'}})")}
"data-on:click" (str "$navChannel = '" cid "'; $navCommunity = ''; $navTarget = ''; @post('/web/navigate')")}
[:span {:class "mr-1.5 text-overlay0 text-xs"} prefix]
[:span {:class "truncate"} (:name channel)]
[:span {:id (str "unread-badge-" cid)
@@ -464,7 +465,7 @@
(defn send-fragment-to-user!
"Send a Hiccup fragment to a specific user."
[connections user-id hiccup-fragment & [{:keys [selector mode] :or {mode "morph"}}]]
[connections user-id hiccup-fragment & [{:keys [selector mode] :or {mode "outer"}}]]
(when-let [conn-state (get @connections user-id)]
(when-let [ch (:sse-channel conn-state)]
(send-patch! ch hiccup-fragment {:selector selector :mode mode}))))