Twotm8 (p.5): Building the frontend

scalascala3scala-jsfly.ionginx-unitlaminarseries:twotm8

Series TL;DR


Meta note - this post is huge, as we're building the entire frontend from start to finish. Please use the Table of contents above and the navigation on the side.

Building apps with Scala.js

Of course we can easily sell our app as a "social network for actual professionals" and force everybody to just use the JSON APIs we've built, without any frontend.

But the road we've taken here wasn't paved with easy decisions and comfortable turns - so let's not stop so close to the coveted title of "Web 2.0 app", and build the frontend in the form of a Single-page application.

To achieve this, we will be using the following libraries:

  • Laminar as our main UI library

  • Waypoint as the library to handle the routing in our SPA

  • ScalaCSS to really push our usage of Scala to unhealthy limits

My choice of these libraries is dictated by familiarity, as I've built a few toy apps with Laminar, but also my susceptibility to hype, which put me on the Laminar path after reading the author's article.

To add both Scala.js and the libraries to our build, we need to first add the Scala.js plugin:

project/plugins.sbt

addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.9.0")

and create a frontend module in the build:

build.sbt

val Versions = new {
  val Scala = "3.1.1"
  val upickle = "1.5.0"
  val Laminar = "0.14.2"
  val scalajsDom = "2.1.0"
  val waypoint = "0.5.0"
  val scalacss = "1.0.0"
}

lazy val frontend =
  project
    .in(file("frontend"))
    .enablePlugins(ScalaJSPlugin)
    .settings(
      scalaJSUseMainModuleInitializer := true,
      scalaVersion := Versions.Scala,
      libraryDependencies ++= Seq(
        "com.raquo" %%% "laminar" % Versions.Laminar,
        "org.scala-js" %%% "scalajs-dom" % Versions.scalajsDom,
        "com.raquo" %%% "waypoint" % Versions.waypoint,
        "com.lihaoyi" %%% "upickle" % Versions.upickle,
        "com.github.japgolly.scalacss" %%% "core" % Versions.scalacss
      )
    )

