feat(mobx): server notification options + data store

This commit is contained in:
Paul 2021-12-11 23:34:46 +00:00
parent f8b8d96d3d
commit 413bf6949b
10 changed files with 237 additions and 102 deletions

View file

@ -117,7 +117,7 @@
} }
&[data-muted="true"] { &[data-muted="true"] {
color: var(--tertiary-foreground); opacity: 0.4;
} }
&[data-alert="true"], &[data-alert="true"],

View file

@ -199,7 +199,7 @@ interface Props {
export const ServerListSidebar = observer(({ unreads }: Props) => { export const ServerListSidebar = observer(({ unreads }: Props) => {
const client = useClient(); const client = useClient();
const layout = useApplicationState().layout; const state = useApplicationState();
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;
@ -210,6 +210,7 @@ export const ServerListSidebar = observer(({ unreads }: Props) => {
const unreadChannels = channels const unreadChannels = channels
.filter((x) => x.unread) .filter((x) => x.unread)
.filter((x) => !state.notifications.isMuted(x.channel))
.map((x) => x.channel?._id); .map((x) => x.channel?._id);
const servers = activeServers.map((server) => { const servers = activeServers.map((server) => {
@ -268,7 +269,7 @@ export const ServerListSidebar = observer(({ unreads }: Props) => {
<ServerList> <ServerList>
<ConditionalLink <ConditionalLink
active={homeActive} active={homeActive}
to={layout.getLastHomePath()}> to={state.layout.getLastHomePath()}>
<ServerEntry home active={homeActive}> <ServerEntry home active={homeActive}>
<Swoosh /> <Swoosh />
<div <div
@ -300,7 +301,7 @@ export const ServerListSidebar = observer(({ unreads }: Props) => {
<ConditionalLink <ConditionalLink
key={entry.server._id} key={entry.server._id}
active={active} active={active}
to={layout.getServerPath(entry.server._id)}> to={state.layout.getServerPath(entry.server._id)}>
<ServerEntry <ServerEntry
active={active} active={active}
onContextMenu={attachContextMenu("Menu", { onContextMenu={attachContextMenu("Menu", {

View file

@ -28,7 +28,6 @@ import ConnectionStatus from "../items/ConnectionStatus";
interface Props { interface Props {
unreads: Unreads; unreads: Unreads;
notifications: Notifications;
} }
const ServerBase = styled.div` const ServerBase = styled.div`
@ -59,7 +58,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 state = useApplicationState();
const { server: server_id, channel: channel_id } = const { server: server_id, channel: channel_id } =
useParams<{ server: string; channel?: string }>(); useParams<{ server: string; channel?: string }>();
@ -85,7 +84,7 @@ const ServerSidebar = observer((props: Props) => {
if (!channel_id) return; if (!channel_id) return;
if (!server_id) return; if (!server_id) return;
layout.setLastOpened(server_id, channel_id); state.layout.setLastOpened(server_id, channel_id);
}, [channel_id, server_id]); }, [channel_id, server_id]);
const uncategorised = new Set(server.channel_ids); const uncategorised = new Set(server.channel_ids);
@ -96,7 +95,6 @@ const ServerSidebar = observer((props: Props) => {
if (!entry) return; if (!entry) return;
const active = channel?._id === entry._id; const active = channel?._id === entry._id;
const muted = props.notifications[id] === "none";
return ( return (
<ConditionalLink <ConditionalLink
@ -120,7 +118,7 @@ const ServerSidebar = observer((props: Props) => {
// ! FIXME: pull it out directly // ! FIXME: pull it out directly
alert={mapChannelWithUnread(entry, props.unreads).unread} alert={mapChannelWithUnread(entry, props.unreads).unread}
compact compact
muted={muted} muted={state.notifications.isMuted(entry)}
/> />
</ConditionalLink> </ConditionalLink>
); );

View file

@ -8,6 +8,7 @@ import { useCallback, useContext, useEffect } from "preact/hooks";
import { useTranslation } from "../../lib/i18n"; import { useTranslation } from "../../lib/i18n";
import { useApplicationState } from "../../mobx/State";
import { connectState } from "../../redux/connector"; import { connectState } from "../../redux/connector";
import { import {
getNotificationState, getNotificationState,
@ -21,7 +22,6 @@ import { AppContext } from "./RevoltClient";
interface Props { interface Props {
options?: NotificationOptions; options?: NotificationOptions;
notifs: Notifications;
} }
const notifications: { [key: string]: Notification } = {}; 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 translate = useTranslation();
const showNotification = options?.desktopEnabled ?? false; const showNotification = options?.desktopEnabled ?? false;
const notifs = useApplicationState().notifications;
const client = useContext(AppContext); const client = useContext(AppContext);
const { guild: guild_id, channel: channel_id } = useParams<{ 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 (client.user!.status?.presence === Presence.Busy) return;
if (msg.author?.relationship === RelationshipStatus.Blocked) return; if (msg.author?.relationship === RelationshipStatus.Blocked) return;
const notifState = getNotificationState(notifs, msg.channel!); if (!notifs.shouldNotify(msg)) return;
if (!shouldNotify(notifState, msg, client.user!._id)) return;
playSound("message"); playSound("message");
if (!showNotification) return; if (!showNotification) return;
@ -294,7 +294,6 @@ const NotifierComponent = connectState(
(state) => { (state) => {
return { return {
options: state.settings.notification, options: state.settings.notification,
notifs: state.notifications,
}; };
}, },
true, true,

View file

@ -152,6 +152,6 @@ export default connectState(SyncManager, (state) => {
}; };
});*/ });*/
function SyncManager() { export default function SyncManager() {
return <></>; return <></>;
} }

View file

@ -48,6 +48,7 @@ import {
StatusContext, StatusContext,
} from "../context/revoltjs/RevoltClient"; } from "../context/revoltjs/RevoltClient";
import { takeError } from "../context/revoltjs/util"; import { takeError } from "../context/revoltjs/util";
import CMNotifications from "./contextmenu/CMNotifications";
import Tooltip from "../components/common/Tooltip"; import Tooltip from "../components/common/Tooltip";
import UserStatus from "../components/common/user/UserStatus"; import UserStatus from "../components/common/user/UserStatus";
@ -117,7 +118,11 @@ type Action =
| { action: "leave_server"; target: Server } | { action: "leave_server"; target: Server }
| { action: "delete_server"; target: Server } | { action: "delete_server"; target: Server }
| { action: "edit_identity"; 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_settings" }
| { action: "open_channel_settings"; id: string } | { action: "open_channel_settings"; id: string }
| { action: "open_server_settings"; id: string } | { action: "open_server_settings"; id: string }
@ -128,13 +133,9 @@ type Action =
state?: NotificationState; state?: NotificationState;
}; };
type Props = {
notifications: Notifications;
};
// ! FIXME: I dare someone to re-write this // ! FIXME: I dare someone to re-write this
// Tip: This should just be split into separate context menus per logical area. // 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 { openScreen, writeClipboard } = useIntermediate();
const client = useContext(AppContext); const client = useContext(AppContext);
const userId = client.user!._id; const userId = client.user!._id;
@ -427,6 +428,7 @@ function ContextMenus(props: Props) {
case "open_notification_options": { case "open_notification_options": {
openContextMenu("NotificationOptions", { openContextMenu("NotificationOptions", {
channel: data.channel, channel: data.channel,
server: data.server,
}); });
break; break;
} }
@ -921,6 +923,16 @@ function ContextMenus(props: Props) {
} }
if (sid && server) { if (sid && server) {
generateAction(
{
action: "open_notification_options",
server,
},
undefined,
undefined,
<ChevronRight size={24} />,
);
if (server.channels[0] !== undefined) if (server.channels[0] !== undefined)
generateAction( generateAction(
{ {
@ -1085,76 +1097,7 @@ function ContextMenus(props: Props) {
); );
}} }}
</ContextMenuWithData> </ContextMenuWithData>
<ContextMenuWithData <CMNotifications />
id="NotificationOptions"
onClose={contextClick}>
{({ channel }: { channel: Channel }) => {
const state = props.notifications[channel._id];
const actual = getNotificationState(
props.notifications,
channel,
);
const elements: Children[] = [
<MenuItem
key="notif"
data={{
action: "set_notification_state",
key: channel._id,
}}>
<Text
id={`app.main.channel.notifications.default`}
/>
<div className="tip">
{state !== undefined && <Square size={20} />}
{state === undefined && (
<CheckSquare size={20} />
)}
</div>
</MenuItem>,
];
function generate(key: string, icon: Children) {
elements.push(
<MenuItem
key={key}
data={{
action: "set_notification_state",
key: channel._id,
state: key,
}}>
{icon}
<Text
id={`app.main.channel.notifications.${key}`}
/>
{state === undefined && actual === key && (
<div className="tip">
<LeftArrowAlt size={20} />
</div>
)}
{state === key && (
<div className="tip">
<Check size={20} />
</div>
)}
</MenuItem>,
);
}
generate("all", <Bell size={24} />);
generate("mention", <At size={24} />);
generate("muted", <BellOff size={24} />);
generate("none", <Block size={24} />);
return elements;
}}
</ContextMenuWithData>
</> </>
); );
} }
export default connectState(ContextMenus, (state) => {
return {
notifications: state.notifications,
};
});

View file

@ -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 (
<ContextMenuWithData id="NotificationOptions" onClose={contextClick}>
{({ 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
? [
<MenuItem
key="notif"
data={{
key: channel._id,
type: "channel",
}}>
<Text
id={`app.main.channel.notifications.default`}
/>
<div className="tip">
{state !== undefined && <Square size={20} />}
{state === undefined && (
<CheckSquare size={20} />
)}
</div>
</MenuItem>,
<LineDivider />,
]
: [];
/**
* 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(
<MenuItem
key={key}
data={{
key: channel ? channel._id : server!._id,
type: channel ? "channel" : "server",
state: key,
}}>
{icon}
<Text
id={`app.main.channel.notifications.${key}`}
/>
{state === undefined && actual === key && (
<div className="tip">
<LeftArrowAlt size={20} />
</div>
)}
{state === key && (
<div className="tip">
<Check size={20} />
</div>
)}
</MenuItem>,
);
}
generate("all", <Bell size={24} />);
generate("mention", <At size={24} />);
generate("none", <BellOff size={24} />);
generate("muted", <Block size={24} />);
return elements;
}}
</ContextMenuWithData>
);
});

View file

@ -10,6 +10,7 @@ import Draft from "./stores/Draft";
import Experiments from "./stores/Experiments"; import Experiments from "./stores/Experiments";
import Layout from "./stores/Layout"; import Layout from "./stores/Layout";
import LocaleOptions from "./stores/LocaleOptions"; import LocaleOptions from "./stores/LocaleOptions";
import NotificationOptions from "./stores/NotificationOptions";
import ServerConfig from "./stores/ServerConfig"; import ServerConfig from "./stores/ServerConfig";
/** /**
@ -22,6 +23,7 @@ export default class State {
experiments: Experiments; experiments: Experiments;
layout: Layout; layout: Layout;
config: ServerConfig; config: ServerConfig;
notifications: NotificationOptions;
private persistent: [string, Persistent<unknown>][] = []; private persistent: [string, Persistent<unknown>][] = [];
@ -35,6 +37,7 @@ export default class State {
this.experiments = new Experiments(); this.experiments = new Experiments();
this.layout = new Layout(); this.layout = new Layout();
this.config = new ServerConfig(); this.config = new ServerConfig();
this.notifications = new NotificationOptions();
makeAutoObservable(this); makeAutoObservable(this);
this.registerListeners = this.registerListeners.bind(this); this.registerListeners = this.registerListeners.bind(this);

View file

@ -49,9 +49,7 @@ export function findLanguage(lang?: string): Language {
} }
/** /**
* Keeps track of the last open channels, tabs, etc. * Keeps track of user's language settings.
* Handles providing good UX experience on navigating
* back and forth between different parts of the app.
*/ */
export default class LocaleOptions implements Store, Persistent<Data> { export default class LocaleOptions implements Store, Persistent<Data> {
private lang: Language; private lang: Language;

View file

@ -1,5 +1,7 @@
import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; 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"; import { mapToRecord } from "../../lib/conversion";
@ -22,21 +24,26 @@ export const DEFAULT_STATES: {
SavedMessages: "all", SavedMessages: "all",
DirectMessage: "all", DirectMessage: "all",
Group: "all", Group: "all",
TextChannel: "mention", TextChannel: undefined!,
VoiceChannel: "mention", VoiceChannel: undefined!,
}; };
/**
* Default state for servers.
*/
export const DEFAULT_SERVER_STATE: NotificationState = "mention";
interface Data { interface Data {
server?: Record<string, string>; server?: Record<string, NotificationState>;
channel?: Record<string, string>; channel?: Record<string, NotificationState>;
} }
/** /**
* Manages the user's notification preferences. * Manages the user's notification preferences.
*/ */
export default class NotificationOptions implements Store, Persistent<Data> { export default class NotificationOptions implements Store, Persistent<Data> {
private server: ObservableMap<string, string>; private server: ObservableMap<string, NotificationState>;
private channel: ObservableMap<string, string>; private channel: ObservableMap<string, NotificationState>;
/** /**
* Construct new Experiments store. * Construct new Experiments store.
@ -72,5 +79,72 @@ export default class NotificationOptions implements Store, Persistent<Data> {
} }
} }
// 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;
}
}
} }