From a8491267a45acdbfced06fe57952fc26feceb33c Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 11 Dec 2021 14:34:12 +0000 Subject: [PATCH] feat(mobx): add layout (paths + sections) --- src/components/common/CollapsibleSection.tsx | 26 +-- .../navigation/BottomNavigation.tsx | 25 +-- .../navigation/left/HomeSidebar.tsx | 13 +- .../navigation/left/ServerListSidebar.tsx | 14 +- .../navigation/left/ServerSidebar.tsx | 11 +- src/mobx/State.ts | 3 + src/mobx/stores/LastOpened.ts | 55 ------ src/mobx/stores/Layout.ts | 161 ++++++++++++++++++ src/mobx/stores/SectionToggle.ts | 0 src/mobx/stores/ServerConfig.ts | 0 src/pages/channels/Channel.tsx | 32 ++-- 11 files changed, 208 insertions(+), 132 deletions(-) delete mode 100644 src/mobx/stores/LastOpened.ts create mode 100644 src/mobx/stores/Layout.ts delete mode 100644 src/mobx/stores/SectionToggle.ts delete mode 100644 src/mobx/stores/ServerConfig.ts diff --git a/src/components/common/CollapsibleSection.tsx b/src/components/common/CollapsibleSection.tsx index ac2d9809..cea03b06 100644 --- a/src/components/common/CollapsibleSection.tsx +++ b/src/components/common/CollapsibleSection.tsx @@ -1,7 +1,6 @@ import { ChevronDown } from "@styled-icons/boxicons-regular"; -import { State, store } from "../../redux"; -import { Action } from "../../redux/reducers"; +import { useApplicationState } from "../../mobx/State"; import Details from "../ui/Details"; @@ -25,27 +24,14 @@ export default function CollapsibleSection({ children, ...detailsProps }: Props) { - const state: State = store.getState(); - - function setState(state: boolean) { - if (state === defaultValue) { - store.dispatch({ - type: "SECTION_TOGGLE_UNSET", - id, - } as Action); - } else { - store.dispatch({ - type: "SECTION_TOGGLE_SET", - id, - state, - } as Action); - } - } + const layout = useApplicationState().layout; return (
setState(e.currentTarget.open)} + open={layout.getSectionState(id, defaultValue)} + onToggle={(e) => + layout.setSectionState(id, e.currentTarget.open, defaultValue) + } {...detailsProps}>
diff --git a/src/components/navigation/BottomNavigation.tsx b/src/components/navigation/BottomNavigation.tsx index 16ec584d..48d684a3 100644 --- a/src/components/navigation/BottomNavigation.tsx +++ b/src/components/navigation/BottomNavigation.tsx @@ -5,8 +5,7 @@ import styled, { css } from "styled-components"; import ConditionalLink from "../../lib/ConditionalLink"; -import { connectState } from "../../redux/connector"; -import { LastOpened } from "../../redux/reducers/last_opened"; +import { useApplicationState } from "../../mobx/State"; import { useClient } from "../../context/revoltjs/RevoltClient"; @@ -47,19 +46,14 @@ const Button = styled.a<{ active: boolean }>` `} `; -interface Props { - lastOpened: LastOpened; -} - -export const BottomNavigation = observer(({ lastOpened }: Props) => { +export default observer(() => { const client = useClient(); + const layout = useApplicationState().layout; const user = client.users.get(client.user!._id); const history = useHistory(); const path = useLocation().pathname; - const channel_id = lastOpened["home"]; - const friendsActive = path.startsWith("/friends"); const settingsActive = path.startsWith("/settings"); const homeActive = !(friendsActive || settingsActive); @@ -73,14 +67,11 @@ export const BottomNavigation = observer(({ lastOpened }: Props) => { if (settingsActive) { if (history.length > 0) { history.goBack(); + return; } } - if (channel_id) { - history.push(`/channel/${channel_id}`); - } else { - history.push("/"); - } + history.push(layout.getLastHomePath()); }}> @@ -117,9 +108,3 @@ export const BottomNavigation = observer(({ lastOpened }: Props) => { ); }); - -export default connectState(BottomNavigation, (state) => { - return { - lastOpened: state.lastOpened, - }; -}); diff --git a/src/components/navigation/left/HomeSidebar.tsx b/src/components/navigation/left/HomeSidebar.tsx index 8c66fc7c..837638ad 100644 --- a/src/components/navigation/left/HomeSidebar.tsx +++ b/src/components/navigation/left/HomeSidebar.tsx @@ -15,6 +15,7 @@ import ConditionalLink from "../../../lib/ConditionalLink"; import PaintCounter from "../../../lib/PaintCounter"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; +import { useApplicationState } from "../../../mobx/State"; import { dispatch } from "../../../redux"; import { connectState } from "../../../redux/connector"; import { Unreads } from "../../../redux/reducers/unreads"; @@ -37,6 +38,7 @@ type Props = { const HomeSidebar = observer((props: Props) => { const { pathname } = useLocation(); const client = useContext(AppContext); + const layout = useApplicationState().layout; const { channel } = useParams<{ channel: string }>(); const { openScreen } = useIntermediate(); @@ -52,15 +54,8 @@ const HomeSidebar = observer((props: Props) => { if (channel && !obj) return ; if (obj) useUnreads({ ...props, channel: obj }); - useEffect(() => { - if (!channel) return; - - dispatch({ - type: "LAST_OPENED_SET", - parent: "home", - child: channel, - }); - }, [channel]); + // Track what page the user was last on (in home page). + useEffect(() => layout.setLastHomePath(pathname), [pathname]); channels.sort((b, a) => a.timestamp.localeCompare(b.timestamp)); diff --git a/src/components/navigation/left/ServerListSidebar.tsx b/src/components/navigation/left/ServerListSidebar.tsx index 3eae8741..4385755d 100644 --- a/src/components/navigation/left/ServerListSidebar.tsx +++ b/src/components/navigation/left/ServerListSidebar.tsx @@ -12,8 +12,8 @@ import ConditionalLink from "../../../lib/ConditionalLink"; import PaintCounter from "../../../lib/PaintCounter"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; +import { useApplicationState } from "../../../mobx/State"; import { connectState } from "../../../redux/connector"; -import { LastOpened } from "../../../redux/reducers/last_opened"; import { Unreads } from "../../../redux/reducers/unreads"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; @@ -195,11 +195,11 @@ function Swoosh() { interface Props { unreads: Unreads; - lastOpened: LastOpened; } -export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => { +export const ServerListSidebar = observer(({ unreads }: Props) => { const client = useClient(); + const layout = useApplicationState().layout; const { server: server_id } = useParams<{ server?: string }>(); const server = server_id ? client.servers.get(server_id) : undefined; @@ -268,7 +268,7 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => { + to={layout.getLastHomePath()}>
{ {servers.map((entry) => { const active = entry.server._id === server?._id; - const id = lastOpened[entry.server._id]; return ( + to={layout.getServerPath(entry.server._id)}> { export default connectState(ServerListSidebar, (state) => { return { unreads: state.unreads, - lastOpened: state.lastOpened, }; }); diff --git a/src/components/navigation/left/ServerSidebar.tsx b/src/components/navigation/left/ServerSidebar.tsx index 97bdc59c..2bff3d89 100644 --- a/src/components/navigation/left/ServerSidebar.tsx +++ b/src/components/navigation/left/ServerSidebar.tsx @@ -10,6 +10,7 @@ import PaintCounter from "../../../lib/PaintCounter"; import { internalEmit } from "../../../lib/eventEmitter"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; +import { useApplicationState } from "../../../mobx/State"; import { dispatch } from "../../../redux"; import { connectState } from "../../../redux/connector"; import { Notifications } from "../../../redux/reducers/notifications"; @@ -58,6 +59,7 @@ const ServerList = styled.div` const ServerSidebar = observer((props: Props) => { const client = useClient(); + const layout = useApplicationState().layout; const { server: server_id, channel: channel_id } = useParams<{ server: string; channel?: string }>(); @@ -75,16 +77,15 @@ const ServerSidebar = observer((props: Props) => { ); if (channel_id && !channel) return ; + // Handle unreads; FIXME: should definitely not be here if (channel) useUnreads({ ...props, channel }); + // Track which channel the user was last on. useEffect(() => { if (!channel_id) return; + if (!server_id) return; - dispatch({ - type: "LAST_OPENED_SET", - parent: server_id!, - child: channel_id!, - }); + layout.setLastOpened(server_id, channel_id); }, [channel_id, server_id]); const uncategorised = new Set(server.channel_ids); diff --git a/src/mobx/State.ts b/src/mobx/State.ts index 0b36de10..8cd5dea0 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -6,6 +6,7 @@ import { useContext } from "preact/hooks"; import Auth from "./stores/Auth"; import Draft from "./stores/Draft"; import Experiments from "./stores/Experiments"; +import Layout from "./stores/Layout"; import LocaleOptions from "./stores/LocaleOptions"; interface StoreDefinition { @@ -24,6 +25,7 @@ export default class State { draft: Draft; locale: LocaleOptions; experiments: Experiments; + layout: Layout; /** * Construct new State. @@ -33,6 +35,7 @@ export default class State { this.draft = new Draft(); this.locale = new LocaleOptions(); this.experiments = new Experiments(); + this.layout = new Layout(); makeAutoObservable(this); } diff --git a/src/mobx/stores/LastOpened.ts b/src/mobx/stores/LastOpened.ts deleted file mode 100644 index e0ff249f..00000000 --- a/src/mobx/stores/LastOpened.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; - -import Persistent from "../Persistent"; - -interface Data { - server?: Record; -} - -/** - * Keeps track of the last open channels, tabs, etc. - * Handles providing good UX experience on navigating - * back and forth between different parts of the app. - */ -export default class Experiments implements Persistent { - private server: ObservableMap; - - /** - * Construct new Experiments store. - */ - constructor() { - this.server = new ObservableMap(); - makeAutoObservable(this); - } - - toJSON() { - return { - server: this.server, - }; - } - - @action hydrate(data: Data) { - if (data.server) { - Object.keys(data.server).forEach((key) => - this.server.set(key, data.server![key]), - ); - } - } - - /** - * Get last opened channel in a server. - * @param server Server ID - */ - @computed get(server: string) { - return this.server.get(server); - } - - /** - * Set last opened channel in a server. - * @param server Server ID - * @param channel Channel ID - */ - @action enable(server: string, channel: string) { - this.server.set(server, channel); - } -} diff --git a/src/mobx/stores/Layout.ts b/src/mobx/stores/Layout.ts new file mode 100644 index 00000000..9d4a7361 --- /dev/null +++ b/src/mobx/stores/Layout.ts @@ -0,0 +1,161 @@ +import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; + +import Persistent from "../Persistent"; + +interface Data { + lastSection?: "home" | "server"; + lastHomePath?: string; + lastOpened?: Record; + openSections?: Record; +} + +/** + * Keeps track of the last open channels, tabs, etc. + * Handles providing good UX experience on navigating + * back and forth between different parts of the app. + */ +export default class Layout implements Persistent { + /** + * The last 'major section' that the user had open. + * This is either the home tab or a channel ID (for a server channel). + */ + private lastSection: "home" | string; + + /** + * The last path the user had open in the home tab. + */ + private lastHomePath: string; + + /** + * Map of last channels viewed in servers. + */ + private lastOpened: ObservableMap; + + /** + * Map of section IDs to their current state. + */ + private openSections: ObservableMap; + + /** + * Construct new Layout store. + */ + constructor() { + this.lastSection = "home"; + this.lastHomePath = "/"; + this.lastOpened = new ObservableMap(); + this.openSections = new ObservableMap(); + makeAutoObservable(this); + } + + toJSON() { + return { + lastSection: this.lastSection, + lastHomePath: this.lastHomePath, + lastOpened: this.lastOpened, + openSections: this.openSections, + }; + } + + @action hydrate(data: Data) { + if (data.lastSection) { + this.lastSection = data.lastSection; + } + + if (data.lastHomePath) { + this.lastHomePath = data.lastHomePath; + } + + if (data.lastOpened) { + Object.keys(data.lastOpened).forEach((key) => + this.lastOpened.set(key, data.lastOpened![key]), + ); + } + + if (data.openSections) { + Object.keys(data.openSections).forEach((key) => + this.openSections.set(key, data.openSections![key]), + ); + } + } + + /** + * Get the last 'major section' the user had open. + * @returns Last open section + */ + @computed getLastSection() { + return this.lastSection; + } + + /** + * Get last opened channel in a server. + * @param server Server ID + */ + @computed getLastOpened(server: string) { + return this.lastOpened.get(server); + } + + /** + * Get the path to a server (as seen on sidebar). + * @param server Server ID + * @returns Pathname + */ + @computed getServerPath(server: string) { + let path = `/server/${server}`; + if (this.lastOpened.has(server)) { + path += `/channel/${this.getLastOpened(server)}`; + } + + return path; + } + + /** + * Set last opened channel in a server. + * @param server Server ID + * @param channel Channel ID + */ + @action setLastOpened(server: string, channel: string) { + this.lastOpened.set(server, channel); + this.lastSection = "server"; + } + + /** + * Get the last path the user had open in the home tab. + * @returns Last home path + */ + @computed getLastHomePath() { + return this.lastHomePath; + } + + /** + * Set the current path open in the home tab. + * @param path Pathname + */ + @action setLastHomePath(path: string) { + this.lastHomePath = path; + this.lastSection = "home"; + } + + /** + * + * @param id Section ID + * @returns Whether the section is open + * @param def Default state value + */ + @computed getSectionState(id: string, def?: boolean) { + return this.openSections.get(id) ?? def ?? false; + } + + /** + * Set the state of a section. + * @param id Section ID + * @param value New state value + * @param def Default state value + */ + @action setSectionState(id: string, value: boolean, def?: boolean) { + if (value === def) { + this.openSections.delete(id); + } else { + this.openSections.set(id, value); + } + } +} diff --git a/src/mobx/stores/SectionToggle.ts b/src/mobx/stores/SectionToggle.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/mobx/stores/ServerConfig.ts b/src/mobx/stores/ServerConfig.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/pages/channels/Channel.tsx b/src/pages/channels/Channel.tsx index bf1c5f76..9f4767f0 100644 --- a/src/pages/channels/Channel.tsx +++ b/src/pages/channels/Channel.tsx @@ -1,21 +1,20 @@ +import { Hash } from "@styled-icons/boxicons-regular"; +import { Ghost } from "@styled-icons/boxicons-solid"; import { observer } from "mobx-react-lite"; import { useParams } from "react-router-dom"; import { Channel as ChannelI } from "revolt.js/dist/maps/Channels"; import styled from "styled-components"; import { Text } from "preact-i18n"; - -import { useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; +import { useApplicationState } from "../../mobx/State"; import { dispatch, getState } from "../../redux"; import { useClient } from "../../context/revoltjs/RevoltClient"; -import { Hash } from "@styled-icons/boxicons-regular"; -import { Ghost } from "@styled-icons/boxicons-solid"; - import AgeGate from "../../components/common/AgeGate"; import MessageBox from "../../components/common/messaging/MessageBox"; import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom"; @@ -52,19 +51,19 @@ const PlaceholderBase = styled.div` justify-content: center; text-align: center; margin: auto; - + .primary { color: var(--secondary-foreground); font-weight: 700; font-size: 22px; margin: 0 0 5px 0; } - + .secondary { color: var(--tertiary-foreground); font-weight: 400; } - + svg { margin: 2em auto; fill-opacity: 0.8; @@ -94,7 +93,6 @@ const TextChannel = observer(({ channel }: { channel: ChannelI }) => { getState().sectionToggle[CHANNELS_SIDEBAR_KEY] ?? true, ); - const id = channel._id; return ( { }} toggleChannelSidebar={() => { if (isTouchscreenDevice) { - return + return; } setChannels(!showChannels); @@ -147,7 +145,7 @@ const TextChannel = observer(({ channel }: { channel: ChannelI }) => { /> - + @@ -173,13 +171,19 @@ function ChannelPlaceholder() {
- + + +
-
-
+
+ +
+
+ +
);