Note the two extra libraries we added:

  • Scalajs-dom - this library provides statically typed interfaces to DOM utilities, such as Fetch, localStorage, etc.

    After experimenting for a bit with using Sttp and Http4s-dom as the browser HTTP client, I've decided to see what it's like to use Fetch directly (spoiler: it's not bad actually).

  • Upickle - we're using it on the backend to encode objects to JSON, so why not use it on the client as well.

While I believe that for large scale applications the shared codebase between the frontend and backend Scala modules is a very strong proposition (see my template), in this project I wanted to develop the frontend and backend in isolation instead. In the end this made the code just that bit more unsafe, but it still came out usable for the size of this app

I have next to 0 experience building meaningful frontend applications, so please evaluate everything I say here against your own experience and general best practices.

Additionally, I used this project to experiment with less familiar Scala and Scala.js features, so don't consider this to be the final word in how I think applications must be written.

Interacting with backend

Code

As mentioned before, we will be using built-in Fetch API to execute HTTP requests to our backend. Gladly, our API is quite simple and fully JSON based, not involving and other encodings, media files, or things like WebSockets.

Because we're not using the approach where the API models are placed in a shared cross-compiled module (for no reason whatsoever, that approach works really well!), we will need to re-define some of the models.

To save on writing JSON codecs, we will once again use uPickle to define serialisation logic. It is at this point that I get really excited about cross-built libraries - one less thing to learn from scratch, code is exactly same on backend and frontend.

With a bit of foresight, we will define the following two generic functions:

def fromJson[T: Reader](a: js.Any): T = upickle.default.read[T](JSON.stringify(a))
def toJsonString[T: Writer](t: T): String = upickle.default.write(t)

The Fetch API returns JSON payloads as js.Any (in Scala types), and when sending a body it accepts payloads of various types, but String is the simplest one.

While our existing API can support a much richer interface (in particular with regards to profiles and followers), our app will be somewhat limited. Those limitations will dictate which parts of the API responses we will explicitly model:

import upickle.default.{Reader, Writer, macroW, macroR}

object Responses:
  case class ThoughtLeaderProfile(
      id: String,
      nickname: String,
      followers: List[String],
      twots: Vector[Twot]
  )
  object ThoughtLeaderProfile:
    given Reader[ThoughtLeaderProfile] = macroR[ThoughtLeaderProfile]

  case class Twot(
      id: String,
      author: String,
      authorNickname: String,
      content: String,
      uwotm8Count: Int,
      uwotm8: Boolean
  )
  object Twot:
    given Reader[Twot] = macroR[Twot]

  case class TokenResponse(jwt: String, expiresIn: Long)
  object TokenResponse:
    given Reader[TokenResponse] = upickle.default.macroR[TokenResponse]
end Responses

Note that

  • we're placing JSON readers directly into companion objects - it's the place that minimises imports and maximises ergonomics

  • we are only defining Readers for the responses - at no point should we have to serialise them, and we would like to enforce it in the type system

  • the definitions don't fully match the ones on the backend - because we're only reading what we will need

The payloads we will be sending to the backend get exactly the same treatment, but this time we only define Writers, as we never expect to read them from anywhere:

object Payloads:

  case class Register(nickname: String, password: String)
  object Register:
    given Writer[Register] = macroW[Register]

  case class Login(nickname: String, password: String)
  object Login:
    given Writer[Login] = macroW[Login]

  case class Create(text: String)
  object Create:
    given Writer[Create] = macroW[Create]

  case class Uwotm8(twot_id: String)
  object Uwotm8:
    given Writer[Uwotm8] = macroW[Uwotm8]

  case class Follow(thought_leader: String)
  object Follow:
    given Writer[Follow] = macroW[Follow]

end Payloads

With these definitions, we could've used the fetch method like this:

import org.scalajs.dom.Fetch.fetch
import org.scalajs.dom.*
import js.Thenable.Implicits.*
import scala.concurrent.ExecutionContext.Implicits.global

def login(
    payload: Payloads.Login
): Future[Either[String, Responses.TokenResponse]] =
  val req = new RequestInit:
    override val method = HttpMethod.POST
    override val body = toJsonString(payload)

  fetch(s"/api/auth/login", req)
    .flatMap { resp =>
      if resp.status == 200 then
        resp.json().map(fromJson[Responses.TokenResponse]).map(Right.apply)
      else resp.text().map(txt => Left(txt))

    }
end login

And this would work well, but after I wrote the API client, I quickly realised that with the way my app is deployed, I would need to prepare for the possibility of a

  • cold process start up - in which case NGINX Unit will return 503 until the app is ready
  • fatal exception (i.e. postgres is gone) and process restart - which will result in a 500 response and the process being restarted

This and general variation in the speed of requests (after all, there's not any users and I'm using Fly.io's free tier) made me realise I need to formalise the handling of slow requests and retries.

API Stability and retries

Code

To handle retries, we will define a general method called exponentialFetch which will mimic fetch's interface, but will handle two important aspects:

  • timing out slow requests
  • optionally retrying request, with exponential back off

The parameters for the retries will be defined in a class Stability, with some sane (for this app) defaults:

import scala.concurrent.duration.*

case class Stability(
    initialDelay: FiniteDuration = 100.millis,
    timeout: FiniteDuration = 500.millis,
    delay: FiniteDuration = 100.millis,
    maxRetries: Int = 5,
    retryCodes: Set[Int] = Set(503, 500)
)

Note that adding 500 to a list of retryable responses is dangerous, as a catastrophic failure on the backend can be misinterpreted by the frontend as a retryable error, and a flurry of requests can make this worse.

We mitigate this by spacing out requests exponentially (we will be using exponents of 2) and having a very small number of retries. That said, it might pay off better to lock down the error states in the backend a bit better and use 503 everywhere, and reserve 500 for non-retryable failures.

Our main method will have this signature:

api.stability.scala

def exponentialFetch(
    info: RequestInfo,
    req: RequestInit,
    forceRetry: Boolean = false
)(using stability: Stability): Future[Response]

We're adding forceRetry parameter to allow the users to circumvent an important internal default - we won't be retrying POST requests, in line with HTTP semantics.

Let's implement this method gradually.

At the heart of this method will be the following method, which we will call recursively:

import scalajs.js.Promise
type Result = Either[String, Response]

def go(attemptsRemaining: Int): Promise[Result] = // ...

You can see that we're defining a new type, Result, to indicate a potential failure (be it a time out or something else).

Before we implement the go function, here's how it will be invoked in exponentialFetch:

go(maxRetries).flatMap {
  case Left(err) =>
    Promise.reject(s"Request to $info failed after all retries: $err")
  case Right(value) =>
    Promise.resolve(value)
}

Let's move on to the go method. The algorithm is simple:

  1. Race two promises, one that sleeps for the timeout time (specified by Stability), and one is the actual HTTP request
  2. If timeout won AND this request is retryable, then sleep for the delay amount of time and invoke go(attemptsRemaining - 1)
  3. If the HTTP request promise won, check its status code:
    • if it's one of the retryCodes and the request is retryable, then sleep for delay amount of time and invoke go(attemptsRemaining - 1)
    • if not, return the result

And if there's no more attempts remaining, we will just report failure through Promise.reject channel.

Let's define a few helpers:

def sleep(delay: FiniteDuration): Promise[Unit] =
  Promise.apply((resolve, reject) =>
    dom.window.setTimeout(() => resolve(()), delay.toMillis)
  )

This defines a promise that will finish successfully after a specified amount of time.

def reqPromise: Promise[Result] =
  fetch(info, req).`then`(resp => Right(resp))

This executes the actual HTTP request, and wraps the result in Right(..). Note that it's defined as a function, as we want to delay the actual network request until we need it.

val retryable =
  forceRetry || req.method.getOrElse(HttpMethod.GET) != HttpMethod.POST

Unless forced, we retry all non-POST requests.

val nAttempt = maxRetries - attemptsRemaining

val newDelay: FiniteDuration =
  if nAttempt == 0 then initialDelay
  else (Math.pow(2.0, nAttempt) * delay.toMillis).millis

if nAttempt != 0 then
  dom.console.log(
    s"Request to $info will be retried, $attemptsRemaining remaining, with delay $newDelay",
    new Date()
  )

This is the calculation of a delay for each attempt, plus some helpful logging.

With those definitions, the rest of go follows our algorithm above:

if (attemptsRemaining == 0) then Promise.resolve(Left("no attempts left"))
else
  Promise
    .race(js.Array(reqPromise, sleep(timeout).`then`(_ => Left("timeout"))))
    .`then` {
      case Left(reason) =>
        // timeout won
        if retryable then
          sleep(newDelay).`then`(_ => go(attemptsRemaining - 1))
        else
          Promise.reject(
            s"Cannot retry the request to $info, reason: $reason"
          )

      case r @ Right(res) =>
        // HTTP request won
        if retryable && retryCodes.contains(res.status) then
          sleep(newDelay).`then`(_ => go(attemptsRemaining - 1))
        else Promise.resolve(r)
    }
end if

Requests

Code

Now we can actually define the HTTP requests for our API interactions.

We will put them into a ApiClient class:

class ApiClient(using Stability):
  // ...

object ApiClient extends ApiClient(using Stability())

We're also providing a stateless instance of it with the default parameters.

As guest

These include login and register:

def login(
    payload: Payloads.Login
): Future[Either[String, Responses.TokenResponse]] =
  val req = new RequestInit:
    override val method = HttpMethod.POST
    override val body = toJsonString(payload)

  exponentialFetch(s"/api/auth/login", req, forceRetry = true)
    .flatMap { resp =>
      if resp.ok then
        resp.json().map(fromJson[Responses.TokenResponse]).map(Right.apply)
      else resp.text().map(txt => Left(txt))

    }
end login

def register(payload: Payloads.Register): Future[Option[String]] =
  val req = new RequestInit:
    override val method = HttpMethod.PUT
    override val body = toJsonString(payload)

  exponentialFetch(s"/api/auth/register", req)
    .flatMap { resp =>
      if resp.ok then Future.successful(None)
      else resp.text().map(txt => Some(txt))

    }
end register

Note that we are forcing retries on login, because by default POST requests won't be retried (as it's not safe in a general sense).

In both cases we collect the response as plaintext in case of anything other than success.

Authorized

This part of the frontend has survived the most iterations, perhaps, of anything else. The exact location where the access token and stored and how it's passed to the api - explicitly or not, has bugged me for days.

In the end I've rewritten most of the app to avoid working with the global (i.e. browser's local storage) state, but rather pass all the dependencies explicitly.

While it's not the most succint representation, it's the cleanest - and this has been the final form of most design decisions I've made.

To authenticate the user, all we need is to add the token to a Authorization header.

So, along with defining a class for it:

case class Token(value: String)

Let's define a couple of helper functions:

private def addAuth(rq: RequestInit, tok: Token) =
  rq.headers = js.Dictionary("Authorization" -> s"Bearer ${tok.value}")
  rq

private def addAuth(rq: RequestInit, tokenMaybe: Option[Token]) =
  tokenMaybe.foreach { tok =>
    rq.headers = js.Dictionary("Authorization" -> s"Bearer ${tok.value}")
  }
  rq

These just make working with RequestInit easier - it doesn't have any builder pattern method like withHeaders(..), so it's a bit cumbersome to use.

We can also define an extension method that will help handle Unauthorized responses for us:

enum Error:
  case Unauthorized

extension (req: Future[Response])
  def authenticated[A](f: Response => Future[A]): Future[Either[Error, A]] =
    req.flatMap { resp =>
      if resp.status == 401 then Future.successful(Left(Error.Unauthorized))
      else f(resp).map(Right.apply)
    }

Here's the way it can be used, for example, to retrieve current user's profile:

api.client.scala

def me(tok: Token): Future[Either[Error, ThoughtLeaderProfile]] =
  val req = new RequestInit {}
  req.method = HttpMethod.GET
  exponentialFetch("/api/thought_leaders/me", addAuth(req, tok))
    .authenticated { resp =>
      resp.json().map(fromJson[Responses.ThoughtLeaderProfile])
    }

