feat(chat): add presence metadata and improve web chat core
Some checks failed
CI / test (push) Failing after 22s

- add user last_seen_at with alembic migration and persist on realtime disconnect
- extend chat serialization with private online/last_seen, group members/online, channel subscribers
- add Redis batch presence lookup helper
- update web chat list/header to display status counters and last-seen labels
- improve delivery receipt handling using last_delivered/last_read boundaries
- include chat info panel and related API/type updates
This commit is contained in:
2026-03-08 02:02:09 +03:00
parent 51275692ac
commit e6a271f8be
17 changed files with 564 additions and 6 deletions

View File

@@ -105,6 +105,11 @@ async def list_chat_members(db: AsyncSession, *, chat_id: int) -> list[ChatMembe
return list(result.scalars().all())
async def list_chat_member_user_ids(db: AsyncSession, *, chat_id: int) -> list[int]:
result = await db.execute(select(ChatMember.user_id).where(ChatMember.chat_id == chat_id))
return list(result.scalars().all())
async def list_user_chat_ids(db: AsyncSession, *, user_id: int) -> list[int]:
result = await db.execute(
select(ChatMember.chat_id).where(ChatMember.user_id == user_id).order_by(ChatMember.chat_id.asc())

View File

@@ -18,6 +18,14 @@ class ChatRead(BaseModel):
is_saved: bool = False
unread_count: int = 0
pinned_message_id: int | None = None
members_count: int | None = None
online_count: int | None = None
subscribers_count: int | None = None
counterpart_user_id: int | None = None
counterpart_name: str | None = None
counterpart_username: str | None = None
counterpart_is_online: bool | None = None
counterpart_last_seen_at: datetime | None = None
created_at: datetime

View File

@@ -12,19 +12,41 @@ from app.messages.repository import (
hide_message_for_user,
list_chat_message_ids,
)
from app.realtime.presence import get_users_online_map
from app.users.repository import get_user_by_id
async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) -> ChatRead:
display_title = chat.title
members_count: int | None = None
online_count: int | None = None
subscribers_count: int | None = None
counterpart_user_id: int | None = None
counterpart_name: str | None = None
counterpart_username: str | None = None
counterpart_is_online: bool | None = None
counterpart_last_seen_at = None
if chat.is_saved:
display_title = "Saved Messages"
elif chat.type == ChatType.PRIVATE:
counterpart_id = await repository.get_private_counterpart_user_id(db, chat_id=chat.id, user_id=user_id)
if counterpart_id:
counterpart_user_id = counterpart_id
counterpart = await get_user_by_id(db, counterpart_id)
if counterpart:
display_title = counterpart.name or counterpart.username
counterpart_name = counterpart.name
counterpart_username = counterpart.username
counterpart_last_seen_at = counterpart.last_seen_at
presence = await get_users_online_map([counterpart_id])
counterpart_is_online = presence.get(counterpart_id, False)
else:
member_ids = await repository.list_chat_member_user_ids(db, chat_id=chat.id)
members_count = len(member_ids)
online_presence = await get_users_online_map(member_ids)
online_count = sum(1 for is_online in online_presence.values() if is_online)
if chat.type == ChatType.CHANNEL:
subscribers_count = members_count
unread_count = await repository.get_unread_count_for_chat(db, chat_id=chat.id, user_id=user_id)
@@ -40,6 +62,14 @@ async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat)
"is_saved": chat.is_saved,
"unread_count": unread_count,
"pinned_message_id": chat.pinned_message_id,
"members_count": members_count,
"online_count": online_count,
"subscribers_count": subscribers_count,
"counterpart_user_id": counterpart_user_id,
"counterpart_name": counterpart_name,
"counterpart_username": counterpart_username,
"counterpart_is_online": counterpart_is_online,
"counterpart_last_seen_at": counterpart_last_seen_at,
"created_at": chat.created_at,
}
)