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,11 +1,11 @@
import { FormEvent, useMemo, useState } from "react";
import { createChat, createPrivateChat, createPublicChat, discoverChats, getChats, getSavedMessagesChat, joinChat } from "../api/chats";
import { createChat, createPrivateChat, createPublicChat, getChats, getSavedMessagesChat } from "../api/chats";
import { searchUsers } from "../api/users";
import type { ChatType, DiscoverChat, UserSearchItem } from "../chat/types";
import type { ChatType, UserSearchItem } from "../chat/types";
import { useChatStore } from "../store/chatStore";
type CreateMode = "group" | "channel";
type DialogMode = "none" | "private" | "group" | "channel" | "discover";
type DialogMode = "none" | "private" | "group" | "channel";
export function NewChatPanel() {
const [dialog, setDialog] = useState<DialogMode>("none");
@@ -15,7 +15,6 @@ export function NewChatPanel() {
const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(false);
const [results, setResults] = useState<UserSearchItem[]>([]);
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
@@ -38,13 +37,6 @@ export function NewChatPanel() {
}
}
async function handleDiscover(value: string) {
setQuery(value);
setError(null);
const items = await discoverChats(value.trim() ? value : undefined);
setDiscoverResults(items);
}
async function refreshChatsAndSelect(chatId?: number) {
const chats = await getChats();
useChatStore.setState({ chats });
@@ -112,26 +104,11 @@ export function NewChatPanel() {
}
}
async function joinPublicChat(chatId: number) {
setLoading(true);
setError(null);
try {
const joined = await joinChat(chatId);
await refreshChatsAndSelect(joined.id);
setDialog("none");
} catch {
setError("Failed to join chat");
} finally {
setLoading(false);
}
}
function closeDialog() {
setDialog("none");
setError(null);
setQuery("");
setResults([]);
setDiscoverResults([]);
setIsPublic(false);
}
@@ -143,9 +120,6 @@ export function NewChatPanel() {
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => void openSavedMessages()}>
Saved Messages
</button>
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("discover"); setMenuOpen(false); }}>
Discover Chats
</button>
<button className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800" onClick={() => { setDialog("channel"); setMenuOpen(false); }}>
New Channel
</button>
@@ -167,7 +141,7 @@ export function NewChatPanel() {
<div className="w-full max-w-sm rounded-2xl border border-slate-700/80 bg-slate-900 p-3 shadow-2xl">
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold">
{dialog === "private" ? "New Message" : dialog === "group" ? "New Group" : dialog === "channel" ? "New Channel" : "Discover Chats"}
{dialog === "private" ? "New Message" : dialog === "group" ? "New Group" : "New Channel"}
</p>
<button className="rounded-md bg-slate-700/80 px-2 py-1 text-xs" onClick={closeDialog}>Close</button>
</div>
@@ -187,31 +161,6 @@ export function NewChatPanel() {
</div>
) : null}
{dialog === "discover" ? (
<div className="space-y-2">
<input className="w-full rounded-xl 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" placeholder="Search groups/channels by title or @handle" value={query} onChange={(e) => void handleDiscover(e.target.value)} />
<p className="text-xs text-slate-400">Search works only for public groups/channels.</p>
<div className="tg-scrollbar max-h-52 overflow-auto">
{discoverResults.map((chat) => (
<div className="mb-1 flex items-center justify-between rounded-lg bg-slate-800 px-3 py-2" key={chat.id}>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{chat.display_title || chat.title || (chat.type === "group" ? "Group" : "Channel")}</p>
<p className="truncate text-xs text-slate-400">{chat.handle ? `@${chat.handle}` : chat.type}</p>
</div>
{chat.is_member ? (
<span className="text-xs text-slate-400">joined</span>
) : (
<button className="rounded bg-sky-500 px-2 py-1 text-xs font-semibold text-slate-950" onClick={() => void joinPublicChat(chat.id)}>
Join
</button>
)}
</div>
))}
{discoverResults.length === 0 ? <p className="text-xs text-slate-400">No public chats</p> : null}
</div>
</div>
) : null}
{dialog === "group" || dialog === "channel" ? (
<form className="space-y-2" onSubmit={(e) => void createByType(e, dialog)}>
<input className="w-full rounded-xl 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" placeholder={dialog === "group" ? "Group title" : "Channel title"} value={title} onChange={(e) => setTitle(e.target.value)} />