feat(i18n): translate the five systems users actually see
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
Ordered by what the group hits, not by string count.
- Chat system messages ("X added Y to the group") and their sidebar
preview twin. These are content, not chrome, so they sat in English in
the middle of conversations.
- Login, register and the invite landing page — the first screen anyone
invited to the instance sees.
- Voice — controls, connection info, the mobile join sheet, settings tab.
- Mobile screens.
- Settings navigation across space, user and instance, plus Discovery and
join requests.
Three call sites build strings outside a component and so read the locale
directly rather than through the hook: the sidebar preview formatter, the
connection-info track labels, and MobileShell's screen map — which holds
plain functions that are *called*, not rendered, where a hook would run
under whichever component invoked the map. Each says so at the definition;
all three re-render often enough that a language switch shows through
promptly.
Adds a test for a mistake made three times in this series: a file with an
exported component plus a private sub-component, where only the exported one
got the hook. tsc catches it, but only after a full build and without naming
the component. The test names it, and also flags the inverse — a hook placed
below an early return, which tsc accepts and React breaks at runtime (this
project shipped that once, as error #310). Verified by mutation: removing a
hook makes it fail and print the component.
Dictionary is at 856 keys, en and pt-BR at exact parity.
Verified: web build (tsc + vite), 492 web tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -110,6 +110,12 @@ fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
|
|||||||
- Configurações do servidor → Cargos (incluindo a tabela de permissões)
|
- Configurações do servidor → Cargos (incluindo a tabela de permissões)
|
||||||
- Configurações da categoria (renomear, privacidade, exceções, excluir)
|
- Configurações da categoria (renomear, privacidade, exceções, excluir)
|
||||||
- Configurações da instância → Geral (nome, descoberta, busca de GIFs)
|
- Configurações da instância → Geral (nome, descoberta, busca de GIFs)
|
||||||
|
- Mensagens de sistema do chat e a prévia delas na barra lateral
|
||||||
|
- Login, registro e a página de convite
|
||||||
|
- Voz: controles, informações de conexão, folha de entrada no celular, aba Voz
|
||||||
|
- Telas do celular (`Mobile*.tsx`)
|
||||||
|
- Navegação das configurações (servidor, usuário e instância) + Descoberta e
|
||||||
|
pedidos de entrada
|
||||||
|
|
||||||
Os rótulos de permissão viraram um conjunto único (`perm.*`), usado pelo painel
|
Os rótulos de permissão viraram um conjunto único (`perm.*`), usado pelo painel
|
||||||
de cargos e pelos editores de exceção de canal e de categoria — antes havia
|
de cargos e pelos editores de exceção de canal e de categoria — antes havia
|
||||||
@@ -118,8 +124,9 @@ duas cópias da mesma lista.
|
|||||||
### Fila sugerida de tradução
|
### Fila sugerida de tradução
|
||||||
|
|
||||||
Configurações → Instância: registro, federação, transmissão, armazenamento e
|
Configurações → Instância: registro, federação, transmissão, armazenamento e
|
||||||
usuários · chrome do `SpaceSettings` e do `UserSettings` (abas) ·
|
usuários (os cinco painéis grandes, só o dono vê) · `ConnectedInstances` ·
|
||||||
`ConnectedInstances` · `UserProfileModal` · telas móveis (`Mobile*.tsx`)
|
`UserProfileModal` · `MainContent` e `ChannelSidebar` · mensagens de erro
|
||||||
|
vindas de stores e hooks (`instanceStore`, `useLiveKit`, `transferStore`)
|
||||||
|
|
||||||
## Correções pendentes
|
## Correções pendentes
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import { api, createApiClient } from '../api/client';
|
|||||||
import { parseInviteInput } from '../utils/inviteParser';
|
import { parseInviteInput } from '../utils/inviteParser';
|
||||||
import { Avatar } from './ui/Avatar';
|
import { Avatar } from './ui/Avatar';
|
||||||
import type { InvitePreview } from '@backspace/shared';
|
import type { InvitePreview } from '@backspace/shared';
|
||||||
|
import { useT } from '../i18n';
|
||||||
|
|
||||||
type JoinPhase = 'preview' | 'connect' | 'fallback' | 'other-instance' | 'already-member';
|
type JoinPhase = 'preview' | 'connect' | 'fallback' | 'other-instance' | 'already-member';
|
||||||
|
|
||||||
export function JoinPage() {
|
export function JoinPage() {
|
||||||
|
const t = useT();
|
||||||
const { inviteCode: rawInviteCode } = useParams<{ inviteCode: string }>();
|
const { inviteCode: rawInviteCode } = useParams<{ inviteCode: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const token = useAuthStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
@@ -59,13 +61,13 @@ export function JoinPage() {
|
|||||||
// Fetch preview on mount
|
// Fetch preview on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!rawInviteCode) {
|
if (!rawInviteCode) {
|
||||||
setPreviewError('No invite code provided');
|
setPreviewError(t('join.page.noCode'));
|
||||||
setIsLoadingPreview(false);
|
setIsLoadingPreview(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
setPreviewError('Invalid invite code');
|
setPreviewError(t('join.page.invalidCode'));
|
||||||
setIsLoadingPreview(false);
|
setIsLoadingPreview(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -84,7 +86,7 @@ export function JoinPage() {
|
|||||||
const data = await client.spaces.invitePreview(parsed.code);
|
const data = await client.spaces.invitePreview(parsed.code);
|
||||||
setPreview(data);
|
setPreview(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setPreviewError(err instanceof Error ? err.message : 'Failed to load invite');
|
setPreviewError(err instanceof Error ? err.message : t('join.page.loadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingPreview(false);
|
setIsLoadingPreview(false);
|
||||||
}
|
}
|
||||||
@@ -106,7 +108,7 @@ export function JoinPage() {
|
|||||||
setPhase('connect');
|
setPhase('connect');
|
||||||
setError('');
|
setError('');
|
||||||
} else {
|
} else {
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to join space';
|
const msg = err instanceof Error ? err.message : t('join.page.joinFailed');
|
||||||
if (msg.toLowerCase().includes('already a member')) {
|
if (msg.toLowerCase().includes('already a member')) {
|
||||||
setPhase('already-member');
|
setPhase('already-member');
|
||||||
setError('');
|
setError('');
|
||||||
@@ -136,7 +138,7 @@ export function JoinPage() {
|
|||||||
setFallbackPassword('');
|
setFallbackPassword('');
|
||||||
setError('');
|
setError('');
|
||||||
} else {
|
} else {
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to connect';
|
const msg = err instanceof Error ? err.message : t('join.page.connectFailed');
|
||||||
if (msg.toLowerCase().includes('already a member')) {
|
if (msg.toLowerCase().includes('already a member')) {
|
||||||
setPhase('already-member');
|
setPhase('already-member');
|
||||||
setError('');
|
setError('');
|
||||||
@@ -160,7 +162,7 @@ export function JoinPage() {
|
|||||||
const space = await joinByCode(parsed.code, parsed.origin);
|
const space = await joinByCode(parsed.code, parsed.origin);
|
||||||
navigate(`/channels/${space.id}`);
|
navigate(`/channels/${space.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to log in';
|
const msg = err instanceof Error ? err.message : t('join.page.loginFailed');
|
||||||
if (msg.toLowerCase().includes('already a member')) {
|
if (msg.toLowerCase().includes('already a member')) {
|
||||||
setPhase('already-member');
|
setPhase('already-member');
|
||||||
setError('');
|
setError('');
|
||||||
@@ -204,7 +206,7 @@ export function JoinPage() {
|
|||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-txt-tertiary">Loading invite...</p>
|
<p className="text-txt-tertiary">{t('join.page.loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -221,9 +223,9 @@ export function JoinPage() {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold text-txt-primary mb-2">Invalid Invite</h1>
|
<h1 className="text-xl font-bold text-txt-primary mb-2">{t('join.page.invalid.title')}</h1>
|
||||||
<p className="text-txt-secondary text-sm mb-6">
|
<p className="text-txt-secondary text-sm mb-6">
|
||||||
{previewError || 'This invite link is invalid or has expired.'}
|
{previewError || t('join.page.invalid.body')}
|
||||||
</p>
|
</p>
|
||||||
{token ? (
|
{token ? (
|
||||||
<button
|
<button
|
||||||
@@ -260,7 +262,7 @@ export function JoinPage() {
|
|||||||
avatarColor={preview.avatarColor}
|
avatarColor={preview.avatarColor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-txt-tertiary uppercase tracking-wide mb-1">You've been invited to join</p>
|
<p className="text-xs text-txt-tertiary uppercase tracking-wide mb-1">{t('join.page.invitedTo')}</p>
|
||||||
<h1 className="text-2xl font-bold text-txt-primary">{preview.spaceName}</h1>
|
<h1 className="text-2xl font-bold text-txt-primary">{preview.spaceName}</h1>
|
||||||
{preview.description && (
|
{preview.description && (
|
||||||
<p className="text-txt-secondary text-sm mt-2">{preview.description}</p>
|
<p className="text-txt-secondary text-sm mt-2">{preview.description}</p>
|
||||||
@@ -303,7 +305,7 @@ export function JoinPage() {
|
|||||||
disabled={isJoining}
|
disabled={isJoining}
|
||||||
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isJoining ? 'Joining...' : `Join as ${user.displayName || user.username}`}
|
{isJoining ? t('join.joining') : `Join as ${user.displayName || user.username}`}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-center text-xs text-txt-tertiary">
|
<p className="text-center text-xs text-txt-tertiary">
|
||||||
Not you?{' '}
|
Not you?{' '}
|
||||||
@@ -378,7 +380,7 @@ export function JoinPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={otherDomain}
|
value={otherDomain}
|
||||||
onChange={(e) => setOtherDomain(e.target.value)}
|
onChange={(e) => setOtherDomain(e.target.value)}
|
||||||
placeholder="e.g. my-instance.com"
|
placeholder={t('join.page.instance.placeholder')}
|
||||||
className="input-standard flex-1 py-2.5"
|
className="input-standard flex-1 py-2.5"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -431,7 +433,7 @@ export function JoinPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Your account password"
|
placeholder={t('join.connect.password.placeholder')}
|
||||||
className="input-standard w-full py-2.5"
|
className="input-standard w-full py-2.5"
|
||||||
disabled={isJoining}
|
disabled={isJoining}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -454,7 +456,7 @@ export function JoinPage() {
|
|||||||
disabled={isJoining || !password}
|
disabled={isJoining || !password}
|
||||||
className="flex-1 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex-1 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isJoining ? 'Connecting...' : 'Connect & Join'}
|
{isJoining ? t('join.connect.connecting') : t('join.connect.submit')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -473,24 +475,24 @@ export function JoinPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mb-4 space-y-3">
|
<div className="mb-4 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">Username</label>
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">{t('join.fallback.username')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={fallbackUsername}
|
value={fallbackUsername}
|
||||||
onChange={(e) => setFallbackUsername(e.target.value)}
|
onChange={(e) => setFallbackUsername(e.target.value)}
|
||||||
placeholder="Your username on this instance"
|
placeholder={t('join.fallback.username.placeholder')}
|
||||||
className="input-standard w-full py-2.5"
|
className="input-standard w-full py-2.5"
|
||||||
disabled={isJoining}
|
disabled={isJoining}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">Password for this instance</label>
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">{t('join.fallback.password')}</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={fallbackPassword}
|
value={fallbackPassword}
|
||||||
onChange={(e) => setFallbackPassword(e.target.value)}
|
onChange={(e) => setFallbackPassword(e.target.value)}
|
||||||
placeholder="Password on the remote instance"
|
placeholder={t('join.fallback.password.placeholder')}
|
||||||
className="input-standard w-full py-2.5"
|
className="input-standard w-full py-2.5"
|
||||||
disabled={isJoining}
|
disabled={isJoining}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -511,7 +513,7 @@ export function JoinPage() {
|
|||||||
disabled={isJoining || !fallbackUsername || !fallbackPassword}
|
disabled={isJoining || !fallbackUsername || !fallbackPassword}
|
||||||
className="flex-1 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex-1 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isJoining ? 'Logging in...' : 'Login & Join'}
|
{isJoining ? t('join.fallback.loggingIn') : t('join.fallback.submit')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -522,6 +524,7 @@ export function JoinPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AlreadyMemberCard({ spaceName, spaceId, navigate }: { spaceName: string; spaceId: string; navigate: (path: string) => void }) {
|
function AlreadyMemberCard({ spaceName, spaceId, navigate }: { spaceName: string; spaceId: string; navigate: (path: string) => void }) {
|
||||||
|
const t = useT();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => navigate(`/channels/${spaceId}`), 2000);
|
const timer = setTimeout(() => navigate(`/channels/${spaceId}`), 2000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
@@ -534,13 +537,13 @@ function AlreadyMemberCard({ spaceName, spaceId, navigate }: { spaceName: string
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-txt-primary font-medium mb-1">You're already in {spaceName}!</p>
|
<p className="text-txt-primary font-medium mb-1">{t('join.page.alreadyMember', { space: spaceName })}</p>
|
||||||
<p className="text-txt-tertiary text-xs mb-4">Redirecting you now...</p>
|
<p className="text-txt-tertiary text-xs mb-4">{t('join.page.redirecting')}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/channels/${spaceId}`)}
|
onClick={() => navigate(`/channels/${spaceId}`)}
|
||||||
className="px-6 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors"
|
className="px-6 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors"
|
||||||
>
|
>
|
||||||
Go to {spaceName}
|
{t('join.page.goTo', { space: spaceName })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { useAuthStore } from '../../stores/authStore';
|
|||||||
import { api, RateLimitError } from '../../api/client';
|
import { api, RateLimitError } from '../../api/client';
|
||||||
import type { InstanceInfoResponse } from '@backspace/shared';
|
import type { InstanceInfoResponse } from '@backspace/shared';
|
||||||
import { SourceCodeLink } from '../ui/SourceCodeLink';
|
import { SourceCodeLink } from '../ui/SourceCodeLink';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
|
const t = useT();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -46,11 +48,11 @@ export function LoginPage() {
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
if (!username.trim()) {
|
if (!username.trim()) {
|
||||||
setError('Username is required');
|
setError(t('auth.username.required'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!password) {
|
if (!password) {
|
||||||
setError('Password is required');
|
setError(t('auth.password.required'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +68,7 @@ export function LoginPage() {
|
|||||||
setRetryAfter(err.retryAfter);
|
setRetryAfter(err.retryAfter);
|
||||||
setError('');
|
setError('');
|
||||||
} else {
|
} else {
|
||||||
setError(err instanceof Error ? err.message : 'Login failed');
|
setError(err instanceof Error ? err.message : t('auth.login.failed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -78,15 +80,15 @@ export function LoginPage() {
|
|||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
|
||||||
<div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10">
|
<div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h1 className="text-2xl font-bold text-txt-primary">Welcome back!</h1>
|
<h1 className="text-2xl font-bold text-txt-primary">{t('auth.login.title')}</h1>
|
||||||
<p className="text-txt-tertiary mt-1">We're so excited to see you again!</p>
|
<p className="text-txt-tertiary mt-1">{t('auth.login.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{retryAfter > 0 && (
|
{retryAfter > 0 && (
|
||||||
<div className="mb-4 p-3 bg-accent-amber/10 border border-accent-amber/30 rounded text-sm">
|
<div className="mb-4 p-3 bg-accent-amber/10 border border-accent-amber/30 rounded text-sm">
|
||||||
<p className="font-medium text-accent-amber">Too many login attempts</p>
|
<p className="font-medium text-accent-amber">{t('auth.login.rateLimited')}</p>
|
||||||
<p className="text-txt-secondary mt-0.5">Try again in {retryAfter}s</p>
|
<p className="text-txt-secondary mt-0.5">{t('auth.retryIn', { seconds: retryAfter })}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -98,7 +100,7 @@ export function LoginPage() {
|
|||||||
|
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
Username <span className="text-txt-danger">*</span>
|
{t('auth.username')} <span className="text-txt-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -112,7 +114,7 @@ export function LoginPage() {
|
|||||||
|
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
Password <span className="text-txt-danger">*</span>
|
{t('auth.password')} <span className="text-txt-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -129,16 +131,16 @@ export function LoginPage() {
|
|||||||
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{retryAfter > 0
|
{retryAfter > 0
|
||||||
? `Try again in ${retryAfter}s`
|
? t('auth.retryIn', { seconds: retryAfter })
|
||||||
: isLoading
|
: isLoading
|
||||||
? 'Logging in...'
|
? t('auth.login.loggingIn')
|
||||||
: 'Log In'}
|
: t('auth.login.submit')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="mt-3 text-sm text-txt-tertiary">
|
<p className="mt-3 text-sm text-txt-tertiary">
|
||||||
Need an account?{' '}
|
{t('auth.needAccount')}{' '}
|
||||||
<Link to={`/register${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline">
|
<Link to={`/register${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline">
|
||||||
Register
|
{t('auth.register')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { api, RateLimitError } from '../../api/client';
|
|||||||
import { useTransferStore } from '../../stores/transferStore';
|
import { useTransferStore } from '../../stores/transferStore';
|
||||||
import { waitForTransferAttachment } from '../../utils/waitForTransfer';
|
import { waitForTransferAttachment } from '../../utils/waitForTransfer';
|
||||||
import { SourceCodeLink } from '../ui/SourceCodeLink';
|
import { SourceCodeLink } from '../ui/SourceCodeLink';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
// Single-source regex for extracting a bare invite token from a pasted full URL.
|
// Single-source regex for extracting a bare invite token from a pasted full URL.
|
||||||
// Token format: 22 chars base64url ([A-Za-z0-9_-]).
|
// Token format: 22 chars base64url ([A-Za-z0-9_-]).
|
||||||
@@ -18,6 +19,7 @@ const INVITE_URL_REGEX = /[?&]invite=([A-Za-z0-9_-]{22})/;
|
|||||||
type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid';
|
type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid';
|
||||||
|
|
||||||
export function RegisterPage() {
|
export function RegisterPage() {
|
||||||
|
const t = useT();
|
||||||
// Step state
|
// Step state
|
||||||
const [step, setStep] = useState<1 | 2>(1);
|
const [step, setStep] = useState<1 | 2>(1);
|
||||||
const [direction, setDirection] = useState<'forward' | 'back'>('forward');
|
const [direction, setDirection] = useState<'forward' | 'back'>('forward');
|
||||||
@@ -187,18 +189,18 @@ export function RegisterPage() {
|
|||||||
|
|
||||||
if (trimmed.length < 3 || trimmed.length > 32) {
|
if (trimmed.length < 3 || trimmed.length > 32) {
|
||||||
setUsernameStatus(trimmed.length > 0 ? 'invalid' : 'idle');
|
setUsernameStatus(trimmed.length > 0 ? 'invalid' : 'idle');
|
||||||
setUsernameStatusMessage(trimmed.length > 0 ? 'Username must be between 3 and 32 characters' : '');
|
setUsernameStatusMessage(trimmed.length > 0 ? t('auth.register.username.length') : '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^[a-z0-9_]+$/.test(trimmed)) {
|
if (!/^[a-z0-9_]+$/.test(trimmed)) {
|
||||||
setUsernameStatus('invalid');
|
setUsernameStatus('invalid');
|
||||||
setUsernameStatusMessage('Username can only contain lowercase letters, numbers, and underscores');
|
setUsernameStatusMessage(t('auth.register.username.charset'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUsernameStatus('checking');
|
setUsernameStatus('checking');
|
||||||
setUsernameStatusMessage('Checking availability...');
|
setUsernameStatusMessage(t('auth.register.username.checking'));
|
||||||
|
|
||||||
usernameCheckTimerRef.current = setTimeout(async () => {
|
usernameCheckTimerRef.current = setTimeout(async () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -213,10 +215,10 @@ export function RegisterPage() {
|
|||||||
setUsernameStatusMessage(result.reason);
|
setUsernameStatusMessage(result.reason);
|
||||||
} else if (result.available) {
|
} else if (result.available) {
|
||||||
setUsernameStatus('available');
|
setUsernameStatus('available');
|
||||||
setUsernameStatusMessage('Username is available');
|
setUsernameStatusMessage(t('auth.register.username.available'));
|
||||||
} else {
|
} else {
|
||||||
setUsernameStatus('taken');
|
setUsernameStatus('taken');
|
||||||
setUsernameStatusMessage('Username is already taken');
|
setUsernameStatusMessage(t('auth.register.username.taken'));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
@@ -265,30 +267,30 @@ export function RegisterPage() {
|
|||||||
|
|
||||||
const trimmed = username.trim();
|
const trimmed = username.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setError('Username is required');
|
setError(t('auth.username.required'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (trimmed.length < 3 || trimmed.length > 32) {
|
if (trimmed.length < 3 || trimmed.length > 32) {
|
||||||
setError('Username must be between 3 and 32 characters');
|
setError(t('auth.register.username.length'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!/^[a-z0-9_]+$/.test(trimmed)) {
|
if (!/^[a-z0-9_]+$/.test(trimmed)) {
|
||||||
setError('Username can only contain lowercase letters, numbers, and underscores');
|
setError(t('auth.register.username.charset'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (usernameStatus === 'taken' || usernameStatus === 'invalid') {
|
if (usernameStatus === 'taken' || usernameStatus === 'invalid') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!password) {
|
if (!password) {
|
||||||
setError('Password is required');
|
setError(t('auth.password.required'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (password.length < 6) {
|
if (password.length < 6) {
|
||||||
setError('Password must be at least 6 characters');
|
setError(t('auth.register.password.length'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setError('Passwords do not match');
|
setError(t('auth.register.password.mismatch'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,7 +381,7 @@ export function RegisterPage() {
|
|||||||
setRetryAfter(err.retryAfter);
|
setRetryAfter(err.retryAfter);
|
||||||
setError('');
|
setError('');
|
||||||
} else {
|
} else {
|
||||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
setError(err instanceof Error ? err.message : t('auth.register.failed'));
|
||||||
}
|
}
|
||||||
setIsRegistering(false);
|
setIsRegistering(false);
|
||||||
}
|
}
|
||||||
@@ -418,7 +420,7 @@ export function RegisterPage() {
|
|||||||
{step === 1 ? (
|
{step === 1 ? (
|
||||||
<div key="step1" className={`w-full${direction === 'back' ? ' animate-step-back' : ''}`}>
|
<div key="step1" className={`w-full${direction === 'back' ? ' animate-step-back' : ''}`}>
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h1 className="text-2xl font-bold text-txt-primary">Create an account</h1>
|
<h1 className="text-2xl font-bold text-txt-primary">{t('auth.register.title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleContinue}>
|
<form onSubmit={handleContinue}>
|
||||||
@@ -439,7 +441,7 @@ export function RegisterPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={manualInviteToken}
|
value={manualInviteToken}
|
||||||
onChange={(e) => setManualInviteToken(e.target.value)}
|
onChange={(e) => setManualInviteToken(e.target.value)}
|
||||||
placeholder="Invite code or link"
|
placeholder={t('auth.register.invite')}
|
||||||
// text-base on mobile prevents iOS Safari from auto-zooming
|
// text-base on mobile prevents iOS Safari from auto-zooming
|
||||||
// when the field is focused (any <input> with font-size <16px triggers zoom).
|
// when the field is focused (any <input> with font-size <16px triggers zoom).
|
||||||
className="input-standard w-full px-3 py-2 text-base md:text-sm"
|
className="input-standard w-full px-3 py-2 text-base md:text-sm"
|
||||||
@@ -447,7 +449,7 @@ export function RegisterPage() {
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
{inviteChecking && (
|
{inviteChecking && (
|
||||||
<div className="text-xs text-txt-tertiary">Checking...</div>
|
<div className="text-xs text-txt-tertiary">{t('auth.register.invite.checking')}</div>
|
||||||
)}
|
)}
|
||||||
{!inviteChecking && inviteCheck?.valid === true && (
|
{!inviteChecking && inviteCheck?.valid === true && (
|
||||||
<div className="text-xs text-status-online flex items-center gap-1">
|
<div className="text-xs text-status-online flex items-center gap-1">
|
||||||
@@ -459,9 +461,9 @@ export function RegisterPage() {
|
|||||||
)}
|
)}
|
||||||
{!inviteChecking && inviteCheck?.valid === false && (
|
{!inviteChecking && inviteCheck?.valid === false && (
|
||||||
<div className="text-xs text-txt-danger">
|
<div className="text-xs text-txt-danger">
|
||||||
{inviteCheck.reason === 'expired' && 'This invite link has expired. Ask the admin for a new one.'}
|
{inviteCheck.reason === 'expired' && t('auth.register.invite.expired')}
|
||||||
{inviteCheck.reason === 'exhausted' && 'This invite has reached its usage limit. Ask the admin to extend it.'}
|
{inviteCheck.reason === 'exhausted' && t('auth.register.invite.exhausted')}
|
||||||
{inviteCheck.reason === 'invalid' && 'Invalid invite code.'}
|
{inviteCheck.reason === 'invalid' && t('auth.register.invite.invalid')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -480,7 +482,7 @@ export function RegisterPage() {
|
|||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Validating invite...</span>
|
<span>{t('auth.register.invite.validating')}</span>
|
||||||
</>
|
</>
|
||||||
) : inviteCheck?.valid === true ? (
|
) : inviteCheck?.valid === true ? (
|
||||||
<>
|
<>
|
||||||
@@ -494,17 +496,17 @@ export function RegisterPage() {
|
|||||||
<svg className="w-3 h-3 flex-shrink-0 mt-0.5 md:mt-0" viewBox="0 0 20 20" fill="currentColor">
|
<svg className="w-3 h-3 flex-shrink-0 mt-0.5 md:mt-0" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Invalid invite link — please request a new one</span>
|
<span>{t('auth.register.invite.invalidLink')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>Validating invite...</>
|
<>{t('auth.register.invite.validating')}</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
Username <span className="text-txt-danger">*</span>
|
{t('auth.username')} <span className="text-txt-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -544,7 +546,7 @@ export function RegisterPage() {
|
|||||||
|
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
Password <span className="text-txt-danger">*</span>
|
{t('auth.password')} <span className="text-txt-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -557,7 +559,7 @@ export function RegisterPage() {
|
|||||||
|
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
Confirm Password <span className="text-txt-danger">*</span>
|
{t('auth.register.confirmPassword')} <span className="text-txt-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -575,7 +577,7 @@ export function RegisterPage() {
|
|||||||
// tighter desktop look from before.
|
// tighter desktop look from before.
|
||||||
className="w-full py-3 md:py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-3 md:py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Continue
|
{t('auth.register.continue')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Helper text when invite is required but not yet entered */}
|
{/* Helper text when invite is required but not yet entered */}
|
||||||
@@ -586,9 +588,9 @@ export function RegisterPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="mt-3 text-sm text-txt-tertiary">
|
<p className="mt-3 text-sm text-txt-tertiary">
|
||||||
Already have an account?{' '}
|
{t('auth.haveAccount')}{' '}
|
||||||
<Link to={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline">
|
<Link to={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline">
|
||||||
Log In
|
{t('auth.login.submit')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
@@ -596,14 +598,14 @@ export function RegisterPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div key="step2" className={`w-full${direction === 'forward' ? ' animate-step-forward' : ''}`}>
|
<div key="step2" className={`w-full${direction === 'forward' ? ' animate-step-forward' : ''}`}>
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h1 className="text-2xl font-bold text-txt-primary">Make it yours</h1>
|
<h1 className="text-2xl font-bold text-txt-primary">{t('auth.register.profile.title')}</h1>
|
||||||
<p className="text-txt-tertiary text-sm mt-1">Personalize your profile, or skip for now</p>
|
<p className="text-txt-tertiary text-sm mt-1">{t('auth.register.profile.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{retryAfter > 0 && (
|
{retryAfter > 0 && (
|
||||||
<div className="mb-4 p-3 bg-accent-amber/10 border border-accent-amber/30 rounded text-sm">
|
<div className="mb-4 p-3 bg-accent-amber/10 border border-accent-amber/30 rounded text-sm">
|
||||||
<p className="font-medium text-accent-amber">Too many attempts</p>
|
<p className="font-medium text-accent-amber">{t('auth.register.rateLimited')}</p>
|
||||||
<p className="text-txt-secondary mt-0.5">Try again in {retryAfter}s</p>
|
<p className="text-txt-secondary mt-0.5">{t('auth.retryIn', { seconds: retryAfter })}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -652,13 +654,13 @@ export function RegisterPage() {
|
|||||||
{/* Display Name */}
|
{/* Display Name */}
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
Display Name
|
{t('auth.register.displayName')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
placeholder={username.trim() || 'Display name'}
|
placeholder={username.trim() || t('auth.register.displayName.placeholder')}
|
||||||
className="input-standard w-full py-2.5 text-base md:text-sm"
|
className="input-standard w-full py-2.5 text-base md:text-sm"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
/>
|
/>
|
||||||
@@ -667,7 +669,7 @@ export function RegisterPage() {
|
|||||||
{/* Avatar Color Picker */}
|
{/* Avatar Color Picker */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
Avatar Color
|
{t('auth.register.avatarColor')}
|
||||||
</label>
|
</label>
|
||||||
{/* Color swatch row: gap tightens on narrow viewports so the 7 swatches
|
{/* Color swatch row: gap tightens on narrow viewports so the 7 swatches
|
||||||
fit inside a 360 px viewport (p-6 inner content area is ~280 px;
|
fit inside a 360 px viewport (p-6 inner content area is ~280 px;
|
||||||
@@ -701,10 +703,10 @@ export function RegisterPage() {
|
|||||||
className="w-full py-3 md:py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-3 md:py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{retryAfter > 0
|
{retryAfter > 0
|
||||||
? `Try again in ${retryAfter}s`
|
? t('auth.retryIn', { seconds: retryAfter })
|
||||||
: isRegistering
|
: isRegistering
|
||||||
? 'Creating account...'
|
? t('auth.register.creating')
|
||||||
: 'Get Started'}
|
: t('auth.register.submit')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mt-3">
|
<div className="flex items-center justify-between mt-3">
|
||||||
@@ -715,7 +717,7 @@ export function RegisterPage() {
|
|||||||
// py-2 px-1 widens the tap area on mobile while keeping the visual link style.
|
// py-2 px-1 widens the tap area on mobile while keeping the visual link style.
|
||||||
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50 py-2 px-1 -mx-1"
|
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50 py-2 px-1 -mx-1"
|
||||||
>
|
>
|
||||||
Back
|
{t('auth.register.back')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -723,7 +725,7 @@ export function RegisterPage() {
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50 py-2 px-1 -mx-1"
|
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50 py-2 px-1 -mx-1"
|
||||||
>
|
>
|
||||||
Skip for now
|
{t('auth.register.skip')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -745,7 +747,7 @@ export function RegisterPage() {
|
|||||||
onClose={() => setAvatarCropSrc(null)}
|
onClose={() => setAvatarCropSrc(null)}
|
||||||
imageSrc={avatarCropSrc}
|
imageSrc={avatarCropSrc}
|
||||||
onCropComplete={handleAvatarCropComplete}
|
onCropComplete={handleAvatarCropComplete}
|
||||||
title="Crop Avatar"
|
title={t('auth.register.cropAvatar')}
|
||||||
aspectRatio={1}
|
aspectRatio={1}
|
||||||
cropShape="round"
|
cropShape="round"
|
||||||
maxOutputDimension={256}
|
maxOutputDimension={256}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { DmChannel, MessageWithUser, SpaceInviteSystemPayload, User } from '@backspace/shared';
|
import type { DmChannel, MessageWithUser, SpaceInviteSystemPayload, User } from '@backspace/shared';
|
||||||
import { SpaceInviteCard } from './SpaceInviteCard';
|
import { SpaceInviteCard } from './SpaceInviteCard';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
interface SystemMessageProps {
|
interface SystemMessageProps {
|
||||||
message: MessageWithUser;
|
message: MessageWithUser;
|
||||||
@@ -13,15 +14,20 @@ interface SystemMessageProps {
|
|||||||
dm?: Pick<DmChannel, 'members'> | null;
|
dm?: Pick<DmChannel, 'members'> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveActorName(message: MessageWithUser, dm?: Pick<DmChannel, 'members'> | null): string {
|
function resolveActorName(
|
||||||
|
message: MessageWithUser,
|
||||||
|
dm: Pick<DmChannel, 'members'> | null | undefined,
|
||||||
|
unknown: string,
|
||||||
|
someone: string,
|
||||||
|
): string {
|
||||||
if (dm) {
|
if (dm) {
|
||||||
const fromRoster = dm.members.find(m => m.id === message.userId) as User | undefined;
|
const fromRoster = dm.members.find(m => m.id === message.userId) as User | undefined;
|
||||||
if (fromRoster) {
|
if (fromRoster) {
|
||||||
return fromRoster.displayName ?? fromRoster.username ?? 'Unknown';
|
return fromRoster.displayName ?? fromRoster.username ?? unknown;
|
||||||
}
|
}
|
||||||
return 'Unknown';
|
return unknown;
|
||||||
}
|
}
|
||||||
return message.user?.displayName ?? message.user?.username ?? 'Someone';
|
return message.user?.displayName ?? message.user?.username ?? someone;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,10 +38,11 @@ function resolveActorName(message: MessageWithUser, dm?: Pick<DmChannel, 'member
|
|||||||
* Exported so unit tests can render it directly without mounting MessageList.
|
* Exported so unit tests can render it directly without mounting MessageList.
|
||||||
*/
|
*/
|
||||||
export function SystemMessage({ message, dm }: SystemMessageProps) {
|
export function SystemMessage({ message, dm }: SystemMessageProps) {
|
||||||
|
const t = useT();
|
||||||
let data: Record<string, unknown> = {};
|
let data: Record<string, unknown> = {};
|
||||||
try { data = JSON.parse(message.content ?? '{}'); } catch { /* fall through to default branch */ }
|
try { data = JSON.parse(message.content ?? '{}'); } catch { /* fall through to default branch */ }
|
||||||
|
|
||||||
const actorName = resolveActorName(message, dm);
|
const actorName = resolveActorName(message, dm, t('system.unknown'), t('system.someone'));
|
||||||
|
|
||||||
if (data.event === 'space_invite') {
|
if (data.event === 'space_invite') {
|
||||||
return (
|
return (
|
||||||
@@ -51,32 +58,32 @@ export function SystemMessage({ message, dm }: SystemMessageProps) {
|
|||||||
switch (data.event) {
|
switch (data.event) {
|
||||||
case 'member_added':
|
case 'member_added':
|
||||||
icon = '→'; // →
|
icon = '→'; // →
|
||||||
text = `${actorName} added ${data.targetDisplayName} to the group`;
|
text = t('system.memberAdded', { actor: actorName, target: String(data.targetDisplayName) });
|
||||||
break;
|
break;
|
||||||
case 'member_removed':
|
case 'member_removed':
|
||||||
if (data.reason === 'leave') {
|
if (data.reason === 'leave') {
|
||||||
icon = '←'; // ←
|
icon = '←'; // ←
|
||||||
text = `${data.targetDisplayName} left the group`;
|
text = t('system.memberLeft', { target: String(data.targetDisplayName) });
|
||||||
} else {
|
} else {
|
||||||
icon = '←';
|
icon = '←';
|
||||||
text = `${actorName} removed ${data.targetDisplayName} from the group`;
|
text = t('system.memberRemoved', { actor: actorName, target: String(data.targetDisplayName) });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'owner_changed':
|
case 'owner_changed':
|
||||||
icon = '♛'; // ♛
|
icon = '♛'; // ♛
|
||||||
text = `${data.newOwnerDisplayName} is now the group owner`;
|
text = t('system.ownerChanged', { owner: String(data.newOwnerDisplayName) });
|
||||||
break;
|
break;
|
||||||
case 'name_changed':
|
case 'name_changed':
|
||||||
icon = '✎'; // ✎
|
icon = '✎'; // ✎
|
||||||
// newName === null is a meaningful "cleared" state — distinct from a
|
// newName === null is a meaningful "cleared" state — distinct from a
|
||||||
// missing field — so we test for a non-empty string explicitly.
|
// missing field — so we test for a non-empty string explicitly.
|
||||||
text = typeof data.newName === 'string' && data.newName.length > 0
|
text = typeof data.newName === 'string' && data.newName.length > 0
|
||||||
? `${actorName} renamed the group to "${data.newName}"`
|
? t('system.nameChanged', { actor: actorName, name: data.newName })
|
||||||
: `${actorName} cleared the group name`;
|
: t('system.nameCleared', { actor: actorName });
|
||||||
break;
|
break;
|
||||||
case 'icon_changed':
|
case 'icon_changed':
|
||||||
icon = '\u{1F5BC}'; // 🖼
|
icon = '\u{1F5BC}'; // 🖼
|
||||||
text = `${actorName} updated the group icon`;
|
text = t('system.iconChanged', { actor: actorName });
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
text = message.content ?? '';
|
text = message.content ?? '';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useCanonicalUserView } from '../../utils/userViewLookup';
|
|||||||
import { formatDmSidebarPreview, formatDmHeaderName } from '../../utils/dmFormatters';
|
import { formatDmSidebarPreview, formatDmHeaderName } from '../../utils/dmFormatters';
|
||||||
import type { DmChannel, User } from '@backspace/shared';
|
import type { DmChannel, User } from '@backspace/shared';
|
||||||
import type { TaggedFriend } from '../../stores/socialStore';
|
import type { TaggedFriend } from '../../stores/socialStore';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
const FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User;
|
const FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User;
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ function MobileDmRow({
|
|||||||
onContextMenu: (e: React.MouseEvent, id: string, isGroup: boolean) => void;
|
onContextMenu: (e: React.MouseEvent, id: string, isGroup: boolean) => void;
|
||||||
formatTimestamp: (ts: number) => string;
|
formatTimestamp: (ts: number) => string;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useT();
|
||||||
const otherMembers = dm.members.filter(m => authUser ? !isSelf(m, authUser) : m.id !== authUser);
|
const otherMembers = dm.members.filter(m => authUser ? !isSelf(m, authUser) : m.id !== authUser);
|
||||||
const isGroup = !!dm.ownerId;
|
const isGroup = !!dm.ownerId;
|
||||||
const rawMainUser = otherMembers[0] ?? null;
|
const rawMainUser = otherMembers[0] ?? null;
|
||||||
@@ -93,7 +95,7 @@ function MobileDmRow({
|
|||||||
// keep the canonical view of the single other member.
|
// keep the canonical view of the single other member.
|
||||||
const name = isGroup
|
const name = isGroup
|
||||||
? formatDmHeaderName(dm, authUser ?? null)
|
? formatDmHeaderName(dm, authUser ?? null)
|
||||||
: mainUser?.displayName ?? (parseFederatedUsername(mainUser?.username ?? '').baseName || 'Unknown');
|
: mainUser?.displayName ?? (parseFederatedUsername(mainUser?.username ?? '').baseName || t('system.unknown'));
|
||||||
|
|
||||||
// Show a single federation globe next to the group name when any non-self
|
// Show a single federation globe next to the group name when any non-self
|
||||||
// member is federated. No tooltip on mobile — the new `group-dm-info` screen
|
// member is federated. No tooltip on mobile — the new `group-dm-info` screen
|
||||||
@@ -181,6 +183,7 @@ function MobileDmRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MobileDmsScreen() {
|
export function MobileDmsScreen() {
|
||||||
|
const t = useT();
|
||||||
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
||||||
const openModal = useUIStore((s) => s.openModal);
|
const openModal = useUIStore((s) => s.openModal);
|
||||||
|
|
||||||
@@ -222,7 +225,7 @@ export function MobileDmsScreen() {
|
|||||||
{
|
{
|
||||||
key: 'leave-group',
|
key: 'leave-group',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Leave Group',
|
label: t('groupDm.leave'),
|
||||||
danger: true,
|
danger: true,
|
||||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" /></svg>,
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" /></svg>,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -251,7 +254,7 @@ export function MobileDmsScreen() {
|
|||||||
<div className="flex flex-col h-full bg-surface-base">
|
<div className="flex flex-col h-full bg-surface-base">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="h-12 flex items-center justify-between px-4 border-b border-border-soft shrink-0">
|
<header className="h-12 flex items-center justify-between px-4 border-b border-border-soft shrink-0">
|
||||||
<h1 className="text-base font-semibold text-txt-primary">Messages</h1>
|
<h1 className="text-base font-semibold text-txt-primary">{t('mobile.messages')}</h1>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => pushMobileScreen('friends')}
|
onClick={() => pushMobileScreen('friends')}
|
||||||
@@ -295,7 +298,7 @@ export function MobileDmsScreen() {
|
|||||||
{sortedDms.length === 0 && (
|
{sortedDms.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center h-40 opacity-80">
|
<div className="flex flex-col items-center justify-center h-40 opacity-80">
|
||||||
<Mascot state="sleeping" className="w-20 h-20 mb-2" />
|
<Mascot state="sleeping" className="w-20 h-20 mb-2" />
|
||||||
<p className="text-txt-tertiary text-sm">No conversations yet.</p>
|
<p className="text-txt-tertiary text-sm">{t('mobile.noConversations')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ImageCropModal } from '../ui/ImageCropModal';
|
|||||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||||
import { MobileScreenHeader } from './MobileScreenHeader';
|
import { MobileScreenHeader } from './MobileScreenHeader';
|
||||||
import { DmMemberRow, type DmMemberRowAction } from './DmMemberRow';
|
import { DmMemberRow, type DmMemberRowAction } from './DmMemberRow';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
const MAX_NAME_LENGTH = 50;
|
const MAX_NAME_LENGTH = 50;
|
||||||
const MAX_GROUP_MEMBERS = 10;
|
const MAX_GROUP_MEMBERS = 10;
|
||||||
@@ -63,6 +64,7 @@ interface MobileGroupDmInfoProps {
|
|||||||
* `MobileChatScreen`'s members button).
|
* `MobileChatScreen`'s members button).
|
||||||
*/
|
*/
|
||||||
export function MobileGroupDmInfo({ params }: MobileGroupDmInfoProps) {
|
export function MobileGroupDmInfo({ params }: MobileGroupDmInfoProps) {
|
||||||
|
const t = useT();
|
||||||
const channelId = params?.channelId ?? null;
|
const channelId = params?.channelId ?? null;
|
||||||
|
|
||||||
const dmChannels = useSpaceStore((s) => s.dmChannels);
|
const dmChannels = useSpaceStore((s) => s.dmChannels);
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ import { FederationPanel } from '../modals/instanceSettingsPanels/FederationPane
|
|||||||
import { StreamingPanel } from '../modals/instanceSettingsPanels/StreamingPanel';
|
import { StreamingPanel } from '../modals/instanceSettingsPanels/StreamingPanel';
|
||||||
import { StoragePanel } from '../modals/instanceSettingsPanels/StoragePanel';
|
import { StoragePanel } from '../modals/instanceSettingsPanels/StoragePanel';
|
||||||
import { UsersPanel } from '../modals/instanceSettingsPanels/UsersPanel';
|
import { UsersPanel } from '../modals/instanceSettingsPanels/UsersPanel';
|
||||||
|
import { translate, useLocaleStore, type TranslationKey } from '../../i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `screenMap` holds plain functions that are *called*, not rendered as
|
||||||
|
* components, so a hook inside them would run under whichever component
|
||||||
|
* happens to invoke the map. The locale is read directly instead; screens
|
||||||
|
* remount on navigation, so a language switch shows on the next push.
|
||||||
|
*/
|
||||||
|
function tr(key: TranslationKey): string {
|
||||||
|
return translate(useLocaleStore.getState().locale, key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper for the Federation sub-panel that forwards FederationPanel's
|
* Wrapper for the Federation sub-panel that forwards FederationPanel's
|
||||||
@@ -60,32 +71,32 @@ const screenMap: Record<string, (params?: Record<string, string>) => React.React
|
|||||||
'settings-instance': () => <MobileInstancePanel />,
|
'settings-instance': () => <MobileInstancePanel />,
|
||||||
'settings-instance-general': () => (
|
'settings-instance-general': () => (
|
||||||
<div className="flex flex-col h-full bg-surface-base">
|
<div className="flex flex-col h-full bg-surface-base">
|
||||||
<MobileScreenHeader title="General" rightActions={<TransferIndicator />} />
|
<MobileScreenHeader title={tr('nav.general')} rightActions={<TransferIndicator />} />
|
||||||
<div className="flex-1 overflow-y-auto p-4"><GeneralPanel /></div>
|
<div className="flex-1 overflow-y-auto p-4"><GeneralPanel /></div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
'settings-instance-registration': () => (
|
'settings-instance-registration': () => (
|
||||||
<div className="flex flex-col h-full bg-surface-base">
|
<div className="flex flex-col h-full bg-surface-base">
|
||||||
<MobileScreenHeader title="Registration" rightActions={<TransferIndicator />} />
|
<MobileScreenHeader title={tr('nav.registration')} rightActions={<TransferIndicator />} />
|
||||||
<div className="flex-1 overflow-y-auto p-4"><RegistrationPanel /></div>
|
<div className="flex-1 overflow-y-auto p-4"><RegistrationPanel /></div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
'settings-instance-federation': () => <MobileFederationPanelWrapper />,
|
'settings-instance-federation': () => <MobileFederationPanelWrapper />,
|
||||||
'settings-instance-streaming': () => (
|
'settings-instance-streaming': () => (
|
||||||
<div className="flex flex-col h-full bg-surface-base">
|
<div className="flex flex-col h-full bg-surface-base">
|
||||||
<MobileScreenHeader title="Streaming" rightActions={<TransferIndicator />} />
|
<MobileScreenHeader title={tr('nav.streaming')} rightActions={<TransferIndicator />} />
|
||||||
<div className="flex-1 overflow-y-auto p-4"><StreamingPanel /></div>
|
<div className="flex-1 overflow-y-auto p-4"><StreamingPanel /></div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
'settings-instance-storage': () => (
|
'settings-instance-storage': () => (
|
||||||
<div className="flex flex-col h-full bg-surface-base">
|
<div className="flex flex-col h-full bg-surface-base">
|
||||||
<MobileScreenHeader title="Storage" rightActions={<TransferIndicator />} />
|
<MobileScreenHeader title={tr('nav.storage')} rightActions={<TransferIndicator />} />
|
||||||
<div className="flex-1 overflow-y-auto p-4"><StoragePanel /></div>
|
<div className="flex-1 overflow-y-auto p-4"><StoragePanel /></div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
'settings-instance-users': () => (
|
'settings-instance-users': () => (
|
||||||
<div className="flex flex-col h-full bg-surface-base">
|
<div className="flex flex-col h-full bg-surface-base">
|
||||||
<MobileScreenHeader title="Users" rightActions={<TransferIndicator />} />
|
<MobileScreenHeader title={tr('nav.users')} rightActions={<TransferIndicator />} />
|
||||||
<div className="flex-1 overflow-y-auto p-4"><UsersPanel /></div>
|
<div className="flex-1 overflow-y-auto p-4"><UsersPanel /></div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ import { MobileVoiceJoinSheet } from '../voice/MobileVoiceJoinSheet';
|
|||||||
import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems';
|
import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems';
|
||||||
import { joinVoiceChannel } from '../../utils/voice';
|
import { joinVoiceChannel } from '../../utils/voice';
|
||||||
import { useDelayedLoading } from '../../hooks/useDelayedLoading';
|
import { useDelayedLoading } from '../../hooks/useDelayedLoading';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
type ResolvedItem =
|
type ResolvedItem =
|
||||||
| { type: 'space'; space: TaggedSpace }
|
| { type: 'space'; space: TaggedSpace }
|
||||||
| { type: 'folder'; folder: SpaceFolder; spaces: TaggedSpace[] };
|
| { type: 'folder'; folder: SpaceFolder; spaces: TaggedSpace[] };
|
||||||
|
|
||||||
export function MobileSpacesScreen() {
|
export function MobileSpacesScreen() {
|
||||||
|
const t = useT();
|
||||||
const spaces = useSpaceStore((s) => s.spaces);
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
const channels = useSpaceStore((s) => s.channels);
|
const channels = useSpaceStore((s) => s.channels);
|
||||||
const categories = useSpaceStore((s) => s.categories);
|
const categories = useSpaceStore((s) => s.categories);
|
||||||
@@ -266,7 +268,7 @@ export function MobileSpacesScreen() {
|
|||||||
folderData[folderId] = { name: null, color: null, spaceIds: [spaceId] };
|
folderData[folderId] = { name: null, color: null, spaceIds: [spaceId] };
|
||||||
|
|
||||||
await updateSpaceLayout(newLayout, folderData);
|
await updateSpaceLayout(newLayout, folderData);
|
||||||
addToast('Folder created', 'success', 3000);
|
addToast(t('mobile.folderCreated'), 'success', 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMoveToFolder = async (spaceId: string, folderId: string) => {
|
const handleMoveToFolder = async (spaceId: string, folderId: string) => {
|
||||||
@@ -283,7 +285,7 @@ export function MobileSpacesScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await updateSpaceLayout(newLayout, folderData);
|
await updateSpaceLayout(newLayout, folderData);
|
||||||
addToast('Moved to folder', 'success', 3000);
|
addToast(t('mobile.movedToFolder'), 'success', 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveFromFolder = async (spaceId: string) => {
|
const handleRemoveFromFolder = async (spaceId: string) => {
|
||||||
@@ -317,7 +319,7 @@ export function MobileSpacesScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await updateSpaceLayout(newLayout, folderData);
|
await updateSpaceLayout(newLayout, folderData);
|
||||||
addToast('Removed from folder', 'success', 3000);
|
addToast(t('mobile.removedFromFolder'), 'success', 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUngroup = async (folderId: string) => {
|
const handleUngroup = async (folderId: string) => {
|
||||||
@@ -341,7 +343,7 @@ export function MobileSpacesScreen() {
|
|||||||
|
|
||||||
await updateSpaceLayout(newLayout, folderData);
|
await updateSpaceLayout(newLayout, folderData);
|
||||||
setOpenFolderId(null);
|
setOpenFolderId(null);
|
||||||
addToast('Folder ungrouped', 'success', 3000);
|
addToast(t('mobile.folderUngrouped'), 'success', 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateFolder = async (folderId: string, updates: { name?: string | null; color?: string | null }) => {
|
const handleUpdateFolder = async (folderId: string, updates: { name?: string | null; color?: string | null }) => {
|
||||||
@@ -374,7 +376,7 @@ export function MobileSpacesScreen() {
|
|||||||
{
|
{
|
||||||
key: 'invite',
|
key: 'invite',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Invite People',
|
label: t('mobile.invitePeople'),
|
||||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM3 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 019.374 21c-2.331 0-4.512-.645-6.374-1.766z" /></svg>,
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM3 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 019.374 21c-2.331 0-4.512-.645-6.374-1.766z" /></svg>,
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
try {
|
try {
|
||||||
@@ -382,16 +384,16 @@ export function MobileSpacesScreen() {
|
|||||||
const origin = (space as TaggedSpace)._instanceOrigin || window.location.origin;
|
const origin = (space as TaggedSpace)._instanceOrigin || window.location.origin;
|
||||||
const url = `${origin}/invite/${code}`;
|
const url = `${origin}/invite/${code}`;
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
addToast('Invite link copied to clipboard', 'success', 3000);
|
addToast(t('mobile.inviteCopied'), 'success', 3000);
|
||||||
} catch {
|
} catch {
|
||||||
addToast('Failed to generate invite', 'warning', 3000);
|
addToast(t('mobile.inviteFailed'), 'warning', 3000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'create-folder',
|
key: 'create-folder',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Create Folder',
|
label: t('mobile.createFolder'),
|
||||||
hidden: inFolder,
|
hidden: inFolder,
|
||||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" /></svg>,
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" /></svg>,
|
||||||
onClick: () => handleCreateFolder(spaceId),
|
onClick: () => handleCreateFolder(spaceId),
|
||||||
@@ -403,12 +405,12 @@ export function MobileSpacesScreen() {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'move-to-folder',
|
key: 'move-to-folder',
|
||||||
type: 'submenu',
|
type: 'submenu',
|
||||||
label: 'Move to Folder',
|
label: t('mobile.moveToFolder'),
|
||||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" /></svg>,
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" /></svg>,
|
||||||
children: availableFolders.map(f => ({
|
children: availableFolders.map(f => ({
|
||||||
key: f.id,
|
key: f.id,
|
||||||
type: 'action' as const,
|
type: 'action' as const,
|
||||||
label: f.name || 'Unnamed Folder',
|
label: f.name || t('mobile.unnamedFolder'),
|
||||||
onClick: () => handleMoveToFolder(spaceId, f.id),
|
onClick: () => handleMoveToFolder(spaceId, f.id),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
@@ -419,7 +421,7 @@ export function MobileSpacesScreen() {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'remove-from-folder',
|
key: 'remove-from-folder',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Remove from Folder',
|
label: t('mobile.removeFromFolder'),
|
||||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" /></svg>,
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" /></svg>,
|
||||||
onClick: () => handleRemoveFromFolder(spaceId),
|
onClick: () => handleRemoveFromFolder(spaceId),
|
||||||
});
|
});
|
||||||
@@ -430,7 +432,7 @@ export function MobileSpacesScreen() {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'transfer',
|
key: 'transfer',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Transfer Ownership',
|
label: t('dm.transfer.title'),
|
||||||
hidden: !isOwner,
|
hidden: !isOwner,
|
||||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" /></svg>,
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" /></svg>,
|
||||||
onClick: () => setTransferModalSpaceId(spaceId),
|
onClick: () => setTransferModalSpaceId(spaceId),
|
||||||
@@ -439,7 +441,7 @@ export function MobileSpacesScreen() {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'leave',
|
key: 'leave',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Leave Space',
|
label: t('mobile.leaveSpace'),
|
||||||
hidden: isOwner,
|
hidden: isOwner,
|
||||||
danger: true,
|
danger: true,
|
||||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" /></svg>,
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" /></svg>,
|
||||||
@@ -462,7 +464,7 @@ export function MobileSpacesScreen() {
|
|||||||
{
|
{
|
||||||
key: 'channel-settings',
|
key: 'channel-settings',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Channel Settings',
|
label: t('mobile.channelSettings'),
|
||||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>,
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>,
|
||||||
onClick: () => openModal('channelSettings', { channelId }),
|
onClick: () => openModal('channelSettings', { channelId }),
|
||||||
},
|
},
|
||||||
@@ -470,7 +472,7 @@ export function MobileSpacesScreen() {
|
|||||||
{
|
{
|
||||||
key: 'delete-channel',
|
key: 'delete-channel',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Delete Channel',
|
label: t('mobile.deleteChannel'),
|
||||||
danger: true,
|
danger: true,
|
||||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /></svg>,
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /></svg>,
|
||||||
onClick: () => setDeleteChannelId(channelId),
|
onClick: () => setDeleteChannelId(channelId),
|
||||||
@@ -490,7 +492,7 @@ export function MobileSpacesScreen() {
|
|||||||
{
|
{
|
||||||
key: 'category-settings',
|
key: 'category-settings',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Category Settings',
|
label: t('mobile.categorySettings'),
|
||||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>,
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>,
|
||||||
onClick: () => openModal('categorySettings', { categoryId }),
|
onClick: () => openModal('categorySettings', { categoryId }),
|
||||||
},
|
},
|
||||||
@@ -498,7 +500,7 @@ export function MobileSpacesScreen() {
|
|||||||
{
|
{
|
||||||
key: 'delete-category',
|
key: 'delete-category',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Delete Category',
|
label: t('mobile.deleteCategory'),
|
||||||
danger: true,
|
danger: true,
|
||||||
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /></svg>,
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /></svg>,
|
||||||
onClick: () => setDeleteCategoryId(categoryId),
|
onClick: () => setDeleteCategoryId(categoryId),
|
||||||
@@ -558,7 +560,7 @@ export function MobileSpacesScreen() {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'mute-user',
|
key: 'mute-user',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
label: 'Mute User',
|
label: t('mobile.muteUser'),
|
||||||
subscribe: useVoiceStore.subscribe,
|
subscribe: useVoiceStore.subscribe,
|
||||||
getChecked: () => useVoiceStore.getState().participantMutes.get(userId) ?? false,
|
getChecked: () => useVoiceStore.getState().participantMutes.get(userId) ?? false,
|
||||||
onChange: (checked) => useVoiceStore.getState().setParticipantMute(userId, checked),
|
onChange: (checked) => useVoiceStore.getState().setParticipantMute(userId, checked),
|
||||||
@@ -818,7 +820,7 @@ export function MobileSpacesScreen() {
|
|||||||
{/* Channel list */}
|
{/* Channel list */}
|
||||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||||
{showChannelSkeleton ? (
|
{showChannelSkeleton ? (
|
||||||
<div className="px-1 pt-1" role="status" aria-label="Loading channels">
|
<div className="px-1 pt-1" role="status" aria-label={t('mobile.loadingChannels')}>
|
||||||
{/* Uncategorized group — 3 channel rows. Sized to match the real
|
{/* Uncategorized group — 3 channel rows. Sized to match the real
|
||||||
channel rows (`px-3 py-2 rounded-lg` with `text-sm`/`w-4 h-4`
|
channel rows (`px-3 py-2 rounded-lg` with `text-sm`/`w-4 h-4`
|
||||||
icon → ~36px height per row). */}
|
icon → ~36px height per row). */}
|
||||||
@@ -901,7 +903,7 @@ export function MobileSpacesScreen() {
|
|||||||
{isSpaceSettledEmpty && (
|
{isSpaceSettledEmpty && (
|
||||||
<div className="flex flex-col items-center justify-center h-32 opacity-80">
|
<div className="flex flex-col items-center justify-center h-32 opacity-80">
|
||||||
<Mascot state="idle" className="w-20 h-20 mb-2" />
|
<Mascot state="idle" className="w-20 h-20 mb-2" />
|
||||||
<p className="text-txt-tertiary text-sm">No channels yet.</p>
|
<p className="text-txt-tertiary text-sm">{t('mobile.noChannels')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -926,7 +928,7 @@ export function MobileSpacesScreen() {
|
|||||||
<svg className="w-5 h-5 text-accent-mint" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="w-5 h-5 text-accent-mint" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm text-txt-primary">Create Space</span>
|
<span className="text-sm text-txt-primary">{t('mobile.createSpace')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowAddSheet(false); openModal('joinSpace'); }}
|
onClick={() => { setShowAddSheet(false); openModal('joinSpace'); }}
|
||||||
@@ -935,7 +937,7 @@ export function MobileSpacesScreen() {
|
|||||||
<svg className="w-5 h-5 text-accent-sky" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="w-5 h-5 text-accent-sky" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm text-txt-primary">Join Space</span>
|
<span className="text-sm text-txt-primary">{t('mobile.joinSpace')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowAddSheet(false); pushMobileScreen('explore'); }}
|
onClick={() => { setShowAddSheet(false); pushMobileScreen('explore'); }}
|
||||||
@@ -944,7 +946,7 @@ export function MobileSpacesScreen() {
|
|||||||
<svg className="w-5 h-5 text-accent-lavender" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="w-5 h-5 text-accent-lavender" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm text-txt-primary">Explore Spaces</span>
|
<span className="text-sm text-txt-primary">{t('mobile.exploreSpaces')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -955,17 +957,17 @@ export function MobileSpacesScreen() {
|
|||||||
{leaveConfirmSpaceId && (
|
{leaveConfirmSpaceId && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
title="Leave Space"
|
title={t('mobile.leaveSpace')}
|
||||||
description="Are you sure you want to leave this space? You'll need a new invite to rejoin."
|
description={t('mobile.leaveSpace.confirm')}
|
||||||
confirmLabel="Leave"
|
confirmLabel={t('mobile.leave')}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
try {
|
try {
|
||||||
await useSpaceStore.getState().leaveSpace(leaveConfirmSpaceId);
|
await useSpaceStore.getState().leaveSpace(leaveConfirmSpaceId);
|
||||||
if (selectedSpaceId === leaveConfirmSpaceId) setSelectedSpaceId(null);
|
if (selectedSpaceId === leaveConfirmSpaceId) setSelectedSpaceId(null);
|
||||||
addToast('Left space', 'success', 3000);
|
addToast(t('mobile.leftSpace'), 'success', 3000);
|
||||||
} catch {
|
} catch {
|
||||||
addToast('Failed to leave space', 'warning', 3000);
|
addToast(t('mobile.leaveSpaceFailed'), 'warning', 3000);
|
||||||
}
|
}
|
||||||
setLeaveConfirmSpaceId(null);
|
setLeaveConfirmSpaceId(null);
|
||||||
}}
|
}}
|
||||||
@@ -976,16 +978,16 @@ export function MobileSpacesScreen() {
|
|||||||
{deleteChannelId && (
|
{deleteChannelId && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
title="Delete Channel"
|
title={t('mobile.deleteChannel')}
|
||||||
description="Are you sure? This will permanently delete the channel and all its messages."
|
description={t('mobile.deleteChannel.confirm')}
|
||||||
confirmLabel="Delete"
|
confirmLabel={t('mobile.delete')}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
try {
|
try {
|
||||||
await useSpaceStore.getState().deleteChannel(deleteChannelId);
|
await useSpaceStore.getState().deleteChannel(deleteChannelId);
|
||||||
addToast('Channel deleted', 'success', 3000);
|
addToast(t('mobile.channelDeleted'), 'success', 3000);
|
||||||
} catch {
|
} catch {
|
||||||
addToast('Failed to delete channel', 'warning', 3000);
|
addToast(t('mobile.deleteChannelFailed'), 'warning', 3000);
|
||||||
}
|
}
|
||||||
setDeleteChannelId(null);
|
setDeleteChannelId(null);
|
||||||
}}
|
}}
|
||||||
@@ -996,16 +998,16 @@ export function MobileSpacesScreen() {
|
|||||||
{deleteCategoryId && (
|
{deleteCategoryId && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
title="Delete Category"
|
title={t('mobile.deleteCategory')}
|
||||||
description="Are you sure? Channels in this category will be moved to uncategorized."
|
description={t('mobile.deleteCategory.confirm')}
|
||||||
confirmLabel="Delete"
|
confirmLabel={t('mobile.delete')}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
try {
|
try {
|
||||||
await useSpaceStore.getState().deleteCategory(deleteCategoryId);
|
await useSpaceStore.getState().deleteCategory(deleteCategoryId);
|
||||||
addToast('Category deleted', 'success', 3000);
|
addToast(t('mobile.categoryDeleted'), 'success', 3000);
|
||||||
} catch {
|
} catch {
|
||||||
addToast('Failed to delete category', 'warning', 3000);
|
addToast(t('category.delete.failed'), 'warning', 3000);
|
||||||
}
|
}
|
||||||
setDeleteCategoryId(null);
|
setDeleteCategoryId(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { VoiceGrid } from '../voice/VoiceGrid';
|
import { VoiceGrid } from '../voice/VoiceGrid';
|
||||||
import { deriveGridTiles } from '../../hooks/useLiveKit';
|
import { deriveGridTiles } from '../../hooks/useLiveKit';
|
||||||
import { requestMicPermission } from '../../utils/voice';
|
import { requestMicPermission } from '../../utils/voice';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen mobile voice/video call view.
|
* Full-screen mobile voice/video call view.
|
||||||
@@ -29,6 +30,7 @@ import { requestMicPermission } from '../../utils/voice';
|
|||||||
* "render-everything-then-let-the-user-pick" pattern.
|
* "render-everything-then-let-the-user-pick" pattern.
|
||||||
*/
|
*/
|
||||||
export function MobileVoiceFullScreen() {
|
export function MobileVoiceFullScreen() {
|
||||||
|
const t = useT();
|
||||||
const popMobileScreen = useUIStore((s) => s.popMobileScreen);
|
const popMobileScreen = useUIStore((s) => s.popMobileScreen);
|
||||||
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
||||||
|
|
||||||
@@ -249,7 +251,7 @@ export function MobileVoiceFullScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isDmCall = currentVoiceChannelId.startsWith('dm-');
|
const isDmCall = currentVoiceChannelId.startsWith('dm-');
|
||||||
let channelName = 'Voice Call';
|
let channelName = t('mobile.voice.title');
|
||||||
let spaceName = '';
|
let spaceName = '';
|
||||||
|
|
||||||
if (isDmCall) {
|
if (isDmCall) {
|
||||||
@@ -298,7 +300,7 @@ export function MobileVoiceFullScreen() {
|
|||||||
<button
|
<button
|
||||||
onClick={popMobileScreen}
|
onClick={popMobileScreen}
|
||||||
className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary"
|
className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary"
|
||||||
aria-label="Collapse call"
|
aria-label={t('mobile.voice.collapse')}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5"
|
className="w-5 h-5"
|
||||||
@@ -329,7 +331,7 @@ export function MobileVoiceFullScreen() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => pushMobileScreen('members')}
|
onClick={() => pushMobileScreen('members')}
|
||||||
className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary"
|
className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary"
|
||||||
aria-label="View members"
|
aria-label={t('mobile.voice.members')}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5"
|
className="w-5 h-5"
|
||||||
@@ -411,7 +413,7 @@ export function MobileVoiceFullScreen() {
|
|||||||
? 'bg-accent-rose/20 text-accent-rose'
|
? 'bg-accent-rose/20 text-accent-rose'
|
||||||
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
||||||
}`}
|
}`}
|
||||||
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
aria-label={isMuted ? t('mobile.voice.unmute') : 'Mute'}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5"
|
className="w-5 h-5"
|
||||||
@@ -443,7 +445,7 @@ export function MobileVoiceFullScreen() {
|
|||||||
? 'bg-accent-rose/20 text-accent-rose'
|
? 'bg-accent-rose/20 text-accent-rose'
|
||||||
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
||||||
}`}
|
}`}
|
||||||
aria-label={isDeafened ? 'Undeafen' : 'Deafen'}
|
aria-label={isDeafened ? t('mobile.voice.undeafen') : t('mobile.voice.deafen')}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5"
|
className="w-5 h-5"
|
||||||
@@ -479,7 +481,7 @@ export function MobileVoiceFullScreen() {
|
|||||||
? 'bg-accent-mint/20 text-accent-mint'
|
? 'bg-accent-mint/20 text-accent-mint'
|
||||||
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
||||||
}`}
|
}`}
|
||||||
aria-label={isCameraOn ? 'Turn camera off' : 'Turn camera on'}
|
aria-label={isCameraOn ? t('mobile.voice.cameraOff') : t('mobile.voice.cameraOn')}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5"
|
className="w-5 h-5"
|
||||||
@@ -501,7 +503,7 @@ export function MobileVoiceFullScreen() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setCameraPickerOpen((v) => !v);
|
setCameraPickerOpen((v) => !v);
|
||||||
}}
|
}}
|
||||||
aria-label="Switch camera"
|
aria-label={t('mobile.voice.switchCamera')}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={cameraPickerOpen}
|
aria-expanded={cameraPickerOpen}
|
||||||
className="absolute -top-1 -right-1 w-6 h-6 rounded-full bg-surface-elevated text-txt-primary flex items-center justify-center shadow-md border border-border-soft active:scale-95 transition-transform"
|
className="absolute -top-1 -right-1 w-6 h-6 rounded-full bg-surface-elevated text-txt-primary flex items-center justify-center shadow-md border border-border-soft active:scale-95 transition-transform"
|
||||||
@@ -542,7 +544,7 @@ export function MobileVoiceFullScreen() {
|
|||||||
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
|
||||||
}`}
|
}`}
|
||||||
aria-label={
|
aria-label={
|
||||||
isScreenSharing ? 'Stop sharing screen' : 'Share screen'
|
isScreenSharing ? t('mobile.voice.stopSharing') : t('mobile.voice.shareScreen')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -564,7 +566,7 @@ export function MobileVoiceFullScreen() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleDisconnect}
|
onClick={handleDisconnect}
|
||||||
className="w-12 h-12 rounded-full bg-accent-rose flex items-center justify-center text-white hover:bg-accent-rose/80 transition-colors"
|
className="w-12 h-12 rounded-full bg-accent-rose flex items-center justify-center text-white hover:bg-accent-rose/80 transition-colors"
|
||||||
aria-label="Disconnect from call"
|
aria-label={t('mobile.voice.disconnect')}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5"
|
className="w-5 h-5"
|
||||||
@@ -594,7 +596,7 @@ export function MobileVoiceFullScreen() {
|
|||||||
<div
|
<div
|
||||||
ref={cameraPickerPopupRef}
|
ref={cameraPickerPopupRef}
|
||||||
role="menu"
|
role="menu"
|
||||||
aria-label="Select camera"
|
aria-label={t('mobile.voice.selectCamera')}
|
||||||
className="fixed z-[60] rounded-md bg-surface-elevated border border-border-hard py-1 shadow-lg overflow-y-auto"
|
className="fixed z-[60] rounded-md bg-surface-elevated border border-border-hard py-1 shadow-lg overflow-y-auto"
|
||||||
style={{
|
style={{
|
||||||
left: cameraPickerRect.left,
|
left: cameraPickerRect.left,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { BansPanel } from './spaceSettingsPanels/BansPanel';
|
|||||||
import type { SpaceVisibility, JoinRequest } from '@backspace/shared';
|
import type { SpaceVisibility, JoinRequest } from '@backspace/shared';
|
||||||
|
|
||||||
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
||||||
|
const t = useT();
|
||||||
const spaces = useSpaceStore((s) => s.spaces);
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
const updateSpace = useSpaceStore((s) => s.updateSpace);
|
const updateSpace = useSpaceStore((s) => s.updateSpace);
|
||||||
const discoveryEnabled = useSettingsStore((s) => s.streamingLimits?.discoveryEnabled ?? true);
|
const discoveryEnabled = useSettingsStore((s) => s.streamingLimits?.discoveryEnabled ?? true);
|
||||||
@@ -49,9 +50,9 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
|||||||
setSaveError('');
|
setSaveError('');
|
||||||
try {
|
try {
|
||||||
await api.spaces.update(spaceId, { visibility, description: description.trim() });
|
await api.spaces.update(spaceId, { visibility, description: description.trim() });
|
||||||
addToast('Settings saved', 'success', 2000);
|
addToast(t('space.overview.saved'), 'success', 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSaveError(err instanceof Error ? err.message : 'Failed to save');
|
setSaveError(err instanceof Error ? err.message : t('space.overview.saveFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -71,7 +72,7 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<h2 className="text-lg font-semibold text-txt-primary mb-6">Discovery</h2>
|
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('space.discovery.title')}</h2>
|
||||||
{!discoveryEnabled && (
|
{!discoveryEnabled && (
|
||||||
<div className="p-2.5 bg-accent-amber/10 border border-accent-amber/30 rounded text-[13px] text-accent-amber">
|
<div className="p-2.5 bg-accent-amber/10 border border-accent-amber/30 rounded text-[13px] text-accent-amber">
|
||||||
Space discovery is disabled by the instance administrator. Changing visibility will have no effect until discovery is re-enabled.
|
Space discovery is disabled by the instance administrator. Changing visibility will have no effect until discovery is re-enabled.
|
||||||
@@ -79,8 +80,8 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Visibility</div>
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('space.discovery.visibility')}</div>
|
||||||
<p className="text-xs text-txt-tertiary mb-2">Control who can discover and join this space.</p>
|
<p className="text-xs text-txt-tertiary mb-2">{t('space.discovery.visibility.description')}</p>
|
||||||
<div className="rounded-lg bg-white/[0.02] p-3.5">
|
<div className="rounded-lg bg-white/[0.02] p-3.5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{visibilityOptions.map((opt) => (
|
{visibilityOptions.map((opt) => (
|
||||||
@@ -111,13 +112,13 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Description</div>
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('space.discovery.description')}</div>
|
||||||
<p className="text-xs text-txt-tertiary mb-2">A short summary shown on the Explore page.</p>
|
<p className="text-xs text-txt-tertiary mb-2">{t('space.discovery.description.hint')}</p>
|
||||||
<div className="rounded-lg bg-white/[0.02] p-3.5">
|
<div className="rounded-lg bg-white/[0.02] p-3.5">
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value.slice(0, 200))}
|
onChange={(e) => setDescription(e.target.value.slice(0, 200))}
|
||||||
placeholder="A short description for the Explore page..."
|
placeholder={t('space.discovery.description.placeholder')}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="input-standard w-full resize-none"
|
className="input-standard w-full resize-none"
|
||||||
/>
|
/>
|
||||||
@@ -148,7 +149,7 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{saving ? 'Saving...' : 'Save'}
|
{saving ? t('space.overview.saving') : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,6 +160,7 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function JoinRequestsSection({ spaceId }: { spaceId: string }) {
|
function JoinRequestsSection({ spaceId }: { spaceId: string }) {
|
||||||
|
const t = useT();
|
||||||
const [requests, setRequests] = useState<JoinRequest[]>([]);
|
const [requests, setRequests] = useState<JoinRequest[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actionError, setActionError] = useState('');
|
const [actionError, setActionError] = useState('');
|
||||||
@@ -185,7 +187,7 @@ function JoinRequestsSection({ spaceId }: { spaceId: string }) {
|
|||||||
await api.explore.decideJoinRequest(spaceId, requestId, action);
|
await api.explore.decideJoinRequest(spaceId, requestId, action);
|
||||||
setRequests(prev => prev.filter(r => r.id !== requestId));
|
setRequests(prev => prev.filter(r => r.id !== requestId));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError(err instanceof Error ? err.message : 'Action failed');
|
setActionError(err instanceof Error ? err.message : t('space.requests.failed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -202,9 +204,9 @@ function JoinRequestsSection({ spaceId }: { spaceId: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-sm text-txt-tertiary">Loading...</div>
|
<div className="text-sm text-txt-tertiary">{t('space.requests.loading')}</div>
|
||||||
) : requests.length === 0 ? (
|
) : requests.length === 0 ? (
|
||||||
<div className="text-sm text-txt-tertiary">No pending join requests</div>
|
<div className="text-sm text-txt-tertiary">{t('space.requests.empty')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 max-h-[240px] overflow-y-auto scrollbar-thin">
|
<div className="space-y-2 max-h-[240px] overflow-y-auto scrollbar-thin">
|
||||||
{requests.map((req) => {
|
{requests.map((req) => {
|
||||||
@@ -237,7 +239,7 @@ function JoinRequestsSection({ spaceId }: { spaceId: string }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleDecide(req.id, 'accept')}
|
onClick={() => handleDecide(req.id, 'accept')}
|
||||||
className="p-1.5 rounded text-status-online hover:bg-status-online/20 transition-colors"
|
className="p-1.5 rounded text-status-online hover:bg-status-online/20 transition-colors"
|
||||||
title="Accept"
|
title={t('space.requests.accept')}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||||
@@ -246,7 +248,7 @@ function JoinRequestsSection({ spaceId }: { spaceId: string }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleDecide(req.id, 'decline')}
|
onClick={() => handleDecide(req.id, 'decline')}
|
||||||
className="p-1.5 rounded text-txt-danger hover:bg-accent-rose/20 transition-colors"
|
className="p-1.5 rounded text-txt-danger hover:bg-accent-rose/20 transition-colors"
|
||||||
title="Decline"
|
title={t('space.requests.decline')}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||||
@@ -321,20 +323,20 @@ export function SpaceSettingsModal() {
|
|||||||
|
|
||||||
{/* Nav list */}
|
{/* Nav list */}
|
||||||
<div className="glass-bubble rounded-lg p-2 flex-1 flex flex-col">
|
<div className="glass-bubble rounded-lg p-2 flex-1 flex flex-col">
|
||||||
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">General</div>
|
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">{t('nav.general')}</div>
|
||||||
<button onClick={() => handleTabClick('overview')} className={tabClass('overview')}>Overview</button>
|
<button onClick={() => handleTabClick('overview')} className={tabClass('overview')}>{t('nav.overview')}</button>
|
||||||
{canManageSpace && (
|
{canManageSpace && (
|
||||||
<button onClick={() => handleTabClick('discovery')} className={tabClass('discovery')}>Discovery</button>
|
<button onClick={() => handleTabClick('discovery')} className={tabClass('discovery')}>{t('nav.discovery')}</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t border-white/[0.04] my-2 mx-2" />
|
<div className="border-t border-white/[0.04] my-2 mx-2" />
|
||||||
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">Management</div>
|
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">{t('nav.management')}</div>
|
||||||
<button onClick={() => handleTabClick('members')} className={tabClass('members')}>Members</button>
|
<button onClick={() => handleTabClick('members')} className={tabClass('members')}>{t('nav.members')}</button>
|
||||||
{canManageRoles && (
|
{canManageRoles && (
|
||||||
<button onClick={() => handleTabClick('roles')} className={tabClass('roles')}>Roles</button>
|
<button onClick={() => handleTabClick('roles')} className={tabClass('roles')}>{t('nav.roles')}</button>
|
||||||
)}
|
)}
|
||||||
{canBanMembers && (
|
{canBanMembers && (
|
||||||
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>Bans</button>
|
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>{t('nav.bans')}</button>
|
||||||
)}
|
)}
|
||||||
{canManageSpace && (
|
{canManageSpace && (
|
||||||
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
||||||
@@ -363,20 +365,20 @@ export function SpaceSettingsModal() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-bubble rounded-lg p-2 space-y-0.5">
|
<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</div>
|
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">{t('nav.general')}</div>
|
||||||
<button onClick={() => handleTabClick('overview')} className={tabClass('overview')}>Overview</button>
|
<button onClick={() => handleTabClick('overview')} className={tabClass('overview')}>{t('nav.overview')}</button>
|
||||||
{canManageSpace && (
|
{canManageSpace && (
|
||||||
<button onClick={() => handleTabClick('discovery')} className={tabClass('discovery')}>Discovery</button>
|
<button onClick={() => handleTabClick('discovery')} className={tabClass('discovery')}>{t('nav.discovery')}</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t border-white/[0.04] my-2 mx-2" />
|
<div className="border-t border-white/[0.04] my-2 mx-2" />
|
||||||
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">Management</div>
|
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">{t('nav.management')}</div>
|
||||||
<button onClick={() => handleTabClick('members')} className={tabClass('members')}>Members</button>
|
<button onClick={() => handleTabClick('members')} className={tabClass('members')}>{t('nav.members')}</button>
|
||||||
{canManageRoles && (
|
{canManageRoles && (
|
||||||
<button onClick={() => handleTabClick('roles')} className={tabClass('roles')}>Roles</button>
|
<button onClick={() => handleTabClick('roles')} className={tabClass('roles')}>{t('nav.roles')}</button>
|
||||||
)}
|
)}
|
||||||
{canBanMembers && (
|
{canBanMembers && (
|
||||||
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>Bans</button>
|
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>{t('nav.bans')}</button>
|
||||||
)}
|
)}
|
||||||
{canManageSpace && (
|
{canManageSpace && (
|
||||||
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
||||||
@@ -398,7 +400,7 @@ export function SpaceSettingsModal() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setMobileView('tabs')}
|
onClick={() => setMobileView('tabs')}
|
||||||
className="flex items-center gap-1.5 text-txt-tertiary hover:text-txt-secondary mb-4 text-sm"
|
className="flex items-center gap-1.5 text-txt-tertiary hover:text-txt-secondary mb-4 text-sm"
|
||||||
aria-label="Back to space settings menu"
|
aria-label={t('nav.back')}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<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" />
|
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||||
|
|||||||
@@ -137,23 +137,23 @@ export function UserSettingsModal() {
|
|||||||
|
|
||||||
{/* Nav list */}
|
{/* Nav list */}
|
||||||
<div className="glass-bubble rounded-lg p-2 flex-1 flex flex-col">
|
<div className="glass-bubble rounded-lg p-2 flex-1 flex flex-col">
|
||||||
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">User Settings</div>
|
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">{t('nav.userSettings')}</div>
|
||||||
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>{t('settings.tab.account')}</button>
|
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>{t('settings.tab.account')}</button>
|
||||||
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>{t('settings.tab.voice')}</button>
|
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>{t('settings.tab.voice')}</button>
|
||||||
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>{t('settings.tab.privacy')}</button>
|
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>{t('settings.tab.privacy')}</button>
|
||||||
|
|
||||||
<div className="border-t border-white/[0.04] my-2 mx-2" />
|
<div className="border-t border-white/[0.04] my-2 mx-2" />
|
||||||
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">App Settings</div>
|
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">{t('nav.appSettings')}</div>
|
||||||
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>{t('settings.tab.connections')}</button>
|
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>{t('settings.tab.connections')}</button>
|
||||||
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>{t('settings.tab.keybinds')}</button>
|
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>{t('settings.tab.keybinds')}</button>
|
||||||
<button onClick={() => handleTabClick('language')} className={tabClass('language')}>{t('settings.tab.language')}</button>
|
<button onClick={() => handleTabClick('language')} className={tabClass('language')}>{t('settings.tab.language')}</button>
|
||||||
{isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>}
|
{isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>{t('settings.desktop.title')}</button>}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<div className="border-t border-white/[0.04] my-2 mx-2" />
|
<div className="border-t border-white/[0.04] my-2 mx-2" />
|
||||||
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">Administration</div>
|
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">{t('nav.administration')}</div>
|
||||||
<button onClick={() => handleTabClick('instance')} className={tabClass('instance')}>Instance</button>
|
<button onClick={() => handleTabClick('instance')} className={tabClass('instance')}>{t('nav.instance')}</button>
|
||||||
{tab === 'instance' && <SidebarSubLinks />}
|
{tab === 'instance' && <SidebarSubLinks />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { Toggle } from '../../ui/Toggle';
|
|||||||
import { VideoSection } from './VideoSection';
|
import { VideoSection } from './VideoSection';
|
||||||
import { AudioInputSection } from './AudioInputSection';
|
import { AudioInputSection } from './AudioInputSection';
|
||||||
import { AudioOutputSection } from './AudioOutputSection';
|
import { AudioOutputSection } from './AudioOutputSection';
|
||||||
|
import { useT } from '../../../i18n';
|
||||||
|
|
||||||
export function VoicePanel() {
|
export function VoicePanel() {
|
||||||
|
const t = useT();
|
||||||
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
|
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
|
||||||
const autoGainControl = useVoiceStore((s) => s.autoGainControl);
|
const autoGainControl = useVoiceStore((s) => s.autoGainControl);
|
||||||
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
|
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
|
||||||
@@ -18,19 +20,19 @@ export function VoicePanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<h2 className="text-lg font-semibold text-txt-primary mb-6">Voice & Video</h2>
|
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('voice.title')}</h2>
|
||||||
|
|
||||||
<AudioInputSection />
|
<AudioInputSection />
|
||||||
<AudioOutputSection />
|
<AudioOutputSection />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
Volume
|
{t('voice.settings.volume')}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
|
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="text-sm text-txt-primary">Sound Effects Volume</div>
|
<div className="text-sm text-txt-primary">{t('voice.settings.sfxVolume')}</div>
|
||||||
<div className="text-xs text-txt-tertiary tabular-nums">{soundEffectVolume}%</div>
|
<div className="text-xs text-txt-tertiary tabular-nums">{soundEffectVolume}%</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -47,9 +49,9 @@ export function VoicePanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-txt-primary">Play sound for every message</div>
|
<div className="text-sm text-txt-primary">{t('voice.settings.messageSound')}</div>
|
||||||
<div className="text-xs text-txt-tertiary">
|
<div className="text-xs text-txt-tertiary">
|
||||||
Off (default): only DMs and messages that mention you. On: every channel.
|
{t('voice.settings.messageSound.description')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Toggle enabled={messageSoundAllChannels} onChange={setMessageSoundAllChannels} />
|
<Toggle enabled={messageSoundAllChannels} onChange={setMessageSoundAllChannels} />
|
||||||
@@ -61,29 +63,29 @@ export function VoicePanel() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
Voice Processing
|
{t('voice.settings.processing')}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
|
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-txt-primary">AI Noise Suppression</div>
|
<div className="text-sm text-txt-primary">{t('voice.settings.noise')}</div>
|
||||||
<div className="text-xs text-txt-tertiary">ML-based noise removal (RNNoise) — filters keyboard, fans, and background noise</div>
|
<div className="text-xs text-txt-tertiary">{t('voice.settings.noise.description')}</div>
|
||||||
</div>
|
</div>
|
||||||
<Toggle enabled={rnnoiseEnabled} onChange={setRnnoiseEnabled} />
|
<Toggle enabled={rnnoiseEnabled} onChange={setRnnoiseEnabled} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-txt-primary">Echo Cancellation</div>
|
<div className="text-sm text-txt-primary">{t('voice.settings.echo')}</div>
|
||||||
<div className="text-xs text-txt-tertiary">Cancels echo from your speakers feeding back into the mic. Always on for voice channels and calls.</div>
|
<div className="text-xs text-txt-tertiary">{t('voice.settings.echo.description')}</div>
|
||||||
</div>
|
</div>
|
||||||
<Toggle enabled={echoCancellation} onChange={setEchoCancellation} />
|
<Toggle enabled={echoCancellation} onChange={setEchoCancellation} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-txt-primary">Auto Gain Control</div>
|
<div className="text-sm text-txt-primary">{t('voice.settings.agc')}</div>
|
||||||
<div className="text-xs text-txt-tertiary">Auto-adjusts mic volume — can cause voice ducking during streams</div>
|
<div className="text-xs text-txt-tertiary">{t('voice.settings.agc.description')}</div>
|
||||||
</div>
|
</div>
|
||||||
<Toggle enabled={autoGainControl} onChange={setAutoGainControl} />
|
<Toggle enabled={autoGainControl} onChange={setAutoGainControl} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTr
|
|||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||||
import { usePortalContainer } from '../../hooks/usePortalContainer';
|
import { usePortalContainer } from '../../hooks/usePortalContainer';
|
||||||
|
import { useT, translate, useLocaleStore, type TranslationKey } from '../../i18n';
|
||||||
|
|
||||||
interface ConnectionInfoPopoverProps {
|
interface ConnectionInfoPopoverProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -34,13 +35,20 @@ function jitterColor(ms: number): string {
|
|||||||
return 'text-txt-danger';
|
return 'text-txt-danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Track labels are built outside the component, so the locale is read at call
|
||||||
|
* time. The popover re-renders on each stats tick, so a language switch shows
|
||||||
|
* through within a second. */
|
||||||
|
function tr(key: TranslationKey): string {
|
||||||
|
return translate(useLocaleStore.getState().locale, key);
|
||||||
|
}
|
||||||
|
|
||||||
function sourceLabel(source: string): string {
|
function sourceLabel(source: string): string {
|
||||||
switch (source) {
|
switch (source) {
|
||||||
case 'microphone': return 'Microphone';
|
case 'microphone': return tr('voice.info.microphone');
|
||||||
case 'camera': return 'Camera';
|
case 'camera': return tr('voice.info.camera');
|
||||||
case 'screen_share': return 'Screen';
|
case 'screen_share': return tr('voice.info.screen');
|
||||||
case 'screen_share_audio': return 'Screen Audio';
|
case 'screen_share_audio': return tr('voice.info.screenAudio');
|
||||||
default: return 'Unknown';
|
default: return tr('voice.info.unknown');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +57,7 @@ function trackLabel(direction: 'send' | 'recv', source: string, participantName:
|
|||||||
if (direction === 'send') {
|
if (direction === 'send') {
|
||||||
return `${sourceLabel(source)} ${arrow}`;
|
return `${sourceLabel(source)} ${arrow}`;
|
||||||
}
|
}
|
||||||
const name = participantName ?? 'Remote';
|
const name = participantName ?? tr('voice.info.remote');
|
||||||
return `${name} ${sourceLabel(source)} ${arrow}`;
|
return `${name} ${sourceLabel(source)} ${arrow}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +126,7 @@ function VideoTrackRow({ track }: { track: VideoTrackStat }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionInfoPopoverProps) {
|
export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionInfoPopoverProps) {
|
||||||
|
const t = useT();
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const portalContainer = usePortalContainer();
|
const portalContainer = usePortalContainer();
|
||||||
const stats = useTrackStats(open);
|
const stats = useTrackStats(open);
|
||||||
@@ -151,36 +160,36 @@ export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionIn
|
|||||||
className="w-[300px] glass rounded-lg overflow-hidden"
|
className="w-[300px] glass rounded-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="px-3 py-2 border-b border-border-hard">
|
<div className="px-3 py-2 border-b border-border-hard">
|
||||||
<span className="text-[14px] font-bold text-txt-primary">Connection Info</span>
|
<span className="text-[14px] font-bold text-txt-primary">{t('voice.info.title')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 py-2 max-h-[calc(100vh-32px)] overflow-y-auto scrollbar-thin">
|
<div className="px-3 py-2 max-h-[calc(100vh-32px)] overflow-y-auto scrollbar-thin">
|
||||||
{!room ? (
|
{!room ? (
|
||||||
<div className="text-[12px] text-txt-tertiary py-2 text-center">Not connected</div>
|
<div className="text-[12px] text-txt-tertiary py-2 text-center">{t('voice.info.notConnected')}</div>
|
||||||
) : !stats ? (
|
) : !stats ? (
|
||||||
<div className="text-[12px] text-txt-tertiary py-2 text-center">Gathering stats...</div>
|
<div className="text-[12px] text-txt-tertiary py-2 text-center">{t('voice.info.gathering')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Network */}
|
{/* Network */}
|
||||||
<SectionHeader title="Network" />
|
<SectionHeader title={t('voice.info.network')} />
|
||||||
<Row
|
<Row
|
||||||
label="Ping"
|
label={t('voice.info.ping')}
|
||||||
value={stats.network.ping !== null ? `${stats.network.ping} ms` : '\u2014'}
|
value={stats.network.ping !== null ? `${stats.network.ping} ms` : '\u2014'}
|
||||||
colorClass={stats.network.ping !== null ? pingColor(stats.network.ping) : undefined}
|
colorClass={stats.network.ping !== null ? pingColor(stats.network.ping) : undefined}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label="Packet Loss"
|
label={t('voice.info.packetLoss')}
|
||||||
value={stats.network.packetLoss !== null ? `${stats.network.packetLoss.toFixed(1)}%` : '\u2014'}
|
value={stats.network.packetLoss !== null ? `${stats.network.packetLoss.toFixed(1)}%` : '\u2014'}
|
||||||
colorClass={stats.network.packetLoss !== null ? lossColor(stats.network.packetLoss) : undefined}
|
colorClass={stats.network.packetLoss !== null ? lossColor(stats.network.packetLoss) : undefined}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label="Jitter"
|
label={t('voice.info.jitter')}
|
||||||
value={stats.network.jitter !== null ? `${stats.network.jitter} ms` : '\u2014'}
|
value={stats.network.jitter !== null ? `${stats.network.jitter} ms` : '\u2014'}
|
||||||
colorClass={stats.network.jitter !== null ? jitterColor(stats.network.jitter) : undefined}
|
colorClass={stats.network.jitter !== null ? jitterColor(stats.network.jitter) : undefined}
|
||||||
/>
|
/>
|
||||||
<Row label="Server" value={stats.network.serverAddress ?? '\u2014'} />
|
<Row label={t('voice.info.server')} value={stats.network.serverAddress ?? '\u2014'} />
|
||||||
<Row
|
<Row
|
||||||
label="Protocol"
|
label={t('voice.info.protocol')}
|
||||||
value={
|
value={
|
||||||
stats.network.protocol
|
stats.network.protocol
|
||||||
? `${stats.network.protocol}${stats.network.candidateType ? ` (${stats.network.candidateType})` : ''}`
|
? `${stats.network.protocol}${stats.network.candidateType ? ` (${stats.network.candidateType})` : ''}`
|
||||||
@@ -192,7 +201,7 @@ export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionIn
|
|||||||
{stats.audioTracks.length > 0 && (
|
{stats.audioTracks.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<SectionHeader title="Audio" />
|
<SectionHeader title={t('voice.info.audio')} />
|
||||||
{stats.audioTracks.map((t) => (
|
{stats.audioTracks.map((t) => (
|
||||||
<AudioTrackRow key={t.key} track={t} />
|
<AudioTrackRow key={t.key} track={t} />
|
||||||
))}
|
))}
|
||||||
@@ -203,7 +212,7 @@ export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionIn
|
|||||||
{stats.videoTracks.length > 0 && (
|
{stats.videoTracks.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<SectionHeader title="Video" />
|
<SectionHeader title={t('voice.info.video')} />
|
||||||
{stats.videoTracks.map((t) => (
|
{stats.videoTracks.map((t) => (
|
||||||
<VideoTrackRow key={t.key} track={t} />
|
<VideoTrackRow key={t.key} track={t} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
|
|||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useDragToClose } from '../../hooks/useDragToClose';
|
import { useDragToClose } from '../../hooks/useDragToClose';
|
||||||
import { VoiceUserRow } from './VoiceUserRow';
|
import { VoiceUserRow } from './VoiceUserRow';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permission state for the in-sheet camera preview. Mirrors the smaller
|
* Permission state for the in-sheet camera preview. Mirrors the smaller
|
||||||
@@ -33,6 +34,7 @@ export function MobileVoiceJoinSheet({
|
|||||||
onJoin,
|
onJoin,
|
||||||
}: MobileVoiceJoinSheetProps) {
|
}: MobileVoiceJoinSheetProps) {
|
||||||
const [preMuted, setPreMuted] = useState(false);
|
const [preMuted, setPreMuted] = useState(false);
|
||||||
|
const t = useT();
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
const voiceUsers = useVoiceStore((s) => s.voiceUsers);
|
const voiceUsers = useVoiceStore((s) => s.voiceUsers);
|
||||||
@@ -284,13 +286,13 @@ export function MobileVoiceJoinSheet({
|
|||||||
setPermState('denied');
|
setPermState('denied');
|
||||||
setPreviewActive(false);
|
setPreviewActive(false);
|
||||||
} else if (name === 'NotReadableError') {
|
} else if (name === 'NotReadableError') {
|
||||||
setPreviewError('Camera is in use by another application.');
|
setPreviewError(t('settings.video.error.inUse'));
|
||||||
} else if (name === 'OverconstrainedError') {
|
} else if (name === 'OverconstrainedError') {
|
||||||
setPreviewError('Selected camera is unavailable.');
|
setPreviewError(t('settings.video.error.unavailable'));
|
||||||
} else if (name === 'NotFoundError') {
|
} else if (name === 'NotFoundError') {
|
||||||
setPreviewError('No camera detected.');
|
setPreviewError(t('settings.video.error.notFound'));
|
||||||
} else {
|
} else {
|
||||||
setPreviewError('Could not start camera preview.');
|
setPreviewError(t('settings.video.error.generic'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -364,13 +366,13 @@ export function MobileVoiceJoinSheet({
|
|||||||
setPermState('denied');
|
setPermState('denied');
|
||||||
setPreviewActive(false);
|
setPreviewActive(false);
|
||||||
} else if (name === 'NotReadableError') {
|
} else if (name === 'NotReadableError') {
|
||||||
setPreviewError('Camera is in use by another application.');
|
setPreviewError(t('settings.video.error.inUse'));
|
||||||
} else if (name === 'OverconstrainedError') {
|
} else if (name === 'OverconstrainedError') {
|
||||||
setPreviewError('Selected camera is unavailable.');
|
setPreviewError(t('settings.video.error.unavailable'));
|
||||||
} else if (name === 'NotFoundError') {
|
} else if (name === 'NotFoundError') {
|
||||||
setPreviewError('No camera detected.');
|
setPreviewError(t('settings.video.error.notFound'));
|
||||||
} else {
|
} else {
|
||||||
setPreviewError('Could not start camera preview.');
|
setPreviewError(t('settings.video.error.generic'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [cameraDeviceId, stopPreview]);
|
}, [cameraDeviceId, stopPreview]);
|
||||||
@@ -387,7 +389,7 @@ export function MobileVoiceJoinSheet({
|
|||||||
const selectedCameraLabel = useMemo(() => {
|
const selectedCameraLabel = useMemo(() => {
|
||||||
if (cameraDeviceId === null) return 'Auto';
|
if (cameraDeviceId === null) return 'Auto';
|
||||||
const d = cameraDevices.find((c) => c.deviceId === cameraDeviceId);
|
const d = cameraDevices.find((c) => c.deviceId === cameraDeviceId);
|
||||||
return d?.label || 'Selected camera';
|
return d?.label || t('voice.join.selectedCamera');
|
||||||
}, [cameraDeviceId, cameraDevices]);
|
}, [cameraDeviceId, cameraDevices]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -457,13 +459,13 @@ export function MobileVoiceJoinSheet({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={startPreviewFromUser}
|
onClick={startPreviewFromUser}
|
||||||
className="absolute inset-0 flex flex-col items-center justify-center text-txt-tertiary active:bg-white/[0.03] transition-colors"
|
className="absolute inset-0 flex flex-col items-center justify-center text-txt-tertiary active:bg-white/[0.03] transition-colors"
|
||||||
aria-label="Enable camera preview"
|
aria-label={t('voice.join.enablePreview')}
|
||||||
>
|
>
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" className="mb-1.5">
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" className="mb-1.5">
|
||||||
<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" />
|
<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>
|
</svg>
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{permState === 'unknown' ? 'Checking camera…' : 'Tap to preview camera'}
|
{t(permState === 'unknown' ? 'voice.join.checkingCamera' : 'voice.join.tapToPreview')}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -471,7 +473,7 @@ export function MobileVoiceJoinSheet({
|
|||||||
{/* Denied state */}
|
{/* Denied state */}
|
||||||
{!previewActive && !previewError && permState === 'denied' && (
|
{!previewActive && !previewError && permState === 'denied' && (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-txt-tertiary px-6 text-center gap-2">
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-txt-tertiary px-6 text-center gap-2">
|
||||||
<span className="text-xs">Camera permission denied.</span>
|
<span className="text-xs">{t('voice.join.permissionDenied')}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startPreviewFromUser}
|
onClick={startPreviewFromUser}
|
||||||
@@ -573,8 +575,8 @@ export function MobileVoiceJoinSheet({
|
|||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{userCount === 0 && (
|
{userCount === 0 && (
|
||||||
<div className="px-5 mb-4 py-3 text-center">
|
<div className="px-5 mb-4 py-3 text-center">
|
||||||
<p className="text-sm text-txt-tertiary">No one is in this channel yet.</p>
|
<p className="text-sm text-txt-tertiary">{t('voice.join.empty')}</p>
|
||||||
<p className="text-xs text-txt-tertiary/60 mt-1">Be the first to join!</p>
|
<p className="text-xs text-txt-tertiary/60 mt-1">{t('voice.join.beFirst')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -584,7 +586,7 @@ export function MobileVoiceJoinSheet({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPreMuted(!preMuted)}
|
onClick={() => setPreMuted(!preMuted)}
|
||||||
className="w-12 h-12 bg-surface-elevated rounded-full flex items-center justify-center text-txt-secondary active:scale-95 transition-transform"
|
className="w-12 h-12 bg-surface-elevated rounded-full flex items-center justify-center text-txt-secondary active:scale-95 transition-transform"
|
||||||
aria-label={preMuted ? 'Unmute microphone' : 'Mute microphone'}
|
aria-label={preMuted ? t('voice.join.unmute') : t('voice.join.mute')}
|
||||||
>
|
>
|
||||||
{preMuted ? (
|
{preMuted ? (
|
||||||
/* Mic off icon */
|
/* Mic off icon */
|
||||||
@@ -607,14 +609,14 @@ export function MobileVoiceJoinSheet({
|
|||||||
onClick={handleJoin}
|
onClick={handleJoin}
|
||||||
className="bg-accent-mint text-black font-semibold rounded-full px-8 py-3 active:scale-95 transition-transform"
|
className="bg-accent-mint text-black font-semibold rounded-full px-8 py-3 active:scale-95 transition-transform"
|
||||||
>
|
>
|
||||||
{isSwitching ? 'Switch Channel' : 'Join Voice'}
|
{t(isSwitching ? 'voice.join.switch' : 'voice.join.join')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-12 h-12 bg-surface-elevated rounded-full flex items-center justify-center text-txt-secondary active:scale-95 transition-transform"
|
className="w-12 h-12 bg-surface-elevated rounded-full flex items-center justify-center text-txt-secondary active:scale-95 transition-transform"
|
||||||
aria-label="Close"
|
aria-label={t('voice.join.close')}
|
||||||
>
|
>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M6 18L18 6M6 6l12 12" />
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
|||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
import { broadcastVoiceStatus } from '../../utils/voice';
|
import { broadcastVoiceStatus } from '../../utils/voice';
|
||||||
import { handleCameraAction } from '../../utils/voiceActions';
|
import { handleCameraAction } from '../../utils/voiceActions';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VoiceControls renders the voice status + button rows.
|
* VoiceControls renders the voice status + button rows.
|
||||||
* It has NO wrapper/card styling — the parent provides the container.
|
* It has NO wrapper/card styling — the parent provides the container.
|
||||||
*/
|
*/
|
||||||
export function VoiceControls() {
|
export function VoiceControls() {
|
||||||
|
const t = useT();
|
||||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||||
const currentVoiceSpaceId = useVoiceStore((s) => s.currentVoiceSpaceId);
|
const currentVoiceSpaceId = useVoiceStore((s) => s.currentVoiceSpaceId);
|
||||||
const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName);
|
const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName);
|
||||||
@@ -55,7 +57,7 @@ export function VoiceControls() {
|
|||||||
|
|
||||||
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
||||||
const channelName =
|
const channelName =
|
||||||
channel?.name ?? currentVoiceChannelName ?? (activeDmCall ? 'DM Call' : 'Voice Channel');
|
channel?.name ?? currentVoiceChannelName ?? (activeDmCall ? t('voice.dmCall') : t('voice.channel'));
|
||||||
|
|
||||||
// Jump back to where the call is happening. DM calls live under @me; space
|
// Jump back to where the call is happening. DM calls live under @me; space
|
||||||
// calls under the space captured at join time.
|
// calls under the space captured at join time.
|
||||||
@@ -147,7 +149,7 @@ export function VoiceControls() {
|
|||||||
if (!showConnectionInfo) setShowScreenShareSettings(false);
|
if (!showConnectionInfo) setShowScreenShareSettings(false);
|
||||||
}}
|
}}
|
||||||
className={`w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0 hover:brightness-125 transition-all`}
|
className={`w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0 hover:brightness-125 transition-all`}
|
||||||
title="Connection Info"
|
title={t('voice.info.title')}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className={qualityColor}>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className={qualityColor}>
|
||||||
<path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z" />
|
<path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z" />
|
||||||
@@ -156,12 +158,12 @@ export function VoiceControls() {
|
|||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
|
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
|
||||||
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
|
{connectionError ? t('voice.failed') : isLiveKitConnected ? t('voice.connected') : t('voice.connecting')}
|
||||||
</div>
|
</div>
|
||||||
{canGoToCall ? (
|
{canGoToCall ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleGoToCall}
|
onClick={handleGoToCall}
|
||||||
title="Go to call"
|
title={t('voice.goToCall')}
|
||||||
className="text-[12px] text-txt-tertiary truncate leading-[16px] w-full text-left hover:text-txt-primary hover:underline transition-colors cursor-pointer"
|
className="text-[12px] text-txt-tertiary truncate leading-[16px] w-full text-left hover:text-txt-primary hover:underline transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
{channelName}
|
{channelName}
|
||||||
@@ -177,7 +179,7 @@ export function VoiceControls() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleDisconnect}
|
onClick={handleDisconnect}
|
||||||
className="w-7 h-7 flex items-center justify-center text-txt-tertiary hover:text-txt-primary transition-colors rounded"
|
className="w-7 h-7 flex items-center justify-center text-txt-tertiary hover:text-txt-primary transition-colors rounded"
|
||||||
title="Disconnect"
|
title={t('voice.disconnect')}
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" />
|
<path d="M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" />
|
||||||
@@ -203,7 +205,7 @@ export function VoiceControls() {
|
|||||||
? 'bg-surface-base text-status-online hover:bg-surface-channel'
|
? 'bg-surface-base text-status-online hover:bg-surface-channel'
|
||||||
: btnDefaultStyle
|
: btnDefaultStyle
|
||||||
}`}
|
}`}
|
||||||
title={isCameraOn ? 'Turn Off Camera' : 'Turn On Camera'}
|
title={t(isCameraOn ? 'voice.camera.off' : 'voice.camera.on')}
|
||||||
>
|
>
|
||||||
{isCameraOn ? (
|
{isCameraOn ? (
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -226,7 +228,7 @@ export function VoiceControls() {
|
|||||||
? 'bg-surface-base text-status-online hover:bg-surface-channel'
|
? 'bg-surface-base text-status-online hover:bg-surface-channel'
|
||||||
: btnDefaultStyle
|
: btnDefaultStyle
|
||||||
}`}
|
}`}
|
||||||
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
|
title={t(isScreenSharing ? 'voice.share.stop' : 'voice.share.start')}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" />
|
<path d="M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" />
|
||||||
@@ -244,7 +246,7 @@ export function VoiceControls() {
|
|||||||
? 'bg-surface-base text-accent-primary hover:bg-surface-channel'
|
? 'bg-surface-base text-accent-primary hover:bg-surface-channel'
|
||||||
: btnDefaultStyle
|
: btnDefaultStyle
|
||||||
}`}
|
}`}
|
||||||
title="Soundboard"
|
title={t('voice.soundboard')}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6Z" />
|
<path d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6Z" />
|
||||||
@@ -264,7 +266,7 @@ export function VoiceControls() {
|
|||||||
? 'bg-surface-base text-accent-primary hover:bg-surface-channel'
|
? 'bg-surface-base text-accent-primary hover:bg-surface-channel'
|
||||||
: btnDefaultStyle
|
: btnDefaultStyle
|
||||||
}`}
|
}`}
|
||||||
title="Video Quality"
|
title={t('voice.videoQuality')}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M3 5v14h18V5H3zm16 12H5V7h14v10z" />
|
<path d="M3 5v14h18V5H3zm16 12H5V7h14v10z" />
|
||||||
@@ -280,7 +282,7 @@ export function VoiceControls() {
|
|||||||
? 'bg-surface-base text-status-online hover:bg-surface-channel'
|
? 'bg-surface-base text-status-online hover:bg-surface-channel'
|
||||||
: btnDefaultStyle
|
: btnDefaultStyle
|
||||||
}`}
|
}`}
|
||||||
title={rnnoiseEnabled ? 'Disable AI Noise Suppression' : 'Enable AI Noise Suppression'}
|
title={t(rnnoiseEnabled ? 'voice.noise.disable' : 'voice.noise.enable')}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" opacity={rnnoiseEnabled ? 0.15 : 0.08} />
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" opacity={rnnoiseEnabled ? 0.15 : 0.08} />
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that calls `t(...)` without declaring `const t = useT()` is a
|
||||||
|
* build error, but it is one you only find after a full `tsc` run — and the
|
||||||
|
* mistake is easy to make in files that hold an exported component plus a
|
||||||
|
* private sub-component, because adding the hook to the exported one looks
|
||||||
|
* like it covered the file. This test names the offending component directly.
|
||||||
|
*
|
||||||
|
* It also flags the reverse: a hook declared above an early `return`, which is
|
||||||
|
* legal, versus below one, which breaks the rules of hooks at runtime rather
|
||||||
|
* than at compile time (React error #310 — this project shipped one once).
|
||||||
|
*/
|
||||||
|
function walk(dir: string): string[] {
|
||||||
|
return readdirSync(dir).flatMap((entry) => {
|
||||||
|
const full = join(dir, entry);
|
||||||
|
if (statSync(full).isDirectory()) return walk(full);
|
||||||
|
return full.endsWith('.tsx') && !full.includes('.test.') ? [full] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const FUNCTION_START = /^(?:export )?function (\w+)\s*[(<]/gm;
|
||||||
|
/** `t(` not preceded by an identifier char, a dot, or a quote. */
|
||||||
|
const USES_T = /[^a-zA-Z_.'`]t\(/;
|
||||||
|
const DECLARES_T = 'const t = useT();';
|
||||||
|
|
||||||
|
describe('useT placement', () => {
|
||||||
|
const files = walk(join(__dirname, '..', 'components'));
|
||||||
|
|
||||||
|
it('every component calling t() declares the hook', () => {
|
||||||
|
const offenders: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const src = readFileSync(file, 'utf8');
|
||||||
|
const starts = [...src.matchAll(FUNCTION_START)];
|
||||||
|
for (let i = 0; i < starts.length; i++) {
|
||||||
|
const from = starts[i]!.index!;
|
||||||
|
const to = i + 1 < starts.length ? starts[i + 1]!.index! : src.length;
|
||||||
|
const body = src.slice(from, to);
|
||||||
|
if (USES_T.test(body) && !body.includes(DECLARES_T)) {
|
||||||
|
offenders.push(`${file.split('/src/')[1]} :: ${starts[i]![1]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the hook is never declared below an early return', () => {
|
||||||
|
const offenders: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const src = readFileSync(file, 'utf8');
|
||||||
|
const starts = [...src.matchAll(FUNCTION_START)];
|
||||||
|
for (let i = 0; i < starts.length; i++) {
|
||||||
|
const from = starts[i]!.index!;
|
||||||
|
const to = i + 1 < starts.length ? starts[i + 1]!.index! : src.length;
|
||||||
|
const body = src.slice(from, to);
|
||||||
|
const hook = body.indexOf(DECLARES_T);
|
||||||
|
if (hook === -1) continue;
|
||||||
|
// An early return at the component's own indentation level, i.e. two
|
||||||
|
// spaces — nested returns inside callbacks are indented further.
|
||||||
|
const earlyReturn = body.search(/\n {2}(?:if \([^\n]*\) )?return /);
|
||||||
|
if (earlyReturn !== -1 && earlyReturn < hook) {
|
||||||
|
offenders.push(`${file.split('/src/')[1]} :: ${starts[i]![1]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -804,6 +804,221 @@ export const en = {
|
|||||||
'instance.general.reset': 'Reset',
|
'instance.general.reset': 'Reset',
|
||||||
'instance.general.saved': 'Settings saved',
|
'instance.general.saved': 'Settings saved',
|
||||||
'instance.general.saveFailed': 'Failed to save',
|
'instance.general.saveFailed': 'Failed to save',
|
||||||
|
// System messages — timeline line and sidebar preview
|
||||||
|
'system.unknown': 'Unknown',
|
||||||
|
'system.someone': 'Someone',
|
||||||
|
'system.aMember': 'A member',
|
||||||
|
'system.memberAdded': '{actor} added {target} to the group',
|
||||||
|
'system.memberLeft': '{target} left the group',
|
||||||
|
'system.memberRemoved': '{actor} removed {target} from the group',
|
||||||
|
'system.ownerChanged': '{owner} is now the group owner',
|
||||||
|
'system.nameChanged': '{actor} renamed the group to "{name}"',
|
||||||
|
'system.nameCleared': '{actor} cleared the group name',
|
||||||
|
'system.iconChanged': '{actor} updated the group icon',
|
||||||
|
'system.preview.inviteTo': 'Sent invite to {space}',
|
||||||
|
'system.preview.invite': 'Sent a space invite',
|
||||||
|
'system.preview.memberAdded': '{actor} added {target}',
|
||||||
|
'system.preview.memberRemoved': '{actor} removed {target}',
|
||||||
|
'system.preview.nameChanged': '{actor} renamed the group',
|
||||||
|
'system.preview.generic': 'System message',
|
||||||
|
// Login, register and the invite landing page
|
||||||
|
'auth.username': 'Username',
|
||||||
|
'auth.password': 'Password',
|
||||||
|
'auth.username.required': 'Username is required',
|
||||||
|
'auth.password.required': 'Password is required',
|
||||||
|
'auth.login.title': 'Welcome back!',
|
||||||
|
'auth.login.subtitle': "We're so excited to see you again!",
|
||||||
|
'auth.login.submit': 'Log In',
|
||||||
|
'auth.login.loggingIn': 'Logging in...',
|
||||||
|
'auth.login.failed': 'Login failed',
|
||||||
|
'auth.login.rateLimited': 'Too many login attempts',
|
||||||
|
'auth.retryIn': 'Try again in {seconds}s',
|
||||||
|
'auth.needAccount': 'Need an account?',
|
||||||
|
'auth.register': 'Register',
|
||||||
|
'auth.haveAccount': 'Already have an account?',
|
||||||
|
'auth.register.title': 'Create an account',
|
||||||
|
'auth.register.confirmPassword': 'Confirm Password',
|
||||||
|
'auth.register.username.length': 'Username must be between 3 and 32 characters',
|
||||||
|
'auth.register.username.charset': 'Username can only contain lowercase letters, numbers, and underscores',
|
||||||
|
'auth.register.username.checking': 'Checking availability...',
|
||||||
|
'auth.register.username.available': 'Username is available',
|
||||||
|
'auth.register.username.taken': 'Username is already taken',
|
||||||
|
'auth.register.password.length': 'Password must be at least 6 characters',
|
||||||
|
'auth.register.password.mismatch': 'Passwords do not match',
|
||||||
|
'auth.register.failed': 'Registration failed',
|
||||||
|
'auth.register.rateLimited': 'Too many attempts',
|
||||||
|
'auth.register.continue': 'Continue',
|
||||||
|
'auth.register.invite': 'Invite code or link',
|
||||||
|
'auth.register.invite.checking': 'Checking...',
|
||||||
|
'auth.register.invite.validating': 'Validating invite...',
|
||||||
|
'auth.register.invite.expired': 'This invite link has expired. Ask the admin for a new one.',
|
||||||
|
'auth.register.invite.exhausted': 'This invite has reached its usage limit. Ask the admin to extend it.',
|
||||||
|
'auth.register.invite.invalid': 'Invalid invite code.',
|
||||||
|
'auth.register.invite.invalidLink': 'Invalid invite link — please request a new one',
|
||||||
|
'auth.register.profile.title': 'Make it yours',
|
||||||
|
'auth.register.profile.subtitle': 'Personalize your profile, or skip for now',
|
||||||
|
'auth.register.displayName': 'Display Name',
|
||||||
|
'auth.register.displayName.placeholder': 'Display name',
|
||||||
|
'auth.register.avatarColor': 'Avatar Color',
|
||||||
|
'auth.register.submit': 'Get Started',
|
||||||
|
'auth.register.creating': 'Creating account...',
|
||||||
|
'auth.register.back': 'Back',
|
||||||
|
'auth.register.skip': 'Skip for now',
|
||||||
|
'auth.register.cropAvatar': 'Crop Avatar',
|
||||||
|
'join.page.loading': 'Loading invite...',
|
||||||
|
'join.page.invalid.title': 'Invalid Invite',
|
||||||
|
'join.page.invalid.body': 'This invite link is invalid or has expired.',
|
||||||
|
'join.page.invitedTo': "You've been invited to join",
|
||||||
|
'join.page.noCode': 'No invite code provided',
|
||||||
|
'join.page.invalidCode': 'Invalid invite code',
|
||||||
|
'join.page.loadFailed': 'Failed to load invite',
|
||||||
|
'join.page.joinFailed': 'Failed to join space',
|
||||||
|
'join.page.connectFailed': 'Failed to connect',
|
||||||
|
'join.page.loginFailed': 'Failed to log in',
|
||||||
|
'join.page.instance.placeholder': 'e.g. my-instance.com',
|
||||||
|
'join.page.alreadyMember': "You're already in {space}!",
|
||||||
|
'join.page.goTo': 'Go to {space}',
|
||||||
|
'join.page.redirecting': 'Redirecting you now...',
|
||||||
|
// Voice — controls, connection info and the mobile join sheet
|
||||||
|
'voice.title': 'Voice & Video',
|
||||||
|
'voice.dmCall': 'DM Call',
|
||||||
|
'voice.channel': 'Voice Channel',
|
||||||
|
'voice.connected': 'Voice Connected',
|
||||||
|
'voice.connecting': 'Connecting...',
|
||||||
|
'voice.failed': 'Connection Failed',
|
||||||
|
'voice.goToCall': 'Go to call',
|
||||||
|
'voice.disconnect': 'Disconnect',
|
||||||
|
'voice.camera.on': 'Turn On Camera',
|
||||||
|
'voice.camera.off': 'Turn Off Camera',
|
||||||
|
'voice.share.start': 'Share Screen',
|
||||||
|
'voice.share.stop': 'Stop Sharing',
|
||||||
|
'voice.soundboard': 'Soundboard',
|
||||||
|
'voice.videoQuality': 'Video Quality',
|
||||||
|
'voice.noise.enable': 'Enable AI Noise Suppression',
|
||||||
|
'voice.noise.disable': 'Disable AI Noise Suppression',
|
||||||
|
'voice.info.title': 'Connection Info',
|
||||||
|
'voice.info.notConnected': 'Not connected',
|
||||||
|
'voice.info.gathering': 'Gathering stats...',
|
||||||
|
'voice.info.network': 'Network',
|
||||||
|
'voice.info.ping': 'Ping',
|
||||||
|
'voice.info.packetLoss': 'Packet Loss',
|
||||||
|
'voice.info.jitter': 'Jitter',
|
||||||
|
'voice.info.server': 'Server',
|
||||||
|
'voice.info.protocol': 'Protocol',
|
||||||
|
'voice.info.audio': 'Audio',
|
||||||
|
'voice.info.video': 'Video',
|
||||||
|
'voice.info.microphone': 'Microphone',
|
||||||
|
'voice.info.camera': 'Camera',
|
||||||
|
'voice.info.screen': 'Screen',
|
||||||
|
'voice.info.screenAudio': 'Screen Audio',
|
||||||
|
'voice.info.unknown': 'Unknown',
|
||||||
|
'voice.info.remote': 'Remote',
|
||||||
|
'voice.join.empty': 'No one is in this channel yet.',
|
||||||
|
'voice.join.beFirst': 'Be the first to join!',
|
||||||
|
'voice.join.mute': 'Mute microphone',
|
||||||
|
'voice.join.unmute': 'Unmute microphone',
|
||||||
|
'voice.join.switch': 'Switch Channel',
|
||||||
|
'voice.join.join': 'Join Voice',
|
||||||
|
'voice.join.close': 'Close',
|
||||||
|
'voice.join.selectedCamera': 'Selected camera',
|
||||||
|
'voice.join.enablePreview': 'Enable camera preview',
|
||||||
|
'voice.join.checkingCamera': 'Checking camera…',
|
||||||
|
'voice.join.tapToPreview': 'Tap to preview camera',
|
||||||
|
'voice.join.permissionDenied': 'Camera permission denied.',
|
||||||
|
'voice.settings.volume': 'Volume',
|
||||||
|
'voice.settings.sfxVolume': 'Sound Effects Volume',
|
||||||
|
'voice.settings.messageSound': 'Play sound for every message',
|
||||||
|
'voice.settings.messageSound.description': 'Off (default): only DMs and messages that mention you. On: every channel.',
|
||||||
|
'voice.settings.processing': 'Voice Processing',
|
||||||
|
'voice.settings.noise': 'AI Noise Suppression',
|
||||||
|
'voice.settings.noise.description': 'ML-based noise removal (RNNoise) — filters keyboard, fans, and background noise',
|
||||||
|
'voice.settings.echo': 'Echo Cancellation',
|
||||||
|
'voice.settings.echo.description': 'Cancels echo from your speakers feeding back into the mic. Always on for voice channels and calls.',
|
||||||
|
'voice.settings.agc': 'Auto Gain Control',
|
||||||
|
'voice.settings.agc.description': 'Auto-adjusts mic volume — can cause voice ducking during streams',
|
||||||
|
// Settings navigation (space, user and instance)
|
||||||
|
'nav.general': 'General',
|
||||||
|
'nav.management': 'Management',
|
||||||
|
'nav.overview': 'Overview',
|
||||||
|
'nav.discovery': 'Discovery',
|
||||||
|
'nav.members': 'Members',
|
||||||
|
'nav.roles': 'Roles',
|
||||||
|
'nav.bans': 'Bans',
|
||||||
|
'nav.userSettings': 'User Settings',
|
||||||
|
'nav.appSettings': 'App Settings',
|
||||||
|
'nav.administration': 'Administration',
|
||||||
|
'nav.instance': 'Instance',
|
||||||
|
'nav.registration': 'Registration',
|
||||||
|
'nav.federation': 'Federation',
|
||||||
|
'nav.streaming': 'Streaming',
|
||||||
|
'nav.storage': 'Storage',
|
||||||
|
'nav.users': 'Users',
|
||||||
|
'nav.back': 'Back to space settings menu',
|
||||||
|
'space.discovery.title': 'Discovery',
|
||||||
|
'space.discovery.visibility': 'Visibility',
|
||||||
|
'space.discovery.visibility.description': 'Control who can discover and join this space.',
|
||||||
|
'space.discovery.description': 'Description',
|
||||||
|
'space.discovery.description.hint': 'A short summary shown on the Explore page.',
|
||||||
|
'space.discovery.description.placeholder': 'A short description for the Explore page...',
|
||||||
|
'space.requests.loading': 'Loading...',
|
||||||
|
'space.requests.empty': 'No pending join requests',
|
||||||
|
'space.requests.accept': 'Accept',
|
||||||
|
'space.requests.decline': 'Decline',
|
||||||
|
'space.requests.failed': 'Action failed',
|
||||||
|
|
||||||
|
// Mobile screens
|
||||||
|
'mobile.messages': 'Messages',
|
||||||
|
'mobile.noConversations': 'No conversations yet.',
|
||||||
|
'mobile.loadingChannels': 'Loading channels',
|
||||||
|
'mobile.noChannels': 'No channels yet.',
|
||||||
|
'mobile.groupInfo': 'Group Info',
|
||||||
|
'mobile.info': 'Info',
|
||||||
|
'mobile.cancelEdit': 'Cancel edit',
|
||||||
|
'mobile.removeGroupIcon': 'Remove group icon',
|
||||||
|
'mobile.federatedGroup': 'Federated group',
|
||||||
|
'mobile.invitePeople': 'Invite People',
|
||||||
|
'mobile.inviteCopied': 'Invite link copied to clipboard',
|
||||||
|
'mobile.inviteFailed': 'Failed to generate invite',
|
||||||
|
'mobile.createFolder': 'Create Folder',
|
||||||
|
'mobile.folderCreated': 'Folder created',
|
||||||
|
'mobile.moveToFolder': 'Move to Folder',
|
||||||
|
'mobile.movedToFolder': 'Moved to folder',
|
||||||
|
'mobile.removeFromFolder': 'Remove from Folder',
|
||||||
|
'mobile.removedFromFolder': 'Removed from folder',
|
||||||
|
'mobile.folderUngrouped': 'Folder ungrouped',
|
||||||
|
'mobile.unnamedFolder': 'Unnamed Folder',
|
||||||
|
'mobile.leaveSpace': 'Leave Space',
|
||||||
|
'mobile.leave': 'Leave',
|
||||||
|
'mobile.leftSpace': 'Left space',
|
||||||
|
'mobile.leaveSpaceFailed': 'Failed to leave space',
|
||||||
|
'mobile.muteUser': 'Mute User',
|
||||||
|
'mobile.channelSettings': 'Channel Settings',
|
||||||
|
'mobile.deleteChannel': 'Delete Channel',
|
||||||
|
'mobile.channelDeleted': 'Channel deleted',
|
||||||
|
'mobile.categorySettings': 'Category Settings',
|
||||||
|
'mobile.deleteCategory': 'Delete Category',
|
||||||
|
'mobile.categoryDeleted': 'Category deleted',
|
||||||
|
'mobile.delete': 'Delete',
|
||||||
|
'mobile.createSpace': 'Create Space',
|
||||||
|
'mobile.joinSpace': 'Join Space',
|
||||||
|
'mobile.exploreSpaces': 'Explore Spaces',
|
||||||
|
'mobile.leaveSpace.confirm': "Are you sure you want to leave this space? You'll need a new invite to rejoin.",
|
||||||
|
'mobile.deleteChannel.confirm': 'Are you sure? This will permanently delete the channel and all its messages.',
|
||||||
|
'mobile.deleteCategory.confirm': 'Are you sure? Channels in this category will be moved to uncategorized.',
|
||||||
|
'mobile.deleteChannelFailed': 'Failed to delete channel',
|
||||||
|
'mobile.voice.title': 'Voice Call',
|
||||||
|
'mobile.voice.collapse': 'Collapse call',
|
||||||
|
'mobile.voice.members': 'View members',
|
||||||
|
'mobile.voice.unmute': 'Unmute',
|
||||||
|
'mobile.voice.deafen': 'Deafen',
|
||||||
|
'mobile.voice.undeafen': 'Undeafen',
|
||||||
|
'mobile.voice.cameraOn': 'Turn camera on',
|
||||||
|
'mobile.voice.cameraOff': 'Turn camera off',
|
||||||
|
'mobile.voice.switchCamera': 'Switch camera',
|
||||||
|
'mobile.voice.selectCamera': 'Select camera',
|
||||||
|
'mobile.voice.shareScreen': 'Share screen',
|
||||||
|
'mobile.voice.stopSharing': 'Stop sharing screen',
|
||||||
|
'mobile.voice.disconnect': 'Disconnect from call',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TranslationKey = keyof typeof en;
|
export type TranslationKey = keyof typeof en;
|
||||||
|
|||||||
@@ -803,4 +803,219 @@ export const ptBR: Partial<Dictionary> = {
|
|||||||
'instance.general.reset': 'Desfazer',
|
'instance.general.reset': 'Desfazer',
|
||||||
'instance.general.saved': 'Configurações salvas',
|
'instance.general.saved': 'Configurações salvas',
|
||||||
'instance.general.saveFailed': 'Não foi possível salvar',
|
'instance.general.saveFailed': 'Não foi possível salvar',
|
||||||
|
// Mensagens de sistema — linha na conversa e prévia na barra lateral
|
||||||
|
'system.unknown': 'Desconhecido',
|
||||||
|
'system.someone': 'Alguém',
|
||||||
|
'system.aMember': 'Um membro',
|
||||||
|
'system.memberAdded': '{actor} adicionou {target} ao grupo',
|
||||||
|
'system.memberLeft': '{target} saiu do grupo',
|
||||||
|
'system.memberRemoved': '{actor} removeu {target} do grupo',
|
||||||
|
'system.ownerChanged': '{owner} agora é dono do grupo',
|
||||||
|
'system.nameChanged': '{actor} mudou o nome do grupo para "{name}"',
|
||||||
|
'system.nameCleared': '{actor} tirou o nome do grupo',
|
||||||
|
'system.iconChanged': '{actor} trocou o ícone do grupo',
|
||||||
|
'system.preview.inviteTo': 'Convite enviado para {space}',
|
||||||
|
'system.preview.invite': 'Convite de servidor enviado',
|
||||||
|
'system.preview.memberAdded': '{actor} adicionou {target}',
|
||||||
|
'system.preview.memberRemoved': '{actor} removeu {target}',
|
||||||
|
'system.preview.nameChanged': '{actor} mudou o nome do grupo',
|
||||||
|
'system.preview.generic': 'Mensagem do sistema',
|
||||||
|
// Login, registro e a página de convite
|
||||||
|
'auth.username': 'Nome de usuário',
|
||||||
|
'auth.password': 'Senha',
|
||||||
|
'auth.username.required': 'O nome de usuário é obrigatório',
|
||||||
|
'auth.password.required': 'A senha é obrigatória',
|
||||||
|
'auth.login.title': 'Que bom te ver!',
|
||||||
|
'auth.login.subtitle': 'A gente estava com saudade!',
|
||||||
|
'auth.login.submit': 'Entrar',
|
||||||
|
'auth.login.loggingIn': 'Entrando...',
|
||||||
|
'auth.login.failed': 'Não foi possível entrar',
|
||||||
|
'auth.login.rateLimited': 'Tentativas de login demais',
|
||||||
|
'auth.retryIn': 'Tente de novo em {seconds}s',
|
||||||
|
'auth.needAccount': 'Ainda não tem conta?',
|
||||||
|
'auth.register': 'Criar conta',
|
||||||
|
'auth.haveAccount': 'Já tem conta?',
|
||||||
|
'auth.register.title': 'Criar uma conta',
|
||||||
|
'auth.register.confirmPassword': 'Confirmar a senha',
|
||||||
|
'auth.register.username.length': 'O nome de usuário precisa ter de 3 a 32 caracteres',
|
||||||
|
'auth.register.username.charset': 'O nome de usuário só aceita letras minúsculas, números e sublinhado',
|
||||||
|
'auth.register.username.checking': 'Verificando se está livre...',
|
||||||
|
'auth.register.username.available': 'Nome de usuário disponível',
|
||||||
|
'auth.register.username.taken': 'Este nome de usuário já está em uso',
|
||||||
|
'auth.register.password.length': 'A senha precisa ter pelo menos 6 caracteres',
|
||||||
|
'auth.register.password.mismatch': 'As senhas não batem',
|
||||||
|
'auth.register.failed': 'Não foi possível criar a conta',
|
||||||
|
'auth.register.rateLimited': 'Tentativas demais',
|
||||||
|
'auth.register.continue': 'Continuar',
|
||||||
|
'auth.register.invite': 'Código ou link de convite',
|
||||||
|
'auth.register.invite.checking': 'Verificando...',
|
||||||
|
'auth.register.invite.validating': 'Validando o convite...',
|
||||||
|
'auth.register.invite.expired': 'Este link de convite expirou. Peça um novo ao administrador.',
|
||||||
|
'auth.register.invite.exhausted': 'Este convite atingiu o limite de usos. Peça ao administrador para aumentar.',
|
||||||
|
'auth.register.invite.invalid': 'Código de convite inválido.',
|
||||||
|
'auth.register.invite.invalidLink': 'Link de convite inválido — peça um novo',
|
||||||
|
'auth.register.profile.title': 'Deixe com a sua cara',
|
||||||
|
'auth.register.profile.subtitle': 'Personalize o perfil, ou faça isso depois',
|
||||||
|
'auth.register.displayName': 'Nome de exibição',
|
||||||
|
'auth.register.displayName.placeholder': 'Nome de exibição',
|
||||||
|
'auth.register.avatarColor': 'Cor da foto',
|
||||||
|
'auth.register.submit': 'Começar',
|
||||||
|
'auth.register.creating': 'Criando a conta...',
|
||||||
|
'auth.register.back': 'Voltar',
|
||||||
|
'auth.register.skip': 'Deixar para depois',
|
||||||
|
'auth.register.cropAvatar': 'Recortar a foto',
|
||||||
|
'join.page.loading': 'Carregando o convite...',
|
||||||
|
'join.page.invalid.title': 'Convite inválido',
|
||||||
|
'join.page.invalid.body': 'Este link de convite é inválido ou expirou.',
|
||||||
|
'join.page.invitedTo': 'Você foi convidado para',
|
||||||
|
'join.page.noCode': 'Nenhum código de convite informado',
|
||||||
|
'join.page.invalidCode': 'Código de convite inválido',
|
||||||
|
'join.page.loadFailed': 'Não foi possível carregar o convite',
|
||||||
|
'join.page.joinFailed': 'Não foi possível entrar no servidor',
|
||||||
|
'join.page.connectFailed': 'Não foi possível conectar',
|
||||||
|
'join.page.loginFailed': 'Não foi possível entrar',
|
||||||
|
'join.page.instance.placeholder': 'ex.: minha-instancia.com',
|
||||||
|
'join.page.alreadyMember': 'Você já está em {space}!',
|
||||||
|
'join.page.goTo': 'Ir para {space}',
|
||||||
|
'join.page.redirecting': 'Redirecionando...',
|
||||||
|
// Voz — controles, informações de conexão e a folha de entrada no celular
|
||||||
|
'voice.title': 'Voz e Vídeo',
|
||||||
|
'voice.dmCall': 'Chamada de DM',
|
||||||
|
'voice.channel': 'Canal de voz',
|
||||||
|
'voice.connected': 'Voz conectada',
|
||||||
|
'voice.connecting': 'Conectando...',
|
||||||
|
'voice.failed': 'Falha na conexão',
|
||||||
|
'voice.goToCall': 'Ir para a call',
|
||||||
|
'voice.disconnect': 'Desconectar',
|
||||||
|
'voice.camera.on': 'Ligar a câmera',
|
||||||
|
'voice.camera.off': 'Desligar a câmera',
|
||||||
|
'voice.share.start': 'Compartilhar tela',
|
||||||
|
'voice.share.stop': 'Parar de compartilhar',
|
||||||
|
'voice.soundboard': 'Soundboard',
|
||||||
|
'voice.videoQuality': 'Qualidade do vídeo',
|
||||||
|
'voice.noise.enable': 'Ligar a supressão de ruído por IA',
|
||||||
|
'voice.noise.disable': 'Desligar a supressão de ruído por IA',
|
||||||
|
'voice.info.title': 'Informações da conexão',
|
||||||
|
'voice.info.notConnected': 'Desconectado',
|
||||||
|
'voice.info.gathering': 'Coletando dados...',
|
||||||
|
'voice.info.network': 'Rede',
|
||||||
|
'voice.info.ping': 'Ping',
|
||||||
|
'voice.info.packetLoss': 'Perda de pacotes',
|
||||||
|
'voice.info.jitter': 'Jitter',
|
||||||
|
'voice.info.server': 'Servidor',
|
||||||
|
'voice.info.protocol': 'Protocolo',
|
||||||
|
'voice.info.audio': 'Áudio',
|
||||||
|
'voice.info.video': 'Vídeo',
|
||||||
|
'voice.info.microphone': 'Microfone',
|
||||||
|
'voice.info.camera': 'Câmera',
|
||||||
|
'voice.info.screen': 'Tela',
|
||||||
|
'voice.info.screenAudio': 'Áudio da tela',
|
||||||
|
'voice.info.unknown': 'Desconhecido',
|
||||||
|
'voice.info.remote': 'Remoto',
|
||||||
|
'voice.join.empty': 'Ninguém neste canal ainda.',
|
||||||
|
'voice.join.beFirst': 'Seja o primeiro a entrar!',
|
||||||
|
'voice.join.mute': 'Desligar o microfone',
|
||||||
|
'voice.join.unmute': 'Ligar o microfone',
|
||||||
|
'voice.join.switch': 'Trocar de canal',
|
||||||
|
'voice.join.join': 'Entrar na voz',
|
||||||
|
'voice.join.close': 'Fechar',
|
||||||
|
'voice.join.selectedCamera': 'Câmera escolhida',
|
||||||
|
'voice.join.enablePreview': 'Ligar a prévia da câmera',
|
||||||
|
'voice.join.checkingCamera': 'Verificando a câmera…',
|
||||||
|
'voice.join.tapToPreview': 'Toque para ver a câmera',
|
||||||
|
'voice.join.permissionDenied': 'Permissão de câmera negada.',
|
||||||
|
'voice.settings.volume': 'Volume',
|
||||||
|
'voice.settings.sfxVolume': 'Volume dos efeitos sonoros',
|
||||||
|
'voice.settings.messageSound': 'Tocar som em toda mensagem',
|
||||||
|
'voice.settings.messageSound.description': 'Desligado (padrão): só DMs e mensagens que mencionam você. Ligado: todos os canais.',
|
||||||
|
'voice.settings.processing': 'Processamento de voz',
|
||||||
|
'voice.settings.noise': 'Supressão de ruído por IA',
|
||||||
|
'voice.settings.noise.description': 'Remoção de ruído por aprendizado de máquina (RNNoise) — filtra teclado, ventilador e barulho de fundo',
|
||||||
|
'voice.settings.echo': 'Cancelamento de eco',
|
||||||
|
'voice.settings.echo.description': 'Cancela o eco do seu som voltando pelo microfone. Sempre ligado em canais de voz e chamadas.',
|
||||||
|
'voice.settings.agc': 'Controle automático de ganho',
|
||||||
|
'voice.settings.agc.description': 'Ajusta o volume do microfone sozinho — pode abaixar a sua voz durante transmissões',
|
||||||
|
// Navegação das configurações (servidor, usuário e instância)
|
||||||
|
'nav.general': 'Geral',
|
||||||
|
'nav.management': 'Gerenciamento',
|
||||||
|
'nav.overview': 'Visão geral',
|
||||||
|
'nav.discovery': 'Descoberta',
|
||||||
|
'nav.members': 'Membros',
|
||||||
|
'nav.roles': 'Cargos',
|
||||||
|
'nav.bans': 'Banimentos',
|
||||||
|
'nav.userSettings': 'Configurações do usuário',
|
||||||
|
'nav.appSettings': 'Configurações do app',
|
||||||
|
'nav.administration': 'Administração',
|
||||||
|
'nav.instance': 'Instância',
|
||||||
|
'nav.registration': 'Registro',
|
||||||
|
'nav.federation': 'Federação',
|
||||||
|
'nav.streaming': 'Transmissão',
|
||||||
|
'nav.storage': 'Armazenamento',
|
||||||
|
'nav.users': 'Usuários',
|
||||||
|
'nav.back': 'Voltar ao menu de configurações do servidor',
|
||||||
|
'space.discovery.title': 'Descoberta',
|
||||||
|
'space.discovery.visibility': 'Visibilidade',
|
||||||
|
'space.discovery.visibility.description': 'Escolha quem pode descobrir e entrar neste servidor.',
|
||||||
|
'space.discovery.description': 'Descrição',
|
||||||
|
'space.discovery.description.hint': 'Um resumo curto que aparece na página do Explorar.',
|
||||||
|
'space.discovery.description.placeholder': 'Uma descrição curta para o Explorar...',
|
||||||
|
'space.requests.loading': 'Carregando...',
|
||||||
|
'space.requests.empty': 'Nenhum pedido de entrada pendente',
|
||||||
|
'space.requests.accept': 'Aceitar',
|
||||||
|
'space.requests.decline': 'Recusar',
|
||||||
|
'space.requests.failed': 'A ação falhou',
|
||||||
|
|
||||||
|
// Telas do celular
|
||||||
|
'mobile.messages': 'Mensagens',
|
||||||
|
'mobile.noConversations': 'Nenhuma conversa ainda.',
|
||||||
|
'mobile.loadingChannels': 'Carregando canais',
|
||||||
|
'mobile.noChannels': 'Nenhum canal ainda.',
|
||||||
|
'mobile.groupInfo': 'Informações do grupo',
|
||||||
|
'mobile.info': 'Informações',
|
||||||
|
'mobile.cancelEdit': 'Cancelar a edição',
|
||||||
|
'mobile.removeGroupIcon': 'Remover o ícone do grupo',
|
||||||
|
'mobile.federatedGroup': 'Grupo federado',
|
||||||
|
'mobile.invitePeople': 'Convidar pessoas',
|
||||||
|
'mobile.inviteCopied': 'Link de convite copiado',
|
||||||
|
'mobile.inviteFailed': 'Não foi possível gerar o convite',
|
||||||
|
'mobile.createFolder': 'Criar pasta',
|
||||||
|
'mobile.folderCreated': 'Pasta criada',
|
||||||
|
'mobile.moveToFolder': 'Mover para pasta',
|
||||||
|
'mobile.movedToFolder': 'Movido para a pasta',
|
||||||
|
'mobile.removeFromFolder': 'Tirar da pasta',
|
||||||
|
'mobile.removedFromFolder': 'Tirado da pasta',
|
||||||
|
'mobile.folderUngrouped': 'Pasta desfeita',
|
||||||
|
'mobile.unnamedFolder': 'Pasta sem nome',
|
||||||
|
'mobile.leaveSpace': 'Sair do servidor',
|
||||||
|
'mobile.leave': 'Sair',
|
||||||
|
'mobile.leftSpace': 'Você saiu do servidor',
|
||||||
|
'mobile.leaveSpaceFailed': 'Não foi possível sair do servidor',
|
||||||
|
'mobile.muteUser': 'Silenciar usuário',
|
||||||
|
'mobile.channelSettings': 'Configurações do canal',
|
||||||
|
'mobile.deleteChannel': 'Excluir canal',
|
||||||
|
'mobile.channelDeleted': 'Canal excluído',
|
||||||
|
'mobile.categorySettings': 'Configurações da categoria',
|
||||||
|
'mobile.deleteCategory': 'Excluir categoria',
|
||||||
|
'mobile.categoryDeleted': 'Categoria excluída',
|
||||||
|
'mobile.delete': 'Excluir',
|
||||||
|
'mobile.createSpace': 'Criar servidor',
|
||||||
|
'mobile.joinSpace': 'Entrar num servidor',
|
||||||
|
'mobile.exploreSpaces': 'Explorar servidores',
|
||||||
|
'mobile.leaveSpace.confirm': 'Quer mesmo sair deste servidor? Você vai precisar de um novo convite para voltar.',
|
||||||
|
'mobile.deleteChannel.confirm': 'Tem certeza? Isto apaga o canal e todas as mensagens dele para sempre.',
|
||||||
|
'mobile.deleteCategory.confirm': 'Tem certeza? Os canais desta categoria vão para o nível de cima.',
|
||||||
|
'mobile.deleteChannelFailed': 'Não foi possível excluir o canal',
|
||||||
|
'mobile.voice.title': 'Chamada de voz',
|
||||||
|
'mobile.voice.collapse': 'Minimizar a chamada',
|
||||||
|
'mobile.voice.members': 'Ver membros',
|
||||||
|
'mobile.voice.unmute': 'Ligar o microfone',
|
||||||
|
'mobile.voice.deafen': 'Desligar o som',
|
||||||
|
'mobile.voice.undeafen': 'Ligar o som',
|
||||||
|
'mobile.voice.cameraOn': 'Ligar a câmera',
|
||||||
|
'mobile.voice.cameraOff': 'Desligar a câmera',
|
||||||
|
'mobile.voice.switchCamera': 'Trocar de câmera',
|
||||||
|
'mobile.voice.selectCamera': 'Escolher a câmera',
|
||||||
|
'mobile.voice.shareScreen': 'Compartilhar a tela',
|
||||||
|
'mobile.voice.stopSharing': 'Parar de compartilhar',
|
||||||
|
'mobile.voice.disconnect': 'Sair da chamada',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import type { DmChannel, DmMessageWithUser, DmLastMessagePreview, User, SpaceInviteSystemPayload } from '@backspace/shared';
|
import type { DmChannel, DmMessageWithUser, DmLastMessagePreview, User, SpaceInviteSystemPayload } from '@backspace/shared';
|
||||||
import { parseFederatedUsername, isSelf } from './identity';
|
import { parseFederatedUsername, isSelf } from './identity';
|
||||||
|
import { translate, useLocaleStore, type TranslationKey } from '../i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain module, not a component — so the locale is read at call time instead of
|
||||||
|
* through the hook. Callers render these previews inside components that
|
||||||
|
* already re-render on the message list changing; a language switch therefore
|
||||||
|
* shows through on the next update rather than instantly.
|
||||||
|
*/
|
||||||
|
function tr(key: TranslationKey, params?: Record<string, string | number>): string {
|
||||||
|
return translate(useLocaleStore.getState().locale, key, params);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── DM Preview Formatting ────────────────────────────────────────────────────
|
// ─── DM Preview Formatting ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -113,9 +124,9 @@ function asString(v: unknown): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveDisplayName(user: User | null | undefined): string {
|
function resolveDisplayName(user: User | null | undefined): string {
|
||||||
if (!user) return 'Unknown';
|
if (!user) return tr('system.unknown');
|
||||||
if (user.displayName) return user.displayName;
|
if (user.displayName) return user.displayName;
|
||||||
return parseFederatedUsername(user.username ?? '').baseName || 'Unknown';
|
return parseFederatedUsername(user.username ?? '').baseName || tr('system.unknown');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,35 +146,35 @@ function formatSystemPreview(content: string | null, actor: User | null | undefi
|
|||||||
case 'space_invite': {
|
case 'space_invite': {
|
||||||
const spaceName = asString((data as Partial<SpaceInviteSystemPayload>).snapshot?.spaceName);
|
const spaceName = asString((data as Partial<SpaceInviteSystemPayload>).snapshot?.spaceName);
|
||||||
return spaceName
|
return spaceName
|
||||||
? `📨 Sent invite to ${spaceName}`
|
? `📨 ${tr('system.preview.inviteTo', { space: spaceName })}`
|
||||||
: '📨 Sent a space invite';
|
: `📨 ${tr('system.preview.invite')}`;
|
||||||
}
|
}
|
||||||
case 'member_added': {
|
case 'member_added': {
|
||||||
const target = asString(data.targetDisplayName) ?? 'someone';
|
const target = asString(data.targetDisplayName) ?? tr('system.someone');
|
||||||
return `${actorName} added ${target}`;
|
return tr('system.preview.memberAdded', { actor: actorName, target });
|
||||||
}
|
}
|
||||||
case 'member_removed': {
|
case 'member_removed': {
|
||||||
const target = asString(data.targetDisplayName) ?? 'someone';
|
const target = asString(data.targetDisplayName) ?? tr('system.someone');
|
||||||
const reason = asString(data.reason);
|
const reason = asString(data.reason);
|
||||||
if (reason === 'leave') return `${target} left the group`;
|
if (reason === 'leave') return tr('system.memberLeft', { target });
|
||||||
return `${actorName} removed ${target}`;
|
return tr('system.preview.memberRemoved', { actor: actorName, target });
|
||||||
}
|
}
|
||||||
case 'owner_changed': {
|
case 'owner_changed': {
|
||||||
const newOwner = asString(data.newOwnerDisplayName) ?? 'A member';
|
const newOwner = asString(data.newOwnerDisplayName) ?? tr('system.aMember');
|
||||||
return `${newOwner} is now the group owner`;
|
return tr('system.ownerChanged', { owner: newOwner });
|
||||||
}
|
}
|
||||||
case 'name_changed': {
|
case 'name_changed': {
|
||||||
// newName === null is a meaningful "cleared" state — distinct from a
|
// newName === null is a meaningful "cleared" state — distinct from a
|
||||||
// missing field — so we check for a non-empty string explicitly.
|
// missing field — so we check for a non-empty string explicitly.
|
||||||
return asString(data.newName)
|
return asString(data.newName)
|
||||||
? `${actorName} renamed the group`
|
? tr('system.preview.nameChanged', { actor: actorName })
|
||||||
: `${actorName} cleared the group name`;
|
: tr('system.nameCleared', { actor: actorName });
|
||||||
}
|
}
|
||||||
case 'icon_changed': {
|
case 'icon_changed': {
|
||||||
return `${actorName} updated the group icon`;
|
return tr('system.iconChanged', { actor: actorName });
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return 'System message';
|
return tr('system.preview.generic');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user