feat(web): service-worker notifications and composer/scroll UX fixes
Some checks failed
CI / test (push) Failing after 21s
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:
33
web/public/notifications-sw.js
Normal file
33
web/public/notifications-sw.js
Normal 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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
84
web/src/utils/webNotifications.ts
Normal file
84
web/src/utils/webNotifications.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user