diff --git a/web/public/notifications-sw.js b/web/public/notifications-sw.js new file mode 100644 index 0000000..5ad7cbf --- /dev/null +++ b/web/public/notifications-sw.js @@ -0,0 +1,33 @@ +self.addEventListener("install", (event) => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const data = event.notification.data || {}; + const targetPath = typeof data.url === "string" ? data.url : "/"; + const targetUrl = new URL(targetPath, self.location.origin).toString(); + + event.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(async (clientList) => { + if (clientList.length > 0) { + const client = clientList[0]; + if ("navigate" in client) { + await client.navigate(targetUrl); + } + if ("focus" in client) { + await client.focus(); + } + return; + } + if (self.clients.openWindow) { + await self.clients.openWindow(targetUrl); + } + }) + ); +}); + diff --git a/web/src/components/MessageComposer.tsx b/web/src/components/MessageComposer.tsx index 4a4e2d0..b7ba57f 100644 --- a/web/src/components/MessageComposer.tsx +++ b/web/src/components/MessageComposer.tsx @@ -280,15 +280,21 @@ export function MessageComposer() { recorder.ondataavailable = (event) => chunksRef.current.push(event.data); recorder.onstop = async () => { const shouldSend = sendVoiceOnStopRef.current; + const durationMs = recordingStartedAtRef.current ? Date.now() - recordingStartedAtRef.current : 0; const data = [...chunksRef.current]; chunksRef.current = []; if (recordingStreamRef.current) { recordingStreamRef.current.getTracks().forEach((track) => track.stop()); recordingStreamRef.current = null; } + recordingStartedAtRef.current = null; if (!shouldSend || data.length === 0) { return; } + if (durationMs < 1000) { + setUploadError("Voice message is too short. Minimum length is 1 second."); + return; + } const blob = new Blob(data, { type: "audio/webm" }); const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" }); await handleUpload(file, "voice"); @@ -313,7 +319,6 @@ export function MessageComposer() { recorderRef.current.stop(); } recorderRef.current = null; - recordingStartedAtRef.current = null; setRecordingState("idle"); setRecordSeconds(0); } @@ -567,8 +572,9 @@ export function MessageComposer() { disabled={recordingState !== "idle" || !activeChatId} onClick={handleSend} type="button" + title="Send message" > - ➤ + ↑ ) : ( diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 47e0b21..e6eb4df 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -494,11 +494,12 @@ export function MessageList() {
{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}
{showScrollToBottom ? ( -
+
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; + } +}