gitdataai/src/components/layout/MemberList.tsx

221 lines
6.7 KiB
TypeScript

import { useEffect, useState, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { Avatar } from '@/components/channel/Avatar';
import { useRoom } from '@/contexts/room';
import { projectMembersGrouped, projectInfo, projectPresence } from '@/client/api';
import type { MemberGroup } from '@/client/model';
import type { PresenceStatus } from '@/client/model';
const STATUS_COLORS: Record<string, string> = {
online: 'var(--status-online)',
idle: 'var(--status-idle)',
dnd: 'var(--status-dnd)',
offline: 'var(--status-offline)',
};
const ROLE_COLORS: Record<string, string> = {
owner: 'var(--role-red)',
admin: 'var(--role-orange)',
member: 'var(--role-blue)',
};
function useRoomSafe() {
try {
return useRoom();
} catch {
return null;
}
}
export function MemberList() {
const { projectName } = useParams<{ projectName: string }>();
const room = useRoomSafe();
const [groups, setGroups] = useState<MemberGroup[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [apiPresence, setApiPresence] = useState<Map<string, PresenceStatus>>(new Map());
// Fetch project presence from API
useEffect(() => {
if (!projectName) {
setApiPresence(new Map());
return;
}
let cancelled = false;
// First get project info to get project_id, then fetch presence
projectInfo(projectName)
.then((infoRes) => {
if (cancelled) return;
const projectId = infoRes.data?.data?.uid;
if (!projectId) return;
return projectPresence(projectId);
})
.then((presenceRes) => {
if (cancelled || !presenceRes) return;
const presenceMap = new Map<string, PresenceStatus>();
for (const p of presenceRes.data?.data || []) {
presenceMap.set(p.user_id, p.status);
}
setApiPresence(presenceMap);
})
.catch((err) => {
if (cancelled) return;
console.error('[MemberList] failed to load project presence:', err);
setApiPresence(new Map());
});
return () => { cancelled = true; };
}, [projectName]);
// Build a map of uid -> presence from room context + API presence
const presenceMap = useMemo(() => {
const map = new Map<string, string>();
// First, add presence from room context (from WebSocket real-time updates)
if (room) {
for (const m of room.members) {
map.set(m.uid, m.presence);
}
}
// Then, override with API-fetched presence (authoritative data)
for (const [userId, status] of apiPresence) {
map.set(userId, status);
}
return map;
}, [room, apiPresence]);
useEffect(() => {
if (!projectName) {
setGroups([]);
setTotal(0);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
projectMembersGrouped(projectName)
.then((res) => {
if (cancelled) return;
const data = res.data.data;
setGroups(data?.groups ?? []);
setTotal(data?.total ?? 0);
})
.catch((err) => {
if (cancelled) return;
console.error('[MemberList] failed to load project members:', err);
setGroups([]);
setTotal(0);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [projectName]);
// Loading state
if (loading) {
return (
<div
className="flex flex-col items-center justify-center h-full w-[240px]"
style={{ backgroundColor: 'var(--surface-sidebar)' }}
>
<div className="w-5 h-5 rounded-full border-2 border-t-transparent animate-spin" style={{ borderColor: 'var(--text-muted)', borderTopColor: 'transparent' }} />
</div>
);
}
// No project selected or no members
if (groups.length === 0) {
return (
<div
className="flex flex-col items-center justify-center h-full w-[240px]"
style={{ backgroundColor: 'var(--surface-sidebar)' }}
>
<p className="text-[12px]" style={{ color: 'var(--text-muted)' }}>
{projectName ? 'No members' : 'Select a project'}
</p>
</div>
);
}
// Real project members
return (
<div
className="flex flex-col h-full w-[240px] pt-4 px-2 overflow-y-auto"
style={{ backgroundColor: 'var(--surface-sidebar)' }}
>
<div
className="px-2 py-2 text-[11px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--text-muted)' }}
>
Members {total}
</div>
{groups.map((g) => {
const color = ROLE_COLORS[g.role.toLowerCase()] || 'var(--role-gray)';
return (
<div key={g.role} className="mb-2">
<div
className="flex items-center px-2 py-1 text-[11px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--text-muted)' }}
>
<span style={{ color }}>{g.role}</span>
<span className="ml-1"> {g.members.length}</span>
</div>
{g.members.map((m) => {
const presence = presenceMap.get(m.user_id) || 'offline';
const isOffline = presence === 'offline';
return (
<button
key={m.user_id}
className={`flex items-center gap-3 px-2 py-1.5 rounded-[4px] transition-colors cursor-pointer w-full text-left ${
isOffline ? 'opacity-40' : ''
}`}
style={{ color: 'var(--text-primary)' }}
>
<div style={{ position: 'relative', flexShrink: 0 }}>
<Avatar
senderType="user"
displayName={m.username}
avatarUrl={m.avatar_url}
roleColor={color}
size={32}
/>
<div
className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-[3px]"
style={{
backgroundColor: STATUS_COLORS[presence] || STATUS_COLORS.offline,
borderColor: 'var(--surface-sidebar)',
}}
/>
</div>
<div className="min-w-0">
<p className="text-[13px] font-medium truncate" style={{ color }}>
{m.username}
</p>
{m.display_name && (
<p className="text-[11px] truncate" style={{ color: 'var(--text-muted)' }}>
{m.display_name}
</p>
)}
</div>
</button>
);
})}
</div>
);
})}
</div>
);
}