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:
27
alembic/versions/0008_user_last_seen_presence.py
Normal file
27
alembic/versions/0008_user_last_seen_presence.py
Normal file
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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<Chat[]> {
|
||||
@@ -174,3 +174,36 @@ export async function getSavedMessagesChat(): Promise<Chat> {
|
||||
const { data } = await http.get<Chat>("/chats/saved");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getChatDetail(chatId: number): Promise<ChatDetail> {
|
||||
const { data } = await http.get<ChatDetail>(`/chats/${chatId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listChatMembers(chatId: number): Promise<ChatMember[]> {
|
||||
const { data } = await http.get<ChatMember[]>(`/chats/${chatId}/members`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateChatTitle(chatId: number, title: string): Promise<Chat> {
|
||||
const { data } = await http.patch<Chat>(`/chats/${chatId}/title`, { title });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function addChatMember(chatId: number, userId: number): Promise<ChatMember> {
|
||||
const { data } = await http.post<ChatMember>(`/chats/${chatId}/members`, { user_id: userId });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateChatMemberRole(chatId: number, userId: number, role: ChatMemberRole): Promise<ChatMember> {
|
||||
const { data } = await http.patch<ChatMember>(`/chats/${chatId}/members/${userId}/role`, { role });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function removeChatMember(chatId: number, userId: number): Promise<void> {
|
||||
await http.delete(`/chats/${chatId}/members/${userId}`);
|
||||
}
|
||||
|
||||
export async function leaveChat(chatId: number): Promise<void> {
|
||||
await http.post(`/chats/${chatId}/leave`);
|
||||
}
|
||||
|
||||
@@ -19,3 +19,8 @@ export async function updateMyProfile(payload: UserProfileUpdatePayload): Promis
|
||||
const { data } = await http.put<AuthUser>("/users/profile", payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUserById(userId: number): Promise<AuthUser> {
|
||||
const { data } = await http.get<AuthUser>(`/users/${userId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
260
web/src/components/ChatInfoPanel.tsx
Normal file
260
web/src/components/ChatInfoPanel.tsx
Normal file
@@ -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<ChatDetail | null>(null);
|
||||
const [members, setMembers] = useState<ChatMember[]>([]);
|
||||
const [memberUsers, setMemberUsers] = useState<Record<number, AuthUser>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const [savingTitle, setSavingTitle] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<UserSearchItem[]>([]);
|
||||
|
||||
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<number, AuthUser> = {};
|
||||
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(
|
||||
<div className="fixed inset-0 z-[120] bg-slate-950/55" onClick={onClose}>
|
||||
<aside className="absolute right-0 top-0 h-full w-full max-w-sm border-l border-slate-700/70 bg-slate-900/95 p-4 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">Chat info</p>
|
||||
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="text-sm text-slate-300">Loading...</p> : null}
|
||||
{error ? <p className="text-sm text-red-400">{error}</p> : null}
|
||||
|
||||
{chat ? (
|
||||
<>
|
||||
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
|
||||
<p className="text-xs text-slate-400">Type</p>
|
||||
<p className="text-sm">{chat.type}</p>
|
||||
<p className="mt-2 text-xs text-slate-400">Title</p>
|
||||
<input
|
||||
className="mt-1 w-full rounded bg-slate-800 px-3 py-2 text-sm outline-none"
|
||||
disabled={!isGroupLike}
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
/>
|
||||
{isGroupLike ? (
|
||||
<button
|
||||
className="mt-2 w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60"
|
||||
disabled={savingTitle || !titleDraft.trim()}
|
||||
onClick={async () => {
|
||||
setSavingTitle(true);
|
||||
try {
|
||||
const updated = await updateChatTitle(chatId, titleDraft.trim());
|
||||
setChat((prev) => (prev ? { ...prev, ...updated } : prev));
|
||||
await loadChats();
|
||||
} catch {
|
||||
setError("Failed to update title");
|
||||
} finally {
|
||||
setSavingTitle(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save title
|
||||
</button>
|
||||
) : null}
|
||||
{chat.handle ? <p className="mt-2 text-xs text-slate-300">@{chat.handle}</p> : null}
|
||||
{chat.description ? <p className="mt-1 text-xs text-slate-400">{chat.description}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">Members ({members.length})</p>
|
||||
<div className="tg-scrollbar max-h-56 space-y-2 overflow-auto">
|
||||
{members.map((member) => {
|
||||
const user = memberUsers[member.user_id];
|
||||
return (
|
||||
<div className="rounded border border-slate-700/60 bg-slate-900/60 p-2" key={member.id}>
|
||||
<p className="truncate text-sm font-semibold">{user?.name || `user #${member.user_id}`}</p>
|
||||
<p className="truncate text-xs text-slate-400">@{user?.username || "unknown"}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<select
|
||||
className="flex-1 rounded bg-slate-800 px-2 py-1 text-xs"
|
||||
disabled={!canChangeRoles || member.user_id === me?.id}
|
||||
value={member.role}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
await updateChatMemberRole(chatId, member.user_id, e.target.value as ChatMember["role"]);
|
||||
await refreshMembers(chatId);
|
||||
} catch {
|
||||
setError("Failed to update role");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="member">member</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="owner">owner</option>
|
||||
</select>
|
||||
{canManageMembers && member.user_id !== me?.id ? (
|
||||
<button
|
||||
className="rounded bg-red-500 px-2 py-1 text-xs font-semibold text-white"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await removeChatMember(chatId, member.user_id);
|
||||
await refreshMembers(chatId);
|
||||
} catch {
|
||||
setError("Failed to remove member");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManageMembers ? (
|
||||
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">Add member</p>
|
||||
<input
|
||||
className="mb-2 w-full rounded bg-slate-800 px-3 py-2 text-sm outline-none"
|
||||
placeholder="@username"
|
||||
value={searchQuery}
|
||||
onChange={async (e) => {
|
||||
const value = e.target.value;
|
||||
setSearchQuery(value);
|
||||
if (value.trim().replace("@", "").length < 2) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const users = await searchUsers(value);
|
||||
setSearchResults(users.filter((u) => !members.some((m) => m.user_id === u.id)));
|
||||
} catch {
|
||||
setError("Failed to search users");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="tg-scrollbar max-h-40 space-y-1 overflow-auto">
|
||||
{searchResults.map((user) => (
|
||||
<button
|
||||
className="block w-full rounded bg-slate-900/70 px-3 py-2 text-left text-sm hover:bg-slate-700"
|
||||
key={user.id}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await addChatMember(chatId, user.id);
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
await refreshMembers(chatId);
|
||||
} catch {
|
||||
setError("Failed to add member");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="truncate font-semibold">{user.name}</p>
|
||||
<p className="truncate text-xs text-slate-400">@{user.username}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{chat.type === "group" || chat.type === "channel" ? (
|
||||
<button
|
||||
className="w-full rounded bg-slate-700 px-3 py-2 text-sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await leaveChat(chatId);
|
||||
await loadChats();
|
||||
setActiveChatId(null);
|
||||
onClose();
|
||||
} catch {
|
||||
setError("Failed to leave chat");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Leave chat
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -145,7 +145,7 @@ export function ChatList() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-slate-400">{chat.type}</p>
|
||||
<p className="truncate text-xs text-slate-400">{chatMetaLabel(chat)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
<button className="rounded-full bg-slate-700/70 px-2 py-1 text-xs md:hidden" onClick={() => setActiveChatId(null)}>
|
||||
Back
|
||||
</button>
|
||||
{activeChatId ? (
|
||||
<button className="rounded-full bg-slate-700/70 px-2 py-1 text-xs" onClick={() => setInfoOpen(true)}>
|
||||
Info
|
||||
</button>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{activeChat?.display_title || activeChat?.title || me?.name || `@${me?.username}`}</p>
|
||||
<p className="truncate text-xs text-slate-300/80">{activeChat ? activeChat.type : "Select a chat"}</p>
|
||||
<p className="truncate text-xs text-slate-300/80">{activeChat ? headerMetaLabel(activeChat) : "Select a chat"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={logout}>
|
||||
@@ -62,6 +70,50 @@ export function ChatsPage() {
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<ChatInfoPanel chatId={activeChatId} open={infoOpen} onClose={() => setInfoOpen(false)} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function headerMetaLabel(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 "Saved Messages";
|
||||
}
|
||||
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"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ interface ChatState {
|
||||
confirmMessageByClientId: (chatId: number, clientMessageId: string, message: Message) => void;
|
||||
removeOptimisticMessage: (chatId: number, clientMessageId: string) => void;
|
||||
setMessageDeliveryStatus: (chatId: number, messageId: number, status: DeliveryStatus) => void;
|
||||
setMessageDeliveryStatusUpTo: (
|
||||
chatId: number,
|
||||
maxMessageId: number,
|
||||
status: DeliveryStatus,
|
||||
senderId: number
|
||||
) => void;
|
||||
removeMessage: (chatId: number, messageId: number) => void;
|
||||
restoreMessages: (chatId: number, messages: Message[]) => void;
|
||||
clearChatMessages: (chatId: number) => void;
|
||||
@@ -151,6 +157,31 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
messagesByChat: { ...state.messagesByChat, [chatId]: next }
|
||||
}));
|
||||
},
|
||||
setMessageDeliveryStatusUpTo: (chatId, maxMessageId, status, senderId) => {
|
||||
const old = get().messagesByChat[chatId] ?? [];
|
||||
if (!old.length) {
|
||||
return;
|
||||
}
|
||||
const order: Record<DeliveryStatus, number> = { sending: 1, sent: 2, delivered: 3, read: 4 };
|
||||
let changed = false;
|
||||
const next = old.map((message) => {
|
||||
if (message.sender_id !== senderId || message.id <= 0 || message.id > maxMessageId) {
|
||||
return message;
|
||||
}
|
||||
const currentStatus = message.delivery_status ?? "sent";
|
||||
if (order[status] <= order[currentStatus]) {
|
||||
return message;
|
||||
}
|
||||
changed = true;
|
||||
return { ...message, delivery_status: status, is_pending: false };
|
||||
});
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
set((state) => ({
|
||||
messagesByChat: { ...state.messagesByChat, [chatId]: next }
|
||||
}));
|
||||
},
|
||||
removeMessage: (chatId, messageId) => {
|
||||
const old = get().messagesByChat[chatId] ?? [];
|
||||
set((state) => ({
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/authpanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/authstore.ts","./src/store/chatstore.ts","./src/utils/format.ts","./src/utils/ws.ts"],"version":"5.9.2"}
|
||||
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/authstore.ts","./src/store/chatstore.ts","./src/utils/format.ts","./src/utils/ws.ts"],"version":"5.9.2"}
|
||||
Reference in New Issue
Block a user