diff --git a/external/lang b/external/lang index 099fb74131c60955e8226ce0a290cb22e959d7d6..9cc46c3a4abab74e17e56597db10e2c16ac0f9b5 160000 --- a/external/lang +++ b/external/lang @@ -1 +1 @@ -Subproject commit 099fb74131c60955e8226ce0a290cb22e959d7d6 +Subproject commit 9cc46c3a4abab74e17e56597db10e2c16ac0f9b5 diff --git a/src/context/intermediate/Intermediate.tsx b/src/context/intermediate/Intermediate.tsx index 12f0e442a014020699acbfef644ef1cdfb4325cd..e96a72a3b8671c35e41d459b0418592fdd747aac 100644 --- a/src/context/intermediate/Intermediate.tsx +++ b/src/context/intermediate/Intermediate.tsx @@ -116,7 +116,7 @@ export default function Intermediate(props: Props) { screen.id } /** 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)} message={(_, action) => { if (action === 'POP') { @@ -128,7 +128,7 @@ export default function Intermediate(props: Props) { return true; }} - />*/} + /> </IntermediateActionsContext.Provider> </IntermediateContext.Provider> ); diff --git a/src/context/revoltjs/Notifications.tsx b/src/context/revoltjs/Notifications.tsx index 51f610b8366858646cc4ca7654880d02150b4748..d9f4f1f9aba804f3d955297330a5e5ffd41ed2e4 100644 --- a/src/context/revoltjs/Notifications.tsx +++ b/src/context/revoltjs/Notifications.tsx @@ -8,9 +8,11 @@ import { connectState } from "../../redux/connector"; import { Message, SYSTEM_USER_ID, User } from "revolt.js"; import { NotificationOptions } from "../../redux/reducers/settings"; import { Route, Switch, useHistory, useParams } from "react-router-dom"; +import { getNotificationState, Notifications } from "../../redux/reducers/notifications"; interface Props { options?: NotificationOptions; + notifs: Notifications; } 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 showNotification = props.options?.desktopEnabled ?? false; + const showNotification = options?.desktopEnabled ?? false; const client = useContext(AppContext); const { guild: guild_id, channel: channel_id } = useParams<{ @@ -39,17 +41,27 @@ function Notifier(props: Props) { async function message(msg: Message) { if (msg.author === client.user!._id) return; if (msg.channel === channel_id && document.hasFocus()) return; - if (client.user?.status?.presence === Users.Presence.Busy) return; - - playSound('message'); - if (!showNotification) 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'); + if (!showNotification) return; + let title; - switch (channel?.channel_type) { + switch (channel.channel_type) { case "SavedMessages": return; case "DirectMessage": @@ -192,7 +204,7 @@ function Notifier(props: Props) { client.removeListener("message", message); client.users.removeListener("mutation", relationship); }; - }, [client, playSound, guild_id, channel_id, showNotification]); + }, [client, playSound, guild_id, channel_id, showNotification, notifs]); useEffect(() => { function visChange() { @@ -217,7 +229,8 @@ const NotifierComponent = connectState( Notifier, state => { return { - options: state.settings.notification + options: state.settings.notification, + notifs: state.notifications }; }, true diff --git a/src/context/revoltjs/SyncManager.tsx b/src/context/revoltjs/SyncManager.tsx index 95361cc5e5439218c9886878f33bc98ac54c8874..d3fd7ca7a1ecb1b3c7d91926403636840e0d6544 100644 --- a/src/context/revoltjs/SyncManager.tsx +++ b/src/context/revoltjs/SyncManager.tsx @@ -9,6 +9,7 @@ import { useContext, useEffect } from "preact/hooks"; import { connectState } from "../../redux/connector"; import { WithDispatcher } from "../../redux/reducers"; import { Settings } from "../../redux/reducers/settings"; +import { Notifications } from "../../redux/reducers/notifications"; import { AppContext, ClientStatus, StatusContext } from "./RevoltClient"; import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"; 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 & { settings: Settings, locale: Language, - sync: SyncOptions + sync: SyncOptions, + notifications: Notifications }; var lastValues: { [key in SyncKeys]?: any } = { }; @@ -78,7 +80,7 @@ function SyncManager(props: Props) { } 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(() => { if (disabled.indexOf(key) === -1) { if (typeof lastValues[key] !== 'undefined') { @@ -117,7 +119,8 @@ export default connectState( return { settings: state.settings, locale: state.locale, - sync: state.sync + sync: state.sync, + notifications: state.notifications }; }, true diff --git a/src/lib/ContextMenus.tsx b/src/lib/ContextMenus.tsx index 89bdf8f49b38b678625b6b1bc320639f11f64610..875e9a4403d490d7034022f871b331a73f6994ff 100644 --- a/src/lib/ContextMenus.tsx +++ b/src/lib/ContextMenus.tsx @@ -5,7 +5,8 @@ import { Attachment, Channels, Message, Servers, Users } from "revolt.js/dist/ap import { ContextMenu, ContextMenuWithData, - MenuItem + MenuItem, + openContextMenu } from "preact-context-menu"; import { ChannelPermission, ServerPermission, UserPermission } from "revolt.js/dist/api/permissions"; import { QueuedMessage } from "../redux/reducers/queue"; @@ -18,6 +19,9 @@ import { Children } from "../types/Preact"; import LineDivider from "../components/ui/LineDivider"; import { connectState } from "../redux/connector"; 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 { user?: string; @@ -68,11 +72,17 @@ type Action = | { action: "close_dm"; target: Channels.DirectMessageChannel } | { action: "leave_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_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 client = useContext(AppContext); const userId = client.user!._id; @@ -301,9 +311,24 @@ function ContextMenus(props: WithDispatcher) { case "ban_member": 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_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 "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 => { openScreen({ id: "error", error: takeError(err) }); @@ -567,6 +592,10 @@ function ContextMenus(props: WithDispatcher) { pushDivider(); if (channel) { + if (channel.channel_type !== 'VoiceChannel') { + generateAction({ action: "open_notification_options", channel }, undefined, undefined, <ChevronRight size={24} />); + } + switch (channel.channel_type) { case 'Group': // ! generateAction({ action: "create_invite", target: channel }); FIXME: add support for group invites @@ -669,14 +698,50 @@ function ContextMenus(props: WithDispatcher) { </MenuItem> )} </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( ContextMenus, - () => { - return {}; + state => { + return { + notifications: state.notifications + }; }, true ); diff --git a/src/pages/settings/panes/Sync.tsx b/src/pages/settings/panes/Sync.tsx index e563128695373e5e4a698ae2a73e24903b6d622e..ad7c84f82ba327c07bcc2845798da099b4e9a647 100644 --- a/src/pages/settings/panes/Sync.tsx +++ b/src/pages/settings/panes/Sync.tsx @@ -20,6 +20,7 @@ export function Component(props: Props & WithDispatcher) { ['appearance', 'appearance.title'], ['theme', 'appearance.theme'], ['locale', 'language.title'] + // notifications sync is always-on ] as [ SyncKeys, string ][]).map( ([ key, title ]) => <Checkbox diff --git a/src/redux/index.ts b/src/redux/index.ts index 33e6bbdca61960d7406ecfa865594b954db4cdb3..dc90bec03a461bd90ec4a8730cc41942684a02c6 100644 --- a/src/redux/index.ts +++ b/src/redux/index.ts @@ -13,6 +13,7 @@ import { Settings } from "./reducers/settings"; import { QueuedMessage } from "./reducers/queue"; import { ExperimentOptions } from "./reducers/experiments"; import { LastOpened } from "./reducers/last_opened"; +import { Notifications } from "./reducers/notifications"; export type State = { config: Core.RevoltNodeConfiguration, @@ -26,6 +27,7 @@ export type State = { sync: SyncOptions; experiments: ExperimentOptions; lastOpened: LastOpened; + notifications: Notifications; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -53,7 +55,8 @@ store.subscribe(() => { drafts, sync, experiments, - lastOpened + lastOpened, + notifications } = store.getState() as State; localForage.setItem("state", { @@ -66,6 +69,7 @@ store.subscribe(() => { drafts, sync, experiments, - lastOpened + lastOpened, + notifications }); }); diff --git a/src/redux/reducers/index.ts b/src/redux/reducers/index.ts index 6c84f87fa76e405ad008e322275bc2e2bbecb620..fe47ccbdc3de006eed1f954b21004518c5e7516c 100644 --- a/src/redux/reducers/index.ts +++ b/src/redux/reducers/index.ts @@ -12,6 +12,7 @@ import { drafts, DraftAction } from "./drafts"; import { sync, SyncAction } from "./sync"; import { experiments, ExperimentsAction } from "./experiments"; import { lastOpened, LastOpenedAction } from "./last_opened"; +import { notifications, NotificationsAction } from "./notifications"; export default combineReducers({ config, @@ -24,7 +25,8 @@ export default combineReducers({ drafts, sync, experiments, - lastOpened + lastOpened, + notifications }); export type Action = @@ -39,6 +41,7 @@ export type Action = | SyncAction | ExperimentsAction | LastOpenedAction + | NotificationsAction | { type: "__INIT"; state: State }; export type WithDispatcher = { dispatcher: (action: Action) => void }; diff --git a/src/redux/reducers/notifications.ts b/src/redux/reducers/notifications.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ca5b030ca9e9ab6355c1da5bd2a62c5c926d4d2 --- /dev/null +++ b/src/redux/reducers/notifications.ts @@ -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; + } +} diff --git a/src/redux/reducers/sync.ts b/src/redux/reducers/sync.ts index 1e2d4a3196d127da3a307639a401124cdce037ca..0e2c8111bac0900819a98bcdbae8e3dd87a7dc72 100644 --- a/src/redux/reducers/sync.ts +++ b/src/redux/reducers/sync.ts @@ -1,19 +1,22 @@ import { AppearanceOptions } from "./settings"; import { Language } from "../../context/Locale"; 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 { locale?: Language; theme?: ThemeOptions; appearance?: AppearanceOptions; + notifications?: Notifications; } export const DEFAULT_ENABLED_SYNC: SyncKeys[] = [ "theme", "appearance", "locale", + "notifications" ]; export interface SyncOptions { disabled?: SyncKeys[];