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

@@ -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`);
}

View File

@@ -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;
}

View File

@@ -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;

View 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
);
}

View File

@@ -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"
});
}

View File

@@ -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);
}
}
};

View File

@@ -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"
});
}

View File

@@ -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) => ({

View File

@@ -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"}