def is_authenticated(token: Token): Future[Boolean] = 
  me(token).map(_.isRight)

And it's the same pattern for retrieving a thought leader's profile:

def get_profile(
    author: String,
    token: Option[Token]
): Future[Responses.ThoughtLeaderProfile] =
  exponentialFetch(
    s"/api/thought_leaders/$author",
    addAuth(new RequestInit {}, token)
  ).flatMap { resp =>
    resp
      .json()
      .map(fromJson[Responses.ThoughtLeaderProfile])
  }

Only difference being that this operation is available to both guests and authenticated users.

Retrieving the user's wall:

def get_wall(token: Token) =
  exponentialFetch("/api/twots/wall", addAuth(new RequestInit {}, token))
    .authenticated { resp =>
      resp.json().map(fromJson[List[Responses.Twot]])
    }

Creating twots:

def create(payload: Payloads.Create, token: Token) =
  val req = new RequestInit:
    override val method = HttpMethod.POST
    override val body = toJsonString(payload)

  exponentialFetch(s"/api/twots/create", addAuth(req, token))
    .authenticated { resp =>
      if resp.ok then Future.successful(None)
      else resp.text().map(txt => Some(txt))

    }
end create

Setting the uwotm8 reaction to a particular value - all it affects is the HTTP method that we will use:

def set_uwotm8(
    payload: Payloads.Uwotm8,
    state: Boolean,
    token: Token
) =
  val req = new RequestInit:
    override val method = if state then HttpMethod.PUT else HttpMethod.DELETE
    override val body = toJsonString(payload)

  exponentialFetch(s"/api/twots/uwotm8", addAuth(req, token))
    .authenticated { resp =>
      if resp.ok then Future.successful(None)
      else resp.text().map(txt => Some(txt))

    }
end set_uwotm8

And we do the same thing with followers:

def set_follow(
    payload: Payloads.Follow,
    state: Boolean,
    token: Token
) =
  val req = new RequestInit:
    override val method = if state then HttpMethod.PUT else HttpMethod.DELETE
    override val body = toJsonString(payload)

  exponentialFetch(s"/api/thought_leaders/follow", addAuth(req, token))
    .authenticated { resp =>
      if resp.ok then Future.successful(None)
      else resp.text().map(txt => Some(txt))

    }
end set_follow

And that's it for the functionality we elect to have in our app. Speaking of which, let's see what sort of pages and interactions we can support with this subset of the API.

SPA structure

Here are the routes we will support and their description:

  • /login - page to log in. Users already logged in should be redirected to their wall

  • /logout - page which logs the user out. Strictly it doesn't have to be a page, and can just be a function in response to an action, but it's good to give users a stable URL

  • /register - page for registration. Users already logged in should be redirected to their wall instead

  • / (index page) - this is where the user's wall is displayed - both the form to create a twot and the timeline of their twots, and from people they follow.

  • /thought_leaders/<nickname> - a particular thought leader's profile

We are going to be using Waypoint, so let's first define the hierarchy of pages as a regular Scala sealed trait. These definitions are the typesafe representation of the state of the application - we will use them for routing and rendering, instead of the URIs:

routes.scala

sealed trait Page
object Page:
  case object Wall extends Page
  case object Login extends Page
  case object Logout extends Page
  case object Register extends Page
  case class Profile(authorId: String) extends Page

Note that we are using sealed traits instead of the Scala 3's enum. This was quite the head scratcher, but the reason for it is that Scala 3's enum value cases (like Wall, Login, etc.) do not have their own classes.

They are just instances of the class created for the enum. This means that ClassTag definitions for Wall, Login, Logout, and Register will all be the same - and Waypoint uses ClassTags to refine general Page type into its constituents. To keep things working as intended, we have to resort to sealed trait hierarchy.

Principles

For SPAs to work, there must be:

  • some client-side management of the browser's location attribute (and location history) - this is what Waypoint provides
  • server-side rendering of the the same HTML page for all the routes that our frontend covers

The second part is important because we want the browser to not load the HTML page again, when we navigate from /login to /thought_leaders/techbro, but instead let the client code handle that transition.

This way the in-memory state of the app will be preserved. This is why in our config.json (for NGINX Unit), we instruct the server to faithfully serve static content if it's a JS, CSS, or HTML file, but for everything else just keep serving the index.html page:

config.json

//...
{
  "match": {
    "uri": "~^((/(.*)\\.(js|css|html))|/)$"
  },
  "action": {
    "share": "/www/static$uri"
  }
},
{
  "action": {
    "share": "/www/static/index.html"
  }
}

Assuming the page is static and the server sets the headers correctly, the browser will just use the cached version every time.

We can verify it like this:

~> curl -v https://twotm8-web.fly.dev/index.html 2>&1 | ack '^<'
< HTTP/2 200
< last-modified: Sun, 20 Mar 2022 12:37:22 GMT
< etag: "62372002-1d1"
< content-type: text/html
< server: Fly/3e293b01 (2022-03-18)
< date: Sun, 20 Mar 2022 16:37:50 GMT
< content-length: 465
< via: 2 fly.io
< fly-request-id: 01FYM3116K2MRBYQTKR32W3G2A-lhr

~> curl -v https://twotm8-web.fly.dev/login 2>&1 | ack '^<'
< HTTP/2 200
< last-modified: Sun, 20 Mar 2022 12:37:22 GMT
< etag: "62372002-1d1"
< content-type: text/html
< server: Fly/3e293b01 (2022-03-18)
< date: Sun, 20 Mar 2022 16:37:57 GMT
< content-length: 465
< via: 2 fly.io
< fly-request-id: 01FYM317PSSD3XQBVP54E90B7Z-lhr

Defining Waypoint routes

Code

When Waypoint detects a URI change (through window events), it needs to do a few things (not in this order):

  1. Match the route against the set defined for the router
  2. Serialise the current page and put it into the browser's history
  3. Parse the URI and create a Scala instance of a Page

When going back in history, serialised page has to be rehydrated back into a Scala instance.

To handle (2) we will, again, use uPickle:

routes.scala

given ReadWriter[Page.Profile] = upickle.default.macroRW[Page.Profile]
given ReadWriter[Page] = upickle.default.macroRW[Page]

The need to provide a separate codec for Page.Profile is, I believe, a bug in uPickle.

For (3), Waypoint integrates with url-dsl to give you a nice DSL for defining routes in terms of URIs.

So let's start with the simplest routes, the ones that don't have any parameters:

import com.raquo.waypoint.*

val mainRoute = Route.static(Page.Wall, root / endOfSegments)
val loginRoute = Route.static(Page.Login, root / "login")
val logoutRoute = Route.static(Page.Logout, root / "logout")
val registerRoute =
  Route.static(Page.Register, root / "register")

The user's profile route requires a bit more work, as it has parameters:

val profileRoute = Route(
  encode = (stp: Profile) => stp.authorId,
  decode = (arg: String) => Profile(arg),
  pattern = root / "thought_leaders" / segment[String] / endOfSegments
)

You can see how the definition of pattern drives the types of arguments and return value in decode and encode respectively.

