feat(chat): add presence metadata and improve web chat core
Some checks failed
CI / test (push) Failing after 22s
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:
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -32,3 +32,18 @@ async def is_user_online(user_id: int) -> bool:
|
||||
return bool(value and str(value).isdigit() and int(value) > 0)
|
||||
except RedisError:
|
||||
return False
|
||||
|
||||
|
||||
async def get_users_online_map(user_ids: list[int]) -> dict[int, bool]:
|
||||
if not user_ids:
|
||||
return {}
|
||||
try:
|
||||
redis = get_redis_client()
|
||||
keys = [f"presence:user:{user_id}" for user_id in user_ids]
|
||||
values = await redis.mget(keys)
|
||||
return {
|
||||
user_id: bool(value and str(value).isdigit() and int(value) > 0)
|
||||
for user_id, value in zip(user_ids, values, strict=False)
|
||||
}
|
||||
except RedisError:
|
||||
return {user_id: False for user_id in user_ids}
|
||||
|
||||
@@ -9,12 +9,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.chats.repository import list_user_chat_ids
|
||||
from app.chats.service import ensure_chat_membership
|
||||
from app.database.session import AsyncSessionLocal
|
||||
from app.messages.schemas import MessageCreateRequest, MessageRead, MessageStatusUpdateRequest
|
||||
from app.messages.service import create_chat_message, mark_message_status
|
||||
from app.realtime.models import ConnectionContext
|
||||
from app.realtime.presence import mark_user_offline, mark_user_online
|
||||
from app.realtime.repository import RedisRealtimeRepository
|
||||
from app.realtime.schemas import ChatEventPayload, MessageStatusPayload, OutgoingRealtimeEvent, SendMessagePayload
|
||||
from app.users.repository import update_user_last_seen_now
|
||||
|
||||
|
||||
class RealtimeGateway:
|
||||
@@ -76,6 +78,7 @@ class RealtimeGateway:
|
||||
if not subscribers:
|
||||
self._chat_subscribers.pop(chat_id, None)
|
||||
await mark_user_offline(user_id)
|
||||
await self._persist_last_seen(user_id)
|
||||
|
||||
async def handle_send_message(self, db: AsyncSession, user_id: int, payload: SendMessagePayload) -> None:
|
||||
message = await create_chat_message(
|
||||
@@ -197,6 +200,7 @@ class RealtimeGateway:
|
||||
if not subscribers:
|
||||
self._chat_subscribers.pop(chat_id, None)
|
||||
await mark_user_offline(user_id)
|
||||
await self._persist_last_seen(user_id)
|
||||
|
||||
@staticmethod
|
||||
def _extract_chat_id(channel: str) -> int | None:
|
||||
@@ -207,5 +211,13 @@ class RealtimeGateway:
|
||||
return None
|
||||
return int(chat_id)
|
||||
|
||||
async def _persist_last_seen(self, user_id: int) -> None:
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await update_user_last_seen_now(db, user_id=user_id)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
realtime_gateway = RealtimeGateway()
|
||||
|
||||
@@ -30,6 +30,7 @@ class User(Base):
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
memberships: Mapped[list["ChatMember"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
sent_messages: Mapped[list["Message"]] = relationship(back_populates="sender")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -47,3 +49,12 @@ async def search_users_by_username(
|
||||
stmt = stmt.order_by(User.username.asc()).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_user_last_seen_now(db: AsyncSession, *, user_id: int) -> User | None:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return None
|
||||
user.last_seen_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
return user
|
||||
|
||||
Reference in New Issue
Block a user