feat(ui): right-side DmRosterPanel for group DMs
This commit is contained in:
@@ -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<string, unknown> };
|
||||
type UIStateShape = {
|
||||
memberListOpen: boolean;
|
||||
showDms: boolean;
|
||||
isMobile: boolean;
|
||||
openUserProfile: ReturnType<typeof vi.fn>;
|
||||
addToast: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
type AuthStateShape = { user: User | null };
|
||||
type SocialStateShape = { friends: { id: string }[]; removeFriend: ReturnType<typeof vi.fn> };
|
||||
|
||||
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> = {}): 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(
|
||||
<>
|
||||
<DmRosterPanel />
|
||||
<ContextMenuRenderer />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
function getMenuLabels(): string[] {
|
||||
const buttons = document.querySelectorAll<HTMLButtonElement>('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');
|
||||
});
|
||||
});
|
||||
@@ -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<User | null>(null);
|
||||
const [pendingTransfer, setPendingTransfer] = useState<User | null>(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 (
|
||||
<div
|
||||
data-dm-roster-panel
|
||||
className="w-60 bg-surface-members flex-shrink-0 overflow-y-auto select-none no-scrollbar hidden md:block border-l border-border-hard"
|
||||
>
|
||||
<div className="p-3">
|
||||
<h3
|
||||
data-dm-roster-header
|
||||
className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-2"
|
||||
>
|
||||
Members — {totalCount}
|
||||
</h3>
|
||||
|
||||
{ownerMember && (
|
||||
<div data-dm-roster-section="owner" className="mb-4">
|
||||
<h4 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1">
|
||||
OWNER
|
||||
</h4>
|
||||
<DmMemberRow
|
||||
member={ownerMember}
|
||||
isOwner
|
||||
isSelf={!!authUser && isSelf(ownerMember, authUser)}
|
||||
callerIsOwner={callerIsOwner}
|
||||
isFriend={isFriendOfCaller(ownerMember)}
|
||||
showKebab
|
||||
onMenuAction={handleMenuAction}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onlineMembers.length > 0 && (
|
||||
<div data-dm-roster-section="online" className="mb-4">
|
||||
<h4 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1">
|
||||
ONLINE — {onlineMembers.length}
|
||||
</h4>
|
||||
{onlineMembers.map((m) => (
|
||||
<DmMemberRow
|
||||
key={m.id}
|
||||
member={m}
|
||||
isOwner={false}
|
||||
isSelf={!!authUser && isSelf(m, authUser)}
|
||||
callerIsOwner={callerIsOwner}
|
||||
isFriend={isFriendOfCaller(m)}
|
||||
showKebab
|
||||
onMenuAction={handleMenuAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{offlineMembers.length > 0 && (
|
||||
<div data-dm-roster-section="offline" className="opacity-60">
|
||||
<h4 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1">
|
||||
OFFLINE — {offlineMembers.length}
|
||||
</h4>
|
||||
{offlineMembers.map((m) => (
|
||||
<DmMemberRow
|
||||
key={m.id}
|
||||
member={m}
|
||||
isOwner={false}
|
||||
isSelf={!!authUser && isSelf(m, authUser)}
|
||||
callerIsOwner={callerIsOwner}
|
||||
isFriend={isFriendOfCaller(m)}
|
||||
showKebab
|
||||
onMenuAction={handleMenuAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingKick}
|
||||
onClose={() => { 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}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingTransfer}
|
||||
onClose={() => { 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <DmRosterPanel />;
|
||||
}
|
||||
return <ActivityPanel />;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user