Biff 2 is in development. Try it out.

Channels

View the code for this section.

Now that users can create and join communities, we're ready to let community admins create and delete channels. We'll start by adding a "New channel" button. But first, let's update com.eelchat.app/wrap-community so it adds the current user's roles to the incoming request:

;; src/com/eelchat/app.clj
;; ...
         [:div {:class "grow-[1.75]"}]]))))

 (defn wrap-community [handler]
-  (fn [{:keys [biff/db path-params] :as ctx}]
+  (fn [{:keys [biff/db user path-params] :as ctx}]
     (if-some [community (xt/entity db (parse-uuid (:id path-params)))]
-      (handler (assoc ctx :community community))
+      (let [roles (->> (:user/memberships user)
+                       (filter (fn [membership]
+                                 (= (:xt/id community)
+                                    (get-in membership [:membership/community :xt/id]))))
+                       first
+                       :membership/roles)]
+        (handler (assoc ctx :community community :roles roles)))
       {:status 303
        :headers {"location" "/app"}})))

Now in com.eelchat.ui, we can check if the user has the :admin role and show the button if so:

;; src/com/eelchat/ui.clj

-(defn app-page [{:keys [uri user] :as ctx} & body]
+(defn app-page [{:keys [uri user community roles] :as ctx} & body]
   (base
    ctx
    [:.flex.bg-orange-50
;; ...
                       url)}
          (:community/title community)])]
      [:.grow]
+     (when (contains? roles :admin)
+       [:<>
+        (biff/form
+         {:action (str "/community/" (:xt/id community) "/channel")}
+         [:button.btn.w-full {:type "submit"} "New channel"])
+        [:.h-3]])
      (biff/form
       {:action "/community"}
       [:button.btn.w-full {:type "submit"} "New community"])

Screenshot of the "New channel" button

(If the button doesn't show up, you may need to sign back into the account that created this community.)

Next we'll add a handler so that the button actually does something. We'll also add a dummy channel-page handler:

;; src/com/eelchat/app.clj
;; ...
   {:status 303
    :headers {"Location" (str "/community/" (:xt/id community))}})
 
+(defn new-channel [{:keys [community roles] :as ctx}]
+  (if (and community (contains? roles :admin))
+    (let [channel-id (random-uuid)]
+     (biff/submit-tx ctx
+       [{:db/doc-type :channel
+         :xt/id channel-id
+         :channel/title (str "Channel #" (rand-int 1000))
+         :channel/community (:xt/id community)}])
+     {:status 303
+      :headers {"Location" (str "/community/" (:xt/id community) "/channel/" channel-id)}})
+    {:status 403
+     :body "Forbidden."}))
+
 (defn community [{:keys [biff/db user community] :as ctx}]
   (let [member (some (fn [membership]
                        (= (:xt/id community) (get-in membership [:membership/community :xt/id])))
;; ...
          [:button.btn {:type "submit"} "Join this community"])
         [:div {:class "grow-[1.75]"}]]))))
 
+(defn channel-page [ctx]
+  ;; We'll update this soon
+  (community ctx))
+
 (defn wrap-community [handler]
   (fn [{:keys [biff/db user path-params] :as ctx}]
     (if-some [community (xt/entity db (parse-uuid (:id path-params)))]
;; ...
       {:status 303
        :headers {"location" "/app"}})))
 
+(defn wrap-channel [handler]
+  (fn [{:keys [biff/db user community path-params] :as ctx}]
+    (let [channel (xt/entity db (parse-uuid (:channel-id path-params)))]
+      (if (= (:channel/community channel) (:xt/id community))
+        (handler (assoc ctx :channel channel))
+        {:status 303
+         :headers {"Location" (str "/community/" (:xt/id community))}}))))
+
 (def module
   {:routes ["" {:middleware [mid/wrap-signed-in]}
             ["/app"           {:get app}]
             ["/community"     {:post new-community}]
             ["/community/:id" {:middleware [wrap-community]}
              [""      {:get community}]
-             ["/join" {:post join-community}]]]})
+             ["/join" {:post join-community}]
+             ["/channel" {:post new-channel}]
+             ["/channel/:channel-id" {:middleware [wrap-channel]}
+              ["" {:get channel-page}]]]]})

Now let's update com.eelchat.ui/app-page so that it displays the channels in the sidebar if you're a member of the community:

;; src/com/eelchat/ui.clj
;; ...
 (ns com.eelchat.ui
   (:require [cheshire.core :as cheshire]
             [clojure.java.io :as io]
             [com.eelchat.settings :as settings]
-            [com.biffweb :as biff]
+            [com.biffweb :as biff :refer [q]]
             [ring.middleware.anti-forgery :as csrf]))
 
;; ...
 
+(defn channels [{:keys [biff/db community roles]}]
+  (when (some? roles)
+    (sort-by
+     :channel/title
+     (q db
+        '{:find (pull channel [*])
+          :in [community]
+          :where [[channel :channel/community community]]}
+        (:xt/id community)))))
+
-(defn app-page [{:keys [uri user community roles] :as ctx} & body]
+(defn app-page [{:keys [biff/db uri user community roles channel] :as ctx} & body]
   (base
    ctx
    [:.flex.bg-orange-50
;; ...
           :selected (when (= url uri)
                       url)}
          (:community/title community)])]