The only thing left to do is to map the pages to respective titles, and create a Waypoint Router[Page]:

import com.raquo.laminar.api.L
import com.raquo.laminar.api.L.*

val router = new Router[Page](
  routes =
    List(mainRoute, loginRoute, registerRoute, profileRoute, logoutRoute),
  getPageTitle = {
    case Wall       => "Twotm8 - safe space for thought leaders"
    case Login      => "Twotm8 - login"
    case Logout     => "Twotm8 - logout"
    case Register   => "Twotm8 - register"
    case Profile(a) => s"Twotm8 - $a"
  },
  serializePage = pg => upickle.default.writeJs(pg).render(),
  deserializePage = str => upickle.default.read[Page](str)
)($popStateEvent = L.windowEvents.onPopState, owner = L.unsafeWindowOwner)

where we use the routes we defined, and delegate to upickle to read/write the page state in window history.

Magic links

The Router interface gives us pushState and replaceState functions, that allow you to navigate between pages, and absoluteUrlForPage, which will produce a serialised URI.

Simplest possible way to use them is to define a special function:

def magicLink(page: Page, text: String)(using router: Router[Page]) = 
  a(
    href := router.absoluteUrlForPage(page),
    onClick.preventDefault --> (_ => router.pushState(page)),
    text
  )

But as my knowledge of Laminar has matured, I have learned the dark art of Literally Copying From Waypoint's Documentation, which provides a much, much better, and more flexible "binder" (in Laminar terms) for navigation:

def navigateTo(page: Page)(using router: Router[Page]): Binder[HtmlElement] =
  Binder { el =>
    import org.scalajs.dom

    val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor]

    if isLinkElement then el.amend(href(router.absoluteUrlForPage(page)))

    (onClick
      .filter(ev =>
        !(isLinkElement && (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey))
      )
      .preventDefault
      --> (_ => redirectTo(page))).bind(el)
  }

Which can be used as such:

a(navigateTo(Page.Wall), "go home!")

And that's what we are going to use from here on. We will also add one more function to redirect immediately:

def redirectTo(pg: Page)(using router: Router[Page]) =
  router.pushState(pg)

Entry point

Code

The general flow for starting a Laminar application is roughly as follows:

  1. We define our app which is just a ReactiveHtmlElement
  2. We install a special hook, that fires when the DOM is fully loaded
  3. We find our future app's container in the loaded DOM
  4. We mount the Laminar app into that DOM element

The DOM element usually comes from the very minimal HTML page. One we define as such:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Twotm8 - a place for thought leaders to thought lead</title>
  </head>
  <body>
  <div id="appContainer"></div>
  <script src="/frontend.js"></script>
  </body>
</html>

Note that we have a <div> element with appContainer as ID - that's where we will mount the Laminar application. /frontend.js is the path to our Scala.js compiled application, we'll address it later.

The entrypoint from Scala.js point is very simple:

object App: 
  val app = div("hello world!")

  def main(args: Array[String]): Unit =
    documentEvents.onDomContentLoaded.foreach { _ =>
      render(dom.document.getElementById("appContainer"), app)
    }(unsafeWindowOwner)
  end main
end App

This is the pattern most applications follow. In our case we need to change it slightly in the face of routing (and, later, usage of ScalaCSS and our own app state).

Waypoint provides a special SplitRender utility, that takes a Signal of the current page (as reported by the router), and then invokes the various render functions that we've specified. In a very simplified form, our app could look like this:

def renderPage(using router: Router[Page]): Signal[HtmlElement] =
  SplitRender[Page, HtmlElement](router.$currentPage)
    .collectStatic(Page.Login)(div("login!"))
    .collectStatic(Page.Logout)(div("logout"))
    .collectStatic(Page.Wall)(div("wall"))
    .collectStatic(Page.Register)(div("register"))
    .collectSignal[Page.Profile](profileSignal =>
      div("Profile for: ", child.text <-- profileSignal.map(_.authorId))
    )
    .$view

def app(using router: Router[Page]) =
  div(
    "Welcome to Twotm8!",
    child <-- renderPage
  )

def main(args: Array[String]): Unit =
  given Router[Page] = Page.router

  documentEvents.onDomContentLoaded.foreach { _ =>
    render(dom.document.getElementById("appContainer"), app)
  }(unsafeWindowOwner)
end main

We're just following Waypoint's recommendation, where instead of pattern matching on the page type (which is the router.$currentPage Signal), we render each page representation only once, and define the reactive logic to handle just the changes of pages of same types.

I.e. a change from Profile("techbro") to Profile("techdude") shouldn't re-render the entire app - we only need to re-render the list of twots and the nickname.

Styling

Code

In the first version of the app, I did the thing I always do - slap a Bootstrap CSS file on it and make it look like any other app built by a developer with no aesthetic ability.

After looking at it for some time though, several things stood out to me as problematic:

  1. Using the combination classnames like btn btn-danger btn-outline made the code of the app a bit cumbersome, where random strings (in which I made a ton of typos) were intermixed with HTML attributes and reactive logic

  2. The pre-made styling options felt like cheating - I really wanted the full web dev experience, believe it or not, and that included writing a bit of CSS by myself.

I also wanted to style the website according to the best color on the internet which turned out to be this wonderful baby-grape diarrhea

  1. While experience for web development in something like VS Code, when you are just editing a HTML and a CSS file with live reloading, is excellent, it quickly grew out of sync with the exact structure of the Laminar app, and the level of repetition started to get ridiculous.

    Introducing something like Sass was not an option because every time I mix a tool from JS world with anything else I get burn marks on every inch of my skin.

All these were enough to yet again look to ScalaCSS, which I had good experience with during implementation of the shitty static site generator (3SG) I used for this blog.

Basic integration

This time though I wanted to see if we can integrate ScalaCSS with Laminar better.

Let's say we define a Stylesheet like this:

import scalacss.internal.StyleA
import scalacss.internal.Attrs.justifyItems

object Styles extends StyleSheet.Inline:
  import dsl.*

  private val weirdWhiteColor =
    mixin(
      fontFamily :=! "Verdana, Geneva, Tahoma, sans-serif",
      color.white,
      textShadow := "2px 2px 0px black"
    )

  val logo = style(
    fontWeight.bold,
    fontStyle.italic,
    fontSize := 3.rem,
    margin := 0.px,
    weirdWhiteColor
  )

You can see how easily we can reuse the weirdWhiteColor style.

The type of Styles.logo is StyleA, which has a htmlClass method - the classname is not meaningful, and will be assigned by ScalaCSS to all the unique styles (names like .a0, .a1, etc.).

To integrate with Laminar, all we need to do is define an implicit conversion:

import com.raquo.laminar.api.L.*

given Conversion[StyleA, Setter[HtmlElement]] with
  def apply(st: StyleA): Setter[HtmlElement] = (cls := st.htmlClass)

Which will create a Setter, that just sets the class attribute on the HTML element.

We can later use it like this:

div(
  Styles.logo,
  "Twotm8!"
)

Here we used an inline StyleSheet, which is useful for applying styles directly to some components - we don't care about the tag type or the classnames.

