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

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