import React, { useMemo } from 'react'; import type { MemberWithUser, Activity } from '@backspace/shared'; import { useSpaceStore } from '../../stores/spaceStore'; import { useUIStore } from '../../stores/uiStore'; import { useActivityStore } from '../../stores/activityStore'; import { Avatar } from '../ui/Avatar'; import { Username } from '../ui/Username'; import { ActivityCard, hasRichActivity, getActivityAccentClass } from '../ui/ActivityCard'; import { getPrimaryActivity } from '@backspace/shared/src/activities.js'; import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity'; import { useCanonicalUserView } from '../../utils/userViewLookup'; import { useDelayedLoading } from '../../hooks/useDelayedLoading'; /** * Derives the display group for a member based on their highest-positioned role * or owner status. Returns { key, label, color, position }. */ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) { if (ownerId && member.userId === ownerId) { // Owner always sorts first — position Infinity so it's above all roles const ownerRole = member.roles?.find(r => r.position > 0); return { key: '__owner__', label: 'OWNER', color: ownerRole?.color ?? 'rgb(var(--accent-rose))', position: Infinity, }; } if (member.roles && member.roles.length > 0) { // Sort by position descending — highest position = most important role const sorted = [...member.roles].sort((a, b) => b.position - a.position); const top = sorted[0]!; return { key: top.id, label: top.name.toUpperCase(), color: top.color, position: top.position, }; } // No explicit roles — just @everyone return { key: '__online__', label: 'ONLINE', color: undefined, position: -1, }; } function MemberSidebarRow({ member, isOffline, colorStyle, activities, isRichActivity, accentClass, onClickMember, }: { member: MemberWithUser; isOffline: boolean; colorStyle: React.CSSProperties | undefined; activities: Activity[]; isRichActivity: boolean; accentClass: string; onClickMember: (e: React.MouseEvent, user: MemberWithUser['user']) => void; }) { const canonical = useCanonicalUserView(member.user); const { baseName } = parseFederatedUsername(canonical.username); const displayName = canonical.displayName ?? baseName; const rowClass = isRichActivity ? `flex items-center gap-2.5 px-2.5 py-2 rounded-[10px] mb-1 cursor-pointer transition-colors glass-pill border-l-2 ${accentClass}` : 'flex items-center gap-2.5 px-2 py-1.5 rounded-[4px] hover:bg-interactive-hover cursor-pointer group transition-colors'; return (
onClickMember(e, canonical)} className={rowClass} >
{!isOffline && isFederationGlobeApplicable(canonical) && (
@{parseFederatedUsername(canonical.username).domain}
)} {!isOffline && ( )}
); } export function MemberSidebar() { const members = useSpaceStore((s) => s.members); const spaces = useSpaceStore((s) => s.spaces); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId); const memberListOpen = useUIStore((s) => s.memberListOpen); const openUserProfile = useUIStore((s) => s.openUserProfile); const userActivities = useActivityStore((s) => s.userActivities); const space = spaces.find(s => s.id === currentSpaceId); const ownerId = space?.ownerId; const { roleGroups, offlineMembers } = useMemo(() => { const online = members.filter(m => m.user.status !== 'offline'); const offline = members.filter(m => m.user.status === 'offline'); // Group online members by their highest role const groups = new Map(); for (const m of online) { const group = getMemberGroup(m, ownerId); if (!groups.has(group.key)) { groups.set(group.key, { label: group.label, color: group.color, position: group.position, members: [] }); } groups.get(group.key)!.members.push(m); } // Sort groups by position descending (highest role first), then ONLINE last const sorted = [...groups.entries()].sort( (a, b) => b[1].position - a[1].position ); return { roleGroups: sorted, offlineMembers: offline }; }, [members, ownerId]); const isLoadingSpace = !!loadingSpaceId && loadingSpaceId === currentSpaceId; const showMemberSkeleton = useDelayedLoading(isLoadingSpace); if (!memberListOpen) return null; const getMemberColor = (member: MemberWithUser): React.CSSProperties | undefined => { if (member.roles && member.roles.length > 0) { const sorted = [...member.roles].sort((a, b) => b.position - a.position); return { color: sorted[0]!.color }; } if (ownerId && member.userId === ownerId) { return { color: 'rgb(var(--accent-rose))' }; } return undefined; }; const handleMemberClick = (e: React.MouseEvent, user: MemberWithUser['user']) => { e.stopPropagation(); openUserProfile(user, e.currentTarget.getBoundingClientRect(), 'left'); }; const renderMember = (member: MemberWithUser, isOffline = false) => { const colorStyle = isOffline ? undefined : getMemberColor(member); const activities = userActivities.get(member.userId) ?? []; const isRichActivity = !isOffline && hasRichActivity(activities); const primary = getPrimaryActivity(activities); const accentClass = primary ? getActivityAccentClass(primary.type) : ''; return ( ); }; return (
{showMemberSkeleton ? (
{/* Role group 1 */}
{Array.from({ length: 2 }, (_, i) => (
))} {/* Role group 2 */}
{Array.from({ length: 5 }, (_, i) => (
))}
) : (
{/* Role-based groups */} {roleGroups.map(([key, group]) => (

{group.label} — {group.members.length}

{group.members.map((m) => renderMember(m))}
))} {/* Offline */} {offlineMembers.length > 0 && (

OFFLINE — {offlineMembers.length}

{offlineMembers.map((m) => renderMember(m, true))}
)}
)}
); }