From c0a61dc0fca4e774e09cdeb31d1329ac61bf73fc Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 10 May 2026 20:49:42 +0200 Subject: [PATCH] feat(mobile): MobileGroupDmInfo pushed screen with inline edit --- .../components/layout/DmMemberRow.test.tsx | 19 + .../web/src/components/layout/DmMemberRow.tsx | 16 +- .../layout/MobileGroupDmInfo.test.tsx | 405 +++++++++++ .../components/layout/MobileGroupDmInfo.tsx | 668 ++++++++++++++++++ .../web/src/components/layout/MobileShell.tsx | 2 + 5 files changed, 1109 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/components/layout/MobileGroupDmInfo.test.tsx create mode 100644 packages/web/src/components/layout/MobileGroupDmInfo.tsx diff --git a/packages/web/src/components/layout/DmMemberRow.test.tsx b/packages/web/src/components/layout/DmMemberRow.test.tsx index c449dd8c..9ab90a94 100644 --- a/packages/web/src/components/layout/DmMemberRow.test.tsx +++ b/packages/web/src/components/layout/DmMemberRow.test.tsx @@ -76,6 +76,7 @@ function renderRow(props: Partial[0]> = {}) { callerIsOwner={props.callerIsOwner ?? false} isFriend={props.isFriend ?? false} showKebab={props.showKebab ?? false} + alwaysShowKebab={props.alwaysShowKebab ?? false} onMenuAction={props.onMenuAction ?? onMenuAction} /> @@ -199,6 +200,24 @@ describe('DmMemberRow — kebab', () => { 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('[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('[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 () => { const user = userEvent.setup(); const { container } = renderRow({ diff --git a/packages/web/src/components/layout/DmMemberRow.tsx b/packages/web/src/components/layout/DmMemberRow.tsx index 5bf02b70..a20b6335 100644 --- a/packages/web/src/components/layout/DmMemberRow.tsx +++ b/packages/web/src/components/layout/DmMemberRow.tsx @@ -25,6 +25,15 @@ export interface DmMemberRowProps { isFriend: boolean; /** Whether to render the kebab "⋮" trigger button (right side of the row). */ 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. */ onMenuAction: (action: DmMemberRowAction, member: User) => void; } @@ -74,6 +83,7 @@ export function DmMemberRow({ callerIsOwner, isFriend, showKebab = false, + alwaysShowKebab = false, onMenuAction, }: DmMemberRowProps) { const canonical = useCanonicalUserView(member); @@ -229,7 +239,11 @@ export function DmMemberRow({ // Right-click on the kebab still opens the same menu at cursor. 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' + }`} > diff --git a/packages/web/src/components/layout/MobileGroupDmInfo.test.tsx b/packages/web/src/components/layout/MobileGroupDmInfo.test.tsx new file mode 100644 index 00000000..e3d1702a --- /dev/null +++ b/packages/web/src/components/layout/MobileGroupDmInfo.test.tsx @@ -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 ( +
+ + +
+ ); + }, +})); + +// 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 { + 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 { + 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>); + useAuthStore.setState({ user: opts.authUser } as Partial>); + useSocialStore.setState({ friends: [] } as Partial>); +} + +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( + <> + + + , + ); +} + +// ── Helper: drive a crop blob into the staged-icon state (matches the +// GroupDmSettings.test.tsx pattern). ────────────────────────────────────── +async function stageIcon(user: ReturnType) { + 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

for an 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(); + }); +}); diff --git a/packages/web/src/components/layout/MobileGroupDmInfo.tsx b/packages/web/src/components/layout/MobileGroupDmInfo.tsx new file mode 100644 index 00000000..47260fca --- /dev/null +++ b/packages/web/src/components/layout/MobileGroupDmInfo.tsx @@ -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 ( + + ); +} + +/** 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; +} + +/** + * 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({ kind: 'unchanged' }); + const [cropSrc, setCropSrc] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(''); + const fileInputRef = useRef(null); + + // ── Destructive confirms (kick + transfer + leave) ───────────────────── + const [pendingKick, setPendingKick] = useState(null); + const [pendingTransfer, setPendingTransfer] = useState(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 `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 ( +
+ +
+ Conversation not found. +
+
+ ); + } + // This screen is meaningless for 1-on-1 DMs. + if (!dmChannel.ownerId) { + return ( +
+ +
+ This conversation has no group info. +
+
+ ); + } + + 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) => { + 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) => ( + + ); + + // 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 ? ( + + ) : null; + + // ── Render ───────────────────────────────────────────────────────────── + return ( +
+ + +
+ {/* HERO ────────────────────────────────────────────────────────── */} +
+
+ + + {/* Clear (X) — owner-only, edit mode only, only when there's an icon to clear. */} + {editing && isOwner && previewIconUrl && ( + + )} + + +
+ + {/* Name — input in edit mode, header otherwise. */} + {editing ? ( + 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" + /> + ) : ( +
+

+ {displayName} +

+ {hasFederatedMember && ( + + + + )} +
+ )} + +

