From a2a52e237d7ac1629128becfa0630d859d6365ac Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Wed, 29 Jun 2022 17:31:59 +0100 Subject: [PATCH] chore(refactor): remove `Notifications` component --- src/context/history.ts | 11 + src/context/revoltjs/Notifications.tsx | 296 ------------------------- src/mobx/State.ts | 27 ++- src/mobx/stores/NotificationOptions.ts | 283 ++++++++++++++++++++++- src/pages/RevoltApp.tsx | 2 - 5 files changed, 316 insertions(+), 303 deletions(-) delete mode 100644 src/context/revoltjs/Notifications.tsx diff --git a/src/context/history.ts b/src/context/history.ts index 5e816997..0e4d1268 100644 --- a/src/context/history.ts +++ b/src/context/history.ts @@ -3,3 +3,14 @@ import { createBrowserHistory } from "history"; export const history = createBrowserHistory({ basename: import.meta.env.BASE_URL, }); + +export const routeInformation = { + getServer: () => + /server\/([0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26})/.exec( + history.location.pathname, + )?.[1], + getChannel: () => + /channel\/([0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26})/.exec( + history.location.pathname, + )?.[1], +}; diff --git a/src/context/revoltjs/Notifications.tsx b/src/context/revoltjs/Notifications.tsx deleted file mode 100644 index c1d144a1..00000000 --- a/src/context/revoltjs/Notifications.tsx +++ /dev/null @@ -1,296 +0,0 @@ -import { Route, Switch, useHistory, useParams } from "react-router-dom"; -import { Message, User } from "revolt.js"; -import { decodeTime } from "ulid"; - -import { useCallback, useEffect } from "preact/hooks"; - -import { useTranslation } from "../../lib/i18n"; - -import { useApplicationState } from "../../mobx/State"; - -import { useClient } from "../../controllers/client/ClientController"; - -const notifications: { [key: string]: Notification } = {}; - -async function createNotification( - title: string, - options: globalThis.NotificationOptions, -) { - try { - return new Notification(title, options); - } catch (err) { - const sw = await navigator.serviceWorker.getRegistration(); - sw?.showNotification(title, options); - } -} - -function Notifier() { - const translate = useTranslation(); - const state = useApplicationState(); - const notifs = state.notifications; - const showNotification = state.settings.get("notifications:desktop"); - - const client = useClient(); - const { guild: guild_id, channel: channel_id } = useParams<{ - guild: string; - channel: string; - }>(); - const history = useHistory(); - - const message = useCallback( - async (msg: Message) => { - if (msg.channel_id === channel_id && document.hasFocus()) return; - if (!notifs.shouldNotify(msg)) return; - - state.settings.sounds.playSound("message"); - if (!showNotification) return; - - const effectiveName = msg.masquerade?.name ?? msg.author?.username; - - let title; - switch (msg.channel?.channel_type) { - case "SavedMessages": - return; - case "DirectMessage": - title = `@${effectiveName}`; - break; - case "Group": - if (msg.author?._id === "00000000000000000000000000") { - title = msg.channel.name; - } else { - title = `@${effectiveName} - ${msg.channel.name}`; - } - break; - case "TextChannel": - title = `@${effectiveName} (#${msg.channel.name}, ${msg.channel.server?.name})`; - break; - default: - title = msg.channel?._id; - break; - } - - let image; - if (msg.attachments) { - const imageAttachment = msg.attachments.find( - (x) => x.metadata.type === "Image", - ); - if (imageAttachment) { - image = client.generateFileURL(imageAttachment, { - max_side: 720, - }); - } - } - - let body, icon; - if (msg.content) { - body = client.markdownToText(msg.content); - - if (msg.masquerade?.avatar) { - icon = client.proxyFile(msg.masquerade.avatar); - } else { - icon = msg.author?.generateAvatarURL({ max_side: 256 }); - } - } else if (msg.system) { - const users = client.users; - - switch (msg.system.type) { - case "user_added": - case "user_remove": - { - const user = users.get(msg.system.id); - body = translate( - `app.main.channel.system.${ - msg.system.type === "user_added" - ? "added_by" - : "removed_by" - }`, - { - user: user?.username, - other_user: users.get(msg.system.by) - ?.username, - }, - ); - icon = user?.generateAvatarURL({ - max_side: 256, - }); - } - break; - case "user_joined": - case "user_left": - case "user_kicked": - case "user_banned": - { - const user = users.get(msg.system.id); - body = translate( - `app.main.channel.system.${msg.system.type}`, - { user: user?.username }, - ); - icon = user?.generateAvatarURL({ - max_side: 256, - }); - } - break; - case "channel_renamed": - { - const user = users.get(msg.system.by); - body = translate( - `app.main.channel.system.channel_renamed`, - { - user: users.get(msg.system.by)?.username, - name: msg.system.name, - }, - ); - icon = user?.generateAvatarURL({ - max_side: 256, - }); - } - break; - case "channel_description_changed": - case "channel_icon_changed": - { - const user = users.get(msg.system.by); - body = translate( - `app.main.channel.system.${msg.system.type}`, - { user: users.get(msg.system.by)?.username }, - ); - icon = user?.generateAvatarURL({ - max_side: 256, - }); - } - break; - } - } - - const notif = await createNotification(title!, { - icon, - image, - body, - timestamp: decodeTime(msg._id), - tag: msg.channel?._id, - badge: "/assets/icons/android-chrome-512x512.png", - silent: true, - }); - - if (notif) { - notif.addEventListener("click", () => { - window.focus(); - const id = msg.channel_id; - if (id !== channel_id) { - const channel = client.channels.get(id); - if (channel) { - if (channel.channel_type === "TextChannel") { - history.push( - `/server/${channel.server_id}/channel/${id}`, - ); - } else { - history.push(`/channel/${id}`); - } - } - } - }); - - notifications[msg.channel_id] = notif; - notif.addEventListener( - "close", - () => delete notifications[msg.channel_id], - ); - } - }, - [ - history, - showNotification, - translate, - channel_id, - client, - notifs, - state, - ], - ); - - const relationship = useCallback( - async (user: User) => { - if (client.user?.status?.presence === "Busy") return; - if (!showNotification) return; - - let event; - switch (user.relationship) { - case "Incoming": - event = translate("notifications.sent_request", { - person: user.username, - }); - break; - case "Friend": - event = translate("notifications.now_friends", { - person: user.username, - }); - break; - default: - return; - } - - const notif = await createNotification(event, { - icon: user.generateAvatarURL({ max_side: 256 }), - badge: "/assets/icons/android-chrome-512x512.png", - timestamp: +new Date(), - }); - - notif?.addEventListener("click", () => { - history.push(`/friends`); - }); - }, - [client.user?.status?.presence, history, showNotification, translate], - ); - - useEffect(() => { - client.addListener("message", message); - client.addListener("user/relationship", relationship); - - return () => { - client.removeListener("message", message); - client.removeListener("user/relationship", relationship); - }; - }, [ - client, - state, - guild_id, - channel_id, - showNotification, - notifs, - message, - relationship, - ]); - - useEffect(() => { - function visChange() { - if (document.visibilityState === "visible") { - if (notifications[channel_id]) { - notifications[channel_id].close(); - } - } - } - - visChange(); - - document.addEventListener("visibilitychange", visChange); - return () => - document.removeEventListener("visibilitychange", visChange); - }, [guild_id, channel_id]); - - return null; -} - -export default function NotificationsComponent() { - return ( - - - - - - - - - - - - ); -} diff --git a/src/mobx/State.ts b/src/mobx/State.ts index b48d9162..02a60e7e 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -39,6 +39,9 @@ export default class State { locale: LocaleOptions; experiments: Experiments; layout: Layout; + /** + * DEPRECATED + */ private config: ServerConfig; notifications: NotificationOptions; queue: MessageQueue; @@ -61,7 +64,7 @@ export default class State { this.experiments = new Experiments(); this.layout = new Layout(); this.config = new ServerConfig(); - this.notifications = new NotificationOptions(); + this.notifications = new NotificationOptions(this); this.queue = new MessageQueue(); this.settings = new Settings(); this.sync = new Sync(this); @@ -159,6 +162,17 @@ export default class State { // Register listener for incoming packets. client.addListener("packet", this.onPacket); + // Register events for notifications. + client.addListener("message", this.notifications.onMessage); + client.addListener( + "user/relationship", + this.notifications.onRelationship, + ); + document.addEventListener( + "visibilitychange", + this.notifications.onVisibilityChange, + ); + // Sync settings from remote server. state.sync .pull(client) @@ -253,6 +267,15 @@ export default class State { if (client) { client.removeListener("message", this.queue.onMessage); client.removeListener("packet", this.onPacket); + client.removeListener("message", this.notifications.onMessage); + client.removeListener( + "user/relationship", + this.notifications.onRelationship, + ); + document.removeEventListener( + "visibilitychange", + this.notifications.onVisibilityChange, + ); } // Wipe all listeners. @@ -293,7 +316,7 @@ export default class State { this.draft = new Draft(); this.experiments = new Experiments(); this.layout = new Layout(); - this.notifications = new NotificationOptions(); + this.notifications = new NotificationOptions(this); this.queue = new MessageQueue(); this.settings = new Settings(); this.sync = new Sync(this); diff --git a/src/mobx/stores/NotificationOptions.ts b/src/mobx/stores/NotificationOptions.ts index 2b6285a2..91a5fe35 100644 --- a/src/mobx/stores/NotificationOptions.ts +++ b/src/mobx/stores/NotificationOptions.ts @@ -1,8 +1,14 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; -import { Channel, Message, Server } from "revolt.js"; +import { Channel, Message, Server, User } from "revolt.js"; +import { decodeTime } from "ulid"; + +import { translate } from "preact-i18n"; import { mapToRecord } from "../../lib/conversion"; +import { history, routeInformation } from "../../context/history"; + +import State from "../State"; import Persistent from "../interfaces/Persistent"; import Store from "../interfaces/Store"; import Syncable from "../interfaces/Syncable"; @@ -37,22 +43,54 @@ export interface Data { channel?: Record; } +/** + * Create a notification either directly or using service worker. + * @param title Notification Title + * @param options Notification Options + * @returns Notification + */ +async function createNotification( + title: string, + options: globalThis.NotificationOptions, +) { + try { + return new Notification(title, options); + } catch (err) { + const sw = await navigator.serviceWorker.getRegistration(); + sw?.showNotification(title, options); + } +} + /** * Manages the user's notification preferences. */ export default class NotificationOptions implements Store, Persistent, Syncable { + private state: State; + private activeNotifications: Record; + private server: ObservableMap; private channel: ObservableMap; /** * Construct new Experiments store. */ - constructor() { + constructor(state: State) { this.server = new ObservableMap(); this.channel = new ObservableMap(); - makeAutoObservable(this); + + makeAutoObservable(this, { + onMessage: false, + onRelationship: false, + }); + + this.state = state; + this.activeNotifications = {}; + + this.onMessage = this.onMessage.bind(this); + this.onRelationship = this.onRelationship.bind(this); + this.onVisibilityChange = this.onVisibilityChange.bind(this); } get id() { @@ -209,6 +247,245 @@ export default class NotificationOptions return false; } + /** + * Handle incoming messages and create a notification. + * @param message Message + */ + async onMessage(message: Message) { + // Ignore if we are currently looking and focused on the channel. + if ( + message.channel_id === routeInformation.getChannel() && + document.hasFocus() + ) + return; + + // Ignore if muted. + if (!this.shouldNotify(message)) return; + + // Play a sound and skip notif if disabled. + this.state.settings.sounds.playSound("message"); + if (!this.state.settings.get("notifications:desktop")) return; + + const effectiveName = + message.masquerade?.name ?? message.author?.username; + + let title; + switch (message.channel?.channel_type) { + case "SavedMessages": + return; + case "DirectMessage": + title = `@${effectiveName}`; + break; + case "Group": + if (message.author?._id === "00000000000000000000000000") { + title = message.channel.name; + } else { + title = `@${effectiveName} - ${message.channel.name}`; + } + break; + case "TextChannel": + title = `@${effectiveName} (#${message.channel.name}, ${message.channel.server?.name})`; + break; + default: + title = message.channel?._id; + break; + } + + let image; + if (message.attachments) { + const imageAttachment = message.attachments.find( + (x) => x.metadata.type === "Image", + ); + if (imageAttachment) { + image = message.client.generateFileURL(imageAttachment, { + max_side: 720, + }); + } + } + + let body, icon; + if (message.content) { + body = message.client.markdownToText(message.content); + + if (message.masquerade?.avatar) { + icon = message.client.proxyFile(message.masquerade.avatar); + } else { + icon = message.author?.generateAvatarURL({ max_side: 256 }); + } + } else if (message.system) { + const users = message.client.users; + + // ! FIXME: I've had to strip translations while + // ! I move stuff into the new project structure + switch (message.system.type) { + case "user_added": + case "user_remove": + { + const user = users.get(message.system.id); + body = `${user?.username} ${ + message.system.type === "user_added" + ? "added by" + : "removed by" + } ${users.get(message.system.by)?.username}`; + /*body = translate( + `app.main.channel.system.${ + message.system.type === "user_added" + ? "added_by" + : "removed_by" + }`, + { + user: user?.username, + other_user: users.get(message.system.by) + ?.username, + }, + );*/ + icon = user?.generateAvatarURL({ + max_side: 256, + }); + } + break; + case "user_joined": + case "user_left": + case "user_kicked": + case "user_banned": + { + const user = users.get(message.system.id); + body = `${user?.username}`; + /*body = translate( + `app.main.channel.system.${message.system.type}`, + { user: user?.username }, + );*/ + icon = user?.generateAvatarURL({ + max_side: 256, + }); + } + break; + case "channel_renamed": + { + const user = users.get(message.system.by); + body = `${user?.username} renamed channel to ${message.system.name}`; + /*body = translate( + `app.main.channel.system.channel_renamed`, + { + user: users.get(message.system.by)?.username, + name: message.system.name, + }, + );*/ + icon = user?.generateAvatarURL({ + max_side: 256, + }); + } + break; + case "channel_description_changed": + case "channel_icon_changed": + { + const user = users.get(message.system.by); + /*body = translate( + `app.main.channel.system.${message.system.type}`, + { user: users.get(message.system.by)?.username }, + );*/ + body = `${users.get(message.system.by)?.username}`; + icon = user?.generateAvatarURL({ + max_side: 256, + }); + } + break; + } + } + + const notif = await createNotification(title!, { + icon, + image, + body, + timestamp: decodeTime(message._id), + tag: message.channel?._id, + badge: "/assets/icons/android-chrome-512x512.png", + silent: true, + }); + + if (notif) { + notif.addEventListener("click", () => { + window.focus(); + + const id = message.channel_id; + if (id !== routeInformation.getChannel()) { + const channel = message.client.channels.get(id); + if (channel) { + if (channel.channel_type === "TextChannel") { + history.push( + `/server/${channel.server_id}/channel/${id}`, + ); + } else { + history.push(`/channel/${id}`); + } + } + } + }); + + this.activeNotifications[message.channel_id] = notif; + + notif.addEventListener( + "close", + () => delete this.activeNotifications[message.channel_id], + ); + } + } + + /** + * Handle user relationship changes. + * @param user User relationship changed with + */ + async onRelationship(user: User) { + // Ignore if disabled. + if (!this.state.settings.get("notifications:desktop")) return; + + // Check whether we are busy. + // This is checked by `shouldNotify` in the case of messages. + if (user.status?.presence === "Busy") { + return false; + } + + let event; + switch (user.relationship) { + case "Incoming": + /*event = translate("notifications.sent_request", { + person: user.username, + });*/ + event = `${user.username} sent you a friend request`; + break; + case "Friend": + /*event = translate("notifications.now_friends", { + person: user.username, + });*/ + event = `Now friends with ${user.username}`; + break; + default: + return; + } + + const notif = await createNotification(event, { + icon: user.generateAvatarURL({ max_side: 256 }), + badge: "/assets/icons/android-chrome-512x512.png", + timestamp: +new Date(), + }); + + notif?.addEventListener("click", () => { + history.push(`/friends`); + }); + } + + /** + * Called when document visibility changes. + */ + onVisibilityChange() { + if (document.visibilityState === "visible") { + const channel_id = routeInformation.getChannel()!; + if (this.activeNotifications[channel_id]) { + this.activeNotifications[channel_id].close(); + } + } + } + @action apply(_key: "notifications", data: unknown, _revision: number) { this.hydrate(data as Data); } diff --git a/src/pages/RevoltApp.tsx b/src/pages/RevoltApp.tsx index 69ccf6f5..c04917ab 100644 --- a/src/pages/RevoltApp.tsx +++ b/src/pages/RevoltApp.tsx @@ -8,7 +8,6 @@ import ContextMenus from "../lib/ContextMenus"; import { isTouchscreenDevice } from "../lib/isTouchscreenDevice"; import Popovers from "../context/intermediate/Popovers"; -import Notifications from "../context/revoltjs/Notifications"; import { Titlebar } from "../components/native/Titlebar"; import BottomNavigation from "../components/navigation/BottomNavigation"; @@ -227,7 +226,6 @@ export default function App() { -