update
This commit is contained in:
@@ -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
|
||||
@@ -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)))}
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}))))
|
||||
|
||||
Reference in New Issue
Block a user