221 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
}
|