feat(mobx): add layout (paths + sections)

This commit is contained in:
Paul 2021-12-11 14:34:12 +00:00
parent f87ecfcbd7
commit a8491267a4
11 changed files with 208 additions and 132 deletions

View file

@ -1,7 +1,6 @@
import { ChevronDown } from "@styled-icons/boxicons-regular"; import { ChevronDown } from "@styled-icons/boxicons-regular";
import { State, store } from "../../redux"; import { useApplicationState } from "../../mobx/State";
import { Action } from "../../redux/reducers";
import Details from "../ui/Details"; import Details from "../ui/Details";
@ -25,27 +24,14 @@ export default function CollapsibleSection({
children, children,
...detailsProps ...detailsProps
}: Props) { }: Props) {
const state: State = store.getState(); const layout = useApplicationState().layout;
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);
}
}
return ( return (
<Details <Details
open={state.sectionToggle[id] ?? defaultValue} open={layout.getSectionState(id, defaultValue)}
onToggle={(e) => setState(e.currentTarget.open)} onToggle={(e) =>
layout.setSectionState(id, e.currentTarget.open, defaultValue)
}
{...detailsProps}> {...detailsProps}>
<summary> <summary>
<div class="padding"> <div class="padding">

View file

@ -5,8 +5,7 @@ import styled, { css } from "styled-components";
import ConditionalLink from "../../lib/ConditionalLink"; import ConditionalLink from "../../lib/ConditionalLink";
import { connectState } from "../../redux/connector"; import { useApplicationState } from "../../mobx/State";
import { LastOpened } from "../../redux/reducers/last_opened";
import { useClient } from "../../context/revoltjs/RevoltClient"; import { useClient } from "../../context/revoltjs/RevoltClient";
@ -47,19 +46,14 @@ const Button = styled.a<{ active: boolean }>`
`} `}
`; `;
interface Props { export default observer(() => {
lastOpened: LastOpened;
}
export const BottomNavigation = observer(({ lastOpened }: Props) => {
const client = useClient(); const client = useClient();
const layout = useApplicationState().layout;
const user = client.users.get(client.user!._id); const user = client.users.get(client.user!._id);
const history = useHistory(); const history = useHistory();
const path = useLocation().pathname; const path = useLocation().pathname;
const channel_id = lastOpened["home"];
const friendsActive = path.startsWith("/friends"); const friendsActive = path.startsWith("/friends");
const settingsActive = path.startsWith("/settings"); const settingsActive = path.startsWith("/settings");
const homeActive = !(friendsActive || settingsActive); const homeActive = !(friendsActive || settingsActive);
@ -73,14 +67,11 @@ export const BottomNavigation = observer(({ lastOpened }: Props) => {
if (settingsActive) { if (settingsActive) {
if (history.length > 0) { if (history.length > 0) {
history.goBack(); history.goBack();
return;
} }
} }
if (channel_id) { history.push(layout.getLastHomePath());
history.push(`/channel/${channel_id}`);
} else {
history.push("/");
}
}}> }}>
<Message size={24} /> <Message size={24} />
</IconButton> </IconButton>
@ -117,9 +108,3 @@ export const BottomNavigation = observer(({ lastOpened }: Props) => {
</Base> </Base>
); );
}); });
export default connectState(BottomNavigation, (state) => {
return {
lastOpened: state.lastOpened,
};
});

View file

@ -15,6 +15,7 @@ import ConditionalLink from "../../../lib/ConditionalLink";
import PaintCounter from "../../../lib/PaintCounter"; import PaintCounter from "../../../lib/PaintCounter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { useApplicationState } from "../../../mobx/State";
import { dispatch } from "../../../redux"; import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector"; import { connectState } from "../../../redux/connector";
import { Unreads } from "../../../redux/reducers/unreads"; import { Unreads } from "../../../redux/reducers/unreads";
@ -37,6 +38,7 @@ type Props = {
const HomeSidebar = observer((props: Props) => { const HomeSidebar = observer((props: Props) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const client = useContext(AppContext); const client = useContext(AppContext);
const layout = useApplicationState().layout;
const { channel } = useParams<{ channel: string }>(); const { channel } = useParams<{ channel: string }>();
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
@ -52,15 +54,8 @@ const HomeSidebar = observer((props: Props) => {
if (channel && !obj) return <Redirect to="/" />; if (channel && !obj) return <Redirect to="/" />;
if (obj) useUnreads({ ...props, channel: obj }); if (obj) useUnreads({ ...props, channel: obj });
useEffect(() => { // Track what page the user was last on (in home page).
if (!channel) return; useEffect(() => layout.setLastHomePath(pathname), [pathname]);
dispatch({
type: "LAST_OPENED_SET",
parent: "home",
child: channel,
});
}, [channel]);
channels.sort((b, a) => a.timestamp.localeCompare(b.timestamp)); channels.sort((b, a) => a.timestamp.localeCompare(b.timestamp));

View file

@ -12,8 +12,8 @@ import ConditionalLink from "../../../lib/ConditionalLink";
import PaintCounter from "../../../lib/PaintCounter"; import PaintCounter from "../../../lib/PaintCounter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { useApplicationState } from "../../../mobx/State";
import { connectState } from "../../../redux/connector"; import { connectState } from "../../../redux/connector";
import { LastOpened } from "../../../redux/reducers/last_opened";
import { Unreads } from "../../../redux/reducers/unreads"; import { Unreads } from "../../../redux/reducers/unreads";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
@ -195,11 +195,11 @@ function Swoosh() {
interface Props { interface Props {
unreads: Unreads; unreads: Unreads;
lastOpened: LastOpened;
} }
export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => { export const ServerListSidebar = observer(({ unreads }: Props) => {
const client = useClient(); const client = useClient();
const layout = useApplicationState().layout;
const { server: server_id } = useParams<{ server?: string }>(); const { server: server_id } = useParams<{ server?: string }>();
const server = server_id ? client.servers.get(server_id) : undefined; const server = server_id ? client.servers.get(server_id) : undefined;
@ -268,7 +268,7 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => {
<ServerList> <ServerList>
<ConditionalLink <ConditionalLink
active={homeActive} active={homeActive}
to={lastOpened.home ? `/channel/${lastOpened.home}` : "/"}> to={layout.getLastHomePath()}>
<ServerEntry home active={homeActive}> <ServerEntry home active={homeActive}>
<Swoosh /> <Swoosh />
<div <div
@ -295,15 +295,12 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => {
<LineDivider /> <LineDivider />
{servers.map((entry) => { {servers.map((entry) => {
const active = entry.server._id === server?._id; const active = entry.server._id === server?._id;
const id = lastOpened[entry.server._id];
return ( return (
<ConditionalLink <ConditionalLink
key={entry.server._id} key={entry.server._id}
active={active} active={active}
to={`/server/${entry.server._id}${ to={layout.getServerPath(entry.server._id)}>
id ? `/channel/${id}` : ""
}`}>
<ServerEntry <ServerEntry
active={active} active={active}
onContextMenu={attachContextMenu("Menu", { onContextMenu={attachContextMenu("Menu", {
@ -359,6 +356,5 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => {
export default connectState(ServerListSidebar, (state) => { export default connectState(ServerListSidebar, (state) => {
return { return {
unreads: state.unreads, unreads: state.unreads,
lastOpened: state.lastOpened,
}; };
}); });

View file

@ -10,6 +10,7 @@ import PaintCounter from "../../../lib/PaintCounter";
import { internalEmit } from "../../../lib/eventEmitter"; import { internalEmit } from "../../../lib/eventEmitter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { useApplicationState } from "../../../mobx/State";
import { dispatch } from "../../../redux"; import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector"; import { connectState } from "../../../redux/connector";
import { Notifications } from "../../../redux/reducers/notifications"; import { Notifications } from "../../../redux/reducers/notifications";
@ -58,6 +59,7 @@ const ServerList = styled.div`
const ServerSidebar = observer((props: Props) => { const ServerSidebar = observer((props: Props) => {
const client = useClient(); const client = useClient();
const layout = useApplicationState().layout;
const { server: server_id, channel: channel_id } = const { server: server_id, channel: channel_id } =
useParams<{ server: string; channel?: string }>(); useParams<{ server: string; channel?: string }>();
@ -75,16 +77,15 @@ const ServerSidebar = observer((props: Props) => {
); );
if (channel_id && !channel) return <Redirect to={`/server/${server_id}`} />; if (channel_id && !channel) return <Redirect to={`/server/${server_id}`} />;
// Handle unreads; FIXME: should definitely not be here
if (channel) useUnreads({ ...props, channel }); if (channel) useUnreads({ ...props, channel });
// Track which channel the user was last on.
useEffect(() => { useEffect(() => {
if (!channel_id) return; if (!channel_id) return;
if (!server_id) return;
dispatch({ layout.setLastOpened(server_id, channel_id);
type: "LAST_OPENED_SET",
parent: server_id!,
child: channel_id!,
});
}, [channel_id, server_id]); }, [channel_id, server_id]);
const uncategorised = new Set(server.channel_ids); const uncategorised = new Set(server.channel_ids);

View file

@ -6,6 +6,7 @@ import { useContext } from "preact/hooks";
import Auth from "./stores/Auth"; import Auth from "./stores/Auth";
import Draft from "./stores/Draft"; import Draft from "./stores/Draft";
import Experiments from "./stores/Experiments"; import Experiments from "./stores/Experiments";
import Layout from "./stores/Layout";
import LocaleOptions from "./stores/LocaleOptions"; import LocaleOptions from "./stores/LocaleOptions";
interface StoreDefinition { interface StoreDefinition {
@ -24,6 +25,7 @@ export default class State {
draft: Draft; draft: Draft;
locale: LocaleOptions; locale: LocaleOptions;
experiments: Experiments; experiments: Experiments;
layout: Layout;
/** /**
* Construct new State. * Construct new State.
@ -33,6 +35,7 @@ export default class State {
this.draft = new Draft(); this.draft = new Draft();
this.locale = new LocaleOptions(); this.locale = new LocaleOptions();
this.experiments = new Experiments(); this.experiments = new Experiments();
this.layout = new Layout();
makeAutoObservable(this); makeAutoObservable(this);
} }

View file

@ -1,55 +0,0 @@
import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import Persistent from "../Persistent";
interface Data {
server?: Record<string, string>;
}
/**
* 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<Data> {
private server: ObservableMap<string, string>;
/**
* 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);
}
}

161
src/mobx/stores/Layout.ts Normal file
View file

@ -0,0 +1,161 @@
import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import Persistent from "../Persistent";
interface Data {
lastSection?: "home" | "server";
lastHomePath?: string;
lastOpened?: Record<string, string>;
openSections?: Record<string, boolean>;
}
/**
* 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<Data> {
/**
* 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<string, string>;
/**
* Map of section IDs to their current state.
*/
private openSections: ObservableMap<string, boolean>;
/**
* 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);
}
}
}

View file

@ -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 { observer } from "mobx-react-lite";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { Channel as ChannelI } from "revolt.js/dist/maps/Channels"; import { Channel as ChannelI } from "revolt.js/dist/maps/Channels";
import styled from "styled-components"; import styled from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks";
import { useState } from "preact/hooks";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useApplicationState } from "../../mobx/State";
import { dispatch, getState } from "../../redux"; import { dispatch, getState } from "../../redux";
import { useClient } from "../../context/revoltjs/RevoltClient"; 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 AgeGate from "../../components/common/AgeGate";
import MessageBox from "../../components/common/messaging/MessageBox"; import MessageBox from "../../components/common/messaging/MessageBox";
import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom"; import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom";
@ -52,19 +51,19 @@ const PlaceholderBase = styled.div`
justify-content: center; justify-content: center;
text-align: center; text-align: center;
margin: auto; margin: auto;
.primary { .primary {
color: var(--secondary-foreground); color: var(--secondary-foreground);
font-weight: 700; font-weight: 700;
font-size: 22px; font-size: 22px;
margin: 0 0 5px 0; margin: 0 0 5px 0;
} }
.secondary { .secondary {
color: var(--tertiary-foreground); color: var(--tertiary-foreground);
font-weight: 400; font-weight: 400;
} }
svg { svg {
margin: 2em auto; margin: 2em auto;
fill-opacity: 0.8; fill-opacity: 0.8;
@ -94,7 +93,6 @@ const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
getState().sectionToggle[CHANNELS_SIDEBAR_KEY] ?? true, getState().sectionToggle[CHANNELS_SIDEBAR_KEY] ?? true,
); );
const id = channel._id;
return ( return (
<AgeGate <AgeGate
type="channel" type="channel"
@ -126,7 +124,7 @@ const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
}} }}
toggleChannelSidebar={() => { toggleChannelSidebar={() => {
if (isTouchscreenDevice) { if (isTouchscreenDevice) {
return return;
} }
setChannels(!showChannels); setChannels(!showChannels);
@ -147,7 +145,7 @@ const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
/> />
<ChannelMain> <ChannelMain>
<ChannelContent> <ChannelContent>
<VoiceHeader id={id} /> <VoiceHeader id={channel._id} />
<MessageArea channel={channel} /> <MessageArea channel={channel} />
<TypingIndicator channel={channel} /> <TypingIndicator channel={channel} />
<JumpToBottom channel={channel} /> <JumpToBottom channel={channel} />
@ -173,13 +171,19 @@ function ChannelPlaceholder() {
<PlaceholderBase> <PlaceholderBase>
<Header placement="primary"> <Header placement="primary">
<Hash size={24} /> <Hash size={24} />
<span className="name"><Text id="app.main.channel.errors.nochannel" /></span> <span className="name">
<Text id="app.main.channel.errors.nochannel" />
</span>
</Header> </Header>
<div className="placeholder"> <div className="placeholder">
<Ghost width={80} /> <Ghost width={80} />
<div className="primary"><Text id="app.main.channel.errors.title" /></div> <div className="primary">
<div className="secondary"><Text id="app.main.channel.errors.nochannels" /></div> <Text id="app.main.channel.errors.title" />
</div>
<div className="secondary">
<Text id="app.main.channel.errors.nochannels" />
</div>
</div> </div>
</PlaceholderBase> </PlaceholderBase>
); );