import ChannelStore from '@app/stores/ChannelStore'; import ServerStore from '@app/stores/ServerStore'; import { MatrixClientManager, SpaceManager } from '@brycord/matrix-client'; import { PencilSimple, Trash, UploadSimple } from '@phosphor-icons/react'; /** * OverviewTab — Server Settings → Overview. Lets an admin rename * the space and change its avatar (icon). Both writes go through * `SpaceManager`, which sends the canonical Matrix `m.room.name` * and `m.room.avatar` state events on the space room — so the * change is interoperable with Element / Cinny / any other Matrix * client viewing the same space. * * Renders the same form on desktop and mobile; the `mobile` prop * just swaps the chrome to the rounded-card style the rest of the * mobile settings tabs use. Logic is identical across both. * * Permission gate uses `SpaceManager.canManageSpaceProfile`, which * checks the local user's power level against the room's * `state_default` (and any per-event overrides) for both * `m.room.name` and `m.room.avatar`. */ import { observer } from 'mobx-react-lite'; import { useEffect, useMemo, useRef, useState } from 'react'; import styles from './OverviewTab.module.css'; const AFK_TIMEOUT_OPTIONS: { label: string; value: number }[] = [ { label: '1 minute', value: 60 * 1000 }, { label: '5 minutes', value: 5 * 60 * 1000 }, { label: '15 minutes', value: 15 * 60 * 1000 }, { label: '30 minutes', value: 30 * 60 * 1000 }, { label: '1 hour', value: 60 * 60 * 1000 }, ]; interface OverviewTabProps { serverId: string; mobile?: boolean; } function getInitials(name: string): string { return name .split(/\s+/) .map((w) => w[0]) .join('') .slice(0, 2) .toUpperCase(); } export const OverviewTab = observer(function OverviewTab({ serverId, mobile = false }: OverviewTabProps) { const server = ServerStore.getServer(serverId); const canManage = useMemo(() => { try { return SpaceManager.getInstance().canManageSpaceProfile(serverId); } catch { return false; } }, [serverId]); // Form state — seeded from the live server snapshot. We track a // "pending" file separately from the committed avatar so the user // can preview their pick before tapping Save. const [name, setName] = useState(server?.name ?? ''); const [pendingIconFile, setPendingIconFile] = useState(null); const [pendingPreviewUrl, setPendingPreviewUrl] = useState(null); // `removeIcon` is a flag set when the user clicks "Remove Icon" so // we can send an empty `m.room.avatar` content on save without // also wiping a freshly-picked file. const [removeIcon, setRemoveIcon] = useState(false); // AFK settings. `null` means "no AFK channel configured". // `initialAfk*` tracks the committed values so we can diff on save. const [afkChannelId, setAfkChannelId] = useState(null); const [afkTimeoutMs, setAfkTimeoutMs] = useState(5 * 60 * 1000); const [initialAfkChannelId, setInitialAfkChannelId] = useState(null); const [initialAfkTimeoutMs, setInitialAfkTimeoutMs] = useState(5 * 60 * 1000); const [saving, setSaving] = useState(false); const [status, setStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null); const fileInputRef = useRef(null); // Reset state whenever the server name in the store changes // underneath us (e.g. another client renamed it). useEffect(() => { setName(server?.name ?? ''); }, [server?.name]); // Load the currently-configured AFK settings when the server // switches. Reads synchronously from the space's in-memory // state event — no network round-trip. useEffect(() => { const current = SpaceManager.getInstance().getAfkSettings(serverId); const channelId = current?.channelId ?? null; const timeoutMs = current?.timeoutMs ?? 5 * 60 * 1000; setAfkChannelId(channelId); setAfkTimeoutMs(timeoutMs); setInitialAfkChannelId(channelId); setInitialAfkTimeoutMs(timeoutMs); }, [serverId]); // Cleanup the temporary preview URL when the component unmounts // or the user picks a different file. useEffect(() => { return () => { if (pendingPreviewUrl) URL.revokeObjectURL(pendingPreviewUrl); }; }, [pendingPreviewUrl]); if (!server) { return (

Server not found

This server is no longer available.

); } if (!canManage) { return (

Overview

You need permission to manage this server. Ask an admin to grant you a role with a higher power level.

); } const trimmedName = name.trim(); const nameDirty = trimmedName !== (server.name ?? ''); const iconDirty = pendingIconFile !== null || removeIcon; const afkDirty = afkChannelId !== initialAfkChannelId || afkTimeoutMs !== initialAfkTimeoutMs; const isDirty = nameDirty || iconDirty || afkDirty; const nameValid = trimmedName.length > 0 && trimmedName.length <= 100; const canSave = isDirty && nameValid && !saving; // List of voice channels in this server for the AFK dropdown. // Filtered live from ChannelStore so newly-created voice channels // appear without needing a remount. const voiceChannels = ChannelStore.getVoiceChannels(serverId); const handlePickFile = () => { fileInputRef.current?.click(); }; const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (fileInputRef.current) fileInputRef.current.value = ''; if (!file) return; setStatus(null); if (!file.type.startsWith('image/')) { setStatus({ type: 'error', message: 'Server icon must be an image file.', }); return; } if (file.size > 10 * 1024 * 1024) { setStatus({ type: 'error', message: 'Server icon must be smaller than 10 MB.', }); return; } if (pendingPreviewUrl) URL.revokeObjectURL(pendingPreviewUrl); setPendingIconFile(file); setPendingPreviewUrl(URL.createObjectURL(file)); // Picking a new file overrides any pending "remove" flag. setRemoveIcon(false); }; const handleRemoveIcon = () => { if (pendingPreviewUrl) URL.revokeObjectURL(pendingPreviewUrl); setPendingIconFile(null); setPendingPreviewUrl(null); setRemoveIcon(true); setStatus(null); }; const handleSave = async () => { if (!canSave) return; setSaving(true); setStatus(null); try { const sm = SpaceManager.getInstance(); // Avatar mutation first so the optimistic store update at // the end has both fields in their final state. let newIconHttp: string | undefined = server.icon; if (pendingIconFile) { const mxc = await sm.setSpaceAvatar(serverId, pendingIconFile); if (mxc) { // Match the same dimensioned thumbnail SpaceManager // produces so the local server entry stays in sync // with what a fresh sync would compute. const client = MatrixClientManager.getInstance().getClient(); newIconHttp = client.mxcUrlToHttp(mxc, 128, 128, 'crop') ?? undefined; } } else if (removeIcon) { await sm.setSpaceAvatar(serverId, null); newIconHttp = undefined; } if (nameDirty) { await sm.renameSpace(serverId, trimmedName); } if (afkDirty) { await sm.setAfkSettings(serverId, afkChannelId, afkTimeoutMs); setInitialAfkChannelId(afkChannelId); setInitialAfkTimeoutMs(afkTimeoutMs); } // Optimistic local update so the sidebar / hero refresh // instantly instead of waiting for the next sync tick. ServerStore.handleServerUpdate({ ...server, name: nameDirty ? trimmedName : server.name, icon: newIconHttp, }); if (pendingPreviewUrl) URL.revokeObjectURL(pendingPreviewUrl); setPendingIconFile(null); setPendingPreviewUrl(null); setRemoveIcon(false); setStatus({ type: 'success', message: 'Saved.' }); } catch (err: any) { setStatus({ type: 'error', message: err?.message || 'Failed to save changes.', }); } finally { setSaving(false); } }; // Decide which preview src to render: pending file > current // server icon > nothing (initials fallback). When the user has // queued an icon removal, force the initials path. const showInitials = removeIcon || (!pendingPreviewUrl && !server.icon); const previewSrc = pendingPreviewUrl ?? server.icon; const rootClass = mobile ? `${styles.root} ${styles.mobileRoot}` : styles.root; const iconBlockClass = mobile ? `${styles.iconBlock} ${styles.mobileIconBlock}` : styles.iconBlock; const fieldLabelClass = mobile ? `${styles.fieldLabel} ${styles.mobileFieldLabel}` : styles.fieldLabel; const saveButtonClass = mobile ? `${styles.saveButton} ${styles.mobileSaveButton}` : styles.saveButton; const actionsClass = mobile ? `${styles.actions} ${styles.mobileActions}` : styles.actions; return (
{!mobile &&

Overview

}
{showInitials ? (
{getInitials(server.name)}
) : ( {server.name} )}

We recommend an image of at least 512×512 — JPEG, PNG, GIF, or WebP, up to 10 MB.

{(server.icon || pendingPreviewUrl) && ( )}
Server Name
{mobile ? (
setName(e.target.value)} maxLength={100} placeholder="My Server" />
) : ( setName(e.target.value)} maxLength={100} placeholder="My Server" /> )}
Idle Settings

Move members to an AFK voice channel when they go idle. They'll be automatically muted on move.

AFK / Idle Channel
AFK Timeout
{status && (
{status.message}
)}
); });