diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 4dc3da3ab3..0000000000 --- a/.babelrc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "presets": [ - "@babel/env", - // Compile tsx files. - "@babel/preset-typescript", - ["@babel/preset-react", {"runtime": "automatic"}] - ], - "plugins": [ - // Allow using @/ for root relative imports. - ["module-resolver", {"alias": {"@": "./src/js/components"}}], - // Our build doesn't need the {"loose": true} option, but if not included it wil - // show a lot of warnings on the storybook build. - ["@babel/proposal-class-properties", {"loose": true}], - ["@babel/proposal-object-rest-spread", {"loose": true}], - // Used only by storybook, but must be included to avoid build warnings/errors. - ["@babel/plugin-proposal-private-methods", {"loose": true}], - ["@babel/plugin-proposal-private-property-in-object", {"loose": true}] - ] -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 954e41e4af..03c5a3a527 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ dist # design system static build /storybook-static +/src/gen \ No newline at end of file diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000000..26b09b143c --- /dev/null +++ b/babel.config.js @@ -0,0 +1,80 @@ +const {existsSync, lstatSync} = require("fs"); +const {resolve, dirname} = require("path"); + +function isRelativeImport(path){ + return path.startsWith("."); +} + +function isDirectory(path) { + return existsSync(path) && lstatSync(path).isDirectory(); +} + +function resolveImport (from, to) { + return resolve(dirname(from), to); +} + +function replaceDirectoryImports() { + return { + visitor: { + ImportDeclaration: (path, state) => { + const importPath = path.node.source.value; + const fileName = state.file.opts.filename; + if (isRelativeImport(importPath) && isDirectory(resolveImport(fileName, importPath))) { + path.node.source.value += "/index"; + } + } + } + } +} + + +// This config will output files to ./src/gen/components via the `yarn components` script +// See https://shadow-cljs.github.io/docs/UsersGuide.html#_javascript_dialects +module.exports = { + presets: [ + "@babel/env", + // Compile tsx files. + "@babel/preset-typescript", + // Use the react runtime import if available. + ["@babel/preset-react", {"runtime": "automatic"}] + ], + plugins: [ + // Add /index to all relative directory imports, because Shadow-CLJS does not support + // them (https://github.com/thheller/shadow-cljs/issues/841#issuecomment-777323477) + // NB: Putting these files in node_modules would have fixed the directory imports + // but broken hot reload (https://github.com/thheller/shadow-cljs/issues/764#issuecomment-663064549) + replaceDirectoryImports, + // Allow using @/ for root relative imports in the component library. + ["module-resolver", {alias: {"@": "./src/js/components"}}], + // Transform material-ui imports into deep imports for faster reload. + // material-ui is very big, and importing it all can slow down development rebuilds by a lot. + // https://material-ui.com/guides/minimizing-bundle-size/#development-environment + ["transform-imports", { + "@material-ui/core": { + transform: "@material-ui/core/esm/${member}", + preventFullImport: true + }, + "@material-ui/icons": { + transform: "@material-ui/icons/esm/${member}", + preventFullImport: true + } + }], + // Our build doesn't need the {loose: true} option, but if not included it wil + // show a lot of warnings on the storybook build. + ["@babel/proposal-class-properties", {loose: true}], + ["@babel/proposal-object-rest-spread", {loose: true}], + // Used only by storybook, but must be included to avoid build warnings/errors. + ["@babel/plugin-proposal-private-methods", {loose: true}], + ["@babel/plugin-proposal-private-property-in-object", {loose: true}], + // Import helpers from @babel/runtime instead of duplicating them everywhere. + "@babel/plugin-transform-runtime", + // Better debug information for styled components. + // https://styled-components.com/docs/tooling#babel-plugin + "babel-plugin-styled-components" + ], + // Do not apply this babel config to node_modules. + // Shadow-CLJS also runs babel over node_modules and we don't want this + // configuration to apply to it. + // We still want it to be picked up by storybook though. + exclude: ["node_modules"] +} diff --git a/package.json b/package.json index 8bae4d9724..2db64dac81 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,11 @@ "update": "standard-version -p --releaseCommitMessageFormat v{{currentTag}}", "dev": "yarn components && concurrently \"yarn components:watch\" \"yarn client:watch\"", "client:watch": "shadow-cljs watch main renderer app", - "components": "babel ./src/js/components/ --extensions \".ts,.tsx\" --out-dir ./dist/js/components/", + "components": "babel ./src/js/components/ --extensions \".ts,.tsx\" --out-dir ./src/gen/components/", "components:watch": "yarn components --watch", "compile": "yarn components && shadow-cljs compile main renderer app", "prod": "yarn components && shadow-cljs release main renderer app", - "clean": "rm -rf resources/public/**/*.js target .shadow-cljs ./src/stories/**/*.js", + "clean": "rm -rf resources/public/**/*.js target .shadow-cljs src/gen", "dist": "electron-builder -p always", "storybook:watch": "start-storybook -p 6006", "storybook": "build-storybook", @@ -59,6 +59,7 @@ } }, "dependencies": { + "@babel/runtime": "^7.15.4", "@geometricpanda/storybook-addon-badges": "^0.0.4", "@js-joda/core": "1.12.0", "@js-joda/locale_en-us": "3.1.1", @@ -100,6 +101,7 @@ "@babel/plugin-proposal-object-rest-spread": "^7.15.6", "@babel/plugin-proposal-private-methods": "^7.14.5", "@babel/plugin-proposal-private-property-in-object": "^7.15.4", + "@babel/plugin-transform-runtime": "^7.15.0", "@babel/preset-env": "^7.15.6", "@babel/preset-react": "^7.14.5", "@babel/preset-typescript": "^7.15.0", @@ -110,6 +112,8 @@ "@storybook/react": "^6.3.8", "babel-loader": "^8.2.2", "babel-plugin-module-resolver": "^4.1.0", + "babel-plugin-styled-components": "^1.13.2", + "babel-plugin-transform-imports": "^2.0.0", "concurrently": "^6.2.1", "electron": "^12.0.4", "electron-builder": "22.10", diff --git a/project.clj b/project.clj index 7fd0fc3f20..26f524b80c 100644 --- a/project.clj +++ b/project.clj @@ -57,7 +57,7 @@ :min-lein-version "2.5.3" - :source-paths ["src/clj" "src/cljs" "src/cljc" "src/js" "dist/js"] + :source-paths ["src/clj" "src/cljs" "src/cljc" "src/js" "src/gen"] :main athens.self-hosted.core :aot [athens.self-hosted.core] diff --git a/src/cljs/athens/self_hosted/presence/events.cljs b/src/cljs/athens/self_hosted/presence/events.cljs index b434da079b..d1cd89d536 100644 --- a/src/cljs/athens/self_hosted/presence/events.cljs +++ b/src/cljs/athens/self_hosted/presence/events.cljs @@ -43,6 +43,12 @@ (update-in [:presence :users new-username] assoc :username new-username)))) +(rf/reg-event-db + :presence/update-color + (fn [db [_ username color]] + (assoc-in db [:presence :users username :color] color))) + + (rf/reg-event-fx :presence/send-rename (fn [_ [_ current-username new-username]] diff --git a/src/cljs/athens/self_hosted/presence/subs.cljs b/src/cljs/athens/self_hosted/presence/subs.cljs index 31e5e81450..d128640146 100644 --- a/src/cljs/athens/self_hosted/presence/subs.cljs +++ b/src/cljs/athens/self_hosted/presence/subs.cljs @@ -21,6 +21,18 @@ users)))) +(rf/reg-sub + :presence/current-user + :<- [:presence/users-with-page-data] + :<- [:settings] + (fn [[users settings] [_]] + (-> (filter (fn [[_ user]] + (= (:username settings) (:username user))) + users) + first + second))) + + (rf/reg-sub :presence/same-page :<- [:presence/users-with-page-data] @@ -34,7 +46,7 @@ (= current-route-uid (:page/uid user))) users)) - []))) + {}))) (rf/reg-sub diff --git a/src/cljs/athens/self_hosted/presence/views.cljs b/src/cljs/athens/self_hosted/presence/views.cljs index a95f33a014..9a98ad8801 100644 --- a/src/cljs/athens/self_hosted/presence/views.cljs +++ b/src/cljs/athens/self_hosted/presence/views.cljs @@ -1,20 +1,14 @@ (ns athens.self-hosted.presence.views (:require - ["/components/Button/Button" :refer [Button]] - ["@material-ui/core/Popover" :as Popover] - ["@material-ui/icons/Link" :default Link] + ["/components/PresenceDetails/PresenceDetails" :refer [PresenceDetails]] [athens.self-hosted.presence.events] [athens.self-hosted.presence.fx] [athens.self-hosted.presence.subs] - [athens.style :as style] [re-frame.core :as rf] [reagent.core :as r] [stylefy.core :as stylefy :refer [use-style]])) -(def m-popover (r/adapt-react-class (.-default Popover))) - - ;; Avatar @@ -59,177 +53,78 @@ initials]]))) -(def ^:private avatar-stack-style - {:display "flex" - ::stylefy/manual [[:svg {:width "1.5rem" - :height "1.5rem"} - ;; In a stack, each sequential item sucks in the spacing - ;; from the item before it - ["&:not(:first-child)" {:margin-left "-0.8rem"}] - ;; All but the last get a slice masked out for readability - ;; - ;; I'm not clear on why 1.55rem / 1.1rem work in this case - ;; It'd be nice to have a simpler masking method - ;; or a better-constructed string with some documentation - ["&:not(:last-child)" {:mask-image "radial-gradient(1.55rem 1.1rem at 160% 50%, transparent calc(96%), #000 100%)" - :-webkit-mask-image "radial-gradient(1.55rem 1.1rem at 160% 50%, transparent calc(96%), #000 100%)"}]]]}) - - -(defn- avatar-stack-el - [& children] - [:div (use-style avatar-stack-style) - children]) +(defn user->person + [{:keys [username color]}] + ;; TODO: have a real notion of user-id, not just username. + {:personId username + :username username + :color color}) -;; List +(defn copy-host-address-to-clipboard + [host-address] + (.. js/navigator -clipboard (writeText host-address)) + (rf/dispatch [:show-snack-msg {:msg "Host address copied to clipboard" + :type :success}])) -(defn- list-el - [& children] - [:ul (use-style {:padding 0 - :margin 0 - :display "flex" - :flex-direction "column" - :list-style "none"}) - children]) +(defn go-to-user-block + [all-users js-person] + (let [{_block-uid :block/uid + page-uid :page/uid} + (->> (js->clj js-person :keywordize-keys true) + :username + (get all-users))] + (rf/dispatch (if page-uid + ;; TODO: if we support navigating to a block, it should be added here. + [:navigate :page {:id page-uid}] + [:show-snack-msg {:msg "User is not on any page" + :type :success}])))) -(defn- list-header-el - [& children] - [:header (use-style {:border-bottom "1px solid #ddd" - :padding "0.25rem 0.5rem" - :display "flex" - :justify-content "space-between" - :align-items "center"}) - children]) - -(defn- list-section-header-el - [& children] - [:li (use-style {:font-size "12px" - :font-weight "bold" - :opacity "0.5" - :padding "1rem 1rem 0.25rem"}) - children]) - - -(defn- list-header-url-el - [& children] - [:span (use-style {:font-size "12px" - :font-weight "700" - :display "inline-block" - :opacity "0.5" - :padding "0.5rem" - :user-select "all" - :margin-right "1em" - :flex "1 1 100%" - :white-space "nowrap" - :text-overflow "hidden"}) - children]) - - -(defn- list-separator-el - [] - [:li (use-style {:margin "0.5rem 0 0.5rem 1rem" - :border-bottom "1px solid #ddd"})]) - - -(def ^:private member-list-item-style - {:padding "0.375rem 1rem" - :display "flex" - :font-size "14px" - :align-items "center" - :font-weight "600" - :color (style/color :body-text-color :opacity-higher) - :transition "backdrop-filter 0.1s ease" - :cursor "default" - ::stylefy/manual [[:svg {:margin-right "0.25rem"}]]}) - - -;; turn off interactive button stylings until we implement interactions like "jump" or "follow" -;; [:&:hover {:background (style/color :body-text-color :opacity-lower)}] -;; [:&:active -;; :&:hover:active -;; :&.is-active {:color (style/color :body-text-color) -;; :background (style/color :body-text-color :opacity-lower)}] -;; [:&:active -;; :&:hover:active -;; :&:active.is-active {:background (style/color :body-text-color :opacity-low)}] -;; [:&:disabled :&:disabled:active {:color (style/color :body-text-color :opacity-low) -;; :background (style/color :body-text-color :opacity-lower) -;; :cursor "default"}]]}) - - - -(defn- member-item-el - [user props] - [:li (use-style member-list-item-style #_{:on-click #(prn user)}) - [avatar-el user props] - (:username user)]) +(defn edit-current-user + [current-username js-person] + (let [{:keys [username color]} (js->clj js-person :keywordize-keys true)] + (rf/dispatch [:settings/update :username username]) + (rf/dispatch [:settings/update :color color]) + ;; Change the color of the old name immediately, then wait for the + ;; rename to happen in the server. + (rf/dispatch [:presence/update-color current-username color]) + (rf/dispatch [:presence/send-rename current-username username]))) ;; Exports + (defn toolbar-presence-el [] - (r/with-let [ele (r/atom nil)] - (let [users (rf/subscribe [:presence/users-with-page-data]) - same-page-users (rf/subscribe [:presence/same-page]) - diff-page-users (rf/subscribe [:presence/diff-page]) - current-route-name (rf/subscribe [:current-route/name])] - [:<> - - ;; Preview - [:> Button {:on-click #(reset! ele (.-currentTarget %))} - [avatar-stack-el - (cond - - (= @current-route-name :page) - [:<> - ;; same page - (for [[username user] @same-page-users] - ^{:key username} - [avatar-el user {:filled true}]) - ;; diff page but online - (for [[username user] @diff-page-users] - ^{:key username} - [avatar-el user {:filled false}])] - - ;; TODO: capture what page user is scrolled to on Daily Notes - ;; (= @current-route-name :home) - ;; [:div "TODO"] - - ;; default to showing all users - :else (for [[username user] @users] - ^{:key username} - [avatar-el user {:filled false}]))]] - - ;; Dropdown - [m-popover - {:open (boolean @ele) - :anchorEl @ele - :onClose #(reset! ele nil) - :anchorOrigin #js{:vertical "bottom" - :horizontal "center"} - :transformOrigin #js{:vertical "top" - :horizontal "center"}} - [list-header-el - [list-header-url-el "ath.ns/34op5fds0a"] - [:> Button [:> Link]]] - - [list-el - ;; On same page - - (when-not (empty? @same-page-users) - [:<> - [list-section-header-el "On This Page"] - (for [[username user] @same-page-users] - ^{:key username} - [member-item-el user {:filled true}]) - [list-separator-el]]) - - ;; Online, different page - (for [[username user] @diff-page-users] - ^{:key username} - [member-item-el user {:filled false}])]]]))) + (r/with-let [selected-db (rf/subscribe [:db-picker/selected-db]) + current-user (rf/subscribe [:presence/current-user]) + all-users (rf/subscribe [:presence/users-with-page-data]) + same-page (rf/subscribe [:presence/same-page]) + diff-page (rf/subscribe [:presence/diff-page]) + settings (rf/subscribe [:settings]) + others-seq #(->> (dissoc % (:username @current-user)) + vals + (map user->person))] + (fn [] + (let [current-user' (user->person (or @current-user + ;; TODO: this is only needed because we don't + ;; have real ids, so it's possible while changing + ;; name that the current-user does not match the + ;; user in settings for a while since there's + ;; no way to track it. + (select-keys @settings [:username :color]))) + current-page-members (others-seq @same-page) + different-page-members (others-seq @diff-page)] + [:> PresenceDetails {:current-user current-user' + :current-page-members current-page-members + :different-page-members different-page-members + :host-address (:url @selected-db) + :handle-press-host-address copy-host-address-to-clipboard + :handle-press-member #(go-to-user-block @all-users %) + :handle-update-profile #(edit-current-user (:username @current-user) %) + ;; TODO: show other states when we support them. + :connection-status "connected"}])))) ;; inline diff --git a/src/cljs/athens/views/app_toolbar.cljs b/src/cljs/athens/views/app_toolbar.cljs index 8ae88cc72f..1eff2db8a8 100644 --- a/src/cljs/athens/views/app_toolbar.cljs +++ b/src/cljs/athens/views/app_toolbar.cljs @@ -16,7 +16,9 @@ ["@material-ui/icons/VerticalSplit" :default VerticalSplit] [athens.electron.db-menu.core :refer [db-menu]] [athens.electron.db-modal :as db-modal] + [athens.electron.utils :as electron.utils] [athens.router :as router] + [athens.self-hosted.presence.views :refer [toolbar-presence-el]] [athens.style :refer [color unzoom]] [athens.subs] [athens.util :as util :refer [app-classes]] @@ -181,7 +183,8 @@ win-fullscreen? (if electron? (subscribe [:win-fullscreen?]) (r/atom false)) - merge-open? (reagent.core/atom false)] + merge-open? (reagent.core/atom false) + selected-db (subscribe [:db-picker/selected-db])] (fn [] [:<> (when @merge-open? @@ -228,6 +231,8 @@ [:div (use-style app-header-secondary-controls-style) (if electron? [:<> + (when (electron.utils/remote-db? @selected-db) + [toolbar-presence-el]) [:> Button {:on-click #(swap! merge-open? not) :title "Merge Roam Database"} [:> MergeType]] diff --git a/src/cljs/athens/views/pages/settings.cljs b/src/cljs/athens/views/pages/settings.cljs index 907cb59567..65b1a689c1 100644 --- a/src/cljs/athens/views/pages/settings.cljs +++ b/src/cljs/athens/views/pages/settings.cljs @@ -4,7 +4,6 @@ ["@material-ui/icons/Check" :default Check] ["@material-ui/icons/NotInterested" :default NotInterested] [athens.db :refer [default-athens-persist]] - [athens.util :refer [js-event->val]] [athens.views.textinput :as textinput] [athens.views.toggle-switch :as toggle-switch] [cljs-http.client :as http] @@ -216,22 +215,6 @@ [:div (stylefy/use-style settings-page-styles) child]) -(defn remote-username-comp - [username update-fn] - [setting-wrapper - [:<> - [:header - [:h3 "Username"] - [:span.glance username]] - [:main - [textinput/textinput {:type "text" - :placeholder "Username" - :on-blur #(update-fn username (js-event->val %)) - :defaultValue username}] - [:aside - [:p "For now, a username is only needed if you are connected to a server."]]]]]) - - (defn reset-settings-comp [reset-fn] [setting-wrapper @@ -262,7 +245,7 @@ (defn page [] - (let [{:keys [email username monitoring backup-time]} @(subscribe [:settings])] + (let [{:keys [email monitoring backup-time]} @(subscribe [:settings])] [settings-container [:<> [:h1 "Settings"] @@ -272,7 +255,4 @@ (dispatch [:settings/update :backup-time x]) (dispatch [:fs/update-write-db]))] [remote-backups-comp] - [remote-username-comp username (fn [current-username new-username] - (dispatch [:presence/send-rename current-username new-username]) - (dispatch [:settings/update :username new-username]))] [reset-settings-comp #(dispatch [:settings/reset])]]])) diff --git a/src/js/components/Button/Button.tsx b/src/js/components/Button/Button.tsx deleted file mode 100644 index ba564eade2..0000000000 --- a/src/js/components/Button/Button.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React from 'react'; -import styled from 'styled-components'; - -const StyledButton = styled.button` - cursor: pointer; - padding: 0.375rem 0.625rem; - margin: 0; - font-family: inherit; - font-size: inherit; - border-radius: 0.25rem; - font-weight: 500; - border: none; - display: inline-flex; - align-items: center; - color: var(--body-text-color); - background-color: transparent; - transition-property: filter, background, color, opacity; - transition-duration: 0.075s; - transition-timing-function: ease; - - &:hover { - background: var(--body-text-color---opacity-lower); - } - - &:active, - &:hover:active, - &[aria-pressed="true"] { - color: var(--body-text-color); - background: var(--body-text-color---opacity-lower); - } - - &:active, - &:hover:active, - &:active[aria-pressed="true"] { - background: var(--body-text-color---opacity-low); - } - - &:disabled, - &:disabled:active { - color: var(--body-text-color---opacity-low); - background: var(--body-text-color---opacity-lower); - cursor: default; - } - - span { - flex: 1 0 auto; - text-align: left; - } - - kbd { - margin-inline-start: 1rem; - font-size: 85%; - } - - > svg { - margin: -0.0835em -0.325rem; - - &:not(:first-child) { - margin-left: 0.251em; - } - &:not(:last-child) { - margin-right: 0.251em; - } - } - - &.is-primary { - color: var(--link-color); - background: var(--link-color---opacity-lower); - - &:hover { - background: var(--link-color---opacity-low); - } - - &:active, - &:hover:active, - &[aria-pressed="true"] { - color: white; - background: var(--link-color); - } - - &:disabled, - &:disabled:active { - color: var(--body-text-color---opacity-low); - background: var(--body-text-color---opacity-lower); - cursor: default; - } - } -`; - -export interface ButtonProps { - /** - * Is this the principal call to action on the page? - */ - isPrimary?: boolean; - /** - * Is this the principal call to action on the page? - */ - isPressed?: boolean; -} - -/** - * Primary UI component for user interaction - */ -export const Button: React.FC = ({ - children, - isPrimary, - isPressed, - ...props -}) => { - return ( - - {children} - - ); -}; diff --git a/src/js/components/PresenceDetails/PresenceDetails.tsx b/src/js/components/PresenceDetails/PresenceDetails.tsx index 6d4157750c..d456499a7b 100644 --- a/src/js/components/PresenceDetails/PresenceDetails.tsx +++ b/src/js/components/PresenceDetails/PresenceDetails.tsx @@ -179,7 +179,16 @@ export const PresenceDetails = ({ isPressed={isPresenceDetailsOpen}> {connectionStatus === 'connected' && ( + <> + {hostAddress && (<> - @@ -253,7 +262,7 @@ export const PresenceDetails = ({ On this page {currentPageMembers.map(member => -