profile image
Sean Walker
2020-07-29

Let’s make twitter with joy: part 5

Welcome to a series I call with joy where I clone popular websites/webapps with my web framework, joy.

If you’re just tuning in Part 4 I went over how forms and submitted and some high level request/database handling stuff.

In this part, I’m actually going to build the thing.

Twitter breakdown

Let’s breakdown twitter into parts so it’s easier to work on one feature at a time:

The feed

The feed is mostly finished with this code here:

(route :get "/" :home)
(defn home [request]
  (def posts (db/from :post :join/one :account :order "post.created_at desc" :limit 15))

  [:vstack {:class "sm:w-100 lg:w-3xl mx-auto"}
   (foreach [post posts]
     (let [account (post :account)]
       [:hstack {:spacing "xs" :align-y "top" :class "bg-background pa-xs bn bl bt br b--solid b--background-alt"}
        [:img {:src (account :photo-url) :class "br-100 ba b--background-alt sm:w-m md:w-m"}]

        [:vstack {:spacing "s"}
         [:hstack {:shrink ""}
          [:div {:class "ellipsis"}
           (account :display-name)]

          [:div {:class "muted ellipsis"}
           (string "@" (account :name))]

          [:time {:data-seconds (post :created-at) :class "muted tr"}
           (post :created-at)]]

         [:div (post :body)]

         [:hstack
          [:svg {:xmlns "http://www.w3.org/2000/svg" :fill "currentColor" :height "1em" :width "1em" :class "bi bi-reply" :viewBox "0 0 16 16"}
           [:path {:fill-rule "evenodd" :d "M9.502 5.013a.144.144 0 0 0-.202.134V6.3a.5.5 0 0 1-.5.5c-.667 0-2.013.005-3.3.822-.984.624-1.99 1.76-2.595 3.876C3.925 10.515 5.09 9.982 6.11 9.7a8.741 8.741 0 0 1 1.921-.306 7.403 7.403 0 0 1 .798.008h.013l.005.001h.001L8.8 9.9l.05-.498a.5.5 0 0 1 .45.498v1.153c0 .108.11.176.202.134l3.984-2.933a.494.494 0 0 1 .042-.028.147.147 0 0 0 0-.252.494.494 0 0 1-.042-.028L9.502 5.013zM8.3 10.386a7.745 7.745 0 0 0-1.923.277c-1.326.368-2.896 1.201-3.94 3.08a.5.5 0 0 1-.933-.305c.464-3.71 1.886-5.662 3.46-6.66 1.245-.79 2.527-.942 3.336-.971v-.66a1.144 1.144 0 0 1 1.767-.96l3.994 2.94a1.147 1.147 0 0 1 0 1.946l-3.994 2.94a1.144 1.144 0 0 1-1.767-.96v-.667z"}]]

          [:spacer]

          [:svg {:xmlns "http://www.w3.org/2000/svg" :fill "currentColor" :height "1em" :width "1em" :class "bi bi-arrow-repeat" :viewBox "0 0 16 16"}
            [:path {:fill-rule "evenodd" :d "M2.854 7.146a.5.5 0 0 0-.708 0l-2 2a.5.5 0 1 0 .708.708L2.5 8.207l1.646 1.647a.5.5 0 0 0 .708-.708l-2-2zm13-1a.5.5 0 0 0-.708 0L13.5 7.793l-1.646-1.647a.5.5 0 0 0-.708.708l2 2a.5.5 0 0 0 .708 0l2-2a.5.5 0 0 0 0-.708z"}]
            [:path {:fill-rule "evenodd" :d "M8 3a4.995 4.995 0 0 0-4.192 2.273.5.5 0 0 1-.837-.546A6 6 0 0 1 14 8a.5.5 0 0 1-1.001 0 5 5 0 0 0-5-5zM2.5 7.5A.5.5 0 0 1 3 8a5 5 0 0 0 9.192 2.727.5.5 0 1 1 .837.546A6 6 0 0 1 2 8a.5.5 0 0 1 .501-.5z"}]]
          [:spacer]

          [:svg {:xmlns "http://www.w3.org/2000/svg" :fill "currentColor" :height "1em" :width "1em" :class "bi bi-heart" :viewBox "0 0 16 16"}
            [:path {:fill-rule "evenodd" :d "M8 2.748l-.717-.737C5.6.281 2.514.878 1.4 3.053c-.523 1.023-.641 2.5.314 4.385.92 1.815 2.834 3.989 6.286 6.357 3.452-2.368 5.365-4.542 6.286-6.357.955-1.886.838-3.362.314-4.385C13.486.878 10.4.28 8.717 2.01L8 2.748zM8 15C-7.333 4.868 3.279-3.04 7.824 1.143c.06.055.119.112.176.171a3.12 3.12 0 0 1 .176-.17C12.72-3.042 23.333 4.867 8 15z"}]]]]]))])

