import React, { useState, useRef, useEffect, useMemo } from 'react'; import type { User } from '@backspace/shared'; import { Modal } from '../ui/Modal'; import { ImageCropModal } from '../ui/ImageCropModal'; import { ConfirmDialog } from '../ui/ConfirmDialog'; 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 } from '../../utils/identity'; import { AvatarStack } from '../ui/AvatarStack'; import { DmMemberRow, type DmMemberRowAction } from '../layout/DmMemberRow'; import { pointAnchor } from '../../hooks/useFloatingPosition'; const MAX_NAME_LENGTH = 50; const MAX_GROUP_MEMBERS = 10; type Tab = 'overview' | 'members'; /** * Settings modal for a group DM. Mirrors `SpaceSettings` structurally: * - desktop: left rail (channel card + tab list) + content area * - mobile: tab list, then content with a back button * * Reads its target channel from `useUIStore.modalData.dmChannelId`. Optional * `initialTab` selects which tab opens first. * * Owner detection: `dmChannel.ownerId === currentUser.id` — local id compare. * Non-owners see read-only fields (icon click is a no-op, name input disabled, * Save button absent). "Leave Group" is enabled for everyone. * * Save flow: * 1. If an icon blob is staged, upload it via transferStore. * 2. Build PATCH body with ONLY changed fields (name and/or icon). * Cleared icon → `icon: null`. * 3. `api.dm.updateMetadata(channelId, body)` then close the modal. * Cancel discards the staged blob; no upload fires. */ export function GroupDmSettings() { const activeModal = useUIStore((s) => s.activeModal); const modalData = useUIStore((s) => s.modalData); const closeModal = useUIStore((s) => s.closeModal); const isMobile = useUIStore((s) => s.isMobile); const addToast = useUIStore((s) => s.addToast); const dmChannels = useSpaceStore((s) => s.dmChannels); const authUser = useAuthStore((s) => s.user); const friends = useSocialStore((s) => s.friends); const isOpen = activeModal === 'groupDmSettings'; const dmChannelId = (modalData.dmChannelId as string | undefined) ?? null; const initialTab = (modalData.initialTab as Tab | undefined) ?? 'overview'; const dmChannel = useMemo( () => dmChannels.find((dm) => dm.id === dmChannelId) ?? null, [dmChannels, dmChannelId], ); // ── Tab + mobile pane state ──────────────────────────────────────────── const [tab, setTab] = useState(initialTab); const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs'); useEffect(() => { if (isOpen) { setTab(initialTab); setMobileView('tabs'); } }, [isOpen, initialTab]); // ── Overview state ───────────────────────────────────────────────────── // `iconState`: // 'unchanged' — nothing staged; current dm.icon is in effect // 'cleared' — owner clicked the X; will PATCH `icon: null` // { blob, previewUrl } — owner cropped a fresh blob; deferred upload type IconState = | { kind: 'unchanged' } | { kind: 'cleared' } | { kind: 'staged'; blob: Blob; previewUrl: string }; 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 [confirmLeave, setConfirmLeave] = useState(false); const [leaving, setLeaving] = useState(false); // Member-action confirmation state lives at the same level as the rest of // the modal's hooks — declared up here so it stays before the early returns // below (React's rules-of-hooks forbid conditional hook calls). const [pendingKick, setPendingKick] = useState(null); const [pendingTransfer, setPendingTransfer] = useState(null); const [memberActionSubmitting, setMemberActionSubmitting] = useState(false); const fileInputRef = useRef(null); // Reset overview state when the modal opens or the underlying channel changes. // Revoking previously-staged preview URLs prevents memory leaks across opens. useEffect(() => { if (!isOpen || !dmChannel) return; setName(dmChannel.name ?? ''); setIconState((prev) => { if (prev.kind === 'staged') URL.revokeObjectURL(prev.previewUrl); return { kind: 'unchanged' }; }); setSaveError(''); setConfirmLeave(false); }, [isOpen, 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; }); }; }, []); if (!isOpen || !dmChannel || !dmChannelId) return null; // Group DMs only: this modal is meaningless for 1-on-1 conversations. if (!dmChannel.ownerId) return null; 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 currentName = dmChannel.name ?? ''; const trimmedName = name.trim(); const nameDirty = trimmedName !== currentName.trim(); const iconDirty = iconState.kind !== 'unchanged'; const isDirty = nameDirty || iconDirty; // What the AvatarStack should show: staged preview > cleared (=no icon) > // current dm.icon. Passing `null` falls through to the member-tile layout. const previewIconUrl: string | null | undefined = iconState.kind === 'staged' ? iconState.previewUrl : iconState.kind === 'cleared' ? null : (dmChannel.icon ?? null); // ── Icon handlers ────────────────────────────────────────────────────── const handleHeroClick = () => { if (!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); // Reset so picking the same file again re-fires onChange. 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 / Leave ────────────────────────────────────────────── const handleCancel = () => { // Discard staged blob (no upload fired) and close. setIconState((prev) => { if (prev.kind === 'staged') URL.revokeObjectURL(prev.previewUrl); return { kind: 'unchanged' }; }); closeModal(); }; const handleSave = async () => { if (!isOwner || !isDirty || saving) return; setSaving(true); setSaveError(''); try { const body: { name?: string | null; icon?: string | null } = {}; if (nameDirty) { // Trimmed, enforced to MAX_NAME_LENGTH client-side; server re-validates. body.name = trimmedName.slice(0, MAX_NAME_LENGTH); } if (iconState.kind === 'cleared') { body.icon = null; } else if (iconState.kind === 'staged') { // Defer-to-save upload: only fires when the user commits the change. 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(dmChannelId, body); // Mirror SpaceSettings save behavior: close the modal. The WS broadcast // (`dm_channel_updated`) updates the open channel in-place. closeModal(); } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to save settings'; setSaveError(msg); addToast(msg, 'warning', 4000); } finally { setSaving(false); } }; const handleLeaveClick = () => { setConfirmLeave(true); }; const handleConfirmLeave = async () => { if (leaving) return; setLeaving(true); try { await api.dm.leave(dmChannelId); closeModal(); } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to leave group'; addToast(msg, 'warning', 4000); } finally { setLeaving(false); setConfirmLeave(false); } }; // ── Members panel data ──────────────────────────────────────────────── // Friend lookup mirrors DmRosterPanel — local-id compare is federation-safe. const isFriendOfCaller = (m: User): boolean => friends.some((f) => f.id === m.id); const memberCount = dmChannel.members.length; const remainingSlots = MAX_GROUP_MEMBERS - memberCount; const canAddMembers = remainingSlots > 0; // Per-member action handler. The kebab is hidden in this view, but right-click // (and `View Profile`) still works via the same context-menu wiring. // (State for the two confirmation dialogs is declared up with the other hooks.) const handleMemberAction = async (action: DmMemberRowAction, member: User) => { if (action === 'profile') { // Fallback path — DmMemberRow normally opens the profile itself via // its own bounding rect. If we reach this branch, just route to a // top-left anchor (matches DmRosterPanel's fallback). useUIStore.getState().openUserProfile(member, pointAnchor(100, 100)); 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) return; setMemberActionSubmitting(true); try { await api.dm.kickMember(dmChannelId, 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 { setMemberActionSubmitting(false); } }; const confirmTransfer = async () => { if (!pendingTransfer) return; setMemberActionSubmitting(true); try { await api.dm.transferOwnership(dmChannelId, 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 { setMemberActionSubmitting(false); } }; // ── Render helpers ───────────────────────────────────────────────────── const tabBtnClass = (t: Tab) => `w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${ tab === t ? 'bg-interactive-selected text-txt-primary font-medium' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover' }`; const handleTabClick = (t: Tab) => { setTab(t); if (isMobile) setMobileView('content'); }; const headerName = dmChannel.name && dmChannel.name.length > 0 ? dmChannel.name : (fallbackName || 'Group DM'); // ── Overview panel ───────────────────────────────────────────────────── const overviewPanel = (

Overview

{/* Hero icon */}
{/* Clear (X) button — owner-only, only when we have a non-empty icon to clear */} {isOwner && previewIconUrl && ( )}
{/* Group name */}
setName(e.target.value.slice(0, MAX_NAME_LENGTH))} placeholder={fallbackName || 'Group DM'} disabled={!isOwner} maxLength={MAX_NAME_LENGTH} className="input-standard w-full" data-group-dm-name-input aria-label="Group name" /> {isOwner && (
{trimmedName.length}/{MAX_NAME_LENGTH}
)}
{saveError && (
{saveError}
)} {/* Save / Cancel — owner only, only when dirty */} {isOwner && (
)} {/* For non-owners: a single Close button (no Save). */} {!isOwner && (
)} {/* Leave Group — destructive footer button, everyone */}
Leave Group