+ {memberCount} {memberCount === 1 ? 'member' : 'members'} +

+ + {/* Edit toggle — owner-only, hidden during edit (Cancel header action takes its place). */} + {isOwner && !editing && ( + + )} + + {saveError && editing && ( +
+ {saveError} +
+ )} +
+ + {/* ACTIONS ROW ──────────────────────────────────────────────────── */} +
+ +
+ + {/* MEMBERS LIST ─────────────────────────────────────────────────── */} +
+ {ownerMember && ( +
+

+ OWNER +

+ {renderMemberRow(ownerMember, true)} +
+ )} + + {onlineMembers.length > 0 && ( +
+

+ ONLINE — {onlineMembers.length} +

+ {onlineMembers.map((m) => renderMemberRow(m, false))} +
+ )} + + {offlineMembers.length > 0 && ( +
+

+ OFFLINE — {offlineMembers.length} +

+ {offlineMembers.map((m) => renderMemberRow(m, false))} +
+ )} +
+ + {/* DESTRUCTIVE FOOTER ────────────────────────────────────────────── */} +
+ +
+
+ + {/* Save / Cancel bar — pinned to the visual viewport bottom so the + iOS soft keyboard doesn't occlude it. Mounted only in edit mode. */} + {editing && ( +
+ + +
+ )} + + {/* Cropper for new icons — 1:1, 256px max, matches GroupDmSettings. */} + setCropSrc(null)} + imageSrc={cropSrc ?? ''} + onCropComplete={handleCropComplete} + title="Crop Group Icon" + cropShape="round" + aspectRatio={1} + maxOutputDimension={256} + /> + + {/* Destructive confirms — leave + kick + transfer */} + { 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} + /> + + { 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} + /> + + { 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} + /> +
+ ); +} + +// Re-export the DmChannel shape for downstream test fixtures (kept tiny). +export type { DmChannel }; diff --git a/packages/web/src/components/layout/MobileShell.tsx b/packages/web/src/components/layout/MobileShell.tsx index e69d5509..9a2546fb 100644 --- a/packages/web/src/components/layout/MobileShell.tsx +++ b/packages/web/src/components/layout/MobileShell.tsx @@ -18,6 +18,7 @@ import { TransferIndicator } from './TransferIndicator'; import { MobileVoiceMiniBar } from './MobileVoiceMiniBar'; import { MobileVoiceFullScreen } from './MobileVoiceFullScreen'; import { MobileMembersScreen } from './MobileMembersScreen'; +import { MobileGroupDmInfo } from './MobileGroupDmInfo'; import { FriendsPage } from '../chat/FriendsPage'; import { ExplorePage } from '../chat/ExplorePage'; import { UserProfileModal } from '../modals/UserProfileModal'; @@ -89,6 +90,7 @@ const screenMap: Record) => React.React ), 'members': (params) => , + 'group-dm-info': (params) => , 'voice-full': () => , 'explore': () => , 'user-profile': (params) => {