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:
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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user