-
Min
-
setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })}
- className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
- [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
- [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
- [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
- />
-
{formatKbps(draft.minBitrateKbps)}
+
Bandwidth
+
Minimum and maximum bitrate bounds, and the step size for the quality slider.
+
+ {/* Bitrate Range */}
+
+
+ Bitrate Range
+
+
+
+
Min
+
setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })}
+ className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
+ [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
+ [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
+ [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
+ />
+
{formatKbps(draft.minBitrateKbps)}
+
+
+
Max
+
setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })}
+ className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
+ [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
+ [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
+ [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
+ />
+
{formatKbps(draft.maxBitrateKbps)}
+
+
-
-
Max
-
setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })}
- className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
- [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
- [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
- [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
- />
-
{formatKbps(draft.maxBitrateKbps)}
+
+ {/* Bitrate Step */}
+
+
+ Slider Step
+
+
+ {
+ const v = Number(e.target.value);
+ if (v >= 50 && v <= 5000) setDraft({ ...draft, bitrateStepKbps: v });
+ }}
+ className="w-24 px-2 py-1 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary"
+ />
+ kbps
+
- {/* Bitrate Step */}
+ {/* Quality */}
-
- Slider Step
-
-
- {
- const v = Number(e.target.value);
- if (v >= 50 && v <= 5000) setDraft({ ...draft, bitrateStepKbps: v });
- }}
- className="w-24 px-2 py-1 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary"
- />
- kbps
-
-
+
Quality
+
Available resolution and frame rate options for screen sharing.
+
+ {/* Allowed Resolutions */}
+
+
+ Allowed Resolutions
+
+
+ {VALID_RESOLUTIONS.map((res) => (
+ toggleResolution(res)}
+ className={`${pillBase} ${draft.allowedResolutions.includes(res) ? pillOn : pillOff}`}
+ >
+ {res}p
+
+ ))}
+
+
- {/* Allowed Resolutions */}
-
-
- Allowed Resolutions
-
-
- {VALID_RESOLUTIONS.map((res) => (
- toggleResolution(res)}
- className={`${pillBase} ${draft.allowedResolutions.includes(res) ? pillOn : pillOff}`}
- >
- {res}p
-
- ))}
-
-
-
- {/* Allowed Frame Rates */}
-
-
- Allowed Frame Rates
-
-
- {VALID_FRAMERATES.map((fps) => (
-
toggleFramerate(fps)}
- className={`${pillBase} ${draft.allowedFramerates.includes(fps) ? pillOn : pillOff}`}
- >
- {fps} fps
-
- ))}
+ {/* Allowed Frame Rates */}
+
+
+ Allowed Frame Rates
+
+
+ {VALID_FRAMERATES.map((fps) => (
+ toggleFramerate(fps)}
+ className={`${pillBase} ${draft.allowedFramerates.includes(fps) ? pillOn : pillOff}`}
+ >
+ {fps} fps
+
+ ))}
+
+
@@ -276,7 +290,7 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
];
return (
-
+
{!discoveryEnabled && (
Space discovery is disabled by the instance administrator. Changing visibility will have no effect until discovery is re-enabled.
@@ -284,48 +298,50 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
)}
-
- Visibility
-
-
- {visibilityOptions.map((opt) => (
-
- setVisibility(opt.value)}
- className="mt-0.5 accent-accent-primary"
- />
-
-
{opt.label}
-
{opt.desc}
-
-
- ))}
+
Visibility
+
Control who can discover and join this space.
+
+
+ {visibilityOptions.map((opt) => (
+
+ setVisibility(opt.value)}
+ className="mt-0.5 accent-accent-primary"
+ />
+
+
{opt.label}
+
{opt.desc}
+
+
+ ))}
+
-
- Description
+
Description
+
A short summary shown on the Explore page.
+
-
{saveError && (
@@ -334,6 +350,12 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
{saveSuccess && (
Settings saved
)}
+
+ {/* Pending Join Requests — only shown when visibility is 'request' */}
+ {(visibility === 'request' || (space.visibility as SpaceVisibility) === 'request') && (
+
+ )}
+
{hasChanges && (
@@ -355,11 +377,6 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
)}
-
- {/* Pending Join Requests — only shown when visibility is 'request' */}
- {(visibility === 'request' || (space.visibility as SpaceVisibility) === 'request') && (
-
- )}
);
}
@@ -493,9 +510,9 @@ export function SpaceSettingsModal() {
return (
-
+
{/* Tabs */}
-
+
setTab('overview')} className={tabClass('overview')}>
Overview
@@ -522,7 +539,7 @@ export function SpaceSettingsModal() {
{/* Content */}
-
+
{tab === 'overview' &&
}
{tab === 'discovery' && canManageSpace &&
}
{tab === 'members' &&
}
diff --git a/packages/web/src/components/modals/UserSettings.tsx b/packages/web/src/components/modals/UserSettings.tsx
index 651ee3f7..6d22d711 100644
--- a/packages/web/src/components/modals/UserSettings.tsx
+++ b/packages/web/src/components/modals/UserSettings.tsx
@@ -57,7 +57,7 @@ export function UserSettingsModal() {
return (
-
+
{/* Profile preview */}
)}
+ {/* Profile section card */}
-
- Status
-
-
setStatus(e.target.value as any)}
- className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary appearance-none"
- >
- Online
- Idle
- Do Not Disturb
-
+
Profile
+
+
+
+ Status
+
+ setStatus(e.target.value as any)}
+ className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary appearance-none"
+ >
+ Online
+ Idle
+ Do Not Disturb
+
+
+
+
+
+ Display Name
+
+ setDisplayName(e.target.value)}
+ className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
+ />
+
+
+
+
+ Custom Status
+
+ setCustomStatus(e.target.value)}
+ className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
+ placeholder="What are you up to?"
+ />
+
+
+ {/* Voice Processing section card */}
-
- Display Name
-
- setDisplayName(e.target.value)}
- className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
- />
-
-
-
-
- Custom Status
-
- setCustomStatus(e.target.value)}
- className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
- placeholder="What are you up to?"
- />
-
-
- {/* Voice Processing */}
-
-
+
Voice Processing
-
-
-
-
-
AI Noise Suppression
-
ML-based noise removal (RNNoise) — filters keyboard, fans, and background noise
-
-
setRnnoiseEnabled(!rnnoiseEnabled)}
- className={`relative w-10 h-5 rounded-full transition-colors ${rnnoiseEnabled ? 'bg-status-online' : 'bg-surface-input'}`}
- >
-
-
-
-
-
-
Echo Cancellation
-
Removes echo when using speakers
+
+
+
+
AI Noise Suppression
+
ML-based noise removal (RNNoise) — filters keyboard, fans, and background noise
+
+
setRnnoiseEnabled(!rnnoiseEnabled)}
+ className={`relative w-10 h-5 rounded-full transition-colors ${rnnoiseEnabled ? 'bg-status-online' : 'bg-surface-input'}`}
+ >
+
+
-
setEchoCancellation(!echoCancellation)}
- className={`relative w-10 h-5 rounded-full transition-colors ${echoCancellation ? 'bg-status-online' : 'bg-surface-input'}`}
- >
-
-
-
-
-
-
Auto Gain Control
-
Auto-adjusts mic volume — can cause voice ducking during streams
+
+
+
Echo Cancellation
+
Removes echo when using speakers
+
+
setEchoCancellation(!echoCancellation)}
+ className={`relative w-10 h-5 rounded-full transition-colors ${echoCancellation ? 'bg-status-online' : 'bg-surface-input'}`}
+ >
+
+
+
+
+
+
+
Auto Gain Control
+
Auto-adjusts mic volume — can cause voice ducking during streams
+
+
setAutoGainControl(!autoGainControl)}
+ className={`relative w-10 h-5 rounded-full transition-colors ${autoGainControl ? 'bg-status-online' : 'bg-surface-input'}`}
+ >
+
+
-
setAutoGainControl(!autoGainControl)}
- className={`relative w-10 h-5 rounded-full transition-colors ${autoGainControl ? 'bg-status-online' : 'bg-surface-input'}`}
- >
-
-
diff --git a/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx
index d8079be5..0d5ff8b6 100644
--- a/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx
+++ b/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx
@@ -24,6 +24,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
const canKick = hasPermissionBit(myPerms, PermissionBits.KICK_MEMBERS);
const [pendingRoleChanges, setPendingRoleChanges] = useState
>>(new Map());
+ const [expandedMemberId, setExpandedMemberId] = useState(null);
const [error, setError] = useState('');
// Assignable roles: exclude @everyone (where role.id === spaceId)
@@ -57,6 +58,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
next.delete(userId);
return next;
});
+ setExpandedMemberId(null);
await loadSpaceDetail(spaceId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update roles');
@@ -80,105 +82,139 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
}
};
- return (
-
- {error && (
-
{error}
- )}
-
- {members.map((member) => {
- const displayName = member.user.displayName ?? member.user.username;
- const isOwner = member.userId === space.ownerId;
- const memberRoleIds = getMemberRoleIds(member);
- const hasPendingChanges = pendingRoleChanges.has(member.userId);
+ const canExpandMember = (member: MemberWithUser) =>
+ canManageRoles && member.userId !== currentUser?.id && member.userId !== space.ownerId && assignableRoles.length > 0;
- return (
-
-
-
-
-
-
{displayName}
-
- {isOwner && (
-
- Owner
-
- )}
- {member.roles?.filter((r) => r.id !== spaceId).map((r) => (
-
+ {error && (
+ {error}
+ )}
+ Manage members of this space. Click a member to edit their roles.
+
+
+
+ Members ({members.length})
+
+
+
+ {members.map((member) => {
+ const displayName = member.user.displayName ?? member.user.username;
+ const isOwner = member.userId === space.ownerId;
+ const memberRoleIds = getMemberRoleIds(member);
+ const hasPendingChanges = pendingRoleChanges.has(member.userId);
+ const isExpanded = expandedMemberId === member.userId;
+ const expandable = canExpandMember(member);
+
+ return (
+
+
{
+ if (expandable) {
+ setExpandedMemberId(isExpanded ? null : member.userId);
+ }
+ }}
+ >
+
+
+
+
{displayName}
+
+ {isOwner && (
+
+ Owner
+
+ )}
+ {member.roles?.filter((r) => r.id !== spaceId).map((r) => (
+
+ {r.name}
+
+ ))}
+ {!isOwner && (!member.roles || member.roles.filter((r) => r.id !== spaceId).length === 0) && (
+ No roles
+ )}
+
+
+
+
+
+ {canKick && member.userId !== currentUser?.id && !isOwner && (
+
{ e.stopPropagation(); handleKick(member.userId); }}
+ className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
>
- {r.name}
-
- ))}
- {!isOwner && (!member.roles || member.roles.filter((r) => r.id !== spaceId).length === 0) && (
- No roles
+ Kick
+
+ )}
+ {expandable && (
+
+
+
)}
-
- {canKick && member.userId !== currentUser?.id && !isOwner && (
-
- handleKick(member.userId)}
- className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
- >
- Kick
-
-
- )}
-
-
- {/* Role checkboxes — shown for non-self, non-owner members when user can manage roles */}
- {canManageRoles && member.userId !== currentUser?.id && !isOwner && assignableRoles.length > 0 && (
-
- {assignableRoles.map((role) => (
-
- handleRoleToggle(member.userId, role.id, memberRoleIds)}
- className="w-3.5 h-3.5 rounded border-txt-tertiary accent-accent-primary"
- />
-
- {role.name}
-
-
- ))}
- {hasPendingChanges && (
-
-
handleSaveRoles(member.userId)}
- className="px-2 py-0.5 text-xs bg-accent-primary hover:bg-accent-primary/80 text-white rounded transition-colors"
- >
- Save
-
-
handleCancelRoleChange(member.userId)}
- className="px-2 py-0.5 text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
- >
- Cancel
-
+ {/* Role checkboxes — only shown when expanded */}
+ {isExpanded && expandable && (
+
+ {assignableRoles.map((role) => (
+
+ handleRoleToggle(member.userId, role.id, memberRoleIds)}
+ className="w-3.5 h-3.5 rounded border-txt-tertiary accent-accent-primary"
+ />
+
+ {role.name}
+
+
+ ))}
+ {hasPendingChanges && (
+
+ handleSaveRoles(member.userId)}
+ className="px-2 py-0.5 text-xs bg-accent-primary hover:bg-accent-primary/80 text-white rounded transition-colors"
+ >
+ Save
+
+ handleCancelRoleChange(member.userId)}
+ className="px-2 py-0.5 text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
+ >
+ Cancel
+
+
+ )}
)}
- )}
-
- );
- })}
+ );
+ })}
+
+
);
diff --git a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx
index 1467bf47..5205b7bf 100644
--- a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx
+++ b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../../stores/authStore';
import { useUIStore } from '../../../stores/uiStore';
import { useNavigate } from 'react-router-dom';
import { api } from '../../../api/client';
+import { getApiForOrigin } from '../../../stores/spaceStore';
import { hasPermissionBit, PermissionBits } from '../../../utils/permissions';
interface OverviewPanelProps {
@@ -56,7 +57,9 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
const hasIconChange = iconFilename !== null;
const hasChanges = hasNameChange || hasIconChange;
- const currentIconUrl = space.icon ? api.uploads.url(space.icon) : null;
+ const currentIconUrl = space.icon
+ ? (space.icon.startsWith('http') ? space.icon : api.uploads.url(space.icon))
+ : null;
const handleIconSelect = (e: React.ChangeEvent
) => {
const file = e.target.files?.[0];
@@ -76,7 +79,8 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
const file = new File([blob], 'icon.png', { type: 'image/png' });
setUploadingIcon(true);
try {
- const attachment = await api.uploads.upload(file);
+ const spaceApi = getApiForOrigin(space._instanceOrigin);
+ const attachment = await spaceApi.uploads.upload(file);
setIconFilename(attachment.filename);
} catch {
setSaveError('Failed to upload icon');
@@ -149,10 +153,14 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
return (
<>
-
+
+ {/* Space Identity */}
+
+
Space Identity
+
{/* Space Icon */}
-
+
Space Icon
@@ -209,7 +217,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
{/* Space Name */}
-
+
Space Name
+
+
{/* Save / Discard */}
{saveError && (
@@ -228,6 +238,22 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
{saveSuccess && (
Settings saved
)}
+ {/* Danger Zone */}
+ {isOwner && (
+
+
Danger Zone
+
+
Permanently delete this space and all its data. This cannot be undone.
+
+ {confirmDelete ? 'Click again to confirm deletion' : 'Delete Space'}
+
+
+
+ )}
+
{canManageSpace && hasChanges && (
@@ -249,19 +275,6 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
)}
-
- {/* Danger Zone */}
- {isOwner && (
-
-
Danger Zone
-
- {confirmDelete ? 'Click again to confirm deletion' : 'Delete Space'}
-
-
- )}
+
{error && (
{error}
)}
@@ -127,36 +127,42 @@ export function RolesPanel({ spaceId }: RolesPanelProps) {
-
- {sortedRoles.map((role) => {
- const isEveryone = role.id === spaceId;
- return (
-
setEditingRoleId(role.id)}
- className="w-full flex items-center justify-between px-3 py-2 rounded hover:bg-interactive-hover transition-colors text-left group"
- >
-
-
-
- {isEveryone ? '@everyone' : role.name}
-
-
-
-
-
-
- );
- })}
+
+
Roles
+
Roles define what permissions members have. Higher roles take priority.
+
+
+ {sortedRoles.map((role) => {
+ const isEveryone = role.id === spaceId;
+ return (
+
setEditingRoleId(role.id)}
+ className="w-full flex items-center justify-between px-3 py-2 rounded hover:bg-interactive-hover transition-colors text-left group"
+ >
+
+
+
+ {isEveryone ? '@everyone' : role.name}
+
+
+
+
+
+
+ );
+ })}
+
+
);
@@ -190,12 +196,14 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
const hasColorChange = !isEveryone && draftColor !== role.color;
const hasPermChange = permissionsToString(draftPermissions) !== (role.permissions ?? '0');
const hasChanges = hasNameChange || hasColorChange || hasPermChange;
+ const showPill = hasChanges || !isEveryone;
const togglePermission = (bit: bigint) => {
setDraftPermissions((prev) => (prev & bit) !== 0n ? prev & ~bit : prev | bit);
};
const handleSave = async () => {
+ setConfirmDelete(false);
setSaving(true);
setSaveError('');
setSaveSuccess(false);
@@ -219,6 +227,7 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
setDraftName(role.name);
setDraftColor(role.color);
setDraftPermissions(stringToPermissions(role.permissions));
+ setConfirmDelete(false);
setSaveError('');
};
@@ -242,7 +251,7 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
};
return (
-
+
{/* Back button */}
- {/* Role Name (not editable for @everyone) */}
+ {/* Identity card (Name + Color — not shown for @everyone) */}
{!isEveryone && (
-
- Role Name
-
- setDraftName(e.target.value)}
- className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary text-sm"
- />
-
- )}
-
- {/* Role Color (not editable for @everyone) */}
- {!isEveryone && (
-
-
- Role Color
-
-
- {PRESET_COLORS.map((c) => (
-
setDraftColor(c)}
- className={`w-7 h-7 rounded-full border-2 transition-all ${
- draftColor === c ? 'border-white scale-110' : 'border-transparent hover:scale-105'
- }`}
- style={{ backgroundColor: c }}
- />
- ))}
-
+ Identity
+
)}
- {/* Permissions */}
-
-
- Permissions
-
-
- {PERMISSION_GROUPS.map((group) => (
-
-
- {group.name}
-
-
- {group.perms.map((perm) => {
- const isOn = (draftPermissions & perm.bit) !== 0n;
- const isAdmin = perm.bit === PermissionBits.ADMINISTRATOR;
- return (
-
(
+
+
+ {group.name}
+
+
+
+ {group.perms.map((perm) => {
+ const isOn = (draftPermissions & perm.bit) !== 0n;
+ const isAdmin = perm.bit === PermissionBits.ADMINISTRATOR;
+ return (
+
+
+ {perm.label}
+
+ {
+ e.preventDefault();
+ togglePermission(perm.bit);
+ }}
+ className={`relative w-9 h-5 rounded-full transition-colors cursor-pointer ${
+ isOn ? 'bg-accent-primary' : 'bg-interactive-muted'
+ }`}
>
-
- {perm.label}
-
{
- e.preventDefault();
- togglePermission(perm.bit);
- }}
- className={`relative w-9 h-5 rounded-full transition-colors cursor-pointer ${
- isOn ? 'bg-accent-primary' : 'bg-interactive-muted'
+ className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform ${
+ isOn ? 'translate-x-4' : 'translate-x-0.5'
}`}
- >
-
-
-
- );
- })}
-
+ />
+
+
+ );
+ })}
- ))}
+
-
+ ))}
- {/* Save / Discard */}
{saveError && (
{saveError}
)}
{saveSuccess && (
Role saved
)}
- {hasChanges && (
+ {showPill && (
-
-
- Discard
-
-
- {saving ? 'Saving...' : 'Save'}
-
+
+ {hasChanges && (
+ <>
+
+ Discard
+
+
+ {saving ? 'Saving...' : 'Save'}
+
+ >
+ )}
+ {!isEveryone && hasChanges && (
+
+ )}
+ {!isEveryone && (
+
+ {deleting ? 'Deleting...' : confirmDelete ? 'Confirm?' : 'Delete Role'}
+
+ )}
)}
-
- {/* Delete Role (not for @everyone) */}
- {!isEveryone && (
-
-
- {deleting ? 'Deleting...' : confirmDelete ? 'Click again to confirm' : 'Delete Role'}
-
-
- )}
);
}
diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts
index 20d92005..bdc81d2c 100644
--- a/packages/web/src/stores/instanceStore.ts
+++ b/packages/web/src/stores/instanceStore.ts
@@ -24,21 +24,39 @@ interface CachedInstanceToken {
username: string;
}
-const STORAGE_KEY = 'backspace_instances';
+const STORAGE_KEY_PREFIX = 'backspace_instances';
+const LEGACY_STORAGE_KEY = 'backspace_instances';
+
+function storageKey(userId: string): string {
+ return `${STORAGE_KEY_PREFIX}_${userId}`;
+}
// ─── localStorage helpers ────────────────────────────────────────────────────
-function loadCachedTokens(): Record
{
+function loadCachedTokens(userId: string): Record {
try {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (!raw) return {};
- return JSON.parse(raw) as Record;
+ const scopedKey = storageKey(userId);
+ const raw = localStorage.getItem(scopedKey);
+ if (raw) {
+ return JSON.parse(raw) as Record;
+ }
+
+ // One-time migration: adopt legacy unscoped key if it exists
+ const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
+ if (legacy) {
+ const parsed = JSON.parse(legacy) as Record;
+ localStorage.setItem(scopedKey, legacy);
+ localStorage.removeItem(LEGACY_STORAGE_KEY);
+ return parsed;
+ }
+
+ return {};
} catch {
return {};
}
}
-function saveCachedTokens(instances: ConnectedInstance[]): void {
+function saveCachedTokens(instances: ConnectedInstance[], userId: string): void {
const cache: Record = {};
for (const inst of instances) {
// Skip tokenless placeholders — writing an empty token would cause
@@ -50,7 +68,7 @@ function saveCachedTokens(instances: ConnectedInstance[]): void {
username: inst.username,
};
}
- localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
+ localStorage.setItem(storageKey(userId), JSON.stringify(cache));
}
// ─── Network error detection ────────────────────────────────────────────────
@@ -216,7 +234,7 @@ export const useInstanceStore = create((set, get) => ({
set((state) => {
const updated = [...state.instances, instance];
- saveCachedTokens(updated);
+ saveCachedTokens(updated, currentUser.id);
return { instances: updated, isLoading: false };
});
@@ -255,7 +273,8 @@ export const useInstanceStore = create((set, get) => ({
set((state) => {
const updated = [...state.instances, instance];
- saveCachedTokens(updated);
+ const userId = useAuthStore.getState().user?.id;
+ if (userId) saveCachedTokens(updated, userId);
return { instances: updated, isLoading: false };
});
@@ -284,7 +303,8 @@ export const useInstanceStore = create((set, get) => ({
set((state) => {
const updated = state.instances.filter(i => i.origin !== origin);
- saveCachedTokens(updated);
+ const userId = useAuthStore.getState().user?.id;
+ if (userId) saveCachedTokens(updated, userId);
return { instances: updated };
});
@@ -405,7 +425,7 @@ export const useInstanceStore = create((set, get) => ({
return;
}
- const cached = loadCachedTokens();
+ const cached = loadCachedTokens(currentUser.id);
// Split server-known instances into two groups:
// - withToken: have a cached token → attempt reconnection
@@ -543,7 +563,7 @@ export const useInstanceStore = create((set, get) => ({
// Save final state to localStorage — persist ALL instances regardless of status
// so disconnected instances survive page reload and can auto-reconnect later
- saveCachedTokens(get().instances);
+ saveCachedTokens(get().instances, currentUser.id);
// Mark auto-connect complete so syncInstanceList is now safe to run
set({ _autoConnectDone: true });
@@ -560,7 +580,7 @@ export const useInstanceStore = create((set, get) => ({
disconnectAllRemote();
set({ instances: [], isLoading: false, error: null, _autoConnectDone: false });
- localStorage.removeItem(STORAGE_KEY);
+ // Token cache preserved — scoped per user, survives logout for seamless reconnect
},
}));
diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts
index 13417e4e..1e9d39b3 100644
--- a/packages/web/src/stores/spaceStore.ts
+++ b/packages/web/src/stores/spaceStore.ts
@@ -199,7 +199,14 @@ export const useSpaceStore = create((set, get) => ({
},
updateSpace: async (spaceId: string, data: UpdateSpaceRequest) => {
- const updated = await api.spaces.update(spaceId, data);
+ const space = get().spaces.find(s => s.id === spaceId);
+ const origin = space?._instanceOrigin ?? '';
+ const client = getApiForOrigin(origin);
+ const updated = await client.spaces.update(spaceId, data);
+ // Normalize remote asset URL so the icon displays correctly in-app
+ if (origin && updated.icon) {
+ updated.icon = resolveAssetUrl(updated.icon, origin) ?? updated.icon;
+ }
set((state) => ({
spaces: state.spaces.map(s => s.id === spaceId ? { ...s, ...updated } : s),
}));