dev / templates / app-cljs-live / client / src / __NAME__ / main.cljs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
;; Boot: room secret, libp2p net wiring, DOM event binding.
;; __NAME__.i18n is required first among the text-bearing namespaces — its
;; load resolves the language and hydrates the static markup before anything
;; renders.
(ns __NAME__.main
  (:require [__NAME__.i18n :as i18n :refer (t)]
            ["ardegazu-social-kit/join" :refer (homeChip joinPasteChip)]
            [__NAME__.config :as config]
            [__NAME__.game.app :as app]
            [__NAME__.game.frames :as frames]
            [__NAME__.game.ui :as ui]
            [__NAME__.id.boot :as id-boot]
            [__NAME__.net.index :as net]
            [shadow.cljs.modern :refer (js-await)]
            [shadow.lazy :as lazy]))

(def ^:private social-loadable (lazy/loadable __NAME__.social/setup!))

(defn- $ ^js [s]
  (js/document.querySelector s))

(defn- register-sw []
  ;; release builds only (the dev server ships no sw.js, and a dev-origin SW squats)
  (when (and (not goog.DEBUG) (some? (.-serviceWorker js/navigator)))
    (-> (.register (.-serviceWorker js/navigator) "./sw.js")
        (.catch (fn [_] nil)))))

