diff --git a/public/img/lodestone_attracting_nails_small.jpeg b/public/img/lodestone_attracting_nails_small.jpeg
new file mode 100644
index 0000000..f79b97e
Binary files /dev/null and b/public/img/lodestone_attracting_nails_small.jpeg differ
diff --git a/public/index.html b/public/index.html
index 342ea1c..61db30f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,6 +4,7 @@
Lodestone
+
-
+
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…
Please turn on JavaScript to run the application.
diff --git a/src/computersandblues/lodestone/app.cljs b/src/computersandblues/lodestone/app.cljs
index 0b19609..945705c 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 (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."
@@ -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) 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,49 +234,54 @@
;;; 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
-(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…"
@@ -341,6 +357,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,65 +376,97 @@
(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)))]
+ (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 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)}))))))
(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 loading]} 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]
- (when (seq loading) " …")
+ [loading-indicator {:loading 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)]
- #_[: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
@@ -442,7 +498,7 @@
;; database
-(def db-version 2)
+(def db-version 3)
(def migrations
{1 (fn migration-0001 [db _]
@@ -450,7 +506,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
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