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() {
-
>