You will stop receiving messages from this conversation. Other members will see a system message.

); // ── Members panel ────────────────────────────────────────────────────── const ownerMember = dmChannel.members.find((m) => m.id === dmChannel.ownerId) ?? null; 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 nonOwnerMembers = dmChannel.members .filter((m) => m.id !== dmChannel.ownerId) .sort(sortByDisplayName); const membersPanel = (

Members

{memberCount}/{MAX_GROUP_MEMBERS}
{ownerMember && ( )} {nonOwnerMembers.map((m) => ( ))}
); return (
{/* Desktop sidebar */}
{/* Channel card */}
{headerName}
{/* Nav list */}
General
{/* Mobile: tab list */} {isMobile && mobileView === 'tabs' && (
{headerName}
General
)} {/* Content area */} {(!isMobile || mobileView === 'content') && (
{isMobile && ( )} {tab === 'overview' && overviewPanel} {tab === 'members' && membersPanel}
)}
{/* Image cropper for new icons (1:1 ratio, matches space-icon convention) */} setCropSrc(null)} imageSrc={cropSrc ?? ''} onCropComplete={handleCropComplete} title="Crop Group Icon" cropShape="round" aspectRatio={1} maxOutputDimension={256} /> {/* Confirmations for member-row actions and Leave Group */} { if (!leaving) setConfirmLeave(false); }} onConfirm={handleConfirmLeave} title="Leave Group" description={`Leave "${headerName}"? You will stop receiving messages from this conversation.`} confirmLabel="Leave" variant="danger" loading={leaving} /> { if (!memberActionSubmitting) 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={memberActionSubmitting} /> { if (!memberActionSubmitting) 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={memberActionSubmitting} />
); }