From 413bf6949b98e8fd988f290c67606e29fb14e3ad Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 11 Dec 2021 23:34:46 +0000 Subject: [PATCH] feat(mobx): server notification options + data store --- .../navigation/items/Item.module.scss | 2 +- .../navigation/left/ServerListSidebar.tsx | 7 +- .../navigation/left/ServerSidebar.tsx | 8 +- src/context/revoltjs/Notifications.tsx | 9 +- src/context/revoltjs/SyncManager.tsx | 2 +- src/lib/ContextMenus.tsx | 95 +++----------- src/lib/contextmenu/CMNotifications.tsx | 119 ++++++++++++++++++ src/mobx/State.ts | 3 + src/mobx/stores/LocaleOptions.ts | 4 +- src/mobx/stores/NotificationOptions.ts | 90 +++++++++++-- 10 files changed, 237 insertions(+), 102 deletions(-) create mode 100644 src/lib/contextmenu/CMNotifications.tsx diff --git a/src/components/navigation/items/Item.module.scss b/src/components/navigation/items/Item.module.scss index a0148f7c..42bed90f 100644 --- a/src/components/navigation/items/Item.module.scss +++ b/src/components/navigation/items/Item.module.scss @@ -117,7 +117,7 @@ } &[data-muted="true"] { - color: var(--tertiary-foreground); + opacity: 0.4; } &[data-alert="true"], diff --git a/src/components/navigation/left/ServerListSidebar.tsx b/src/components/navigation/left/ServerListSidebar.tsx index ed9042f0..3a27f84b 100644 --- a/src/components/navigation/left/ServerListSidebar.tsx +++ b/src/components/navigation/left/ServerListSidebar.tsx @@ -199,7 +199,7 @@ interface Props { export const ServerListSidebar = observer(({ unreads }: Props) => { const client = useClient(); - const layout = useApplicationState().layout; + const state = useApplicationState(); const { server: server_id } = useParams<{ server?: string }>(); const server = server_id ? client.servers.get(server_id) : undefined; @@ -210,6 +210,7 @@ export const ServerListSidebar = observer(({ unreads }: Props) => { const unreadChannels = channels .filter((x) => x.unread) + .filter((x) => !state.notifications.isMuted(x.channel)) .map((x) => x.channel?._id); const servers = activeServers.map((server) => { @@ -268,7 +269,7 @@ export const ServerListSidebar = observer(({ unreads }: Props) => { + to={state.layout.getLastHomePath()}>
{ + to={state.layout.getServerPath(entry.server._id)}> { const client = useClient(); - const layout = useApplicationState().layout; + const state = useApplicationState(); const { server: server_id, channel: channel_id } = useParams<{ server: string; channel?: string }>(); @@ -85,7 +84,7 @@ const ServerSidebar = observer((props: Props) => { if (!channel_id) return; if (!server_id) return; - layout.setLastOpened(server_id, channel_id); + state.layout.setLastOpened(server_id, channel_id); }, [channel_id, server_id]); const uncategorised = new Set(server.channel_ids); @@ -96,7 +95,6 @@ const ServerSidebar = observer((props: Props) => { if (!entry) return; const active = channel?._id === entry._id; - const muted = props.notifications[id] === "none"; return ( { // ! FIXME: pull it out directly alert={mapChannelWithUnread(entry, props.unreads).unread} compact - muted={muted} + muted={state.notifications.isMuted(entry)} /> ); diff --git a/src/context/revoltjs/Notifications.tsx b/src/context/revoltjs/Notifications.tsx index 153b1160..f4c451a0 100644 --- a/src/context/revoltjs/Notifications.tsx +++ b/src/context/revoltjs/Notifications.tsx @@ -8,6 +8,7 @@ import { useCallback, useContext, useEffect } from "preact/hooks"; import { useTranslation } from "../../lib/i18n"; +import { useApplicationState } from "../../mobx/State"; import { connectState } from "../../redux/connector"; import { getNotificationState, @@ -21,7 +22,6 @@ import { AppContext } from "./RevoltClient"; interface Props { options?: NotificationOptions; - notifs: Notifications; } const notifications: { [key: string]: Notification } = {}; @@ -38,9 +38,10 @@ async function createNotification( } } -function Notifier({ options, notifs }: Props) { +function Notifier({ options }: Props) { const translate = useTranslation(); const showNotification = options?.desktopEnabled ?? false; + const notifs = useApplicationState().notifications; const client = useContext(AppContext); const { guild: guild_id, channel: channel_id } = useParams<{ @@ -57,8 +58,7 @@ function Notifier({ options, notifs }: Props) { if (client.user!.status?.presence === Presence.Busy) return; if (msg.author?.relationship === RelationshipStatus.Blocked) return; - const notifState = getNotificationState(notifs, msg.channel!); - if (!shouldNotify(notifState, msg, client.user!._id)) return; + if (!notifs.shouldNotify(msg)) return; playSound("message"); if (!showNotification) return; @@ -294,7 +294,6 @@ const NotifierComponent = connectState( (state) => { return { options: state.settings.notification, - notifs: state.notifications, }; }, true, diff --git a/src/context/revoltjs/SyncManager.tsx b/src/context/revoltjs/SyncManager.tsx index f4af77e0..ec272ea5 100644 --- a/src/context/revoltjs/SyncManager.tsx +++ b/src/context/revoltjs/SyncManager.tsx @@ -152,6 +152,6 @@ export default connectState(SyncManager, (state) => { }; });*/ -function SyncManager() { +export default function SyncManager() { return <>; } diff --git a/src/lib/ContextMenus.tsx b/src/lib/ContextMenus.tsx index 6a93bde1..eb6e67d1 100644 --- a/src/lib/ContextMenus.tsx +++ b/src/lib/ContextMenus.tsx @@ -48,6 +48,7 @@ import { StatusContext, } from "../context/revoltjs/RevoltClient"; import { takeError } from "../context/revoltjs/util"; +import CMNotifications from "./contextmenu/CMNotifications"; import Tooltip from "../components/common/Tooltip"; import UserStatus from "../components/common/user/UserStatus"; @@ -117,7 +118,11 @@ type Action = | { action: "leave_server"; target: Server } | { action: "delete_server"; target: Server } | { action: "edit_identity"; target: Server } - | { action: "open_notification_options"; channel: Channel } + | { + action: "open_notification_options"; + channel?: Channel; + server?: Server; + } | { action: "open_settings" } | { action: "open_channel_settings"; id: string } | { action: "open_server_settings"; id: string } @@ -128,13 +133,9 @@ type Action = state?: NotificationState; }; -type Props = { - notifications: Notifications; -}; - // ! FIXME: I dare someone to re-write this // Tip: This should just be split into separate context menus per logical area. -function ContextMenus(props: Props) { +export default function ContextMenus() { const { openScreen, writeClipboard } = useIntermediate(); const client = useContext(AppContext); const userId = client.user!._id; @@ -427,6 +428,7 @@ function ContextMenus(props: Props) { case "open_notification_options": { openContextMenu("NotificationOptions", { channel: data.channel, + server: data.server, }); break; } @@ -921,6 +923,16 @@ function ContextMenus(props: Props) { } if (sid && server) { + generateAction( + { + action: "open_notification_options", + server, + }, + undefined, + undefined, + , + ); + if (server.channels[0] !== undefined) generateAction( { @@ -1085,76 +1097,7 @@ function ContextMenus(props: Props) { ); }} - - {({ channel }: { channel: Channel }) => { - const state = props.notifications[channel._id]; - const actual = getNotificationState( - props.notifications, - channel, - ); - - const elements: Children[] = [ - - -
- {state !== undefined && } - {state === undefined && ( - - )} -
-
, - ]; - - function generate(key: string, icon: Children) { - elements.push( - - {icon} - - {state === undefined && actual === key && ( -
- -
- )} - {state === key && ( -
- -
- )} -
, - ); - } - - generate("all", ); - generate("mention", ); - generate("muted", ); - generate("none", ); - - return elements; - }} -
+ ); } - -export default connectState(ContextMenus, (state) => { - return { - notifications: state.notifications, - }; -}); diff --git a/src/lib/contextmenu/CMNotifications.tsx b/src/lib/contextmenu/CMNotifications.tsx new file mode 100644 index 00000000..a107b611 --- /dev/null +++ b/src/lib/contextmenu/CMNotifications.tsx @@ -0,0 +1,119 @@ +import { + At, + Bell, + BellOff, + Check, + CheckSquare, + Block, + Square, + LeftArrowAlt, +} from "@styled-icons/boxicons-regular"; +import { observer } from "mobx-react-lite"; +import { Channel } from "revolt.js/dist/maps/Channels"; +import { Server } from "revolt.js/dist/maps/Servers"; + +import { ContextMenuWithData, MenuItem } from "preact-context-menu"; +import { Text } from "preact-i18n"; + +import { useApplicationState } from "../../mobx/State"; +import { NotificationState } from "../../mobx/stores/NotificationOptions"; + +import LineDivider from "../../components/ui/LineDivider"; + +import { Children } from "../../types/Preact"; + +interface Action { + key: string; + type: "channel" | "server"; + state?: NotificationState; +} + +/** + * Provides a context menu for controlling notification options. + */ +export default observer(() => { + const notifications = useApplicationState().notifications; + + const contextClick = (data?: Action) => + data && + (data.type === "channel" + ? notifications.setChannelState(data.key, data.state) + : notifications.setServerState(data.key, data.state)); + + return ( + + {({ channel, server }: { channel?: Channel; server?: Server }) => { + // Find the computed and actual state values for channel / server. + const state = channel + ? notifications.getChannelState(channel._id) + : notifications.computeForServer(server!._id); + + const actual = channel + ? notifications.computeForChannel(channel) + : undefined; + + // If we're editing channel, show a default option too. + const elements: Children[] = channel + ? [ + + +
+ {state !== undefined && } + {state === undefined && ( + + )} +
+
, + , + ] + : []; + + /** + * Generate a new entry we can select. + * @param key Notification state + * @param icon Icon for this state + */ + function generate(key: string, icon: Children) { + elements.push( + + {icon} + + {state === undefined && actual === key && ( +
+ +
+ )} + {state === key && ( +
+ +
+ )} +
, + ); + } + + generate("all", ); + generate("mention", ); + generate("none", ); + generate("muted", ); + + return elements; + }} +
+ ); +}); diff --git a/src/mobx/State.ts b/src/mobx/State.ts index c4d808ed..aff12eb8 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -10,6 +10,7 @@ import Draft from "./stores/Draft"; import Experiments from "./stores/Experiments"; import Layout from "./stores/Layout"; import LocaleOptions from "./stores/LocaleOptions"; +import NotificationOptions from "./stores/NotificationOptions"; import ServerConfig from "./stores/ServerConfig"; /** @@ -22,6 +23,7 @@ export default class State { experiments: Experiments; layout: Layout; config: ServerConfig; + notifications: NotificationOptions; private persistent: [string, Persistent][] = []; @@ -35,6 +37,7 @@ export default class State { this.experiments = new Experiments(); this.layout = new Layout(); this.config = new ServerConfig(); + this.notifications = new NotificationOptions(); makeAutoObservable(this); this.registerListeners = this.registerListeners.bind(this); diff --git a/src/mobx/stores/LocaleOptions.ts b/src/mobx/stores/LocaleOptions.ts index a4bd249f..842a3616 100644 --- a/src/mobx/stores/LocaleOptions.ts +++ b/src/mobx/stores/LocaleOptions.ts @@ -49,9 +49,7 @@ export function findLanguage(lang?: string): Language { } /** - * 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. + * Keeps track of user's language settings. */ export default class LocaleOptions implements Store, Persistent { private lang: Language; diff --git a/src/mobx/stores/NotificationOptions.ts b/src/mobx/stores/NotificationOptions.ts index 0d8bc6ea..44e4ef4d 100644 --- a/src/mobx/stores/NotificationOptions.ts +++ b/src/mobx/stores/NotificationOptions.ts @@ -1,5 +1,7 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; -import { Channel } from "revolt-api/types/Channels"; +import { Channel } from "revolt.js/dist/maps/Channels"; +import { Message } from "revolt.js/dist/maps/Messages"; +import { Server } from "revolt.js/dist/maps/Servers"; import { mapToRecord } from "../../lib/conversion"; @@ -22,21 +24,26 @@ export const DEFAULT_STATES: { SavedMessages: "all", DirectMessage: "all", Group: "all", - TextChannel: "mention", - VoiceChannel: "mention", + TextChannel: undefined!, + VoiceChannel: undefined!, }; +/** + * Default state for servers. + */ +export const DEFAULT_SERVER_STATE: NotificationState = "mention"; + interface Data { - server?: Record; - channel?: Record; + server?: Record; + channel?: Record; } /** * Manages the user's notification preferences. */ export default class NotificationOptions implements Store, Persistent { - private server: ObservableMap; - private channel: ObservableMap; + private server: ObservableMap; + private channel: ObservableMap; /** * Construct new Experiments store. @@ -72,5 +79,72 @@ export default class NotificationOptions implements Store, Persistent { } } - // TODO: implement + computeForChannel(channel: Channel) { + if (this.channel.has(channel._id)) { + return this.channel.get(channel._id); + } + + if (channel.server_id) { + return this.computeForServer(channel.server_id); + } + + return DEFAULT_STATES[channel.channel_type]; + } + + shouldNotify(message: Message) { + const state = this.computeForChannel(message.channel!); + + switch (state) { + case "muted": + case "none": + return false; + case "mention": + if (!message.mention_ids?.includes(message.client.user!._id)) + return false; + } + + return true; + } + + computeForServer(server_id: string) { + if (this.server.has(server_id)) { + return this.server.get(server_id); + } + + return DEFAULT_SERVER_STATE; + } + + getChannelState(channel_id: string) { + return this.channel.get(channel_id); + } + + setChannelState(channel_id: string, state?: NotificationState) { + if (state) { + this.channel.set(channel_id, state); + } else { + this.channel.delete(channel_id); + } + } + + getServerState(server_id: string) { + return this.server.get(server_id); + } + + setServerState(server_id: string, state?: NotificationState) { + if (state) { + this.server.set(server_id, state); + } else { + this.server.delete(server_id); + } + } + + isMuted(target?: Channel | Server) { + if (target instanceof Channel) { + return this.computeForChannel(target) === "muted"; + } else if (target instanceof Server) { + return this.computeForServer(target._id) === "muted"; + } else { + return false; + } + } }