feat: mentions badge in chat list and muted-mention delivery
All checks were successful
CI / test (push) Successful in 21s
All checks were successful
CI / test (push) Successful in 21s
This commit is contained in:
@@ -237,6 +237,42 @@ async def get_unread_count_for_chat(db: AsyncSession, *, chat_id: int, user_id:
|
|||||||
return int(result.scalar_one() or 0)
|
return int(result.scalar_one() or 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_unread_mentions_count_for_chat(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
chat_id: int,
|
||||||
|
user_id: int,
|
||||||
|
username: str | None,
|
||||||
|
) -> int:
|
||||||
|
normalized_username = (username or "").strip().lower()
|
||||||
|
if not normalized_username:
|
||||||
|
return 0
|
||||||
|
last_read_subquery = (
|
||||||
|
select(MessageReceipt.last_read_message_id)
|
||||||
|
.where(MessageReceipt.chat_id == chat_id, MessageReceipt.user_id == user_id)
|
||||||
|
.limit(1)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
mention_like = f"%@{normalized_username}%"
|
||||||
|
stmt = (
|
||||||
|
select(func.count(Message.id))
|
||||||
|
.outerjoin(
|
||||||
|
MessageHidden,
|
||||||
|
(MessageHidden.message_id == Message.id) & (MessageHidden.user_id == user_id),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Message.chat_id == chat_id,
|
||||||
|
Message.sender_id != user_id,
|
||||||
|
MessageHidden.id.is_(None),
|
||||||
|
Message.id > func.coalesce(last_read_subquery, 0),
|
||||||
|
Message.text.is_not(None),
|
||||||
|
func.lower(Message.text).like(mention_like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return int(result.scalar_one() or 0)
|
||||||
|
|
||||||
|
|
||||||
async def get_last_visible_message_for_user(
|
async def get_last_visible_message_for_user(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class ChatRead(BaseModel):
|
|||||||
archived: bool = False
|
archived: bool = False
|
||||||
pinned: bool = False
|
pinned: bool = False
|
||||||
unread_count: int = 0
|
unread_count: int = 0
|
||||||
|
unread_mentions_count: int = 0
|
||||||
pinned_message_id: int | None = None
|
pinned_message_id: int | None = None
|
||||||
members_count: int | None = None
|
members_count: int | None = None
|
||||||
online_count: int | None = None
|
online_count: int | None = None
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ from app.realtime.presence import get_users_online_map
|
|||||||
from app.users.repository import get_user_by_id, has_block_relation_between_users
|
from app.users.repository import get_user_by_id, has_block_relation_between_users
|
||||||
|
|
||||||
|
|
||||||
async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) -> ChatRead:
|
async def serialize_chat_for_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
chat: Chat,
|
||||||
|
current_username: str | None = None,
|
||||||
|
) -> ChatRead:
|
||||||
display_title = chat.title
|
display_title = chat.title
|
||||||
my_role = None
|
my_role = None
|
||||||
membership = await repository.get_chat_member(db, chat_id=chat.id, user_id=user_id)
|
membership = await repository.get_chat_member(db, chat_id=chat.id, user_id=user_id)
|
||||||
@@ -66,7 +72,16 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat)
|
|||||||
if chat.type == ChatType.CHANNEL:
|
if chat.type == ChatType.CHANNEL:
|
||||||
subscribers_count = members_count
|
subscribers_count = members_count
|
||||||
|
|
||||||
|
if not current_username:
|
||||||
|
current_user = await get_user_by_id(db, user_id)
|
||||||
|
current_username = current_user.username if current_user else None
|
||||||
unread_count = await repository.get_unread_count_for_chat(db, chat_id=chat.id, user_id=user_id)
|
unread_count = await repository.get_unread_count_for_chat(db, chat_id=chat.id, user_id=user_id)
|
||||||
|
unread_mentions_count = await repository.get_unread_mentions_count_for_chat(
|
||||||
|
db,
|
||||||
|
chat_id=chat.id,
|
||||||
|
user_id=user_id,
|
||||||
|
username=current_username,
|
||||||
|
)
|
||||||
last_message = await repository.get_last_visible_message_for_user(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)
|
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)
|
archived = bool(user_setting and user_setting.archived)
|
||||||
@@ -86,6 +101,7 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat)
|
|||||||
"archived": archived,
|
"archived": archived,
|
||||||
"pinned": pinned,
|
"pinned": pinned,
|
||||||
"unread_count": unread_count,
|
"unread_count": unread_count,
|
||||||
|
"unread_mentions_count": unread_mentions_count,
|
||||||
"pinned_message_id": chat.pinned_message_id,
|
"pinned_message_id": chat.pinned_message_id,
|
||||||
"members_count": members_count,
|
"members_count": members_count,
|
||||||
"online_count": online_count,
|
"online_count": online_count,
|
||||||
@@ -105,7 +121,12 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat)
|
|||||||
|
|
||||||
|
|
||||||
async def serialize_chats_for_user(db: AsyncSession, *, user_id: int, chats: list[Chat]) -> list[ChatRead]:
|
async def serialize_chats_for_user(db: AsyncSession, *, user_id: int, chats: list[Chat]) -> list[ChatRead]:
|
||||||
return [await serialize_chat_for_user(db, user_id=user_id, chat=chat) for chat in chats]
|
current_user = await get_user_by_id(db, user_id)
|
||||||
|
current_username = current_user.username if current_user else None
|
||||||
|
return [
|
||||||
|
await serialize_chat_for_user(db, user_id=user_id, chat=chat, current_username=current_username)
|
||||||
|
for chat in chats
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: ChatCreateRequest) -> Chat:
|
async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: ChatCreateRequest) -> Chat:
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ async def dispatch_message_notifications(db: AsyncSession, message: Message) ->
|
|||||||
sender_name = sender_users[0].username if sender_users else "Someone"
|
sender_name = sender_users[0].username if sender_users else "Someone"
|
||||||
|
|
||||||
for recipient in users:
|
for recipient in users:
|
||||||
if await is_chat_muted_for_user(db, chat_id=message.chat_id, user_id=recipient.id):
|
|
||||||
continue
|
|
||||||
base_payload = {
|
base_payload = {
|
||||||
"chat_id": message.chat_id,
|
"chat_id": message.chat_id,
|
||||||
"message_id": message.id,
|
"message_id": message.id,
|
||||||
@@ -71,6 +69,9 @@ async def dispatch_message_notifications(db: AsyncSession, message: Message) ->
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if await is_chat_muted_for_user(db, chat_id=message.chat_id, user_id=recipient.id):
|
||||||
|
continue
|
||||||
|
|
||||||
if not await is_user_online(recipient.id):
|
if not await is_user_online(recipient.id):
|
||||||
payload = {
|
payload = {
|
||||||
**base_payload,
|
**base_payload,
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ All fields are optional.
|
|||||||
"archived": false,
|
"archived": false,
|
||||||
"pinned": false,
|
"pinned": false,
|
||||||
"unread_count": 3,
|
"unread_count": 3,
|
||||||
|
"unread_mentions_count": 1,
|
||||||
"pinned_message_id": null,
|
"pinned_message_id": null,
|
||||||
"members_count": 2,
|
"members_count": 2,
|
||||||
"online_count": 1,
|
"online_count": 1,
|
||||||
@@ -748,6 +749,8 @@ Body:
|
|||||||
|
|
||||||
Response: `200` + `ChatNotificationSettingsRead`
|
Response: `200` + `ChatNotificationSettingsRead`
|
||||||
|
|
||||||
|
Note: mentions (`@username`) are delivered even when chat is muted.
|
||||||
|
|
||||||
### POST `/api/v1/chats/{chat_id}/archive`
|
### POST `/api/v1/chats/{chat_id}/archive`
|
||||||
|
|
||||||
Auth required.
|
Auth required.
|
||||||
@@ -935,4 +938,3 @@ Configured via env vars:
|
|||||||
- Invite links are generated for group/channel chats.
|
- Invite links are generated for group/channel chats.
|
||||||
- In channels, only users with sufficient role (owner/admin) can post.
|
- In channels, only users with sufficient role (owner/admin) can post.
|
||||||
- `email` router exists in codebase but has no public REST endpoints yet.
|
- `email` router exists in codebase but has no public REST endpoints yet.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface Chat {
|
|||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
unread_count?: number;
|
unread_count?: number;
|
||||||
|
unread_mentions_count?: number;
|
||||||
pinned_message_id?: number | null;
|
pinned_message_id?: number | null;
|
||||||
members_count?: number | null;
|
members_count?: number | null;
|
||||||
online_count?: number | null;
|
online_count?: number | null;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function ChatList() {
|
|||||||
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
|
const setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
|
||||||
const loadChats = useChatStore((s) => s.loadChats);
|
const loadChats = useChatStore((s) => s.loadChats);
|
||||||
const me = useAuthStore((s) => s.me);
|
const me = useAuthStore((s) => s.me);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [userResults, setUserResults] = useState<UserSearchItem[]>([]);
|
const [userResults, setUserResults] = useState<UserSearchItem[]>([]);
|
||||||
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
|
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
|
||||||
@@ -234,10 +235,19 @@ export function ChatList() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="truncate text-sm font-semibold">{chatLabel(chat)}</p>
|
<p className="truncate text-sm font-semibold">{chatLabel(chat)}</p>
|
||||||
|
{(chat.unread_count ?? 0) > 0 || (chat.unread_mentions_count ?? 0) > 0 ? (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{(chat.unread_mentions_count ?? 0) > 0 ? (
|
||||||
|
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-semibold text-slate-950">
|
||||||
|
@
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{(chat.unread_count ?? 0) > 0 ? (
|
{(chat.unread_count ?? 0) > 0 ? (
|
||||||
<span className="inline-flex min-w-5 items-center justify-center rounded-full bg-sky-500 px-1.5 py-0.5 text-[10px] font-semibold text-slate-950">
|
<span className="inline-flex min-w-5 items-center justify-center rounded-full bg-sky-500 px-1.5 py-0.5 text-[10px] font-semibold text-slate-950">
|
||||||
{chat.unread_count}
|
{chat.unread_count}
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="shrink-0 text-[11px] text-slate-400">
|
<span className="shrink-0 text-[11px] text-slate-400">
|
||||||
{formatChatListTime(chat.last_message_created_at)}
|
{formatChatListTime(chat.last_message_created_at)}
|
||||||
@@ -281,8 +291,17 @@ export function ChatList() {
|
|||||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setSettingsOpen(true); setMenuOpen(false); }}>
|
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setSettingsOpen(true); setMenuOpen(false); }}>
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
<button className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => setMenuOpen(false)}>
|
<button
|
||||||
More
|
className="block w-full rounded px-3 py-2 text-left text-sm text-red-300 hover:bg-slate-800"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
if (!window.confirm("Log out of your account?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { ChatList } from "../components/ChatList";
|
|||||||
import { ChatInfoPanel } from "../components/ChatInfoPanel";
|
import { ChatInfoPanel } from "../components/ChatInfoPanel";
|
||||||
import { MessageComposer } from "../components/MessageComposer";
|
import { MessageComposer } from "../components/MessageComposer";
|
||||||
import { MessageList } from "../components/MessageList";
|
import { MessageList } from "../components/MessageList";
|
||||||
import { getNotifications, type NotificationItem } from "../api/notifications";
|
|
||||||
import { searchMessages } from "../api/chats";
|
import { searchMessages } from "../api/chats";
|
||||||
import type { Message } from "../chat/types";
|
import type { Message } from "../chat/types";
|
||||||
import { useRealtime } from "../hooks/useRealtime";
|
import { useRealtime } from "../hooks/useRealtime";
|
||||||
@@ -14,7 +13,6 @@ import { useState } from "react";
|
|||||||
|
|
||||||
export function ChatsPage() {
|
export function ChatsPage() {
|
||||||
const me = useAuthStore((s) => s.me);
|
const me = useAuthStore((s) => s.me);
|
||||||
const logout = useAuthStore((s) => s.logout);
|
|
||||||
const loadChats = useChatStore((s) => s.loadChats);
|
const loadChats = useChatStore((s) => s.loadChats);
|
||||||
const activeChatId = useChatStore((s) => s.activeChatId);
|
const activeChatId = useChatStore((s) => s.activeChatId);
|
||||||
const chats = useChatStore((s) => s.chats);
|
const chats = useChatStore((s) => s.chats);
|
||||||
@@ -29,9 +27,6 @@ export function ChatsPage() {
|
|||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<Message[]>([]);
|
const [searchResults, setSearchResults] = useState<Message[]>([]);
|
||||||
const [searchActiveIndex, setSearchActiveIndex] = useState(0);
|
const [searchActiveIndex, setSearchActiveIndex] = useState(0);
|
||||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
|
||||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
|
||||||
const [loadingNotifications, setLoadingNotifications] = useState(false);
|
|
||||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const activeTrack = useAudioPlayerStore((s) => s.track);
|
const activeTrack = useAudioPlayerStore((s) => s.track);
|
||||||
const isAudioPlaying = useAudioPlayerStore((s) => s.isPlaying);
|
const isAudioPlaying = useAudioPlayerStore((s) => s.isPlaying);
|
||||||
@@ -139,33 +134,6 @@ export function ChatsPage() {
|
|||||||
setFocusedMessage(current.chat_id, current.id);
|
setFocusedMessage(current.chat_id, current.id);
|
||||||
}, [searchOpen, searchActiveIndex, searchResults, setFocusedMessage]);
|
}, [searchOpen, searchActiveIndex, searchResults, setFocusedMessage]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!notificationsOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
setLoadingNotifications(true);
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const items = await getNotifications(30);
|
|
||||||
if (!cancelled) {
|
|
||||||
setNotifications(items);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) {
|
|
||||||
setNotifications([]);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setLoadingNotifications(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [notificationsOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="h-screen w-full p-2 text-text md:p-3">
|
<main className="h-screen w-full p-2 text-text md:p-3">
|
||||||
<div className="mx-auto flex h-full max-w-[1500px] gap-2 md:gap-3">
|
<div className="mx-auto flex h-full max-w-[1500px] gap-2 md:gap-3">
|
||||||
@@ -192,15 +160,6 @@ export function ChatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
|
||||||
className="relative rounded-full bg-slate-700/70 px-2.5 py-1.5 text-xs font-semibold hover:bg-slate-600/80"
|
|
||||||
onClick={() => setNotificationsOpen(true)}
|
|
||||||
>
|
|
||||||
Notifications
|
|
||||||
{notifications.length > 0 ? (
|
|
||||||
<span className="ml-1 rounded-full bg-sky-500 px-1.5 py-0.5 text-[10px] text-slate-950">{notifications.length}</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className="rounded-full bg-slate-700/70 px-2.5 py-1.5 text-xs font-semibold hover:bg-slate-600/80"
|
className="rounded-full bg-slate-700/70 px-2.5 py-1.5 text-xs font-semibold hover:bg-slate-600/80"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -212,9 +171,6 @@ export function ChatsPage() {
|
|||||||
>
|
>
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
<button className="rounded-full bg-slate-700/70 px-2.5 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={logout}>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -311,26 +267,6 @@ export function ChatsPage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<ChatInfoPanel chatId={activeChatId} open={infoOpen} onClose={() => setInfoOpen(false)} />
|
<ChatInfoPanel chatId={activeChatId} open={infoOpen} onClose={() => setInfoOpen(false)} />
|
||||||
{notificationsOpen ? (
|
|
||||||
<div className="fixed inset-0 z-[130] flex items-start justify-center bg-slate-950/60 p-3 md:p-6" onClick={() => setNotificationsOpen(false)}>
|
|
||||||
<div className="w-full max-w-xl rounded-2xl border border-slate-700/80 bg-slate-900 p-3 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<p className="text-sm font-semibold">Notifications</p>
|
|
||||||
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={() => setNotificationsOpen(false)}>Close</button>
|
|
||||||
</div>
|
|
||||||
{loadingNotifications ? <p className="px-2 py-1 text-xs text-slate-400">Loading...</p> : null}
|
|
||||||
{!loadingNotifications && notifications.length === 0 ? <p className="px-2 py-1 text-xs text-slate-400">No notifications</p> : null}
|
|
||||||
<div className="tg-scrollbar max-h-[50vh] space-y-1 overflow-auto">
|
|
||||||
{notifications.map((item) => (
|
|
||||||
<div className="rounded-lg bg-slate-800/80 px-3 py-2" key={item.id}>
|
|
||||||
<p className="text-xs font-semibold text-slate-200">{item.event_type}</p>
|
|
||||||
<p className="text-[11px] text-slate-400">{new Date(item.created_at).toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/components/settingspanel.tsx","./src/components/toastviewport.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/audioplayerstore.ts","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/uistore.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/preferences.ts","./src/utils/ws.ts"],"version":"5.9.2"}
|
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/components/settingspanel.tsx","./src/components/toastviewport.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/audioplayerstore.ts","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/uistore.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/preferences.ts","./src/utils/webnotifications.ts","./src/utils/ws.ts"],"version":"5.9.2"}
|
||||||
Reference in New Issue
Block a user