diff --git a/web/src/hooks/useRealtime.ts b/web/src/hooks/useRealtime.ts
index e41df62..543641d 100644
--- a/web/src/hooks/useRealtime.ts
+++ b/web/src/hooks/useRealtime.ts
@@ -3,6 +3,7 @@ import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import type { Message } from "../chat/types";
import { getAppPreferences } from "../utils/preferences";
+import { buildNotificationPreview, showNotificationViaServiceWorker } from "../utils/webNotifications";
import { buildWsUrl } from "../utils/ws";
interface RealtimeEnvelope {
@@ -273,56 +274,29 @@ function maybeShowBrowserNotification(chatId: number, message: Message, activeCh
}
const title = chat?.display_title || chat?.title || "New message";
const preview = buildNotificationPreview(message, prefs.messagePreview);
- const notification = new Notification(title, {
- body: preview.body,
- icon: preview.image,
- tag: `chat-${chatId}`,
- });
- notification.onclick = () => {
- window.focus();
- const store = useChatStore.getState();
- store.setActiveChatId(chatId);
- store.setFocusedMessage(chatId, message.id);
- notification.close();
- };
-}
-function messagePreviewByType(type: Message["type"]): string {
- if (type === "image") return "Photo";
- if (type === "video") return "Video";
- if (type === "audio") return "Audio";
- if (type === "voice") return "Voice message";
- if (type === "file") return "File";
- if (type === "circle_video") return "Video message";
- return "New message";
-}
-
-function buildNotificationPreview(
- message: Message,
- withPreview: boolean
-): { body: string; image?: string } {
- if (!withPreview) {
- return { body: "New message" };
- }
- if (message.type !== "text") {
- if (message.type === "image") {
- const imageUrl = typeof message.text === "string" && isLikelyUrl(message.text) ? message.text : undefined;
- return { body: "🖼 Photo", image: imageUrl };
+ void (async () => {
+ const shownViaSw = await showNotificationViaServiceWorker({
+ chatId,
+ messageId: message.id,
+ title,
+ body: preview.body,
+ image: preview.image,
+ });
+ if (shownViaSw) {
+ return;
}
- return { body: messagePreviewByType(message.type) };
- }
- const text = message.text?.trim();
- if (!text) {
- return { body: "New message" };
- }
- return { body: text };
-}
-
-function isLikelyUrl(value: string): boolean {
- try {
- const parsed = new URL(value);
- return parsed.protocol === "http:" || parsed.protocol === "https:";
- } catch {
- return false;
- }
+ const notification = new Notification(title, {
+ body: preview.body,
+ icon: preview.image,
+ tag: `chat-${chatId}`,
+ });
+ notification.onclick = () => {
+ window.focus();
+ const store = useChatStore.getState();
+ store.setActiveChatId(chatId);
+ store.setFocusedMessage(chatId, message.id);
+ notification.close();
+ };
+ })();
}
diff --git a/web/src/main.tsx b/web/src/main.tsx
index ccfc976..faf6bfc 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -2,8 +2,11 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./app/App";
import { AppErrorBoundary } from "./components/AppErrorBoundary";
+import { registerNotificationServiceWorker } from "./utils/webNotifications";
import "./index.css";
+void registerNotificationServiceWorker();
+
ReactDOM.createRoot(document.getElementById("root")!).render(
diff --git a/web/src/pages/ChatsPage.tsx b/web/src/pages/ChatsPage.tsx
index dac0971..8c64718 100644
--- a/web/src/pages/ChatsPage.tsx
+++ b/web/src/pages/ChatsPage.tsx
@@ -52,6 +52,30 @@ export function ChatsPage() {
}
}, [activeChatId, loadMessages]);
+ useEffect(() => {
+ const applyNotificationNavigation = () => {
+ const url = new URL(window.location.href);
+ const chatParam = url.searchParams.get("chat");
+ const messageParam = url.searchParams.get("message");
+ const chatId = Number(chatParam);
+ const messageId = Number(messageParam);
+ if (!Number.isFinite(chatId) || chatId <= 0) {
+ return;
+ }
+ setActiveChatId(chatId);
+ if (Number.isFinite(messageId) && messageId > 0) {
+ setFocusedMessage(chatId, messageId);
+ }
+ url.searchParams.delete("chat");
+ url.searchParams.delete("message");
+ window.history.replaceState({}, "", url.toString());
+ };
+
+ applyNotificationNavigation();
+ window.addEventListener("popstate", applyNotificationNavigation);
+ return () => window.removeEventListener("popstate", applyNotificationNavigation);
+ }, [setActiveChatId, setFocusedMessage]);
+
useEffect(() => {
if (!searchOpen) {
return;
diff --git a/web/src/utils/webNotifications.ts b/web/src/utils/webNotifications.ts
new file mode 100644
index 0000000..5fc7028
--- /dev/null
+++ b/web/src/utils/webNotifications.ts
@@ -0,0 +1,84 @@
+import type { Message } from "../chat/types";
+
+export async function registerNotificationServiceWorker(): Promise {
+ if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
+ return;
+ }
+ try {
+ await navigator.serviceWorker.register("/notifications-sw.js");
+ } catch {
+ return;
+ }
+}
+
+export async function showNotificationViaServiceWorker(params: {
+ chatId: number;
+ messageId: number;
+ title: string;
+ body: string;
+ image?: string;
+}): Promise {
+ if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
+ return false;
+ }
+ try {
+ const registration = await navigator.serviceWorker.getRegistration();
+ if (!registration) {
+ return false;
+ }
+ const url = `/?chat=${params.chatId}&message=${params.messageId}`;
+ await registration.showNotification(params.title, {
+ body: params.body,
+ icon: params.image,
+ tag: `chat-${params.chatId}`,
+ data: {
+ chatId: params.chatId,
+ messageId: params.messageId,
+ url,
+ },
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function messagePreviewByType(type: Message["type"]): string {
+ if (type === "image") return "Photo";
+ if (type === "video") return "Video";
+ if (type === "audio") return "Audio";
+ if (type === "voice") return "Voice message";
+ if (type === "file") return "File";
+ if (type === "circle_video") return "Video message";
+ return "New message";
+}
+
+export function buildNotificationPreview(
+ message: Message,
+ withPreview: boolean
+): { body: string; image?: string } {
+ if (!withPreview) {
+ return { body: "New message" };
+ }
+ if (message.type !== "text") {
+ if (message.type === "image") {
+ const imageUrl = typeof message.text === "string" && isLikelyUrl(message.text) ? message.text : undefined;
+ return { body: "🖼 Photo", image: imageUrl };
+ }
+ return { body: messagePreviewByType(message.type) };
+ }
+ const text = message.text?.trim();
+ if (!text) {
+ return { body: "New message" };
+ }
+ return { body: text };
+}
+
+function isLikelyUrl(value: string): boolean {
+ try {
+ const parsed = new URL(value);
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
+ } catch {
+ return false;
+ }
+}