From a7267a241de9105243487d47e656c56111568250 Mon Sep 17 00:00:00 2001 From: arne Date: Wed, 19 Nov 2025 12:30:12 +0100 Subject: [PATCH 01/10] Improve page header - Add illustration in correct size - Rephrase explanatory intro paragraphs --- .../img/lodestone_attracting_nails_small.jpeg | Bin 0 -> 14562 bytes public/index.html | 9 +++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 public/img/lodestone_attracting_nails_small.jpeg diff --git a/public/img/lodestone_attracting_nails_small.jpeg b/public/img/lodestone_attracting_nails_small.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..f79b97edc55d99943d633a32ed487568f12c5259 GIT binary patch literal 14562 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 H_&fD~0z32n literal 0 HcmV?d00001 diff --git a/public/index.html b/public/index.html index 342ea1c..199534f 100644 --- a/public/index.html +++ b/public/index.html @@ -69,6 +69,10 @@ vertical-align: middle; } + p.intro { + font-size: 20px; + } + #root { clear: both; } @@ -215,9 +219,10 @@ - +

Lodestone

-

Lodestone is an application to help you navigate the Fediverse. It surfaces things you enjoyed and allows you to sift through them again. It aims to be a companion to the server hosting your Mastodon instance, or any other compatible Fediverse software.

+

Lodestone helps you navigate the Fediverse.

+

It gives you a tool to quickly search through your favorite posts, and helps you rediscover the things you found relevant.

