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

@@ -1,16 +1,17 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, joinChat, pinChat, unarchiveChat, unpinChat } from "../api/chats";
import { archiveChat, clearChat, createPrivateChat, deleteChat, getChats, getSavedMessagesChat, joinChat, pinChat, unarchiveChat, unpinChat } from "../api/chats";
import { globalSearch } from "../api/search";
import type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
import { updateMyProfile } from "../api/users";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { NewChatPanel } from "./NewChatPanel";
import { SettingsPanel } from "./SettingsPanel";
import { applyAppearancePreferences, getAppPreferences } from "../utils/preferences";
export function ChatList() {
const chats = useChatStore((s) => s.chats);
const messagesByChat = useChatStore((s) => s.messagesByChat);
const activeChatId = useChatStore((s) => s.activeChatId);
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
@@ -28,6 +29,8 @@ export function ChatList() {
const [deleteModalChatId, setDeleteModalChatId] = useState<number | null>(null);
const [deleteForAll, setDeleteForAll] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [profileName, setProfileName] = useState("");
const [profileUsername, setProfileUsername] = useState("");
const [profileBio, setProfileBio] = useState("");
@@ -119,6 +122,10 @@ export function ChatList() {
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
useEffect(() => {
applyAppearancePreferences(getAppPreferences());
}, []);
useEffect(() => {
if (!me) {
return;
@@ -130,6 +137,12 @@ export function ChatList() {
setProfileAllowPrivateMessages(me.allow_private_messages ?? true);
}, [me]);
async function openSavedMessages() {
const saved = await getSavedMessagesChat();
const updatedChats = await getChats();
useChatStore.setState({ chats: updatedChats, activeChatId: saved.id });
}
const filteredChats = chats.filter((chat) => {
if (chat.archived) {
return false;
@@ -157,10 +170,29 @@ export function ChatList() {
const visibleChats = tab === "archived" ? archivedChats : filteredChats;
return (
<aside className="relative flex h-full w-full flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); }}>
<aside className="relative flex h-full w-full flex-col bg-slate-900/60" onClick={() => { setCtxChatId(null); setCtxPos(null); setMenuOpen(false); }}>
<div className="border-b border-slate-700/50 px-3 py-3">
<div className="mb-2 flex items-center gap-2">
<button className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs"></button>
<div className="relative mb-2 flex items-center gap-2">
<button className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs" onClick={() => setMenuOpen((v) => !v)}></button>
{menuOpen ? (
<div className="absolute left-0 top-11 z-40 w-56 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl">
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setProfileOpen(true); setMenuOpen(false); }}>
My Profile
</button>
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { void openSavedMessages(); setMenuOpen(false); }}>
Saved Messages
</button>
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setTab("people"); setMenuOpen(false); }}>
Contacts
</button>
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setSettingsOpen(true); setMenuOpen(false); }}>
Settings
</button>
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => setMenuOpen(false)}>
More
</button>
</div>
) : null}
<label className="block flex-1">
<input
className="w-full rounded-full border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
@@ -282,8 +314,13 @@ export function ChatList() {
}}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
{chat.pinned ? "📌" : (chat.display_title || chat.title || chat.type).slice(0, 1)}
<div className="relative mt-0.5">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-500/30 text-sm font-semibold uppercase text-sky-100">
{chat.pinned ? "📌" : (chat.display_title || chat.title || chat.type).slice(0, 1)}
</div>
{chat.type === "private" && chat.counterpart_is_online ? (
<span className="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-slate-900 bg-emerald-400" />
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
@@ -294,11 +331,11 @@ export function ChatList() {
</span>
) : (
<span className="shrink-0 text-[11px] text-slate-400">
{messagesByChat[chat.id]?.length ? "now" : ""}
{formatChatListTime(chat.last_message_created_at)}
</span>
)}
</div>
<p className="truncate text-xs text-slate-400">{chatMetaLabel(chat)}</p>
<p className="truncate text-xs text-slate-400">{chatPreviewLabel(chat)}</p>
</div>
</div>
</button>
@@ -468,6 +505,7 @@ export function ChatList() {
</div>
</div>
) : null}
<SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</aside>
);
}
@@ -520,6 +558,50 @@ function chatMetaLabel(chat: {
return `${subscribers} subscribers`;
}
function chatPreviewLabel(chat: {
last_message_text?: string | null;
last_message_type?: "text" | "image" | "video" | "audio" | "voice" | "file" | "circle_video" | null;
type: "private" | "group" | "channel";
is_saved?: boolean;
counterpart_is_online?: boolean | null;
counterpart_last_seen_at?: string | null;
members_count?: number | null;
online_count?: number | null;
subscribers_count?: number | null;
}): string {
if (chat.last_message_text?.trim()) {
return chat.last_message_text.trim();
}
if (chat.last_message_type) {
if (chat.last_message_type === "image") return "Photo";
if (chat.last_message_type === "video") return "Video";
if (chat.last_message_type === "audio") return "Audio";
if (chat.last_message_type === "voice") return "Voice message";
if (chat.last_message_type === "file") return "File";
if (chat.last_message_type === "circle_video") return "Video message";
}
return chatMetaLabel(chat);
}
function formatChatListTime(value?: string | null): string {
if (!value) {
return "";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "";
}
const now = new Date();
const sameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
if (sameDay) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return date.toLocaleDateString([], { day: "2-digit", month: "2-digit" });
}
function formatLastSeen(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {