feat(realtime): live online/offline events and unified search
Some checks failed
CI / test (push) Failing after 18s
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:
@@ -3,26 +3,30 @@ from redis.exceptions import RedisError
|
||||
from app.utils.redis_client import get_redis_client
|
||||
|
||||
|
||||
async def mark_user_online(user_id: int) -> None:
|
||||
async def mark_user_online(user_id: int) -> bool:
|
||||
try:
|
||||
redis = get_redis_client()
|
||||
key = f"presence:user:{user_id}"
|
||||
count = await redis.incr(key)
|
||||
if count == 1:
|
||||
await redis.expire(key, 3600)
|
||||
return True
|
||||
return False
|
||||
except RedisError:
|
||||
return
|
||||
return False
|
||||
|
||||
|
||||
async def mark_user_offline(user_id: int) -> None:
|
||||
async def mark_user_offline(user_id: int) -> bool:
|
||||
try:
|
||||
redis = get_redis_client()
|
||||
key = f"presence:user:{user_id}"
|
||||
value = await redis.decr(key)
|
||||
if value <= 0:
|
||||
await redis.delete(key)
|
||||
return True
|
||||
return False
|
||||
except RedisError:
|
||||
return
|
||||
return False
|
||||
|
||||
|
||||
async def is_user_online(user_id: int) -> bool:
|
||||
|
||||
@@ -15,6 +15,8 @@ RealtimeEventName = Literal[
|
||||
"typing_stop",
|
||||
"message_read",
|
||||
"message_delivered",
|
||||
"user_online",
|
||||
"user_offline",
|
||||
"error",
|
||||
]
|
||||
|
||||
|
||||
@@ -54,7 +54,9 @@ class RealtimeGateway:
|
||||
)
|
||||
for chat_id in user_chat_ids:
|
||||
self._chat_subscribers[chat_id].add(user_id)
|
||||
await mark_user_online(user_id)
|
||||
became_online = await mark_user_online(user_id)
|
||||
if became_online:
|
||||
await self._broadcast_presence(user_chat_ids, user_id=user_id, is_online=True, last_seen_at=None)
|
||||
await self._send_user_event(
|
||||
user_id,
|
||||
OutgoingRealtimeEvent(
|
||||
@@ -77,8 +79,15 @@ class RealtimeGateway:
|
||||
subscribers.discard(user_id)
|
||||
if not subscribers:
|
||||
self._chat_subscribers.pop(chat_id, None)
|
||||
await mark_user_offline(user_id)
|
||||
became_offline = await mark_user_offline(user_id)
|
||||
await self._persist_last_seen(user_id)
|
||||
if became_offline:
|
||||
await self._broadcast_presence(
|
||||
user_chat_ids,
|
||||
user_id=user_id,
|
||||
is_online=False,
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
async def handle_send_message(self, db: AsyncSession, user_id: int, payload: SendMessagePayload) -> None:
|
||||
message = await create_chat_message(
|
||||
@@ -195,12 +204,22 @@ class RealtimeGateway:
|
||||
user_connections.pop(connection_id, None)
|
||||
if not user_connections:
|
||||
self._connections.pop(user_id, None)
|
||||
affected_chat_ids: list[int] = []
|
||||
for chat_id, subscribers in list(self._chat_subscribers.items()):
|
||||
if user_id in subscribers:
|
||||
affected_chat_ids.append(chat_id)
|
||||
subscribers.discard(user_id)
|
||||
if not subscribers:
|
||||
self._chat_subscribers.pop(chat_id, None)
|
||||
await mark_user_offline(user_id)
|
||||
became_offline = await mark_user_offline(user_id)
|
||||
await self._persist_last_seen(user_id)
|
||||
if became_offline:
|
||||
await self._broadcast_presence(
|
||||
affected_chat_ids,
|
||||
user_id=user_id,
|
||||
is_online=False,
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_chat_id(channel: str) -> int | None:
|
||||
@@ -219,5 +238,24 @@ class RealtimeGateway:
|
||||
except Exception:
|
||||
return
|
||||
|
||||
async def _broadcast_presence(
|
||||
self,
|
||||
chat_ids: list[int],
|
||||
*,
|
||||
user_id: int,
|
||||
is_online: bool,
|
||||
last_seen_at: datetime | None,
|
||||
) -> None:
|
||||
event_name = "user_online" if is_online else "user_offline"
|
||||
for chat_id in chat_ids:
|
||||
payload = {
|
||||
"chat_id": chat_id,
|
||||
"user_id": user_id,
|
||||
"is_online": is_online,
|
||||
}
|
||||
if last_seen_at is not None:
|
||||
payload["last_seen_at"] = last_seen_at.isoformat()
|
||||
await self._publish_chat_event(chat_id, event=event_name, payload=payload)
|
||||
|
||||
|
||||
realtime_gateway = RealtimeGateway()
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -127,6 +127,19 @@ export function useRealtime() {
|
||||
chatStore.setMessageDeliveryStatusUpTo(chatId, maxId, "read", authStore.me?.id ?? -1);
|
||||
}
|
||||
}
|
||||
if (event.event === "user_online" || event.event === "user_offline") {
|
||||
const chatId = Number(event.payload.chat_id);
|
||||
const userId = Number(event.payload.user_id);
|
||||
const isOnline = Boolean(event.payload.is_online);
|
||||
const lastSeenAtRaw = event.payload.last_seen_at;
|
||||
const lastSeenAt = typeof lastSeenAtRaw === "string" ? lastSeenAtRaw : undefined;
|
||||
if (!Number.isFinite(chatId) || !Number.isFinite(userId)) {
|
||||
return;
|
||||
}
|
||||
if (userId !== authStore.me?.id) {
|
||||
chatStore.applyPresenceEvent(chatId, userId, isOnline, lastSeenAt);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ interface ChatState {
|
||||
setTypingUsers: (chatId: number, userIds: number[]) => void;
|
||||
setReplyToMessage: (chatId: number, message: Message | null) => void;
|
||||
updateChatPinnedMessage: (chatId: number, pinnedMessageId: number | null) => void;
|
||||
applyPresenceEvent: (chatId: number, userId: number, isOnline: boolean, lastSeenAt?: string) => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
@@ -244,5 +245,32 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
updateChatPinnedMessage: (chatId, pinnedMessageId) =>
|
||||
set((state) => ({
|
||||
chats: state.chats.map((chat) => (chat.id === chatId ? { ...chat, pinned_message_id: pinnedMessageId } : chat))
|
||||
})),
|
||||
applyPresenceEvent: (chatId, userId, isOnline, lastSeenAt) =>
|
||||
set((state) => ({
|
||||
chats: state.chats.map((chat) => {
|
||||
if (chat.id !== chatId) {
|
||||
return chat;
|
||||
}
|
||||
if (chat.type === "private" && chat.counterpart_user_id === userId) {
|
||||
return {
|
||||
...chat,
|
||||
counterpart_is_online: isOnline,
|
||||
counterpart_last_seen_at: isOnline ? chat.counterpart_last_seen_at : (lastSeenAt ?? new Date().toISOString())
|
||||
};
|
||||
}
|
||||
if (chat.type === "group") {
|
||||
const currentOnline = chat.online_count ?? 0;
|
||||
const membersCount = chat.members_count ?? currentOnline;
|
||||
const nextOnline = isOnline
|
||||
? Math.min(membersCount, currentOnline + 1)
|
||||
: Math.max(0, currentOnline - 1);
|
||||
return {
|
||||
...chat,
|
||||
online_count: nextOnline
|
||||
};
|
||||
}
|
||||
return chat;
|
||||
})
|
||||
}))
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user