diff --git a/alembic/versions/0008_user_last_seen_presence.py b/alembic/versions/0008_user_last_seen_presence.py new file mode 100644 index 0000000..638a938 --- /dev/null +++ b/alembic/versions/0008_user_last_seen_presence.py @@ -0,0 +1,27 @@ +"""add users.last_seen_at for presence metadata + +Revision ID: 0008_user_last_seen_presence +Revises: 0007_message_hidden_table +Create Date: 2026-03-08 12:10:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0008_user_last_seen_presence" +down_revision: Union[str, Sequence[str], None] = "0007_message_hidden_table" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index(op.f("ix_users_last_seen_at"), "users", ["last_seen_at"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_users_last_seen_at"), table_name="users") + op.drop_column("users", "last_seen_at") diff --git a/app/chats/repository.py b/app/chats/repository.py index 25d13ad..01040b6 100644 --- a/app/chats/repository.py +++ b/app/chats/repository.py @@ -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()) diff --git a/app/chats/schemas.py b/app/chats/schemas.py index e5b3343..c01b9c9 100644 --- a/app/chats/schemas.py +++ b/app/chats/schemas.py @@ -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 diff --git a/app/chats/service.py b/app/chats/service.py index 9a2682a..1d70fb6 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -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, } ) diff --git a/app/realtime/presence.py b/app/realtime/presence.py index b6a6bcc..9d20f7b 100644 --- a/app/realtime/presence.py +++ b/app/realtime/presence.py @@ -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} diff --git a/app/realtime/service.py b/app/realtime/service.py index 9ce0654..945b22d 100644 --- a/app/realtime/service.py +++ b/app/realtime/service.py @@ -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() diff --git a/app/users/models.py b/app/users/models.py index a037c73..53806ac 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -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") diff --git a/app/users/repository.py b/app/users/repository.py index f243fde..0a9329c 100644 --- a/app/users/repository.py +++ b/app/users/repository.py @@ -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 diff --git a/web/src/api/chats.ts b/web/src/api/chats.ts index bc73706..510975e 100644 --- a/web/src/api/chats.ts +++ b/web/src/api/chats.ts @@ -1,5 +1,5 @@ import { http } from "./http"; -import type { Chat, ChatType, DiscoverChat, Message, MessageType } from "../chat/types"; +import type { Chat, ChatDetail, ChatMember, ChatMemberRole, ChatType, DiscoverChat, Message, MessageType } from "../chat/types"; import axios from "axios"; export async function getChats(query?: string): Promise { @@ -174,3 +174,36 @@ export async function getSavedMessagesChat(): Promise { const { data } = await http.get("/chats/saved"); return data; } + +export async function getChatDetail(chatId: number): Promise { + const { data } = await http.get(`/chats/${chatId}`); + return data; +} + +export async function listChatMembers(chatId: number): Promise { + const { data } = await http.get(`/chats/${chatId}/members`); + return data; +} + +export async function updateChatTitle(chatId: number, title: string): Promise { + const { data } = await http.patch(`/chats/${chatId}/title`, { title }); + return data; +} + +export async function addChatMember(chatId: number, userId: number): Promise { + const { data } = await http.post(`/chats/${chatId}/members`, { user_id: userId }); + return data; +} + +export async function updateChatMemberRole(chatId: number, userId: number, role: ChatMemberRole): Promise { + const { data } = await http.patch(`/chats/${chatId}/members/${userId}/role`, { role }); + return data; +} + +export async function removeChatMember(chatId: number, userId: number): Promise { + await http.delete(`/chats/${chatId}/members/${userId}`); +} + +export async function leaveChat(chatId: number): Promise { + await http.post(`/chats/${chatId}/leave`); +} diff --git a/web/src/api/users.ts b/web/src/api/users.ts index 8f40573..f3606d1 100644 --- a/web/src/api/users.ts +++ b/web/src/api/users.ts @@ -19,3 +19,8 @@ export async function updateMyProfile(payload: UserProfileUpdatePayload): Promis const { data } = await http.put("/users/profile", payload); return data; } + +export async function getUserById(userId: number): Promise { + const { data } = await http.get(`/users/${userId}`); + return data; +} diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 2133212..9ad10bc 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -13,6 +13,14 @@ export interface Chat { is_saved?: boolean; unread_count?: number; pinned_message_id?: number | null; + members_count?: number | null; + online_count?: number | null; + subscribers_count?: number | null; + counterpart_user_id?: number | null; + counterpart_name?: string | null; + counterpart_username?: string | null; + counterpart_is_online?: boolean | null; + counterpart_last_seen_at?: string | null; created_at: string; } @@ -20,6 +28,19 @@ export interface DiscoverChat extends Chat { is_member: boolean; } +export type ChatMemberRole = "owner" | "admin" | "member"; + +export interface ChatMember { + id: number; + user_id: number; + role: ChatMemberRole; + joined_at: string; +} + +export interface ChatDetail extends Chat { + members: ChatMember[]; +} + export interface Message { id: number; chat_id: number; diff --git a/web/src/components/ChatInfoPanel.tsx b/web/src/components/ChatInfoPanel.tsx new file mode 100644 index 0000000..422901d --- /dev/null +++ b/web/src/components/ChatInfoPanel.tsx @@ -0,0 +1,260 @@ +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { + addChatMember, + getChatDetail, + leaveChat, + listChatMembers, + removeChatMember, + updateChatMemberRole, + updateChatTitle +} from "../api/chats"; +import { getUserById, searchUsers } from "../api/users"; +import type { AuthUser, ChatDetail, ChatMember, UserSearchItem } from "../chat/types"; +import { useAuthStore } from "../store/authStore"; +import { useChatStore } from "../store/chatStore"; + +interface Props { + chatId: number | null; + open: boolean; + onClose: () => void; +} + +export function ChatInfoPanel({ chatId, open, onClose }: Props) { + const me = useAuthStore((s) => s.me); + const loadChats = useChatStore((s) => s.loadChats); + const setActiveChatId = useChatStore((s) => s.setActiveChatId); + const [chat, setChat] = useState(null); + const [members, setMembers] = useState([]); + const [memberUsers, setMemberUsers] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [titleDraft, setTitleDraft] = useState(""); + const [savingTitle, setSavingTitle] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + + const myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]); + const isGroupLike = chat?.type === "group" || chat?.type === "channel"; + const canManageMembers = Boolean(isGroupLike && (myRole === "owner" || myRole === "admin")); + const canChangeRoles = Boolean(isGroupLike && myRole === "owner"); + + async function refreshMembers(targetChatId: number) { + const nextMembers = await listChatMembers(targetChatId); + setMembers(nextMembers); + const ids = [...new Set(nextMembers.map((m) => m.user_id))]; + const profiles = await Promise.all(ids.map((id) => getUserById(id))); + const byId: Record = {}; + for (const profile of profiles) { + byId[profile.id] = profile; + } + setMemberUsers(byId); + } + + useEffect(() => { + if (!open || !chatId) { + return; + } + let cancelled = false; + setLoading(true); + setError(null); + void (async () => { + try { + const detail = await getChatDetail(chatId); + if (cancelled) return; + setChat(detail); + setTitleDraft(detail.title ?? ""); + await refreshMembers(chatId); + } catch { + if (!cancelled) setError("Failed to load chat info"); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [open, chatId]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + if (open) { + window.addEventListener("keydown", onKeyDown); + } + return () => window.removeEventListener("keydown", onKeyDown); + }, [open, onClose]); + + if (!open || !chatId) { + return null; + } + + return createPortal( +
+ +
, + document.body + ); +} diff --git a/web/src/components/ChatList.tsx b/web/src/components/ChatList.tsx index 1694549..722e7d2 100644 --- a/web/src/components/ChatList.tsx +++ b/web/src/components/ChatList.tsx @@ -145,7 +145,7 @@ export function ChatList() { )} -

{chat.type}

+

{chatMetaLabel(chat)}

@@ -283,3 +283,46 @@ function chatLabel(chat: { display_title?: string | null; title: string | null; if (chat.type === "group") return "Group"; return "Channel"; } + +function chatMetaLabel(chat: { + type: "private" | "group" | "channel"; + is_saved?: boolean; + counterpart_is_online?: boolean | null; + counterpart_last_seen_at?: string | null; + members_count?: number | null; + online_count?: number | null; + subscribers_count?: number | null; +}): string { + if (chat.is_saved) { + return "Personal cloud chat"; + } + if (chat.type === "private") { + if (chat.counterpart_is_online) { + return "online"; + } + if (chat.counterpart_last_seen_at) { + return `last seen ${formatLastSeen(chat.counterpart_last_seen_at)}`; + } + return "offline"; + } + if (chat.type === "group") { + const members = chat.members_count ?? 0; + const online = chat.online_count ?? 0; + return `${members} members, ${online} online`; + } + const subscribers = chat.subscribers_count ?? chat.members_count ?? 0; + return `${subscribers} subscribers`; +} + +function formatLastSeen(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "recently"; + } + return date.toLocaleString(undefined, { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit" + }); +} diff --git a/web/src/hooks/useRealtime.ts b/web/src/hooks/useRealtime.ts index 120a555..11275ad 100644 --- a/web/src/hooks/useRealtime.ts +++ b/web/src/hooks/useRealtime.ts @@ -104,23 +104,27 @@ export function useRealtime() { if (event.event === "message_delivered") { const chatId = Number(event.payload.chat_id); const messageId = Number(event.payload.message_id); + const lastDeliveredMessageId = Number(event.payload.last_delivered_message_id); const userId = Number(event.payload.user_id); if (!Number.isFinite(chatId) || !Number.isFinite(messageId) || !Number.isFinite(userId)) { return; } if (userId !== authStore.me?.id) { - chatStore.setMessageDeliveryStatus(chatId, messageId, "delivered"); + const maxId = Number.isFinite(lastDeliveredMessageId) ? lastDeliveredMessageId : messageId; + chatStore.setMessageDeliveryStatusUpTo(chatId, maxId, "delivered", authStore.me?.id ?? -1); } } if (event.event === "message_read") { const chatId = Number(event.payload.chat_id); const messageId = Number(event.payload.message_id); + const lastReadMessageId = Number(event.payload.last_read_message_id); const userId = Number(event.payload.user_id); if (!Number.isFinite(chatId) || !Number.isFinite(messageId) || !Number.isFinite(userId)) { return; } if (userId !== authStore.me?.id) { - chatStore.setMessageDeliveryStatus(chatId, messageId, "read"); + const maxId = Number.isFinite(lastReadMessageId) ? lastReadMessageId : messageId; + chatStore.setMessageDeliveryStatusUpTo(chatId, maxId, "read", authStore.me?.id ?? -1); } } }; diff --git a/web/src/pages/ChatsPage.tsx b/web/src/pages/ChatsPage.tsx index a6ee7d1..34e187f 100644 --- a/web/src/pages/ChatsPage.tsx +++ b/web/src/pages/ChatsPage.tsx @@ -1,10 +1,12 @@ import { useEffect } from "react"; import { ChatList } from "../components/ChatList"; +import { ChatInfoPanel } from "../components/ChatInfoPanel"; import { MessageComposer } from "../components/MessageComposer"; import { MessageList } from "../components/MessageList"; import { useRealtime } from "../hooks/useRealtime"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; +import { useState } from "react"; export function ChatsPage() { const me = useAuthStore((s) => s.me); @@ -15,6 +17,7 @@ export function ChatsPage() { const setActiveChatId = useChatStore((s) => s.setActiveChatId); const loadMessages = useChatStore((s) => s.loadMessages); const activeChat = chats.find((chat) => chat.id === activeChatId); + const [infoOpen, setInfoOpen] = useState(false); useRealtime(); @@ -41,9 +44,14 @@ export function ChatsPage() { + {activeChatId ? ( + + ) : null}

{activeChat?.display_title || activeChat?.title || me?.name || `@${me?.username}`}

-

{activeChat ? activeChat.type : "Select a chat"}

+

{activeChat ? headerMetaLabel(activeChat) : "Select a chat"}