avatar

Pawel Zielonka

Posted on 17th August 2020

Building Speech To Text Web Application – 3. part

news-paper News | Software Development |

In the previous parts, I presented how to set up the project, development environment and basic components of our application: root component and router. You already know how to manage the application state and produce side effects using re-frame. It’s time to prepare our application for communication with external websites and implement a simple authentication mechanism.

AWS SDK

Amazon gives us the ability to customize the SDK build. To do so, go to this page and select the services you need. In this case, we need AWS.S3 and AWS.TranscribeService. On the right panel, you should see a blue “Build” button. Click it to download a file. Save it in the resources/public/js/aws directory and inject it into index.html:

<script src="./js/aws/sdk-<version>.js"></script>

Let’s set the region in aws-transcribe-clj.core/init! by using one of the helper functions that I created to work with AWS services.

(defn ^:export init! []
  . . .  (aws/set-region! c/AWS-REGION)
  (mount-root))

Facebook SDK

As I mentioned in the first part, we’re going to use Facebook to authenticate users. We will use SDK for JavaScript for that purpose. We can inject it dynamically by creating a function that we will call in aws-transcribe-clj.core/init!. Here you can find a namespace with several helper functions wrapping Facebook API.

(defn init-facebook! []
  (rf/dispatch
    [::fe/load-sdk
    (fn []
      (fb/subscribe-event "auth.authResponseChange"
                          #(rf/dispatch [::fe/auth-response-changed %
                                          {:on-connected    [::r/navigate :transcribe]
                                          :on-disconnected [::r/navigate :welcome]}]))
      (fb/subscribe-event "xfbml.render"
                          #(rf/dispatch [::fe/activate-login %])))]))

...

(defn ^:export init! []
  ...
  (aws/set-region! c/AWS-REGION)
  (init-facebook!)
  (mount-root))

This function dispatches an event :aws-transcribe-clj.facebook.events/load-sdk with a callback function. Right after the SDK is loaded, the callback is called. In order to display the login button and handle user authentication, we will need to subscribe to two events:

  • auth.authResponseChange – fires when the user logs in or logs out. We can find out what the login status is on the basis of data passed to a callback function. If the user is logged in (connected), the application will redirect him to the :transcribe route, otherwise, to the :welcome route in order to go through the authentication process. These instructions are passed as a Clojure map in the event vector data.
  • xfbml.render – fires when Facebook Login Button is loaded. We attach a callback that dispatches :aws-transcribe-clj.facebook.events/activate-login event with incoming data as argument.

Login button

Now, let’s create a component that will be responsible for displaying the Facebook Login Button:

(ns aws-transcribe-clj.facebook.ui
  (:require [reagent.core :as r]
            [re-frame.core :as rf]
            [aws-transcribe-clj.facebook.events :as fe]))

(defn login-button []
  (let [container-id "login-button-root"]
  (r/create-class
    {:display-name           "facebook-login-button"
      :component-did-mount    #(rf/dispatch [::fe/refresh-login-button container-id])
      :component-did-update   #(rf/dispatch [::fe/refresh-login-button container-id])
      :component-will-unmount #(rf/dispatch [::fe/deactivate-login])
      :reagent-render         (fn []
                                [:div {:id container-id}
                                [:div {:id "fb-root"} nil]
                                [:div {:class                "fb-login-button"
                                        :data-size            "large"
                                        :data-button-type     "continue_with"
                                        :data-layout          "default"
                                        :data-use-continue-as "true"}
                                  nil]])})))

This time, we used the reagent.core/create-class function to specify component lifecycle methods:

  • component-did-mount
  • component-did-update
  • component-will-unmount

When the component is mounted or its state is updated, we will dispatch an event refreshing our button. When handled, it will produce an effect calling the FB.XFBML.parse() function. In the case when the component is about to unmount we want to set a flag in the application state indicating that the button shouldn’t be displayed.

Going further!

As you can see we have to produce two effects: one for loading Facebook SDK and another for rendering the login button. Let’s create handlers for them:

(ns aws-transcribe-clj.facebook.fx
  (:require [oops.core :refer [ocall!]]
            [re-frame.core :as rf]
            [aws-transcribe-clj.config :as config]
            [aws-transcribe-clj.facebook.sdk :as fb]))

(rf/reg-fx
  ::load-sdk!
  (fn [{:keys [callback]}]
    (fb/load-sdk
      (clj->js {:appId   config/FB-APP-ID
                :cookie  true
                :xfbml   true
                :version config/FB-SDK-VERSION})
      callback)))

(rf/reg-fx
  ::xfbml-parse!
  (fn [id]
    (when (fb/sdk-ready?)
      (fb/xfbml-parse
        (ocall! js/document :getElementById id)))))

Now, we can register all the event handlers we need:

(ns aws-transcribe-clj.facebook.events
  (:require [re-frame.core :as rf]
            [aws-transcribe-clj.facebook.fx :as fx]
            [aws-transcribe-clj.interceptors :as i]))

(rf/reg-event-fx
  ::load-sdk
  [rf/trim-v]
  (fn [_ [callback]]
    {::fx/load-sdk! {:callback callback}}))

(rf/reg-event-fx
  ::auth-response-changed
  [rf/trim-v i/clojurize-event-data]
  (fn [{:keys [db]} [{:keys [status] :as data} {:keys [on-connected on-disconnected]}]]
    (cond-> {:db (assoc-in db [:facebook :auth]
                          (merge (select-keys (:authResponse data) [:accessToken :userID])
                                  (select-keys data [:status])))}
      (and (= "connected" status) (seq on-connected)) (assoc :dispatch on-connected)
      (and (= "unknown" status) (seq on-disconnected)) (assoc :dispatch on-disconnected))))

(rf/reg-event-db
  ::activate-login
  (fn [db _]
    (assoc-in db [:facebook :login :ready?] true)))

(rf/reg-event-db
  ::deactivate-login
  (fn [db _]
    (assoc-in db [:facebook :login :ready?] false)))

(rf/reg-event-fx
  ::refresh-login-button
  [rf/trim-v]
  (fn [_ [container-id]]
    {::fx/xfbml-parse! container-id}))

We have implemented, among other things, two event handlers that will assure readiness of the login button rendering on the welcome page. Let’s modify the view and hide the container if the button is not ready to be shown:

(ns aws-transcribe-clj.views
  (:require [re-frame.core :as rf]
            [aws-transcribe-clj.facebook.subs :as fsubs]
            [aws-transcribe-clj.facebook.ui :as fui]))

...

(defn welcome []
  [:div {:hidden (not @(rf/subscribe [::fsubs/login-ready?]))}
  [:div.container
    [:h1 "Welcome"]
    [fui/login-button]]])

Of course, we need a subscription for the flag:

(ns aws-transcribe-clj.facebook.subs
  (:require [re-frame.core :as rf]))

(rf/reg-sub
  ::login-ready?
  (fn [db]
    (get-in db [:facebook :login :ready?])))

Authentication

In the previous part, we enabled reitit controllers. Now I’m going to show you how to use them to perform certain actions while navigating to a new route. In this case, we would like the user to be redirected to the login page if he has not been authenticated yet. To be able to check the login status, let’s create a subscription first:

(ns aws-transcribe-clj.facebook.subs ...)

...
(rf/reg-sub
  ::connected?
  (fn [db]
    (= "connected" (get-in db [:facebook :auth :status]))))

Now we can define a function that will check the authentication status and navigate to the welcome page in case of anonymous user:

(ns aws-transcribe-clj.routes
  (:require ...
            [aws-transcribe-clj.facebook.subs :as fsubs]
            ...))...
(defn wrap-auth [match]
  (let [authenticated? @(rf/subscribe [::fsubs/connected?])]
    (when-not authenticated?
      (rf/dispatch [::navigate :welcome]))))...

and use it as start function of a controller at the top level in the route tree:

(def router
  (rfd/router
    ["" {:controllers [{:identity identity
                        :start    wrap-auth}]}
    ["/" {:name :welcome
          :view #'v/welcome}]
    ...    {:default-view #'v/not-found}))

The identity function takes a match as an argument and returns a controller identity. The controller’s start and stop functions take this identity as an argument. When you navigate to a route of a controller, its identity is calculated and the controller is initialized by calling the start function with this value. When you leave a route, the stop function is called with the same value. Changing a match will cause an identity value to be recalculated. If it differs from the previous value, the controller will be reinitialized.

In our case, we use the controller at the root level. Thanks to it, whenever we change the route, we will be able to check if the user is authenticated.

Additionally, we have to make sure that the user does not see the contents of the view until the login process is successful. To achieve that: 

  1. create builders for start and stop functions
(ns aws-transcribe-clj.transcribe.controllers
  (:require [re-frame.core :as rf]
            [aws-transcribe-clj.transcribe.events :as e]))

(defn start
  [params]
  (fn [match]
    (when @(rf/subscribe (-> params :subs :authenticated?))
      (rf/dispatch [::e/set-up]))))

(defn stop
  ([] (stop {}))
  ([params]
  (fn [match]
    (rf/dispatch [::e/tear-down]))))
  1. add them to the :transcribe route controller:
(ns aws-transcribe-clj.routes
  (:require ...
            [aws-transcribe-clj.transcribe.controllers :as tc]
            ...))
...

(def router
  (rfd/router
    ["" {:controllers [{:identity identity
                        :start    wrap-auth}]}
    ["/"
      {:name :welcome
      :view #'v/welcome}]
    ["/transcribe"
      {:name        :transcribe
      :view        #'v/transcribe
      :controllers [{:identity identity
                      :start    (tc/start {:subs {:authenticated? [::fsubs/connected?]}})
                      :stop     (tc/stop)}]}]]
    {:default-view #'v/not-found}))
  1. implement event handlers for set-up and tear-down events:
(ns aws-transcribe-clj.transcribe.events
  (:require [re-frame.core :as rf]))

(rf/reg-event-db
  ::set-up
  [rf/trim-v]
  (fn [db]
    (assoc-in db [:transcribe :ui :show?] true)))

(rf/reg-event-db
  ::tear-down
  [rf/trim-v]
  (fn [db]
    (dissoc db :transcribe)))
  1. create a subscription that will tell us whether we can show the content:
(ns aws-transcribe-clj.transcribe.subs
  (:require [re-frame.core :as rf]))

(rf/reg-sub
  ::show?
  (fn [db]
    (get-in db [:transcribe :ui :show?])))
  1. modify transcribe view:
(ns aws-transcribe-clj.views
  (:require ...
            [aws-transcribe-clj.transcribe.subs :as tsubs]))
...

(defn transcribe []
  [:div
  (when @(rf/subscribe [::tsubs/show?])
    [:div.container
      [:h1 "Transcribe"]])])

Let’s go to the http://localhost:9000 and login with Facebook! You will be redirected to http://localhost:9000/transcribe, but if you try to open the browser directly at this page, you will be forced to authenticate first.

This is the end of the third part. In the next part, I’m going to show you how to deal with audio recording and retrieve transcriptions from AWS Transcribe.