diff --git a/app/realtime/presence.py b/app/realtime/presence.py index 9d20f7b..13e3677 100644 --- a/app/realtime/presence.py +++ b/app/realtime/presence.py @@ -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: diff --git a/app/realtime/schemas.py b/app/realtime/schemas.py index e865830..4ffb6b2 100644 --- a/app/realtime/schemas.py +++ b/app/realtime/schemas.py @@ -15,6 +15,8 @@ RealtimeEventName = Literal[ "typing_stop", "message_read", "message_delivered", + "user_online", + "user_offline", "error", ] diff --git a/app/realtime/service.py b/app/realtime/service.py index 945b22d..3594fc8 100644 --- a/app/realtime/service.py +++ b/app/realtime/service.py @@ -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() diff --git a/web/src/components/ChatList.tsx b/web/src/components/ChatList.tsx index 722e7d2..f97f6b0 100644 --- a/web/src/components/ChatList.tsx +++ b/web/src/components/ChatList.tsx @@ -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([]); + const [discoverResults, setDiscoverResults] = useState([]); + const [searchLoading, setSearchLoading] = useState(false); const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("all"); const [ctxChatId, setCtxChatId] = useState(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() { ))} + {search.trim().replace("@", "").length >= 2 ? ( +
+ {searchLoading ?

Searching...

: null} + {!searchLoading && userResults.length === 0 && discoverResults.length === 0 ? ( +

Nothing found

+ ) : null} + {userResults.length > 0 ? ( +
+

People

+ {userResults.slice(0, 5).map((user) => ( + + ))} +
+ ) : null} + {discoverResults.length > 0 ? ( +
+

Groups and Channels

+ {discoverResults.slice(0, 5).map((chat) => ( + + ))} +
+ ) : null} +
+ ) : null}
{filteredChats.map((chat) => ( diff --git a/web/src/components/NewChatPanel.tsx b/web/src/components/NewChatPanel.tsx index 104fafa..054f78b 100644 --- a/web/src/components/NewChatPanel.tsx +++ b/web/src/components/NewChatPanel.tsx @@ -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("none"); @@ -15,7 +15,6 @@ export function NewChatPanel() { const [description, setDescription] = useState(""); const [isPublic, setIsPublic] = useState(false); const [results, setResults] = useState([]); - const [discoverResults, setDiscoverResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(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() { - @@ -167,7 +141,7 @@ export function NewChatPanel() {

- {dialog === "private" ? "New Message" : dialog === "group" ? "New Group" : dialog === "channel" ? "New Channel" : "Discover Chats"} + {dialog === "private" ? "New Message" : dialog === "group" ? "New Group" : "New Channel"}

@@ -187,31 +161,6 @@ export function NewChatPanel() {
) : null} - {dialog === "discover" ? ( -
- void handleDiscover(e.target.value)} /> -

Search works only for public groups/channels.

-
- {discoverResults.map((chat) => ( -
-
-

{chat.display_title || chat.title || (chat.type === "group" ? "Group" : "Channel")}

-

{chat.handle ? `@${chat.handle}` : chat.type}

-
- {chat.is_member ? ( - joined - ) : ( - - )} -
- ))} - {discoverResults.length === 0 ?

No public chats

: null} -
-
- ) : null} - {dialog === "group" || dialog === "channel" ? (
void createByType(e, dialog)}> setTitle(e.target.value)} /> diff --git a/web/src/hooks/useRealtime.ts b/web/src/hooks/useRealtime.ts index 11275ad..444da73 100644 --- a/web/src/hooks/useRealtime.ts +++ b/web/src/hooks/useRealtime.ts @@ -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 = () => { diff --git a/web/src/store/chatStore.ts b/web/src/store/chatStore.ts index d8c8a2f..c0b5f31 100644 --- a/web/src/store/chatStore.ts +++ b/web/src/store/chatStore.ts @@ -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((set, get) => ({ @@ -244,5 +245,32 @@ export const useChatStore = create((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; + }) })) }));