feat(realtime): live online/offline events and unified search
Some checks failed
CI / test (push) Failing after 18s

- add websocket events user_online/user_offline
- broadcast presence changes on first connect and final disconnect only
- apply live presence updates in web chat store and realtime hook
- move public discover into unified left search (users + groups/channels)
- remove separate Discover Chats dialog/menu entry
This commit is contained in:
2026-03-08 02:12:11 +03:00
parent afeb0acbe7
commit 46dc601c84
7 changed files with 193 additions and 63 deletions

View File

@@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { clearChat, deleteChat } from "../api/chats";
import { clearChat, createPrivateChat, discoverChats, deleteChat, getChats, joinChat } from "../api/chats";
import { searchUsers } from "../api/users";
import type { DiscoverChat, UserSearchItem } from "../chat/types";
import { updateMyProfile } from "../api/users";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
@@ -14,6 +16,9 @@ export function ChatList() {
const loadChats = useChatStore((s) => s.loadChats);
const me = useAuthStore((s) => s.me);
const [search, setSearch] = useState("");
const [userResults, setUserResults] = useState<UserSearchItem[]>([]);
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("all");
const [ctxChatId, setCtxChatId] = useState<number | null>(null);
const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null);
@@ -40,6 +45,40 @@ export function ChatList() {
return () => clearTimeout(timer);
}, [search, loadChats]);
useEffect(() => {
const term = search.trim();
if (term.replace("@", "").length < 2) {
setUserResults([]);
setDiscoverResults([]);
setSearchLoading(false);
return;
}
let cancelled = false;
setSearchLoading(true);
void (async () => {
try {
const [users, publicChats] = await Promise.all([searchUsers(term), discoverChats(term)]);
if (cancelled) {
return;
}
setUserResults(users);
setDiscoverResults(publicChats);
} catch {
if (!cancelled) {
setUserResults([]);
setDiscoverResults([]);
}
} finally {
if (!cancelled) {
setSearchLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [search]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") {
@@ -112,6 +151,63 @@ export function ChatList() {
</button>
))}
</div>
{search.trim().replace("@", "").length >= 2 ? (
<div className="mt-3 rounded-xl border border-slate-700/70 bg-slate-900/70 p-2">
{searchLoading ? <p className="px-2 py-1 text-xs text-slate-400">Searching...</p> : null}
{!searchLoading && userResults.length === 0 && discoverResults.length === 0 ? (
<p className="px-2 py-1 text-xs text-slate-400">Nothing found</p>
) : null}
{userResults.length > 0 ? (
<div className="mb-1">
<p className="px-2 py-1 text-[10px] uppercase tracking-wide text-slate-400">People</p>
{userResults.slice(0, 5).map((user) => (
<button
className="block w-full rounded-lg px-2 py-1.5 text-left hover:bg-slate-800"
key={`user-${user.id}`}
onClick={async () => {
const chat = await createPrivateChat(user.id);
const updatedChats = await getChats();
useChatStore.setState({ chats: updatedChats, activeChatId: chat.id });
setSearch("");
setUserResults([]);
setDiscoverResults([]);
}}
>
<p className="truncate text-xs font-semibold">{user.name}</p>
<p className="truncate text-[11px] text-slate-400">@{user.username}</p>
</button>
))}
</div>
) : null}
{discoverResults.length > 0 ? (
<div>
<p className="px-2 py-1 text-[10px] uppercase tracking-wide text-slate-400">Groups and Channels</p>
{discoverResults.slice(0, 5).map((chat) => (
<button
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-slate-800"
key={`discover-${chat.id}`}
onClick={async () => {
if (!chat.is_member) {
await joinChat(chat.id);
}
const updatedChats = await getChats();
useChatStore.setState({ chats: updatedChats, activeChatId: chat.id });
setSearch("");
setUserResults([]);
setDiscoverResults([]);
}}
>
<div className="min-w-0">
<p className="truncate text-xs font-semibold">{chat.display_title || chat.title || (chat.type === "group" ? "Group" : "Channel")}</p>
<p className="truncate text-[11px] text-slate-400">{chat.handle ? `@${chat.handle}` : chat.type}</p>
</div>
<span className="ml-2 shrink-0 text-[10px] text-slate-400">{chat.is_member ? "open" : "join"}</span>
</button>
))}
</div>
) : null}
</div>
) : null}
</div>
<div className="tg-scrollbar flex-1 overflow-auto">
{filteredChats.map((chat) => (