import React, { useMemo, useState } from 'react'; import type { User } from '@backspace/shared'; import { useChatStore } from '../../stores/chatStore'; import { useSpaceStore } from '../../stores/spaceStore'; import { useUIStore } from '../../stores/uiStore'; import { useAuthStore } from '../../stores/authStore'; import { useSocialStore } from '../../stores/socialStore'; import { isSelf, parseFederatedUsername } from '../../utils/identity'; import { api } from '../../api/client'; import { ConfirmDialog } from '../ui/ConfirmDialog'; import { DmMemberRow, type DmMemberRowAction } from './DmMemberRow'; import { pointAnchor } from '../../hooks/useFloatingPosition'; /** * Right-side roster for group DMs. Mirrors `MemberSidebar`'s layout language * (240px column, structural surface, hidden on mobile) so toggling * `memberListOpen` feels identical across spaces and group DMs. * * Renders nothing for 1-on-1 DMs and when `memberListOpen` is false. The * default-state inheritance is intentional — this panel does not own a * separate boolean and explicitly carries the user's global toggle preference. */ export function DmRosterPanel() { const memberListOpen = useUIStore((s) => s.memberListOpen); const openUserProfile = useUIStore((s) => s.openUserProfile); const addToast = useUIStore((s) => s.addToast); const currentChannelId = useChatStore((s) => s.currentChannelId); const dmChannels = useSpaceStore((s) => s.dmChannels); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const showDms = useUIStore((s) => s.showDms); const authUser = useAuthStore((s) => s.user); const friends = useSocialStore((s) => s.friends); const removeFriendStore = useSocialStore((s) => s.removeFriend); const dmChannel = useMemo( () => dmChannels.find((dm) => dm.id === currentChannelId) ?? null, [dmChannels, currentChannelId], ); // Confirm-state for destructive actions. Two separate slots — kick + transfer // — so we can keep simple state without a discriminated union. const [pendingKick, setPendingKick] = useState(null); const [pendingTransfer, setPendingTransfer] = useState(null); const [submitting, setSubmitting] = useState(false); // ── Visibility gates ──────────────────────────────────────────────────── // Render only when: // 1. We're in the DM view (showDms=true OR no current space). // 2. The current channel is a group DM (ownerId set). // 3. The global member-list toggle is on. const inDmView = showDms || !currentSpaceId; const isGroupDm = !!dmChannel?.ownerId; if (!inDmView || !dmChannel || !isGroupDm || !memberListOpen) { return null; } const ownerId = dmChannel.ownerId; const callerIsOwner = !!authUser && ownerId === authUser.id; // ── Section grouping ──────────────────────────────────────────────────── // Owner first (always exactly one row), then online and offline groups // sorted alphabetically by displayName (falling back to baseName from the // username). This mirrors MemberSidebar's offline-section ordering and // its display-name-first label resolution. const sortByDisplayName = (a: User, b: User) => { const aName = (a.displayName ?? parseFederatedUsername(a.username).baseName).toLowerCase(); const bName = (b.displayName ?? parseFederatedUsername(b.username).baseName).toLowerCase(); return aName.localeCompare(bName); }; const ownerMember = dmChannel.members.find((m) => m.id === ownerId) ?? null; const nonOwnerMembers = dmChannel.members.filter((m) => m.id !== ownerId); const onlineMembers = nonOwnerMembers .filter((m) => m.status !== 'offline') .sort(sortByDisplayName); const offlineMembers = nonOwnerMembers .filter((m) => m.status === 'offline') .sort(sortByDisplayName); // ── Per-row helpers ──────────────────────────────────────────────────── const isFriendOfCaller = (m: User): boolean => { // Federation-safe: friends can be replicated locally, so the local id // is the right comparison target — Friend.id is always the local id on // the current instance, mirroring MessageList's WelcomeHeader pattern. return friends.some((f) => f.id === m.id); }; const handleMenuAction = async (action: DmMemberRowAction, member: User) => { if (action === 'profile') { // Profile popout is anchored by DmMemberRow itself (matches the // MemberSidebar pattern). This branch only fires on the unlikely // fallback path where the row couldn't compute its bounding rect — // in that case, anchor to the top-left of the roster column. openUserProfile(member, pointAnchor(100, 100)); return; } if (action === 'kick') { setPendingKick(member); return; } if (action === 'transfer') { setPendingTransfer(member); return; } if (action === 'remove-friend') { try { await removeFriendStore(member.id); } catch (err) { addToast( err instanceof Error ? err.message : 'Failed to remove friend', 'warning', 3000, ); } } }; const confirmKick = async () => { if (!pendingKick) return; setSubmitting(true); try { // Pass federated identity when the target is a federated user. // `pendingKick.id` may be the home id (when the home view is in the // userViews cache) OR a local id from any instance — neither is // guaranteed to match the OWNER instance's local replicated id. The // owner instance resolves home id + home instance via // `resolveOrCreateReplicatedUser`, which is the only deterministic // way to find the right `dm_members.userId` row across instances. const federated = pendingKick.homeUserId && pendingKick.homeInstance ? { homeUserId: pendingKick.homeUserId, homeInstance: pendingKick.homeInstance } : undefined; await api.dm.kickMember(dmChannel.id, pendingKick.id, federated); addToast( `Removed ${pendingKick.displayName ?? parseFederatedUsername(pendingKick.username).baseName} from the group`, 'success', 3000, ); setPendingKick(null); } catch (err) { addToast( err instanceof Error ? err.message : 'Failed to remove member', 'warning', 3000, ); } finally { setSubmitting(false); } }; const confirmTransfer = async () => { if (!pendingTransfer) return; setSubmitting(true); try { // See confirmKick for the rationale on federated identity. const federated = pendingTransfer.homeUserId && pendingTransfer.homeInstance ? { homeUserId: pendingTransfer.homeUserId, homeInstance: pendingTransfer.homeInstance } : undefined; await api.dm.transferOwnership(dmChannel.id, pendingTransfer.id, federated); addToast( `Ownership transferred to ${pendingTransfer.displayName ?? parseFederatedUsername(pendingTransfer.username).baseName}`, 'success', 3000, ); setPendingTransfer(null); } catch (err) { addToast( err instanceof Error ? err.message : 'Failed to transfer ownership', 'warning', 3000, ); } finally { setSubmitting(false); } }; const totalCount = dmChannel.members.length; // ── Render ───────────────────────────────────────────────────────────── // Width / surface mirror `MemberSidebar` so toggling visually swaps a // like-shaped column. return (

Members — {totalCount}

{ownerMember && (

OWNER

)} {onlineMembers.length > 0 && (

ONLINE — {onlineMembers.length}

{onlineMembers.map((m) => ( ))}
)} {offlineMembers.length > 0 && (

OFFLINE — {offlineMembers.length}

{offlineMembers.map((m) => ( ))}
)}
{ if (!submitting) setPendingKick(null); }} onConfirm={confirmKick} title="Remove from Group" description={ pendingKick ? `Remove ${pendingKick.displayName ?? parseFederatedUsername(pendingKick.username).baseName} from this group? They won't be able to see new messages.` : '' } confirmLabel="Remove" variant="danger" loading={submitting} /> { if (!submitting) setPendingTransfer(null); }} onConfirm={confirmTransfer} title="Transfer Ownership" description={ pendingTransfer ? `Transfer ownership to ${pendingTransfer.displayName ?? parseFederatedUsername(pendingTransfer.username).baseName}? You'll lose owner privileges.` : '' } confirmLabel="Transfer" variant="warning" loading={submitting} />
); }