Feature: Basic notification options implementation

This commit is contained in:
Paul 2021-06-24 23:59:46 +01:00
parent 51ac1f599c
commit 21859e4c55
10 changed files with 173 additions and 25 deletions

2
external/lang vendored

@ -1 +1 @@
Subproject commit 099fb74131c60955e8226ce0a290cb22e959d7d6 Subproject commit 9cc46c3a4abab74e17e56597db10e2c16ac0f9b5

View file

@ -116,7 +116,7 @@ export default function Intermediate(props: Props) {
screen.id screen.id
} /** By specifying a key, we reset state whenever switching screen. */ } /** By specifying a key, we reset state whenever switching screen. */
/> />
{/*<Prompt <Prompt
when={[ 'modify_account', 'special_prompt', 'special_input', 'image_viewer', 'profile', 'channel_info', 'user_picker' ].includes(screen.id)} when={[ 'modify_account', 'special_prompt', 'special_input', 'image_viewer', 'profile', 'channel_info', 'user_picker' ].includes(screen.id)}
message={(_, action) => { message={(_, action) => {
if (action === 'POP') { if (action === 'POP') {
@ -128,7 +128,7 @@ export default function Intermediate(props: Props) {
return true; return true;
}} }}
/>*/} />
</IntermediateActionsContext.Provider> </IntermediateActionsContext.Provider>
</IntermediateContext.Provider> </IntermediateContext.Provider>
); );

View file