(defn- boot []
  (joinPasteChip (js-obj "t" i18n/t-kit)) ; installed-PWA escape hatch: paste an invite, stay in the app
  (homeChip (js-obj "t" i18n/t-kit)) ; "back to the hub" link — visible only while body.soc-menu
  ;; room secret lives in the URL fragment; peers prove knowledge of it in the
  ;; hello handshake — the relay only ever routes opaque noise ciphertext
  (let [hash-secret (.replace (.-hash js/location) (js/RegExp. "^#(g=)?") "")
        secret (if (.test (js/RegExp. "^[A-Za-z0-9_-]{22,}$") hash-secret)
                 hash-secret
                 (let [s (net/new-room-secret)]
                   (.replaceState js/history nil "" (str "#" s))
                   s))
        ;; synchronous first-paint decision — no known user ever sees the
        ;; stranger gate. The id-kit mirror (`<ns>:name`) is readable before
        ;; any await: a named user holds the splash until auto-join below;
        ;; only the unnamed get the gate.
        mirror-ns (id-boot/ns-of config/APP-SALT)
        mirror-name (.trim ^string (let [v (try (js/localStorage.getItem (str mirror-ns ":name"))
                                                (catch :default _ nil))]
                                     (if (some? v) v "")))
        boot-done (volatile! nil)
        ;; never hang on the splash — if the net boot stalls, fall back to the gate
        boot-failsafe (js/setTimeout (fn [] (when-some [f @boot-done] (f))) 15000)]
    (vreset! boot-done (fn []
                         (js/clearTimeout boot-failsafe)
                         (.remove (.-classList js/document.documentElement) "boot")))
    (if (seq mirror-name)
      (do
        ;; prefill anyway so the 15s failsafe gate comes up ready if the net stalls
        (set! (.-value ($ "#nick")) mirror-name)
        (set! (.-textContent ($ "#splashmsg"))
              (t "splash.enter" {"name" (.slice mirror-name 0 24)})))
      (@boot-done))

    ;; suite identity via the ardegazu.ro bridge (offline: local mirror only)
    (js-await [ident (id-boot/boot-identity config/APP-SALT)]
      (let [^js boot* (unchecked-get ident "boot")
            ^js profile (unchecked-get boot* "profile")
            ^js bridge (unchecked-get ident "bridge")]
        ;; a language picked anywhere in the suite follows you here
        (i18n/absorb-suite-lang (unchecked-get profile "lang"))
        ;; refreshStatus needs the net object; the callback closes over a
        ;; ref rebound once it exists
        (let [status-ref (volatile! (fn [] nil))]
          (js-await [nn (net/create-net
                         (js-obj "secret" secret
                                 "appSalt" config/APP-SALT
                                 "descriptorUrl" config/AP2P-DESCRIPTOR-URL
                                 "turnUrl" config/TURN-URL
                                 "relayFallback" config/RELAY-FALLBACK
                                 "identity" ((unchecked-get ^js ident "netIdentity") config/APP-SALT))
                         (js-obj "onRoomRoster" (fn [l] (app/on-room-roster l))
                                 "onPeerOpen" (fn [id] (app/on-peer-open id))
                                 "onPeerGone" (fn [id] (app/on-peer-gone id))
                                 "onMessage" (fn [from payload]
                                               (when (frames/is-app-frame payload)
                                                 (app/handle-msg from payload)))
                                 "onStatus" (fn [] (@status-ref))))]
            (let [refresh-status (fn []
                                   (ui/set-status ((unchecked-get ^js nn "relayConnected"))
                                                  ((unchecked-get ^js nn "openCount"))))]
              (vreset! status-ref refresh-status)
              (app/init-app nn)
              (refresh-status)

              ;; ---------------- DOM events ----------------

              (let [^js nick-el ($ "#nick")]
                ;; the suite profile name wins; the app-local nick is the fallback
                (set! (.-value nick-el)
                      (let [pn (unchecked-get profile "name")]
                        (if (seq pn)
                          pn
                          (let [v (js/localStorage.getItem "__SUB__-nick")]
                            (if (some? v) v "")))))

                ;; language: picker in the join panel; the choice mirrors locally
                ;; and rides the suite record so every app follows
                (.appendChild ($ "#ovJoin .panel")
                              (i18n/lang-picker-el
                               {:on-pick (fn [lang]
                                           (when (some? (unchecked-get boot* "identity"))
                                             (.putProfile bridge (js-obj "lang" lang))))}))
                (i18n/on-lang-change (fn [_]
                                       (i18n/hydrate-html js/document)
                                       (app/rerender)
                                       (refresh-status)))

                (let [join (fn []
                             (let [typed-raw (.trim (.-value nick-el))
                                   sliced (.slice typed-raw 0 24)
                                   nick (if (seq sliced)
                                          sliced
                                          (str "guest-" (.slice ^string (unchecked-get ^js nn "myId") -4)))]
                               (js/localStorage.setItem "__SUB__-nick" nick)
                               ;; suite-wide rename only on a real edit — never push the truncation
                               (when (and (some? (unchecked-get boot* "identity"))
                                          (seq typed-raw)
                                          (not (identical? typed-raw (unchecked-get profile "name"))))
                                 (.putProfile bridge (js-obj "name" typed-raw)))
                               (.blur nick-el)
                               (app/join nick)))]
                  (.addEventListener ($ "#joinbtn") "click" join)
                  (.addEventListener nick-el "keydown"
                                     (fn [^js e]
                                       (when (identical? (.-key e) "Enter") (join)))))

                (let [^js note-el ($ "#noteinput")]
                  (.addEventListener ($ "#noteform") "submit"
                                     (fn [^js e]
                                       (.preventDefault e)
                                       (app/send-note (.-value note-el))
                                       (set! (.-value note-el) ""))))

                ;; already named suite-wide: straight into the room on EVERY boot —
                ;; invite link and bare hub URL alike. The gate is only for the
                ;; unnamed first-timer.
                (let [pn (.trim ^string (unchecked-get profile "name"))
                      ;; the splash promised the mirror name — keep the promise
                      suite-name (if (seq pn) pn mirror-name)]
                  (when (seq suite-name)
                    (let [nick (.slice suite-name 0 24)]
                      (js/localStorage.setItem "__SUB__-nick" nick)
                      (ui/toast (t "join.auto" {"name" nick}))
                      (app/join nick))))
                ;; splash off: either the room just appeared (auto-join) or the gate
                ;; is the truthful view (stale mirror name); harmless no-op if removed
                (@boot-done)

                ;; the social side rides its own chunk; must never break the app
                (when (and (some? (unchecked-get boot* "identity"))
                           (some? (unchecked-get boot* "seed")))
                  (-> (lazy/load social-loadable)
                      (.then (fn [setup!]
                               (setup!
                                (js-obj "ident" ident
                                        "net" nn
                                        "myName" (fn []
                                                   (let [v (.trim (.-value nick-el))]
                                                     (if (seq v)
                                                       v
                                                       (let [pn (unchecked-get profile "name")]
                                                         (if (seq pn) pn "?")))))))))
                      (.catch (fn [_] nil))))

                (let [share-invite
                      (fn []
                        (if (some? (.-share js/navigator))
                          ;; user dismissing the sheet is not an error
                          (-> (.share js/navigator (js-obj "url" (.-href js/location)))
                              (.catch (fn [_] nil)))
                          (-> (.writeText (.-clipboard js/navigator) (.-href js/location))
                              (.then (fn [_] (ui/toast (t "toast.invite-copied"))))
                              (.catch (fn [_] (ui/toast (.-href js/location)))))))]
                  (.addEventListener ($ "#copybtn") "click" share-invite))

                ;; iOS: block pinch zoom inside the app
                (.addEventListener js/document "gesturestart" (fn [^js e] (.preventDefault e)))
                nil))))))))

(defn init []
  (register-sw)
  (-> (boot)
      (.catch (fn [e]
                ;; a failed boot must still reveal the gate
                (.remove (.-classList js/document.documentElement) "boot")
                (throw e)))))

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/dev.git