feat(mobile): MobileGroupDmInfo pushed screen with inline edit
This commit is contained in:
@@ -76,6 +76,7 @@ function renderRow(props: Partial<Parameters<typeof DmMemberRow>[0]> = {}) {
|
|||||||
callerIsOwner={props.callerIsOwner ?? false}
|
callerIsOwner={props.callerIsOwner ?? false}
|
||||||
isFriend={props.isFriend ?? false}
|
isFriend={props.isFriend ?? false}
|
||||||
showKebab={props.showKebab ?? false}
|
showKebab={props.showKebab ?? false}
|
||||||
|
alwaysShowKebab={props.alwaysShowKebab ?? false}
|
||||||
onMenuAction={props.onMenuAction ?? onMenuAction}
|
onMenuAction={props.onMenuAction ?? onMenuAction}
|
||||||
/>
|
/>
|
||||||
<ContextMenuRenderer />
|
<ContextMenuRenderer />
|
||||||
@@ -199,6 +200,24 @@ describe('DmMemberRow — kebab', () => {
|
|||||||
expect(container.querySelector('[data-dm-member-kebab]')).toBeFalsy();
|
expect(container.querySelector('[data-dm-member-kebab]')).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the kebab transparent at rest when showKebab=true and alwaysShowKebab=false (desktop hover-reveal)', () => {
|
||||||
|
const { container } = renderRow({ showKebab: true });
|
||||||
|
const kebab = container.querySelector<HTMLButtonElement>('[data-dm-member-kebab]');
|
||||||
|
expect(kebab).toBeTruthy();
|
||||||
|
// Desktop default: hidden until row is hovered.
|
||||||
|
expect(kebab!.className).toContain('opacity-0');
|
||||||
|
expect(kebab!.className).toContain('group-hover:opacity-100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces the kebab fully opaque when alwaysShowKebab=true (mobile)', () => {
|
||||||
|
const { container } = renderRow({ showKebab: true, alwaysShowKebab: true });
|
||||||
|
const kebab = container.querySelector<HTMLButtonElement>('[data-dm-member-kebab]');
|
||||||
|
expect(kebab).toBeTruthy();
|
||||||
|
expect(kebab!.className).toContain('opacity-100');
|
||||||
|
// The hover-reveal class must NOT be present in always-show mode.
|
||||||
|
expect(kebab!.className).not.toContain('group-hover:opacity-100');
|
||||||
|
});
|
||||||
|
|
||||||
it('shows the kebab when showKebab=true and clicking it opens the same menu', async () => {
|
it('shows the kebab when showKebab=true and clicking it opens the same menu', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { container } = renderRow({
|
const { container } = renderRow({
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ export interface DmMemberRowProps {
|
|||||||
isFriend: boolean;
|
isFriend: boolean;
|
||||||
/** Whether to render the kebab "⋮" trigger button (right side of the row). */
|
/** Whether to render the kebab "⋮" trigger button (right side of the row). */
|
||||||
showKebab?: boolean;
|
showKebab?: boolean;
|
||||||
|
/**
|
||||||
|
* Force the kebab to be fully opaque regardless of hover state.
|
||||||
|
*
|
||||||
|
* Desktop hides the kebab until the row is hovered (`opacity-0 group-hover:opacity-100`),
|
||||||
|
* which is invisible on touch devices that don't fire `:hover`. Mobile callers
|
||||||
|
* (e.g. `MobileGroupDmInfo`) pass `alwaysShowKebab` to keep the trigger
|
||||||
|
* permanently visible. Default `false` preserves the desktop hover-reveal.
|
||||||
|
*/
|
||||||
|
alwaysShowKebab?: boolean;
|
||||||
/** Fired when the viewer activates a menu entry. The component closes the menu itself. */
|
/** Fired when the viewer activates a menu entry. The component closes the menu itself. */
|
||||||
onMenuAction: (action: DmMemberRowAction, member: User) => void;
|
onMenuAction: (action: DmMemberRowAction, member: User) => void;
|
||||||
}
|
}
|
||||||
@@ -74,6 +83,7 @@ export function DmMemberRow({
|
|||||||
callerIsOwner,
|
callerIsOwner,
|
||||||
isFriend,
|
isFriend,
|
||||||
showKebab = false,
|
showKebab = false,
|
||||||
|
alwaysShowKebab = false,
|
||||||
onMenuAction,
|
onMenuAction,
|
||||||
}: DmMemberRowProps) {
|
}: DmMemberRowProps) {
|
||||||
const canonical = useCanonicalUserView(member);
|
const canonical = useCanonicalUserView(member);
|
||||||
@@ -229,7 +239,11 @@ export function DmMemberRow({
|
|||||||
// Right-click on the kebab still opens the same menu at cursor.
|
// Right-click on the kebab still opens the same menu at cursor.
|
||||||
handleContextMenu(e);
|
handleContextMenu(e);
|
||||||
}}
|
}}
|
||||||
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-[4px] text-txt-tertiary hover:text-txt-primary hover:bg-interactive-active opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
|
className={`flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-[4px] text-txt-tertiary hover:text-txt-primary hover:bg-interactive-active transition-opacity ${
|
||||||
|
alwaysShowKebab
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0 group-hover:opacity-100 focus:opacity-100'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<KebabIcon />
|
<KebabIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type { DmChannel, User } from '@backspace/shared';
|
||||||
|
|
||||||
|
// ── Stubs for transitively-imported infra ──────────────────────────────────
|
||||||
|
vi.mock('../../audio/AudioManager', () => ({
|
||||||
|
AudioManager: {
|
||||||
|
getInstance: vi.fn().mockReturnValue({
|
||||||
|
setOutputDevice: vi.fn(),
|
||||||
|
setVolume: vi.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Replace the visual-viewport hook with a deterministic value — tests don't
|
||||||
|
// run real iOS keyboard transitions, but the bar must still mount and the
|
||||||
|
// `bottom` style must resolve.
|
||||||
|
vi.mock('../../hooks/useVisualViewportInset', () => ({
|
||||||
|
useVisualViewportInset: () => ({
|
||||||
|
value: 'env(safe-area-inset-bottom)',
|
||||||
|
keyboardOpen: false,
|
||||||
|
height: 800,
|
||||||
|
offsetTop: 0,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock ImageCropModal — fires onCropComplete(blob) synchronously when the
|
||||||
|
// caller clicks the test "Confirm Crop" button (same pattern as
|
||||||
|
// GroupDmSettings.test.tsx, see commit cf292ac).
|
||||||
|
vi.mock('../ui/ImageCropModal', () => ({
|
||||||
|
ImageCropModal: ({
|
||||||
|
isOpen,
|
||||||
|
onCropComplete,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
imageSrc: string;
|
||||||
|
onCropComplete: (blob: Blob) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
cropShape?: 'rect' | 'round';
|
||||||
|
aspectRatio?: number;
|
||||||
|
maxOutputDimension?: number;
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
return (
|
||||||
|
<div role="dialog" aria-label="cropper-mock">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="cropper-mock-confirm"
|
||||||
|
onClick={() =>
|
||||||
|
onCropComplete(new Blob(['fake-image-bytes'], { type: 'image/webp' }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Confirm Crop
|
||||||
|
</button>
|
||||||
|
<button type="button" data-testid="cropper-mock-cancel" onClick={onClose}>
|
||||||
|
Cancel Crop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the api client — assert call counts on uploads + updateMetadata.
|
||||||
|
const mockUpdateMetadata = vi.fn();
|
||||||
|
const mockLeave = vi.fn();
|
||||||
|
const mockKickMember = vi.fn();
|
||||||
|
const mockTransferOwnership = vi.fn();
|
||||||
|
vi.mock('../../api/client', () => ({
|
||||||
|
api: {
|
||||||
|
dm: {
|
||||||
|
updateMetadata: (...args: unknown[]) => mockUpdateMetadata(...args),
|
||||||
|
leave: (...args: unknown[]) => mockLeave(...args),
|
||||||
|
kickMember: (...args: unknown[]) => mockKickMember(...args),
|
||||||
|
transferOwnership: (...args: unknown[]) => mockTransferOwnership(...args),
|
||||||
|
},
|
||||||
|
uploads: { url: (f: string) => `/api/uploads/${f}` },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Global fetch spy — Cancel path must NEVER hit /api/uploads.
|
||||||
|
const fetchSpy = vi.fn();
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchSpy.mockReset();
|
||||||
|
fetchSpy.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ filename: 'unused.webp' }),
|
||||||
|
} as Response);
|
||||||
|
// @ts-expect-error overriding jsdom global
|
||||||
|
global.fetch = fetchSpy;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock transferStore — Save path goes through startUpload → waitForTransferAttachment.
|
||||||
|
const mockStartUpload = vi.fn();
|
||||||
|
vi.mock('../../stores/transferStore', () => ({
|
||||||
|
useTransferStore: Object.assign(
|
||||||
|
(selector: (s: unknown) => unknown) => selector({}),
|
||||||
|
{
|
||||||
|
getState: () => ({
|
||||||
|
startUpload: (...args: unknown[]) => mockStartUpload(...args),
|
||||||
|
transfers: new Map(),
|
||||||
|
}),
|
||||||
|
setState: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockWaitForTransfer = vi.fn();
|
||||||
|
vi.mock('../../utils/waitForTransfer', () => ({
|
||||||
|
waitForTransferAttachment: (...args: unknown[]) => mockWaitForTransfer(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Stub cropImage so the real ImageCropModal apply path stays inert.
|
||||||
|
vi.mock('../../utils/cropImage', () => ({
|
||||||
|
cropImage: vi.fn().mockResolvedValue(new Blob(['cropped'], { type: 'image/webp' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Spy on uiStore push/openModal so we can verify navigation/profile-screen
|
||||||
|
// pushes. We keep the real module live (the component reads several pieces
|
||||||
|
// of state directly) and just observe state transitions through `getState`.
|
||||||
|
|
||||||
|
import { MobileGroupDmInfo } from './MobileGroupDmInfo';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { useSocialStore } from '../../stores/socialStore';
|
||||||
|
import { ContextMenuRenderer } from '../ui/ContextMenuRenderer';
|
||||||
|
|
||||||
|
// ── Fixtures ───────────────────────────────────────────────────────────────
|
||||||
|
function makeUser(overrides: Partial<User> = {}): User {
|
||||||
|
return {
|
||||||
|
id: 'user-self',
|
||||||
|
username: 'me',
|
||||||
|
displayName: 'Me',
|
||||||
|
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(overrides: Partial<DmChannel> = {}): DmChannel {
|
||||||
|
return {
|
||||||
|
id: 'dm-1',
|
||||||
|
federatedId: null,
|
||||||
|
ownerId: 'user-self', // viewer is owner by default
|
||||||
|
ownerHomeUserId: null,
|
||||||
|
ownerHomeInstance: null,
|
||||||
|
createdAt: 0,
|
||||||
|
members: [
|
||||||
|
makeUser({ id: 'user-self', username: 'me', displayName: 'Me' }),
|
||||||
|
makeUser({ id: 'user-2', username: 'alice', displayName: 'Alice' }),
|
||||||
|
makeUser({ id: 'user-3', username: 'bob', displayName: 'Bob' }),
|
||||||
|
],
|
||||||
|
lastMessage: null,
|
||||||
|
name: 'My Group',
|
||||||
|
icon: null,
|
||||||
|
metadataUpdatedAt: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStoreState(opts: { dmChannel: DmChannel; authUser: User | null }) {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [opts.dmChannel],
|
||||||
|
} as Partial<ReturnType<typeof useSpaceStore.getState>>);
|
||||||
|
useAuthStore.setState({ user: opts.authUser } as Partial<ReturnType<typeof useAuthStore.getState>>);
|
||||||
|
useSocialStore.setState({ friends: [] } as Partial<ReturnType<typeof useSocialStore.getState>>);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUpdateMetadata.mockReset();
|
||||||
|
mockLeave.mockReset();
|
||||||
|
mockKickMember.mockReset();
|
||||||
|
mockTransferOwnership.mockReset();
|
||||||
|
mockStartUpload.mockReset();
|
||||||
|
mockWaitForTransfer.mockReset();
|
||||||
|
mockUpdateMetadata.mockResolvedValue({});
|
||||||
|
mockLeave.mockResolvedValue({ success: true });
|
||||||
|
mockStartUpload.mockResolvedValue('transfer-1');
|
||||||
|
mockWaitForTransfer.mockResolvedValue({ attachmentId: 'a-1', filename: 'icon-123.webp' });
|
||||||
|
|
||||||
|
// Reset UI store entirely between tests so push/openModal call counts are clean.
|
||||||
|
useUIStore.setState({
|
||||||
|
activeModal: null,
|
||||||
|
modalData: {},
|
||||||
|
isMobile: true,
|
||||||
|
toasts: [],
|
||||||
|
mobileStack: [],
|
||||||
|
mobileScreen: 'dms',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderScreen() {
|
||||||
|
// ContextMenuRenderer is needed so DmMemberRow long-press/kebab menus
|
||||||
|
// actually mount their items into the DOM (the menu is portal-rendered).
|
||||||
|
return render(
|
||||||
|
<>
|
||||||
|
<MobileGroupDmInfo params={{ channelId: 'dm-1' }} />
|
||||||
|
<ContextMenuRenderer />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper: drive a crop blob into the staged-icon state (matches the
|
||||||
|
// GroupDmSettings.test.tsx pattern). ──────────────────────────────────────
|
||||||
|
async function stageIcon(user: ReturnType<typeof userEvent.setup>) {
|
||||||
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
expect(fileInput).not.toBeNull();
|
||||||
|
const file = new File(['raw'], 'pick.png', { type: 'image/png' });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||||
|
});
|
||||||
|
const confirmBtn = await screen.findByTestId('cropper-mock-confirm');
|
||||||
|
await user.click(confirmBtn);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole('dialog', { name: 'cropper-mock' })).toBeNull(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('MobileGroupDmInfo — edit toggle', () => {
|
||||||
|
it('owner: clicking Edit swaps the name <h1> for an <input> and reveals the Save/Cancel bar', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const dm = makeGroupDm({ name: 'My Group' });
|
||||||
|
setStoreState({ dmChannel: dm, authUser: makeUser({ id: 'user-self' }) });
|
||||||
|
|
||||||
|
renderScreen();
|
||||||
|
|
||||||
|
// Resting state: header heading is present, no input, no edit bar.
|
||||||
|
expect(document.querySelector('[data-mobile-group-name]')).not.toBeNull();
|
||||||
|
expect(document.querySelector('[data-mobile-group-name-input]')).toBeNull();
|
||||||
|
expect(document.querySelector('[data-mobile-group-edit-bar]')).toBeNull();
|
||||||
|
|
||||||
|
const editBtn = document.querySelector('[data-mobile-group-edit]') as HTMLButtonElement;
|
||||||
|
expect(editBtn).not.toBeNull();
|
||||||
|
await user.click(editBtn);
|
||||||
|
|
||||||
|
// After click: input replaces heading and the edit bar mounts.
|
||||||
|
expect(document.querySelector('[data-mobile-group-name]')).toBeNull();
|
||||||
|
const nameInput = document.querySelector('[data-mobile-group-name-input]') as HTMLInputElement;
|
||||||
|
expect(nameInput).not.toBeNull();
|
||||||
|
expect(nameInput.value).toBe('My Group');
|
||||||
|
expect(document.querySelector('[data-mobile-group-edit-bar]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-owner: Edit button is not rendered', () => {
|
||||||
|
const dm = makeGroupDm({ ownerId: 'user-2' }); // viewer NOT owner
|
||||||
|
setStoreState({ dmChannel: dm, authUser: makeUser({ id: 'user-self' }) });
|
||||||
|
|
||||||
|
renderScreen();
|
||||||
|
|
||||||
|
expect(document.querySelector('[data-mobile-group-edit]')).toBeNull();
|
||||||
|
// The name should be rendered as a heading, not an input.
|
||||||
|
expect(document.querySelector('[data-mobile-group-name]')).not.toBeNull();
|
||||||
|
expect(document.querySelector('[data-mobile-group-name-input]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MobileGroupDmInfo — save flows', () => {
|
||||||
|
it('name-only edit: Save fires api.dm.updateMetadata with { name }', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const dm = makeGroupDm({ name: 'Old Name' });
|
||||||
|
setStoreState({ dmChannel: dm, authUser: makeUser({ id: 'user-self' }) });
|
||||||
|
|
||||||
|
renderScreen();
|
||||||
|
|
||||||
|
await user.click(document.querySelector('[data-mobile-group-edit]') as HTMLButtonElement);
|
||||||
|
const input = document.querySelector('[data-mobile-group-name-input]') as HTMLInputElement;
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, 'New Name');
|
||||||
|
|
||||||
|
const saveBtn = document.querySelector('[data-mobile-group-save]') as HTMLButtonElement;
|
||||||
|
expect(saveBtn.disabled).toBe(false);
|
||||||
|
await user.click(saveBtn);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockUpdateMetadata).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockUpdateMetadata).toHaveBeenCalledWith('dm-1', { name: 'New Name' });
|
||||||
|
// Name-only path — upload helpers must NOT fire.
|
||||||
|
expect(mockStartUpload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('icon edit: Save first uploads the staged Blob, then PATCHes with { icon: filename }', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const dm = makeGroupDm();
|
||||||
|
setStoreState({ dmChannel: dm, authUser: makeUser({ id: 'user-self' }) });
|
||||||
|
|
||||||
|
renderScreen();
|
||||||
|
await user.click(document.querySelector('[data-mobile-group-edit]') as HTMLButtonElement);
|
||||||
|
|
||||||
|
// Run the cropper round-trip; the mocked cropper synchronously hands a
|
||||||
|
// Blob back to the component, transitioning iconState → 'staged'.
|
||||||
|
await stageIcon(user);
|
||||||
|
|
||||||
|
const saveBtn = document.querySelector('[data-mobile-group-save]') as HTMLButtonElement;
|
||||||
|
await waitFor(() => expect(saveBtn.disabled).toBe(false));
|
||||||
|
await user.click(saveBtn);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockStartUpload).toHaveBeenCalledTimes(1));
|
||||||
|
await waitFor(() => expect(mockUpdateMetadata).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockUpdateMetadata).toHaveBeenCalledWith('dm-1', { icon: 'icon-123.webp' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MobileGroupDmInfo — cancel', () => {
|
||||||
|
it('Cancel after staging an icon discards state — no /api/uploads call fires, name reverts', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const dm = makeGroupDm({ name: 'Stable' });
|
||||||
|
setStoreState({ dmChannel: dm, authUser: makeUser({ id: 'user-self' }) });
|
||||||
|
|
||||||
|
renderScreen();
|
||||||
|
await user.click(document.querySelector('[data-mobile-group-edit]') as HTMLButtonElement);
|
||||||
|
|
||||||
|
// Dirty the name AND stage an icon, then Cancel.
|
||||||
|
const input = document.querySelector('[data-mobile-group-name-input]') as HTMLInputElement;
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, 'Dirty Name');
|
||||||
|
await stageIcon(user);
|
||||||
|
|
||||||
|
const cancelBtn = document.querySelector('[data-mobile-group-save-cancel]') as HTMLButtonElement;
|
||||||
|
await user.click(cancelBtn);
|
||||||
|
|
||||||
|
// Edit bar unmounts; resting heading reappears with original name.
|
||||||
|
await waitFor(() => expect(document.querySelector('[data-mobile-group-edit-bar]')).toBeNull());
|
||||||
|
const heading = document.querySelector('[data-mobile-group-name]');
|
||||||
|
expect(heading?.textContent).toBe('Stable');
|
||||||
|
|
||||||
|
// No upload + no PATCH must have fired.
|
||||||
|
expect(mockStartUpload).not.toHaveBeenCalled();
|
||||||
|
expect(mockUpdateMetadata).not.toHaveBeenCalled();
|
||||||
|
const uploadCalls = fetchSpy.mock.calls.filter(([url]: [string]) =>
|
||||||
|
typeof url === 'string' && url.includes('/api/uploads'),
|
||||||
|
);
|
||||||
|
expect(uploadCalls.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MobileGroupDmInfo — member row interactions', () => {
|
||||||
|
it('tapping View Profile on a row pushes the mobile user-profile screen via openUserProfile', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const dm = makeGroupDm();
|
||||||
|
setStoreState({ dmChannel: dm, authUser: makeUser({ id: 'user-self' }) });
|
||||||
|
|
||||||
|
renderScreen();
|
||||||
|
|
||||||
|
// Right-click (contextMenu event) on the non-owner row "Alice" to open
|
||||||
|
// the row's menu. The kebab is rendered but contextMenu is the most
|
||||||
|
// reliable mobile-equivalent trigger in jsdom (no long-press in DOM).
|
||||||
|
const rows = document.querySelectorAll('[data-dm-member-row]');
|
||||||
|
// Owner is rendered first, then sorted online members. Alice should be
|
||||||
|
// the second row (online, non-owner, alphabetical).
|
||||||
|
const aliceRow = Array.from(rows).find((r) => (r as HTMLElement).dataset.userId === 'user-2');
|
||||||
|
expect(aliceRow).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.contextMenu(aliceRow!, { clientX: 100, clientY: 100 });
|
||||||
|
|
||||||
|
const profileBtn = await screen.findByText('View Profile');
|
||||||
|
await user.click(profileBtn);
|
||||||
|
|
||||||
|
// The DmMemberRow itself routes the "profile" action by calling
|
||||||
|
// `useUIStore.getState().openUserProfile(...)`. On mobile that pushes a
|
||||||
|
// `user-profile` entry onto the mobile stack (see uiStore.openUserProfile).
|
||||||
|
await waitFor(() => {
|
||||||
|
const stack = useUIStore.getState().mobileStack;
|
||||||
|
const top = stack[stack.length - 1];
|
||||||
|
expect(top?.screen).toBe('user-profile');
|
||||||
|
expect(top?.params?.userId).toBe('user-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('long-press / context menu on a row opens the action menu', async () => {
|
||||||
|
const dm = makeGroupDm();
|
||||||
|
setStoreState({ dmChannel: dm, authUser: makeUser({ id: 'user-self' }) });
|
||||||
|
|
||||||
|
renderScreen();
|
||||||
|
|
||||||
|
const rows = document.querySelectorAll('[data-dm-member-row]');
|
||||||
|
const aliceRow = Array.from(rows).find((r) => (r as HTMLElement).dataset.userId === 'user-2');
|
||||||
|
expect(aliceRow).toBeTruthy();
|
||||||
|
|
||||||
|
// Synthetic contextmenu event — DmMemberRow's `onContextMenu` handler
|
||||||
|
// calls openContextMenu(). After firing, the portal-rendered menu should
|
||||||
|
// contain at least the "View Profile" entry.
|
||||||
|
fireEvent.contextMenu(aliceRow!, { clientX: 100, clientY: 100 });
|
||||||
|
|
||||||
|
const profile = await screen.findByText('View Profile');
|
||||||
|
expect(profile).not.toBeNull();
|
||||||
|
// Owner-caller + non-self → Transfer + Remove visible too.
|
||||||
|
expect(screen.queryByText('Transfer Ownership')).not.toBeNull();
|
||||||
|
expect(screen.queryByText('Remove from Group')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { DmChannel, User } from '@backspace/shared';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { useSocialStore } from '../../stores/socialStore';
|
||||||
|
import { useTransferStore } from '../../stores/transferStore';
|
||||||
|
import { waitForTransferAttachment } from '../../utils/waitForTransfer';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
import { isSelf, parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity';
|
||||||
|
import { useVisualViewportInset } from '../../hooks/useVisualViewportInset';
|
||||||
|
import { AvatarStack } from '../ui/AvatarStack';
|
||||||
|
import { ImageCropModal } from '../ui/ImageCropModal';
|
||||||
|
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||||
|
import { MobileScreenHeader } from './MobileScreenHeader';
|
||||||
|
import { DmMemberRow, type DmMemberRowAction } from './DmMemberRow';
|
||||||
|
|
||||||
|
const MAX_NAME_LENGTH = 50;
|
||||||
|
const MAX_GROUP_MEMBERS = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iconography for the federation globe shown next to the group name on mobile.
|
||||||
|
* No tooltip on mobile — the dedicated info screen surfaces federation
|
||||||
|
* identity via the per-member rows, so the global indicator is intentionally
|
||||||
|
* decorative here.
|
||||||
|
*/
|
||||||
|
function GroupGlobeIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="text-txt-tertiary/80 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sort helper — alphabetical by display-name fallback, lower-cased. */
|
||||||
|
function sortByDisplayName(a: User, b: User): number {
|
||||||
|
const aName = (a.displayName ?? parseFederatedUsername(a.username).baseName).toLowerCase();
|
||||||
|
const bName = (b.displayName ?? parseFederatedUsername(b.username).baseName).toLowerCase();
|
||||||
|
return aName.localeCompare(bName);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobileGroupDmInfoProps {
|
||||||
|
params?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushed mobile screen for group DM info + management.
|
||||||
|
*
|
||||||
|
* Mirrors `MobileMembersScreen` geometry (header + scrollable body) and
|
||||||
|
* surfaces the same surface area as the desktop `GroupDmSettings` modal +
|
||||||
|
* `DmRosterPanel`, condensed into a single column. The Edit button toggles an
|
||||||
|
* **in-place** edit mode inside the hero — this avoids pushing yet another
|
||||||
|
* screen on top of the screen stack (an anti-pattern in MobileScreenStack).
|
||||||
|
*
|
||||||
|
* Reads its target channel from `params.channelId`, set by the caller (e.g.
|
||||||
|
* `MobileChatScreen`'s members button).
|
||||||
|
*/
|
||||||
|
export function MobileGroupDmInfo({ params }: MobileGroupDmInfoProps) {
|
||||||
|
const channelId = params?.channelId ?? null;
|
||||||
|
|
||||||
|
const dmChannels = useSpaceStore((s) => s.dmChannels);
|
||||||
|
const authUser = useAuthStore((s) => s.user);
|
||||||
|
const friends = useSocialStore((s) => s.friends);
|
||||||
|
const openModal = useUIStore((s) => s.openModal);
|
||||||
|
const popMobileScreen = useUIStore((s) => s.popMobileScreen);
|
||||||
|
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
||||||
|
const addToast = useUIStore((s) => s.addToast);
|
||||||
|
|
||||||
|
const dmChannel = useMemo(
|
||||||
|
() => dmChannels.find((dm) => dm.id === channelId) ?? null,
|
||||||
|
[dmChannels, channelId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Inline edit state ──────────────────────────────────────────────────
|
||||||
|
type IconState =
|
||||||
|
| { kind: 'unchanged' }
|
||||||
|
| { kind: 'cleared' }
|
||||||
|
| { kind: 'staged'; blob: Blob; previewUrl: string };
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [iconState, setIconState] = useState<IconState>({ kind: 'unchanged' });
|
||||||
|
const [cropSrc, setCropSrc] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState('');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// ── Destructive confirms (kick + transfer + leave) ─────────────────────
|
||||||
|
const [pendingKick, setPendingKick] = useState<User | null>(null);
|
||||||
|
const [pendingTransfer, setPendingTransfer] = useState<User | null>(null);
|
||||||
|
const [submittingMemberAction, setSubmittingMemberAction] = useState(false);
|
||||||
|
const [confirmLeave, setConfirmLeave] = useState(false);
|
||||||
|
const [leaving, setLeaving] = useState(false);
|
||||||
|
|
||||||
|
// ── iOS keyboard-aware Save/Cancel bar ─────────────────────────────────
|
||||||
|
// `useVisualViewportInset` returns a CSS value that resolves to
|
||||||
|
// `env(safe-area-inset-bottom)` when no keyboard is open, or `<n>px` of
|
||||||
|
// occlusion when one is. We paste that straight into the bar's `bottom`
|
||||||
|
// style so it rides above the soft keyboard on iOS PWA.
|
||||||
|
const { value: bottomInset, keyboardOpen } = useVisualViewportInset();
|
||||||
|
|
||||||
|
// Reset edit state whenever the channel changes or edit mode opens.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dmChannel) return;
|
||||||
|
if (editing) {
|
||||||
|
setName(dmChannel.name ?? '');
|
||||||
|
setIconState((prev) => {
|
||||||
|
if (prev.kind === 'staged') URL.revokeObjectURL(prev.previewUrl);
|
||||||
|
return { kind: 'unchanged' };
|
||||||
|
});
|
||||||
|
setSaveError('');
|
||||||
|
}
|
||||||
|
}, [editing, dmChannel?.id, dmChannel?.name]);
|
||||||
|
|
||||||
|
// Final cleanup: revoke any lingering preview URL on unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setIconState((prev) => {
|
||||||
|
if (prev.kind === 'staged') URL.revokeObjectURL(prev.previewUrl);
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Empty / non-group safety ───────────────────────────────────────────
|
||||||
|
if (!dmChannel) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-surface-base">
|
||||||
|
<MobileScreenHeader title="Group Info" />
|
||||||
|
<div className="flex-1 flex items-center justify-center text-txt-tertiary text-sm">
|
||||||
|
Conversation not found.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// This screen is meaningless for 1-on-1 DMs.
|
||||||
|
if (!dmChannel.ownerId) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-surface-base">
|
||||||
|
<MobileScreenHeader title="Info" />
|
||||||
|
<div className="flex-1 flex items-center justify-center text-txt-tertiary text-sm">
|
||||||
|
This conversation has no group info.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = !!authUser && dmChannel.ownerId === authUser.id;
|
||||||
|
|
||||||
|
const otherMembers: User[] = authUser
|
||||||
|
? dmChannel.members.filter((m) => !isSelf(m, authUser))
|
||||||
|
: dmChannel.members;
|
||||||
|
|
||||||
|
const fallbackName = otherMembers
|
||||||
|
.map((m) => m.displayName ?? parseFederatedUsername(m.username).baseName)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
const displayName = dmChannel.name && dmChannel.name.length > 0 ? dmChannel.name : fallbackName || 'Group DM';
|
||||||
|
|
||||||
|
const currentName = dmChannel.name ?? '';
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
const nameDirty = trimmedName !== currentName.trim();
|
||||||
|
const iconDirty = iconState.kind !== 'unchanged';
|
||||||
|
const isDirty = nameDirty || iconDirty;
|
||||||
|
|
||||||
|
const previewIconUrl: string | null | undefined =
|
||||||
|
iconState.kind === 'staged'
|
||||||
|
? iconState.previewUrl
|
||||||
|
: iconState.kind === 'cleared'
|
||||||
|
? null
|
||||||
|
: (dmChannel.icon ?? null);
|
||||||
|
|
||||||
|
// Show the global federation globe next to the group name when any member
|
||||||
|
// (besides self) is federated. Mobile intentionally omits the tooltip —
|
||||||
|
// per-member rows below carry the federation identity explicitly.
|
||||||
|
const hasFederatedMember = otherMembers.some((m) => isFederationGlobeApplicable(m));
|
||||||
|
|
||||||
|
// Member buckets — owner first, then online/offline alphabetically.
|
||||||
|
const ownerMember = dmChannel.members.find((m) => m.id === dmChannel.ownerId) ?? null;
|
||||||
|
const nonOwnerMembers = dmChannel.members.filter((m) => m.id !== dmChannel.ownerId);
|
||||||
|
const onlineMembers = nonOwnerMembers
|
||||||
|
.filter((m) => m.status !== 'offline')
|
||||||
|
.sort(sortByDisplayName);
|
||||||
|
const offlineMembers = nonOwnerMembers
|
||||||
|
.filter((m) => m.status === 'offline')
|
||||||
|
.sort(sortByDisplayName);
|
||||||
|
|
||||||
|
const memberCount = dmChannel.members.length;
|
||||||
|
const canAddMembers = memberCount < MAX_GROUP_MEMBERS;
|
||||||
|
|
||||||
|
// Friend lookup — federation-safe local-id compare (mirrors DmRosterPanel).
|
||||||
|
const isFriendOfCaller = (m: User): boolean => friends.some((f) => f.id === m.id);
|
||||||
|
|
||||||
|
// ── Icon handlers ──────────────────────────────────────────────────────
|
||||||
|
const handleHeroClick = () => {
|
||||||
|
if (!editing || !isOwner) return;
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => setCropSrc(reader.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCropComplete = (blob: Blob) => {
|
||||||
|
setIconState((prev) => {
|
||||||
|
if (prev.kind === 'staged') URL.revokeObjectURL(prev.previewUrl);
|
||||||
|
const previewUrl = URL.createObjectURL(blob);
|
||||||
|
return { kind: 'staged', blob, previewUrl };
|
||||||
|
});
|
||||||
|
setCropSrc(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearIcon = () => {
|
||||||
|
if (!isOwner) return;
|
||||||
|
setIconState((prev) => {
|
||||||
|
if (prev.kind === 'staged') URL.revokeObjectURL(prev.previewUrl);
|
||||||
|
return { kind: 'cleared' };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Save / Cancel ─────────────────────────────────────────────────────
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIconState((prev) => {
|
||||||
|
if (prev.kind === 'staged') URL.revokeObjectURL(prev.previewUrl);
|
||||||
|
return { kind: 'unchanged' };
|
||||||
|
});
|
||||||
|
setSaveError('');
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!channelId || !isOwner || !isDirty || saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError('');
|
||||||
|
try {
|
||||||
|
const body: { name?: string | null; icon?: string | null } = {};
|
||||||
|
|
||||||
|
if (nameDirty) {
|
||||||
|
body.name = trimmedName.slice(0, MAX_NAME_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconState.kind === 'cleared') {
|
||||||
|
body.icon = null;
|
||||||
|
} else if (iconState.kind === 'staged') {
|
||||||
|
const file = new File([iconState.blob], 'dm-icon.webp', {
|
||||||
|
type: iconState.blob.type || 'image/webp',
|
||||||
|
});
|
||||||
|
const tid = await useTransferStore.getState().startUpload(file, {
|
||||||
|
tray: false,
|
||||||
|
});
|
||||||
|
const { filename } = await waitForTransferAttachment(tid);
|
||||||
|
body.icon = filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.dm.updateMetadata(channelId, body);
|
||||||
|
// Reset state and exit edit mode. The WS `dm_channel_updated` event
|
||||||
|
// will refresh `dmChannels` in-place.
|
||||||
|
setIconState((prev) => {
|
||||||
|
if (prev.kind === 'staged') URL.revokeObjectURL(prev.previewUrl);
|
||||||
|
return { kind: 'unchanged' };
|
||||||
|
});
|
||||||
|
setEditing(false);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Failed to save settings';
|
||||||
|
setSaveError(msg);
|
||||||
|
addToast(msg, 'warning', 4000);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Leave ──────────────────────────────────────────────────────────────
|
||||||
|
const handleConfirmLeave = async () => {
|
||||||
|
if (!channelId || leaving) return;
|
||||||
|
setLeaving(true);
|
||||||
|
try {
|
||||||
|
await api.dm.leave(channelId);
|
||||||
|
// Return to the previous screen (typically MobileDmsScreen via
|
||||||
|
// MobileChatScreen). The user is no longer a member.
|
||||||
|
popMobileScreen();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Failed to leave group';
|
||||||
|
addToast(msg, 'warning', 4000);
|
||||||
|
} finally {
|
||||||
|
setLeaving(false);
|
||||||
|
setConfirmLeave(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Per-member action handler ──────────────────────────────────────────
|
||||||
|
const handleMemberAction = async (action: DmMemberRowAction, member: User) => {
|
||||||
|
if (action === 'profile') {
|
||||||
|
// DmMemberRow normally opens the profile itself. Fallback path —
|
||||||
|
// push the mobile user-profile screen directly.
|
||||||
|
pushMobileScreen('user-profile', { userId: member.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'kick') {
|
||||||
|
setPendingKick(member);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'transfer') {
|
||||||
|
setPendingTransfer(member);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'remove-friend') {
|
||||||
|
try {
|
||||||
|
await useSocialStore.getState().removeFriend(member.id);
|
||||||
|
} catch (err) {
|
||||||
|
addToast(
|
||||||
|
err instanceof Error ? err.message : 'Failed to remove friend',
|
||||||
|
'warning',
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmKick = async () => {
|
||||||
|
if (!pendingKick || !channelId) return;
|
||||||
|
setSubmittingMemberAction(true);
|
||||||
|
try {
|
||||||
|
await api.dm.kickMember(channelId, 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 {
|
||||||
|
setSubmittingMemberAction(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmTransfer = async () => {
|
||||||
|
if (!pendingTransfer || !channelId) return;
|
||||||
|
setSubmittingMemberAction(true);
|
||||||
|
try {
|
||||||
|
await api.dm.transferOwnership(channelId, 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 {
|
||||||
|
setSubmittingMemberAction(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render helpers ─────────────────────────────────────────────────────
|
||||||
|
const renderMemberRow = (member: User, ownerFlag: boolean) => (
|
||||||
|
<DmMemberRow
|
||||||
|
key={member.id}
|
||||||
|
member={member}
|
||||||
|
isOwner={ownerFlag}
|
||||||
|
isSelf={!!authUser && isSelf(member, authUser)}
|
||||||
|
callerIsOwner={isOwner}
|
||||||
|
isFriend={isFriendOfCaller(member)}
|
||||||
|
showKebab
|
||||||
|
alwaysShowKebab
|
||||||
|
onMenuAction={handleMemberAction}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The header acts as the back button. When in edit mode we add a `Cancel`
|
||||||
|
// text action on the right — pairs with the Save/Cancel bar at the bottom
|
||||||
|
// (intentional duplication so a tap-target is always reachable above the
|
||||||
|
// keyboard).
|
||||||
|
const headerRight = editing ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-2 py-1 text-sm text-txt-tertiary hover:text-txt-secondary"
|
||||||
|
data-mobile-edit-cancel
|
||||||
|
aria-label="Cancel edit"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-surface-base">
|
||||||
|
<MobileScreenHeader title="Group Info" rightActions={headerRight} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex-1 overflow-y-auto"
|
||||||
|
// Reserve room for the Save/Cancel bar when it's pinned to the
|
||||||
|
// bottom — otherwise the destructive footer can hide under it.
|
||||||
|
style={editing ? { paddingBottom: keyboardOpen ? 72 : 96 } : undefined}
|
||||||
|
>
|
||||||
|
{/* HERO ────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-col items-center gap-3 px-4 pt-8 pb-6 border-b border-border-soft">
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleHeroClick}
|
||||||
|
disabled={!editing || !isOwner}
|
||||||
|
data-mobile-group-icon-hero
|
||||||
|
aria-label={editing && isOwner ? 'Change group icon' : 'Group icon'}
|
||||||
|
className={`relative block rounded-full overflow-hidden ${
|
||||||
|
editing && isOwner ? 'cursor-pointer' : 'cursor-default'
|
||||||
|
}`}
|
||||||
|
style={{ width: 80, height: 80 }}
|
||||||
|
>
|
||||||
|
<AvatarStack
|
||||||
|
members={otherMembers}
|
||||||
|
size={80}
|
||||||
|
border="chat"
|
||||||
|
iconUrl={previewIconUrl}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Clear (X) — owner-only, edit mode only, only when there's an icon to clear. */}
|
||||||
|
{editing && isOwner && previewIconUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearIcon}
|
||||||
|
data-mobile-group-icon-clear
|
||||||
|
aria-label="Remove group icon"
|
||||||
|
className="absolute -top-1 -right-1 w-6 h-6 rounded-full bg-surface-elevated border border-border-hard flex items-center justify-center text-txt-tertiary hover:text-txt-danger hover:bg-accent-rose/10 transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name — input in edit mode, header otherwise. */}
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value.slice(0, MAX_NAME_LENGTH))}
|
||||||
|
placeholder={fallbackName || 'Group DM'}
|
||||||
|
maxLength={MAX_NAME_LENGTH}
|
||||||
|
disabled={!isOwner}
|
||||||
|
data-mobile-group-name-input
|
||||||
|
aria-label="Group name"
|
||||||
|
className="input-standard w-full max-w-[280px] text-center text-base"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center gap-1.5 max-w-full">
|
||||||
|
<h1
|
||||||
|
data-mobile-group-name
|
||||||
|
className="text-xl font-semibold text-txt-primary text-center truncate max-w-[260px]"
|
||||||
|
title={displayName}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</h1>
|
||||||
|
{hasFederatedMember && (
|
||||||
|
<span data-mobile-group-globe className="inline-flex" aria-label="Federated group">
|
||||||
|
<GroupGlobeIcon />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-txt-tertiary">
|
||||||
|
{memberCount} {memberCount === 1 ? 'member' : 'members'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Edit toggle — owner-only, hidden during edit (Cancel header action takes its place). */}
|
||||||
|
{isOwner && !editing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
data-mobile-group-edit
|
||||||
|
className="mt-1 px-4 py-1.5 rounded-full bg-surface-elevated hover:bg-interactive-hover text-txt-secondary text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{saveError && editing && (
|
||||||
|
<div className="w-full max-w-[280px] mt-1 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-xs text-center">
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ACTIONS ROW ──────────────────────────────────────────────────── */}
|
||||||
|
<div className="px-4 py-3 border-b border-border-soft">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => channelId && openModal('addDmMember', { dmChannelId: channelId })}
|
||||||
|
disabled={!canAddMembers}
|
||||||
|
data-mobile-group-add-member
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-md bg-accent-mint/10 hover:bg-accent-mint/20 text-accent-mint text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
Add Member
|
||||||
|
{!canAddMembers && (
|
||||||
|
<span className="text-[11px] text-txt-tertiary">— Group is full</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MEMBERS LIST ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="p-3">
|
||||||
|
{ownerMember && (
|
||||||
|
<div data-mobile-group-section="owner" className="mb-4">
|
||||||
|
<h3 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1">
|
||||||
|
OWNER
|
||||||
|
</h3>
|
||||||
|
{renderMemberRow(ownerMember, true)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onlineMembers.length > 0 && (
|
||||||
|
<div data-mobile-group-section="online" className="mb-4">
|
||||||
|
<h3 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1">
|
||||||
|
ONLINE — {onlineMembers.length}
|
||||||
|
</h3>
|
||||||
|
{onlineMembers.map((m) => renderMemberRow(m, false))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{offlineMembers.length > 0 && (
|
||||||
|
<div data-mobile-group-section="offline" className="opacity-60">
|
||||||
|
<h3 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1">
|
||||||
|
OFFLINE — {offlineMembers.length}
|
||||||
|
</h3>
|
||||||
|
{offlineMembers.map((m) => renderMemberRow(m, false))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DESTRUCTIVE FOOTER ────────────────────────────────────────────── */}
|
||||||
|
<div className="px-4 py-4 border-t border-border-soft">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmLeave(true)}
|
||||||
|
disabled={leaving}
|
||||||
|
data-mobile-group-leave
|
||||||
|
className="w-full px-4 py-2.5 text-accent-rose hover:bg-accent-rose/10 text-sm font-medium rounded-md transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{leaving ? 'Leaving...' : 'Leave Group'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save / Cancel bar — pinned to the visual viewport bottom so the
|
||||||
|
iOS soft keyboard doesn't occlude it. Mounted only in edit mode. */}
|
||||||
|
{editing && (
|
||||||
|
<div
|
||||||
|
data-mobile-group-edit-bar
|
||||||
|
className="fixed left-0 right-0 z-30 bg-surface-base border-t border-border-hard px-4 py-3 flex items-center justify-end gap-2"
|
||||||
|
style={{ bottom: bottomInset }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-3 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50"
|
||||||
|
data-mobile-group-save-cancel
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isDirty || saving || !isOwner}
|
||||||
|
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
data-mobile-group-save
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cropper for new icons — 1:1, 256px max, matches GroupDmSettings. */}
|
||||||
|
<ImageCropModal
|
||||||
|
isOpen={cropSrc !== null}
|
||||||
|
onClose={() => setCropSrc(null)}
|
||||||
|
imageSrc={cropSrc ?? ''}
|
||||||
|
onCropComplete={handleCropComplete}
|
||||||
|
title="Crop Group Icon"
|
||||||
|
cropShape="round"
|
||||||
|
aspectRatio={1}
|
||||||
|
maxOutputDimension={256}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Destructive confirms — leave + kick + transfer */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={confirmLeave}
|
||||||
|
onClose={() => { if (!leaving) setConfirmLeave(false); }}
|
||||||
|
onConfirm={handleConfirmLeave}
|
||||||
|
title="Leave Group"
|
||||||
|
description={`Leave "${displayName}"? You will stop receiving messages from this conversation.`}
|
||||||
|
confirmLabel="Leave"
|
||||||
|
variant="danger"
|
||||||
|
loading={leaving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!pendingKick}
|
||||||
|
onClose={() => { if (!submittingMemberAction) 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={submittingMemberAction}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!pendingTransfer}
|
||||||
|
onClose={() => { if (!submittingMemberAction) 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={submittingMemberAction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export the DmChannel shape for downstream test fixtures (kept tiny).
|
||||||
|
export type { DmChannel };
|
||||||
@@ -18,6 +18,7 @@ import { TransferIndicator } from './TransferIndicator';
|
|||||||
import { MobileVoiceMiniBar } from './MobileVoiceMiniBar';
|
import { MobileVoiceMiniBar } from './MobileVoiceMiniBar';
|
||||||
import { MobileVoiceFullScreen } from './MobileVoiceFullScreen';
|
import { MobileVoiceFullScreen } from './MobileVoiceFullScreen';
|
||||||
import { MobileMembersScreen } from './MobileMembersScreen';
|
import { MobileMembersScreen } from './MobileMembersScreen';
|
||||||
|
import { MobileGroupDmInfo } from './MobileGroupDmInfo';
|
||||||
import { FriendsPage } from '../chat/FriendsPage';
|
import { FriendsPage } from '../chat/FriendsPage';
|
||||||
import { ExplorePage } from '../chat/ExplorePage';
|
import { ExplorePage } from '../chat/ExplorePage';
|
||||||
import { UserProfileModal } from '../modals/UserProfileModal';
|
import { UserProfileModal } from '../modals/UserProfileModal';
|
||||||
@@ -89,6 +90,7 @@ const screenMap: Record<string, (params?: Record<string, string>) => React.React
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
'members': (params) => <MobileMembersScreen params={params} />,
|
'members': (params) => <MobileMembersScreen params={params} />,
|
||||||
|
'group-dm-info': (params) => <MobileGroupDmInfo params={params} />,
|
||||||
'voice-full': () => <MobileVoiceFullScreen />,
|
'voice-full': () => <MobileVoiceFullScreen />,
|
||||||
'explore': () => <ExplorePage />,
|
'explore': () => <ExplorePage />,
|
||||||
'user-profile': (params) => {
|
'user-profile': (params) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user