social-kit / src / ardegazu / social / join.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
;; ported-from: src/join.ts @ v1.2.0
;;
;; The "got an invite?" chip — dependency-free, its own entry
;; ("ardegazu-social-kit/join") so game main chunks stay lean.
;;
;; Installed-PWA reality: tapping a web link opens the BROWSER, not the
;; installed app. The escape hatch is pasting: inside the app, a pasted
;; invite for THIS app becomes a same-origin hash change + reload — the
;; standalone window stays a standalone window. A pasted link for a
;; different suite app navigates there (best the platform allows).
;; chat/board have this in their lobbies already; this chip gives the
;; games the same affordance with zero per-game UI surgery.
(ns ardegazu.social.join
  (:require [ardegazu.social.text :as text]))

(def ^:private CSS "
.soc-joinchip {
  position: fixed; right: calc(env(safe-area-inset-right, 0px) + 12px);
  bottom: calc(env(safe-area-inset-bottom, 0px) + 12px); z-index: 9998;
  display: flex; gap: 8px; align-items: center;
  font: 600 12px/1 -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;
  color: var(--soc-dim, #a49fb5); background: var(--soc-bg, rgba(20, 18, 28, .82));
  border: 1px solid var(--soc-line, #2b2838);
  border-radius: 999px; padding: 7px 12px; cursor: pointer; user-select: none;
  backdrop-filter: blur(6px);
}
.soc-joinchip input {
  font: 13px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--soc-fg, #eae6f2);
  background: transparent; border: 0; outline: none; width: 13em; min-width: 0;
}
.soc-joinchip .go { color: var(--soc-accent, #93c1a5); font-weight: 700; cursor: pointer; }
body.soc-play .soc-joinchip { display: none; }
")

;; Anchored: the secret must be a whole fragment/input — never a substring.
(def ^:private secret-re #"(?:#(?:g=)?|^)([A-Za-z0-9_-]{22,64})(?=$|[&?.\s])")

(def ^:private host-re #"(^|\.)ardegazu\.ro$")

(defn join-paste-chip [opts]
  (let [opts (if (identical? opts js/undefined) (js-obj) opts)]
    (when (nil? (js/document.querySelector ".soc-joinchip"))
      (let [tt (text/mk-t (unchecked-get opts "t"))
            style (js/document.createElement "style")
            chip (js/document.createElement "div")]
        (set! (.-textContent style) CSS)
        (.appendChild js/document.head style)
        (set! (.-className chip) "soc-joinchip")
        (let [label (fn []
                      (set! (.-innerHTML chip) "<span></span>")
                      (set! (.-textContent (.querySelector chip "span"))
                            (tt "social.paste.chip" js/undefined)))]
          (label)
          (set! (.-title chip) (tt "social.paste.chip.title" js/undefined))
          (let [collapse (fn [] (label))
                expand
                (fn []
                  (set! (.-innerHTML chip)
                        "<input type=\"text\" autocomplete=\"off\" spellcheck=\"false\"><span class=\"go\"></span>")
                  (let [input (.querySelector chip "input")]
                    (set! (.-placeholder input) (tt "social.paste.placeholder" js/undefined))
                    (set! (.-textContent (.querySelector chip ".go")) (tt "social.paste.join" js/undefined))
                    (let [apply-fn
                          (fn []
                            (let [raw (.trim ^js (.-value input))]
                              (when ^boolean (js* "!!(~{})" raw)
                                (let [host (try (.-host (js/URL. raw))
                                                (catch :default _ nil)) ; bare secret or fragment
                                      m (.match ^js raw secret-re)]
                                  (if (nil? m)
                                    (do (set! (.-value input) "")
                                        (set! (.-placeholder input) (tt "social.paste.error" js/undefined)))
                                    (if (and ^boolean (js* "!!(~{})" host)
                                             (not (identical? host (.-host js/location)))
                                             (.test host-re host))
                                      ;; another suite app — go there (best the platform allows)
                                      (set! (.-href js/location) raw)
                                      ;; this app: same-origin hash + reload keeps a
                                      ;; standalone window standalone
                                      (do (set! (.-hash js/location) (aget m 1))
                                          (js/location.reload))))))))]
                      (.addEventListener (.querySelector chip ".go") "click" (fn [] (apply-fn)))
                      (.addEventListener input "keydown"
                                         (fn [e]
                                           (when (identical? (.-key e) "Enter") (apply-fn))
                                           (when (identical? (.-key e) "Escape") (collapse))))
                      ;; paste = intent
                      (.addEventListener input "paste" (fn [] (js/setTimeout apply-fn 50)))
                      (js/setTimeout (fn [] (.focus input)) 0))))]
            (.addEventListener chip "click"
                               (fn [e]
                                 (when (and (not (identical? (.-tagName (.-target e)) "INPUT"))
                                            (nil? (.querySelector chip "input")))
                                   (expand))))
            (.appendChild js/document.body chip)
            js/undefined))))))

(def ^:private HOME-CSS "
.soc-home {
  position: fixed; display: none; align-items: center; justify-content: center;
  top: calc(env(safe-area-inset-top, 0px) + 8px);
  left: calc(env(safe-area-inset-left, 0px) + 12px);
  z-index: 10001; width: 34px; height: 34px; border-radius: 999px;
  text-decoration: none; font-size: 16px;
  color: var(--soc-dim, #a49fb5); background: var(--soc-bg, rgba(20, 18, 28, .82));
  border: 1px solid var(--soc-line, #2b2838); backdrop-filter: blur(6px);
}
/* Installed on a device WITHOUT a sensor housing (SE, iPad), status bar style
   black-translucent: the bar overlays the page while safe-area-inset-top
   reports 0 — the inset covers hardware cutouts, not the translucent bar —
   so the chip sat under the clock. A standalone-only floor clears the 20pt
   bar; devices where the inset is real (notch/island) exceed it anyway, and
   in-browser pages keep the tight 8px. */
@media (display-mode: standalone) {
  .soc-home { top: max(calc(env(safe-area-inset-top, 0px) + 8px), 32px); }
}
body.soc-menu .soc-home { display: flex; }
")

(defn home-chip
  "A tiny fixed \"back to the hub\" chip. Visible only under `body.soc-menu`
  (the app flips the class on its menu screens), so gameplay stays
  chrome-free — the counterpart of `body.soc-play` hiding the other chips."
  [opts]
  (let [opts (if (identical? opts js/undefined) (js-obj) opts)]
    (when (nil? (js/document.querySelector ".soc-home"))
      (let [tt (text/mk-t (unchecked-get opts "t"))
            style (js/document.createElement "style")
            a (js/document.createElement "a")]
        (set! (.-textContent style) HOME-CSS)
        (.appendChild js/document.head style)
        (set! (.-className a) "soc-home")
        (set! (.-href a) "https://ardegazu.ro/")
        (set! (.-textContent a) "⌂")
        (set! (.-title a) (tt "social.home.title" js/undefined))
        (.setAttribute a "aria-label" (tt "social.home.title" js/undefined))
        (.appendChild js/document.body a)
        js/undefined))))

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/social-kit.git