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>
|
||||
|
||||
@@ -492,6 +492,179 @@ export const en = {
|
||||
'explore.request.send': 'Send Request',
|
||||
'explore.request.sending': 'Sending...',
|
||||
'explore.request.cancel': 'Cancel',
|
||||
// Settings — voice: video
|
||||
'settings.video.title': 'Video',
|
||||
'settings.video.checking': 'Checking camera access…',
|
||||
'settings.video.needPermission': 'Grant camera permission to choose a camera.',
|
||||
'settings.video.promptNeeded': 'Camera permission needed to choose a camera and preview.',
|
||||
'settings.video.enablePreview': 'Enable camera preview',
|
||||
'settings.video.tryAgain': 'Try again',
|
||||
'settings.video.testCamera': 'Test camera',
|
||||
'settings.video.stopPreview': 'Stop preview',
|
||||
'settings.video.auto': 'Auto (system default)',
|
||||
'settings.video.currentlyUsing': 'Currently using: {label}',
|
||||
'settings.video.detectedCamera': 'detected camera',
|
||||
'settings.video.noCameras': 'No cameras detected.',
|
||||
'settings.video.error.inUse': 'Camera is in use by another application.',
|
||||
'settings.video.error.unavailable': 'Selected camera is unavailable.',
|
||||
'settings.video.error.notFound': 'No camera detected.',
|
||||
'settings.video.error.generic': 'Could not start camera preview.',
|
||||
'settings.video.blocked.mac': 'Grant camera access in System Settings → Privacy & Security → Camera, then restart the app.',
|
||||
'settings.video.blocked.windows': 'Grant camera access in Settings → Privacy & Security → Camera, then restart the app.',
|
||||
'settings.video.blocked.browser': 'Camera permission was denied. Reset it in your browser/Chromium prompt and try again.',
|
||||
|
||||
// Create channel / category / space
|
||||
'create.cancel': 'Cancel',
|
||||
'create.creating': 'Creating...',
|
||||
'create.channel.title': 'Create Channel',
|
||||
'create.channel.submit': 'Create Channel',
|
||||
'create.channel.type': 'Channel Type',
|
||||
'create.channel.type.text': 'Text',
|
||||
'create.channel.type.voice': 'Voice',
|
||||
'create.channel.type.text.description': 'Send messages, images, and files',
|
||||
'create.channel.type.voice.description': 'Hang out with voice and video',
|
||||
'create.channel.name': 'Channel Name',
|
||||
'create.channel.name.placeholder': 'new-channel',
|
||||
'create.channel.name.required': 'Channel name is required',
|
||||
'create.channel.topic': 'Topic (optional)',
|
||||
'create.channel.topic.placeholder': "What's this channel about?",
|
||||
'create.channel.category': 'Category',
|
||||
'create.channel.category.none': 'No Category',
|
||||
'create.channel.failed': 'Failed to create channel',
|
||||
'create.category.title': 'Create Category',
|
||||
'create.category.submit': 'Create Category',
|
||||
'create.category.name': 'Category Name',
|
||||
'create.category.name.placeholder': 'new-category',
|
||||
'create.category.name.required': 'Category name is required',
|
||||
'create.category.failed': 'Failed to create category',
|
||||
'create.noSpace': 'No space selected',
|
||||
'create.space.title': 'Create a Space',
|
||||
'create.space.submit': 'Create',
|
||||
'create.space.upload': 'Upload',
|
||||
'create.space.remove': 'Remove',
|
||||
'create.space.iconColor': 'Icon Color',
|
||||
'create.space.name': 'Space Name',
|
||||
'create.space.name.placeholder': 'My Awesome Space',
|
||||
'create.space.name.required': 'Space name is required',
|
||||
'create.space.description': 'Description',
|
||||
'create.space.description.placeholder': 'A short description for your space...',
|
||||
'create.space.visibility': 'Visibility',
|
||||
'create.space.visibility.private': 'Private',
|
||||
'create.space.visibility.private.description': 'Only people with an invite link can join',
|
||||
'create.space.visibility.request': 'Request to Join',
|
||||
'create.space.visibility.request.description': 'Visible in Explore — people can request to join',
|
||||
'create.space.visibility.public': 'Public',
|
||||
'create.space.visibility.public.description': 'Visible in Explore — anyone can join instantly',
|
||||
'create.space.iconFailed': 'Failed to upload icon',
|
||||
'create.space.failed': 'Failed to create space',
|
||||
'create.space.cropIcon': 'Crop Space Icon',
|
||||
|
||||
// Group DMs
|
||||
'groupDm.newDm.title': 'New Direct Message',
|
||||
'groupDm.newDm.search': 'Search for a user...',
|
||||
'groupDm.newDm.failed': 'Failed to create DM',
|
||||
'groupDm.add.title': 'Add Friends to DM',
|
||||
'groupDm.add.search': 'Search friends...',
|
||||
'groupDm.add.alreadyIn': 'Already in this DM',
|
||||
'groupDm.add.noMatch': 'No friends match your search',
|
||||
'groupDm.add.noFriends': 'No friends yet',
|
||||
'groupDm.add.select': 'Select Friends',
|
||||
'groupDm.add.submit': 'Add {count} Friend',
|
||||
'groupDm.add.submitPlural': 'Add {count} Friends',
|
||||
'groupDm.add.adding': 'Adding...',
|
||||
'groupDm.add.failed': 'Failed to add members',
|
||||
'groupDm.fallbackName': 'Group DM',
|
||||
'groupDm.icon.change': 'Change group icon',
|
||||
'groupDm.icon.label': 'Group icon',
|
||||
'groupDm.icon.crop': 'Crop Group Icon',
|
||||
'groupDm.name': 'Group Name',
|
||||
'groupDm.tab.general': 'General',
|
||||
'groupDm.tab.overview': 'Overview',
|
||||
'groupDm.tab.members': 'Members',
|
||||
'groupDm.back': 'Back to group DM settings menu',
|
||||
'groupDm.save': 'Save',
|
||||
'groupDm.saving': 'Saving...',
|
||||
'groupDm.cancel': 'Cancel',
|
||||
'groupDm.close': 'Close',
|
||||
'groupDm.saveFailed': 'Failed to save settings',
|
||||
'groupDm.members.title': 'Members',
|
||||
'groupDm.members.add': 'Add Member',
|
||||
'groupDm.members.full': 'Group is full',
|
||||
'groupDm.leave': 'Leave Group',
|
||||
'groupDm.leaving': 'Leaving...',
|
||||
'groupDm.leave.description': 'You will stop receiving messages from this conversation. Other members will see a system message.',
|
||||
'groupDm.leave.confirm': 'Leave "{name}"? You will stop receiving messages from this conversation.',
|
||||
'groupDm.leave.button': 'Leave',
|
||||
'groupDm.leave.failed': 'Failed to leave group',
|
||||
|
||||
// Join a space
|
||||
'join.title': 'Join a Space',
|
||||
'join.discover.lead': 'Discover spaces to join, or browse them all in Explore.',
|
||||
'join.discover.error': 'Couldn’t load spaces to discover right now. You can still join with an invite code below.',
|
||||
'join.discover.empty': 'No spaces to discover yet — try an invite code below, or check back later.',
|
||||
'join.discover.browse': 'Browse all in Explore',
|
||||
'join.haveCode': 'Have an invite code?',
|
||||
'join.code.placeholder': 'e.g. abc123 or https://instance.com/join/abc123',
|
||||
'join.cancel': 'Cancel',
|
||||
'join.back': 'Back',
|
||||
'join.submit': 'Join Space',
|
||||
'join.joining': 'Joining...',
|
||||
'join.failed': 'Failed to join space',
|
||||
'join.connect.lead': 'Connect to {host} to join this space.',
|
||||
'join.connect.password': 'Enter your password to connect',
|
||||
'join.connect.password.placeholder': 'Your account password',
|
||||
'join.connect.note': 'Your password is verified locally, then used to create or access your account on the remote instance.',
|
||||
'join.connect.submit': 'Connect & Join',
|
||||
'join.connect.connecting': 'Connecting...',
|
||||
'join.fallback.lead': 'An account already exists on {host} with a different password. Enter the credentials you used on that instance.',
|
||||
'join.fallback.username': 'Username',
|
||||
'join.fallback.username.placeholder': 'Your username on this instance',
|
||||
'join.fallback.password': 'Password for this instance',
|
||||
'join.fallback.password.placeholder': 'Password on the remote instance',
|
||||
'join.fallback.submit': 'Login & Join',
|
||||
'join.fallback.loggingIn': 'Logging in...',
|
||||
|
||||
// Delete account / transfer ownership / error screen
|
||||
'danger.delete.title': 'Delete Account',
|
||||
'danger.delete.lead': 'This will permanently delete your account.',
|
||||
'danger.delete.item.spaces': 'All space memberships will be removed',
|
||||
'danger.delete.item.friends': 'All friend connections will be removed',
|
||||
'danger.delete.item.dms': 'All DM memberships will be removed',
|
||||
'danger.delete.item.messages': 'Your messages will remain but be attributed to "Deleted User"',
|
||||
'danger.delete.owned': 'You own {count} space. Handle it before continuing:',
|
||||
'danger.delete.ownedPlural': 'You own {count} spaces. Handle each before continuing:',
|
||||
'danger.delete.transfer': 'Transfer',
|
||||
'danger.delete.deleteSpace': 'Delete Space',
|
||||
'danger.delete.selectOwner': 'Select new owner...',
|
||||
'danger.delete.continue': 'Continue',
|
||||
'danger.delete.processing': 'Processing...',
|
||||
'danger.delete.spacesFailed': 'Failed to process spaces',
|
||||
'danger.delete.permanent': 'This action is permanent and cannot be undone.',
|
||||
'danger.delete.password': 'Password',
|
||||
'danger.delete.password.placeholder': 'Enter your password',
|
||||
'danger.delete.back': 'Back',
|
||||
'danger.delete.confirm': 'Delete My Account',
|
||||
'danger.delete.deleting': 'Deleting...',
|
||||
'danger.delete.usernameMismatch': 'Username does not match',
|
||||
'danger.delete.failed': 'Failed to delete account',
|
||||
'danger.delete.federating': 'Removing your account from connected instances...',
|
||||
'danger.delete.now': 'Delete Account Now',
|
||||
'danger.delete.done': 'Account deleted',
|
||||
'danger.delete.redirecting': 'Redirecting to login...',
|
||||
'danger.transfer.title': 'Transfer Ownership',
|
||||
'danger.transfer.lead': 'Choose a member to become the new owner of {space}',
|
||||
'danger.transfer.confirm': 'Transfer ownership of {space} to {member}?',
|
||||
'danger.transfer.note': 'You will become a regular member.',
|
||||
'danger.transfer.search': 'Search members...',
|
||||
'danger.transfer.loading': 'Loading members...',
|
||||
'danger.transfer.empty': 'No members found',
|
||||
'danger.transfer.cancel': 'Cancel',
|
||||
'danger.transfer.submit': 'Transfer',
|
||||
'danger.transfer.transferring': 'Transferring...',
|
||||
'danger.transfer.failed': 'Failed to transfer ownership',
|
||||
'error.title': 'Something went wrong',
|
||||
'error.tryAgain': 'Try Again',
|
||||
'error.reload': 'Reload Page',
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof en;
|
||||
|
||||
@@ -491,4 +491,177 @@ export const ptBR: Partial<Dictionary> = {
|
||||
'explore.request.send': 'Enviar pedido',
|
||||
'explore.request.sending': 'Enviando...',
|
||||
'explore.request.cancel': 'Cancelar',
|
||||
// Configurações — voz: vídeo
|
||||
'settings.video.title': 'Vídeo',
|
||||
'settings.video.checking': 'Verificando o acesso à câmera…',
|
||||
'settings.video.needPermission': 'Dê permissão de câmera para escolher uma.',
|
||||
'settings.video.promptNeeded': 'Precisa da permissão de câmera para escolher uma e ver a prévia.',
|
||||
'settings.video.enablePreview': 'Ligar a prévia da câmera',
|
||||
'settings.video.tryAgain': 'Tentar de novo',
|
||||
'settings.video.testCamera': 'Testar câmera',
|
||||
'settings.video.stopPreview': 'Parar prévia',
|
||||
'settings.video.auto': 'Automático (padrão do sistema)',
|
||||
'settings.video.currentlyUsing': 'Usando agora: {label}',
|
||||
'settings.video.detectedCamera': 'câmera detectada',
|
||||
'settings.video.noCameras': 'Nenhuma câmera detectada.',
|
||||
'settings.video.error.inUse': 'A câmera está sendo usada por outro programa.',
|
||||
'settings.video.error.unavailable': 'A câmera escolhida não está disponível.',
|
||||
'settings.video.error.notFound': 'Nenhuma câmera encontrada.',
|
||||
'settings.video.error.generic': 'Não foi possível iniciar a prévia da câmera.',
|
||||
'settings.video.blocked.mac': 'Libere a câmera em Ajustes do Sistema → Privacidade e Segurança → Câmera e reinicie o aplicativo.',
|
||||
'settings.video.blocked.windows': 'Libere a câmera em Configurações → Privacidade e Segurança → Câmera e reinicie o aplicativo.',
|
||||
'settings.video.blocked.browser': 'A permissão de câmera foi negada. Redefina no aviso do navegador e tente de novo.',
|
||||
|
||||
// Criar canal / categoria / servidor
|
||||
'create.cancel': 'Cancelar',
|
||||
'create.creating': 'Criando...',
|
||||
'create.channel.title': 'Criar canal',
|
||||
'create.channel.submit': 'Criar canal',
|
||||
'create.channel.type': 'Tipo de canal',
|
||||
'create.channel.type.text': 'Texto',
|
||||
'create.channel.type.voice': 'Voz',
|
||||
'create.channel.type.text.description': 'Enviar mensagens, imagens e arquivos',
|
||||
'create.channel.type.voice.description': 'Conversar por voz e vídeo',
|
||||
'create.channel.name': 'Nome do canal',
|
||||
'create.channel.name.placeholder': 'novo-canal',
|
||||
'create.channel.name.required': 'O nome do canal é obrigatório',
|
||||
'create.channel.topic': 'Tópico (opcional)',
|
||||
'create.channel.topic.placeholder': 'Sobre o que é este canal?',
|
||||
'create.channel.category': 'Categoria',
|
||||
'create.channel.category.none': 'Sem categoria',
|
||||
'create.channel.failed': 'Não foi possível criar o canal',
|
||||
'create.category.title': 'Criar categoria',
|
||||
'create.category.submit': 'Criar categoria',
|
||||
'create.category.name': 'Nome da categoria',
|
||||
'create.category.name.placeholder': 'nova-categoria',
|
||||
'create.category.name.required': 'O nome da categoria é obrigatório',
|
||||
'create.category.failed': 'Não foi possível criar a categoria',
|
||||
'create.noSpace': 'Nenhum servidor selecionado',
|
||||
'create.space.title': 'Criar um servidor',
|
||||
'create.space.submit': 'Criar',
|
||||
'create.space.upload': 'Enviar',
|
||||
'create.space.remove': 'Remover',
|
||||
'create.space.iconColor': 'Cor do ícone',
|
||||
'create.space.name': 'Nome do servidor',
|
||||
'create.space.name.placeholder': 'Meu servidor incrível',
|
||||
'create.space.name.required': 'O nome do servidor é obrigatório',
|
||||
'create.space.description': 'Descrição',
|
||||
'create.space.description.placeholder': 'Uma descrição curta do seu servidor...',
|
||||
'create.space.visibility': 'Visibilidade',
|
||||
'create.space.visibility.private': 'Privado',
|
||||
'create.space.visibility.private.description': 'Só entra quem tiver um link de convite',
|
||||
'create.space.visibility.request': 'Por pedido',
|
||||
'create.space.visibility.request.description': 'Aparece no Explorar — as pessoas pedem para entrar',
|
||||
'create.space.visibility.public': 'Público',
|
||||
'create.space.visibility.public.description': 'Aparece no Explorar — qualquer um entra na hora',
|
||||
'create.space.iconFailed': 'Não foi possível enviar o ícone',
|
||||
'create.space.failed': 'Não foi possível criar o servidor',
|
||||
'create.space.cropIcon': 'Recortar o ícone do servidor',
|
||||
|
||||
// DMs em grupo
|
||||
'groupDm.newDm.title': 'Nova mensagem direta',
|
||||
'groupDm.newDm.search': 'Buscar um usuário...',
|
||||
'groupDm.newDm.failed': 'Não foi possível criar a DM',
|
||||
'groupDm.add.title': 'Adicionar amigos à DM',
|
||||
'groupDm.add.search': 'Buscar amigos...',
|
||||
'groupDm.add.alreadyIn': 'Já está nesta DM',
|
||||
'groupDm.add.noMatch': 'Nenhum amigo encontrado',
|
||||
'groupDm.add.noFriends': 'Nenhum amigo ainda',
|
||||
'groupDm.add.select': 'Escolha os amigos',
|
||||
'groupDm.add.submit': 'Adicionar {count} amigo',
|
||||
'groupDm.add.submitPlural': 'Adicionar {count} amigos',
|
||||
'groupDm.add.adding': 'Adicionando...',
|
||||
'groupDm.add.failed': 'Não foi possível adicionar os membros',
|
||||
'groupDm.fallbackName': 'DM em grupo',
|
||||
'groupDm.icon.change': 'Trocar o ícone do grupo',
|
||||
'groupDm.icon.label': 'Ícone do grupo',
|
||||
'groupDm.icon.crop': 'Recortar o ícone do grupo',
|
||||
'groupDm.name': 'Nome do grupo',
|
||||
'groupDm.tab.general': 'Geral',
|
||||
'groupDm.tab.overview': 'Visão geral',
|
||||
'groupDm.tab.members': 'Membros',
|
||||
'groupDm.back': 'Voltar ao menu de configurações do grupo',
|
||||
'groupDm.save': 'Salvar',
|
||||
'groupDm.saving': 'Salvando...',
|
||||
'groupDm.cancel': 'Cancelar',
|
||||
'groupDm.close': 'Fechar',
|
||||
'groupDm.saveFailed': 'Não foi possível salvar as configurações',
|
||||
'groupDm.members.title': 'Membros',
|
||||
'groupDm.members.add': 'Adicionar membro',
|
||||
'groupDm.members.full': 'O grupo está cheio',
|
||||
'groupDm.leave': 'Sair do grupo',
|
||||
'groupDm.leaving': 'Saindo...',
|
||||
'groupDm.leave.description': 'Você para de receber as mensagens desta conversa. Os outros membros veem um aviso.',
|
||||
'groupDm.leave.confirm': 'Sair de "{name}"? Você para de receber as mensagens desta conversa.',
|
||||
'groupDm.leave.button': 'Sair',
|
||||
'groupDm.leave.failed': 'Não foi possível sair do grupo',
|
||||
|
||||
// Entrar num servidor
|
||||
'join.title': 'Entrar num servidor',
|
||||
'join.discover.lead': 'Descubra servidores para entrar, ou veja todos no Explorar.',
|
||||
'join.discover.error': 'Não deu para carregar os servidores agora. Você ainda pode entrar com um código de convite abaixo.',
|
||||
'join.discover.empty': 'Nenhum servidor para descobrir ainda — tente um código de convite abaixo, ou volte mais tarde.',
|
||||
'join.discover.browse': 'Ver todos no Explorar',
|
||||
'join.haveCode': 'Tem um código de convite?',
|
||||
'join.code.placeholder': 'ex.: abc123 ou https://instancia.com/join/abc123',
|
||||
'join.cancel': 'Cancelar',
|
||||
'join.back': 'Voltar',
|
||||
'join.submit': 'Entrar no servidor',
|
||||
'join.joining': 'Entrando...',
|
||||
'join.failed': 'Não foi possível entrar no servidor',
|
||||
'join.connect.lead': 'Conecte-se a {host} para entrar neste servidor.',
|
||||
'join.connect.password': 'Digite a sua senha para conectar',
|
||||
'join.connect.password.placeholder': 'A senha da sua conta',
|
||||
'join.connect.note': 'A sua senha é verificada aqui e depois usada para criar ou acessar a sua conta na outra instância.',
|
||||
'join.connect.submit': 'Conectar e entrar',
|
||||
'join.connect.connecting': 'Conectando...',
|
||||
'join.fallback.lead': 'Já existe uma conta em {host} com outra senha. Digite os dados que você usou naquela instância.',
|
||||
'join.fallback.username': 'Nome de usuário',
|
||||
'join.fallback.username.placeholder': 'Seu nome de usuário nessa instância',
|
||||
'join.fallback.password': 'Senha desta instância',
|
||||
'join.fallback.password.placeholder': 'Senha na instância remota',
|
||||
'join.fallback.submit': 'Entrar e participar',
|
||||
'join.fallback.loggingIn': 'Entrando...',
|
||||
|
||||
// Excluir conta / transferir propriedade / tela de erro
|
||||
'danger.delete.title': 'Excluir conta',
|
||||
'danger.delete.lead': 'Isto apaga a sua conta para sempre.',
|
||||
'danger.delete.item.spaces': 'Você sai de todos os servidores',
|
||||
'danger.delete.item.friends': 'Todas as amizades são desfeitas',
|
||||
'danger.delete.item.dms': 'Você sai de todas as DMs',
|
||||
'danger.delete.item.messages': 'Suas mensagens continuam, mas passam a aparecer como "Usuário excluído"',
|
||||
'danger.delete.owned': 'Você é dono de {count} servidor. Resolva antes de continuar:',
|
||||
'danger.delete.ownedPlural': 'Você é dono de {count} servidores. Resolva cada um antes de continuar:',
|
||||
'danger.delete.transfer': 'Transferir',
|
||||
'danger.delete.deleteSpace': 'Excluir servidor',
|
||||
'danger.delete.selectOwner': 'Escolha o novo dono...',
|
||||
'danger.delete.continue': 'Continuar',
|
||||
'danger.delete.processing': 'Processando...',
|
||||
'danger.delete.spacesFailed': 'Não foi possível resolver os servidores',
|
||||
'danger.delete.permanent': 'Isto é permanente e não dá para desfazer.',
|
||||
'danger.delete.password': 'Senha',
|
||||
'danger.delete.password.placeholder': 'Digite a sua senha',
|
||||
'danger.delete.back': 'Voltar',
|
||||
'danger.delete.confirm': 'Excluir minha conta',
|
||||
'danger.delete.deleting': 'Excluindo...',
|
||||
'danger.delete.usernameMismatch': 'O nome de usuário não confere',
|
||||
'danger.delete.failed': 'Não foi possível excluir a conta',
|
||||
'danger.delete.federating': 'Removendo a sua conta das instâncias conectadas...',
|
||||
'danger.delete.now': 'Excluir a conta agora',
|
||||
'danger.delete.done': 'Conta excluída',
|
||||
'danger.delete.redirecting': 'Voltando para a tela de login...',
|
||||
'danger.transfer.title': 'Transferir propriedade',
|
||||
'danger.transfer.lead': 'Escolha quem vai ser o novo dono de {space}',
|
||||
'danger.transfer.confirm': 'Transferir a propriedade de {space} para {member}?',
|
||||
'danger.transfer.note': 'Você passa a ser um membro comum.',
|
||||
'danger.transfer.search': 'Buscar membros...',
|
||||
'danger.transfer.loading': 'Carregando membros...',
|
||||
'danger.transfer.empty': 'Nenhum membro encontrado',
|
||||
'danger.transfer.cancel': 'Cancelar',
|
||||
'danger.transfer.submit': 'Transferir',
|
||||
'danger.transfer.transferring': 'Transferindo...',
|
||||
'danger.transfer.failed': 'Não foi possível transferir a propriedade',
|
||||
'error.title': 'Alguma coisa deu errado',
|
||||
'error.tryAgain': 'Tentar de novo',
|
||||
'error.reload': 'Recarregar a página',
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App';
|
||||
import { startPendingMessageOrchestrator } from './stores/pendingMessageRehydrate';
|
||||
import './styles/globals.css';
|
||||
import { translate, useLocaleStore } from './i18n';
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
@@ -33,6 +34,11 @@ class ErrorBoundary extends React.Component<
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Class component: no hooks. Reading the locale once per render is also
|
||||
// the right call here — an error screen has no reason to re-render on a
|
||||
// language switch, and `translate` falls back to English then to the key.
|
||||
const tr = (key: Parameters<typeof translate>[1]) =>
|
||||
translate(useLocaleStore.getState().locale, key);
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
@@ -46,7 +52,7 @@ class ErrorBoundary extends React.Component<
|
||||
gap: '16px',
|
||||
padding: '24px',
|
||||
}}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 'bold' }}>Something went wrong</h1>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 'bold' }}>{tr('error.title')}</h1>
|
||||
<p style={{ color: '#a0a0aa', maxWidth: '480px', textAlign: 'center' }}>{this.state.error?.message}</p>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
@@ -62,7 +68,7 @@ class ErrorBoundary extends React.Component<
|
||||
fontFamily: "'DM Sans', sans-serif",
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
{tr('error.tryAgain')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
@@ -77,7 +83,7 @@ class ErrorBoundary extends React.Component<
|
||||
fontFamily: "'DM Sans', sans-serif",
|
||||
}}
|
||||
>
|
||||
Reload Page
|
||||
{tr('error.reload')}
|
||||
</button>
|
||||
</div>
|
||||
{this.state.error?.stack && (
|
||||
|
||||
Reference in New Issue
Block a user