Here’s how that looks:

a twitter-like feed with fake user profiles and fake data

So that’s good, new post time!

Posts

We’re going to clone twitter’s ui again here, there’s going to be a floating post button in the bottom right hand corner of the screen.

When you click it a few things will happen:

  1. A modal will pop up
  2. It will show a textarea, a button and a character counter
  3. When you click the button, the modal will go away
  4. On the server, a new post row will be inserted

New post button

I like a new post button floating a little ways from the bottom right, I can do this with ridge pretty easily:

[:div {:class "fixed bottom-m right-m"}
 [:button {:class "br-100 h-l w-l pa-0"}
   (raw `<svg width="1.5em" height="1.5em" viewBox="0 0 16 16" class="bi bi-pen" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
           <path fill-rule="evenodd" d="M5.707 13.707a1 1 0 0 1-.39.242l-3 1a1 1 0 0 1-1.266-1.265l1-3a1 1 0 0 1 .242-.391L10.086 2.5a2 2 0 0 1 2.828 0l.586.586a2 2 0 0 1 0 2.828l-7.793 7.793zM3 11l7.793-7.793a1 1 0 0 1 1.414 0l.586.586a1 1 0 0 1 0 1.414L5 13l-3 1 1-3z"/>
           <path fill-rule="evenodd" d="M9.854 2.56a.5.5 0 0 0-.708 0L5.854 5.855a.5.5 0 0 1-.708-.708L8.44 1.854a1.5 1.5 0 0 1 2.122 0l.293.292a.5.5 0 0 1-.707.708l-.293-.293z"/>
           <path d="M13.293 1.207a1 1 0 0 1 1.414 0l.03.03a1 1 0 0 1 .03 1.383L13.5 4 12 2.5l1.293-1.293z"/>
         </svg>`)]]]]))

This svg is coming from bootstrap icons btw, here are the changes I made to get this button looking decent:

Here’s what the atomic css classes mean:

The last thing I did was change bootstrap icons default of 1em to 1.5em to make the icon a little bigger.

Here’s how it looks:

a twitter clone with a twitter feed and a round blue button with a pen icon

Now to make it do something, alpine and htmx to the rescue.

New post button does something

Alright, let’s add alpinejs and htmx to the project:

cd public
wget https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine.min.js

The first thing that sucks is that twitter uses a modal, so unfortunately, we need a modal. Here’s how I did that:

; # add alpine to the layout
[:head
  ...
  [:script {:src "/alpine.min.js" :defer ""}]]

Change the body from this:

[:body body]

to this:

[:body {:x-data "{ modal: false }"
        :x-on:keydown.escape.window "modal = false"}
 [:div {:x-show "modal"
        :x-on:click.prevent "modal = false"
        :class "fixed fill bg-inverse o-75"}]

 body

 [:div {:class "fixed bottom-m right-m"}
   [:button {:x-on:click "modal = true"
             :class "br-100 h-l w-l pa-0"}
     (raw `<plus button svg code here>`)]]

 [:div {:x-show "modal"
        :class "relative px-s"}
   [:div {:class "fixed left-m right-m top-m bg-background br-xs z-2 mw-3xl mx-auto"}
    [:div {:x-data "{ body: '' }"}
     (form-with req (action-for :posts/create)
      [:vstack {:spacing "xs" :class "pa-s"}
        [:textarea {:rows 7
                    :name "body"
                    :autofocus ""
                    :x-model "body"
                    :x-ref "textarea"
                    :class "b--none w-100 bs--none bg-background focus:bs--none pa-s"
                    :placeholder "What's happening?"}]
        [:hstack {:spacing "m"}
         [:button {:type "submit" :x-bind:disabled "body.length === 0" :stretch ""}
          "Post"]
         [:div {:class "w-m" :x-text "body.length"}]]])]]]]

