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