diff --git a/packages/web/src/components/layout/DmRosterPanel.test.tsx b/packages/web/src/components/layout/DmRosterPanel.test.tsx new file mode 100644 index 00000000..6ac9263a --- /dev/null +++ b/packages/web/src/components/layout/DmRosterPanel.test.tsx @@ -0,0 +1,473 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { User, DmChannel } from '@backspace/shared'; + +// ── Stubs / mocks ────────────────────────────────────────────────────────── + +// AudioManager is pulled in transitively via spaceStore — avoid AudioWorkletNode. +vi.mock('../../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +// Mutable selector-state holders so individual tests can flip caller / channel +// shape without rebuilding the whole module mock graph. +type ChatStateShape = { currentChannelId: string | null }; +type SpaceStateShape = { dmChannels: DmChannel[]; currentSpaceId: string | null; userViews: Map }; +type UIStateShape = { + memberListOpen: boolean; + showDms: boolean; + isMobile: boolean; + openUserProfile: ReturnType; + addToast: ReturnType; +}; +type AuthStateShape = { user: User | null }; +type SocialStateShape = { friends: { id: string }[]; removeFriend: ReturnType }; + +const chatState: ChatStateShape = { currentChannelId: 'dm-1' }; +const spaceState: SpaceStateShape = { + dmChannels: [], + currentSpaceId: null, + userViews: new Map(), +}; +const uiState: UIStateShape = { + memberListOpen: true, + showDms: true, + isMobile: false, + openUserProfile: vi.fn(), + addToast: vi.fn(), +}; +const authState: AuthStateShape = { user: null }; +const socialState: SocialStateShape = { friends: [], removeFriend: vi.fn() }; + +vi.mock('../../stores/chatStore', () => ({ + useChatStore: Object.assign( + (selector: (s: ChatStateShape) => unknown) => selector(chatState), + { + getState: () => chatState, + setState: vi.fn(), + subscribe: vi.fn(), + }, + ), +})); + +vi.mock('../../stores/spaceStore', () => ({ + useSpaceStore: Object.assign( + (selector: (s: SpaceStateShape) => unknown) => selector(spaceState), + { + getState: () => spaceState, + setState: vi.fn(), + subscribe: vi.fn(), + }, + ), +})); + +vi.mock('../../stores/uiStore', () => ({ + useUIStore: Object.assign( + (selector: (s: UIStateShape) => unknown) => selector(uiState), + { + getState: () => uiState, + setState: vi.fn(), + subscribe: vi.fn(), + }, + ), +})); + +vi.mock('../../stores/authStore', () => ({ + useAuthStore: Object.assign( + (selector: (s: AuthStateShape) => unknown) => selector(authState), + { + getState: () => authState, + setState: vi.fn(), + subscribe: vi.fn(), + }, + ), +})); + +vi.mock('../../stores/socialStore', () => ({ + useSocialStore: Object.assign( + (selector: (s: SocialStateShape) => unknown) => selector(socialState), + { + getState: () => socialState, + setState: vi.fn(), + subscribe: vi.fn(), + }, + ), +})); + +// API client — both owner-only methods are stubbed; calls return success by default. +const apiKickMember = vi.fn().mockResolvedValue({ success: true }); +const apiTransferOwnership = vi.fn().mockResolvedValue({}); +vi.mock('../../api/client', () => ({ + api: { + dm: { + kickMember: (channelId: string, userId: string) => apiKickMember(channelId, userId), + transferOwnership: (channelId: string, userId: string) => + apiTransferOwnership(channelId, userId), + }, + }, +})); + +// useCanonicalUserView falls back to the input on cache miss; that's exactly +// what we want here. + +// ── Imports under test ───────────────────────────────────────────────────── + +import { DmRosterPanel } from './DmRosterPanel'; +import { ContextMenuRenderer } from '../ui/ContextMenuRenderer'; +import { useContextMenuStore } from '../../stores/contextMenuStore'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function makeUser(overrides: Partial = {}): User { + return { + id: 'u-default', + username: 'someone', + displayName: null, + avatar: null, + banner: null, + accentColor: null, + avatarColor: null, + bio: null, + status: 'online', + customStatus: null, + isAdmin: false, + createdAt: 0, + homeInstance: null, + homeUserId: null, + replicatedInstances: [], + ...overrides, + }; +} + +function makeGroupDm(members: User[], ownerId: string): DmChannel { + return { + id: 'dm-1', + federatedId: null, + ownerId, + ownerHomeUserId: null, + ownerHomeInstance: null, + createdAt: 0, + members, + lastMessage: null, + name: null, + icon: null, + metadataUpdatedAt: 0, + }; +} + +function setScenario(opts: { + caller: User; + members: User[]; + ownerId: string; + isGroupDm?: boolean; + memberListOpen?: boolean; + showDms?: boolean; + currentSpaceId?: string | null; + friends?: { id: string }[]; + channelId?: string | null; +}) { + authState.user = opts.caller; + uiState.memberListOpen = opts.memberListOpen ?? true; + uiState.showDms = opts.showDms ?? true; + spaceState.currentSpaceId = opts.currentSpaceId ?? null; + spaceState.dmChannels = opts.isGroupDm === false + ? [] + : [makeGroupDm(opts.members, opts.ownerId)]; + // Also support 1-on-1 case: caller passes isGroupDm=false → no channel. + if (opts.isGroupDm === false) { + spaceState.dmChannels = [{ + ...makeGroupDm(opts.members, opts.ownerId), + ownerId: null, + }]; + } + chatState.currentChannelId = opts.channelId === undefined ? 'dm-1' : opts.channelId; + socialState.friends = opts.friends ?? []; +} + +beforeEach(() => { + // Reset shared state before every test. + authState.user = null; + spaceState.dmChannels = []; + spaceState.currentSpaceId = null; + spaceState.userViews = new Map(); + uiState.memberListOpen = true; + uiState.showDms = true; + uiState.isMobile = false; + uiState.openUserProfile = vi.fn(); + uiState.addToast = vi.fn(); + chatState.currentChannelId = 'dm-1'; + socialState.friends = []; + socialState.removeFriend = vi.fn().mockResolvedValue(undefined); + apiKickMember.mockClear().mockResolvedValue({ success: true }); + apiTransferOwnership.mockClear().mockResolvedValue({}); + + act(() => { + useContextMenuStore.getState().close(); + }); +}); + +function renderPanel() { + return render( + <> + + + , + ); +} + +function getMenuLabels(): string[] { + const buttons = document.querySelectorAll('div.fixed.z-\\[200\\] button'); + return Array.from(buttons).map((b) => b.textContent?.trim() ?? ''); +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('DmRosterPanel — visibility gates', () => { + it('renders nothing when memberListOpen is false', () => { + const owner = makeUser({ id: 'owner-1', username: 'owner' }); + setScenario({ caller: owner, members: [owner], ownerId: 'owner-1', memberListOpen: false }); + const { container } = renderPanel(); + expect(container.querySelector('[data-dm-roster-panel]')).toBeFalsy(); + }); + + it('renders nothing when current channel is not a group DM', () => { + const caller = makeUser({ id: 'me' }); + setScenario({ + caller, + members: [caller], + ownerId: 'me', + isGroupDm: false, + }); + const { container } = renderPanel(); + expect(container.querySelector('[data-dm-roster-panel]')).toBeFalsy(); + }); + + it('renders nothing when not in DM view (currentSpaceId set + showDms=false)', () => { + const owner = makeUser({ id: 'owner-1' }); + setScenario({ + caller: owner, + members: [owner], + ownerId: 'owner-1', + showDms: false, + currentSpaceId: 'space-1', + }); + const { container } = renderPanel(); + expect(container.querySelector('[data-dm-roster-panel]')).toBeFalsy(); + }); + + it('renders the panel for a group DM with member list open', () => { + const owner = makeUser({ id: 'owner-1', username: 'owner', displayName: 'Owner' }); + setScenario({ caller: owner, members: [owner], ownerId: 'owner-1' }); + const { container } = renderPanel(); + expect(container.querySelector('[data-dm-roster-panel]')).toBeTruthy(); + expect(container.querySelector('[data-dm-roster-header]')?.textContent).toContain('Members — 1'); + }); +}); + +describe('DmRosterPanel — section grouping', () => { + it('groups members into OWNER + ONLINE + OFFLINE sections', () => { + const owner = makeUser({ id: 'o', username: 'owner', displayName: 'Owner', status: 'online' }); + const onlineA = makeUser({ id: 'a', username: 'alice', displayName: 'Alice', status: 'online' }); + const onlineB = makeUser({ id: 'b', username: 'bob', displayName: 'Bob', status: 'online' }); + const offline = makeUser({ id: 'z', username: 'zoe', displayName: 'Zoe', status: 'offline' }); + + setScenario({ + caller: makeUser({ id: 'me' }), + members: [owner, onlineB, onlineA, offline], + ownerId: 'o', + }); + const { container } = renderPanel(); + + expect(container.querySelector('[data-dm-roster-section="owner"]')).toBeTruthy(); + expect(container.querySelector('[data-dm-roster-section="online"]')).toBeTruthy(); + expect(container.querySelector('[data-dm-roster-section="offline"]')).toBeTruthy(); + expect(container.querySelector('[data-dm-roster-header]')?.textContent).toContain('Members — 4'); + }); + + it('sorts ONLINE and OFFLINE alphabetically by displayName', () => { + const owner = makeUser({ id: 'o', username: 'zzz-owner', displayName: 'Owner' }); + const charlie = makeUser({ id: 'c', username: 'c', displayName: 'Charlie', status: 'online' }); + const alice = makeUser({ id: 'a', username: 'a', displayName: 'Alice', status: 'online' }); + const bob = makeUser({ id: 'b', username: 'b', displayName: 'Bob', status: 'online' }); + + setScenario({ + caller: makeUser({ id: 'me' }), + members: [owner, charlie, alice, bob], + ownerId: 'o', + }); + const { container } = renderPanel(); + const onlineSection = container.querySelector('[data-dm-roster-section="online"]')!; + const rows = onlineSection.querySelectorAll('[data-dm-member-row]'); + const names = Array.from(rows).map((r) => r.getAttribute('data-user-id')); + expect(names).toEqual(['a', 'b', 'c']); + }); + + it('hides empty ONLINE / OFFLINE sections', () => { + const owner = makeUser({ id: 'o', displayName: 'Solo' }); + setScenario({ caller: owner, members: [owner], ownerId: 'o' }); + const { container } = renderPanel(); + expect(container.querySelector('[data-dm-roster-section="online"]')).toBeFalsy(); + expect(container.querySelector('[data-dm-roster-section="offline"]')).toBeFalsy(); + }); +}); + +describe('DmRosterPanel — action wiring', () => { + it('kick action: opens confirm dialog, then calls api.dm.kickMember on confirm', async () => { + const u = userEvent.setup(); + const owner = makeUser({ id: 'me', username: 'me', displayName: 'Me' }); + const target = makeUser({ id: 'tgt', username: 'tgt', displayName: 'Tgt' }); + + setScenario({ caller: owner, members: [owner, target], ownerId: 'me' }); + const { container } = renderPanel(); + + // Open menu via right-click on the target's row. + const targetRow = container.querySelector( + '[data-dm-roster-section="online"] [data-dm-member-row]', + ) as HTMLElement; + expect(targetRow).toBeTruthy(); + targetRow.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50 })); + + const kickBtn = await screen.findByText('Remove from Group'); + await u.click(kickBtn); + + // Confirm dialog visible. + const confirmBtn = await screen.findByRole('button', { name: 'Remove' }); + await u.click(confirmBtn); + + await waitFor(() => { + expect(apiKickMember).toHaveBeenCalledTimes(1); + }); + expect(apiKickMember).toHaveBeenCalledWith('dm-1', 'tgt'); + }); + + it('transfer action: opens confirm dialog, then calls api.dm.transferOwnership on confirm', async () => { + const u = userEvent.setup(); + const owner = makeUser({ id: 'me', username: 'me', displayName: 'Me' }); + const target = makeUser({ id: 'tgt', username: 'tgt', displayName: 'Tgt' }); + setScenario({ caller: owner, members: [owner, target], ownerId: 'me' }); + const { container } = renderPanel(); + + const targetRow = container.querySelector( + '[data-dm-roster-section="online"] [data-dm-member-row]', + ) as HTMLElement; + targetRow.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50 })); + + const transferBtn = await screen.findByText('Transfer Ownership'); + await u.click(transferBtn); + + const confirmBtn = await screen.findByRole('button', { name: 'Transfer' }); + await u.click(confirmBtn); + + await waitFor(() => { + expect(apiTransferOwnership).toHaveBeenCalledTimes(1); + }); + expect(apiTransferOwnership).toHaveBeenCalledWith('dm-1', 'tgt'); + }); + + it('remove-friend action: calls socialStore.removeFriend with the row user id', async () => { + const u = userEvent.setup(); + const me = makeUser({ id: 'me' }); + const friend = makeUser({ id: 'friend-1', username: 'friend', displayName: 'Friend' }); + setScenario({ + caller: me, + members: [me, friend], + ownerId: 'someone-else', // caller is not owner + friends: [{ id: 'friend-1' }], + }); + const { container } = renderPanel(); + + // Find the friend's row in the online section. + const onlineSection = container.querySelector('[data-dm-roster-section="online"]')!; + const rows = onlineSection.querySelectorAll('[data-dm-member-row]'); + const friendRow = Array.from(rows).find( + (r) => r.getAttribute('data-user-id') === 'friend-1', + ) as HTMLElement; + expect(friendRow).toBeTruthy(); + friendRow.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50 })); + + const removeBtn = await screen.findByText('Remove Friend'); + await u.click(removeBtn); + + await waitFor(() => { + expect(socialState.removeFriend).toHaveBeenCalledWith('friend-1'); + }); + }); + + it('profile action: calls openUserProfile with the row user', async () => { + const u = userEvent.setup(); + const me = makeUser({ id: 'me' }); + const other = makeUser({ id: 'other', username: 'other', displayName: 'Other' }); + setScenario({ caller: me, members: [me, other], ownerId: 'me' }); + + const { container } = renderPanel(); + + const targetRow = container.querySelector( + '[data-dm-roster-section="online"] [data-dm-member-row]', + ) as HTMLElement; + targetRow.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50 })); + + const profileBtn = await screen.findByText('View Profile'); + await u.click(profileBtn); + + expect(uiState.openUserProfile).toHaveBeenCalledTimes(1); + expect(uiState.openUserProfile.mock.calls[0]![0]).toEqual(expect.objectContaining({ id: 'other' })); + }); +}); + +describe('DmRosterPanel — caller permissions wiring', () => { + it('non-owner caller does NOT see kick/transfer in any row menu', async () => { + const me = makeUser({ id: 'me' }); + const owner = makeUser({ id: 'o', username: 'owner', displayName: 'Owner' }); + const other = makeUser({ id: 'o2', username: 'o2', displayName: 'O2' }); + setScenario({ + caller: me, + members: [me, owner, other], + ownerId: 'o', + }); + const { container } = renderPanel(); + + // Pick "other" row in the online section. + const otherRow = Array.from( + container.querySelectorAll('[data-dm-roster-section="online"] [data-dm-member-row]'), + ).find((r) => r.getAttribute('data-user-id') === 'o2') as HTMLElement; + act(() => { + otherRow.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50 }), + ); + }); + + // Wait for the portal to render the menu, then assert on its label set. + await screen.findByText('View Profile'); + const labels = getMenuLabels(); + expect(labels).not.toContain('Transfer Ownership'); + expect(labels).not.toContain('Remove from Group'); + expect(labels).toContain('View Profile'); + }); + + it('owner caller sees kick + transfer for non-self rows', async () => { + const me = makeUser({ id: 'me', username: 'me', displayName: 'Me' }); + const target = makeUser({ id: 'tgt', username: 'tgt', displayName: 'Tgt' }); + setScenario({ caller: me, members: [me, target], ownerId: 'me' }); + const { container } = renderPanel(); + + const targetRow = container.querySelector( + '[data-dm-roster-section="online"] [data-dm-member-row]', + ) as HTMLElement; + act(() => { + targetRow.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50 }), + ); + }); + + await screen.findByText('Transfer Ownership'); + const labels = getMenuLabels(); + expect(labels).toContain('Transfer Ownership'); + expect(labels).toContain('Remove from Group'); + }); +}); diff --git a/packages/web/src/components/layout/DmRosterPanel.tsx b/packages/web/src/components/layout/DmRosterPanel.tsx new file mode 100644 index 00000000..e77edc77 --- /dev/null +++ b/packages/web/src/components/layout/DmRosterPanel.tsx @@ -0,0 +1,267 @@ +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'; + +/** + * 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') { + openUserProfile(member, { top: 100, left: 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 { + await api.dm.kickMember(dmChannel.id, pendingKick.id); + 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 { + await api.dm.transferOwnership(dmChannel.id, pendingTransfer.id); + 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} + /> +
+ ); +} diff --git a/packages/web/src/components/layout/RightPanel.tsx b/packages/web/src/components/layout/RightPanel.tsx index b4d802a3..0b482235 100644 --- a/packages/web/src/components/layout/RightPanel.tsx +++ b/packages/web/src/components/layout/RightPanel.tsx @@ -1,14 +1,28 @@ import React from 'react'; import { MemberSidebar } from './MemberSidebar'; import { ActivityPanel } from './ActivityPanel'; +import { DmRosterPanel } from './DmRosterPanel'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore } from '../../stores/spaceStore'; +import { useChatStore } from '../../stores/chatStore'; export function RightPanel() { const showDms = useUIStore((s) => s.showDms); + const memberListOpen = useUIStore((s) => s.memberListOpen); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); + const dmChannels = useSpaceStore((s) => s.dmChannels); + const currentChannelId = useChatStore((s) => s.currentChannelId); + // In the DM view (showDms or no current space), the right column is normally + // the activity panel. The exception is group DMs while the user has the + // member list toggled on — then we swap in DmRosterPanel, which itself + // returns null if any of its preconditions are unmet. if (showDms || !currentSpaceId) { + const dmChannel = dmChannels.find((dm) => dm.id === currentChannelId); + const isGroupDm = !!dmChannel?.ownerId; + if (isGroupDm && memberListOpen) { + return ; + } return ; }