feat(web): service-worker notifications and composer/scroll UX fixes
Some checks failed
CI / test (push) Failing after 21s

- register notifications service worker and handle click-to-open chat/message

- route realtime notifications through service worker with fallback

- support ?chat=&message= deep-link navigation in chats page

- enforce 1s minimum voice message length

- lift scroll-to-bottom button to avoid overlap with composer action
This commit is contained in:
2026-03-08 11:33:58 +03:00
parent 68ba97bb90
commit 4fe89ce89a
7 changed files with 179 additions and 53 deletions

View File

@@ -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);
}
})
);
});

View File

@@ -280,15 +280,21 @@ export function MessageComposer() {
recorder.ondataavailable = (event) => chunksRef.current.push(event.data); recorder.ondataavailable = (event) => chunksRef.current.push(event.data);
recorder.onstop = async () => { recorder.onstop = async () => {
const shouldSend = sendVoiceOnStopRef.current; const shouldSend = sendVoiceOnStopRef.current;
const durationMs = recordingStartedAtRef.current ? Date.now() - recordingStartedAtRef.current : 0;
const data = [...chunksRef.current]; const data = [...chunksRef.current];
chunksRef.current = []; chunksRef.current = [];
if (recordingStreamRef.current) { if (recordingStreamRef.current) {
recordingStreamRef.current.getTracks().forEach((track) => track.stop()); recordingStreamRef.current.getTracks().forEach((track) => track.stop());
recordingStreamRef.current = null; recordingStreamRef.current = null;
} }
recordingStartedAtRef.current = null;
if (!shouldSend || data.length === 0) { if (!shouldSend || data.length === 0) {
return; 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 blob = new Blob(data, { type: "audio/webm" });
const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" }); const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" });
await handleUpload(file, "voice"); await handleUpload(file, "voice");
@@ -313,7 +319,6 @@ export function MessageComposer() {
recorderRef.current.stop(); recorderRef.current.stop();
} }
recorderRef.current = null; recorderRef.current = null;
recordingStartedAtRef.current = null;
setRecordingState("idle"); setRecordingState("idle");
setRecordSeconds(0); setRecordSeconds(0);
} }
@@ -567,8 +572,9 @@ export function MessageComposer() {
disabled={recordingState !== "idle" || !activeChatId} disabled={recordingState !== "idle" || !activeChatId}
onClick={handleSend} onClick={handleSend}
type="button" type="button"
title="Send message"
> >
</button> </button>
) : ( ) : (
<button <button
@@ -578,6 +584,7 @@ export function MessageComposer() {
disabled={isUploading || !activeChatId} disabled={isUploading || !activeChatId}
onPointerDown={onMicPointerDown} onPointerDown={onMicPointerDown}
type="button" type="button"
title="Hold to record voice"
> >
🎤 🎤
</button> </button>

View File

@@ -494,11 +494,12 @@ export function MessageList() {
<div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div> <div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div>
{showScrollToBottom ? ( {showScrollToBottom ? (
<div className="pointer-events-none absolute bottom-4 right-4 z-40"> <div className="pointer-events-none absolute bottom-20 right-4 z-40 md:bottom-24">
<button <button
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-600/80 bg-slate-900/90 text-lg text-slate-100 shadow-lg hover:bg-slate-800" className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-600/80 bg-slate-900/90 text-lg text-slate-100 shadow-lg hover:bg-slate-800"
onClick={scrollToBottom} onClick={scrollToBottom}
type="button" type="button"
title="Scroll to latest"
> >
</button> </button>

View File

@@ -3,6 +3,7 @@ import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import type { Message } from "../chat/types"; import type { Message } from "../chat/types";
import { getAppPreferences } from "../utils/preferences"; import { getAppPreferences } from "../utils/preferences";
import { buildNotificationPreview, showNotificationViaServiceWorker } from "../utils/webNotifications";
import { buildWsUrl } from "../utils/ws"; import { buildWsUrl } from "../utils/ws";
interface RealtimeEnvelope { interface RealtimeEnvelope {
@@ -273,6 +274,18 @@ function maybeShowBrowserNotification(chatId: number, message: Message, activeCh
} }
const title = chat?.display_title || chat?.title || "New message"; const title = chat?.display_title || chat?.title || "New message";
const preview = buildNotificationPreview(message, prefs.messagePreview); const preview = buildNotificationPreview(message, prefs.messagePreview);
void (async () => {
const shownViaSw = await showNotificationViaServiceWorker({
chatId,
messageId: message.id,
title,
body: preview.body,
image: preview.image,
});
if (shownViaSw) {
return;
}
const notification = new Notification(title, { const notification = new Notification(title, {
body: preview.body, body: preview.body,
icon: preview.image, icon: preview.image,
@@ -285,44 +298,5 @@ function maybeShowBrowserNotification(chatId: number, message: Message, activeCh
store.setFocusedMessage(chatId, message.id); store.setFocusedMessage(chatId, message.id);
notification.close(); 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 };
}
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;
}
} }

View File

@@ -2,8 +2,11 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { App } from "./app/App"; import { App } from "./app/App";
import { AppErrorBoundary } from "./components/AppErrorBoundary"; import { AppErrorBoundary } from "./components/AppErrorBoundary";
import { registerNotificationServiceWorker } from "./utils/webNotifications";
import "./index.css"; import "./index.css";
void registerNotificationServiceWorker();
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<AppErrorBoundary> <AppErrorBoundary>

View File

@@ -52,6 +52,30 @@ export function ChatsPage() {
} }
}, [activeChatId, loadMessages]); }, [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(() => { useEffect(() => {
if (!searchOpen) { if (!searchOpen) {
return; return;

View File

@@ -0,0 +1,84 @@
import type { Message } from "../chat/types";
export async function registerNotificationServiceWorker(): Promise<void> {
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<boolean> {
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;
}
}