All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- Implemented Button component with various props for customization. - Created Modal component with header, content, and footer subcomponents. - Added Spinner component for loading indicators. - Developed Toast component for displaying notifications. - Introduced Tooltip component for contextual hints with keyboard shortcuts. - Added corresponding CSS modules for styling each component. - Updated index file to export new components. - Configured TypeScript settings for the UI package.
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
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<File | null>(null);
|
||
const [pendingPreviewUrl, setPendingPreviewUrl] = useState<string | null>(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<string | null>(null);
|
||
const [afkTimeoutMs, setAfkTimeoutMs] = useState<number>(5 * 60 * 1000);
|
||
const [initialAfkChannelId, setInitialAfkChannelId] = useState<string | null>(null);
|
||
const [initialAfkTimeoutMs, setInitialAfkTimeoutMs] = useState<number>(5 * 60 * 1000);
|
||
const [saving, setSaving] = useState(false);
|
||
const [status, setStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||
<div className={styles.gate}>
|
||
<h2 className={styles.gateTitle}>Server not found</h2>
|
||
<p className={styles.gateBody}>This server is no longer available.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!canManage) {
|
||
return (
|
||
<div className={styles.gate}>
|
||
<h2 className={styles.gateTitle}>Overview</h2>
|
||
<p className={styles.gateBody}>
|
||
You need permission to manage this server. Ask an admin to grant you a role with a higher power level.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<HTMLInputElement>) => {
|
||
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 (
|
||
<div className={rootClass}>
|
||
{!mobile && <h2 className={styles.heading}>Overview</h2>}
|
||
|
||
<div className={iconBlockClass}>
|
||
<div className={styles.iconPreviewWrap}>
|
||
{showInitials ? (
|
||
<div className={styles.iconInitials}>{getInitials(server.name)}</div>
|
||
) : (
|
||
<img src={previewSrc} alt={server.name} className={styles.iconPreview} draggable={false} />
|
||
)}
|
||
<div className={styles.iconBadge}>
|
||
<PencilSimple size={12} weight="bold" />
|
||
</div>
|
||
</div>
|
||
<div className={styles.iconActions}>
|
||
<p className={styles.iconActionsHelp}>
|
||
We recommend an image of at least 512×512 — JPEG, PNG, GIF, or WebP, up to 10 MB.
|
||
</p>
|
||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||
<button type="button" className={styles.iconButton} onClick={handlePickFile} disabled={saving}>
|
||
<UploadSimple size={14} weight="bold" />
|
||
{server.icon || pendingPreviewUrl ? 'Change Icon' : 'Upload Icon'}
|
||
</button>
|
||
{(server.icon || pendingPreviewUrl) && (
|
||
<button
|
||
type="button"
|
||
className={`${styles.iconButton} ${styles.iconButtonDanger}`}
|
||
onClick={handleRemoveIcon}
|
||
disabled={saving}
|
||
>
|
||
<Trash size={14} weight="bold" />
|
||
Remove
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||
onChange={handleFileChange}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
|
||
<div>
|
||
<div className={fieldLabelClass}>Server Name</div>
|
||
{mobile ? (
|
||
<div className={styles.mobileNameCard}>
|
||
<input
|
||
type="text"
|
||
className={styles.nameInput}
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
maxLength={100}
|
||
placeholder="My Server"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<input
|
||
type="text"
|
||
className={styles.nameInput}
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
maxLength={100}
|
||
placeholder="My Server"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div className={styles.idleSection}>
|
||
<div className={styles.idleHeading}>Idle Settings</div>
|
||
<p className={styles.idleDescription}>
|
||
Move members to an AFK voice channel when they go idle. They'll be automatically muted on move.
|
||
</p>
|
||
<div className={styles.idleRow}>
|
||
<div className={styles.idleField}>
|
||
<div className={fieldLabelClass}>AFK / Idle Channel</div>
|
||
<select
|
||
className={styles.idleSelect}
|
||
value={afkChannelId ?? ''}
|
||
onChange={(e) => setAfkChannelId(e.target.value || null)}
|
||
>
|
||
<option value="">No AFK Channel</option>
|
||
{voiceChannels.map((ch) => (
|
||
<option key={ch.id} value={ch.id}>
|
||
{ch.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className={styles.idleField}>
|
||
<div className={fieldLabelClass}>AFK Timeout</div>
|
||
<select
|
||
className={styles.idleSelect}
|
||
value={afkTimeoutMs}
|
||
onChange={(e) => setAfkTimeoutMs(Number(e.target.value))}
|
||
>
|
||
{AFK_TIMEOUT_OPTIONS.map((opt) => (
|
||
<option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{status && (
|
||
<div className={`${styles.status} ${status.type === 'error' ? styles.statusError : styles.statusSuccess}`}>
|
||
{status.message}
|
||
</div>
|
||
)}
|
||
|
||
<div className={actionsClass}>
|
||
<button type="button" className={saveButtonClass} onClick={handleSave} disabled={!canSave}>
|
||
{saving ? 'Saving…' : 'Save Changes'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|