Wow that’s a mouthful and it’s not even really done yet because we still have to add the @ name searching in the textbox. That might be a whole post in itself.

One thing that also needs to happen here is we need to generate the posts controller:

joy generate controller post

Now :posts/create should exist.

I’m just going to do a quick run down with what’s going on here.

The first thing to notice is the body

[:body {:x-data "{ modal: false }"
        :x-on:keydown.escape.window "modal = false"}]

This has two alpine attributes on it, x-data and x-on.keydown.escape.window the first one sets up the “initial state” for all of the elements that descend from the body, the second one closes the modal when you hit the escape key from any element, focus doesn’t matter because of the window modifier.

The next thing to notice is the element right below the body

[:div {:x-show "modal"
       :x-on:click.prevent "modal = false"
       :class "fixed fill bg-inverse o-75"}]

This is the modal’s backdrop and it sets the modal to false when clicked, the .prevent modifier is kind of unecessary since it’s not an anchor tag or anything and the :x-show attribute does what it says, when modal evaluates to true, it shows the div. The classes there translate to:

{
  position: fixed; /* fixed */
  /* fill */
  top: 0;
  bottom: 0;
  right: 0;
  left: 0;

  /* bg-inverse */
  background-color: var(--background-inverse); /* this depends on which ridge.css theme you're using */

  /* o-75 */
  opacity: 0.75;
}

Next is the button change:

[:div {:class "fixed bottom-m right-m"}
  [:button {:x-on:click "modal = true"
            :class "br-100 h-l w-l pa-0"}
    (raw `<plus button svg code here>`)]]

All that changed here is the alpine event handler (or trigger, if you like), :x-on:click "modal = true". It’s pretty self-explanatory, you could also use the @click syntax instead of x-on:click if you like.

Next is what shows up in front of the backdrop:

[:div {:x-show "modal"
       :class "relative px-s"}
  [:div {:class "fixed left-m right-m top-m bg-background br-xs z-2 mw-3xl mx-auto"}
   [:div {:x-data "{ body: '' }"}
    (form-with req (action-for :posts/create)
     [:vstack {:spacing "xs" :class "pa-s"}
       [:textarea {:rows 7
                   :name "body"
                   :autofocus ""
                   :x-model "body"
                   :x-ref "textarea"
                   :class "b--none w-100 bs--none bg-background focus:bs--none pa-s"
                   :placeholder "What's happening?"}]
       [:hstack {:spacing "m"}
        [:button {:type "submit" :x-bind:disabled "body.length === 0" :stretch ""}
         "Post"]
        [:div {:class "w-m" :x-text "body.length"}]]])]]]]

Alright so this needs a breakdown of it’s own, the first part is the modal itself:

[:div {:x-show "modal"
       :class "relative px-s"}
  [:div {:class "fixed left-m right-m top-m bg-background br-xs z-2 mw-3xl mx-auto"}]]

I don’t really want to go over the classes too much, mw is max-width and mx-auto is margin-left: auto; margin-right: auto, the other ones are hopefully self explanatory. Hopefully. So this modal is shown when the modal variable evaluates to true. Next up is the form inside of the modal

[:div {:x-data "{ body: '' }"}
 (form-with req (action-for :posts/create)
  [:vstack {:spacing "xs" :class "pa-s"}
    [:textarea {:rows 7
                :name "body"
                :autofocus ""
                :x-model "body"
                :class "b--none w-100 bs--none bg-background focus:bs--none pa-s"
                :placeholder "What's happening?"}]
    [:hstack {:spacing "m"}
     [:button {:type "submit" :x-bind:disabled "body.length === 0" :stretch ""}
      "Post"]
     [:div {:class "w-m" :x-text "body.length"}]]])]]]]

So here there’s a new “scope” or “component” if you like. You can tell because there’s an x-data attribute on the div above the form. Then in the textarea, there’s a new attribute: x-model.

x-model keeps the text typed in the textarea in sync with the body variable from x-data, so called “two way binding”.

This allows us to keep track of the text in alpine and use x-bind:disabled to disable the button when there is no text and also report the character count in x-text in that last div.

This is kind of getting involved, but I soldier on. Next time I’ll clone an easier web app. Part 6 is going to show how to get the @ searching working, it’s not going to be the prettiest but it will work.