From 99e7c7090185f32b4615399d0c6a0a4fe19e610b Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 10:59:44 +0300 Subject: [PATCH] feat: realtime sync, settings UX and chat list improvements - 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 --- app/chats/repository.py | 23 ++ app/chats/router.py | 21 +- app/chats/schemas.py | 4 + app/chats/service.py | 4 + app/media/schemas.py | 1 + app/media/service.py | 1 + app/realtime/schemas.py | 1 + app/realtime/service.py | 18 ++ web/src/app/App.tsx | 52 +++- web/src/chat/types.ts | 4 + web/src/components/ChatInfoPanel.tsx | 401 +++++++++++++++++++++---- web/src/components/ChatList.tsx | 100 +++++- web/src/components/MessageComposer.tsx | 15 +- web/src/components/SettingsPanel.tsx | 288 ++++++++++++++++++ web/src/hooks/useRealtime.ts | 62 ++++ web/src/index.css | 1 + web/src/utils/preferences.ts | 87 ++++++ web/tsconfig.tsbuildinfo | 2 +- 18 files changed, 1007 insertions(+), 78 deletions(-) create mode 100644 web/src/components/SettingsPanel.tsx create mode 100644 web/src/utils/preferences.ts diff --git a/app/chats/repository.py b/app/chats/repository.py index 7c2706e..88e53cf 100644 --- a/app/chats/repository.py +++ b/app/chats/repository.py @@ -237,6 +237,29 @@ async def get_unread_count_for_chat(db: AsyncSession, *, chat_id: int, user_id: return int(result.scalar_one() or 0) +async def get_last_visible_message_for_user( + db: AsyncSession, + *, + chat_id: int, + user_id: int, +) -> Message | None: + stmt = ( + select(Message) + .outerjoin( + MessageHidden, + (MessageHidden.message_id == Message.id) & (MessageHidden.user_id == user_id), + ) + .where( + Message.chat_id == chat_id, + MessageHidden.id.is_(None), + ) + .order_by(Message.id.desc()) + .limit(1) + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def get_chat_notification_setting( db: AsyncSession, *, chat_id: int, user_id: int ) -> ChatNotificationSetting | None: diff --git a/app/chats/router.py b/app/chats/router.py index 46d25df..a121cf3 100644 --- a/app/chats/router.py +++ b/app/chats/router.py @@ -43,6 +43,7 @@ from app.chats.service import ( update_chat_title_for_user, ) from app.database.session import get_db +from app.realtime.service import realtime_gateway from app.users.models import User router = APIRouter(prefix="/chats", tags=["chats"]) @@ -94,6 +95,7 @@ async def create_chat( current_user: User = Depends(get_current_user), ) -> ChatRead: chat = await create_chat_for_user(db, creator_id=current_user.id, payload=payload) + realtime_gateway.add_chat_subscription(chat_id=chat.id, user_id=current_user.id) return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat) @@ -104,6 +106,8 @@ async def join_chat( current_user: User = Depends(get_current_user), ) -> ChatRead: chat = await join_public_chat_for_user(db, chat_id=chat_id, user_id=current_user.id) + realtime_gateway.add_chat_subscription(chat_id=chat.id, user_id=current_user.id) + await realtime_gateway.publish_chat_updated(chat_id=chat.id) return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat) @@ -131,6 +135,7 @@ async def update_chat_title( current_user: User = Depends(get_current_user), ) -> ChatRead: chat = await update_chat_title_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload) + await realtime_gateway.publish_chat_updated(chat_id=chat.id) return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat) @@ -151,7 +156,10 @@ async def add_chat_member( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ) -> ChatMemberRead: - return await add_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=payload.user_id) + member = await add_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=payload.user_id) + realtime_gateway.add_chat_subscription(chat_id=chat_id, user_id=payload.user_id) + await realtime_gateway.publish_chat_updated(chat_id=chat_id) + return member @router.patch("/{chat_id}/members/{user_id}/role", response_model=ChatMemberRead) @@ -162,13 +170,15 @@ async def update_chat_member_role( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ) -> ChatMemberRead: - return await update_chat_member_role_for_user( + member = await update_chat_member_role_for_user( db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=user_id, role=payload.role, ) + await realtime_gateway.publish_chat_updated(chat_id=chat_id) + return member @router.delete("/{chat_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -179,6 +189,8 @@ async def remove_chat_member( current_user: User = Depends(get_current_user), ) -> None: await remove_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=user_id) + realtime_gateway.remove_chat_subscription(chat_id=chat_id, user_id=user_id) + await realtime_gateway.publish_chat_updated(chat_id=chat_id) @router.post("/{chat_id}/leave", status_code=status.HTTP_204_NO_CONTENT) @@ -188,6 +200,8 @@ async def leave_chat( current_user: User = Depends(get_current_user), ) -> None: await leave_chat_for_user(db, chat_id=chat_id, user_id=current_user.id) + realtime_gateway.remove_chat_subscription(chat_id=chat_id, user_id=current_user.id) + await realtime_gateway.publish_chat_updated(chat_id=chat_id) @router.delete("/{chat_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -217,6 +231,7 @@ async def pin_chat_message( current_user: User = Depends(get_current_user), ) -> ChatRead: chat = await pin_chat_message_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload) + await realtime_gateway.publish_chat_updated(chat_id=chat.id) return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat) @@ -300,4 +315,6 @@ async def join_by_invite( current_user: User = Depends(get_current_user), ) -> ChatRead: chat = await join_chat_by_invite_for_user(db, user_id=current_user.id, payload=payload) + realtime_gateway.add_chat_subscription(chat_id=chat.id, user_id=current_user.id) + await realtime_gateway.publish_chat_updated(chat_id=chat.id) return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat) diff --git a/app/chats/schemas.py b/app/chats/schemas.py index 55f3d13..b1adedf 100644 --- a/app/chats/schemas.py +++ b/app/chats/schemas.py @@ -3,6 +3,7 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field from app.chats.models import ChatMemberRole, ChatType +from app.messages.models import MessageType class ChatRead(BaseModel): @@ -29,6 +30,9 @@ class ChatRead(BaseModel): counterpart_username: str | None = None counterpart_is_online: bool | None = None counterpart_last_seen_at: datetime | None = None + last_message_text: str | None = None + last_message_type: MessageType | None = None + last_message_created_at: datetime | None = None my_role: ChatMemberRole | None = None created_at: datetime diff --git a/app/chats/service.py b/app/chats/service.py index b18c143..9e2d536 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -67,6 +67,7 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) subscribers_count = members_count unread_count = await repository.get_unread_count_for_chat(db, chat_id=chat.id, user_id=user_id) + last_message = await repository.get_last_visible_message_for_user(db, chat_id=chat.id, user_id=user_id) user_setting = await repository.get_chat_user_setting(db, chat_id=chat.id, user_id=user_id) archived = bool(user_setting and user_setting.archived) pinned = bool(user_setting and user_setting.pinned) @@ -94,6 +95,9 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) "counterpart_username": counterpart_username, "counterpart_is_online": counterpart_is_online, "counterpart_last_seen_at": counterpart_last_seen_at, + "last_message_text": last_message.text if last_message else None, + "last_message_type": last_message.type if last_message else None, + "last_message_created_at": last_message.created_at if last_message else None, "my_role": my_role, "created_at": chat.created_at, } diff --git a/app/media/schemas.py b/app/media/schemas.py index b0c83bc..460fb13 100644 --- a/app/media/schemas.py +++ b/app/media/schemas.py @@ -37,6 +37,7 @@ class ChatAttachmentRead(BaseModel): id: int message_id: int sender_id: int + message_type: str message_created_at: datetime file_url: str file_type: str diff --git a/app/media/service.py b/app/media/service.py index eeef756..ff94830 100644 --- a/app/media/service.py +++ b/app/media/service.py @@ -157,6 +157,7 @@ async def list_attachments_for_chat( id=attachment.id, message_id=attachment.message_id, sender_id=message.sender_id, + message_type=message.type.value if hasattr(message.type, "value") else str(message.type), message_created_at=message.created_at, file_url=attachment.file_url, file_type=attachment.file_type, diff --git a/app/realtime/schemas.py b/app/realtime/schemas.py index 82de8bb..298ddcc 100644 --- a/app/realtime/schemas.py +++ b/app/realtime/schemas.py @@ -17,6 +17,7 @@ RealtimeEventName = Literal[ "message_delivered", "user_online", "user_offline", + "chat_updated", "pong", "error", ] diff --git a/app/realtime/service.py b/app/realtime/service.py index 3594fc8..0f2de58 100644 --- a/app/realtime/service.py +++ b/app/realtime/service.py @@ -144,6 +144,24 @@ class RealtimeGateway: async def load_user_chat_ids(self, db: AsyncSession, user_id: int) -> list[int]: return await list_user_chat_ids(db, user_id=user_id) + def add_chat_subscription(self, *, chat_id: int, user_id: int) -> None: + self._chat_subscribers[chat_id].add(user_id) + + def remove_chat_subscription(self, *, chat_id: int, user_id: int) -> None: + subscribers = self._chat_subscribers.get(chat_id) + if not subscribers: + return + subscribers.discard(user_id) + if not subscribers: + self._chat_subscribers.pop(chat_id, None) + + async def publish_chat_updated(self, *, chat_id: int) -> None: + await self._publish_chat_event( + chat_id, + event="chat_updated", + payload={"chat_id": chat_id}, + ) + async def _handle_redis_event(self, channel: str, payload: dict) -> None: chat_id = self._extract_chat_id(channel) if chat_id is None: diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index a743eb3..ce9c99e 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -1,8 +1,12 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { joinByInvite } from "../api/chats"; import { ToastViewport } from "../components/ToastViewport"; import { AuthPage } from "../pages/AuthPage"; import { ChatsPage } from "../pages/ChatsPage"; import { useAuthStore } from "../store/authStore"; +import { useChatStore } from "../store/chatStore"; + +const PENDING_INVITE_TOKEN_KEY = "bm_pending_invite_token"; export function App() { const accessToken = useAuthStore((s) => s.accessToken); @@ -10,6 +14,9 @@ export function App() { const loadMe = useAuthStore((s) => s.loadMe); const refresh = useAuthStore((s) => s.refresh); const logout = useAuthStore((s) => s.logout); + const loadChats = useChatStore((s) => s.loadChats); + const setActiveChatId = useChatStore((s) => s.setActiveChatId); + const [joiningInvite, setJoiningInvite] = useState(false); useEffect(() => { if (!accessToken) { @@ -25,6 +32,36 @@ export function App() { }); }, [accessToken, loadMe, refresh, logout]); + useEffect(() => { + const token = extractInviteTokenFromLocation(); + if (!token) { + return; + } + window.localStorage.setItem(PENDING_INVITE_TOKEN_KEY, token); + window.history.replaceState(null, "", "/"); + }, []); + + useEffect(() => { + if (!accessToken || !me || joiningInvite) { + return; + } + const token = window.localStorage.getItem(PENDING_INVITE_TOKEN_KEY); + if (!token) { + return; + } + setJoiningInvite(true); + void (async () => { + try { + const chat = await joinByInvite(token); + await loadChats(); + setActiveChatId(chat.id); + window.localStorage.removeItem(PENDING_INVITE_TOKEN_KEY); + } finally { + setJoiningInvite(false); + } + })(); + }, [accessToken, me, joiningInvite, loadChats, setActiveChatId]); + if (!accessToken || !me) { return ; } @@ -35,3 +72,16 @@ export function App() { ); } + +function extractInviteTokenFromLocation(): string | null { + if (typeof window === "undefined") { + return null; + } + const url = new URL(window.location.href); + const tokenFromQuery = url.searchParams.get("token")?.trim(); + if (tokenFromQuery) { + return tokenFromQuery; + } + const match = url.pathname.match(/^\/join\/([^/]+)$/i); + return match?.[1]?.trim() || null; +} diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 65200c2..24ee605 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -24,6 +24,9 @@ export interface Chat { counterpart_username?: string | null; counterpart_is_online?: boolean | null; counterpart_last_seen_at?: string | null; + last_message_text?: string | null; + last_message_type?: MessageType | null; + last_message_created_at?: string | null; my_role?: ChatMemberRole | null; created_at: string; } @@ -102,6 +105,7 @@ export interface ChatAttachment { id: number; message_id: number; sender_id: number; + message_type: MessageType; message_created_at: string; file_url: string; file_type: string; diff --git a/web/src/components/ChatInfoPanel.tsx b/web/src/components/ChatInfoPanel.tsx index 09ed759..f0c9be1 100644 --- a/web/src/components/ChatInfoPanel.tsx +++ b/web/src/components/ChatInfoPanel.tsx @@ -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(null); const [members, setMembers] = useState([]); @@ -46,22 +48,29 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { const [inviteLink, setInviteLink] = useState(null); const [attachments, setAttachments] = useState([]); 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>([]); + 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( -
{ setAttachmentCtx(null); + setMemberCtx(null); onClose(); }} > -
{chat.type === "private" && !chat.is_saved && chat.counterpart_user_id ? ( @@ -504,6 +600,142 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { > Copy link + + + ) : null} + {memberCtx ? ( +
event.stopPropagation()} + > + {myRole === "owner" && memberCtx.member.role === "member" ? ( + + ) : null} + {myRole === "owner" && memberCtx.member.role === "admin" ? ( + + ) : null} + {myRole === "owner" && memberCtx.member.role !== "owner" ? ( + + ) : null} + {(myRole === "owner" || (myRole === "admin" && memberCtx.member.role === "member")) ? ( + + ) : null} + +
+ ) : null} + {mediaViewer ? ( +
setMediaViewer(null)}> +
event.stopPropagation()}> + + {mediaViewer.items.length > 1 ? ( + <> + + + + ) : null} + + {mediaViewer.items[mediaViewer.index].type === "image" ? ( + media + ) : ( +
) : null} , @@ -548,3 +780,44 @@ function attachmentKind(fileType: string): string { if (fileType === "application/pdf") return "PDF"; return "File"; } + +async function getRecentMessagesForLinks(chatId: number): Promise { + 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(); + 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; +} diff --git a/web/src/components/ChatList.tsx b/web/src/components/ChatList.tsx index c545c31..6e4a701 100644 --- a/web/src/components/ChatList.tsx +++ b/web/src/components/ChatList.tsx @@ -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(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 ( -