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