feat: realtime sync, settings UX and chat list improvements
Some checks failed
CI / test (push) Failing after 21s

- add chat_updated realtime event and dynamic chat subscriptions

- auto-join invite links in web app

- implement Telegram-like settings panel (general/notifications/privacy)

- add browser notification preferences and keyboard send mode

- improve chat list with last message preview/time and online badge

- rework chat members UI with context actions and role crowns
This commit is contained in:
2026-03-08 10:59:44 +03:00
parent a4fa72df30
commit 99e7c70901
18 changed files with 1007 additions and 78 deletions

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import type { Message } from "../chat/types";
import { getAppPreferences } from "../utils/preferences";
import { buildWsUrl } from "../utils/ws";
interface RealtimeEnvelope {
@@ -21,6 +22,7 @@ export function useRealtime() {
const lastPongAtRef = useRef<number>(Date.now());
const reconnectAttemptsRef = useRef(0);
const manualCloseRef = useRef(false);
const notificationPermissionRequestedRef = useRef(false);
const wsUrl = useMemo(() => {
return accessToken ? buildWsUrl(accessToken) : null;
@@ -67,6 +69,10 @@ export function useRealtime() {
if (store.activeChatId) {
void store.loadMessages(store.activeChatId);
}
if ("Notification" in window && Notification.permission === "default" && !notificationPermissionRequestedRef.current) {
notificationPermissionRequestedRef.current = true;
void Notification.requestPermission();
}
};
ws.onmessage = (messageEvent) => {
@@ -99,11 +105,21 @@ export function useRealtime() {
} else if (wasInserted) {
chatStore.incrementUnread(chatId);
}
maybeShowBrowserNotification(chatId, message, chatStore.activeChatId);
}
if (!chatStore.chats.some((chat) => chat.id === chatId)) {
void chatStore.loadChats();
}
}
if (event.event === "chat_updated") {
const chatId = Number(event.payload.chat_id);
if (Number.isFinite(chatId)) {
void chatStore.loadChats();
if (chatStore.activeChatId === chatId) {
void chatStore.loadMessages(chatId);
}
}
}
if (event.event === "pong") {
lastPongAtRef.current = Date.now();
}
@@ -216,3 +232,49 @@ export function useRealtime() {
return null;
}
function maybeShowBrowserNotification(chatId: number, message: Message, activeChatId: number | null): void {
const prefs = getAppPreferences();
if (!prefs.webNotifications) {
return;
}
if (!("Notification" in window) || Notification.permission !== "granted") {
return;
}
if (!document.hidden && activeChatId === chatId) {
return;
}
const chat = useChatStore.getState().chats.find((item) => item.id === chatId);
if (chat?.type === "private" && !prefs.privateNotifications) {
return;
}
if (chat?.type === "group" && !prefs.groupNotifications) {
return;
}
if (chat?.type === "channel" && !prefs.channelNotifications) {
return;
}
const title = chat?.display_title || chat?.title || "New message";
const body = prefs.messagePreview ? (message.text?.trim() || messagePreviewByType(message.type)) : "New message";
const notification = new Notification(title, {
body,
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";
}