If we wanted a more familiar type of stylesheet, with selectors and such, we need to use a standalone Stylesheet:

object Standalone extends StyleSheet.Standalone:
  import dsl.*
  "body" - (
    backgroundColor(c"#9ba0dc"), // here's that gorgeous colour!
    fontFamily :=! "Arial, Helvetica, sans-serif"
  )

In fact I've used this stylesheet to apply the style to an element that is not managed by laminar - namely <body> that comes directly from the index.html file.

Functions

A cool feature that was useful several times was style functions.

For example, let's say I want to fully style the Follow/Following button. It depends on the actual state of following returned by the API, but it can also be hidden for guest users.

We can define the state like this:

import japgolly.univeq.UnivEq

enum FollowState derives UnivEq:
  case Yes, No, Hide

And our conditional style as such:

private val followButtonCommon =
  mixin(
    border(1.px, solid),
    fontSize(1.2.rem),
    padding(5.px)
  )

import FollowState.*

val followButton = styleF[FollowState](Domain.ofValues(Yes, No, Hide)) {
  case Yes =>
    styleS(
      backgroundColor(rgb(159, 216, 159)),
      borderColor.white,
      followButtonCommon
    )
  case No =>
    styleS(
      backgroundColor.white,
      borderColor(rgb(159, 216, 159)),
      followButtonCommon
    )
  case Hide => styleS(display.none)
}

And when we call followButton.apply(Yes).htmlClass we will get a unique classname with the correct styles applied.

For something like a boolean parameter, there's a built-in combinator:

val uwotm8NotPressed = style(fontWeight.normal)
val uwotm8Pressed = style(fontWeight.bold)

val uwotm8Button = styleF.bool {
  case false =>
    styleS(uwotm8NotPressed)
  case true =>
    styleS(uwotm8Pressed)
}

I won't put the entire stylesheet here, but you can see the full code in the repository. Needless to say, the styles are fugly as hell.

Rendering

Last piece of the puzzle was rendering the stylesheets into the document when the app is loaded.

To do this we hook up to the already familiar documentEvents.onDomContentLoaded, and when the DOM is loaded, we just add a <style> tag to the document's header:

def main(args: Array[String]): Unit =
  documentEvents.onDomContentLoaded.foreach { _ =>
    import scalacss.ProdDefaults.*

    // render styles
    val sty = styleTag(Styles.render[String])
    dom.document.querySelector("head").appendChild(sty.ref)

    // render Laminar app
    render(dom.document.getElementById("appContainer"), app)
  }(unsafeWindowOwner)
end main

App state

Code

There are certain bits of information that we need available for most pages, either for dynamic actions (like following/unfollowing), or for rendering itself (like welcome banner on the wall, or the list of twots).

Thankfully, the app is quite small, and there's two main things we need in shared memory:

  1. Current authentication token
  2. Some cached version of user's profile, namely their nickname

Additionally, whenever the authentication token changes in memory, we want to persist it in browser's local storage as well - so that people can close the browser and not lose their authentication.

