feat(i18n): translate five more systems
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
- Settings > Voice: video — preview, permission states, camera picker.
This closes the Voice & Video tab; input, output and video are all done.
- Create channel / category / space
- Group DMs — new DM, add friends, group settings, leave
- Join a space — discovery, invite code, and both federated auth phases
- Delete account, transfer ownership, and the top-level error screen
Three patterns worth noting. Strings that are chosen in a helper or held in
state (the camera's hard-blocked copy, its preview errors, the space
visibility options, the invite failure reasons) now travel as translation
keys and are resolved where they render, so switching language re-labels
what is already on screen. Sentences that wrap an emphasised literal — the
host name when joining, the space and member names when transferring — are
split around their placeholder instead of interpolated, leaving word order
to the translation. And `CreateChannel` mapped over `(t) =>` for the channel
type, which shadowed the translator; the loop variable is now `channelType`.
The error boundary in main.tsx is a class component, so it reads the locale
once per render via `translate` rather than the hook — correct here anyway,
since an error screen has no reason to re-render on a language switch.
Two GroupDmSettings tests located the name input by its aria-label, which
this change translates. They now use the `data-group-dm-name-input`
attribute the input already carries, matching how the rest of that file
locates elements — and no longer break when copy changes.
Dictionary is at 595 keys, en and pt-BR at exact parity.
Verified: web build (tsc + vite), 490 web tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { api } from '../../api/client';
|
||||
import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
||||
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
||||
import type { User } from '@backspace/shared';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
function AddDmFriendRow({
|
||||
friend,
|
||||
@@ -26,6 +27,7 @@ function AddDmFriendRow({
|
||||
isAdding: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const canonical = useCanonicalUserView(friend as unknown as User);
|
||||
const { baseName } = parseFederatedUsername(canonical.username);
|
||||
const friendDisplayName = canonical.displayName ?? baseName;
|
||||
@@ -54,7 +56,7 @@ function AddDmFriendRow({
|
||||
{friendDisplayName}
|
||||
</div>
|
||||
<div className="text-[11px] text-txt-tertiary truncate">
|
||||
{isInDm ? 'Already in this DM' : `@${canonical.username}`}
|
||||
{isInDm ? t('groupDm.add.alreadyIn') : `@${canonical.username}`}
|
||||
</div>
|
||||
</div>
|
||||
{!isInDm && (
|
||||
@@ -77,6 +79,7 @@ function AddDmFriendRow({
|
||||
}
|
||||
|
||||
export function AddDmMemberModal() {
|
||||
const t = useT();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState('');
|
||||
@@ -190,18 +193,20 @@ export function AddDmMemberModal() {
|
||||
closeModal();
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message || 'Failed to add members');
|
||||
setError((err as Error).message || t('groupDm.add.failed'));
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonText = selectedFriends.length === 0
|
||||
? 'Select Friends'
|
||||
: `Add ${selectedFriends.length} Friend${selectedFriends.length > 1 ? 's' : ''}`;
|
||||
? t('groupDm.add.select')
|
||||
: t(selectedFriends.length > 1 ? 'groupDm.add.submitPlural' : 'groupDm.add.submit', {
|
||||
count: selectedFriends.length,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title="Add Friends to DM" mobileStyle="sheet">
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title={t('groupDm.add.title')} mobileStyle="sheet">
|
||||
<div className="space-y-3">
|
||||
{/* Header with member count */}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -239,7 +244,7 @@ export function AddDmMemberModal() {
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search friends..."
|
||||
placeholder={t('groupDm.add.search')}
|
||||
className="input-search w-full py-2 text-[14px]"
|
||||
disabled={remainingSlots <= 0}
|
||||
/>
|
||||
@@ -256,7 +261,7 @@ export function AddDmMemberModal() {
|
||||
<div className="max-h-[300px] overflow-y-auto space-y-[2px]">
|
||||
{filteredFriends.length === 0 && (
|
||||
<div className="py-4 text-center text-txt-tertiary text-[14px]">
|
||||
{query.trim() ? 'No friends match your search' : 'No friends yet'}
|
||||
{query.trim() ? t('groupDm.add.noMatch') : t('groupDm.add.noFriends')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -284,7 +289,7 @@ export function AddDmMemberModal() {
|
||||
disabled={selectedFriends.length === 0 || isAdding}
|
||||
className="w-full py-2 rounded-md text-[13px] font-semibold transition-colors bg-accent-mint text-surface-base hover:bg-accent-mint/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isAdding ? 'Adding...' : buttonText}
|
||||
{isAdding ? t('groupDm.add.adding') : buttonText}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -2,8 +2,10 @@ import React, { useState } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
export function CreateCategoryModal() {
|
||||
const t = useT();
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -19,12 +21,12 @@ export function CreateCategoryModal() {
|
||||
setError('');
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('Category name is required');
|
||||
setError(t('create.category.name.required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSpaceId) {
|
||||
setError('No space selected');
|
||||
setError(t('create.noSpace'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,14 +36,14 @@ export function CreateCategoryModal() {
|
||||
closeModal();
|
||||
setName('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create category');
|
||||
setError(err instanceof Error ? err.message : t('create.category.failed'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title="Create Category" mobileStyle="sheet">
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title={t('create.category.title')} mobileStyle="sheet">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||
@@ -51,14 +53,14 @@ export function CreateCategoryModal() {
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Category Name
|
||||
{t('create.category.name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input-standard w-full"
|
||||
placeholder="new-category"
|
||||
placeholder={t('create.category.name.placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -71,14 +73,14 @@ export function CreateCategoryModal() {
|
||||
onClick={closeModal}
|
||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t('create.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Category'}
|
||||
{isLoading ? t('create.creating') : t('create.category.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,10 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
export function CreateChannelModal() {
|
||||
const t = useT();
|
||||
const [name, setName] = useState('');
|
||||
const [type, setType] = useState<'text' | 'voice'>('text');
|
||||
const [topic, setTopic] = useState('');
|
||||
@@ -31,12 +33,12 @@ export function CreateChannelModal() {
|
||||
setError('');
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('Channel name is required');
|
||||
setError(t('create.channel.name.required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSpaceId) {
|
||||
setError('No space selected');
|
||||
setError(t('create.noSpace'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -49,14 +51,14 @@ export function CreateChannelModal() {
|
||||
setType('text');
|
||||
setCategoryId('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create channel');
|
||||
setError(err instanceof Error ? err.message : t('create.channel.failed'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title="Create Channel" mobileStyle="sheet">
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title={t('create.channel.title')} mobileStyle="sheet">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||
@@ -66,14 +68,14 @@ export function CreateChannelModal() {
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Channel Type
|
||||
{t('create.channel.type')}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{(['text', 'voice'] as const).map((t) => (
|
||||
{(['text', 'voice'] as const).map((channelType) => (
|
||||
<label
|
||||
key={t}
|
||||
key={channelType}
|
||||
className={`flex items-center gap-3 p-3 rounded cursor-pointer border ${
|
||||
type === t
|
||||
type === channelType
|
||||
? 'border-accent-primary bg-interactive-hover'
|
||||
: 'border-border-soft bg-surface-channel hover:bg-interactive-hover'
|
||||
}`}
|
||||
@@ -81,24 +83,24 @@ export function CreateChannelModal() {
|
||||
<input
|
||||
type="radio"
|
||||
name="channelType"
|
||||
value={t}
|
||||
checked={type === t}
|
||||
onChange={() => setType(t)}
|
||||
value={channelType}
|
||||
checked={type === channelType}
|
||||
onChange={() => setType(channelType)}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="w-5 h-5 text-txt-tertiary">
|
||||
{t === 'text' && (
|
||||
{channelType === 'text' && (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" /></svg>
|
||||
)}
|
||||
{t === 'voice' && (
|
||||
{channelType === 'voice' && (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" /></svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-txt-primary capitalize">{t}</div>
|
||||
<div className="text-sm font-medium text-txt-primary">{t(`create.channel.type.${channelType}`)}</div>
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
{t === 'text' && 'Send messages, images, and files'}
|
||||
{t === 'voice' && 'Hang out with voice and video'}
|
||||
{channelType === 'text' && t('create.channel.type.text.description')}
|
||||
{channelType === 'voice' && t('create.channel.type.voice.description')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@@ -108,14 +110,14 @@ export function CreateChannelModal() {
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Channel Name
|
||||
{t('create.channel.name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input-standard w-full"
|
||||
placeholder="new-channel"
|
||||
placeholder={t('create.channel.name.placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -123,14 +125,14 @@ export function CreateChannelModal() {
|
||||
{type === 'text' && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Topic (optional)
|
||||
{t('create.channel.topic')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
className="input-standard w-full"
|
||||
placeholder="What's this channel about?"
|
||||
placeholder={t('create.channel.topic.placeholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -138,14 +140,14 @@ export function CreateChannelModal() {
|
||||
{categories.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Category
|
||||
{t('create.channel.category')}
|
||||
</label>
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
className="input-standard w-full"
|
||||
>
|
||||
<option value="">No Category</option>
|
||||
<option value="">{t('create.channel.category.none')}</option>
|
||||
{[...categories].sort((a, b) => a.position - b.position).map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
@@ -161,14 +163,14 @@ export function CreateChannelModal() {
|
||||
onClick={closeModal}
|
||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t('create.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Channel'}
|
||||
{isLoading ? t('create.creating') : t('create.channel.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,14 +9,17 @@ import { waitForTransferAttachment } from '../../utils/waitForTransfer';
|
||||
import { AVATAR_COLORS } from '@backspace/shared';
|
||||
import type { SpaceVisibility, AvatarColor } from '@backspace/shared';
|
||||
import { SPACE_GRADIENT_MAP, getSpaceGradient } from '../../utils/gradients';
|
||||
import { useT, type TranslationKey } from '../../i18n';
|
||||
|
||||
const visibilityOptions: { value: SpaceVisibility; label: string; desc: string }[] = [
|
||||
{ value: 'private', label: 'Private', desc: 'Only people with an invite link can join' },
|
||||
{ value: 'request', label: 'Request to Join', desc: 'Visible in Explore — people can request to join' },
|
||||
{ value: 'public', label: 'Public', desc: 'Visible in Explore — anyone can join instantly' },
|
||||
/** Label and description are translation keys, resolved where they render. */
|
||||
const visibilityOptions: { value: SpaceVisibility; labelKey: TranslationKey; descKey: TranslationKey }[] = [
|
||||
{ value: 'private', labelKey: 'create.space.visibility.private', descKey: 'create.space.visibility.private.description' },
|
||||
{ value: 'request', labelKey: 'create.space.visibility.request', descKey: 'create.space.visibility.request.description' },
|
||||
{ value: 'public', labelKey: 'create.space.visibility.public', descKey: 'create.space.visibility.public.description' },
|
||||
];
|
||||
|
||||
export function CreateSpaceModal() {
|
||||
const t = useT();
|
||||
const [name, setName] = useState('');
|
||||
const [visibility, setVisibility] = useState<SpaceVisibility>('private');
|
||||
const [description, setDescription] = useState('');
|
||||
@@ -65,7 +68,7 @@ export function CreateSpaceModal() {
|
||||
const { filename } = await waitForTransferAttachment(tid);
|
||||
setIconFilename(filename);
|
||||
} catch {
|
||||
setError('Failed to upload icon');
|
||||
setError(t('create.space.iconFailed'));
|
||||
setIconPreview(null);
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
} finally {
|
||||
@@ -97,7 +100,7 @@ export function CreateSpaceModal() {
|
||||
setError('');
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('Space name is required');
|
||||
setError(t('create.space.name.required'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,7 +116,7 @@ export function CreateSpaceModal() {
|
||||
handleClose();
|
||||
navigate(`/channels/${space.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create space');
|
||||
setError(err instanceof Error ? err.message : t('create.space.failed'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -121,7 +124,7 @@ export function CreateSpaceModal() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Create a Space" mobileStyle="sheet">
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title={t('create.space.title')} mobileStyle="sheet">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||
@@ -158,7 +161,7 @@ export function CreateSpaceModal() {
|
||||
) : (
|
||||
<>
|
||||
<span className="text-2xl font-bold">{(name || 'S').charAt(0).toUpperCase()}</span>
|
||||
<span className="text-[9px] font-medium opacity-60">Upload</span>
|
||||
<span className="text-[9px] font-medium opacity-60">{t('create.space.upload')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -177,7 +180,7 @@ export function CreateSpaceModal() {
|
||||
onClick={handleRemoveIcon}
|
||||
className="ml-2 self-start mt-1 text-txt-tertiary hover:text-txt-danger text-xs transition-colors"
|
||||
>
|
||||
Remove
|
||||
{t('create.space.remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -185,7 +188,7 @@ export function CreateSpaceModal() {
|
||||
{/* Icon Color */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Icon Color
|
||||
{t('create.space.iconColor')}
|
||||
</label>
|
||||
<div className="flex gap-2 justify-center">
|
||||
{AVATAR_COLORS.map((key) => {
|
||||
@@ -211,14 +214,14 @@ export function CreateSpaceModal() {
|
||||
{/* Space Name */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Space Name
|
||||
{t('create.space.name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input-standard w-full"
|
||||
placeholder="My Awesome Space"
|
||||
placeholder={t('create.space.name.placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -226,7 +229,7 @@ export function CreateSpaceModal() {
|
||||
{/* Visibility */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-2">
|
||||
Visibility
|
||||
{t('create.space.visibility')}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{visibilityOptions.map((opt) => (
|
||||
@@ -247,8 +250,8 @@ export function CreateSpaceModal() {
|
||||
className="mt-0.5 accent-accent-primary"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-txt-primary">{opt.label}</div>
|
||||
<div className="text-xs text-txt-tertiary">{opt.desc}</div>
|
||||
<div className="text-sm font-medium text-txt-primary">{t(opt.labelKey)}</div>
|
||||
<div className="text-xs text-txt-tertiary">{t(opt.descKey)}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
@@ -258,12 +261,12 @@ export function CreateSpaceModal() {
|
||||
{/* Description */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-1.5">
|
||||
Description
|
||||
{t('create.space.description')}
|
||||
</div>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value.slice(0, 200))}
|
||||
placeholder="A short description for your space..."
|
||||
placeholder={t('create.space.description.placeholder')}
|
||||
rows={3}
|
||||
className="input-standard w-full resize-none"
|
||||
/>
|
||||
@@ -279,14 +282,14 @@ export function CreateSpaceModal() {
|
||||
onClick={handleClose}
|
||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t('create.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || uploadingIcon}
|
||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create'}
|
||||
{isLoading ? t('create.creating') : t('create.space.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,7 +302,7 @@ export function CreateSpaceModal() {
|
||||
onClose={() => setCropSrc(null)}
|
||||
imageSrc={cropSrc ?? ''}
|
||||
onCropComplete={handleCropComplete}
|
||||
title="Crop Space Icon"
|
||||
title={t('create.space.cropIcon')}
|
||||
cropShape="round"
|
||||
aspectRatio={1}
|
||||
maxOutputDimension={256}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useInstanceStore } from '../../stores/instanceStore';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { api } from '../../api/client';
|
||||
import { deleteAccountOnRemotes, type FederationOpResult } from '../../utils/federationOps';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
interface DeleteAccountModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -21,6 +22,7 @@ interface OwnedSpaceInfo {
|
||||
}
|
||||
|
||||
export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps) {
|
||||
const t = useT();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const instances = useInstanceStore((s) => s.instances);
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
@@ -125,7 +127,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
}
|
||||
setStep('confirm');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to process spaces');
|
||||
setError(err instanceof Error ? err.message : t('danger.delete.spacesFailed'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -133,7 +135,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (confirmUsername !== user.username) {
|
||||
setError('Username does not match');
|
||||
setError(t('danger.delete.usernameMismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,7 +159,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
setStep('complete');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete account');
|
||||
setError(err instanceof Error ? err.message : t('danger.delete.failed'));
|
||||
if (step === 'federation') {
|
||||
// Stay on federation step so user can see results
|
||||
} else {
|
||||
@@ -176,7 +178,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
setDeletionComplete(true);
|
||||
setStep('complete');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete account');
|
||||
setError(err instanceof Error ? err.message : t('danger.delete.failed'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -188,7 +190,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
<div className="relative max-w-lg w-full mx-4 max-h-[calc(100vh-2rem)] flex flex-col bg-surface-elevated rounded-lg shadow-xl animate-slide-up overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pt-5 flex-shrink-0">
|
||||
<h2 className="text-lg font-bold text-txt-primary">Delete Account</h2>
|
||||
<h2 className="text-lg font-bold text-txt-primary">{t('danger.delete.title')}</h2>
|
||||
{step !== 'complete' && (
|
||||
<button onClick={onClose} className="text-txt-tertiary hover:text-txt-primary transition-colors p-1">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -203,19 +205,19 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
{step === 'warning' && (
|
||||
<>
|
||||
<div className="bg-accent-rose/10 border border-accent-rose/20 rounded-lg p-3.5">
|
||||
<p className="text-sm text-txt-primary font-medium mb-2">This will permanently delete your account.</p>
|
||||
<p className="text-sm text-txt-primary font-medium mb-2">{t('danger.delete.lead')}</p>
|
||||
<ul className="text-xs text-txt-secondary space-y-1">
|
||||
<li>- All space memberships will be removed</li>
|
||||
<li>- All friend connections will be removed</li>
|
||||
<li>- All DM memberships will be removed</li>
|
||||
<li>- Your messages will remain but be attributed to "Deleted User"</li>
|
||||
<li>- {t('danger.delete.item.spaces')}</li>
|
||||
<li>- {t('danger.delete.item.friends')}</li>
|
||||
<li>- {t('danger.delete.item.dms')}</li>
|
||||
<li>- {t('danger.delete.item.messages')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{ownedSpaces.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-txt-primary font-medium mb-2">
|
||||
You own {ownedSpaces.length} space{ownedSpaces.length > 1 ? 's' : ''}. Handle each before continuing:
|
||||
{t(ownedSpaces.length > 1 ? 'danger.delete.ownedPlural' : 'danger.delete.owned', { count: ownedSpaces.length })}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{ownedSpaces.map(space => (
|
||||
@@ -230,7 +232,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
: 'bg-white/[0.06] text-txt-secondary hover:text-txt-primary'
|
||||
}`}
|
||||
>
|
||||
Transfer
|
||||
{t('danger.delete.transfer')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSpaceAction(space.id, 'delete')}
|
||||
@@ -240,7 +242,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
: 'bg-white/[0.06] text-txt-secondary hover:text-txt-primary'
|
||||
}`}
|
||||
>
|
||||
Delete Space
|
||||
{t('danger.delete.deleteSpace')}
|
||||
</button>
|
||||
</div>
|
||||
{space.action === 'transfer' && (
|
||||
@@ -249,7 +251,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
onChange={(e) => handleTransferTo(space.id, e.target.value)}
|
||||
className="input-standard w-full px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">Select new owner...</option>
|
||||
<option value="">{t('danger.delete.selectOwner')}</option>
|
||||
{space.members.map(m => (
|
||||
<option key={m.userId} value={m.userId}>
|
||||
{m.displayName || m.username}
|
||||
@@ -275,7 +277,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
}
|
||||
className="w-full py-2 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Processing...' : 'Continue'}
|
||||
{isLoading ? t('danger.delete.processing') : t('danger.delete.continue')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -284,7 +286,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
{step === 'confirm' && (
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleConfirmDelete(); }} className="space-y-4">
|
||||
<div className="bg-accent-rose/10 border border-accent-rose/20 rounded-lg p-3.5">
|
||||
<p className="text-sm text-txt-danger font-medium">This action is permanent and cannot be undone.</p>
|
||||
<p className="text-sm text-txt-danger font-medium">{t('danger.delete.permanent')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -301,13 +303,13 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-txt-secondary mb-1.5">Password</label>
|
||||
<label className="block text-xs text-txt-secondary mb-1.5">{t('danger.delete.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="input-danger w-full"
|
||||
placeholder="Enter your password"
|
||||
placeholder={t('danger.delete.password.placeholder')}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
@@ -321,14 +323,14 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
onClick={() => { setStep('warning'); setError(''); }}
|
||||
className="flex-1 py-2 bg-white/[0.06] hover:bg-white/[0.1] text-txt-secondary text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Back
|
||||
{t('danger.delete.back')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || confirmUsername !== user.username || !confirmPassword}
|
||||
className="flex-1 py-2 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Deleting...' : 'Delete My Account'}
|
||||
{isLoading ? t('danger.delete.deleting') : t('danger.delete.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -337,7 +339,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
{/* Step 3: Federation Progress */}
|
||||
{step === 'federation' && (
|
||||
<>
|
||||
<p className="text-sm text-txt-secondary">Removing your account from connected instances...</p>
|
||||
<p className="text-sm text-txt-secondary">{t('danger.delete.federating')}</p>
|
||||
<div className="space-y-2">
|
||||
{instances.filter(i => i.status === 'connected').map(inst => {
|
||||
const result = federationResults.find(r => r.origin === inst.origin);
|
||||
@@ -376,7 +378,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
disabled={isLoading}
|
||||
className="w-full py-2 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Deleting...' : 'Delete Account Now'}
|
||||
{isLoading ? t('danger.delete.deleting') : t('danger.delete.now')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -388,8 +390,8 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
|
||||
<svg className="w-12 h-12 text-txt-tertiary mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<p className="text-lg font-medium text-txt-primary mb-1">Account deleted</p>
|
||||
<p className="text-sm text-txt-tertiary">Redirecting to login...</p>
|
||||
<p className="text-lg font-medium text-txt-primary mb-1">{t('danger.delete.done')}</p>
|
||||
<p className="text-sm text-txt-tertiary">{t('danger.delete.redirecting')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -207,7 +207,7 @@ describe('GroupDmSettings — non-owner', () => {
|
||||
|
||||
renderModal();
|
||||
|
||||
const input = screen.getByLabelText('Group name') as HTMLInputElement;
|
||||
const input = document.querySelector('[data-group-dm-name-input]') as HTMLInputElement;
|
||||
expect(input.disabled).toBe(true);
|
||||
|
||||
// Save button is not rendered for non-owners; only "Close".
|
||||
@@ -234,7 +234,7 @@ describe('GroupDmSettings — owner overview', () => {
|
||||
|
||||
renderModal();
|
||||
|
||||
const input = screen.getByLabelText('Group name') as HTMLInputElement;
|
||||
const input = document.querySelector('[data-group-dm-name-input]') as HTMLInputElement;
|
||||
expect(input.disabled).toBe(false);
|
||||
expect(input.value).toBe('Old Name');
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
||||
import { AvatarStack } from '../ui/AvatarStack';
|
||||
import { DmMemberRow, type DmMemberRowAction } from '../layout/DmMemberRow';
|
||||
import { pointAnchor } from '../../hooks/useFloatingPosition';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
const MAX_NAME_LENGTH = 50;
|
||||
const MAX_GROUP_MEMBERS = 10;
|
||||
@@ -40,6 +41,7 @@ type Tab = 'overview' | 'members';
|
||||
* Cancel discards the staged blob; no upload fires.
|
||||
*/
|
||||
export function GroupDmSettings() {
|
||||
const t = useT();
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
const modalData = useUIStore((s) => s.modalData);
|
||||
const closeModal = useUIStore((s) => s.closeModal);
|
||||
@@ -221,7 +223,7 @@ export function GroupDmSettings() {
|
||||
// (`dm_channel_updated`) updates the open channel in-place.
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to save settings';
|
||||
const msg = err instanceof Error ? err.message : t('groupDm.saveFailed');
|
||||
setSaveError(msg);
|
||||
addToast(msg, 'warning', 4000);
|
||||
} finally {
|
||||
@@ -240,7 +242,7 @@ export function GroupDmSettings() {
|
||||
await api.dm.leave(dmChannelId);
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to leave group';
|
||||
const msg = err instanceof Error ? err.message : t('groupDm.leave.failed');
|
||||
addToast(msg, 'warning', 4000);
|
||||
} finally {
|
||||
setLeaving(false);
|
||||
@@ -280,7 +282,7 @@ export function GroupDmSettings() {
|
||||
await useSocialStore.getState().removeFriend(member.id);
|
||||
} catch (err) {
|
||||
addToast(
|
||||
err instanceof Error ? err.message : 'Failed to remove friend',
|
||||
err instanceof Error ? err.message : t('dm.removeFriend.failed'),
|
||||
'warning',
|
||||
3000,
|
||||
);
|
||||
@@ -301,7 +303,7 @@ export function GroupDmSettings() {
|
||||
setPendingKick(null);
|
||||
} catch (err) {
|
||||
addToast(
|
||||
err instanceof Error ? err.message : 'Failed to remove member',
|
||||
err instanceof Error ? err.message : t('dm.remove.failed'),
|
||||
'warning',
|
||||
3000,
|
||||
);
|
||||
@@ -323,7 +325,7 @@ export function GroupDmSettings() {
|
||||
setPendingTransfer(null);
|
||||
} catch (err) {
|
||||
addToast(
|
||||
err instanceof Error ? err.message : 'Failed to transfer ownership',
|
||||
err instanceof Error ? err.message : t('dm.transfer.failed'),
|
||||
'warning',
|
||||
3000,
|
||||
);
|
||||
@@ -345,7 +347,7 @@ export function GroupDmSettings() {
|
||||
if (isMobile) setMobileView('content');
|
||||
};
|
||||
|
||||
const headerName = dmChannel.name && dmChannel.name.length > 0 ? dmChannel.name : (fallbackName || 'Group DM');
|
||||
const headerName = dmChannel.name && dmChannel.name.length > 0 ? dmChannel.name : (fallbackName || t('groupDm.fallbackName'));
|
||||
|
||||
// ── Overview panel ─────────────────────────────────────────────────────
|
||||
const overviewPanel = (
|
||||
@@ -360,7 +362,7 @@ export function GroupDmSettings() {
|
||||
onClick={handleHeroClick}
|
||||
disabled={!isOwner}
|
||||
data-group-dm-icon-hero
|
||||
aria-label={isOwner ? 'Change group icon' : 'Group icon'}
|
||||
aria-label={isOwner ? t('groupDm.icon.change') : t('groupDm.icon.label')}
|
||||
className={`relative block rounded-full overflow-hidden group ${
|
||||
isOwner ? 'cursor-pointer' : 'cursor-default'
|
||||
}`}
|
||||
@@ -416,18 +418,18 @@ export function GroupDmSettings() {
|
||||
{/* Group name */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
Group Name
|
||||
{t('groupDm.name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.slice(0, MAX_NAME_LENGTH))}
|
||||
placeholder={fallbackName || 'Group DM'}
|
||||
placeholder={fallbackName || t('groupDm.fallbackName')}
|
||||
disabled={!isOwner}
|
||||
maxLength={MAX_NAME_LENGTH}
|
||||
className="input-standard w-full"
|
||||
data-group-dm-name-input
|
||||
aria-label="Group name"
|
||||
aria-label={t('groupDm.name')}
|
||||
/>
|
||||
{isOwner && (
|
||||
<div className="text-[11px] text-txt-tertiary text-right mt-1">
|
||||
@@ -451,7 +453,7 @@ export function GroupDmSettings() {
|
||||
className="px-3 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
data-group-dm-cancel
|
||||
>
|
||||
Cancel
|
||||
{t('groupDm.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -460,7 +462,7 @@ export function GroupDmSettings() {
|
||||
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'}
|
||||
{saving ? t('groupDm.saving') : t('groupDm.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -474,7 +476,7 @@ export function GroupDmSettings() {
|
||||
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
|
||||
{t('groupDm.close')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -482,10 +484,10 @@ export function GroupDmSettings() {
|
||||
{/* 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
|
||||
{t('groupDm.leave')}
|
||||
</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.
|
||||
{t('groupDm.leave.description')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -494,7 +496,7 @@ export function GroupDmSettings() {
|
||||
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'}
|
||||
{leaving ? t('groupDm.leaving') : t('groupDm.leave')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -514,7 +516,7 @@ export function GroupDmSettings() {
|
||||
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>
|
||||
<h2 className="text-lg font-semibold text-txt-primary">{t('groupDm.members.title')}</h2>
|
||||
<span className="text-[12px] text-txt-tertiary">
|
||||
{memberCount}/{MAX_GROUP_MEMBERS}
|
||||
</span>
|
||||
@@ -530,9 +532,9 @@ export function GroupDmSettings() {
|
||||
<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
|
||||
{t('groupDm.members.add')}
|
||||
{!canAddMembers && (
|
||||
<span className="ml-auto text-[11px] text-txt-tertiary">Group is full</span>
|
||||
<span className="ml-auto text-[11px] text-txt-tertiary">{t('groupDm.members.full')}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -613,13 +615,13 @@ export function GroupDmSettings() {
|
||||
|
||||
<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
|
||||
{t('groupDm.tab.general')}
|
||||
</div>
|
||||
<button type="button" onClick={() => handleTabClick('overview')} className={tabBtnClass('overview')}>
|
||||
Overview
|
||||
{t('groupDm.tab.overview')}
|
||||
</button>
|
||||
<button type="button" onClick={() => handleTabClick('members')} className={tabBtnClass('members')}>
|
||||
Members
|
||||
{t('groupDm.tab.members')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -634,7 +636,7 @@ export function GroupDmSettings() {
|
||||
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"
|
||||
aria-label={t('groupDm.back')}
|
||||
>
|
||||
<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" />
|
||||
@@ -655,7 +657,7 @@ export function GroupDmSettings() {
|
||||
onClose={() => setCropSrc(null)}
|
||||
imageSrc={cropSrc ?? ''}
|
||||
onCropComplete={handleCropComplete}
|
||||
title="Crop Group Icon"
|
||||
title={t('groupDm.icon.crop')}
|
||||
cropShape="round"
|
||||
aspectRatio={1}
|
||||
maxOutputDimension={256}
|
||||
@@ -666,9 +668,9 @@ export function GroupDmSettings() {
|
||||
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"
|
||||
title={t('groupDm.leave')}
|
||||
description={t('groupDm.leave.confirm', { name: headerName })}
|
||||
confirmLabel={t('groupDm.leave.button')}
|
||||
variant="danger"
|
||||
loading={leaving}
|
||||
/>
|
||||
@@ -677,13 +679,15 @@ export function GroupDmSettings() {
|
||||
isOpen={!!pendingKick}
|
||||
onClose={() => { if (!memberActionSubmitting) setPendingKick(null); }}
|
||||
onConfirm={confirmKick}
|
||||
title="Remove from Group"
|
||||
title={t('dm.remove.title')}
|
||||
description={
|
||||
pendingKick
|
||||
? `Remove ${pendingKick.displayName ?? parseFederatedUsername(pendingKick.username).baseName} from this group? They won't be able to see new messages.`
|
||||
? t('dm.remove.body', {
|
||||
name: pendingKick.displayName ?? parseFederatedUsername(pendingKick.username).baseName,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Remove"
|
||||
confirmLabel={t('dm.remove.confirm')}
|
||||
variant="danger"
|
||||
loading={memberActionSubmitting}
|
||||
/>
|
||||
@@ -692,13 +696,15 @@ export function GroupDmSettings() {
|
||||
isOpen={!!pendingTransfer}
|
||||
onClose={() => { if (!memberActionSubmitting) setPendingTransfer(null); }}
|
||||
onConfirm={confirmTransfer}
|
||||
title="Transfer Ownership"
|
||||
title={t('dm.transfer.title')}
|
||||
description={
|
||||
pendingTransfer
|
||||
? `Transfer ownership to ${pendingTransfer.displayName ?? parseFederatedUsername(pendingTransfer.username).baseName}? You'll lose owner privileges.`
|
||||
? t('dm.transfer.body', {
|
||||
name: pendingTransfer.displayName ?? parseFederatedUsername(pendingTransfer.username).baseName,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Transfer"
|
||||
confirmLabel={t('dm.transfer.confirm')}
|
||||
variant="warning"
|
||||
loading={memberActionSubmitting}
|
||||
/>
|
||||
|
||||
@@ -8,10 +8,12 @@ import { useExploreStore } from '../../stores/exploreStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { parseInviteInput } from '../../utils/inviteParser';
|
||||
import { ExploreSpacePreviewCard } from './ExploreSpacePreviewCard';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
type JoinPhase = 'input' | 'connect' | 'fallback';
|
||||
|
||||
export function JoinSpaceModal() {
|
||||
const t = useT();
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -111,7 +113,7 @@ export function JoinSpaceModal() {
|
||||
setPhase('connect');
|
||||
setError('');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join space');
|
||||
setError(err instanceof Error ? err.message : t('join.failed'));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -161,7 +163,7 @@ export function JoinSpaceModal() {
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title="Join a Space" mobileStyle="sheet">
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title={t('join.title')} mobileStyle="sheet">
|
||||
{/* Error display (shared across all phases) */}
|
||||
{error && (
|
||||
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||
@@ -176,7 +178,7 @@ export function JoinSpaceModal() {
|
||||
{discoveryEnabled ? (
|
||||
<div className="mb-1">
|
||||
<p className="text-txt-secondary text-sm mb-3">
|
||||
Discover spaces to join, or browse them all in Explore.
|
||||
{t('join.discover.lead')}
|
||||
</p>
|
||||
|
||||
{discoveryLoading && previewSpaces.length === 0 ? (
|
||||
@@ -187,11 +189,11 @@ export function JoinSpaceModal() {
|
||||
</div>
|
||||
) : discoveryError ? (
|
||||
<div className="p-2.5 rounded-lg bg-surface-channel border border-border-soft text-[13px] text-txt-tertiary">
|
||||
Couldn’t load spaces to discover right now. You can still join with an invite code below.
|
||||
{t('join.discover.error')}
|
||||
</div>
|
||||
) : previewSpaces.length === 0 ? (
|
||||
<div className="p-3 rounded-lg bg-surface-channel border border-border-soft text-[13px] text-txt-tertiary text-center">
|
||||
No spaces to discover yet — try an invite code below, or check back later.
|
||||
{t('join.discover.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[280px] overflow-y-auto pr-0.5">
|
||||
@@ -210,7 +212,7 @@ export function JoinSpaceModal() {
|
||||
onClick={handleBrowseExplore}
|
||||
className="mt-3 w-full py-2 flex items-center justify-center gap-1.5 text-sm font-medium text-accent-primary hover:bg-accent-primary/10 rounded-lg transition-colors"
|
||||
>
|
||||
Browse all in Explore
|
||||
{t('join.discover.browse')}
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
@@ -226,7 +228,7 @@ export function JoinSpaceModal() {
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
<div className="flex-1 h-px bg-white/[0.06]" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-txt-tertiary">
|
||||
Have an invite code?
|
||||
{t('join.haveCode')}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-white/[0.06]" />
|
||||
</div>
|
||||
@@ -239,7 +241,7 @@ export function JoinSpaceModal() {
|
||||
value={inviteCode}
|
||||
onChange={(e) => setInviteCode(e.target.value)}
|
||||
className="input-standard w-full"
|
||||
placeholder="e.g. abc123 or https://instance.com/join/abc123"
|
||||
placeholder={t('join.code.placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||
@@ -250,14 +252,14 @@ export function JoinSpaceModal() {
|
||||
onClick={closeModal}
|
||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t('join.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !inviteCode.trim()}
|
||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Joining...' : 'Join Space'}
|
||||
{isLoading ? t('join.joining') : t('join.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,25 +273,30 @@ export function JoinSpaceModal() {
|
||||
<form onSubmit={handleConnect}>
|
||||
<input type="text" autoComplete="username" value={user?.username || ''} readOnly tabIndex={-1} className="sr-only" />
|
||||
<p className="text-txt-secondary text-sm mb-4">
|
||||
Connect to <span className="text-txt-primary font-medium">{hostDisplay}</span> to join this space.
|
||||
{/* The host name is emphasised, so the sentence is split around
|
||||
its placeholder rather than interpolated as flat text. */}
|
||||
{(() => {
|
||||
const [before, after] = t('join.connect.lead').split('{host}');
|
||||
return (<>{before}<span className="text-txt-primary font-medium">{hostDisplay}</span>{after}</>);
|
||||
})()}
|
||||
</p>
|
||||
<div className="mb-4 space-y-2">
|
||||
<div>
|
||||
<label className="block text-xs text-txt-tertiary mb-1">
|
||||
Enter your password to connect
|
||||
{t('join.connect.password')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Your account password"
|
||||
placeholder={t('join.connect.password.placeholder')}
|
||||
className="input-standard w-full"
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<div className="text-xs text-txt-tertiary mt-1">
|
||||
Your password is verified locally, then used to create or access your account on the remote instance.
|
||||
{t('join.connect.note')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -304,7 +311,7 @@ export function JoinSpaceModal() {
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back
|
||||
{t('join.back')}
|
||||
</button>
|
||||
<div className="w-px h-5 bg-white/10" />
|
||||
<button
|
||||
@@ -312,14 +319,14 @@ export function JoinSpaceModal() {
|
||||
onClick={closeModal}
|
||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t('join.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !password}
|
||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Connecting...' : 'Connect & Join'}
|
||||
{isLoading ? t('join.connect.connecting') : t('join.connect.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,28 +338,28 @@ export function JoinSpaceModal() {
|
||||
{phase === 'fallback' && (
|
||||
<form onSubmit={handleFallbackLogin}>
|
||||
<div className="mb-3 p-2 bg-accent-amber/10 border border-accent-amber/30 rounded text-xs text-accent-amber">
|
||||
An account already exists on {hostDisplay} with a different password. Enter the credentials you used on that instance.
|
||||
{t('join.fallback.lead', { host: hostDisplay })}
|
||||
</div>
|
||||
<div className="mb-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
|
||||
<label className="block text-xs text-txt-tertiary mb-1">{t('join.fallback.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fallbackUsername}
|
||||
onChange={(e) => setFallbackUsername(e.target.value)}
|
||||
placeholder="Your username on this instance"
|
||||
placeholder={t('join.fallback.username.placeholder')}
|
||||
className="input-standard w-full"
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-txt-tertiary mb-1">Password for this instance</label>
|
||||
<label className="block text-xs text-txt-tertiary mb-1">{t('join.fallback.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={fallbackPassword}
|
||||
onChange={(e) => setFallbackPassword(e.target.value)}
|
||||
placeholder="Password on the remote instance"
|
||||
placeholder={t('join.fallback.password.placeholder')}
|
||||
className="input-standard w-full"
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
@@ -371,7 +378,7 @@ export function JoinSpaceModal() {
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back
|
||||
{t('join.back')}
|
||||
</button>
|
||||
<div className="w-px h-5 bg-white/10" />
|
||||
<button
|
||||
@@ -379,14 +386,14 @@ export function JoinSpaceModal() {
|
||||
onClick={closeModal}
|
||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t('join.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !fallbackUsername || !fallbackPassword}
|
||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Logging in...' : 'Login & Join'}
|
||||
{isLoading ? t('join.fallback.loggingIn') : t('join.fallback.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { api } from '../../api/client';
|
||||
import type { User } from '@backspace/shared';
|
||||
import { parseFederatedUsername } from '../../utils/identity';
|
||||
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
function NewDmUserRow({
|
||||
user,
|
||||
@@ -36,6 +37,7 @@ function NewDmUserRow({
|
||||
}
|
||||
|
||||
export function NewDmModal() {
|
||||
const t = useT();
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<User[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
@@ -104,19 +106,19 @@ export function NewDmModal() {
|
||||
useUIStore.getState().setShowDms(true);
|
||||
navigate(`/channels/@me/${channel.id}`);
|
||||
} catch (err) {
|
||||
setError((err as Error).message || 'Failed to create DM');
|
||||
setError((err as Error).message || t('groupDm.newDm.failed'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title="New Direct Message" mobileStyle="sheet">
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title={t('groupDm.newDm.title')} mobileStyle="sheet">
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder="Search for a user..."
|
||||
placeholder={t('groupDm.newDm.search')}
|
||||
className="input-search w-full py-2 text-[14px]"
|
||||
/>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useUIStore } from '../../stores/uiStore';
|
||||
import { normalizeUserAssets } from '../../utils/assetUrls';
|
||||
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
function TransferMemberRow({
|
||||
member,
|
||||
@@ -44,6 +45,7 @@ function TransferMemberRow({
|
||||
}
|
||||
|
||||
export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose: () => void }) {
|
||||
const t = useT();
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const space = useSpaceStore((s) => s.spaces.find(sp => sp.id === spaceId));
|
||||
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||
@@ -127,7 +129,7 @@ export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string;
|
||||
addToast(`Ownership transferred to ${selectedMember?.user.displayName || selectedMember?.user.username}`, 'success', 3000);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Failed to transfer ownership', 'warning', 3000);
|
||||
addToast(err instanceof Error ? err.message : t('danger.transfer.failed'), 'warning', 3000);
|
||||
} finally {
|
||||
setTransferring(false);
|
||||
}
|
||||
@@ -141,9 +143,12 @@ export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string;
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-4 pb-3 border-b border-white/[0.06]">
|
||||
<h3 className="text-base font-semibold text-txt-primary">Transfer Ownership</h3>
|
||||
<h3 className="text-base font-semibold text-txt-primary">{t('danger.transfer.title')}</h3>
|
||||
<p className="text-xs text-txt-tertiary mt-0.5">
|
||||
Choose a member to become the new owner of <span className="font-medium text-txt-secondary">{space.name}</span>
|
||||
{(() => {
|
||||
const [before, after] = t('danger.transfer.lead').split('{space}');
|
||||
return (<>{before}<span className="font-medium text-txt-secondary">{space.name}</span>{after}</>);
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -152,10 +157,21 @@ export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string;
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/20">
|
||||
<p className="text-sm text-txt-secondary">
|
||||
Transfer ownership of <span className="font-semibold text-txt-primary">{space.name}</span> to{' '}
|
||||
<span className="font-semibold text-txt-primary">{selectedMember.user.displayName || selectedMember.user.username}</span>?
|
||||
{(() => {
|
||||
const parts = t('danger.transfer.confirm').split(/\{space\}|\{member\}/);
|
||||
const memberName = selectedMember.user.displayName || selectedMember.user.username;
|
||||
return (
|
||||
<>
|
||||
{parts[0]}
|
||||
<span className="font-semibold text-txt-primary">{space.name}</span>
|
||||
{parts[1]}
|
||||
<span className="font-semibold text-txt-primary">{memberName}</span>
|
||||
{parts[2]}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</p>
|
||||
<p className="text-xs text-txt-tertiary mt-1.5">You will become a regular member.</p>
|
||||
<p className="text-xs text-txt-tertiary mt-1.5">{t('danger.transfer.note')}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
@@ -163,14 +179,14 @@ export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string;
|
||||
className="flex-1 py-2.5 text-sm font-medium text-txt-secondary bg-interactive-hover hover:bg-interactive-selected rounded-lg transition-colors disabled:opacity-50"
|
||||
disabled={transferring}
|
||||
>
|
||||
Cancel
|
||||
{t('danger.transfer.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleTransfer}
|
||||
disabled={transferring}
|
||||
className="flex-1 py-2.5 bg-accent-amber hover:bg-accent-amber/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{transferring ? 'Transferring...' : 'Transfer'}
|
||||
{transferring ? t('danger.transfer.transferring') : t('danger.transfer.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,16 +198,16 @@ export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string;
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search members..."
|
||||
placeholder={t('danger.transfer.search')}
|
||||
className="input-search w-full"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 min-h-0">
|
||||
{loading ? (
|
||||
<p className="text-xs text-txt-tertiary text-center py-4">Loading members...</p>
|
||||
<p className="text-xs text-txt-tertiary text-center py-4">{t('danger.transfer.loading')}</p>
|
||||
) : filteredMembers.length === 0 ? (
|
||||
<p className="text-xs text-txt-tertiary text-center py-4">No members found</p>
|
||||
<p className="text-xs text-txt-tertiary text-center py-4">{t('danger.transfer.empty')}</p>
|
||||
) : (
|
||||
filteredMembers.map((member) => (
|
||||
<TransferMemberRow
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Track } from 'livekit-client';
|
||||
import { useVoiceStore } from '../../../stores/voiceStore';
|
||||
import { getActiveRoom } from '../../../hooks/useLiveKit';
|
||||
import { isElectron, getElectronAPI } from '../../../platform/platform';
|
||||
import { useT, type TranslationKey } from '../../../i18n';
|
||||
|
||||
/**
|
||||
* Permission state machine for the Video section.
|
||||
@@ -26,19 +27,20 @@ function errorName(err: unknown): string {
|
||||
return err instanceof Error ? err.name : '';
|
||||
}
|
||||
|
||||
function getHardBlockedCopy(): string {
|
||||
/** Returns a translation key rather than finished text — the caller renders it. */
|
||||
function getHardBlockedKey(): TranslationKey {
|
||||
if (!isElectron()) {
|
||||
return "Camera blocked at browser level. Click the camera icon in your browser's address bar, reset the permission, then click Try again.";
|
||||
return 'settings.video.blocked.browser';
|
||||
}
|
||||
const platform = getElectronAPI()?.platform;
|
||||
if (platform === 'darwin') {
|
||||
return 'Grant camera access in System Settings → Privacy & Security → Camera, then restart the app.';
|
||||
return 'settings.video.blocked.mac';
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return 'Grant camera access in Settings → Privacy & Security → Camera, then restart the app.';
|
||||
return 'settings.video.blocked.windows';
|
||||
}
|
||||
// linux or unknown
|
||||
return 'Camera permission was denied. Reset it in your browser/Chromium prompt and try again.';
|
||||
return 'settings.video.blocked.browser';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +74,7 @@ function buildDisplayLabels(devices: MediaDeviceInfo[]): Map<string, string> {
|
||||
}
|
||||
|
||||
export function VideoSection() {
|
||||
const t = useT();
|
||||
const cameraDeviceId = useVoiceStore((s) => s.cameraDeviceId);
|
||||
const setCameraDeviceId = useVoiceStore((s) => s.setCameraDeviceId);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
@@ -84,7 +87,7 @@ export function VideoSection() {
|
||||
// (a) whether `getUserMedia` is currently running and (b) whether the
|
||||
// currently-using subline is visible.
|
||||
const [previewActive, setPreviewActive] = useState(false);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [previewErrorKey, setPreviewErrorKey] = useState<TranslationKey | null>(null);
|
||||
// Tracks the deviceId the browser actually picked for the active preview track.
|
||||
// Drives the "Currently using: …" subline shown only when previewActive (or
|
||||
// in-call attach) AND cameraDeviceId === null.
|
||||
@@ -292,7 +295,7 @@ export function VideoSection() {
|
||||
videoEl.srcObject = new MediaStream([mst]);
|
||||
videoEl.play().catch(() => {});
|
||||
setActiveDeviceId(mst.getSettings().deviceId ?? null);
|
||||
setPreviewError(null);
|
||||
setPreviewErrorKey(null);
|
||||
|
||||
return () => {
|
||||
detachInCall();
|
||||
@@ -333,16 +336,16 @@ export function VideoSection() {
|
||||
}
|
||||
const id = stream.getVideoTracks()[0]?.getSettings().deviceId ?? null;
|
||||
setActiveDeviceId(id);
|
||||
setPreviewError(null);
|
||||
setPreviewErrorKey(null);
|
||||
} catch (err) {
|
||||
if (gen !== startGenRef.current || !mountedRef.current) return;
|
||||
const name = errorName(err);
|
||||
if (name === 'NotReadableError') setPreviewError('Camera is in use by another application.');
|
||||
else if (name === 'OverconstrainedError') setPreviewError('Selected camera is unavailable.');
|
||||
if (name === 'NotReadableError') setPreviewErrorKey('settings.video.error.inUse');
|
||||
else if (name === 'OverconstrainedError') setPreviewErrorKey('settings.video.error.unavailable');
|
||||
else if (name === 'NotAllowedError') {
|
||||
setPermState('denied');
|
||||
setPreviewActive(false);
|
||||
} else setPreviewError('Could not start camera preview.');
|
||||
} else setPreviewErrorKey('settings.video.error.generic');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -369,7 +372,7 @@ export function VideoSection() {
|
||||
// Called from (a) click on dormant tile, (b) "Enable camera preview" CTA,
|
||||
// (c) retry from an error state.
|
||||
const startPreviewFromUser = async () => {
|
||||
setPreviewError(null);
|
||||
setPreviewErrorKey(null);
|
||||
const gen = ++startGenRef.current;
|
||||
stopPreCall();
|
||||
setPreviewActive(true);
|
||||
@@ -403,13 +406,13 @@ export function VideoSection() {
|
||||
setPermState((prev) => (prev === 'denied' ? 'hard-blocked' : 'denied'));
|
||||
setPreviewActive(false);
|
||||
} else if (name === 'NotReadableError') {
|
||||
setPreviewError('Camera is in use by another application.');
|
||||
setPreviewErrorKey('settings.video.error.inUse');
|
||||
} else if (name === 'OverconstrainedError') {
|
||||
setPreviewError('Selected camera is unavailable.');
|
||||
setPreviewErrorKey('settings.video.error.unavailable');
|
||||
} else if (name === 'NotFoundError') {
|
||||
setPreviewError('No camera detected.');
|
||||
setPreviewErrorKey('settings.video.error.notFound');
|
||||
} else {
|
||||
setPreviewError('Could not start camera preview.');
|
||||
setPreviewErrorKey('settings.video.error.generic');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -419,7 +422,7 @@ export function VideoSection() {
|
||||
stopPreCall();
|
||||
setPreviewActive(false);
|
||||
setActiveDeviceId(null);
|
||||
setPreviewError(null);
|
||||
setPreviewErrorKey(null);
|
||||
};
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
@@ -428,10 +431,10 @@ export function VideoSection() {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
Video
|
||||
{t('settings.video.title')}
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 text-sm text-txt-tertiary">
|
||||
Checking camera access…
|
||||
{t('settings.video.checking')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -441,20 +444,20 @@ export function VideoSection() {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
Video
|
||||
{t('settings.video.title')}
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-2">
|
||||
<div className="text-sm text-txt-primary">⚠ Camera access denied</div>
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
{permState === 'hard-blocked'
|
||||
? getHardBlockedCopy()
|
||||
: 'Grant camera permission to choose a camera.'}
|
||||
? t(getHardBlockedKey())
|
||||
: t('settings.video.needPermission')}
|
||||
</div>
|
||||
<button
|
||||
onClick={startPreviewFromUser}
|
||||
className="text-xs px-3 py-1.5 rounded-md bg-surface-base hover:bg-interactive-hover text-txt-secondary transition-colors"
|
||||
>
|
||||
Try again
|
||||
{t('settings.video.tryAgain')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -465,7 +468,7 @@ export function VideoSection() {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
Video
|
||||
{t('settings.video.title')}
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-3">
|
||||
<div className="aspect-video w-full rounded-lg bg-surface-base overflow-hidden relative flex flex-col items-center justify-center text-center px-6">
|
||||
@@ -473,14 +476,14 @@ export function VideoSection() {
|
||||
<path d="M17 10.5V7a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h12a1 1 0 001-1v-3.5l4 4v-11l-4 4z" />
|
||||
</svg>
|
||||
<div className="text-sm text-txt-secondary mb-1">
|
||||
Camera permission needed to choose a camera and preview.
|
||||
{t('settings.video.promptNeeded')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={startPreviewFromUser}
|
||||
className="w-full text-[13px] px-3 py-2 rounded-md bg-accent-primary hover:bg-accent-primary-hover active:bg-accent-primary-active text-white font-medium transition-colors"
|
||||
>
|
||||
Enable camera preview
|
||||
{t('settings.video.enablePreview')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -490,8 +493,8 @@ export function VideoSection() {
|
||||
// permState === 'granted'
|
||||
const selectedLabel =
|
||||
cameraDeviceId === null
|
||||
? 'Auto (system default)'
|
||||
: displayLabels.get(cameraDeviceId) ?? 'Auto (system default)';
|
||||
? t('settings.video.auto')
|
||||
: displayLabels.get(cameraDeviceId) ?? t('settings.video.auto');
|
||||
|
||||
const handleSelect = (id: string | null) => {
|
||||
setCameraDeviceId(id);
|
||||
@@ -507,7 +510,7 @@ export function VideoSection() {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
Video
|
||||
{t('settings.video.title')}
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
|
||||
<div className="aspect-video w-full rounded-lg bg-surface-base overflow-hidden relative mb-3 group">
|
||||
@@ -523,7 +526,7 @@ export function VideoSection() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={startPreviewFromUser}
|
||||
aria-label="Test camera"
|
||||
aria-label={t('settings.video.testCamera')}
|
||||
className="absolute inset-0 flex flex-col items-center justify-center text-txt-tertiary hover:text-txt-secondary hover:bg-white/[0.02] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary"
|
||||
>
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor" className="mb-2">
|
||||
@@ -532,7 +535,7 @@ export function VideoSection() {
|
||||
<span className="text-xs">Click to test camera</span>
|
||||
</button>
|
||||
)}
|
||||
{previewActive && !previewError && (
|
||||
{previewActive && !previewErrorKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopPreviewFromUser}
|
||||
@@ -542,18 +545,18 @@ export function VideoSection() {
|
||||
min-h-[44px] min-w-[44px] px-3 py-2 text-xs flex items-center justify-center
|
||||
md:min-h-0 md:min-w-0 md:text-[11px] md:px-2 md:py-1 md:opacity-0 md:group-hover:opacity-100"
|
||||
>
|
||||
Stop preview
|
||||
{t('settings.video.stopPreview')}
|
||||
</button>
|
||||
)}
|
||||
{previewError && (
|
||||
{previewErrorKey && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-surface-base/90 text-xs text-txt-tertiary text-center px-4 gap-2">
|
||||
<div>{previewError}</div>
|
||||
<div>{t(previewErrorKey)}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startPreviewFromUser}
|
||||
className="text-[11px] px-2 py-1 rounded-md bg-surface-base hover:bg-interactive-hover text-txt-secondary transition-colors"
|
||||
>
|
||||
Try again
|
||||
{t('settings.video.tryAgain')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -579,7 +582,7 @@ export function VideoSection() {
|
||||
{listOpen && (
|
||||
<div className="mt-1 rounded-md bg-surface-base border border-border-hard py-1">
|
||||
<DropdownItem
|
||||
label="Auto (system default)"
|
||||
label={t('settings.video.auto')}
|
||||
active={cameraDeviceId === null}
|
||||
onClick={() => handleSelect(null)}
|
||||
/>
|
||||
@@ -599,13 +602,14 @@ export function VideoSection() {
|
||||
(previewActive || isCameraOn) &&
|
||||
(() => {
|
||||
const m = devices.find((d) => d.deviceId === activeDeviceId);
|
||||
const fallback = t('settings.video.detectedCamera');
|
||||
const label = m
|
||||
? displayLabels.get(activeDeviceId) ?? (m.label || 'detected camera')
|
||||
: 'detected camera';
|
||||
return <div className="text-xs text-txt-tertiary mt-1">Currently using: {label}</div>;
|
||||
? displayLabels.get(activeDeviceId) ?? (m.label || fallback)
|
||||
: fallback;
|
||||
return <div className="text-xs text-txt-tertiary mt-1">{t('settings.video.currentlyUsing', { label })}</div>;
|
||||
})()}
|
||||
{devices.length === 0 && (
|
||||
<div className="text-xs text-txt-tertiary mt-1">No cameras detected.</div>
|
||||
<div className="text-xs text-txt-tertiary mt-1">{t('settings.video.noCameras')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user