Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,121 @@ self.addEventListener("fetch", (event) => {
return;
}
});

// --- soju.im/webpush ---------------------------------------------------
// The server (via the hosted-backend) sends exactly one IRC message as
// the encrypted push payload: no trailing CRLF, tags dropped except
// msgid. We parse the minimum needed to render a notification.

function parseIrcLine(line) {
let rest = line;
let msgid = null;
if (rest.startsWith("@")) {
const sp = rest.indexOf(" ");
if (sp === -1) return null;
const tags = rest.slice(1, sp);
for (const t of tags.split(";")) {
const eq = t.indexOf("=");
if (eq !== -1 && t.slice(0, eq) === "msgid") msgid = t.slice(eq + 1);
}
rest = rest.slice(sp + 1);
}
if (!rest.startsWith(":")) return null;
const sp = rest.indexOf(" ");
if (sp === -1) return null;
const nick = rest.slice(1, sp).split("!")[0];
rest = rest.slice(sp + 1);
const trailingIdx = rest.indexOf(" :");
let head = rest;
let text = "";
if (trailingIdx !== -1) {
head = rest.slice(0, trailingIdx);
text = rest.slice(trailingIdx + 2);
}
const parts = head.split(" ");
return { msgid, nick, command: parts[0], target: parts[1] || "", text };
}

// Network name for the notification context. The hosted build is
// single-network, so the manifest's name is the network ("Obby").
// Cached after first read; the SW may be killed between pushes, so the
// fetch is cheap and falls back to "" on failure.
let cachedNetworkName = null;
async function getNetworkName() {
if (cachedNetworkName !== null) return cachedNetworkName;
try {
const res = await fetch("/manifest.webmanifest", { cache: "force-cache" });
const m = await res.json();
cachedNetworkName = m.name || m.short_name || "";
} catch {
cachedNetworkName = "";
}
return cachedNetworkName;
}

function isChannelTarget(target) {
return !!target && "#&^$".includes(target.charAt(0));
}

self.addEventListener("push", (event) => {
if (!event.data) return;
let raw;
try {
raw = event.data.text();
} catch {
return;
}
event.waitUntil(
(async () => {
const parsed = parseIrcLine(raw);
if (!parsed || !parsed.nick) return;

// If a client window is focused, the in-app notifier already
// surfaces this message -- don't double-notify.
const wins = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
if (wins.some((c) => c.focused)) return;

const network = await getNetworkName();
const channel = isChannelTarget(parsed.target);

// Channel highlight: "alice in #weather"; DM: "alice".
// The network name is appended as context when known.
let title = channel
? `${parsed.nick} in ${parsed.target}`
: parsed.nick;
if (network) title += ` · ${network}`;

await self.registration.showNotification(title, {
body: parsed.text || "",
icon: "/pwa/icon-192.png",
badge: "/pwa/icon-192.png",
// Group by conversation: per-channel for highlights, per-sender
// for DMs -- so repeats collapse instead of stacking endlessly.
tag: channel ? `hl-${parsed.target}` : `pm-${parsed.nick}`,
renotify: true,
data: {
nick: parsed.nick,
target: parsed.target,
network,
},
});
})(),
);
});

