feat(ui): GroupDmSettings modal with Overview + Members tabs
This commit is contained in:
@@ -0,0 +1,706 @@
|
||||
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';
|
||||
|
||||
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<Tab>(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<IconState>({ kind: 'unchanged' });
|
||||
const [cropSrc, setCropSrc] = useState<string | null>(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<User | null>(null);
|
||||
const [pendingTransfer, setPendingTransfer] = useState<User | null>(null);
|
||||
const [memberActionSubmitting, setMemberActionSubmitting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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, { top: 100, left: 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 = (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-lg font-semibold text-txt-primary mb-6">Overview</h2>
|
||||
|
||||
{/* Hero icon */}
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHeroClick}
|
||||
disabled={!isOwner}
|
||||
data-group-dm-icon-hero
|
||||
aria-label={isOwner ? 'Change group icon' : 'Group icon'}
|
||||
className={`relative block rounded-full overflow-hidden group ${
|
||||
isOwner ? 'cursor-pointer' : 'cursor-default'
|
||||
}`}
|
||||
style={{ width: 80, height: 80 }}
|
||||
>
|
||||
<AvatarStack
|
||||
members={otherMembers}
|
||||
size={80}
|
||||
border="modal"
|
||||
iconUrl={previewIconUrl}
|
||||
/>
|
||||
{isOwner && (
|
||||
<div
|
||||
data-group-dm-icon-overlay
|
||||
className="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center text-white text-[10px] font-medium"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span className="mt-1">Click to upload</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Clear (X) button — owner-only, only when we have a non-empty icon to clear */}
|
||||
{isOwner && previewIconUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearIcon}
|
||||
data-group-dm-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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group name */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
Group Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => 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 && (
|
||||
<div className="text-[11px] text-txt-tertiary text-right mt-1">
|
||||
{trimmedName.length}/{MAX_NAME_LENGTH}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save / Cancel — owner only, only when dirty */}
|
||||
{isOwner && (
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-3 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
data-group-dm-cancel
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || saving}
|
||||
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-group-dm-save
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For non-owners: a single Close button (no Save). */}
|
||||
{!isOwner && (
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-1.5 bg-surface-elevated hover:bg-interactive-hover text-txt-primary text-sm font-medium rounded-full transition-colors"
|
||||
data-group-dm-close
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Leave Group — destructive footer button, everyone */}
|
||||
<div className="pt-4 border-t border-border-soft">
|
||||
<div className="text-[11px] font-semibold text-txt-danger uppercase tracking-wider mb-1.5">
|
||||
Leave Group
|
||||
</div>
|
||||
<p className="text-xs text-txt-tertiary mb-3">
|
||||
You will stop receiving messages from this conversation. Other members will see a system message.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLeaveClick}
|
||||
disabled={leaving}
|
||||
className="px-4 py-2 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||
data-group-dm-leave
|
||||
>
|
||||
{leaving ? 'Leaving...' : 'Leave Group'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── 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 = (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-txt-primary">Members</h2>
|
||||
<span className="text-[12px] text-txt-tertiary">
|
||||
{memberCount}/{MAX_GROUP_MEMBERS}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useUIStore.getState().openModal('addDmMember', { dmChannelId })}
|
||||
disabled={!canAddMembers}
|
||||
data-group-dm-add-member
|
||||
className="w-full flex items-center gap-2.5 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="ml-auto text-[11px] text-txt-tertiary">Group is full</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div data-group-dm-member-list className="space-y-0.5">
|
||||
{ownerMember && (
|
||||
<DmMemberRow
|
||||
member={ownerMember}
|
||||
isOwner
|
||||
isSelf={!!authUser && isSelf(ownerMember, authUser)}
|
||||
callerIsOwner={isOwner}
|
||||
isFriend={isFriendOfCaller(ownerMember)}
|
||||
showKebab={false}
|
||||
onMenuAction={handleMemberAction}
|
||||
/>
|
||||
)}
|
||||
{nonOwnerMembers.map((m) => (
|
||||
<DmMemberRow
|
||||
key={m.id}
|
||||
member={m}
|
||||
isOwner={false}
|
||||
isSelf={!!authUser && isSelf(m, authUser)}
|
||||
callerIsOwner={isOwner}
|
||||
isFriend={isFriendOfCaller(m)}
|
||||
showKebab={false}
|
||||
onMenuAction={handleMemberAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={closeModal} size="settings" mobileStyle="fullscreen">
|
||||
<div className="flex h-full">
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden md:flex w-52 flex-shrink-0 flex-col p-4 gap-3">
|
||||
{/* Channel card */}
|
||||
<div className="glass-bubble rounded-lg p-3 flex items-center gap-3">
|
||||
<AvatarStack
|
||||
members={otherMembers}
|
||||
size={36}
|
||||
border="modal"
|
||||
iconUrl={dmChannel.icon}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-txt-primary truncate">{headerName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav list */}
|
||||
<div className="glass-bubble rounded-lg p-2 flex-1 flex flex-col">
|
||||
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">
|
||||
General
|
||||
</div>
|
||||
<button type="button" onClick={() => handleTabClick('overview')} className={tabBtnClass('overview')}>
|
||||
Overview
|
||||
</button>
|
||||
<button type="button" onClick={() => handleTabClick('members')} className={tabBtnClass('members')}>
|
||||
Members
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: tab list */}
|
||||
{isMobile && mobileView === 'tabs' && (
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<div className="glass-bubble rounded-lg p-3 flex items-center gap-3">
|
||||
<AvatarStack
|
||||
members={otherMembers}
|
||||
size={36}
|
||||
border="modal"
|
||||
iconUrl={dmChannel.icon}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-txt-primary truncate">{headerName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-bubble rounded-lg p-2 space-y-0.5">
|
||||
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">
|
||||
General
|
||||
</div>
|
||||
<button type="button" onClick={() => handleTabClick('overview')} className={tabBtnClass('overview')}>
|
||||
Overview
|
||||
</button>
|
||||
<button type="button" onClick={() => handleTabClick('members')} className={tabBtnClass('members')}>
|
||||
Members
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content area */}
|
||||
{(!isMobile || mobileView === 'content') && (
|
||||
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin py-6">
|
||||
<div className="px-6 max-w-[640px] mx-auto">
|
||||
{isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileView('tabs')}
|
||||
className="flex items-center gap-1.5 text-txt-tertiary hover:text-txt-secondary mb-4 text-sm"
|
||||
aria-label="Back to group DM settings menu"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
Group Settings
|
||||
</button>
|
||||
)}
|
||||
{tab === 'overview' && overviewPanel}
|
||||
{tab === 'members' && membersPanel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image cropper for new icons (1:1 ratio, matches space-icon convention) */}
|
||||
<ImageCropModal
|
||||
isOpen={cropSrc !== null}
|
||||
onClose={() => setCropSrc(null)}
|
||||
imageSrc={cropSrc ?? ''}
|
||||
onCropComplete={handleCropComplete}
|
||||
title="Crop Group Icon"
|
||||
cropShape="round"
|
||||
aspectRatio={1}
|
||||
maxOutputDimension={256}
|
||||
/>
|
||||
|
||||
{/* Confirmations for member-row actions and Leave Group */}
|
||||
<ConfirmDialog
|
||||
isOpen={confirmLeave}
|
||||
onClose={() => { 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}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingKick}
|
||||
onClose={() => { 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}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingTransfer}
|
||||
onClose={() => { 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}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user