@ -8,9 +8,11 @@ import { connectState } from "../../redux/connector";
import { Message, SYSTEM_USER_ID, User } from "revolt.js"; import { Message, SYSTEM_USER_ID, User } from "revolt.js";
import { NotificationOptions } from "../../redux/reducers/settings"; import { NotificationOptions } from "../../redux/reducers/settings";
import { Route, Switch, useHistory, useParams } from "react-router-dom"; import { Route, Switch, useHistory, useParams } from "react-router-dom";
import { getNotificationState, Notifications } from "../../redux/reducers/notifications";
interface Props { interface Props {
options?: NotificationOptions; options?: NotificationOptions;
notifs: Notifications;
} }
const notifications: { [key: string]: Notification } = {}; const notifications: { [key: string]: Notification } = {};
@ -24,9 +26,9 @@ async function createNotification(title: string, options: globalThis.Notificatio
} }
} }
function Notifier(props: Props) { function Notifier({ options, notifs }: Props) {
const translate = useTranslation(); const translate = useTranslation();
const showNotification = props.options?.desktopEnabled ?? false; const showNotification = options?.desktopEnabled ?? false;
const client = useContext(AppContext); const client = useContext(AppContext);
const { guild: guild_id, channel: channel_id } = useParams<{ const { guild: guild_id, channel: channel_id } = useParams<{
@ -39,17 +41,27 @@ function Notifier(props: Props) {
async function message(msg: Message) { async function message(msg: Message) {
if (msg.author === client.user!._id) return; if (msg.author === client.user!._id) return;
if (msg.channel === channel_id && document.hasFocus()) return; if (msg.channel === channel_id && document.hasFocus()) return;
if (client.user?.status?.presence === Users.Presence.Busy) return; if (client.user!.status?.presence === Users.Presence.Busy) return;
const channel = client.channels.get(msg.channel);
const author = client.users.get(msg.author);
if (!channel) return;
if (author?.relationship === Users.Relationship.Blocked) return;
const notifState = getNotificationState(notifs, channel);
switch (notifState) {
case 'muted':
case 'none': return;
case 'mention': {
if (!msg.mentions?.includes(client.user!._id)) return;
}
}
playSound('message'); playSound('message');
if (!showNotification) return; if (!showNotification) return;
const channel = client.channels.get(msg.channel);
const author = client.users.get(msg.author);
if (author?.relationship === Users.Relationship.Blocked) return;
let title; let title;
switch (channel?.channel_type) { switch (channel.channel_type) {
case "SavedMessages": case "SavedMessages":
return; return;
case "DirectMessage": case "DirectMessage":
@ -192,7 +204,7 @@ function Notifier(props: Props) {
client.removeListener("message", message); client.removeListener("message", message);
client.users.removeListener("mutation", relationship); client.users.removeListener("mutation", relationship);
}; };
}, [client, playSound, guild_id, channel_id, showNotification]); }, [client, playSound, guild_id, channel_id, showNotification, notifs]);
useEffect(() => { useEffect(() => {
function visChange() { function visChange() {
@ -217,7 +229,8 @@ const NotifierComponent = connectState(
Notifier, Notifier,
state => { state => {
return { return {
options: state.settings.notification options: state.settings.notification,
notifs: state.notifications
}; };
}, },
true true

View file

@ -9,6 +9,7 @@ import { useContext, useEffect } from "preact/hooks";
import { connectState } from "../../redux/connector"; import { connectState } from "../../redux/connector";
import { WithDispatcher } from "../../redux/reducers"; import { WithDispatcher } from "../../redux/reducers";
import { Settings } from "../../redux/reducers/settings"; import { Settings } from "../../redux/reducers/settings";
import { Notifications } from "../../redux/reducers/notifications";
import { AppContext, ClientStatus, StatusContext } from "./RevoltClient"; import { AppContext, ClientStatus, StatusContext } from "./RevoltClient";
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"; import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { DEFAULT_ENABLED_SYNC, SyncData, SyncKeys, SyncOptions } from "../../redux/reducers/sync"; import { DEFAULT_ENABLED_SYNC, SyncData, SyncKeys, SyncOptions } from "../../redux/reducers/sync";
@ -16,7 +17,8 @@ import { DEFAULT_ENABLED_SYNC, SyncData, SyncKeys, SyncOptions } from "../../red
type Props = WithDispatcher & { type Props = WithDispatcher & {
settings: Settings, settings: Settings,
locale: Language, locale: Language,
sync: SyncOptions sync: SyncOptions,
notifications: Notifications
}; };
var lastValues: { [key in SyncKeys]?: any } = { }; var lastValues: { [key in SyncKeys]?: any } = { };
@ -78,7 +80,7 @@ function SyncManager(props: Props) {
} }
let disabled = props.sync.disabled ?? []; let disabled = props.sync.disabled ?? [];
for (let [key, object] of [ ['appearance', props.settings.appearance], ['theme', props.settings.theme], ['locale', props.locale] ] as [SyncKeys, any][]) { for (let [key, object] of [ ['appearance', props.settings.appearance], ['theme', props.settings.theme], ['locale', props.locale], ['notifications', props.notifications] ] as [SyncKeys, any][]) {
useEffect(() => { useEffect(() => {
if (disabled.indexOf(key) === -1) { if (disabled.indexOf(key) === -1) {
if (typeof lastValues[key] !== 'undefined') { if (typeof lastValues[key] !== 'undefined') {
@ -117,7 +119,8 @@ export default connectState(
return { return {
settings: state.settings, settings: state.settings,
locale: state.locale, locale: state.locale,
sync: state.sync sync: state.sync,
notifications: state.notifications
}; };
}, },
true true

View file

@ -5,7 +5,8 @@ import { Attachment, Channels, Message, Servers, Users } from "revolt.js/dist/ap
import { import {
ContextMenu, ContextMenu,
ContextMenuWithData, ContextMenuWithData,
MenuItem MenuItem,
openContextMenu
} from "preact-context-menu"; } from "preact-context-menu";
import { ChannelPermission, ServerPermission, UserPermission } from "revolt.js/dist/api/permissions"; import { ChannelPermission, ServerPermission, UserPermission } from "revolt.js/dist/api/permissions";
import { QueuedMessage } from "../redux/reducers/queue"; import { QueuedMessage } from "../redux/reducers/queue";
@ -18,6 +19,9 @@ import { Children } from "../types/Preact";
import LineDivider from "../components/ui/LineDivider"; import LineDivider from "../components/ui/LineDivider";
import { connectState } from "../redux/connector"; import { connectState } from "../redux/connector";
import { internalEmit } from "./eventEmitter"; import { internalEmit } from "./eventEmitter";
import { AtSign, Bell, BellOff, Check, CheckSquare, ChevronRight, Slash, Square } from "@styled-icons/feather";
import { getNotificationState, Notifications, NotificationState } from "../redux/reducers/notifications";
import { ArrowLeft } from "@styled-icons/bootstrap";
interface ContextMenuData { interface ContextMenuData {
user?: string; user?: string;
@ -68,11 +72,17 @@ type Action =
| { action: "close_dm"; target: Channels.DirectMessageChannel } | { action: "close_dm"; target: Channels.DirectMessageChannel }
| { action: "leave_server"; target: Servers.Server } | { action: "leave_server"; target: Servers.Server }
| { action: "delete_server"; target: Servers.Server } | { action: "delete_server"; target: Servers.Server }
| { action: "open_notification_options", channel: Channels.Channel }
| { action: "open_channel_settings", id: string } | { action: "open_channel_settings", id: string }
| { action: "open_server_settings", id: string } | { action: "open_server_settings", id: string }
| { action: "open_server_channel_settings", server: string, id: string }; | { action: "open_server_channel_settings", server: string, id: string }
| { action: "set_notification_state", key: string, state?: NotificationState };
function ContextMenus(props: WithDispatcher) { type Props = WithDispatcher & {
notifications: Notifications
};
function ContextMenus(props: Props) {
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;
@ -301,9 +311,24 @@ function ContextMenus(props: WithDispatcher) {
case "ban_member": case "ban_member":
case "kick_member": openScreen({ id: "special_prompt", type: data.action, target: data.target, user: data.user }); break; case "kick_member": openScreen({ id: "special_prompt", type: data.action, target: data.target, user: data.user }); break;
case "open_notification_options": {
openContextMenu("NotificationOptions", { channel: data.channel });
break;
}
case "open_channel_settings": history.push(`/channel/${data.id}/settings`); break; case "open_channel_settings": history.push(`/channel/${data.id}/settings`); break;
case "open_server_channel_settings": history.push(`/server/${data.server}/channel/${data.id}/settings`); break; case "open_server_channel_settings": history.push(`/server/${data.server}/channel/${data.id}/settings`); break;
case "open_server_settings": history.push(`/server/${data.id}/settings`); break; case "open_server_settings": history.push(`/server/${data.id}/settings`); break;
case "set_notification_state": {
const { key, state } = data;
if (state) {
props.dispatcher({ type: "NOTIFICATIONS_SET", key, state });
} else {
props.dispatcher({ type: "NOTIFICATIONS_REMOVE", key });
}
break;
}
} }
})().catch(err => { })().catch(err => {
openScreen({ id: "error", error: takeError(err) }); openScreen({ id: "error", error: takeError(err) });
@ -567,6 +592,10 @@ function ContextMenus(props: WithDispatcher) {
pushDivider(); pushDivider();
if (channel) { if (channel) {
if (channel.channel_type !== 'VoiceChannel') {
generateAction({ action: "open_notification_options", channel }, undefined, undefined, <ChevronRight size={24} />);
}
switch (channel.channel_type) { switch (channel.channel_type) {
case 'Group': case 'Group':
// ! generateAction({ action: "create_invite", target: channel }); FIXME: add support for group invites // ! generateAction({ action: "create_invite", target: channel }); FIXME: add support for group invites
@ -669,14 +698,50 @@ function ContextMenus(props: WithDispatcher) {
</MenuItem> </MenuItem>
)} )}
</ContextMenu> </ContextMenu>
<ContextMenuWithData id="NotificationOptions" onClose={contextClick}>
{({ channel }: { channel: Channels.Channel }) => {
const state = props.notifications[channel._id];
const actual = getNotificationState(props.notifications, channel);
let elements: Children[] = [
<MenuItem 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 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"><ArrowLeft size={20} /></div> }
{ (state === key) && <div className="tip"><Check size={20} /></div> }
</MenuItem>
);
}
generate('all', <Bell size={24} />);
generate('mention', <AtSign size={24} />);
generate('muted', <BellOff size={24} />);
generate('none', <Slash size={24} />);
return elements;
}}
</ContextMenuWithData>
</> </>
); );
} }
export default connectState( export default connectState(
ContextMenus, ContextMenus,
() => { state => {
return {}; return {
notifications: state.notifications
};
}, },
true true
); );

View file

@ -20,6 +20,7 @@ export function Component(props: Props & WithDispatcher) {
['appearance', 'appearance.title'], ['appearance', 'appearance.title'],
['theme', 'appearance.theme'], ['theme', 'appearance.theme'],
['locale', 'language.title'] ['locale', 'language.title']
// notifications sync is always-on
] as [ SyncKeys, string ][]).map( ] as [ SyncKeys, string ][]).map(
([ key, title ]) => ([ key, title ]) =>
<Checkbox <Checkbox

View file

@ -13,6 +13,7 @@ import { Settings } from "./reducers/settings";
import { QueuedMessage } from "./reducers/queue"; import { QueuedMessage } from "./reducers/queue";
import { ExperimentOptions } from "./reducers/experiments"; import { ExperimentOptions } from "./reducers/experiments";
import { LastOpened } from "./reducers/last_opened"; import { LastOpened } from "./reducers/last_opened";
import { Notifications } from "./reducers/notifications";
export type State = { export type State = {
config: Core.RevoltNodeConfiguration, config: Core.RevoltNodeConfiguration,
@ -26,6 +27,7 @@ export type State = {
sync: SyncOptions; sync: SyncOptions;
experiments: ExperimentOptions; experiments: ExperimentOptions;
lastOpened: LastOpened; lastOpened: LastOpened;
notifications: Notifications;
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -53,7 +55,8 @@ store.subscribe(() => {
drafts, drafts,
sync, sync,
experiments, experiments,
lastOpened lastOpened,
notifications
} = store.getState() as State; } = store.getState() as State;
localForage.setItem("state", { localForage.setItem("state", {
@ -66,6 +69,7 @@ store.subscribe(() => {
drafts, drafts,
sync, sync,
experiments, experiments,
lastOpened lastOpened,
notifications
}); });
}); });

View file

@ -12,6 +12,7 @@ import { drafts, DraftAction } from "./drafts";
import { sync, SyncAction } from "./sync"; import { sync, SyncAction } from "./sync";
import { experiments, ExperimentsAction } from "./experiments"; import { experiments, ExperimentsAction } from "./experiments";
import { lastOpened, LastOpenedAction } from "./last_opened"; import { lastOpened, LastOpenedAction } from "./last_opened";
import { notifications, NotificationsAction } from "./notifications";
export default combineReducers({ export default combineReducers({
config, config,
@ -24,7 +25,8 @@ export default combineReducers({
drafts, drafts,
sync, sync,
experiments, experiments,
lastOpened lastOpened,
notifications
}); });
export type Action = export type Action =
@ -39,6 +41,7 @@ export type Action =
| SyncAction | SyncAction
| ExperimentsAction | ExperimentsAction
| LastOpenedAction | LastOpenedAction
| NotificationsAction
| { type: "__INIT"; state: State }; | { type: "__INIT"; state: State };
export type WithDispatcher = { dispatcher: (action: Action) => void }; export type WithDispatcher = { dispatcher: (action: Action) => void };

View file

@ -0,0 +1,56 @@
import { Channel } from "revolt.js";
export type NotificationState = 'all' | 'mention' | 'none' | 'muted';
export type Notifications = {
[key: string]: NotificationState
}
export const DEFAULT_STATES: { [key in Channel['channel_type']]: NotificationState } = {
'SavedMessages': 'all',
'DirectMessage': 'all',
'Group': 'all',
'TextChannel': 'mention',
'VoiceChannel': 'mention'
};
export function getNotificationState(notifications: Notifications, channel: Channel) {
return notifications[channel._id] ?? DEFAULT_STATES[channel.channel_type];
}
export type NotificationsAction =
| { type: undefined }
| {
type: "NOTIFICATIONS_SET";
key: string;
state: NotificationState;
}
| {
type: "NOTIFICATIONS_REMOVE";
key: string;
}
| {
type: "RESET";
};
export function notifications(
state = {} as Notifications,
action: NotificationsAction
): Notifications {
switch (action.type) {
case "NOTIFICATIONS_SET":
return {
...state,
[action.key]: action.state
};
case "NOTIFICATIONS_REMOVE":
{
const { [action.key]: _, ...newState } = state;
return newState;
}
case "RESET":
return {};
default:
return state;
}
}

View file

@ -1,19 +1,22 @@
import { AppearanceOptions } from "./settings"; import { AppearanceOptions } from "./settings";
import { Language } from "../../context/Locale"; import { Language } from "../../context/Locale";
import { ThemeOptions } from "../../context/Theme"; import { ThemeOptions } from "../../context/Theme";
import { Notifications } from "./notifications";
export type SyncKeys = "theme" | "appearance" | "locale"; export type SyncKeys = "theme" | "appearance" | "locale" | "notifications";
export interface SyncData { export interface SyncData {
locale?: Language; locale?: Language;
theme?: ThemeOptions; theme?: ThemeOptions;
appearance?: AppearanceOptions; appearance?: AppearanceOptions;
notifications?: Notifications;
} }
export const DEFAULT_ENABLED_SYNC: SyncKeys[] = [ export const DEFAULT_ENABLED_SYNC: SyncKeys[] = [
"theme", "theme",
"appearance", "appearance",
"locale", "locale",
"notifications"
]; ];
export interface SyncOptions { export interface SyncOptions {
disabled?: SyncKeys[]; disabled?: SyncKeys[];