diff --git a/public/img/lodestone_attracting_nails_small.jpeg b/public/img/lodestone_attracting_nails_small.jpeg deleted file mode 100644 index f79b97e..0000000 Binary files a/public/img/lodestone_attracting_nails_small.jpeg and /dev/null differ diff --git a/public/index.html b/public/index.html index 61db30f..342ea1c 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,6 @@ Lodestone - - +

Lodestone

-

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.

+

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.

Loading application… diff --git a/src/computersandblues/lodestone/app.cljs b/src/computersandblues/lodestone/app.cljs index 945705c..0b19609 100644 --- a/src/computersandblues/lodestone/app.cljs +++ b/src/computersandblues/lodestone/app.cljs @@ -4,8 +4,7 @@ [clojure.string :as str] [clojure.pprint :as pprint] [computersandblues.lodestone.database :as db] - [computersandblues.lodestone.match :refer [query->matching-fn]] - [applied-science.js-interop :as j])) + [computersandblues.lodestone.match :refer [query->matching-fn]])) (def posts-init-state {:query nil @@ -28,21 +27,13 @@ :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 (apply array 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." @@ -68,27 +59,26 @@ "Small helper function to send authorized requests to mastodon-compatible APIs" [{:keys [url method bearer-token payload] :or {method :get}}] - (. (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)))))) + (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)))))))) -(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 @@ -109,26 +99,24 @@ (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] - (-> (.-search location) - (js/URLSearchParams.) - (.get "code"))) + (.get (js/URLSearchParams. (.-search location)) "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)] @@ -155,12 +143,6 @@ (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" [] @@ -174,11 +156,14 @@ ; 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. - (-> (fetch-application-settings) - (.then (fn [application] + (-> (db/open-cursor! ::db/application db/all) + (db/transduce-cursor (comp (take 1) + (map #(js->clj % :keywordize-keys true)))) + (.then (fn [[application]] (let [code (oauth-authorization-code (.-location js/window))] (cond - (:bearer_token application) application + (:bearer_token application) + (js/Promise.resolve application) (and application code) (handle-oauth-authorization-code! @@ -196,12 +181,16 @@ (.then (fn [application] (when application (swap! state assoc :section :posts) - (promise-all [application (db/count! ::db/posts)])))) + (js/Promise.all #js [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)}) + :bearer-token (:bearer_token application) + :continue? + (fn [response] + (and (seq (:body response)) + (< (count (:favorites @state)) 500)))}) (refresh-displayed-posts! (:section/posts @state)))))))) ;;; views @@ -234,54 +223,49 @@ ;;; api interaction -(defn- favorites-url [{:keys [instance-url limit max-id min-id] +(defn- favorites-url [{:keys [instance-url limit max-id] :or {limit 40}}] - (let [params (->search-params (cond-> {:limit limit} - max-id (assoc :max_id max-id) - min-id (assoc :min_id min-id)))] + (let [params (search-params (cond-> {:limit limit} + max-id (assoc :max_id max-id)))] (str instance-url "/api/v1/favourites?" params))) -(defn paginate-posts! - [{:keys [instance-url bearer-token - max-id min-id +(defn fetch-favorites! + [{:keys [instance-url bearer-token max-id on-response on-error continue?] :or {continue? (fn [response] (seq (:body response))) on-response on-response on-error on-error}}] - ((fn paginate! [url] + (js/console.log 'fetch-favorites! instance-url max-id bearer-token) + ((fn fetch-favorites' [url] (let [req-id (js/Date.now)] - (js/console.log :paginate! url :max-id max-id :min-id min-id) + (println :calling url) (swap! state update-in [:section/posts :loading] conj req-id) (-> (mastodon-request! {:url url :bearer-token bearer-token}) (.then (fn [response] - (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))))) + (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)))) (.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 :min-id min-id}))) + (favorites-url {:instance-url instance-url :max-id max-id}))) ;;; views -(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] +(defn debounce [ms f] (let [prev (volatile! (js/Date.now)) - scheduled (volatile! (js/setTimeout (fn dummy []) ms))] + trail (volatile! (js/setTimeout (fn dummy []) ms))] (fn debounced-fn [& args] (let [now (js/Date.now)] - (js/clearTimeout @scheduled) - (vreset! scheduled (js/setTimeout (fn [] - (vreset! prev (js/Date.now)) - (apply f args)) - (max 0 (- ms (- now @prev))))))))) + (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)))))))))) (defn search [] [:input {:placeholder "Start typing to search…" @@ -357,14 +341,6 @@ (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 @@ -376,97 +352,65 @@ (map #(js->clj % :keywordize-keys true))) refresh-id (js/Date.now)] (swap! state update-in [:section/posts :loading] conj 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)))))))) + (-> (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)))))))) -(def debounced-refresh! (debounce 40 refresh-displayed-posts!)) +(def debounced-refresh! (debounce 20 (partial refresh-displayed-posts!))) (defn- fetch-posts! [opts] (let [defaults {:max-id nil :on-response (fn [response] - (let [url-params (url->search-params (.-url (:raw response)))] - (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")))))) + (doseq [post (:body response)] + (db/put! ::db/posts post)) (debounced-refresh! (:section/posts @state)))}] - (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 max-id})) - (when max-id - (fetch-posts! {:instance-url (:instance_url application) - :bearer-token (:bearer_token application) - :max-id min-id})) - (when-not (or min-id max-id) - (fetch-posts! {:instance-url (:instance_url application) - :bearer-token (:bearer_token application)})))))) + (fetch-favorites! (merge defaults opts)))) (defn- disconnect-account! [e] (.preventDefault e) (when (js/confirm "Are you sure? This will log you out and clear your local cache.") - (. (promise-all [(db/clear! ::db/posts) (db/clear! ::db/application)]) + (. (js/Promise.all #js [(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 - n-displayed (count displayed-posts)] + (let [{:keys [per-page query total displayed-posts loading]} posts] [:section.posts [:h2 "Favorites"] [:header.controls [:p.display-info (str "Loaded " total " posts" (when (or query (> total per-page)) - (str ", displaying " n-displayed (when query - (if (= 1 n-displayed) - " match" - " matches")))))] + (str ", displaying " (count displayed-posts) (when query " matches"))))] [:section.search-form [search] - [loading-indicator {:loading loading}] + (when (seq loading) " …") #_(cond (= api-state :loading) " …" (= api-state :error) " API Error!")] [:section.buttons - [: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)]])) + ^{: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"]]])) ;; help section @@ -498,7 +442,7 @@ ;; database -(def db-version 3) +(def db-version 2) (def migrations {1 (fn migration-0001 [db _] @@ -506,10 +450,7 @@ (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}))) - 3 (fn migration-0003 [_ txn] - (-> (db/open-store txn ::db/posts "readwrite") - (db/create-index! ::db/post-internal-id "internal_id" {:unique false})))}) + (db/create-index! ::db/post-created-at "created_at" {:unique false})))}) ;; go go go diff --git a/src/computersandblues/lodestone/match.cljs b/src/computersandblues/lodestone/match.cljs index 1452952..e964cb1 100644 --- a/src/computersandblues/lodestone/match.cljs +++ b/src/computersandblues/lodestone/match.cljs @@ -21,5 +21,4 @@ (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 #(when-let [desc (j/get % :description)] (match? desc)) (j/get post :media_attachments)))))) ; search in alt text + (some #(match? (j/get % :username)) (j/get post :mentions))))))