Curating Posts

Posts are looking pretty snazzy now, but we still don’t have a way of easily finding posts a user might want to see. Discoverability of new content is integral to social media platforms, so we should really add this.

The first discoverability feature we’ll address is tags. We implemented the Markdown for tags earlier—now let’s add the queries and page to browse posts by tag.

Tags

We’re going to use a PostgreSQL regex condition on our query, but we don’t want to require knowledge of PostgreSQL specifics in our guestbook.messages namespace. So we’ll use Clojure expressions in HugSQL to generate our regex inside our query.

First, let’s create a namespace guestbook.db.util and write a helper function there:

 (​ns​ guestbook.db.util
  (:require [clojure.string :as string]))
 
 (​defn​ tag-regex [tag]
  (when-not (re-matches #​"[-w]+"​ tag)
  (​throw​ (​ex-info​ ​"Tag must only contain alphanumeric characters!"
  {:tag tag})))
  (str ​"'.*(\s|^)#"
  tag
 "(\s|$).*'"​))

Note that it’s sanitizing our input. This is important when using Clojure expressions to inject generated strings because HugSQL has no way of knowing which parts of our returned SQL are meant to be escaped.

Now let’s write a simple query using this helper function:

 -- :name get-feed-for-tag :? :*
 -- :require [guestbook.db.util :refer [tag-regex]]
 -- Given a tag, return its feed
 select​ * ​from
 (​select​ ​distinct​ ​on​ (p.id) * ​from​ posts_and_boosts ​as​ p
 where
 /*~ (if (:tag params) */
  p.message ~*
 /*~*/
 false
 /*~ ) ~*/
 --~ (when (:tag params) (tag-regex (:tag params)))
 order​ ​by​ p.id, posted_at ​desc​) ​as​ t
 order​ ​by​ t.posted_at ​asc

To use our helper function, we need to require the new guestbook.db.util namespace from guestbook.db.core.

 (​ns​ guestbook.db.core
  (:require
 ;;...
  [guestbook.db.util])
 ;;...
  )

Great, now let’s try it out from the REPL:

 guestbook.db.core> (​conman/bind-connection​ *db* ​"sql/queries.sql"​)
 ;​;...
 guestbook.db.core> (​get-feed-for-tag​ {:tag ​"bar"​})
 ({:message ​"This is a tagged post #foo #bar #baz"
 ;;...
  }
  {:message ​"This is another tagged post #bar"
 ;;...
  })
 guestbook.db.core>

Perfect, we’re getting tagged posts correctly! Let’s add a wrapper function and a service API endpoint to get our posts by tag:

 (​defn​ get-feed-for-tag [tag]
  {:messages
  (​db/get-feed-for-tag​ {:tag tag})})
 ;; in "/api/messages"
 [​"/tagged/:tag"
  {:get
  {:parameters {:path {:tag string?}}
  :responses
  {200
  {:body ​;; Data Spec for response body
  {:messages
  [{:id pos-int?
  :name string?
  :message string?
  :timestamp inst?
  :author (​ds/maybe​ string?)
  :avatar (​ds/maybe​ string?)}]}}}
  :handler
  (​fn​ [{{{:keys [tag]} :path
  {:keys [boosts]
  :or {boosts true}} :query} :parameters}]
  (​if​ boosts
  (​response/ok
  (​msg/get-feed-for-tag​ tag))
  (​response/not-implemented​ {:message ​"Tags only support boosts."​})))}}]

Then let’s test it out in Swagger:

images/screenshots/tag-swagger.png

Looks good. Now let’s write our tag page based on our author page:

 (​ns​ guestbook.views.tag
  (:require
  [re-frame.core :as rf]
  [guestbook.messages :as messages]))
 
 (​def​ tag-controllers
  [{:parameters {:path [:tag]}
  :start (​fn​ [{{:keys [tag]} :path}]
  (​rf/dispatch​ [:messages/load-by-tag tag]))}])
 
 (​defn​ tag [_]
  (​let​ [messages (​rf/subscribe​ [:messages/list])]
  (​fn​ [{{{:keys [tag]} :path
  {:keys [post]} :query} :parameters}]
  [:div.content
  [:div.columns.is-centered>div.column.is-two-thirds
  [:div.columns>div.column
  [:h3 (str ​"Posts tagged #"​ tag)]
  (​if​ @(​rf/subscribe​ [:messages/loading?])
  [messages/message-list-placeholder]
  [messages/message-list messages post])]]])))

We have the same core component as the author page but without the author-specific pieces. Let’s write the supporting code for this page and then connect it to our routes.

 (​rf/reg-event-fx
  :messages/load-by-tag
  (​fn​ [{:keys [db]} [_ tag]]
  {:db (assoc db
  :messages/loading? true
  :messages/filter
  {:message #(re-find
  (re-pattern (str ​"(?<=\s|^)#"​ tag ​"(?=\s|$)"​))
  %)}
  :messages/list nil)
  :ajax/get {:url (str ​"/api/messages/tagged/"​ tag)
  :success-path [:messages]
  :success-event [:messages/set]}}))
 ;; require [guestbook.views.tag :as tag] in :cljs
 [​"/tag/:tag"
  (merge
  {:name ::tag}
  #?(:cljs {:parameters {:query {(​ds/opt​ :post) pos-int?}
  :path {:tag string?}}
  :controllers tag/tag-controllers
  :view #​'tag/tag​}))]

Our :messages/load-by-tag is similar to our :messages/load-by-author; we’ve just changed two things. We’ve switched the :url in the :ajax/get effect to the one we just created, and we’ve changed the :messages/filter map to filter messages with a regular expression.

Let’s try it out by clicking one of our tag links:

images/screenshots/tags-page.png

Great! Now we can view all posts with a specific tag.

We have the ability to view posts for a single tag and a single author. Next, let’s add the ability for users to subscribe to authors or tags.

Personalized Feed

One of the core features of most social media is a personal curated feed, with posts relevant to them. Let’s add the ability for users to subscribe/unsubscribe to users and/or tags and view a personalized feed based on their subscriptions.

First, let’s write a query that takes a vector of users and a vector of tags and returns a feed.

 -- :name get-feed :? :*
 -- :require [guestbook.db.util :refer [tags-regex]]
 -- Given a vector of follows and a vector of tags, return a feed
 select​ * ​from
 (​select​ ​distinct​ ​on​ (p.id) * ​from​ posts_and_boosts ​as​ p
 where
 /*~ (if (seq (:follows params)) */
  p.poster ​in​ (:v*:follows)
 /*~*/
 false
 /*~ ) ~*/
 or
 /*~ (if (seq (:tags params)) */
  p.message ~*
 /*~*/
 false
 /*~ ) ~*/
 --~ (when (seq (:tags params)) (tags-regex (:tags params)))
 order​ ​by​ p.id, posted_at ​desc​) ​as​ t
 order​ ​by​ t.posted_at ​asc

We need to write another helper function similar to tag-regex that takes a vector of tags instead of just one.

 (​defn​ tags-regex [tags-raw]
  (​let​ [tags (filter #(re-matches #​"[-w]+"​ %) tags-raw)]
  (when (​not-empty​ tags)
  (str ​"'.*(\s|^)#("
  (​string/join​ ​"|"​ tags)
 ")(\s|$).*'"​))))

Now we’re able to get a feed based on a map of :follows and :tags, and we can leave one or the other empty.

Next, we need to get this subscriptions map from somewhere. Let’s add subscriptions to a user’s :profile JSONB. We’ll reuse our /api/my-account/set-profile endpoint, so we just need to add some utilities for working with the :subscriptions field on the :profile map.

Create a new ClojureScript namespace called guestbook.subscriptions and add a subscribe-button component:

 (​ns​ guestbook.subscriptions
  (:require [re-frame.core :as rf]))
 
 (​rf/reg-event-fx
  :subscription/set
  (​fn​ [{:keys [db]} [_ subs-type sub subscribe?]]
  (​let​ [profile (​update-in
  (​get-in​ db [:auth/user :profile])
  [:subscriptions subs-type]
  (​fnil
  (​if​ subscribe?
  #(conj % sub)
  (partial filterv (partial not= sub)))
  []))]
  {:db (assoc db ::loading? true)
  :ajax/post
  {:url ​"/api/my-account/set-profile"
  :params {:profile profile}
  :success-event [:subscription/handle profile]}})))
 
 (​rf/reg-event-db
  :subscription/handle
  (​fn​ [db [_ profile]]
  (-> db
  (​assoc-in​ [:auth/user :profile] profile)
  (dissoc
  ::loading?))))
 
 (​rf/reg-sub
  :subscription/subscribed?
  :<-[:auth/user]
  (​fn​ [{:keys [profile]} [_ subs-type sub]]
  (boolean
  (some
  (partial = sub)
  (​get-in​ profile [:subscriptions subs-type] [])))))
 
 (​rf/reg-sub
  :subscription/loading?
  (​fn​ [db _]
  (::loading? db)))
 
 (​defn​ subscribe-button [subs-type sub]
  (​let​ [subscribed? @(​rf/subscribe​ [:subscription/subscribed? subs-type sub])
  loading? @(​rf/subscribe​ [:subscription/loading?])]
  (​case​ @(​rf/subscribe​ [:auth/user-state])
  :authenticated
  [:button.button.is-primary.is-rounded
  {:class (when subscribed? ​"is-outlined"​)
  :on-click #(​rf/dispatch
  [:subscription/set subs-type sub (not subscribed?)])
  :disabled loading?}
  (​if​ subscribed?
 "Unfollow "
 "Follow "​)
  (str
  (​case​ subs-type
  :follows ​"@"
  :tags ​"#"
 ""​)
  sub)]
 ;;ELSE
  [:p ​"Log in to personalize your feed."​])))

Our :subscription/set event is the core of our logic. Unlike our profile page where we have a bunch of local changes, we’re doing a single change at a time. The subs-type parameter specifies whether we’re working with a tag or a user, sub is the value itself, and subscribe? is whether we’re adding or removing the subscription. Based on these parameters, we modify the profile we have and immediately send it to set-profile, and set a ::loading? flag to ensure that we don’t send conflicting updates simultaneously. The rest of the namespace is fairly straightforward.

Now let’s include the subscribe-button in our tag page and our author page:

 [:h3 (str ​"Posts tagged #"​ tag)]
 [sub/subscribe-button :tags tag]
images/screenshots/follow-tag.png
 [:h3 ​"Posts by "​ display-name ​" <@"​ user ​">"​]
 [sub/subscribe-button :follows user]
images/screenshots/follow-user.png

Great! We have the ability to manage subscriptions for our feed.

Now we just need to add a page to render it. Let’s start with a new namespace called guestbook.views.feed:

 (​ns​ guestbook.views.feed
  (:require
  [guestbook.auth :as auth]
  [guestbook.messages :as messages]
  [re-frame.core :as rf]))
 
 (​def​ feed-controllers
  [{:identity #(​js/Date.​)
  :start (​fn​ [_]
  (​rf/dispatch​ [:messages/load-feed]))}])
 
 (​defn​ feed [_]
  (​let​ [messages (​rf/subscribe​ [:messages/list])]
  (​fn​ [{{{:keys [post]} :query} :parameters}]
  [:div.content
  [:div.columns.is-centered>div.column.is-two-thirds
 
  (​case​ @(​rf/subscribe​ [:auth/user-state])
  :loading
  [:div.columns>div.column {:style {:width ​"5em"​}}
  [:progress.progress.is-dark.is-small {:max 100} ​"30%"​]]
 
  :authenticated
  [:<>
  [:div.columns>div.column
  [:h3 (str ​"My Feed"​)]
  (​if​ @(​rf/subscribe​ [:messages/loading?])
  [messages/message-list-placeholder]
  [messages/message-list messages post])]
  [:div.columns>div.column
  [messages/message-form]]]
 
  :anonymous
  [:div.columns>div.column
  [:div.notification.is-clearfix
  [:span
 "Log in or create an account to curate a personalized feed!"​]
  [:div.buttons.is-pulled-right
  [auth/login-button]
  [auth/register-button]]]])]])))

This is similar to our other message-list-based pages, but we have the added concern that the feed is dependent on the current user’s session rather than parameters. To manage this, we’ve expanded the scope of our :auth/user-state case statement, and we’ve added an :identity function to our controller that changes with every reload. This ensures that we never get stuck with an old list of messages.

Before this page will work, we need to connect a service endpoint to the query we wrote earlier, and we need to write the :messages/load-feed event to call it.

Let’s start with our endpoint:

 (​defn​ get-feed [feed-map]
  (when-not (every? #(re-matches #​"[-w]+"​ %) (:tags feed-map))
  (​throw
  (​ex-info
 "Tags must only contain alphanumeric characters, dashes, or underscores!"
  feed-map)))
  {:messages
  (​db/get-feed​ (merge {:follows []
  :tags []}
  feed-map))})
 ;; in "/api/messages"
 [​"/feed"
  {::auth/roles (​auth/roles​ :messages/feed)
  :get
  {:responses
  {200
  {:body ​;; Data Spec for response body
  {:messages
  [{:id pos-int?
  :name string?
  :message string?
  :timestamp inst?
  :author (​ds/maybe​ string?)
  :avatar (​ds/maybe​ string?)}]}}}
  :handler
  (​fn​ [{{{:keys [boosts]
  :or {boosts true}} :query} :parameters
  {{{:keys [subscriptions]} :profile} :identity} :session}]
  (​if​ boosts
  (​response/ok
  (​msg/get-feed​ subscriptions))
  (​response/not-implemented​ {:message ​"Feed only supports boosts."​})))}}]

Note that we require an identity to be present on the request map, so we should add a new auth entry as well:

 ;; Inside `roles`
 :messages/feed #{:authenticated}

Now let’s write our re-frame event:

 (​rf/reg-event-fx
  :messages/load-feed
  (​fn​ [{:keys [db]} _]
  (​let​ [{:keys [follows tags]}
  (​get-in​ db [:auth/user :profile :subscriptions])]
  {:db (assoc db
  :messages/loading? true
  :messages/list nil
  :messages/filter
  [{:message
  #(some
  (​fn​ [tag]
  (re-find
  (re-pattern (str ​"(?<=\s|^)#"​ tag ​"(?=\s|$)"​))
  %))
  tags)}
 
 
  {:poster
  #(some
  (partial = %)
  follows)}])
  :ajax/get {:url ​"/api/messages/feed"
  :success-path [:messages]
  :success-event [:messages/set]}})))
 (​defn​ add-message? [filter-map msg]
  (every?
  (​fn​ [[k matcher]]
  (​let​ [v (get msg k)]
  (​cond
  (​set?​ matcher)
  (​matcher​ v)
  (​fn?​ matcher)
  (​matcher​ v)
  :else
  (= matcher v))))
  filter-map))
 
 (​rf/reg-event-db
  :message/add
  (​fn​ [db [_ message]]
  (​let​ [msg-filter (:messages/filter db)
  filters (​if​ (map? msg-filter)
  [msg-filter]
  msg-filter)]
  (​if​ (some #(​add-message?​ % message) filters)
  (​update​ db :messages/list conj message)
  db))))

Notice that we have to update our :message/filter logic to account for multiple ways of matching. We’ve simply allowed for a vector of maps in place of a single map. This allows us to accomodate the feed filter without having to change our older, simpler filters.

Finally, let’s add our feed page to our routes and our navbar:

 ;; require [guestbook.views.feed :as feed] in :cljs
 [​"/feed"
  (merge
  {:name ::feed}
  #?(:cljs {:parameters {:query {(​ds/opt​ :post) pos-int?}}
  :controllers feed/feed-controllers
  :view #​'feed/feed​}))]
 ;; In navbar
 (when (= @(​rf/subscribe​ [:auth/user-state]) :authenticated)
  [:<>
  [:a.navbar-item
  {:href (​rtfe/href​ :guestbook.routes.app/author
  {:user (:login @(​rf/subscribe​ [:auth/user]))})}
 "My Posts"​]
  [:a.navbar-item
  {:href (​rtfe/href​ :guestbook.routes.app/feed)}
 "My Feed"​]])

Let’s try it out.

images/screenshots/feed-page.png

Great! We have a personalized feed. Aside from enriching some features and tweaking style, we have a fully functional and feature-complete social media application! Congratulations!

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.138.116.20