+     [:.h-4]
+     (for [c (channels ctx)
+           :let [active (= (:xt/id c) (:xt/id channel))]]
+       [:.mt-3 (if active
+                 [:span.font-bold (:channel/title c)]
+                 [:a.link {:href (str "/community/" (:xt/id community)
+                                      "/channel/" (:xt/id c))}
+                  (:channel/title c)])])
      [:.grow]
      (when (contains? roles :admin)
        [:<>

If you create multiple channels, you should be able to navigate between them:

Screenshot with several channels in the navigation sidebar

Delete channels

Next we'll add a delete button for each channel. They'll only be visible if you're an admin. Make a new src/com/eelchat/ui/icons.clj file, containing the free X icon from Font Awesome:

;; src/com/eelchat/ui/icons.clj
(ns com.eelchat.ui.icons)

(def data
  {:x {:view-box "0 0 384 512", :path "M376.6 84.5c11.3-13.6 9.5-33.8-4.1-45.1s-33.8-9.5-45.1 4.1L192 206 56.6 43.5C45.3 29.9 25.1 28.1 11.5 39.4S-3.9 70.9 7.4 84.5L150.3 256 7.4 427.5c-11.3 13.6-9.5 33.8 4.1 45.1s33.8 9.5 45.1-4.1L192 306 327.4 468.5c11.3 13.6 31.5 15.4 45.1 4.1s15.4-31.5 4.1-45.1L233.7 256 376.6 84.5z"}})

(defn icon [k & [opts]]
  (let [{:keys [view-box path]} (data k)]
    [:svg.flex-shrink-0.inline
     (merge {:xmlns "http://www.w3.org/2000/svg"
             :viewBox view-box}
            opts)
     [:path {:fill "currentColor"
             :d path}]]))

Then modify com.eelchat.ui/app-page so it includes the delete buttons. We'll do a little finagling to make the icon vertically aligned:

;; src/com/eelchat/ui.clj
;; ...
 (ns com.eelchat.ui
   (:require [cheshire.core :as cheshire]
             [clojure.java.io :as io]
             [clojure.string :as str]
             [com.eelchat.settings :as settings]
+            [com.eelchat.ui.icons :refer [icon]]
;; ...
(defn app-page [{:keys [biff/db uri user community roles channel] :as ctx} & body]
   (base
    ctx
    [:.flex.bg-orange-50
+    {:hx-headers (cheshire/generate-string
+                  {:x-csrf-token csrf/*anti-forgery-token*})}
     [:.h-screen.w-80.p-3.pr-0.flex.flex-col.flex-grow
      [:select
       {:class '[text-sm
;; ...
             :let [url (str "/community/" (:xt/id community))]]
         [:option.cursor-pointer
          {:value url
           :selected (str/starts-with? uri url)}
          (:community/title community)])]
      [:.h-4]
      (for [c (channels ctx)
-           :let [active (= (:xt/id c) (:xt/id channel))]]
+           :let [active (= (:xt/id c) (:xt/id channel))
+                 href (str "/community/" (:xt/id community)
+                           "/channel/" (:xt/id c))]]
-       [:.mt-3 (if active
-                 [:span.font-bold (:channel/title c)]
-                 [:a.link {:href (str "/community/" (:xt/id community)
-                                      "/channel/" (:xt/id c))}
-                  (:channel/title c)])])
+       [:.mt-4.flex.justify-between.leading-none
+        (if active
+          [:span.font-bold (:channel/title c)]
+          [:a.link {:href href}
+           (:channel/title c)])
+        (when (contains? roles :admin)
+          [:button.opacity-50.hover:opacity-100.flex.items-center
+           {:hx-delete href
+            :hx-confirm (str "Delete " (:channel/title c) "?")
+            :hx-target "closest div"
+            :hx-swap "outerHTML"
+            :_ (when active
+                 (str "on htmx:afterRequest set window.location to '/community/" (:xt/id community) "'"))}
+           (icon :x {:class "w-3 h-3"})])])
      [:.grow]
      (when (contains? roles :admin)
        [:<>

The :hx-headers value allows us to trigger an htmx request from the button element without wrapping it in biff/form, which is normally responsible for adding the CSRF token to your requests.

After we define the corresponding request handler, our delete buttons will be fully functional:

;; src/com/eelchat/app.clj
;; ...
     {:status 403
      :body "Forbidden."}))
 
+(defn delete-channel [{:keys [channel roles] :as ctx}]
+  (when (contains? roles :admin)
+    (biff/submit-tx ctx
+      [{:db/op :delete
+        :xt/id (:xt/id channel)}]))
+  [:<>])
+
 (defn community [{:keys [biff/db user community] :as ctx}]
   (let [member (some (fn [membership]
                        (= (:xt/id community) (get-in membership [:membership/community :xt/id])))
;; ...
              ["/join" {:post join-community}]
              ["/channel" {:post new-channel}]
              ["/channel/:channel-id" {:middleware [wrap-channel]}
-              ["" {:get channel-page}]]]]})
+              ["" {:get channel-page
+                   :delete delete-channel}]]]]})

In this case, [:<>] is an easy way to return an empty response (see the Rum docs).

Voila:

Screen recording of the delete buttons

Have a question? Join the #biff channel on Clojurians Slack, or ask on GitHub.

Sign up for Biff: The Newsletter
Announcements, blog posts, et cetera et cetera.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.