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

@@ -4,6 +4,7 @@ import {
addChatMember,
createInviteLink,
getChatAttachments,
getMessages,
getChatNotificationSettings,
getChatDetail,
leaveChat,
@@ -14,7 +15,7 @@ import {
updateChatTitle
} from "../api/chats";
import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } from "../api/users";
import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, UserSearchItem } from "../chat/types";
import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, Message, UserSearchItem } from "../chat/types";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { useUiStore } from "../store/uiStore";
@@ -29,6 +30,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const me = useAuthStore((s) => s.me);
const loadChats = useChatStore((s) => s.loadChats);
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
const showToast = useUiStore((s) => s.showToast);
const [chat, setChat] = useState<ChatDetail | null>(null);
const [members, setMembers] = useState<ChatMember[]>([]);
@@ -46,22 +48,29 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
const [attachmentsLoading, setAttachmentsLoading] = useState(false);
const [attachmentCtx, setAttachmentCtx] = useState<{ x: number; y: number; url: string } | null>(null);
const [attachmentsTab, setAttachmentsTab] = useState<"media" | "files">("media");
const [linkItems, setLinkItems] = useState<Array<{ url: string; messageId: number; createdAt: string }>>([]);
const [attachmentCtx, setAttachmentCtx] = useState<{ x: number; y: number; url: string; messageId: number } | null>(null);
const [memberCtx, setMemberCtx] = useState<{ x: number; y: number; member: ChatMember } | null>(null);
const [attachmentsTab, setAttachmentsTab] = useState<"all" | "photos" | "videos" | "audio" | "links" | "voice">("all");
const [mediaViewer, setMediaViewer] = useState<{ items: Array<{ url: string; type: "image" | "video"; messageId: number }>; index: number } | null>(null);
const myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]);
const myRole = useMemo(() => {
if (chat?.my_role) {
return chat.my_role;
}
return members.find((m) => m.user_id === me?.id)?.role;
}, [chat?.my_role, members, me?.id]);
const isGroupLike = chat?.type === "group" || chat?.type === "channel";
const showMembersSection = Boolean(chat && isGroupLike && !chat.is_saved);
const canManageMembers = Boolean(isGroupLike && (myRole === "owner" || myRole === "admin"));
const canChangeRoles = Boolean(isGroupLike && myRole === "owner");
const mediaAttachments = useMemo(
() => attachments.filter((item) => item.file_type.startsWith("image/") || item.file_type.startsWith("video/")),
[attachments]
);
const fileAttachments = useMemo(
() => attachments.filter((item) => !item.file_type.startsWith("image/") && !item.file_type.startsWith("video/")),
const photoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("image/")), [attachments]);
const videoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("video/")), [attachments]);
const voiceAttachments = useMemo(() => attachments.filter((item) => item.message_type === "voice"), [attachments]);
const audioAttachments = useMemo(
() => attachments.filter((item) => item.message_type === "audio" || (item.file_type.startsWith("audio/") && item.message_type !== "voice")),
[attachments]
);
const allAttachmentItems = useMemo(() => [...attachments].sort((a, b) => b.id - a.id), [attachments]);
async function refreshMembers(targetChatId: number) {
const nextMembers = await listChatMembers(targetChatId);
@@ -75,6 +84,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
setMemberUsers(byId);
}
function jumpToMessage(messageId: number) {
if (!chatId) {
return;
}
setActiveChatId(chatId);
setFocusedMessage(chatId, messageId);
onClose();
}
useEffect(() => {
if (!open || !chatId) {
return;
@@ -109,8 +127,10 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
}
await refreshMembers(chatId);
const chatAttachments = await getChatAttachments(chatId, 120);
const messages = await getRecentMessagesForLinks(chatId);
if (!cancelled) {
setAttachments(chatAttachments);
setLinkItems(extractLinkItems(messages));
}
} catch {
if (!cancelled) setError("Failed to load chat info");
@@ -126,6 +146,17 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
};
}, [open, chatId]);
useEffect(() => {
if (!open) {
return;
}
setInviteLink(null);
setMembers([]);
setMemberUsers({});
setSearchQuery("");
setSearchResults([]);
}, [chatId, open]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
@@ -143,14 +174,22 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
}
return createPortal(
<div
<div
className="fixed inset-0 z-[120] bg-slate-950/55"
onClick={() => {
setAttachmentCtx(null);
setMemberCtx(null);
onClose();
}}
>
<aside className="absolute right-0 top-0 flex h-full w-full max-w-sm flex-col border-l border-slate-700/70 bg-slate-900/95 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<aside
className="absolute right-0 top-0 flex h-full w-full max-w-sm flex-col border-l border-slate-700/70 bg-slate-900/95 shadow-2xl"
onClick={(e) => {
setAttachmentCtx(null);
setMemberCtx(null);
e.stopPropagation();
}}
>
<div className="flex items-center justify-between border-b border-slate-700/60 px-4 py-3">
<p className="text-sm font-semibold">Chat info</p>
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={onClose}>Close</button>
@@ -245,45 +284,36 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
<div className="tg-scrollbar max-h-56 space-y-2 overflow-auto">
{members.map((member) => {
const user = memberUsers[member.user_id];
const isSelf = member.user_id === me?.id;
const canOpenMemberMenu =
canManageMembers &&
!isSelf &&
(myRole === "owner" || (myRole === "admin" && member.role === "member"));
return (
<div className="rounded border border-slate-700/60 bg-slate-900/60 p-2" key={member.id}>
<p className="truncate text-sm font-semibold">{user?.name || `user #${member.user_id}`}</p>
<button
className="block w-full rounded border border-slate-700/60 bg-slate-900/60 p-2 text-left hover:bg-slate-800/70"
key={member.id}
onContextMenu={(event) => {
if (!canOpenMemberMenu) {
return;
}
event.preventDefault();
setMemberCtx({
x: Math.min(event.clientX + 4, window.innerWidth - 210),
y: Math.min(event.clientY + 4, window.innerHeight - 170),
member,
});
}}
type="button"
>
<p className="flex items-center gap-1 truncate text-sm font-semibold">
<span className="truncate">{user?.name || `user #${member.user_id}`}</span>
{member.role === "owner" ? <span className="rounded bg-amber-500/20 px-1.5 py-0.5 text-[10px] text-amber-300">👑 owner</span> : null}
{member.role === "admin" ? <span className="rounded bg-sky-500/20 px-1.5 py-0.5 text-[10px] text-sky-300">👑 admin</span> : null}
</p>
<p className="truncate text-xs text-slate-400">@{user?.username || "unknown"}</p>
<div className="mt-2 flex items-center gap-2">
<select
className="flex-1 rounded bg-slate-800 px-2 py-1 text-xs"
disabled={!canChangeRoles || member.user_id === me?.id}
value={member.role}
onChange={async (e) => {
try {
await updateChatMemberRole(chatId, member.user_id, e.target.value as ChatMember["role"]);
await refreshMembers(chatId);
} catch {
setError("Failed to update role");
}
}}
>
<option value="member">member</option>
<option value="admin">admin</option>
<option value="owner">owner</option>
</select>
{canManageMembers && member.user_id !== me?.id ? (
<button
className="rounded bg-red-500 px-2 py-1 text-xs font-semibold text-white"
onClick={async () => {
try {
await removeChatMember(chatId, member.user_id);
await refreshMembers(chatId);
} catch {
setError("Failed to remove member");
}
}}
>
Remove
</button>
) : null}
</div>
</div>
{canOpenMemberMenu ? <p className="mt-1 text-[11px] text-slate-500">Right click for actions</p> : null}
</button>
);
})}
</div>
@@ -358,37 +388,74 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
</p>
<div className="mb-2 flex items-center gap-2 border-b border-slate-700/60 pb-2 text-xs">
<button
className={`rounded px-2 py-1 ${attachmentsTab === "media" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("media")}
className={`rounded px-2 py-1 ${attachmentsTab === "all" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("all")}
type="button"
>
Media
All
</button>
<button
className={`rounded px-2 py-1 ${attachmentsTab === "files" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("files")}
className={`rounded px-2 py-1 ${attachmentsTab === "photos" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("photos")}
type="button"
>
Files
Photos
</button>
<button
className={`rounded px-2 py-1 ${attachmentsTab === "videos" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("videos")}
type="button"
>
Videos
</button>
<button
className={`rounded px-2 py-1 ${attachmentsTab === "audio" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("audio")}
type="button"
>
Audio
</button>
<button
className={`rounded px-2 py-1 ${attachmentsTab === "voice" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("voice")}
type="button"
>
Voice
</button>
<button
className={`rounded px-2 py-1 ${attachmentsTab === "links" ? "bg-sky-500/30 text-sky-200" : "text-slate-300"}`}
onClick={() => setAttachmentsTab("links")}
type="button"
>
Links
</button>
</div>
{attachmentsLoading ? <p className="text-xs text-slate-400">Loading attachments...</p> : null}
{!attachmentsLoading && attachments.length === 0 ? <p className="text-xs text-slate-400">No attachments yet</p> : null}
{!attachmentsLoading && attachmentsTab === "media" ? (
{!attachmentsLoading && (attachmentsTab === "all" || attachmentsTab === "photos" || attachmentsTab === "videos") ? (
<>
<div className="grid grid-cols-3 gap-1">
{mediaAttachments.slice(0, 90).map((item) => (
{(attachmentsTab === "photos" ? photoAttachments : attachmentsTab === "videos" ? videoAttachments : [...photoAttachments, ...videoAttachments])
.slice(0, 120)
.map((item) => (
<button
className="group relative aspect-square overflow-hidden rounded-md border border-slate-700/70 bg-slate-900"
key={`media-item-${item.id}`}
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
onClick={() => {
const mediaItems = [...photoAttachments, ...videoAttachments]
.sort((a, b) => b.id - a.id)
.map((it) => ({ url: it.file_url, type: it.file_type.startsWith("video/") ? "video" as const : "image" as const, messageId: it.message_id }));
const idx = mediaItems.findIndex((it) => it.url === item.file_url && it.messageId === item.message_id);
setMediaViewer({ items: mediaItems, index: idx >= 0 ? idx : 0 });
}}
onContextMenu={(event) => {
event.preventDefault();
setAttachmentCtx({
x: Math.min(event.clientX + 4, window.innerWidth - 190),
y: Math.min(event.clientY + 4, window.innerHeight - 120),
url: item.file_url
url: item.file_url,
messageId: item.message_id,
});
}}
type="button"
@@ -403,19 +470,23 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
</div>
</>
) : null}
{!attachmentsLoading && attachmentsTab === "files" ? (
{!attachmentsLoading && (attachmentsTab === "all" || attachmentsTab === "audio" || attachmentsTab === "voice") ? (
<div className="tg-scrollbar max-h-72 space-y-1 overflow-auto">
{fileAttachments.slice(0, 100).map((item) => (
{(attachmentsTab === "audio" ? audioAttachments : attachmentsTab === "voice" ? voiceAttachments : allAttachmentItems)
.filter((item) => !item.file_type.startsWith("image/") && !item.file_type.startsWith("video/"))
.slice(0, 120)
.map((item) => (
<button
className="block w-full rounded-md border border-slate-700/60 bg-slate-900/70 px-2 py-1.5 text-left hover:bg-slate-700/70"
key={`file-item-${item.id}`}
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
onClick={() => jumpToMessage(item.message_id)}
onContextMenu={(event) => {
event.preventDefault();
setAttachmentCtx({
x: Math.min(event.clientX + 4, window.innerWidth - 190),
y: Math.min(event.clientY + 4, window.innerHeight - 120),
url: item.file_url
url: item.file_url,
messageId: item.message_id,
});
}}
type="button"
@@ -428,6 +499,31 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
))}
</div>
) : null}
{!attachmentsLoading && attachmentsTab === "links" ? (
<div className="tg-scrollbar max-h-72 space-y-1 overflow-auto">
{linkItems.length === 0 ? <p className="text-xs text-slate-400">No links found</p> : null}
{linkItems.map((item, index) => (
<button
className="block w-full rounded-md border border-slate-700/60 bg-slate-900/70 px-2 py-1.5 text-left hover:bg-slate-700/70"
key={`link-item-${item.messageId}-${index}`}
onClick={() => jumpToMessage(item.messageId)}
onContextMenu={(event) => {
event.preventDefault();
setAttachmentCtx({
x: Math.min(event.clientX + 4, window.innerWidth - 190),
y: Math.min(event.clientY + 4, window.innerHeight - 120),
url: item.url,
messageId: item.messageId,
});
}}
type="button"
>
<p className="truncate text-xs font-semibold text-sky-300">{item.url}</p>
<p className="text-[11px] text-slate-400">{new Date(item.createdAt).toLocaleString()}</p>
</button>
))}
</div>
) : null}
</div>
{chat.type === "private" && !chat.is_saved && chat.counterpart_user_id ? (
@@ -504,6 +600,142 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
>
Copy link
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
jumpToMessage(attachmentCtx.messageId);
setAttachmentCtx(null);
}}
type="button"
>
Jump to message
</button>
</div>
) : null}
{memberCtx ? (
<div
className="fixed z-[130] w-52 rounded-lg border border-slate-700/90 bg-slate-900/95 p-1 shadow-2xl"
style={{ left: memberCtx.x, top: memberCtx.y }}
onClick={(event) => event.stopPropagation()}
>
{myRole === "owner" && memberCtx.member.role === "member" ? (
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
try {
await updateChatMemberRole(chatId, memberCtx.member.user_id, "admin");
await refreshMembers(chatId);
} catch {
setError("Failed to update role");
} finally {
setMemberCtx(null);
}
}}
type="button"
>
Make admin
</button>
) : null}
{myRole === "owner" && memberCtx.member.role === "admin" ? (
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
try {
await updateChatMemberRole(chatId, memberCtx.member.user_id, "member");
await refreshMembers(chatId);
} catch {
setError("Failed to update role");
} finally {
setMemberCtx(null);
}
}}
type="button"
>
Remove admin rights
</button>
) : null}
{myRole === "owner" && memberCtx.member.role !== "owner" ? (
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
try {
await updateChatMemberRole(chatId, memberCtx.member.user_id, "owner");
await refreshMembers(chatId);
} catch {
setError("Failed to transfer ownership");
} finally {
setMemberCtx(null);
}
}}
type="button"
>
Transfer ownership
</button>
) : null}
{(myRole === "owner" || (myRole === "admin" && memberCtx.member.role === "member")) ? (
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
onClick={async () => {
try {
await removeChatMember(chatId, memberCtx.member.user_id);
await refreshMembers(chatId);
} catch {
setError("Failed to remove member");
} finally {
setMemberCtx(null);
}
}}
type="button"
>
Remove from chat
</button>
) : null}
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => setMemberCtx(null)}
type="button"
>
Cancel
</button>
</div>
) : null}
{mediaViewer ? (
<div className="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/90 p-2 md:p-4" onClick={() => setMediaViewer(null)}>
<div className="relative flex h-full w-full max-w-5xl items-center justify-center" onClick={(event) => event.stopPropagation()}>
<button className="absolute left-2 top-2 z-10 rounded bg-slate-900/80 px-2 py-1 text-xs" onClick={() => setMediaViewer(null)} type="button">
Close
</button>
{mediaViewer.items.length > 1 ? (
<>
<button
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-2 text-lg"
onClick={() => setMediaViewer((prev) => (prev ? { ...prev, index: prev.index <= 0 ? prev.items.length - 1 : prev.index - 1 } : prev))}
type="button"
>
</button>
<button
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-2 text-lg"
onClick={() => setMediaViewer((prev) => (prev ? { ...prev, index: prev.index >= prev.items.length - 1 ? 0 : prev.index + 1 } : prev))}
type="button"
>
</button>
</>
) : null}
<button
className="absolute right-2 top-2 z-10 rounded bg-slate-900/80 px-2 py-1 text-xs"
onClick={() => jumpToMessage(mediaViewer.items[mediaViewer.index].messageId)}
type="button"
>
Jump
</button>
{mediaViewer.items[mediaViewer.index].type === "image" ? (
<img className="max-h-full max-w-full rounded-xl object-contain" src={mediaViewer.items[mediaViewer.index].url} alt="media" />
) : (
<video className="max-h-full max-w-full rounded-xl" controls src={mediaViewer.items[mediaViewer.index].url} />
)}
</div>
</div>
) : null}
</div>,
@@ -548,3 +780,44 @@ function attachmentKind(fileType: string): string {
if (fileType === "application/pdf") return "PDF";
return "File";
}
async function getRecentMessagesForLinks(chatId: number): Promise<Message[]> {
const pages = 4;
const all: Message[] = [];
let beforeId: number | undefined;
for (let i = 0; i < pages; i += 1) {
const batch = await getMessages(chatId, beforeId);
if (!batch.length) {
break;
}
all.push(...batch);
beforeId = batch[batch.length - 1]?.id;
if (!beforeId || batch.length < 50) {
break;
}
}
return all;
}
function extractLinkItems(messages: Message[]): Array<{ url: string; messageId: number; createdAt: string }> {
const out: Array<{ url: string; messageId: number; createdAt: string }> = [];
const seen = new Set<string>();
const regex = /\bhttps?:\/\/[^\s<>"')]+/gi;
for (const message of messages) {
if (!message.text) {
continue;
}
const links = message.text.match(regex) ?? [];
for (const raw of links) {
const url = raw.trim();
const key = `${message.id}:${url}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
out.push({ url, messageId: message.id, createdAt: message.created_at });
}
}
out.sort((a, b) => b.messageId - a.messageId);
return out;
}