Compare commits

..

No commits in common. "905fe2bd88aa4e9090560616657d0d5b4598c792" and "4fe1ea90ccd816ceae97ed663dc42cf55d6a8d5d" have entirely different histories.

4 changed files with 117 additions and 231 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

@ -4,7 +4,6 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Lodestone</title> <title>Lodestone</title>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🪨</text></svg>" />
<style> <style>
*, *:before, *:after { box-sizing: border-box; } *, *:before, *:after { box-sizing: border-box; }
html, body { padding: 0; margin: 0; } html, body { padding: 0; margin: 0; }
@ -45,7 +44,7 @@
h3 { h3 {
clear: both; clear: both;
font-size: 20px; font-size: 18px;
line-height: 24px; line-height: 24px;
margin-bottom: 6px; margin-bottom: 6px;
} }
@ -53,8 +52,6 @@
img, img,
video { video {
max-width: 100%; max-width: 100%;
max-height: 100%;
width: auto;
height: auto; height: auto;
} }
@ -72,10 +69,6 @@
vertical-align: middle; vertical-align: middle;
} }
p.intro {
font-size: 20px;
}
#root { #root {
clear: both; clear: both;
} }
@ -89,11 +82,6 @@
background: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.8);
} }
button[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
section.login label, section.login label,
section.login input { section.login input {
display: block; display: block;
@ -108,61 +96,20 @@
section.posts .controls { section.posts .controls {
padding: 0 0 36px; padding: 0 0 36px;
display: grid;
grid-template:
"a"
"b"
"c";
} }
section.posts .controls .search-form input { section.posts .controls .search-form input {
width: 80%; 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;
}
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) { @media screen and (min-width: 640px) {
section.posts .controls {
display: grid;
grid-template:
"a a"
"b c";
}
section.posts .controls .post-info { section.posts .controls .post-info {
grid-area: a; grid-area: a;
} }
@ -177,12 +124,14 @@
section.posts .controls .buttons { section.posts .controls .buttons {
grid-area: c; grid-area: c;
display: flex;
justify-content: end;
} }
} }
section.posts .controls .control-button { section.posts .controls .control-button {
padding: 6px; padding: 6px;
margin: 6px 0 6px 12px; margin: 6px 0;
background: #f5e6ab; background: #f5e6ab;
color: #444; color: #444;
cursor: pointer; cursor: pointer;
@ -220,24 +169,22 @@
margin-bottom: 0 margin-bottom: 0
} }
section.posts .post .attachments { section.posts .post .content + .attachments {
margin-top: 16px; margin-top: 18px;
height: 320px;
display: flex;
max-width: 100%;
overflow: auto;
} }
section.posts .post .controls { section.posts .post .controls {
margin: 24px 0 0; margin: 24px 0 0;
padding: 0; padding: 0;
list-style-type: none; list-style-type: none;
display: flex;
justify-content: end;
flex-wrap: wrap;
} }
@media screen and (min-width: 640px) { @media screen and (min-width: 640px) {
section.posts .post .controls {
display: flex;
justify-content: end
}
section.posts .post .controls .control-element a { section.posts .post .controls .control-element a {
margin: 0 0 0 12px; margin: 0 0 0 12px;
} }
@ -268,10 +215,9 @@
</style> </style>
</head> </head>
<body> <body>
<img class="illustration" src="./img/lodestone_attracting_nails_small.jpeg" width="150" /> <img class="illustration" src="./img/lodestone_attracting_nails.png" width="150" />
<h1>Lodestone</h1> <h1>Lodestone</h1>
<p class="intro">Lodestone helps you navigate the Fediverse.</p> <p>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.</p>
<p>It gives you a tool to quickly search through your favorite posts, and helps you rediscover the things you found relevant.</p>
<div id="root"> <div id="root">
Loading application… Loading application…
<noscript>Please turn on JavaScript to run the application.</noscript> <noscript>Please turn on JavaScript to run the application.</noscript>

View file

@ -4,8 +4,7 @@
[clojure.string :as str] [clojure.string :as str]
[clojure.pprint :as pprint] [clojure.pprint :as pprint]
[computersandblues.lodestone.database :as db] [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 (def posts-init-state
{:query nil {:query nil
@ -28,21 +27,13 @@
:section/posts posts-init-state})) :section/posts posts-init-state}))
; TODO Ensure that cached data is up to date ; TODO Ensure that cached data is up to date
; TODO Manually fetch older / newer favorites
; TODO Handle 429 ; TODO Handle 429
; TODO Search for tags (`#foo`) and handles (`@bar`) ; TODO Search for tags (`#foo`) and handles (`@bar`)
; TODO Explain which kind of search currently is possible ; TODO Explain which kind of search currently is possible
;; Mastodon API helpers ;; 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 (defn- link-header
"Given a JS `Response` object, will parse the `link` header and find a link of "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." a given `link-type` if present. Useful for paginating API requests."
@ -68,6 +59,8 @@
"Small helper function to send authorized requests to mastodon-compatible APIs" "Small helper function to send authorized requests to mastodon-compatible APIs"
[{:keys [url method bearer-token payload] [{:keys [url method bearer-token payload]
:or {method :get}}] :or {method :get}}]
(js/Promise.
(fn [resolve reject]
(. (js/fetch url (. (js/fetch url
(clj->js (cond-> {:method (str/upper-case (name method))} (clj->js (cond-> {:method (str/upper-case (name method))}
bearer-token (assoc-in [:headers :authorization] (str "Bearer " bearer-token)) bearer-token (assoc-in [:headers :authorization] (str "Bearer " bearer-token))
@ -79,16 +72,13 @@
(-> (.json res) (-> (.json res)
(.then (.then
(fn [body] (fn [body]
(promise-resolve {:raw res (resolve {:raw res
:body (js->clj body {:keywordize-keys true})})))) :body (js->clj body {:keywordize-keys true})}))))
(promise-reject res)))))) (reject res))))))))
(defn- ->search-params [params] (defn- search-params [params]
(js/URLSearchParams. (clj->js 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) ;; all of the app's sections (i.e. different views / pieces of functionality)
;; login & application setup ;; login & application setup
@ -109,22 +99,20 @@
(set! (.-location js/window) (set! (.-location js/window)
(str (:instance_url application) (str (:instance_url application)
"/oauth/authorize?" "/oauth/authorize?"
(->search-params {:response_type "code" (search-params {:response_type "code"
:client_id (:client_id application) :client_id (:client_id application)
:redirect_uri (:redirect_uri application) ; TODO handle multiple reidrect uris? :redirect_uri (:redirect_uri application) ; TODO handle multiple reidrect uris?
:scope "read:favourites"})))) :scope "read:favourites"}))))
(defn oauth-authorization-code [location] (defn oauth-authorization-code [location]
(-> (.-search location) (.get (js/URLSearchParams. (.-search location)) "code"))
(js/URLSearchParams.)
(.get "code")))
(defn handle-oauth-authorization-code! [{:keys [application code]}] (defn handle-oauth-authorization-code! [{:keys [application code]}]
(-> (->
(mastodon-request! {:method :post (mastodon-request! {:method :post
:url (str (:instance_url application) :url (str (:instance_url application)
"/oauth/token?" "/oauth/token?"
(->search-params {:grant_type "authorization_code" (search-params {:grant_type "authorization_code"
:code code :code code
:client_id (:client_id application) :client_id (:client_id application)
:client_secret (:client_secret application) :client_secret (:client_secret application)
@ -155,12 +143,6 @@
(declare fetch-posts!) (declare fetch-posts!)
(declare refresh-displayed-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! (defn setup-application!
"Handles Mastodon application setup on the client side" "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 ; 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 ; `create-remote-application!` function that is called once the user submits
; the form with their instance URL. ; the form with their instance URL.
(-> (fetch-application-settings) (-> (db/open-cursor! ::db/application db/all)
(.then (fn [application] (db/transduce-cursor (comp (take 1)
(map #(js->clj % :keywordize-keys true))))
(.then (fn [[application]]
(let [code (oauth-authorization-code (.-location js/window))] (let [code (oauth-authorization-code (.-location js/window))]
(cond (cond
(:bearer_token application) application (:bearer_token application)
(js/Promise.resolve application)
(and application code) (and application code)
(handle-oauth-authorization-code! (handle-oauth-authorization-code!
@ -196,12 +181,16 @@
(.then (fn [application] (.then (fn [application]
(when application (when application
(swap! state assoc :section :posts) (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]] (.then (fn [[application post-count]]
(when post-count (when post-count
(if (zero? post-count) (if (zero? post-count)
(fetch-posts! {:instance-url (:instance_url application) (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)))))))) (refresh-displayed-posts! (:section/posts @state))))))))
;;; views ;;; views
@ -234,54 +223,49 @@
;;; api interaction ;;; 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}}] :or {limit 40}}]
(let [params (->search-params (cond-> {:limit limit} (let [params (search-params (cond-> {:limit limit}
max-id (assoc :max_id max-id) max-id (assoc :max_id max-id)))]
min-id (assoc :min_id min-id)))]
(str instance-url "/api/v1/favourites?" params))) (str instance-url "/api/v1/favourites?" params)))
(defn paginate-posts! (defn fetch-favorites!
[{:keys [instance-url bearer-token [{:keys [instance-url bearer-token max-id
max-id min-id
on-response on-error continue?] on-response on-error continue?]
:or {continue? (fn [response] :or {continue? (fn [response]
(seq (:body response))) (seq (:body response)))
on-response on-response on-response on-response
on-error on-error}}] 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)] (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) (swap! state update-in [:section/posts :loading] conj req-id)
(-> (mastodon-request! {:url url :bearer-token bearer-token}) (-> (mastodon-request! {:url url :bearer-token bearer-token})
(.then (fn [response] (.then (fn [response]
(let [next-url (link-header "next" (:raw response))]
(swap! state update-in [:section/posts :loading] disj req-id)
(on-response response) (on-response response)
(when (and (continue? response) next-url) (if (continue? response)
(js/setTimeout #(paginate! next-url) 500))))) (js/setTimeout #(fetch-favorites' (link-header "next" (:raw response))) 500)
(swap! state update-in [:section/posts :loading] disj req-id))))
(.catch (fn [response] (.catch (fn [response]
(swap! state update-in [:section/posts :loading] disj req-id) (swap! state update-in [:section/posts :loading] disj req-id)
(on-error response)))))) (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 ;;; views
(defn debounce (defn debounce [ms f]
"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)) (let [prev (volatile! (js/Date.now))
scheduled (volatile! (js/setTimeout (fn dummy []) ms))] trail (volatile! (js/setTimeout (fn dummy []) ms))]
(fn debounced-fn [& args] (fn debounced-fn [& args]
(let [now (js/Date.now)] (let [now (js/Date.now)]
(js/clearTimeout @scheduled) (if (> (- now @prev) ms)
(vreset! scheduled (js/setTimeout (fn [] (do (vreset! prev now)
(vreset! prev (js/Date.now))
(apply f args)) (apply f args))
(max 0 (- ms (- now @prev))))))))) (do (js/clearTimeout @trail)
(vreset! trail (js/setTimeout (fn []
(vreset! prev (js/Date.now))
(apply f args))))))))))
(defn search [] (defn search []
[:input {:placeholder "Start typing to search…" [:input {:placeholder "Start typing to search…"
@ -357,14 +341,6 @@
(js/navigator.clipboard.writeText (:url post)))} "◎ Copy URL to clipboard"]]]] (js/navigator.clipboard.writeText (:url post)))} "◎ Copy URL to clipboard"]]]]
#_[debug post]]) #_[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! (defn- refresh-displayed-posts!
[posts-section] [posts-section]
(let [{:keys [per-page query]} posts-section (let [{:keys [per-page query]} posts-section
@ -376,97 +352,65 @@
(map #(js->clj % :keywordize-keys true))) (map #(js->clj % :keywordize-keys true)))
refresh-id (js/Date.now)] refresh-id (js/Date.now)]
(swap! state update-in [:section/posts :loading] conj refresh-id) (swap! state update-in [:section/posts :loading] conj refresh-id)
(. (promise-all [(db/count! ::db/posts) (-> (js/Promise.all #js [(db/count! ::db/posts)
(-> (db/open-cursor! ::db/posts ::db/post-created-at db/all "prev") (-> (db/open-cursor! ::db/posts ::db/post-created-at db/all "prev")
(db/transduce-cursor xform))]) (db/transduce-cursor xform))])
(then (fn [[total displayed-posts]] (.then (fn [[total displayed-posts]]
(swap! state update :section/posts #(-> (assoc % :total total) (swap! state update :section/posts #(-> (assoc % :total total)
(assoc :displayed-posts displayed-posts) (assoc :displayed-posts displayed-posts)
(update :loading disj refresh-id)))))))) (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] (defn- fetch-posts! [opts]
(let [defaults {:max-id nil (let [defaults {:max-id nil
:on-response (fn [response] :on-response (fn [response]
(let [url-params (url->search-params (.-url (:raw response)))]
(doseq [post (:body response)] (doseq [post (:body response)]
(db/put! ::db/posts (cond-> post (db/put! ::db/posts 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)))}] (debounced-refresh! (:section/posts @state)))}]
(paginate-posts! (merge defaults opts)))) (fetch-favorites! (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] (defn- disconnect-account! [e]
(.preventDefault e) (.preventDefault e)
(when (js/confirm "Are you sure? This will log you out and clear your local cache.") (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 [_] (then (fn [_]
(swap! state #(-> (assoc % :section :login) (swap! state #(-> (assoc % :section :login)
(assoc :section/posts posts-init-state)))))))) (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]}] (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 [:section.posts
[:h2 "Favorites"] [:h2 "Favorites"]
[:header.controls [:header.controls
[:p.display-info [:p.display-info
(str "Loaded " total " posts" (str "Loaded " total " posts"
(when (or query (> total per-page)) (when (or query (> total per-page))
(str ", displaying " n-displayed (when query (str ", displaying " (count displayed-posts) (when query " matches"))))]
(if (= 1 n-displayed)
" match"
" matches")))))]
[:section.search-form [:section.search-form
[search] [search]
[loading-indicator {:loading loading}] (when (seq loading) " …")
#_(cond (= api-state :loading) " …" #_(cond (= api-state :loading) " …"
(= api-state :error) " API Error!")] (= api-state :error) " API Error!")]
[:section.buttons [: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"]]] [:button.control-button {:on-click disconnect-account!} "▤ Disconnect account"]]]
[:ul.results (map-indexed (fn [idx p] [: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 ;; help section
@ -498,7 +442,7 @@
;; database ;; database
(def db-version 3) (def db-version 2)
(def migrations (def migrations
{1 (fn migration-0001 [db _] {1 (fn migration-0001 [db _]
@ -506,10 +450,7 @@
(db/create-object-store! db ::db/posts {:keyPath "id"})) (db/create-object-store! db ::db/posts {:keyPath "id"}))
2 (fn migration-0002 [_ txn] 2 (fn migration-0002 [_ txn]
(-> (db/open-store txn ::db/posts "readwrite") (-> (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 ;; go go go

View file

@ -21,5 +21,4 @@
(fn [post] (fn [post]
(or (match? (j/get post :content)) (or (match? (j/get post :content))
(match? (j/get-in post [:account :acct])) ; search for url + username of poster (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