self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((wins) => {
for (const c of wins) {
if ("focus" in c) return c.focus();
}
if (self.clients.openWindow) return self.clients.openWindow("/");
}),
);
});
26 changes: 26 additions & 0 deletions src/components/ui/UserSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ import {
import { useMediaQuery } from "../../hooks/useMediaQuery";
import { useModalBehavior } from "../../hooks/useModalBehavior";
import ircClient from "../../lib/ircClient";
import { requestNotificationPermission } from "../../lib/notifications";
import { openExternalUrl } from "../../lib/openUrl";
import { isTauri } from "../../lib/platformUtils";
import { settingsRegistry } from "../../lib/settings";
import type { SettingValue } from "../../lib/settings/types";
import { registerWebPush, unregisterWebPush } from "../../lib/webpush";
import useStore, {
type GlobalSettings,
loadSavedServers,
Expand Down Expand Up @@ -704,6 +706,29 @@ export const UserSettings: React.FC = React.memo(() => {
quitMessage,
});

// The notifications toggle is the user gesture we use to manage
// soju.im/webpush. Turning it on prompts for browser permission and,
// if granted, subscribes this device on every connected server that
// supports push; turning it off tears the subscription down.
const wantNotifications = (settings as Partial<GlobalSettings>)
.enableNotifications;
if (wantNotifications) {
const permission = await requestNotificationPermission();
if (permission === "granted") {
for (const srv of servers) {
if (srv.vapidKey && srv.capabilities?.includes("soju.im/webpush")) {
void registerWebPush(srv.id, srv.vapidKey);
}
}
}
} else if (wantNotifications === false) {
for (const srv of servers) {
if (srv.capabilities?.includes("soju.im/webpush")) {
void unregisterWebPush(srv.id);
}
}
Comment on lines +715 to +729

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle push subscription calls as awaited tasks on connected servers only.

This currently dispatches registration/unregistration as fire-and-forget across all servers, which can fail silently and attempt work against disconnected servers.

🛠️ Proposed fix
+    const pushServers = servers.filter(
+      (srv) =>
+        srv.isConnected && srv.capabilities?.includes("soju.im/webpush"),
+    );
+
     if (wantNotifications) {
       const permission = await requestNotificationPermission();
       if (permission === "granted") {
-        for (const srv of servers) {
-          if (srv.vapidKey && srv.capabilities?.includes("soju.im/webpush")) {
-            void registerWebPush(srv.id, srv.vapidKey);
-          }
-        }
+        const tasks = pushServers
+          .filter((srv) => !!srv.vapidKey)
+          .map((srv) => registerWebPush(srv.id, srv.vapidKey!));
+        await Promise.allSettled(tasks);
       }
     } else if (wantNotifications === false) {
-      for (const srv of servers) {
-        if (srv.capabilities?.includes("soju.im/webpush")) {
-          void unregisterWebPush(srv.id);
-        }
-      }
+      const tasks = pushServers.map((srv) => unregisterWebPush(srv.id));
+      await Promise.allSettled(tasks);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/UserSettings.tsx` around lines 706 - 720, The current code
fires registerWebPush/unregisterWebPush without awaiting and runs them for all
servers (which may be disconnected); change the logic to only operate on
connected servers and await the operations (either sequentially with await
inside the for..of or gather promises and await Promise.all). Specifically, in
the wantNotifications branch, after permission === "granted", filter servers by
a connection flag (e.g., srv.connected or srv.isConnected) and by srv.vapidKey
and capabilities before calling registerWebPush(srv.id, srv.vapidKey), and await
those calls; similarly, when wantNotifications === false, filter by the same
connection flag and capabilities then await unregisterWebPush(srv.id). Ensure
you handle errors from each awaited call (try/catch or Promise.allSettled) so
failures don’t fail silently.

}

// Save notification sound file
if (notificationSoundFile) {
const reader = new FileReader();
Expand Down Expand Up @@ -759,6 +784,7 @@ export const UserSettings: React.FC = React.memo(() => {
originalValues?.homepage,
originalValues?.pronouns,
originalValues?.status,
servers,
]);

// Handle close
Expand Down
4 changes: 4 additions & 0 deletions src/lib/irc/IRCClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,10 @@ export class IRCClient implements IRCClientContext {
"labeled-response",
"draft/read-marker",
"obsidianirc/cmdslist",
// soju.im/webpush: lets us register a browser PushManager
// subscription with the server so DMs/highlights wake the device
// via the platform push service even when the tab is closed.
"soju.im/webpush",
// soju.im/bouncer-networks: multi-network bouncer discovery. The
// -notify variant gives us live add/change/del pushes without
// polling LISTNETWORKS.
Expand Down
20 changes: 20 additions & 0 deletions src/lib/settings/definitions/allSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,26 @@ const profileSettings: SettingDefinition[] = [
* Notification Settings
*/
const notificationSettings: SettingDefinition[] = [
{
id: "notifications.enable",
key: "enableNotifications",
category: "notifications",
subcategory: "General",
title: msg`Enable Notifications`,
description: msg`Show desktop notifications for mentions and DMs. On servers that support push (soju.im/webpush) this also wakes this device when the app is closed.`,
type: "toggle",
defaultValue: false,
searchKeywords: [
"notification",
"push",
"desktop",
"alert",
"mention",
"dm",
"webpush",
],
priority: 0,
},
{
id: "notifications.enableSounds",
key: "enableNotificationSounds",
Expand Down
129 changes: 129 additions & 0 deletions src/lib/webpush.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// soju.im/webpush client glue.
//
// When a server advertises the soju.im/webpush cap + a VAPID= ISUPPORT
// token, we create a browser PushManager subscription against that VAPID
// key and hand the resulting {endpoint, p256dh, auth} to the server via
// `WEBPUSH REGISTER`. From then on the server (through the hosted
// backend) can wake the device for DMs even with the tab closed.
//
// The actual notification rendering lives in public/sw.js's `push`
// handler. This module is only the subscribe + REGISTER side.

import ircClient from "./ircClient";

// VAPID keys are URL-safe base64 (RFC 4648 §5). PushManager wants the
// applicationServerKey as a raw Uint8Array of the decoded bytes.
function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const normalised = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(normalised);
// Allocate an explicit ArrayBuffer so the result is
// Uint8Array<ArrayBuffer> (not ...<ArrayBufferLike>), which is what
// PushManager's applicationServerKey BufferSource type requires.
const out = new Uint8Array(new ArrayBuffer(raw.length));
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}

// PushSubscription.getKey() returns raw bytes; the WEBPUSH protocol wants
// them URL-safe base64 (unpadded), matching the VAPID convention.
function bufToBase64Url(buf: ArrayBuffer | null): string {
if (!buf) return "";
const bytes = new Uint8Array(buf);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function pushSupported(): boolean {
return (
typeof navigator !== "undefined" &&
"serviceWorker" in navigator &&
typeof window !== "undefined" &&
"PushManager" in window &&
"Notification" in window
);
}

// Track which (server, endpoint) pairs we've already REGISTERed this
// session so a reconnect or a repeated `ready` doesn't spam the command.
const registered = new Set<string>();

/**
* Subscribe to push for `serverId` using `vapidKey`, then send
* WEBPUSH REGISTER. No-ops when push is unsupported, permission is
* denied, or we've already registered this endpoint with this server.
*
* Requires notification permission to already be `granted` -- we don't
* prompt here (prompting belongs to a user gesture in the settings UI).
*/
export async function registerWebPush(
serverId: string,
vapidKey: string,
): Promise<void> {
if (!pushSupported() || !vapidKey) return;
if (Notification.permission !== "granted") return;

try {
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();

// If a subscription exists but was minted against a different VAPID
// key (server rotated keys, or this device subscribed to another
// network), it won't decrypt our pushes -- resubscribe.
if (sub) {
const existingKey = sub.options?.applicationServerKey;
if (existingKey) {
const want = urlBase64ToUint8Array(vapidKey);
const have = new Uint8Array(existingKey as ArrayBuffer);
const same =
have.length === want.length && have.every((b, i) => b === want[i]);
if (!same) {
await sub.unsubscribe();
sub = null;
}
}
}

if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
});
}

const endpoint = sub.endpoint;
const p256dh = bufToBase64Url(sub.getKey("p256dh"));
const auth = bufToBase64Url(sub.getKey("auth"));
if (!endpoint || !p256dh || !auth) return;

const dedupeKey = `${serverId}\x00${endpoint}`;
if (registered.has(dedupeKey)) return;
registered.add(dedupeKey);

ircClient.sendRaw(
serverId,
`WEBPUSH REGISTER ${endpoint} p256dh=${p256dh};auth=${auth}`,
);
} catch (err) {
console.warn("[webpush] subscribe/register failed:", err);
}
}

/**
* Tear down this device's push subscription and tell the server to drop
* it. Used when the user turns notifications off.
*/
export async function unregisterWebPush(serverId: string): Promise<void> {
if (!pushSupported()) return;
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (!sub) return;
ircClient.sendRaw(serverId, `WEBPUSH UNREGISTER ${sub.endpoint}`);
registered.delete(`${serverId}\x00${sub.endpoint}`);
await sub.unsubscribe();
} catch (err) {
console.warn("[webpush] unregister failed:", err);
}
}
2 changes: 1 addition & 1 deletion src/locales/cs/messages.mjs

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions src/locales/cs/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,10 @@ msgstr "Povolit zvuky upozornění"
msgid "Enable notifications"
msgstr "Povolit upozornění"

#: src/lib/settings/definitions/allSettings.ts
msgid "Enable Notifications"
msgstr "Zapnout oznámení"

#: src/lib/settings/definitions/allSettings.ts
msgid "Enter account name..."
msgstr "Zadejte název účtu..."
Expand Down Expand Up @@ -2914,6 +2918,10 @@ msgstr "Zobrazit komentáře"
msgid "Show comments ({commentCount})"
msgstr "Zobrazit komentáře ({commentCount})"

#: src/lib/settings/definitions/allSettings.ts
msgid "Show desktop notifications for mentions and DMs. On servers that support push (soju.im/webpush) this also wakes this device when the app is closed."
msgstr "Zobrazovat oznámení na ploše pro zmínky a soukromé zprávy. Na serverech s podporou push (soju.im/webpush) to také probudí toto zařízení, když je aplikace zavřená."

#: src/components/ui/UserContextMenu.tsx
msgid "Show in Bots Menu →"
msgstr "Zobrazit v nabídce Boti →"
Expand Down
2 changes: 1 addition & 1 deletion src/locales/de/messages.mjs

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions src/locales/de/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,10 @@ msgstr "Benachrichtigungstöne aktivieren"
msgid "Enable notifications"
msgstr "Benachrichtigungen aktivieren"

#: src/lib/settings/definitions/allSettings.ts
msgid "Enable Notifications"
msgstr "Benachrichtigungen aktivieren"

#: src/lib/settings/definitions/allSettings.ts
msgid "Enter account name..."
msgstr "Kontoname eingeben..."
Expand Down Expand Up @@ -2914,6 +2918,10 @@ msgstr "Kommentare anzeigen"
msgid "Show comments ({commentCount})"
msgstr "Kommentare anzeigen ({commentCount})"

#: src/lib/settings/definitions/allSettings.ts
msgid "Show desktop notifications for mentions and DMs. On servers that support push (soju.im/webpush) this also wakes this device when the app is closed."
msgstr "Desktop-Benachrichtigungen für Erwähnungen und DMs anzeigen. Auf Servern, die Push unterstützen (soju.im/webpush), wird dieses Gerät auch geweckt, wenn die App geschlossen ist."

#: src/components/ui/UserContextMenu.tsx
msgid "Show in Bots Menu →"
msgstr "Im Bots-Menü anzeigen →"
Expand Down
2 changes: 1 addition & 1 deletion src/locales/en/messages.mjs

Large diffs are not rendered by default.

Loading
Loading