Loading application… From 80d0a6e258275c01f8fe6fda9d4eacdc2ba73360 Mon Sep 17 00:00:00 2001 From: arne Date: Wed, 19 Nov 2025 13:23:00 +0100 Subject: [PATCH 02/10] Implement nicer loading indicator --- public/index.html | 31 ++++++++++++++++++++++++ src/computersandblues/lodestone/app.cljs | 3 ++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/public/index.html b/public/index.html index 199534f..941fb80 100644 --- a/public/index.html +++ b/public/index.html @@ -106,6 +106,37 @@ width: 80%; } + section.posts .controls .loading-indicator { + width: 20px; + height: 20px; + vertical-align: middle; + margin-left: 16px; + overflow: visible; + } + + section.posts .controls .loading-indicator .arc { + /* svg */ + fill: transparent; + stroke-width: 20; + stroke-linecap: round; + stroke-linejoin: round; + stroke: #dccb8b; + animation: spin 2s ease-in-out infinite; + transform-origin: 50px 50px; + } + + @keyframes spin { + 100% { + transform: rotate(360deg); + } + } + + section.posts .controls .buttons { + display: flex; + justify-content: end; + flex-wrap: wrap; + } + @media screen and (min-width: 640px) { section.posts .controls { display: grid; diff --git a/src/computersandblues/lodestone/app.cljs b/src/computersandblues/lodestone/app.cljs index 0b19609..f98a6aa 100644 --- a/src/computersandblues/lodestone/app.cljs +++ b/src/computersandblues/lodestone/app.cljs @@ -390,10 +390,11 @@ (str ", displaying " (count displayed-posts) (when query " matches"))))] [:section.search-form [search] - (when (seq loading) " …") + [loading-indicator (select-keys posts [:loading])] #_(cond (= api-state :loading) " …" (= api-state :error) " API Error!")] [:section.buttons + [:button.control-button {:on-click fetch-more-posts!} "⇓ Fetch more"] [:button.control-button {:on-click disconnect-account!} "▤ Disconnect account"]]] [:ul.results (map-indexed (fn [idx p] ^{:key idx} [:li.result [post {:post p}]]) displayed-posts)] From f984e9b14d6f86d12be047c00c91739c8ae6a9b7 Mon Sep 17 00:00:00 2001 From: arne Date: Wed, 19 Nov 2025 19:43:09 +0100 Subject: [PATCH 03/10] Add "load more" button and fix pagination --- public/index.html | 41 +++-- src/computersandblues/lodestone/app.cljs | 214 +++++++++++++++-------- 2 files changed, 169 insertions(+), 86 deletions(-) diff --git a/public/index.html b/public/index.html index 941fb80..a910649 100644 --- a/public/index.html +++ b/public/index.html @@ -86,6 +86,11 @@ background: rgba(255, 255, 255, 0.8); } + button[disabled] { + opacity: 0.6; + cursor: not-allowed; + } + section.login label, section.login input { display: block; @@ -100,6 +105,11 @@ section.posts .controls { padding: 0 0 36px; + display: grid; + grid-template: + "a" + "b" + "c"; } section.posts .controls .search-form input { @@ -137,14 +147,19 @@ flex-wrap: wrap; } - @media screen and (min-width: 640px) { - section.posts .controls { - display: grid; - grid-template: - "a a" - "b c"; - } + section.posts .controls .post-info { + grid-area: b; + } + section.posts .controls .search-form { + grid-area: c; + } + + section.posts .controls .buttons { + grid-area: a; + } + + @media screen and (min-width: 640px) { section.posts .controls .post-info { grid-area: a; } @@ -159,14 +174,12 @@ section.posts .controls .buttons { grid-area: c; - display: flex; - justify-content: end; } } section.posts .controls .control-button { padding: 6px; - margin: 6px 0; + margin: 6px 0 6px 12px; background: #f5e6ab; color: #444; cursor: pointer; @@ -212,14 +225,12 @@ margin: 24px 0 0; padding: 0; list-style-type: none; + display: flex; + justify-content: end; + flex-wrap: wrap; } @media screen and (min-width: 640px) { - section.posts .post .controls { - display: flex; - justify-content: end - } - section.posts .post .controls .control-element a { margin: 0 0 0 12px; } diff --git a/src/computersandblues/lodestone/app.cljs b/src/computersandblues/lodestone/app.cljs index f98a6aa..1b9b27c 100644 --- a/src/computersandblues/lodestone/app.cljs +++ b/src/computersandblues/lodestone/app.cljs @@ -4,7 +4,8 @@ [clojure.string :as str] [clojure.pprint :as pprint] [computersandblues.lodestone.database :as db] - [computersandblues.lodestone.match :refer [query->matching-fn]])) + [computersandblues.lodestone.match :refer [query->matching-fn]] + [applied-science.js-interop :as j])) (def posts-init-state {:query nil @@ -27,13 +28,21 @@ :section/posts posts-init-state})) ; TODO Ensure that cached data is up to date -; TODO Manually fetch older / newer favorites ; TODO Handle 429 ; TODO Search for tags (`#foo`) and handles (`@bar`) ; TODO Explain which kind of search currently is possible ;; Mastodon API helpers +(defn- promise-all [xs] + (js/Promise.all (clj->js xs))) + +(defn- promise-resolve [val] + (js/Promise.resolve val)) + +(defn- promise-reject [val] + (js/Promise.reject val)) + (defn- link-header "Given a JS `Response` object, will parse the `link` header and find a link of a given `link-type` if present. Useful for paginating API requests." @@ -59,26 +68,27 @@ "Small helper function to send authorized requests to mastodon-compatible APIs" [{:keys [url method bearer-token payload] :or {method :get}}] - (js/Promise. - (fn [resolve reject] - (. (js/fetch url - (clj->js (cond-> {:method (str/upper-case (name method))} - bearer-token (assoc-in [:headers :authorization] (str "Bearer " bearer-token)) - payload (-> - (assoc-in [:headers :content-type] "application/json; charset=utf-8") - (assoc :body (js/JSON.stringify (clj->js payload))))))) - (then (fn [res] - (if (.-ok res) - (-> (.json res) - (.then - (fn [body] - (resolve {:raw res - :body (js->clj body {:keywordize-keys true})})))) - (reject res)))))))) + (. (js/fetch url + (clj->js (cond-> {:method (str/upper-case (name method))} + bearer-token (assoc-in [:headers :authorization] (str "Bearer " bearer-token)) + payload (-> + (assoc-in [:headers :content-type] "application/json; charset=utf-8") + (assoc :body (js/JSON.stringify (clj->js payload))))))) + (then (fn [res] + (if (.-ok res) + (-> (.json res) + (.then + (fn [body] + (promise-resolve {:raw res + :body (js->clj body {:keywordize-keys true})})))) + (promise-reject res)))))) -(defn- search-params [params] +(defn- ->search-params [params] (js/URLSearchParams. (clj->js params))) +(defn- url->search-params [url] + (.-searchParams (js/URL. url))) + ;; all of the app's sections (i.e. different views / pieces of functionality) ;; login & application setup @@ -99,24 +109,26 @@ (set! (.-location js/window) (str (:instance_url application) "/oauth/authorize?" - (search-params {:response_type "code" - :client_id (:client_id application) - :redirect_uri (:redirect_uri application) ; TODO handle multiple reidrect uris? - :scope "read:favourites"})))) + (->search-params {:response_type "code" + :client_id (:client_id application) + :redirect_uri (:redirect_uri application) ; TODO handle multiple reidrect uris? + :scope "read:favourites"})))) (defn oauth-authorization-code [location] - (.get (js/URLSearchParams. (.-search location)) "code")) + (-> (.-search location) + (js/URLSearchParams.) + (.get "code"))) (defn handle-oauth-authorization-code! [{:keys [application code]}] (-> (mastodon-request! {:method :post :url (str (:instance_url application) "/oauth/token?" - (search-params {:grant_type "authorization_code" - :code code - :client_id (:client_id application) - :client_secret (:client_secret application) - :redirect_uri (:redirect_uri application)}))}) + (->search-params {:grant_type "authorization_code" + :code code + :client_id (:client_id application) + :client_secret (:client_secret application) + :redirect_uri (:redirect_uri application)}))}) (.then (fn [res] (let [bearer-token (-> res :body :access_token) application (assoc application :bearer_token bearer-token)] @@ -143,6 +155,12 @@ (declare fetch-posts!) (declare refresh-displayed-posts!) +(defn- fetch-application-settings [] + (-> (db/open-cursor! ::db/application db/all) + (db/transduce-cursor (comp (take 1) + (map #(js->clj % :keywordize-keys true))) + (fn [_ x] x)))) + (defn setup-application! "Handles Mastodon application setup on the client side" [] @@ -156,14 +174,11 @@ ; the last case is not handled in this function, but is handled by the ; `create-remote-application!` function that is called once the user submits ; the form with their instance URL. - (-> (db/open-cursor! ::db/application db/all) - (db/transduce-cursor (comp (take 1) - (map #(js->clj % :keywordize-keys true)))) - (.then (fn [[application]] + (-> (fetch-application-settings) + (.then (fn [application] (let [code (oauth-authorization-code (.-location js/window))] (cond - (:bearer_token application) - (js/Promise.resolve application) + (:bearer_token application) (js/Promise.resolve application) (and application code) (handle-oauth-authorization-code! @@ -181,16 +196,12 @@ (.then (fn [application] (when application (swap! state assoc :section :posts) - (js/Promise.all #js [application (db/count! ::db/posts)])))) + (promise-all [application (db/count! ::db/posts)])))) (.then (fn [[application post-count]] (when post-count (if (zero? post-count) (fetch-posts! {:instance-url (:instance_url application) - :bearer-token (:bearer_token application) - :continue? - (fn [response] - (and (seq (:body response)) - (< (count (:favorites @state)) 500)))}) + :bearer-token (:bearer_token application)}) (refresh-displayed-posts! (:section/posts @state)))))))) ;;; views @@ -223,34 +234,36 @@ ;;; api interaction -(defn- favorites-url [{:keys [instance-url limit max-id] +(defn- favorites-url [{:keys [instance-url limit max-id min-id] :or {limit 40}}] - (let [params (search-params (cond-> {:limit limit} - max-id (assoc :max_id max-id)))] + (let [params (->search-params (cond-> {:limit limit} + max-id (assoc :max_id max-id) + min-id (assoc :min_id min-id)))] (str instance-url "/api/v1/favourites?" params))) -(defn fetch-favorites! - [{:keys [instance-url bearer-token max-id +(defn paginate-posts! + [{:keys [instance-url bearer-token + max-id min-id on-response on-error continue?] :or {continue? (fn [response] (seq (:body response))) on-response on-response on-error on-error}}] - (js/console.log 'fetch-favorites! instance-url max-id bearer-token) - ((fn fetch-favorites' [url] + ((fn paginate! [url] (let [req-id (js/Date.now)] - (println :calling url) + (js/console.log :paginate! url :max-id max-id :min-id min-id) (swap! state update-in [:section/posts :loading] conj req-id) (-> (mastodon-request! {:url url :bearer-token bearer-token}) (.then (fn [response] - (on-response response) - (if (continue? response) - (js/setTimeout #(fetch-favorites' (link-header "next" (:raw response))) 500) - (swap! state update-in [:section/posts :loading] disj req-id)))) + (let [next-url (link-header "next" (:raw response))] + (swap! state update-in [:section/posts :loading] disj req-id) + (on-response response) + (when (and (continue? response) next-url) + (js/setTimeout #(paginate! next-url) 500))))) (.catch (fn [response] (swap! state update-in [:section/posts :loading] disj req-id) (on-error response)))))) - (favorites-url {:instance-url instance-url :max-id max-id}))) + (favorites-url {:instance-url instance-url :max-id max-id :min-id min-id}))) ;;; views @@ -341,6 +354,14 @@ (js/navigator.clipboard.writeText (:url post)))} "◎ Copy URL to clipboard"]]]] #_[debug post]]) +#_(defn logging [f] + (let [n (volatile! 0)] + (fn [& args] + (when (< @n 10) + (vswap! n inc) + (js/console.log :logging args) + (apply f args))))) + (defn- refresh-displayed-posts! [posts-section] (let [{:keys [per-page query]} posts-section @@ -352,42 +373,90 @@ (map #(js->clj % :keywordize-keys true))) refresh-id (js/Date.now)] (swap! state update-in [:section/posts :loading] conj refresh-id) - (-> (js/Promise.all #js [(db/count! ::db/posts) - (-> (db/open-cursor! ::db/posts ::db/post-created-at db/all "prev") - (db/transduce-cursor xform))]) - (.then (fn [[total displayed-posts]] - (swap! state update :section/posts #(-> (assoc % :total total) - (assoc :displayed-posts displayed-posts) - (update :loading disj refresh-id)))))))) + (. (promise-all [(db/count! ::db/posts) + (-> (db/open-cursor! ::db/posts ::db/post-created-at db/all "prev") + (db/transduce-cursor xform))]) + (then (fn [[total displayed-posts]] + (swap! state update :section/posts #(-> (assoc % :total total) + (assoc :displayed-posts displayed-posts) + (update :loading disj refresh-id)))))))) -(def debounced-refresh! (debounce 20 (partial refresh-displayed-posts!))) +(def debounced-refresh! (debounce 40 refresh-displayed-posts!)) (defn- fetch-posts! [opts] (let [defaults {:max-id nil :on-response (fn [response] - (doseq [post (:body response)] - (db/put! ::db/posts post)) + (let [url-params (url->search-params (.-url (:raw response)))] + (js/console.log :on-response + :max_id (.get url-params "max_id") + :min_id (.get url-params "min_id")) + (doseq [post (:body response)] + (db/put! ::db/posts (cond-> post + ; these IDs are internal server ids and it looks like + ; they are not returned in any response; they are + ; required for pagination, so we're storing them to be + ; able to abort and continue pagination if we want or + ; if outer circumstances decide so (for example if the + ; tab is closed) + (.get url-params "max_id") + (assoc :internal_id (.get url-params "max_id")) + + (.get url-params "min_id") + (assoc :internal_id (.get url-params "min_id")))))) (debounced-refresh! (:section/posts @state)))}] - (fetch-favorites! (merge defaults opts)))) + (paginate-posts! (merge defaults opts)))) + +(defn- internal-post-id [max-or-min] + (-> (db/open-cursor! ::db/posts ::db/post-created-at db/all (if (= max-or-min :min) + "next" + "prev")) + (db/transduce-cursor (comp (keep (j/get :internal_id)) + (take 1)) + (fn [_ x] x)))) + +(defn fetch-more-posts! [e] + (.preventDefault e) + (. (promise-all [(fetch-application-settings) (internal-post-id :min) (internal-post-id :max)]) + (then (fn [[application min-id max-id]] + (when min-id + (fetch-posts! {:instance-url (:instance_url application) + :bearer-token (:bearer_token application) + :min-id min-id})) + (when max-id + (fetch-posts! {:instance-url (:instance_url application) + :bearer-token (:bearer_token application) + :max-id max-id})) + (when-not (or min-id max-id) + (fetch-posts! {:instance-url (:instance_url application) + :bearer-token (:bearer_token application)})))))) (defn- disconnect-account! [e] (.preventDefault e) (when (js/confirm "Are you sure? This will log you out and clear your local cache.") - (. (js/Promise.all #js [(db/clear! ::db/posts) - (db/clear! ::db/application)]) + (. (promise-all [(db/clear! ::db/posts) (db/clear! ::db/application)]) (then (fn [_] (swap! state #(-> (assoc % :section :login) (assoc :section/posts posts-init-state)))))))) +(defn loading-indicator [{:keys [loading]}] + (when (seq loading) + ; see https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/d#elliptical_arc_curve + [:svg.loading-indicator {:viewBox "-10 -10 120 120" :xmlns "http://www.w3.org/2000/svg"} + [:path.arc {:d "M50,0 A50,50 180 0,1 100,50"}]])) + (defn posts-section [{:keys [posts]}] - (let [{:keys [per-page query total displayed-posts loading]} posts] + (let [{:keys [per-page query total displayed-posts]} posts + n-displayed (count displayed-posts)] [:section.posts [:h2 "Favorites"] [:header.controls [:p.display-info (str "Loaded " total " posts" (when (or query (> total per-page)) - (str ", displaying " (count displayed-posts) (when query " matches"))))] + (str ", displaying " n-displayed (when query + (if (= 1 n-displayed) + " match" + " matches")))))] [:section.search-form [search] [loading-indicator (select-keys posts [:loading])] @@ -443,7 +512,7 @@ ;; database -(def db-version 2) +(def db-version 3) (def migrations {1 (fn migration-0001 [db _] @@ -451,7 +520,10 @@ (db/create-object-store! db ::db/posts {:keyPath "id"})) 2 (fn migration-0002 [_ txn] (-> (db/open-store txn ::db/posts "readwrite") - (db/create-index! ::db/post-created-at "created_at" {:unique false})))}) + (db/create-index! ::db/post-created-at "created_at" {:unique false}))) + 3 (fn migration-0003 [_ txn] + (-> (db/open-store txn ::db/posts "readwrite") + (db/create-index! ::db/post-internal-id "internal_id" {:unique false})))}) ;; go go go From dbe15d905456d34b2f509c4b288168e02fb58b45 Mon Sep 17 00:00:00 2001 From: arne Date: Wed, 19 Nov 2025 19:50:26 +0100 Subject: [PATCH 04/10] Fix pagination direction for "fetch more" button --- src/computersandblues/lodestone/app.cljs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/computersandblues/lodestone/app.cljs b/src/computersandblues/lodestone/app.cljs index 1b9b27c..c5cd57f 100644 --- a/src/computersandblues/lodestone/app.cljs +++ b/src/computersandblues/lodestone/app.cljs @@ -387,9 +387,6 @@ (let [defaults {:max-id nil :on-response (fn [response] (let [url-params (url->search-params (.-url (:raw response)))] - (js/console.log :on-response - :max_id (.get url-params "max_id") - :min_id (.get url-params "min_id")) (doseq [post (:body response)] (db/put! ::db/posts (cond-> post ; these IDs are internal server ids and it looks like @@ -421,11 +418,11 @@ (when min-id (fetch-posts! {:instance-url (:instance_url application) :bearer-token (:bearer_token application) - :min-id min-id})) + :min-id max-id})) (when max-id (fetch-posts! {:instance-url (:instance_url application) :bearer-token (:bearer_token application) - :max-id max-id})) + :max-id min-id})) (when-not (or min-id max-id) (fetch-posts! {:instance-url (:instance_url application) :bearer-token (:bearer_token application)})))))) From a152c942ea1f53bc37397f721dea8568f067ad21 Mon Sep 17 00:00:00 2001 From: arne Date: Wed, 19 Nov 2025 20:13:28 +0100 Subject: [PATCH 05/10] Give attachments a max height --- public/index.html | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/public/index.html b/public/index.html index a910649..9124f9a 100644 --- a/public/index.html +++ b/public/index.html @@ -44,7 +44,7 @@ h3 { clear: both; - font-size: 18px; + font-size: 20px; line-height: 24px; margin-bottom: 6px; } @@ -52,6 +52,8 @@ img, video { max-width: 100%; + max-height: 100%; + width: auto; height: auto; } @@ -217,8 +219,12 @@ margin-bottom: 0 } - section.posts .post .content + .attachments { - margin-top: 18px; + section.posts .post .attachments { + margin-top: 16px; + height: 320px; + display: flex; + max-width: 100%; + overflow: auto; } section.posts .post .controls { From fa3e3ad5cb2b37b06dfda6dbb796897387faca72 Mon Sep 17 00:00:00 2001 From: arne Date: Wed, 19 Nov 2025 20:21:42 +0100 Subject: [PATCH 06/10] Disable fetch more button while posts are loaded --- src/computersandblues/lodestone/app.cljs | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/src/computersandblues/lodestone/app.cljs b/src/computersandblues/lodestone/app.cljs index c5cd57f..ed236ff 100644 --- a/src/computersandblues/lodestone/app.cljs +++ b/src/computersandblues/lodestone/app.cljs @@ -442,7 +442,7 @@ [:path.arc {:d "M50,0 A50,50 180 0,1 100,50"}]])) (defn posts-section [{:keys [posts]}] - (let [{:keys [per-page query total displayed-posts]} posts + (let [{:keys [per-page query total displayed-posts loading]} posts n-displayed (count displayed-posts)] [:section.posts [:h2 "Favorites"] @@ -456,28 +456,14 @@ " matches")))))] [:section.search-form [search] - [loading-indicator (select-keys posts [:loading])] + [loading-indicator {:loading loading}] #_(cond (= api-state :loading) " …" (= api-state :error) " API Error!")] [:section.buttons - [:button.control-button {:on-click fetch-more-posts!} "⇓ Fetch more"] + [:button.control-button {:on-click fetch-more-posts! :disabled (boolean (seq loading))} "⇓ Fetch more"] [:button.control-button {:on-click disconnect-account!} "▤ Disconnect account"]]] [:ul.results (map-indexed (fn [idx p] - ^{:key idx} [:li.result [post {:post p}]]) displayed-posts)] - #_[:div.load-buttons - [:button - {:on-click (fn [_] - (let [num-posts (count posts)] - (fetch-posts! {:continue? (fn [response] - (and (seq (:body response)) - (< (count (:favorites @state)) (+ num-posts 1000))))})))} - "Load more"] - " " - [:button - {:on-click (fn [_] - (fetch-posts! {:continue? (fn [response] - (seq (:body response)))}))} - "Load all"]]])) + ^{:key idx} [:li.result [post {:post p}]]) displayed-posts)]])) ;; help section From aec81cc1d7aef226f4a4aa6a0456ad991483ba31 Mon Sep 17 00:00:00 2001 From: arne Date: Wed, 19 Nov 2025 20:21:59 +0100 Subject: [PATCH 07/10] Match query hits in image and video descriptions --- src/computersandblues/lodestone/match.cljs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/computersandblues/lodestone/match.cljs b/src/computersandblues/lodestone/match.cljs index e964cb1..1452952 100644 --- a/src/computersandblues/lodestone/match.cljs +++ b/src/computersandblues/lodestone/match.cljs @@ -21,4 +21,5 @@ (fn [post] (or (match? (j/get post :content)) (match? (j/get-in post [:account :acct])) ; search for url + username of poster - (some #(match? (j/get % :username)) (j/get post :mentions)))))) + (some #(match? (j/get % :username)) (j/get post :mentions)) + (some #(when-let [desc (j/get % :description)] (match? desc)) (j/get post :media_attachments)))))) ; search in alt text From b7fce71a1751f4a7e76d8331eb98fb382cd99f0e Mon Sep 17 00:00:00 2001 From: arne Date: Wed, 19 Nov 2025 20:49:57 +0100 Subject: [PATCH 08/10] Fix bug in `promise-all` --- src/computersandblues/lodestone/app.cljs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/computersandblues/lodestone/app.cljs b/src/computersandblues/lodestone/app.cljs index ed236ff..565989d 100644 --- a/src/computersandblues/lodestone/app.cljs +++ b/src/computersandblues/lodestone/app.cljs @@ -35,7 +35,7 @@ ;; Mastodon API helpers (defn- promise-all [xs] - (js/Promise.all (clj->js xs))) + (js/Promise.all (apply array xs))) (defn- promise-resolve [val] (js/Promise.resolve val)) @@ -178,7 +178,7 @@ (.then (fn [application] (let [code (oauth-authorization-code (.-location js/window))] (cond - (:bearer_token application) (js/Promise.resolve application) + (:bearer_token application) application (and application code) (handle-oauth-authorization-code! From b0b4b01e371b4677e8b1e2a5074c0c18d99ed783 Mon Sep 17 00:00:00 2001 From: arne Date: Wed, 19 Nov 2025 22:14:32 +0100 Subject: [PATCH 09/10] Fix bug in debounce --- src/computersandblues/lodestone/app.cljs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/computersandblues/lodestone/app.cljs b/src/computersandblues/lodestone/app.cljs index 565989d..945705c 100644 --- a/src/computersandblues/lodestone/app.cljs +++ b/src/computersandblues/lodestone/app.cljs @@ -267,18 +267,21 @@ ;;; views -(defn debounce [ms f] +(defn debounce + "Wraps `f` so it's called at most once every `ms` milliseconds. Will schedule + the last call to `f` so that it's called after the delay has passed, and will + always prefer to call the most recent call to `f` as close to the delay + as possible." + [ms f] (let [prev (volatile! (js/Date.now)) - trail (volatile! (js/setTimeout (fn dummy []) ms))] + scheduled (volatile! (js/setTimeout (fn dummy []) ms))] (fn debounced-fn [& args] (let [now (js/Date.now)] - (if (> (- now @prev) ms) - (do (vreset! prev now) - (apply f args)) - (do (js/clearTimeout @trail) - (vreset! trail (js/setTimeout (fn [] - (vreset! prev (js/Date.now)) - (apply f args)))))))))) + (js/clearTimeout @scheduled) + (vreset! scheduled (js/setTimeout (fn [] + (vreset! prev (js/Date.now)) + (apply f args)) + (max 0 (- ms (- now @prev))))))))) (defn search [] [:input {:placeholder "Start typing to search…" From 905fe2bd88aa4e9090560616657d0d5b4598c792 Mon Sep 17 00:00:00 2001 From: arne Date: Wed, 19 Nov 2025 22:29:05 +0100 Subject: [PATCH 10/10] Add favicon --- public/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/public/index.html b/public/index.html index 9124f9a..61db30f 100644 --- a/public/index.html +++ b/public/index.html @@ -4,6 +4,7 @@ Lodestone +