After several iterations, I arrived at the following design:

  1. There's a global AppState that contains reactive containers for the token and some cached profile information

  2. The state is initialised with just the token picked up from local local storage (if it's there)

  3. Upon mounting the app, a request to /api/thought_leaders/me is made - it both verifies that the token is still working, and also picks up the information about the profile.

    That information is then cached in the AppState

This way the pages only access the in-memory view of the profile, and because we're providing a reactive access to it (by exposing just the Signal or current value), we are able to, say, set up a refresh loop which checks the auth and profile information every 30 seconds.

With those considerations in mind, our AppState looks like this:

case class CachedProfile(id: String, nickname: String)

class AppState private (
    _authToken: Var[Option[Token]],
    _profile: Var[Option[CachedProfile]]
):
  def clear() =
    _authToken.set(None)
    _profile.set(None)
    AppState.deleteToken()

  val $token = _authToken.signal
  val $profile = _profile.signal

  def token = _authToken.now()
  def profile = _profile.now()

  def setToken(token: Token) =
    _authToken.set(Some(token))
    AppState.setToken(token.value)

  def setProfile(profile: CachedProfile) =
    _profile.set(Some(profile))
end AppState

You can see we don't expose the Vars directly, which allows us to control what happens if the state needs to be cleared (if the authentication has become stale).

The missing bits are the initialisation of the state and working with the local storage:

object AppState:
  def init: AppState =
    AppState(
      _authToken = Var(getToken()),
      _profile = Var(None)
    )

  private val tokenKey = "twotm8-auth-token"

  private def getToken(): Option[Token] =
    Option(dom.window.localStorage.getItem(tokenKey)).map(Token.apply)

  private def setToken(value: String): Unit =
    Option(dom.window.localStorage.setItem(tokenKey, value))

  private def deleteToken(): Unit =
    dom.window.localStorage.removeItem(tokenKey)

end AppState

After using this pattern for a bit, I would rate it as "workable", but there's clearly some improvements more experienced UX developers can suggest.

Updating state

One bit that we are missing is the logic which re-hydrates the CachedProfile that we store in AppState.

Additionally, we want to run this process regularly, say every 30 seconds, to make sure that the user hasn't been deleted in the interim, or to pick up the changes to their nickname.

First of all, let's create a Signal that will combine both the state changes triggered by, say, login/logout, but which is also firing every 30 seconds:

def app(using state: AppState) = 
  // ...
  val tokenState: Signal[Option[Token]] =
    state.$token.composeChanges(
      EventStream.merge(
        _,
        EventStream.periodic(30 * 1000).sample(state.$token)
      )
    )

And separately we can create an observer for this signal, which will update the profile in the app state:

def app(using state: AppState) = 
  // ...
  // val tokenState = ...
  val authRefresh = Observer { (token: Option[Token]) =>
    token.foreach { tok =>
      ApiClient.me(tok).collect {
        case Left(_) =>
          state.clear()
        case Right(prof) =>
          state.setProfile(
            CachedProfile(id = prof.id, nickname = prof.nickname)
          )
      }
    }
  }

In Airstream (which powers Laminar), observables such as Signal and EventStream are lazy. We need to bind our observer authRefresh to tokenState, and for the binding to start evaluating, we will need to provide an owner.

Gladly, with Laminar we can just use the --> operator:

div(
  "My app",
  tokenState --> authRefresh
)

And as long as this element is mounted in the DOM, the refresh process will run. And the element will be in the DOM as this will be the container for the entire app.

We will use this pattern (of binding observables to observes in the context of a Laminar node) a lot, so check out Laminar's docs on this topic

Pages

Let's define the signatures for pages we've identified:

def Login(using router: Router[Page], state: AppState): HtmlElement

def Wall(using router: Router[Page], state: AppState): HtmlElement

def Register(using router: Router[Page], state: AppState): HtmlElement

def Logout(using Router[Page])(using state: AppState): HtmlElement

def Profile(page: Signal[Page.Profile])(using router: Router[Page], state: AppState): HtmlElement

The structure is the same for most pages, apart from Profile, which takes a Signal of profile data, and renders the page according to the requested profile.

Renderer

With the function signatures for each pages, we can define our unified renderer:

def renderPage(using
    router: Router[Page]
)(using AppState): Signal[HtmlElement] =
  SplitRender[Page, HtmlElement](router.$currentPage)
    .collectStatic(Page.Login)(Login)
    .collectStatic(Page.Logout)(Logout)
    .collectStatic(Page.Wall)(Wall)
    .collectStatic(Page.Register)(Register)
    .collectSignal[Page.Profile](Profile)
    .$view

Which can be used in our app container as such:

div(
  child <-- renderPage
)

Of course the auth refresh loop will go into this same div element, along with the header and any other dynamic elements we need (like showing/hiding Logout button), but we will provide the full code for the app's entry point later.

Reusable components

The more I refactored the frontend (as it's the part I knew the least about), the more I learned to appreciate the design of Laminar's Setters and Modifiers, that lends itself very nicely to component reuse.

That and my frivolous use of given instances led to what I think is mostly readable code. Most of the gaps I found were in my own understanding of Airstream's EventStreams and Signals.

For example, here are two utilities that immediately redirect to the desired page depending on auth status:

def authenticatedOnly(using router: Router[Page], state: AppState) =
  state.$token --> { tok =>
    if tok.isEmpty then redirectTo(Page.Login)
  }

def guestOnly(using router: Router[Page], state: AppState) =
  state.$token --> { tok =>
    if tok.isDefined then redirectTo(Page.Wall)
  }

// both use this function we defined earlier:
def redirectTo(pg: Page)(using router: Router[Page]) =
  router.pushState(pg)

And because they are Laminar's modifiers, we can add them to our elements quite easily:

div(
  authenticatedOnly,
  "hello",
  ...
)

Another component I found useful was this Flash:

def Flash(error: Var[Option[String]]) =
  child.maybe <-- error.signal.map {
    _.map(msg => div(Styles.errorFlash, msg))
  }

Designed to be used in components where there's some sort of validation going on.

Login

Code

The requirements for the login page interface are quite simple:

  1. Nickname and Password fields
  2. Button to send the request to the server
  3. Page should only be shown to unauthenticated users
  4. If the server responds with some error message, it should be shown to user

The main logic will be in the function LoginForm that renders the form and submits it, but once we have that definition the page looks like this:

import com.raquo.laminar.api.L.*

def Login(using router: Router[Page], state: AppState): HtmlElement =
  val error = Var[Option[String]](None)
  val input = LoginForm(error)
  val form =
    state.$token.map {
      case None      => Some(input)
      case Some(tok) => None
    }

  div(
    Styles.subContainer,
    h1("Login"),
    guestOnly,
    Flash(error),
    child.maybe <-- form,
    p(
      span(Styles.infoText, "Don't have an account yet? "),
      a(Styles.infoLink, navigateTo(Page.Register), "Register then!")
    )
  )
end Login

The only reactive bit is where we hook up to the state changes of the token in app state, and only render the login form if the token is absent.

The LoginForm is a function with this signature:

private def LoginForm(
    error: Var[Option[String]]
)(using router: Router[Page], state: AppState): HtmlElement

Let's build it gradually. The way we will work with the form is as follows:

  1. Input fields for both nickname and password will write the value into their respective reactive variables
  2. We will hook up to the onClick event on the submit button, to send the formed payload to the backend, and if authentication is successful, we'll redirect to the Wall page.

Reactive variables for nickname and password:

  val nickname = Var("")
  val password = Var("")

And we can extract the onClick action modifier:

  val sendLogin = onClick.preventDefault --> { _ =>
    ApiClient
      .login(
        Payloads.Login(nickname = nickname.now(), password = password.now())
      ) // returns Future[Either[String, TokenResponse]]
      .foreach {
        case Left(err) =>
          error.set(Some(err))
        case Right(response) =>
          val token = Token(response.jwt)
          state.setToken(token)
          redirectTo(Page.Wall)
      }
  }

This pattern of Observable[T] --> {(v: T) => ...} is just one of the ways of creating an Observer[T].

To make sure the values of nickname and password are updated, we need to hook up the Var writer to the onInput events, like this:

input(
  Styles.inputPassword,
  tpe := "password",
  idAttr := "password",
  onInput.mapToValue --> password,
  required := true
)

onInput.mapToValue --> password is the reactive part, which maps the events from onInput to the .value attribute of the <input> node and writes them to password variable.

And the way we bind the sendLogin action to a button is even simpler:

div(
  button(
    Styles.inputLabel,
    "dooooeeeeet",
    sendLogin
  )
)

And here's the full form:

  form(
    div(
      label(
        Styles.inputLabel,
        Styles.infoText,
        forId := "nickname",
        "Nickname"
      ),
      input(
        Styles.inputNickname,
        tpe := "text",
        idAttr := "nickname",
        onInput.mapToValue --> nickname,
        required := true
      )
    ),
    div(
      label(
        Styles.inputLabel,
        Styles.infoText,
        forId := "password",
        "Password"
      ),
      input(
        Styles.inputPassword,
        tpe := "password",
        idAttr := "password",
        onInput.mapToValue --> password,
        required := true
      )
    ),
    div(
      button(
        Styles.inputLabel,
        "dooooeeeeet",
        sendLogin
      )
    )
  )

Register

Code

In fact, the Register page is almost exactly the same! The only difference is some text nodes, and what we do with nickname and password.

So the page looks like this:

def Register(using router: Router[Page], state: AppState): HtmlElement =
  val error = Var[Option[String]](None)

  div(
    Styles.subContainer,
    h1("Register"),
    guestOnly,
    Flash(error),
    RegisterForm(error),
    p(
      span(Styles.infoText, "Already have an account? "),
      a(
        Styles.infoLink,
        navigateTo(Page.Login),
        "Then go to login page you silly sausage!"
      )
    )
  )
end Register

And we can just copypaste LoginForm, rename sendLogin to sendRegistration, and use this implementation, where we redirect to login page in case of successful registration:

  val sendRegistration = onClick.preventDefault --> { _ =>
    ApiClient
      .register(
        Payloads.Register(nickname = nickname.now(), password = password.now())
      )
      .foreach {
        case e @ Some(err) => error.set(e)
        case None =>
          router.replaceState(Page.Login)

      }
  }

This amount of similarity is an excellent opportunity to refactor and reuse, so let's joyously wave at the opportunity as it passes by.

Wall

Code

def Wall(using router: Router[Page], state: AppState): HtmlElement

On the user's wall, there's 3 distinct components:

  1. Welcome banner (i.e. Welcome, <nickname>)
  2. A form to submit a twot
  3. List of twots from the user themselves and the leaders they follow

The welcome banner is quite simple, as it only needs to react to the changes in the cached profile from the app's state:

val welcomeBanner: Signal[Option[HtmlElement]] = state.$profile.map {
  _.map { profile =>
    h1(
      Styles.welcomeBanner,
      "Welcome back, ",
      b(profile.nickname)
    )
  }
}

List of twots is a bit more interesting, because we want to add two requirements to it:

  1. It should be re-fetched every 10 seconds
  2. If the user submits a twot, the list has to be re-fetched immediately

The second requirement hints at some sort of interaction between the "create twot" form and the list of twots displayed on the page.

We will implement both requirements by using an EventBus:

  val bus = EventBus[Int]()

The exact type doesn't matter, because the events we will be submitting are not actually meaningful.

The periodic update part is easy, we'll just keep writing the numbers into the bus:

  val periodicUpdate = EventStream.periodic(10 * 1000) --> bus.writer

And to be able to trigger the update from somewhere else, we will just give the downstream users an opaque function:

  val reset = () => bus.emit(0)

This way the form to create a twot doesn't need to know how the update dynamics are implemented.

Finally, we will hook up to the events from the bus, sample the auth token at that moment in time, call our API for the user's wall and render it. The actual rendering of each twot will be handled by a function we're yet to define, along with a function to render the form:

def TwotCard(twot: Responses.Twot, update: () => Unit)(using Router[Page], AppState): HtmlElement
def CreateTwot(update: () => Unit)(using Router[Page], AppState): HtmlElement
  val twots: EventStream[Seq[HtmlElement]] =
    bus.events.sample(state.$token).flatMap {
      case None => EventStream.fromValue(Seq.empty)
      case Some(tok) =>
        EventStream
          .fromFuture(ApiClient.get_wall(tok))
          .collect { case Right(twots) =>
            twots.toList.map(TwotCard)
          }
    }

And with those definitions, the entire page can be put together like this:

  div(
    Styles.twots,
    authenticatedOnly,
    child.maybe <-- welcomeBanner,
    CreateTwot(reset),
    periodicUpdate,
    children <-- twots
  )

Create twot form

Code

def CreateTwot(update: () => Unit)(using Router[Page], AppState): HtmlElement

This part is simple, because we've already shown the general way of working with forms.

In this case it's simpler, as there's only one field, and same error container:

  val error = Var[Option[String]](None)
  val text = Var("")

and instead of redirecting, all we need to do is invoke the update function passed in:

  val sendTwot = onClick.preventDefault --> { _ =>
    state.token.foreach { token =>
      ApiClient
        .create(Payloads.Create(text.now()), token)
        .collect {
          case Right(e @ Some(err)) => error.set(e)
          case Right(None) =>
            update()
        }
    }
  }

The button, of course, has to say "RAGE" on it:

  div(
    Styles.subContainer,
    Flash(error),
    authenticatedOnly,
    div(
      Styles.createForm,
      div(
        Styles.twotInputBlock,
        input(
          Styles.inputTwot,
          tpe := "text",
          onInput.mapToValue --> text
        )
      ),
      div(
        Styles.rageButtonBlock,
        button(
          Styles.rageButton,
          "RAGE",
          sendTwot
        )
      )
    )
  )

Twot card

Code

def TwotCard(twot: Responses.Twot)(using Router[Page], AppState): HtmlElement =
  TwotCard(twot, () => ())

def TwotCard(twot: Responses.Twot, update: () => Unit)(using Router[Page])(using state: AppState): HtmlElement

This component serves 3 main purposes

  1. Render twot's contents with opacity controlled by the number of uwotm8 reactions

  2. Render an interactive UWOTM8 button

  3. Provide a link to the author's profile

  4. Provide a button to delete a twot, only visible to its author

Calculating opacity is simple, we just need to use some MATHS, to place the counter on a logarithmic scale:

import scalajs.js.{Math as JSMath}
val opacity =
  (1.0 - 0.2 * JSMath.log(1 + twot.uwotm8Count) / JSMath.LOG2E)

The interactive uwotm8 button has two main difficulties:

  • we want to maintain the state depending on whether the user has already reacted to the twot or not

  • when clicked we want to send the API call and flip the state

Let's keep the state in a reactive variable, initialised to the last known state (retrieved from the API):

  val current = Var(twot.uwotm8)

When the button is clicked, we take the current (boolean) value, flip it, and send the desired API request:

  val sendUwotm8 =
    onClick.preventDefault --> { _ =>
      // flip the value
      val newState = !current.now()
      state.token.foreach { token =>
        // force the new state in the API
        ApiClient
          .set_uwotm8(Payloads.Uwotm8(twot.id), newState, token)
          .collect {
            case Right(None)    => current.set(newState)
            case Right(Some(s)) => println("Just quietly fail, will ya: " + s)
          }
      }
    }

The appropriate styling can be achieved by treating the cls attribute as dynamic, and using the Style Functions we've introduced earlier:

cls <-- current.signal.map(Styles.uwotm8Button(_).htmlClass),

For deleting the twot, we need to just invoke the relevant API call and trigger a refresh:

val sendDelete =
  onClick.preventDefault --> { _ =>
    state.token.foreach { token =>
      ApiClient.delete_twot(twot.id, token).foreach { res =>
        update()
      }
    }
  }

The visibility of the button is easily controlled by the fact that we have authenticated user's id in our state.$profile signal, and the author's id in the twot.

val deleteButton =
  state.$profile.map { prof =>
    prof
      .filter(_.id == twot.author)
      .map(_ => button(Styles.deleteButton, sendDelete, "❌"))
  }

Put together, the card looks like this:

  div(
    Styles.twotCard,
    div(
      Styles.twot,
      // manually control opacity
      styleAttr := s"opacity: $opacity",
      div(
        Styles.twotTitle,
        a(
          Styles.profileLink,
          // navigate to author's profile
          navigateTo(Page.Profile(twot.authorNickname)),
          "@" + twot.authorNickname
        ),
        child.maybe <-- deleteButton
      ),
      div(
        Styles.twotText,
        // render twot
        twot.content
      )
    ),
    div(
      Styles.twotUwotm8,
      button(
        // interactive UWOTM8 button
        sendUwotm8,
        cls <-- current.signal.map(Styles.uwotm8Button(_).htmlClass),
        "UWOTM8"
      )
    )
  )

Logout

Code

Before we move on to the profile page, let's relax and define the gorgeously simple logout page:

package twotm8
package frontend

import com.raquo.laminar.api.L.*
import com.raquo.waypoint.Router

def Logout(using Router[Page])(using state: AppState) =
  div(
    child.maybe <-- Signal.fromValue {
      state.clear()
      redirectTo(Page.Login)
      None
    }
  )

Beautiful! There's many ways to implement it, but all of them are very short.

If only our users never did anything else other than log out.

Profile

Code

def Profile(page: Signal[Page.Profile])(using Router[Page])(using
    state: AppState
): HtmlElement

This is one heavy page.

  • We need to retrieve and render user's profile, twots and all

  • We need to render the follow button and manage its state

All this compounded by the fact that profile name is dynamic, and that the page can work with or without authenticated. So it stands to reason that it will be slightly more complex than our previous ones.

Gladly we can reuse both the components (like TwotCard), and the techniques (follow button is very similar to UWOTM8 button)

First of all, let's retrieve the thought leader's profile:

  val profile: Signal[Option[ThoughtLeaderProfile]] =
    page
      .map(_.authorId)
      .combineWith(state.$token)
      .flatMap { case (name, token) =>
        Signal.fromFuture(ApiClient.get_profile(name, token))
      }

Note that both the requested profile name and the current state of authentication are treated as dynamic.

Once we have the profile, we can deduce whether the currently logged in user is following this thought leader:

  val isFollowing: Signal[FollowState] =
    profile
      .map(_.map(_.followers.toSet).getOrElse(Set.empty))
      .combineWith(state.$profile)
      .map { case (followers, currentUser) =>
        currentUser
          .map { prof =>
            if (followers.contains(prof.id)) then Yes else No
          }
          .getOrElse(Hide)
      }

Assuming that we will deal with the "Follow" button later, and will give it the following signature:

private def FollowButton(
    followState: Var[FollowState],
    thought_leader: Responses.ThoughtLeaderProfile
)(using state: AppState): HtmlElement

We can render the profile header as such:

  val renderName =
    h2(
      Styles.thoughtLeaderHeader,
      child.maybe <-- profile.map(_.map("@" + _.nickname)),
      nbsp,
      child.maybe <-- profile.map { thoughtLeader =>
        thoughtLeader.map { profile =>
          FollowButton(followState, profile)
        }
      }
    )

Note that even though we use profile in multiple places, only 1 API call will be made.

Thought leader's twots are easy to render, because we already have all the building blocks:

  val renderTwots =
    div(
      Styles.twots,
      children <-- profile.map { p =>
        p.map(_.twots.toList)
          .getOrElse(Nil)
          .map(TwotCard)
      }
    )

And all the components can be put together as follows:

  div(
    Styles.subContainer,
    isFollowing --> followState,
    renderName,
    renderTwots
  )

Follow/unfollow button

private def FollowButton(
    followState: Var[FollowState],
    thought_leader: Responses.ThoughtLeaderProfile
)(using state: AppState): HtmlElement

The logic here will be quite similar to the uwotm8 button, we just need to handle the fact that both the profile and the auth token might not be present at the time when the button is invoked:

  val sendFollow = onClick --> { _ =>
    for
      token <- state.token
      id <- state.profile.map(_.id)
      current = followState.now()
      newState = if current == Yes then No else Yes
    do
      ApiClient
        .set_follow(
          Payloads.Follow(thought_leader.id),
          newState == Yes,
          token
        )
        .foreach {
          case Right(None)    => followState.set(newState)
          case Right(Some(s)) => println("Request failed: " + s)
          case Left(e) => println("Request failed, but worse" + e.toString)
        }
  }

But otherwise it's the same as before - we're just flipping the state and send an API request.

All we need to handle is styling and the text on the button, both of which depend on the current follow state:

  button(
    tpe := "button",
    cls <-- followState.signal.map(Styles.followButton(_)).map(_.htmlClass),
    sendFollow,
    child.text <-- followState.signal.map {
      case Yes       => "Following"
      case No | Hide => "Follow"

    }
  )

And that's ALL the pages we needed to implement! Congrats for making it this far, even if you just accidentally underscroll trying to skip the entire post!

Entry point

Code

We've already shown the outline of what our entry point will look like, just need to put a few finishing touches.

First, I want to have a dynamically displayed Logout button, only shown to logged in users. Gladly it's trivial:

  def app(using state: AppState) =
    // ...

    val logoutButton = state.$token.map { tok =>
      tok.map { _ =>
        button(
          Styles.navButton,
          "Logout",
          navigateTo(Page.Logout)
        )
      }
    }

And we can mount our entire application's layout, along with dynamically rendered content and the profile refresh loop like this:

    div(
      Styles.container,
      header(
        Styles.logoHeader,
        h1(Styles.logo, "Twotm8"),
        small(
          Styles.logoSubtitle,
          "Safe space for Thought Leaders to thought lead"
        ),
        div(
          div(
            button(
              Styles.navButton,
              "Home",
              navigateTo(Page.Wall)
            ),
            // optionally show the Logout button
            child.maybe <-- logoutButton
          )
        )
      ),
      // render the page currently selected by the router
      child <-- renderPage,
      // profile refresh loop
      tokenState --> authRefresh
    )

And believe it or not, our frontend is all done and dusted!

Build

The last bit I want to add is a build task to do the following two things:

  1. Build the frontend's JavaScript file
  2. Write out a HTML file to mount said frontend

(2) was done out of pure laziness.

First, let's make a task that will trigger the linking of JavaScript code, and depending on the environment variable, we will either trigger the full or fast optimisation.

lazy val frontendFile = taskKey[File]("")
frontendFile := {
  if (sys.env.get("SN_RELEASE").contains("fast"))
    (frontend / Compile / fullOptJS).value.data
  else
    (frontend / Compile / fastOptJS).value.data
}

Confusingly, I use SN_RELEASE=fast to trigger full optimisation - because this variable is reused by the backend, which has a releaseFast mode... There's no good reason to do this and I apologise.

And we can use this task to create the buildFrontend SBT task:

build.sbt

lazy val buildFrontend = taskKey[Unit]("")
buildFrontend := {
  val js = frontendFile.value
  val destination = (ThisBuild / baseDirectory).value / "build"

  IO.write(
    destination / "index.html",
    """
      <!DOCTYPE html>
      <html lang="en">
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <meta http-equiv="X-UA-Compatible" content="ie=edge">
          <title>Twotm8 - a place for thought leaders to thought lead</title>
        </head>
        <body>
        <div id="appContainer"></div>
        <script src="/frontend.js"></script>
        </body>
      </html>
    """.stripMargin
  )

  IO.copyFile(js, destination / "frontend.js")
}

Which will place both the frontend.js and index.html into the build/ folder at the root of the project.

Previously we defined buildBackend and buildApp tasks, so let's re-define the buildApp:

build.sbt

val buildApp = taskKey[Unit]("")
buildApp := {
  buildBackend.value
  buildFrontend.value
}

And that's it! A complete build of our app is done by calling the buildApp task, and in the build/ folder it will produce the three files our app consists of:

  1. twotm8 binary backend
  2. index.html HTML mounting point for our app
  3. frontend.js our entire frontend

In development mode, file sizes are as follows:

.rw-r--r-- 4.6M 21 Mar 17:19 frontend.js
.rw-r--r--  465 21 Mar 17:19 index.html
.rwxr--r-- 8.2M 21 Mar 17:19 twotm8

And in production mode (where SBT is launched with SN_RELEASE=fast):

.rw-r--r-- 853k 21 Mar 17:22 frontend.js
.rw-r--r--  465 21 Mar 17:22 index.html
.rwxr--r-- 5.7M 21 Mar 17:23 twotm8 

Conclusion

  • Overall, this was an excellent experience. I spent more active thinking time working out codecs for Postgres than I did thinking about organising the app - most movements were mechanical

  • Laminar's uniform approach to Binders, Modifiers, and Setters really shines - ability to reuse processes and components wholesale is quite amazing

  • It took me a while to figure out how best to line up various requirements with the semantics of EventStreams and Signals. In fairness I struggle the same amount with fs2 streams which I've used a lot more

  • Integrating ScalaCSS with Laminar has been the highlight of this project for me