feat(channels): rename channels, and quieter defaults for sound and streams
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
Four owner requests in one pass. Rename: the PATCH /api/channels/:id route already accepted a name and a topic, and the API client already exposed it — the only thing missing was a way to reach it. The channel settings Overview tab now edits both, gated on MANAGE_CHANNELS like the server is, with Save/Discard appearing only when the form is dirty. The server's lowercase-and-hyphenate rule moved to @backspace/shared so the field can show what a name will actually become instead of letting it change shape on save; one copy of the rule, used by both sides. Stream volume: someone else's screen share now starts at 50 instead of 100 (DEFAULT_STREAM_VOLUME). The publisher boosts system audio before sending, so the old default arrived far too loud for a viewer who never touched the slider. Only the default moves — the slider still covers the full range and a value the user set is still persisted. Sound effects: SFX_BASE_VOLUME 0.8 -> 0.4, halving every cue. The .ogg files were left alone; they already peak below full scale, so the harshness was level, not recorded distortion. Every SFX playback path goes through getSfxVolume(), so this is the single knob. Translations: channel settings (including the override editor's permission labels), the friends page end to end — tabs, lists, requests, the add tab and discovery cards — the invite modal including its per-invite failure reasons, and the group-DM member actions. Failure reasons are carried as translation keys rather than finished text, so results already on screen re-label when the language changes. Verified: web build (tsc + vite), server typecheck, 490 web tests, 804 server tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+10
-2
@@ -29,6 +29,9 @@ código e os commits seguem em inglês, como o resto do repositório.
|
|||||||
| Busca: filtros embutidos e tradução | `50a8f12c` | Os filtros já existiam; faltava `de:fulano` e descoberta |
|
| Busca: filtros embutidos e tradução | `50a8f12c` | Os filtros já existiam; faltava `de:fulano` e descoberta |
|
||||||
| Emojis e figurinhas do servidor | `7d003021` | `:nome:` no markdown sem plugin; figurinha como mensagem inteira |
|
| Emojis e figurinhas do servidor | `7d003021` | `:nome:` no markdown sem plugin; figurinha como mensagem inteira |
|
||||||
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
|
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
|
||||||
|
| Renomear canal (nome e tópico) | (ver git log) | Rota `PATCH /api/channels/:id` já existia; faltava interface. Normalização em `shared/constants` para servidor e web usarem a mesma regra |
|
||||||
|
| Volume da tela em 50% por padrão | (ver git log) | `DEFAULT_STREAM_VOLUME`; o publicador reforça o áudio do sistema, então o padrão anterior chegava alto demais |
|
||||||
|
| Sons 50% mais baixos | (ver git log) | Só `SFX_BASE_VOLUME` (0.8 → 0.4); os `.ogg` não foram regerados |
|
||||||
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
||||||
|
|
||||||
## Já existia no código (verificado, não construir de novo)
|
## Já existia no código (verificado, não construir de novo)
|
||||||
@@ -87,11 +90,16 @@ fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
|
|||||||
- Lista de membros (grupos por cargo, carregamento)
|
- Lista de membros (grupos por cargo, carregamento)
|
||||||
- Busca (rótulos, filtros e sintaxe embutida)
|
- Busca (rótulos, filtros e sintaxe embutida)
|
||||||
- Registro de auditoria e Estatísticas (nasceram bilíngues)
|
- Registro de auditoria e Estatísticas (nasceram bilíngues)
|
||||||
|
- Configurações do canal (visão geral, privacidade, exclusão e rótulos de permissão)
|
||||||
|
- Amigos: abas, listas, pedidos, aba de adicionar e cartões de descoberta
|
||||||
|
- Modal de convite (incluindo os motivos de falha por convite)
|
||||||
|
- Lista de membros da DM em grupo (remover e transferir propriedade)
|
||||||
|
|
||||||
### Fila sugerida de tradução
|
### Fila sugerida de tradução
|
||||||
|
|
||||||
Mensagens e composer · lista de membros · servidores e canais · amigos e DMs ·
|
Configurações restantes (conta, vídeo, atalhos, instância) · configurações do
|
||||||
modais de convite · configurações restantes · telas de erro
|
servidor e cargos · telas de erro · explorar servidores · DMs em grupo
|
||||||
|
(`GroupDmSettings`, `AddDmMemberModal`)
|
||||||
|
|
||||||
## Correções pendentes
|
## Correções pendentes
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import type {
|
|||||||
Channel,
|
Channel,
|
||||||
ChannelCategory,
|
ChannelCategory,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
import {
|
||||||
|
normalizeChannelName,
|
||||||
|
CHANNEL_NAME_MIN_LENGTH,
|
||||||
|
CHANNEL_NAME_MAX_LENGTH,
|
||||||
|
} from '@backspace/shared/src/constants.js';
|
||||||
|
|
||||||
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
|
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
|
||||||
return {
|
return {
|
||||||
@@ -190,8 +195,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(400).send({ error: 'Channel name is required', statusCode: 400 });
|
return reply.code(400).send({ error: 'Channel name is required', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
|
const trimmedName = normalizeChannelName(name);
|
||||||
if (trimmedName.length < 1 || trimmedName.length > 100) {
|
if (trimmedName.length < CHANNEL_NAME_MIN_LENGTH || trimmedName.length > CHANNEL_NAME_MAX_LENGTH) {
|
||||||
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
|
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,8 +301,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const updates: Partial<typeof schema.channels.$inferInsert> = {};
|
const updates: Partial<typeof schema.channels.$inferInsert> = {};
|
||||||
|
|
||||||
if (name !== undefined) {
|
if (name !== undefined) {
|
||||||
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
|
const trimmedName = normalizeChannelName(name);
|
||||||
if (trimmedName.length < 1 || trimmedName.length > 100) {
|
if (trimmedName.length < CHANNEL_NAME_MIN_LENGTH || trimmedName.length > CHANNEL_NAME_MAX_LENGTH) {
|
||||||
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
|
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
|
||||||
}
|
}
|
||||||
updates.name = trimmedName;
|
updates.name = trimmedName;
|
||||||
|
|||||||
@@ -45,3 +45,20 @@ export const GROUP_DM_NAME_MAX_LENGTH = 50;
|
|||||||
export const GROUP_DM_NAME_MIN_LENGTH = 1;
|
export const GROUP_DM_NAME_MIN_LENGTH = 1;
|
||||||
export const GROUP_DM_ICON_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
export const GROUP_DM_ICON_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
||||||
export const GROUP_DM_ICON_MIME_PREFIX = 'image/';
|
export const GROUP_DM_ICON_MIME_PREFIX = 'image/';
|
||||||
|
|
||||||
|
// ─── Channel Names ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CHANNEL_NAME_MIN_LENGTH = 1;
|
||||||
|
export const CHANNEL_NAME_MAX_LENGTH = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical channel-name form: trimmed, lowercased, runs of whitespace folded
|
||||||
|
* into single hyphens.
|
||||||
|
*
|
||||||
|
* The server applies this before storing, so the client uses the same function
|
||||||
|
* to show what a name will actually become. Two copies of the rule would drift;
|
||||||
|
* one that lives here cannot.
|
||||||
|
*/
|
||||||
|
export function normalizeChannelName(name: string): string {
|
||||||
|
return name.trim().toLowerCase().replace(/\s+/g, '-');
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils
|
|||||||
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
||||||
import { Username } from '../ui/Username';
|
import { Username } from '../ui/Username';
|
||||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||||
|
import { useT, type TranslationKey } from '../../i18n';
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = { online: 'Online', idle: 'Idle', dnd: 'Do Not Disturb', offline: 'Offline' };
|
const statusLabelKey: Record<string, TranslationKey> = {
|
||||||
|
online: 'friends.status.online',
|
||||||
|
idle: 'friends.status.idle',
|
||||||
|
dnd: 'friends.status.dnd',
|
||||||
|
offline: 'friends.status.offline',
|
||||||
|
};
|
||||||
|
|
||||||
function ActivityFriendItem({
|
function ActivityFriendItem({
|
||||||
friend,
|
friend,
|
||||||
@@ -98,6 +104,7 @@ interface FriendsPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FriendsPage({ mobile }: FriendsPageProps) {
|
export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||||
|
const t = useT();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('online');
|
const [activeTab, setActiveTab] = useState<Tab>('online');
|
||||||
const [pendingUnfriend, setPendingUnfriend] = useState<{ id: string; name: string } | null>(null);
|
const [pendingUnfriend, setPendingUnfriend] = useState<{ id: string; name: string } | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -172,12 +179,12 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<h2 className="text-xs font-bold text-txt-tertiary mb-4 tracking-wider px-2">
|
<h2 className="text-xs font-bold text-txt-tertiary mb-4 tracking-wider px-2">
|
||||||
Online — {onlineFriends.length}
|
{t('friends.heading.online', { count: onlineFriends.length })}
|
||||||
</h2>
|
</h2>
|
||||||
{onlineFriends.length === 0 ? (
|
{onlineFriends.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full opacity-80">
|
<div className="flex flex-col items-center justify-center h-full opacity-80">
|
||||||
<Mascot state="idle" className="w-32 h-32 mb-4" />
|
<Mascot state="idle" className="w-32 h-32 mb-4" />
|
||||||
<p className="text-txt-tertiary text-sm">No one's online right now.</p>
|
<p className="text-txt-tertiary text-sm">{t('friends.empty.online')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -192,12 +199,12 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<h2 className="text-xs font-bold text-txt-tertiary mb-4 tracking-wider px-2">
|
<h2 className="text-xs font-bold text-txt-tertiary mb-4 tracking-wider px-2">
|
||||||
All Friends — {friends.length}
|
{t('friends.heading.all', { count: friends.length })}
|
||||||
</h2>
|
</h2>
|
||||||
{friends.length === 0 ? (
|
{friends.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full opacity-80">
|
<div className="flex flex-col items-center justify-center h-full opacity-80">
|
||||||
<Mascot state="lonely" className="w-32 h-32 mb-4" />
|
<Mascot state="lonely" className="w-32 h-32 mb-4" />
|
||||||
<p className="text-txt-tertiary text-sm">No friends yet — add someone!</p>
|
<p className="text-txt-tertiary text-sm">{t('friends.empty.all')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -212,12 +219,12 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<h2 className="text-xs font-bold text-txt-tertiary mb-4 tracking-wider px-2">
|
<h2 className="text-xs font-bold text-txt-tertiary mb-4 tracking-wider px-2">
|
||||||
Pending — {pendingIncoming.length + pendingOutgoing.length}
|
{t('friends.heading.pending', { count: pendingIncoming.length + pendingOutgoing.length })}
|
||||||
</h2>
|
</h2>
|
||||||
{[...pendingIncoming, ...pendingOutgoing].length === 0 ? (
|
{[...pendingIncoming, ...pendingOutgoing].length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full opacity-80">
|
<div className="flex flex-col items-center justify-center h-full opacity-80">
|
||||||
<Mascot state="sleeping" className="w-32 h-32 mb-4" />
|
<Mascot state="sleeping" className="w-32 h-32 mb-4" />
|
||||||
<p className="text-txt-tertiary text-sm">No pending requests — Nori is napping.</p>
|
<p className="text-txt-tertiary text-sm">{t('friends.empty.pending')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -292,7 +299,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<Mascot state="sleeping" className="w-[100px] h-[100px]" />
|
<Mascot state="sleeping" className="w-[100px] h-[100px]" />
|
||||||
<div className="text-sm text-txt-tertiary mt-4 max-w-[240px]">
|
<div className="text-sm text-txt-tertiary mt-4 max-w-[240px]">
|
||||||
It's quiet for now... When friends start an activity, we'll show it here!
|
{t('friends.empty.activity')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -300,7 +307,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
{activeFriends.length > 0 && (
|
{activeFriends.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h2 className="text-xs font-bold text-txt-tertiary mb-2 tracking-wider px-2">
|
<h2 className="text-xs font-bold text-txt-tertiary mb-2 tracking-wider px-2">
|
||||||
Active — {activeFriends.length}
|
{t('friends.heading.active', { count: activeFriends.length })}
|
||||||
</h2>
|
</h2>
|
||||||
{activeFriends.map(f => renderActivityFriend(f))}
|
{activeFriends.map(f => renderActivityFriend(f))}
|
||||||
</div>
|
</div>
|
||||||
@@ -308,7 +315,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
{idleFriends.length > 0 && (
|
{idleFriends.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h2 className="text-xs font-bold text-txt-tertiary mb-2 tracking-wider px-2">
|
<h2 className="text-xs font-bold text-txt-tertiary mb-2 tracking-wider px-2">
|
||||||
Online — {idleFriends.length}
|
{t('friends.heading.online', { count: idleFriends.length })}
|
||||||
</h2>
|
</h2>
|
||||||
{idleFriends.map(f => renderActivityFriend(f))}
|
{idleFriends.map(f => renderActivityFriend(f))}
|
||||||
</div>
|
</div>
|
||||||
@@ -316,7 +323,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
{offlineActivityFriends.length > 0 && (
|
{offlineActivityFriends.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xs font-bold text-txt-tertiary mb-2 tracking-wider px-2">
|
<h2 className="text-xs font-bold text-txt-tertiary mb-2 tracking-wider px-2">
|
||||||
Offline — {offlineActivityFriends.length}
|
{t('friends.heading.offline', { count: offlineActivityFriends.length })}
|
||||||
</h2>
|
</h2>
|
||||||
{offlineActivityFriends.map(f => renderActivityFriend(f, true))}
|
{offlineActivityFriends.map(f => renderActivityFriend(f, true))}
|
||||||
</div>
|
</div>
|
||||||
@@ -341,7 +348,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span className="font-semibold text-sm text-txt-primary">Friends</span>
|
<span className="font-semibold text-sm text-txt-primary">{t('friends.title')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-14 px-4 flex items-center border-b border-border-hard flex-shrink-0 z-10 bg-surface-chat">
|
<div className="h-14 px-4 flex items-center border-b border-border-hard flex-shrink-0 z-10 bg-surface-chat">
|
||||||
@@ -349,14 +356,14 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
||||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-bold text-txt-primary">Friends</span>
|
<span className="font-bold text-txt-primary">{t('friends.title')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[1px] h-6 bg-surface-elevated mx-2" />
|
<div className="w-[1px] h-6 bg-surface-elevated mx-2" />
|
||||||
<div className="flex items-center gap-4 ml-2">
|
<div className="flex items-center gap-4 ml-2">
|
||||||
<TabButton active={activeTab === 'online'} onClick={() => setActiveTab('online')}>Online</TabButton>
|
<TabButton active={activeTab === 'online'} onClick={() => setActiveTab('online')}>{t('friends.tab.online')}</TabButton>
|
||||||
<TabButton active={activeTab === 'all'} onClick={() => setActiveTab('all')}>All</TabButton>
|
<TabButton active={activeTab === 'all'} onClick={() => setActiveTab('all')}>{t('friends.tab.all')}</TabButton>
|
||||||
<TabButton active={activeTab === 'pending'} onClick={() => setActiveTab('pending')}>
|
<TabButton active={activeTab === 'pending'} onClick={() => setActiveTab('pending')}>
|
||||||
Pending
|
{t('friends.tab.pending')}
|
||||||
{(pendingIncoming.length > 0) && (
|
{(pendingIncoming.length > 0) && (
|
||||||
<span className="ml-2 px-1.5 py-0.5 bg-accent-rose text-white text-[10px] rounded-full leading-none">
|
<span className="ml-2 px-1.5 py-0.5 bg-accent-rose text-white text-[10px] rounded-full leading-none">
|
||||||
{pendingIncoming.length}
|
{pendingIncoming.length}
|
||||||
@@ -369,7 +376,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
activeTab === 'add' ? 'text-status-online bg-transparent' : 'bg-status-online text-[#13131a] hover:bg-status-online/90'
|
activeTab === 'add' ? 'text-status-online bg-transparent' : 'bg-status-online text-[#13131a] hover:bg-status-online/90'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Add Friend
|
{t('friends.tab.add')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
@@ -391,7 +398,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
: 'border-transparent text-txt-secondary hover:text-txt-primary'
|
: 'border-transparent text-txt-secondary hover:text-txt-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab === 'add' ? 'Add Friend' : tab === 'activity' ? 'Activity' : tab.charAt(0).toUpperCase() + tab.slice(1)}
|
{t(`friends.tab.${tab}` as TranslationKey)}
|
||||||
{tab === 'pending' && pendingIncoming.length > 0 && (
|
{tab === 'pending' && pendingIncoming.length > 0 && (
|
||||||
<span className="ml-1.5 px-1.5 py-0.5 text-xs bg-notification text-white rounded-full">
|
<span className="ml-1.5 px-1.5 py-0.5 text-xs bg-notification text-white rounded-full">
|
||||||
{pendingIncoming.length}
|
{pendingIncoming.length}
|
||||||
@@ -413,9 +420,11 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
setPendingUnfriend(null);
|
setPendingUnfriend(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Remove Friend"
|
title={t('friends.remove.title')}
|
||||||
description={`Are you sure you want to remove ${pendingUnfriend?.name ?? 'this user'} as a friend? You can always send them a new friend request later.`}
|
description={t('friends.remove.body', {
|
||||||
confirmLabel="Remove"
|
name: pendingUnfriend?.name ?? t('friends.remove.someone'),
|
||||||
|
})}
|
||||||
|
confirmLabel={t('friends.remove.confirm')}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -429,6 +438,7 @@ function AddFriendTab({
|
|||||||
}: {
|
}: {
|
||||||
onOpenDm: (userId: string, homeUserId?: string, homeInstance?: string | null) => void;
|
onOpenDm: (userId: string, homeUserId?: string, homeInstance?: string | null) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useT();
|
||||||
const searchUsers = useSocialStore((s) => s.searchUsers);
|
const searchUsers = useSocialStore((s) => s.searchUsers);
|
||||||
const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest);
|
const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest);
|
||||||
const friends = useSocialStore((s) => s.friends);
|
const friends = useSocialStore((s) => s.friends);
|
||||||
@@ -527,7 +537,7 @@ function AddFriendTab({
|
|||||||
setDirectAddLoading(true);
|
setDirectAddLoading(true);
|
||||||
try {
|
try {
|
||||||
await sendFriendRequest(query.trim());
|
await sendFriendRequest(query.trim());
|
||||||
addToast('Friend request sent!', 'success');
|
addToast(t('friends.add.sent'), 'success');
|
||||||
setQuery('');
|
setQuery('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// The shared API client throws `new Error(body.error)` for non-2xx
|
// The shared API client throws `new Error(body.error)` for non-2xx
|
||||||
@@ -548,8 +558,8 @@ function AddFriendTab({
|
|||||||
const displayUsers = isSearchMode ? enrichedSearchResults : discoverUsers;
|
const displayUsers = isSearchMode ? enrichedSearchResults : discoverUsers;
|
||||||
const displayLoading = isSearchMode ? searchLoading : discoverLoading;
|
const displayLoading = isSearchMode ? searchLoading : discoverLoading;
|
||||||
const emptyLabel = isSearchMode
|
const emptyLabel = isSearchMode
|
||||||
? 'No users match your search.'
|
? t('friends.add.empty.search')
|
||||||
: 'No discoverable users yet — invite people to join!';
|
: t('friends.add.empty.discover');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
@@ -559,15 +569,29 @@ function AddFriendTab({
|
|||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
||||||
<path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
<path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
</svg>
|
</svg>
|
||||||
<h2 className="text-base font-bold text-txt-primary">Find People</h2>
|
<h2 className="text-base font-bold text-txt-primary">{t('friends.add.title')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-txt-tertiary mb-4">Search by username or use <span className="font-medium text-txt-secondary">user@instance</span> to add someone directly.</p>
|
<p className="text-sm text-txt-tertiary mb-4">
|
||||||
|
{/* The `user@instance` example is emphasised, so the sentence is
|
||||||
|
split around its placeholder rather than interpolated as text —
|
||||||
|
translations keep control of the word order either side of it. */}
|
||||||
|
{(() => {
|
||||||
|
const [before, after] = t('friends.add.hint').split('{handle}');
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{before}
|
||||||
|
<span className="font-medium text-txt-secondary">user@instance</span>
|
||||||
|
{after}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Unified search input */}
|
{/* Unified search input */}
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search or add by username..."
|
placeholder={t('friends.add.searchPlaceholder')}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
className="input-search w-full px-4 py-3 rounded-lg"
|
className="input-search w-full px-4 py-3 rounded-lg"
|
||||||
@@ -591,14 +615,14 @@ function AddFriendTab({
|
|||||||
<path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
<path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div className="flex-1 min-w-0 text-sm text-txt-secondary">
|
<div className="flex-1 min-w-0 text-sm text-txt-secondary">
|
||||||
Send friend request to <span className="font-semibold text-txt-primary">{directAddDisplay}</span>
|
{t('friends.add.directTo')} <span className="font-semibold text-txt-primary">{directAddDisplay}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleDirectAdd}
|
onClick={handleDirectAdd}
|
||||||
disabled={directAddLoading}
|
disabled={directAddLoading}
|
||||||
className="px-3 py-1.5 rounded-md bg-accent-primary hover:bg-accent-primary-hover text-white text-sm font-medium transition-colors disabled:opacity-50 flex-shrink-0"
|
className="px-3 py-1.5 rounded-md bg-accent-primary hover:bg-accent-primary-hover text-white text-sm font-medium transition-colors disabled:opacity-50 flex-shrink-0"
|
||||||
>
|
>
|
||||||
{directAddLoading ? 'Sending...' : 'Send Request'}
|
{directAddLoading ? t('friends.add.sending') : t('friends.add.send')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -611,7 +635,7 @@ function AddFriendTab({
|
|||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z" />
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-xs font-semibold tracking-wider text-txt-tertiary">Discover People</span>
|
<span className="text-xs font-semibold tracking-wider text-txt-tertiary">{t('friends.add.discover')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -665,6 +689,7 @@ function UserDiscoverCard({
|
|||||||
onOpenDm: (userId: string, homeUserId?: string, homeInstance?: string | null) => void;
|
onOpenDm: (userId: string, homeUserId?: string, homeInstance?: string | null) => void;
|
||||||
onRelationshipChange: (userId: string, origin: string, relationship: TaggedDiscoverUser['relationship'], requestId?: string) => void;
|
onRelationshipChange: (userId: string, origin: string, relationship: TaggedDiscoverUser['relationship'], requestId?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useT();
|
||||||
const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest);
|
const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest);
|
||||||
const updateFriendRequest = useSocialStore((s) => s.updateFriendRequest);
|
const updateFriendRequest = useSocialStore((s) => s.updateFriendRequest);
|
||||||
const openModal = useUIStore((s) => s.openModal);
|
const openModal = useUIStore((s) => s.openModal);
|
||||||
@@ -695,7 +720,7 @@ function UserDiscoverCard({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// See handleDirectAdd above — err.message is the server error code.
|
// See handleDirectAdd above — err.message is the server error code.
|
||||||
const code = err instanceof Error ? err.message : undefined;
|
const code = err instanceof Error ? err.message : undefined;
|
||||||
setError(mapServerErrorToMessage(code, code ?? 'Failed to send request', username));
|
setError(mapServerErrorToMessage(code, code ?? t('friends.add.failed'), username));
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
}
|
}
|
||||||
@@ -709,7 +734,7 @@ function UserDiscoverCard({
|
|||||||
await updateFriendRequest(user.requestId, 'accepted');
|
await updateFriendRequest(user.requestId, 'accepted');
|
||||||
onRelationshipChange(user.id, user._instanceOrigin, 'friends');
|
onRelationshipChange(user.id, user._instanceOrigin, 'friends');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to accept request');
|
setError(err instanceof Error ? err.message : t('friends.accept.failed'));
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
}
|
}
|
||||||
@@ -723,7 +748,7 @@ function UserDiscoverCard({
|
|||||||
await updateFriendRequest(user.requestId, 'declined');
|
await updateFriendRequest(user.requestId, 'declined');
|
||||||
onRelationshipChange(user.id, user._instanceOrigin, 'none');
|
onRelationshipChange(user.id, user._instanceOrigin, 'none');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to decline request');
|
setError(err instanceof Error ? err.message : t('friends.decline.failed'));
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
}
|
}
|
||||||
@@ -741,7 +766,7 @@ function UserDiscoverCard({
|
|||||||
await client.social.cancelRequest(user.requestId);
|
await client.social.cancelRequest(user.requestId);
|
||||||
onRelationshipChange(user.id, user._instanceOrigin, 'none');
|
onRelationshipChange(user.id, user._instanceOrigin, 'none');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to cancel request');
|
setError(err instanceof Error ? err.message : t('friends.cancel.failed'));
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
}
|
}
|
||||||
@@ -840,7 +865,7 @@ function UserDiscoverCard({
|
|||||||
disabled={actionLoading}
|
disabled={actionLoading}
|
||||||
className="w-full py-1.5 bg-accent-primary hover:bg-accent-primary-hover text-white text-[13px] font-medium rounded transition-colors disabled:opacity-50"
|
className="w-full py-1.5 bg-accent-primary hover:bg-accent-primary-hover text-white text-[13px] font-medium rounded transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{actionLoading ? 'Sending...' : 'Send Friend Request'}
|
{actionLoading ? t('friends.add.sending') : t('friends.add.sendRequest')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{user.relationship === 'outbound_pending' && (
|
{user.relationship === 'outbound_pending' && (
|
||||||
@@ -849,7 +874,7 @@ function UserDiscoverCard({
|
|||||||
disabled={actionLoading || !user.requestId}
|
disabled={actionLoading || !user.requestId}
|
||||||
className="w-full py-1.5 bg-accent-amber/15 hover:bg-accent-amber/25 text-accent-amber text-[13px] font-medium rounded transition-colors disabled:opacity-50"
|
className="w-full py-1.5 bg-accent-amber/15 hover:bg-accent-amber/25 text-accent-amber text-[13px] font-medium rounded transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{actionLoading ? 'Cancelling...' : 'Request Pending'}
|
{actionLoading ? t('friends.add.cancelling') : t('friends.add.pending')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{user.relationship === 'inbound_pending' && (
|
{user.relationship === 'inbound_pending' && (
|
||||||
@@ -899,6 +924,7 @@ function TabButton({ children, active, onClick }: { children: React.ReactNode, a
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove: () => void, onDm: () => void }) {
|
function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove: () => void, onDm: () => void }) {
|
||||||
|
const t = useT();
|
||||||
const canonical = useCanonicalUserView(friend as unknown as User);
|
const canonical = useCanonicalUserView(friend as unknown as User);
|
||||||
const instanceLabel = friend._instanceOrigin ? (() => { try { return new URL(friend._instanceOrigin).host; } catch { return friend._instanceOrigin; } })() : '';
|
const instanceLabel = friend._instanceOrigin ? (() => { try { return new URL(friend._instanceOrigin).host; } catch { return friend._instanceOrigin; } })() : '';
|
||||||
const { baseName: friendBaseName } = parseFederatedUsername(canonical.username);
|
const { baseName: friendBaseName } = parseFederatedUsername(canonical.username);
|
||||||
@@ -913,7 +939,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove
|
|||||||
<span className="text-txt-tertiary text-[13px] opacity-60 group-hover:opacity-100 transition-opacity font-medium">@{friend.username}</span>
|
<span className="text-txt-tertiary text-[13px] opacity-60 group-hover:opacity-100 transition-opacity font-medium">@{friend.username}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[12px] text-txt-tertiary font-medium">{statusLabel[friend.status] ?? friend.status}</span>
|
<span className="text-[12px] text-txt-tertiary font-medium">{statusLabelKey[friend.status] ? t(statusLabelKey[friend.status]!) : friend.status}</span>
|
||||||
{instanceLabel && (
|
{instanceLabel && (
|
||||||
<span className="text-[11px] text-txt-tertiary/60 font-medium">via {instanceLabel}</span>
|
<span className="text-[11px] text-txt-tertiary/60 font-medium">via {instanceLabel}</span>
|
||||||
)}
|
)}
|
||||||
@@ -924,7 +950,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onDm(); }}
|
onClick={(e) => { e.stopPropagation(); onDm(); }}
|
||||||
className="w-9 h-9 flex items-center justify-center bg-surface-base rounded-full text-txt-tertiary hover:text-txt-primary transition-colors"
|
className="w-9 h-9 flex items-center justify-center bg-surface-base rounded-full text-txt-tertiary hover:text-txt-primary transition-colors"
|
||||||
title="Message"
|
title={t('friends.action.message')}
|
||||||
>
|
>
|
||||||
<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 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-5H6V7h12v2z" />
|
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-5H6V7h12v2z" />
|
||||||
@@ -933,7 +959,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||||
className="w-9 h-9 flex items-center justify-center bg-surface-base rounded-full text-txt-tertiary hover:text-txt-danger transition-colors"
|
className="w-9 h-9 flex items-center justify-center bg-surface-base rounded-full text-txt-tertiary hover:text-txt-danger transition-colors"
|
||||||
title="Remove Friend"
|
title={t('friends.action.remove')}
|
||||||
>
|
>
|
||||||
<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="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" />
|
||||||
@@ -951,6 +977,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
|||||||
onDecline?: () => void;
|
onDecline?: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useT();
|
||||||
const rawUser = request.user;
|
const rawUser = request.user;
|
||||||
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;
|
||||||
const canonicalUser = useCanonicalUserView((rawUser as unknown as User | null) ?? _FALLBACK_USER);
|
const canonicalUser = useCanonicalUserView((rawUser as unknown as User | null) ?? _FALLBACK_USER);
|
||||||
@@ -970,7 +997,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
|||||||
<span className="text-txt-tertiary text-xs">@{user.username}</span>
|
<span className="text-txt-tertiary text-xs">@{user.username}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs text-txt-tertiary">{type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'}</span>
|
<span className="text-xs text-txt-tertiary">{t(type === 'incoming' ? 'friends.request.incoming' : 'friends.request.outgoing')}</span>
|
||||||
{instanceLabel && (
|
{instanceLabel && (
|
||||||
<span className="text-[11px] text-txt-tertiary/60 font-medium">via {instanceLabel}</span>
|
<span className="text-[11px] text-txt-tertiary/60 font-medium">via {instanceLabel}</span>
|
||||||
)}
|
)}
|
||||||
@@ -983,7 +1010,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
|||||||
<button
|
<button
|
||||||
onClick={() => onAccept?.()}
|
onClick={() => onAccept?.()}
|
||||||
className="p-2 bg-surface-base rounded-full text-status-online hover:bg-status-online hover:text-white transition-all"
|
className="p-2 bg-surface-base rounded-full text-status-online hover:bg-status-online hover:text-white transition-all"
|
||||||
title="Accept"
|
title={t('friends.action.accept')}
|
||||||
>
|
>
|
||||||
<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="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" />
|
||||||
@@ -992,7 +1019,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
|||||||
<button
|
<button
|
||||||
onClick={() => onDecline?.()}
|
onClick={() => onDecline?.()}
|
||||||
className="p-2 bg-surface-base rounded-full text-txt-danger hover:bg-accent-rose hover:text-white transition-all"
|
className="p-2 bg-surface-base rounded-full text-txt-danger hover:bg-accent-rose hover:text-white transition-all"
|
||||||
title="Decline"
|
title={t('friends.action.decline')}
|
||||||
>
|
>
|
||||||
<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="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" />
|
||||||
@@ -1003,7 +1030,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
|||||||
<button
|
<button
|
||||||
onClick={() => onCancel?.()}
|
onClick={() => onCancel?.()}
|
||||||
className="p-2 bg-surface-base rounded-full text-txt-tertiary hover:text-txt-danger transition-all"
|
className="p-2 bg-surface-base rounded-full text-txt-tertiary hover:text-txt-danger transition-all"
|
||||||
title="Cancel Request"
|
title={t('friends.action.cancel')}
|
||||||
>
|
>
|
||||||
<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="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" />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { api } from '../../api/client';
|
|||||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||||
import { DmMemberRow, type DmMemberRowAction } from './DmMemberRow';
|
import { DmMemberRow, type DmMemberRowAction } from './DmMemberRow';
|
||||||
import { pointAnchor } from '../../hooks/useFloatingPosition';
|
import { pointAnchor } from '../../hooks/useFloatingPosition';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Right-side roster for group DMs. Mirrors `MemberSidebar`'s layout language
|
* Right-side roster for group DMs. Mirrors `MemberSidebar`'s layout language
|
||||||
@@ -44,6 +45,7 @@ export function DmRosterPanel() {
|
|||||||
const [pendingKick, setPendingKick] = useState<User | null>(null);
|
const [pendingKick, setPendingKick] = useState<User | null>(null);
|
||||||
const [pendingTransfer, setPendingTransfer] = useState<User | null>(null);
|
const [pendingTransfer, setPendingTransfer] = useState<User | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
// ── Visibility gates ────────────────────────────────────────────────────
|
// ── Visibility gates ────────────────────────────────────────────────────
|
||||||
// Render only when:
|
// Render only when:
|
||||||
@@ -110,7 +112,7 @@ export function DmRosterPanel() {
|
|||||||
await removeFriendStore(member.id);
|
await removeFriendStore(member.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(
|
addToast(
|
||||||
err instanceof Error ? err.message : 'Failed to remove friend',
|
err instanceof Error ? err.message : t('dm.removeFriend.failed'),
|
||||||
'warning',
|
'warning',
|
||||||
3000,
|
3000,
|
||||||
);
|
);
|
||||||
@@ -141,7 +143,7 @@ export function DmRosterPanel() {
|
|||||||
setPendingKick(null);
|
setPendingKick(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(
|
addToast(
|
||||||
err instanceof Error ? err.message : 'Failed to remove member',
|
err instanceof Error ? err.message : t('dm.remove.failed'),
|
||||||
'warning',
|
'warning',
|
||||||
3000,
|
3000,
|
||||||
);
|
);
|
||||||
@@ -167,7 +169,7 @@ export function DmRosterPanel() {
|
|||||||
setPendingTransfer(null);
|
setPendingTransfer(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(
|
addToast(
|
||||||
err instanceof Error ? err.message : 'Failed to transfer ownership',
|
err instanceof Error ? err.message : t('dm.transfer.failed'),
|
||||||
'warning',
|
'warning',
|
||||||
3000,
|
3000,
|
||||||
);
|
);
|
||||||
@@ -256,13 +258,15 @@ export function DmRosterPanel() {
|
|||||||
isOpen={!!pendingKick}
|
isOpen={!!pendingKick}
|
||||||
onClose={() => { if (!submitting) setPendingKick(null); }}
|
onClose={() => { if (!submitting) setPendingKick(null); }}
|
||||||
onConfirm={confirmKick}
|
onConfirm={confirmKick}
|
||||||
title="Remove from Group"
|
title={t('dm.remove.title')}
|
||||||
description={
|
description={
|
||||||
pendingKick
|
pendingKick
|
||||||
? `Remove ${pendingKick.displayName ?? parseFederatedUsername(pendingKick.username).baseName} from this group? They won't be able to see new messages.`
|
? t('dm.remove.body', {
|
||||||
|
name: pendingKick.displayName ?? parseFederatedUsername(pendingKick.username).baseName,
|
||||||
|
})
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
confirmLabel="Remove"
|
confirmLabel={t('dm.remove.confirm')}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
/>
|
/>
|
||||||
@@ -271,13 +275,15 @@ export function DmRosterPanel() {
|
|||||||
isOpen={!!pendingTransfer}
|
isOpen={!!pendingTransfer}
|
||||||
onClose={() => { if (!submitting) setPendingTransfer(null); }}
|
onClose={() => { if (!submitting) setPendingTransfer(null); }}
|
||||||
onConfirm={confirmTransfer}
|
onConfirm={confirmTransfer}
|
||||||
title="Transfer Ownership"
|
title={t('dm.transfer.title')}
|
||||||
description={
|
description={
|
||||||
pendingTransfer
|
pendingTransfer
|
||||||
? `Transfer ownership to ${pendingTransfer.displayName ?? parseFederatedUsername(pendingTransfer.username).baseName}? You'll lose owner privileges.`
|
? t('dm.transfer.body', {
|
||||||
|
name: pendingTransfer.displayName ?? parseFederatedUsername(pendingTransfer.username).baseName,
|
||||||
|
})
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
confirmLabel="Transfer"
|
confirmLabel={t('dm.transfer.confirm')}
|
||||||
variant="warning"
|
variant="warning"
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,34 +7,54 @@ import { PermissionBits, permissionsToString, stringToPermissions, hasPermission
|
|||||||
import { Toggle } from '../ui/Toggle';
|
import { Toggle } from '../ui/Toggle';
|
||||||
import { PermissionsEditor } from '../ui/PermissionsEditor';
|
import { PermissionsEditor } from '../ui/PermissionsEditor';
|
||||||
import type { PermissionDef } from '../ui/OverrideEntry';
|
import type { PermissionDef } from '../ui/OverrideEntry';
|
||||||
|
import { useT, translate, useLocaleStore, type TranslationKey } from '../../i18n';
|
||||||
|
import {
|
||||||
|
normalizeChannelName,
|
||||||
|
CHANNEL_NAME_MAX_LENGTH,
|
||||||
|
} from '@backspace/shared/src/constants';
|
||||||
|
|
||||||
// ─── Permission Definitions for Channel Overrides ──────────────────────────────
|
// ─── Permission Definitions for Channel Overrides ──────────────────────────────
|
||||||
|
|
||||||
const TEXT_CHANNEL_PERMISSIONS: PermissionDef[] = [
|
type ChannelPermissionDef = Omit<PermissionDef, 'label'> & { labelKey: TranslationKey };
|
||||||
{ key: 'VIEW_CHANNEL', label: 'View Channel', bit: PermissionBits.VIEW_CHANNEL },
|
|
||||||
{ key: 'SEND_MESSAGES', label: 'Send Messages', bit: PermissionBits.SEND_MESSAGES },
|
const TEXT_CHANNEL_PERMISSIONS: ChannelPermissionDef[] = [
|
||||||
{ key: 'MANAGE_MESSAGES', label: 'Manage Messages', bit: PermissionBits.MANAGE_MESSAGES },
|
{ key: 'VIEW_CHANNEL', labelKey: 'channel.perm.viewChannel', bit: PermissionBits.VIEW_CHANNEL },
|
||||||
{ key: 'ATTACH_FILES', label: 'Attach Files', bit: PermissionBits.ATTACH_FILES },
|
{ key: 'SEND_MESSAGES', labelKey: 'channel.perm.sendMessages', bit: PermissionBits.SEND_MESSAGES },
|
||||||
{ key: 'READ_MESSAGE_HISTORY', label: 'Read Message History', bit: PermissionBits.READ_MESSAGE_HISTORY },
|
{ key: 'MANAGE_MESSAGES', labelKey: 'channel.perm.manageMessages', bit: PermissionBits.MANAGE_MESSAGES },
|
||||||
{ key: 'ADD_REACTIONS', label: 'Add Reactions', bit: PermissionBits.ADD_REACTIONS },
|
{ key: 'ATTACH_FILES', labelKey: 'channel.perm.attachFiles', bit: PermissionBits.ATTACH_FILES },
|
||||||
|
{ key: 'READ_MESSAGE_HISTORY', labelKey: 'channel.perm.readHistory', bit: PermissionBits.READ_MESSAGE_HISTORY },
|
||||||
|
{ key: 'ADD_REACTIONS', labelKey: 'channel.perm.addReactions', bit: PermissionBits.ADD_REACTIONS },
|
||||||
];
|
];
|
||||||
|
|
||||||
const VOICE_CHANNEL_PERMISSIONS: PermissionDef[] = [
|
const VOICE_CHANNEL_PERMISSIONS: ChannelPermissionDef[] = [
|
||||||
{ key: 'VIEW_CHANNEL', label: 'View Channel', bit: PermissionBits.VIEW_CHANNEL },
|
{ key: 'VIEW_CHANNEL', labelKey: 'channel.perm.viewChannel', bit: PermissionBits.VIEW_CHANNEL },
|
||||||
{ key: 'CONNECT', label: 'Connect', bit: PermissionBits.CONNECT },
|
{ key: 'CONNECT', labelKey: 'channel.perm.connect', bit: PermissionBits.CONNECT },
|
||||||
{ key: 'SPEAK', label: 'Speak', bit: PermissionBits.SPEAK },
|
{ key: 'SPEAK', labelKey: 'channel.perm.speak', bit: PermissionBits.SPEAK },
|
||||||
{ key: 'STREAM', label: 'Stream', bit: PermissionBits.STREAM },
|
{ key: 'STREAM', labelKey: 'channel.perm.stream', bit: PermissionBits.STREAM },
|
||||||
{ key: 'MUTE_MEMBERS', label: 'Mute Members', bit: PermissionBits.MUTE_MEMBERS },
|
{ key: 'MUTE_MEMBERS', labelKey: 'channel.perm.muteMembers', bit: PermissionBits.MUTE_MEMBERS },
|
||||||
{ key: 'DEAFEN_MEMBERS', label: 'Deafen Members', bit: PermissionBits.DEAFEN_MEMBERS },
|
{ key: 'DEAFEN_MEMBERS', labelKey: 'channel.perm.deafenMembers', bit: PermissionBits.DEAFEN_MEMBERS },
|
||||||
{ key: 'MOVE_MEMBERS', label: 'Move Members', bit: PermissionBits.MOVE_MEMBERS },
|
{ key: 'MOVE_MEMBERS', labelKey: 'channel.perm.moveMembers', bit: PermissionBits.MOVE_MEMBERS },
|
||||||
{ key: 'DISCONNECT_MEMBERS', label: 'Disconnect Members', bit: PermissionBits.DISCONNECT_MEMBERS },
|
{ key: 'DISCONNECT_MEMBERS', labelKey: 'channel.perm.disconnectMembers', bit: PermissionBits.DISCONNECT_MEMBERS },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Channel icon — the hash for a text channel, a padlock when it is private. */
|
||||||
|
function ChannelGlyph({ isPrivate }: { isPrivate: boolean }) {
|
||||||
|
return isPrivate ? (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="opacity-60 flex-shrink-0">
|
||||||
|
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="opacity-60 flex-shrink-0">
|
||||||
|
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Overview Tab ───────────────────────────────────────────────────────────────
|
// ─── Overview Tab ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function OverviewTab({
|
function OverviewTab({
|
||||||
channelId,
|
|
||||||
channelName,
|
channelName,
|
||||||
|
channelTopic,
|
||||||
channelType,
|
channelType,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
isFetching,
|
isFetching,
|
||||||
@@ -43,9 +63,10 @@ function OverviewTab({
|
|||||||
canManageChannels,
|
canManageChannels,
|
||||||
onTogglePrivate,
|
onTogglePrivate,
|
||||||
onDeleteChannel,
|
onDeleteChannel,
|
||||||
|
onSaveDetails,
|
||||||
}: {
|
}: {
|
||||||
channelId: string;
|
|
||||||
channelName: string;
|
channelName: string;
|
||||||
|
channelTopic: string;
|
||||||
channelType: string;
|
channelType: string;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
@@ -54,39 +75,154 @@ function OverviewTab({
|
|||||||
canManageChannels: boolean;
|
canManageChannels: boolean;
|
||||||
onTogglePrivate: () => void;
|
onTogglePrivate: () => void;
|
||||||
onDeleteChannel: () => void;
|
onDeleteChannel: () => void;
|
||||||
|
onSaveDetails: (details: { name?: string; topic?: string }) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const isVoice = channelType === 'voice';
|
||||||
|
|
||||||
|
const [name, setName] = useState(channelName);
|
||||||
|
const [topic, setTopic] = useState(channelTopic);
|
||||||
|
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||||
|
const [saveError, setSaveError] = useState('');
|
||||||
|
|
||||||
|
// Re-sync when the channel changes underneath us. The store value only moves
|
||||||
|
// when the server confirms — our own save, or a rename from another client
|
||||||
|
// over the websocket — so adopting it never fights with typing.
|
||||||
|
useEffect(() => {
|
||||||
|
setName(channelName);
|
||||||
|
setSaveError('');
|
||||||
|
}, [channelName]);
|
||||||
|
useEffect(() => {
|
||||||
|
setTopic(channelTopic);
|
||||||
|
}, [channelTopic]);
|
||||||
|
|
||||||
|
// "Saved" is a confirmation, not a state to sit in: clear it after a beat so
|
||||||
|
// the button reads as ready again the next time the form is touched.
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveState !== 'saved') return;
|
||||||
|
const timer = setTimeout(() => setSaveState('idle'), 2000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [saveState]);
|
||||||
|
|
||||||
|
const normalized = normalizeChannelName(name);
|
||||||
|
const topicChanged = !isVoice && topic.trim() !== channelTopic;
|
||||||
|
const nameChanged = normalized !== channelName;
|
||||||
|
const isDirty = nameChanged || topicChanged;
|
||||||
|
const nameIsEmpty = normalized.length === 0;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!isDirty || nameIsEmpty) return;
|
||||||
|
setSaveError('');
|
||||||
|
setSaveState('saving');
|
||||||
|
try {
|
||||||
|
await onSaveDetails({
|
||||||
|
...(nameChanged ? { name: normalized } : {}),
|
||||||
|
...(topicChanged ? { topic: topic.trim() } : {}),
|
||||||
|
});
|
||||||
|
setSaveState('saved');
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err instanceof Error ? err.message : t('channel.saveFailed'));
|
||||||
|
setSaveState('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscard = () => {
|
||||||
|
setName(channelName);
|
||||||
|
setTopic(channelTopic);
|
||||||
|
setSaveError('');
|
||||||
|
setSaveState('idle');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
<label htmlFor="channel-name" className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
Channel
|
{t('channel.name.label')}
|
||||||
</label>
|
</label>
|
||||||
|
{canManageChannels ? (
|
||||||
|
<>
|
||||||
<div className="flex items-center gap-2 text-txt-primary">
|
<div className="flex items-center gap-2 text-txt-primary">
|
||||||
{isPrivate ? (
|
<ChannelGlyph isPrivate={isPrivate} />
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="opacity-60 flex-shrink-0">
|
<input
|
||||||
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
|
id="channel-name"
|
||||||
</svg>
|
type="text"
|
||||||
) : (
|
value={name}
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="opacity-60 flex-shrink-0">
|
onChange={(e) => { setName(e.target.value); setSaveState('idle'); }}
|
||||||
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
|
onKeyDown={(e) => { if (e.key === 'Enter') void handleSave(); }}
|
||||||
</svg>
|
maxLength={CHANNEL_NAME_MAX_LENGTH}
|
||||||
|
placeholder={t('channel.name.placeholder')}
|
||||||
|
className="input-standard flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* The server lowercases and hyphenates; show that up front rather
|
||||||
|
than letting the name change shape on save. */}
|
||||||
|
{normalized !== name.trim() && !nameIsEmpty && (
|
||||||
|
<div className="mt-1.5 text-xs text-txt-tertiary">
|
||||||
|
{t('channel.name.willBecome', { name: `#${normalized}` })}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{nameIsEmpty && (
|
||||||
|
<div className="mt-1.5 text-xs text-txt-danger">{t('channel.name.empty')}</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-txt-primary">
|
||||||
|
<ChannelGlyph isPrivate={isPrivate} />
|
||||||
<span className="text-sm font-medium">{channelName}</span>
|
<span className="text-sm font-medium">{channelName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{canManageChannels && !isVoice && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="channel-topic" className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
|
{t('channel.topic.label')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="channel-topic"
|
||||||
|
type="text"
|
||||||
|
value={topic}
|
||||||
|
onChange={(e) => { setTopic(e.target.value); setSaveState('idle'); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') void handleSave(); }}
|
||||||
|
placeholder={t('channel.topic.placeholder')}
|
||||||
|
className="input-standard w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canManageChannels && (isDirty || saveState !== 'idle') && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={saveState === 'saving' || nameIsEmpty || !isDirty}
|
||||||
|
className="px-3 py-1.5 rounded bg-accent-mint/15 border border-accent-mint/30 text-accent-mint text-sm font-medium hover:bg-accent-mint/25 transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
{saveState === 'saving' ? t('channel.saving') : saveState === 'saved' ? t('channel.saved') : t('channel.save')}
|
||||||
|
</button>
|
||||||
|
{isDirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleDiscard}
|
||||||
|
disabled={saveState === 'saving'}
|
||||||
|
className="px-3 py-1.5 rounded text-txt-tertiary text-sm hover:text-txt-secondary hover:bg-interactive-hover transition-colors"
|
||||||
|
>
|
||||||
|
{t('channel.discard')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(error || saveError) && (
|
||||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||||
{error}
|
{error || saveError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pt-2 border-t border-border-soft">
|
<div className="pt-2 border-t border-border-soft">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-txt-primary">Private Channel</div>
|
<div className="text-sm font-medium text-txt-primary">{t('channel.private.title')}</div>
|
||||||
<div className="text-xs text-txt-tertiary mt-0.5">
|
<div className="text-xs text-txt-tertiary mt-0.5">
|
||||||
Only selected members and roles will be able to view this channel.
|
{t('channel.private.description')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex-shrink-0 ml-4 ${(isLoading || isFetching) ? 'opacity-50 pointer-events-none' : ''}`}>
|
<div className={`flex-shrink-0 ml-4 ${(isLoading || isFetching) ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
@@ -100,20 +236,18 @@ function OverviewTab({
|
|||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 mt-0.5 text-txt-secondary">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 mt-0.5 text-txt-secondary">
|
||||||
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
|
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
<span>{t('channel.private.note')}</span>
|
||||||
This channel is hidden from members without explicit access. Users with the Administrator permission or space owners can always see all channels.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canManageChannels && (
|
{canManageChannels && (
|
||||||
<div className="pt-4 border-t border-border-soft">
|
<div className="pt-4 border-t border-border-soft">
|
||||||
<label className="block text-xs font-bold text-accent-rose uppercase mb-2">Danger Zone</label>
|
<label className="block text-xs font-bold text-accent-rose uppercase mb-2">{t('channel.danger')}</label>
|
||||||
<button
|
<button
|
||||||
onClick={onDeleteChannel}
|
onClick={onDeleteChannel}
|
||||||
className="w-full px-3 py-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors"
|
className="w-full px-3 py-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors"
|
||||||
>
|
>
|
||||||
Delete Channel
|
{t('channel.delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -131,6 +265,7 @@ export function ChannelSettingsModal() {
|
|||||||
const channels = useSpaceStore((s) => s.channels);
|
const channels = useSpaceStore((s) => s.channels);
|
||||||
const spaces = useSpaceStore((s) => s.spaces);
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
const [tab, setTab] = useState<'overview' | 'permissions'>('overview');
|
const [tab, setTab] = useState<'overview' | 'permissions'>('overview');
|
||||||
const [isPrivate, setIsPrivate] = useState(false);
|
const [isPrivate, setIsPrivate] = useState(false);
|
||||||
@@ -181,7 +316,7 @@ export function ChannelSettingsModal() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
setError(err.message || 'Failed to load channel overrides');
|
setError(err.message || translate(useLocaleStore.getState().locale, 'channel.private.loadFailed'));
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsFetching(false);
|
setIsFetching(false);
|
||||||
@@ -224,12 +359,21 @@ export function ChannelSettingsModal() {
|
|||||||
// Re-fetch to keep in sync
|
// Re-fetch to keep in sync
|
||||||
fetchPrivateState();
|
fetchPrivateState();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to update channel privacy');
|
setError(err instanceof Error ? err.message : t('channel.private.updateFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveDetails = async (details: { name?: string; topic?: string }) => {
|
||||||
|
if (Object.keys(details).length === 0) return;
|
||||||
|
setError('');
|
||||||
|
const channelApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
||||||
|
// The websocket `channel_updated` broadcast writes the new values into the
|
||||||
|
// store, so there is nothing to merge here — awaiting is enough.
|
||||||
|
await channelApi.channels.update(channelId, details);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteChannel = async () => {
|
const handleDeleteChannel = async () => {
|
||||||
if (!channelId || !currentSpaceId) return;
|
if (!channelId || !currentSpaceId) return;
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
@@ -238,7 +382,7 @@ export function ChannelSettingsModal() {
|
|||||||
await channelApi.channels.delete(channelId);
|
await channelApi.channels.delete(channelId);
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to delete channel');
|
setError(err instanceof Error ? err.message : t('channel.delete.failed'));
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -252,17 +396,17 @@ export function ChannelSettingsModal() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal isOpen={isOpen} onClose={closeModal} title="Channel Settings" mobileStyle="fullscreen" maxWidth={showTabs ? 'max-w-2xl' : 'max-w-md'}>
|
<Modal isOpen={isOpen} onClose={closeModal} title={t('channel.settings.title')} mobileStyle="fullscreen" maxWidth={showTabs ? 'max-w-2xl' : 'max-w-md'}>
|
||||||
{showTabs ? (
|
{showTabs ? (
|
||||||
<div className="flex gap-4 h-[min(520px,70vh)]">
|
<div className="flex gap-4 h-[min(520px,70vh)]">
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="w-32 flex-shrink-0 self-start z-10">
|
<div className="w-32 flex-shrink-0 self-start z-10">
|
||||||
<div className="glass-bubble rounded-lg p-1.5 space-y-0.5">
|
<div className="glass-bubble rounded-lg p-1.5 space-y-0.5">
|
||||||
<button onClick={() => setTab('overview')} className={tabClass('overview')}>
|
<button onClick={() => setTab('overview')} className={tabClass('overview')}>
|
||||||
Overview
|
{t('channel.tab.overview')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setTab('permissions')} className={tabClass('permissions')}>
|
<button onClick={() => setTab('permissions')} className={tabClass('permissions')}>
|
||||||
Permissions
|
{t('channel.tab.permissions')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -271,8 +415,8 @@ export function ChannelSettingsModal() {
|
|||||||
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
|
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
|
||||||
{tab === 'overview' && (
|
{tab === 'overview' && (
|
||||||
<OverviewTab
|
<OverviewTab
|
||||||
channelId={channelId}
|
|
||||||
channelName={channel.name}
|
channelName={channel.name}
|
||||||
|
channelTopic={channel.topic ?? ''}
|
||||||
channelType={channel.type}
|
channelType={channel.type}
|
||||||
isPrivate={isPrivate}
|
isPrivate={isPrivate}
|
||||||
isFetching={isFetching}
|
isFetching={isFetching}
|
||||||
@@ -281,6 +425,7 @@ export function ChannelSettingsModal() {
|
|||||||
canManageChannels={canManageChannels}
|
canManageChannels={canManageChannels}
|
||||||
onTogglePrivate={handleToggle}
|
onTogglePrivate={handleToggle}
|
||||||
onDeleteChannel={() => setShowDeleteConfirm(true)}
|
onDeleteChannel={() => setShowDeleteConfirm(true)}
|
||||||
|
onSaveDetails={handleSaveDetails}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === 'permissions' && (
|
{tab === 'permissions' && (
|
||||||
@@ -288,7 +433,9 @@ export function ChannelSettingsModal() {
|
|||||||
entityId={channelId}
|
entityId={channelId}
|
||||||
spaceId={currentSpaceId}
|
spaceId={currentSpaceId}
|
||||||
instanceOrigin={space?._instanceOrigin}
|
instanceOrigin={space?._instanceOrigin}
|
||||||
permDefs={channel.type === 'voice' ? VOICE_CHANNEL_PERMISSIONS : TEXT_CHANNEL_PERMISSIONS}
|
permDefs={(channel.type === 'voice' ? VOICE_CHANNEL_PERMISSIONS : TEXT_CHANNEL_PERMISSIONS).map(
|
||||||
|
({ labelKey, ...rest }) => ({ ...rest, label: t(labelKey) }),
|
||||||
|
)}
|
||||||
getOverrides={() => {
|
getOverrides={() => {
|
||||||
const channelApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
const channelApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
||||||
return channelApi.channels.getOverrides(channelId);
|
return channelApi.channels.getOverrides(channelId);
|
||||||
@@ -307,8 +454,8 @@ export function ChannelSettingsModal() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<OverviewTab
|
<OverviewTab
|
||||||
channelId={channelId}
|
|
||||||
channelName={channel.name}
|
channelName={channel.name}
|
||||||
|
channelTopic={channel.topic ?? ''}
|
||||||
channelType={channel.type}
|
channelType={channel.type}
|
||||||
isPrivate={isPrivate}
|
isPrivate={isPrivate}
|
||||||
isFetching={isFetching}
|
isFetching={isFetching}
|
||||||
@@ -317,6 +464,7 @@ export function ChannelSettingsModal() {
|
|||||||
canManageChannels={canManageChannels}
|
canManageChannels={canManageChannels}
|
||||||
onTogglePrivate={handleToggle}
|
onTogglePrivate={handleToggle}
|
||||||
onDeleteChannel={() => setShowDeleteConfirm(true)}
|
onDeleteChannel={() => setShowDeleteConfirm(true)}
|
||||||
|
onSaveDetails={handleSaveDetails}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -325,13 +473,13 @@ export function ChannelSettingsModal() {
|
|||||||
isOpen={showDeleteConfirm}
|
isOpen={showDeleteConfirm}
|
||||||
onClose={() => setShowDeleteConfirm(false)}
|
onClose={() => setShowDeleteConfirm(false)}
|
||||||
onConfirm={handleDeleteChannel}
|
onConfirm={handleDeleteChannel}
|
||||||
title={`Delete #${channel.name}?`}
|
title={t('channel.delete.title', { name: channel.name })}
|
||||||
description={<>
|
description={<>
|
||||||
This will permanently delete <strong>#{channel.name}</strong> and all of its messages.
|
{t('channel.delete.body', { name: channel.name })}
|
||||||
{channel.type === 'voice' && ' Any users currently in this voice channel will be disconnected.'}
|
{channel.type === 'voice' && ` ${t('channel.delete.voiceNote')}`}
|
||||||
{' '}This action cannot be undone.
|
{' '}{t('channel.delete.irreversible')}
|
||||||
</>}
|
</>}
|
||||||
confirmLabel="Delete Channel"
|
confirmLabel={t('channel.delete')}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
loading={isDeleting}
|
loading={isDeleting}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,36 +9,41 @@ import { api } from '../../api/client';
|
|||||||
import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
||||||
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
||||||
import type { Friend, MemberWithUser, SpaceInviteRequest, User } from '@backspace/shared';
|
import type { Friend, MemberWithUser, SpaceInviteRequest, User } from '@backspace/shared';
|
||||||
|
import { useT, type TranslationKey } from '../../i18n';
|
||||||
|
|
||||||
type SendStatus =
|
type SendStatus =
|
||||||
| { kind: 'pending' }
|
| { kind: 'pending' }
|
||||||
| { kind: 'success' }
|
| { kind: 'success' }
|
||||||
| { kind: 'failure'; reason: string };
|
| { kind: 'failure'; reasonKey: TranslationKey };
|
||||||
|
|
||||||
const FAILURE_COPY: Record<string, string> = {
|
const FAILURE_KEYS: Record<string, TranslationKey> = {
|
||||||
invite_invalid: 'Invite link no longer valid',
|
invite_invalid: 'invite.fail.invite_invalid',
|
||||||
not_a_friend: 'Not a friend',
|
not_a_friend: 'invite.fail.not_a_friend',
|
||||||
user_not_found: 'User not found',
|
user_not_found: 'invite.fail.user_not_found',
|
||||||
already_member: 'Already a member',
|
already_member: 'invite.fail.already_member',
|
||||||
upstream: "Couldn't verify invite — try again",
|
upstream: 'invite.fail.upstream',
|
||||||
cannot_invite_self: 'Cannot invite yourself',
|
cannot_invite_self: 'invite.fail.cannot_invite_self',
|
||||||
invalid_body: 'Invalid request',
|
invalid_body: 'invite.fail.invalid_body',
|
||||||
invalid_target: 'Invalid target',
|
invalid_target: 'invite.fail.invalid_target',
|
||||||
};
|
};
|
||||||
|
|
||||||
function reasonForError(error: unknown): string {
|
/**
|
||||||
|
* Maps a thrown error to a translation key rather than to finished text: the
|
||||||
|
* row renders it, so switching language re-labels results already on screen.
|
||||||
|
*/
|
||||||
|
function reasonKeyForError(error: unknown): TranslationKey {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
// The api client throws Error(message) where message is the server's
|
// The api client throws Error(message) where message is the server's
|
||||||
// `error` field (e.g. 'invite_invalid'). Fall back to message-based
|
// `error` field (e.g. 'invite_invalid'). Fall back to message-based
|
||||||
// network detection for transport failures.
|
// network detection for transport failures.
|
||||||
const msg = error.message;
|
const msg = error.message;
|
||||||
const mapped = FAILURE_COPY[msg];
|
const mapped = FAILURE_KEYS[msg];
|
||||||
if (mapped) return mapped;
|
if (mapped) return mapped;
|
||||||
if (/network|fetch|failed to fetch/i.test(msg)) {
|
if (/network|fetch|failed to fetch/i.test(msg)) {
|
||||||
return "Couldn't reach your instance";
|
return 'invite.fail.network';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "Couldn't send (server error)";
|
return 'invite.fail.generic';
|
||||||
}
|
}
|
||||||
|
|
||||||
function InviteResultFriendRow({
|
function InviteResultFriendRow({
|
||||||
@@ -48,6 +53,7 @@ function InviteResultFriendRow({
|
|||||||
friend: Friend;
|
friend: Friend;
|
||||||
status: SendStatus | undefined;
|
status: SendStatus | undefined;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useT();
|
||||||
const canonical = useCanonicalUserView(friend as unknown as User);
|
const canonical = useCanonicalUserView(friend as unknown as User);
|
||||||
const { baseName } = parseFederatedUsername(canonical.username);
|
const { baseName } = parseFederatedUsername(canonical.username);
|
||||||
const dn = canonical.displayName ?? baseName;
|
const dn = canonical.displayName ?? baseName;
|
||||||
@@ -65,10 +71,10 @@ function InviteResultFriendRow({
|
|||||||
<div className="text-[11px] text-txt-tertiary truncate">@{canonical.username}</div>
|
<div className="text-[11px] text-txt-tertiary truncate">@{canonical.username}</div>
|
||||||
</div>
|
</div>
|
||||||
{status?.kind === 'success' && (
|
{status?.kind === 'success' && (
|
||||||
<span className="text-[12px] text-accent-mint flex-shrink-0">✓ Sent</span>
|
<span className="text-[12px] text-accent-mint flex-shrink-0">✓ {t('invite.sent')}</span>
|
||||||
)}
|
)}
|
||||||
{status?.kind === 'failure' && (
|
{status?.kind === 'failure' && (
|
||||||
<span className="text-[12px] text-txt-danger flex-shrink-0">✗ {status.reason}</span>
|
<span className="text-[12px] text-txt-danger flex-shrink-0">✗ {t(status.reasonKey)}</span>
|
||||||
)}
|
)}
|
||||||
{status?.kind === 'pending' && (
|
{status?.kind === 'pending' && (
|
||||||
<span className="text-[12px] text-txt-tertiary flex-shrink-0">...</span>
|
<span className="text-[12px] text-txt-tertiary flex-shrink-0">...</span>
|
||||||
@@ -90,6 +96,7 @@ function InviteSelectFriendRow({
|
|||||||
sending: boolean;
|
sending: boolean;
|
||||||
onToggle: (id: string, friend: Friend) => void;
|
onToggle: (id: string, friend: Friend) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useT();
|
||||||
const canonical = useCanonicalUserView(friend as unknown as User);
|
const canonical = useCanonicalUserView(friend as unknown as User);
|
||||||
const { baseName } = parseFederatedUsername(canonical.username);
|
const { baseName } = parseFederatedUsername(canonical.username);
|
||||||
const dn = canonical.displayName ?? baseName;
|
const dn = canonical.displayName ?? baseName;
|
||||||
@@ -116,7 +123,7 @@ function InviteSelectFriendRow({
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[13px] font-medium text-txt-primary truncate">{dn}</div>
|
<div className="text-[13px] font-medium text-txt-primary truncate">{dn}</div>
|
||||||
<div className="text-[11px] text-txt-tertiary truncate">
|
<div className="text-[11px] text-txt-tertiary truncate">
|
||||||
{alreadyMember ? 'Already in space' : `@${canonical.username}`}
|
{alreadyMember ? t('invite.alreadyInSpace') : `@${canonical.username}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!alreadyMember && (
|
{!alreadyMember && (
|
||||||
@@ -153,6 +160,7 @@ export function InviteModal() {
|
|||||||
// the /invite endpoint 403s. Show an explanatory notice instead of the invite
|
// the /invite endpoint 403s. Show an explanatory notice instead of the invite
|
||||||
// affordances, and skip the invite-code fetch entirely.
|
// affordances, and skip the invite-code fetch entirely.
|
||||||
const isRequestOnly = currentSpace?.visibility === 'request';
|
const isRequestOnly = currentSpace?.visibility === 'request';
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
const [inviteCode, setInviteCode] = useState('');
|
const [inviteCode, setInviteCode] = useState('');
|
||||||
const [codeError, setCodeError] = useState('');
|
const [codeError, setCodeError] = useState('');
|
||||||
@@ -180,7 +188,7 @@ export function InviteModal() {
|
|||||||
setCodeLoading(false);
|
setCodeLoading(false);
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
setCodeError((err as Error)?.message ?? 'Failed to generate invite link');
|
setCodeError((err as Error)?.message ?? t('invite.linkFailed'));
|
||||||
setCodeLoading(false);
|
setCodeLoading(false);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -276,7 +284,7 @@ export function InviteModal() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
friend,
|
friend,
|
||||||
status: { kind: 'failure' as const, reason: reasonForError(err) },
|
status: { kind: 'failure' as const, reasonKey: reasonKeyForError(err) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -324,8 +332,10 @@ export function InviteModal() {
|
|||||||
const inResultsView = results.size > 0 && !sending;
|
const inResultsView = results.size > 0 && !sending;
|
||||||
const submitLabel =
|
const submitLabel =
|
||||||
selectedFriends.length === 0
|
selectedFriends.length === 0
|
||||||
? 'Select Friends'
|
? t('invite.select')
|
||||||
: `Send ${selectedFriends.length} Invite${selectedFriends.length > 1 ? 's' : ''}`;
|
: t(selectedFriends.length > 1 ? 'invite.sendPlural' : 'invite.send', {
|
||||||
|
count: selectedFriends.length,
|
||||||
|
});
|
||||||
const hasFailures =
|
const hasFailures =
|
||||||
inResultsView &&
|
inResultsView &&
|
||||||
selectedFriends.some((f) => results.get(f.id)?.kind === 'failure');
|
selectedFriends.some((f) => results.get(f.id)?.kind === 'failure');
|
||||||
@@ -334,26 +344,25 @@ export function InviteModal() {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
title="Invite Friends"
|
title={t('invite.title')}
|
||||||
mobileStyle="sheet"
|
mobileStyle="sheet"
|
||||||
>
|
>
|
||||||
{isRequestOnly ? (
|
{isRequestOnly ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-[13px] text-txt-tertiary">
|
<p className="text-[13px] text-txt-tertiary">
|
||||||
This space uses join requests — people join by requesting approval
|
{t('invite.requestOnly')}
|
||||||
from a manager, so it has no invite link to share.
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={closeModal}
|
onClick={closeModal}
|
||||||
className="w-full py-2 rounded-md text-[13px] font-semibold glass-pill text-txt-primary"
|
className="w-full py-2 rounded-md text-[13px] font-semibold glass-pill text-txt-primary"
|
||||||
>
|
>
|
||||||
Got it
|
{t('invite.gotIt')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-[13px] text-txt-tertiary">
|
<p className="text-[13px] text-txt-tertiary">
|
||||||
Send to friends, or share a link.
|
{t('invite.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Selected chips — hidden in results view */}
|
{/* Selected chips — hidden in results view */}
|
||||||
@@ -368,7 +377,7 @@ export function InviteModal() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => removeFriend(f.id)}
|
onClick={() => removeFriend(f.id)}
|
||||||
className="opacity-60 hover:opacity-100 transition-opacity text-[14px] leading-none"
|
className="opacity-60 hover:opacity-100 transition-opacity text-[14px] leading-none"
|
||||||
aria-label={`Remove ${f.displayName ?? f.username}`}
|
aria-label={t('invite.remove', { name: f.displayName ?? f.username })}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -384,7 +393,7 @@ export function InviteModal() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search friends..."
|
placeholder={t('invite.searchPlaceholder')}
|
||||||
className="input-search w-full py-2 text-[14px]"
|
className="input-search w-full py-2 text-[14px]"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -403,9 +412,7 @@ export function InviteModal() {
|
|||||||
<>
|
<>
|
||||||
{filteredFriends.length === 0 && (
|
{filteredFriends.length === 0 && (
|
||||||
<div className="py-4 text-center text-txt-tertiary text-[14px]">
|
<div className="py-4 text-center text-txt-tertiary text-[14px]">
|
||||||
{query.trim()
|
{query.trim() ? t('invite.noMatch') : t('invite.noFriends')}
|
||||||
? 'No friends match your search'
|
|
||||||
: 'No friends yet'}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{filteredFriends.map((friend) => (
|
{filteredFriends.map((friend) => (
|
||||||
@@ -431,14 +438,14 @@ export function InviteModal() {
|
|||||||
disabled={sending}
|
disabled={sending}
|
||||||
className="flex-1 py-2 rounded-md text-[13px] font-semibold transition-colors bg-accent-mint text-surface-base hover:bg-accent-mint/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex-1 py-2 rounded-md text-[13px] font-semibold transition-colors bg-accent-mint text-surface-base hover:bg-accent-mint/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Retry failed
|
{t('invite.retryFailed')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={closeModal}
|
onClick={closeModal}
|
||||||
className="flex-1 py-2 rounded-md text-[13px] font-semibold glass-pill text-txt-primary"
|
className="flex-1 py-2 rounded-md text-[13px] font-semibold glass-pill text-txt-primary"
|
||||||
>
|
>
|
||||||
Done
|
{t('invite.done')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -447,14 +454,14 @@ export function InviteModal() {
|
|||||||
disabled={selectedFriends.length === 0 || sending || codeLoading}
|
disabled={selectedFriends.length === 0 || sending || codeLoading}
|
||||||
className="w-full py-2 rounded-md text-[13px] font-semibold transition-colors bg-accent-mint text-surface-base hover:bg-accent-mint/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-2 rounded-md text-[13px] font-semibold transition-colors bg-accent-mint text-surface-base hover:bg-accent-mint/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{sending ? 'Sending...' : submitLabel}
|
{sending ? t('invite.sending') : submitLabel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Share-link footer */}
|
{/* Share-link footer */}
|
||||||
<div className="pt-3 border-t border-white/[0.06]">
|
<div className="pt-3 border-t border-white/[0.06]">
|
||||||
<p className="text-[12px] text-txt-tertiary mb-2">
|
<p className="text-[12px] text-txt-tertiary mb-2">
|
||||||
Or share a link
|
{t('invite.orLink')}
|
||||||
</p>
|
</p>
|
||||||
{codeError && (
|
{codeError && (
|
||||||
<div className="mb-2 text-[12px] text-txt-danger">{codeError}</div>
|
<div className="mb-2 text-[12px] text-txt-danger">{codeError}</div>
|
||||||
@@ -462,7 +469,7 @@ export function InviteModal() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={codeLoading ? 'Generating...' : inviteUrl}
|
value={codeLoading ? t('invite.generating') : inviteUrl}
|
||||||
readOnly
|
readOnly
|
||||||
className="input-embedded flex-1 font-mono text-xs px-2 py-1.5"
|
className="input-embedded flex-1 font-mono text-xs px-2 py-1.5"
|
||||||
/>
|
/>
|
||||||
@@ -473,7 +480,7 @@ export function InviteModal() {
|
|||||||
linkCopied ? 'text-accent-mint' : 'text-txt-primary'
|
linkCopied ? 'text-accent-mint' : 'text-txt-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{linkCopied ? 'Copied!' : 'Copy'}
|
{linkCopied ? t('invite.copied') : t('invite.copy')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore, DEFAULT_STREAM_VOLUME } from '../../stores/voiceStore';
|
||||||
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
||||||
import { useAudioTrackPlayer } from '../../hooks/useAudioTrackPlayer';
|
import { useAudioTrackPlayer } from '../../hooks/useAudioTrackPlayer';
|
||||||
import type { ParticipantInfo } from '../../hooks/useLiveKit';
|
import type { ParticipantInfo } from '../../hooks/useLiveKit';
|
||||||
@@ -97,7 +97,7 @@ export function GlobalAudioRenderer() {
|
|||||||
{remoteParticipants.map((p: ParticipantInfo) => {
|
{remoteParticipants.map((p: ParticipantInfo) => {
|
||||||
const micVolume = participantVolumes.get(p.userId) ?? 100;
|
const micVolume = participantVolumes.get(p.userId) ?? 100;
|
||||||
const isMicMuted = participantMutes.get(p.userId) ?? false;
|
const isMicMuted = participantMutes.get(p.userId) ?? false;
|
||||||
const streamVol = streamVolumes.get(p.userId) ?? 100;
|
const streamVol = streamVolumes.get(p.userId) ?? DEFAULT_STREAM_VOLUME;
|
||||||
const isStreamMuted = streamMutes.get(p.userId) ?? false;
|
const isStreamMuted = streamMutes.get(p.userId) ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore, DEFAULT_STREAM_VOLUME } from '../../stores/voiceStore';
|
||||||
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
|
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
|
||||||
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
||||||
import { stopScreenShare, changeScreenShare } from '../../utils/screenShare';
|
import { stopScreenShare, changeScreenShare } from '../../utils/screenShare';
|
||||||
@@ -60,7 +60,7 @@ function StreamQualityItem() {
|
|||||||
|
|
||||||
/** Wrapper component for stream volume slider — needs store subscription. */
|
/** Wrapper component for stream volume slider — needs store subscription. */
|
||||||
function StreamVolumeItem({ userId }: { userId: string }) {
|
function StreamVolumeItem({ userId }: { userId: string }) {
|
||||||
const streamVolume = useVoiceStore((s) => s.streamVolumes.get(userId) ?? 100);
|
const streamVolume = useVoiceStore((s) => s.streamVolumes.get(userId) ?? DEFAULT_STREAM_VOLUME);
|
||||||
const setStreamVolume = useVoiceStore((s) => s.setStreamVolume);
|
const setStreamVolume = useVoiceStore((s) => s.setStreamVolume);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -227,6 +227,142 @@ export const en = {
|
|||||||
'profile.activity.watching': 'Watching',
|
'profile.activity.watching': 'Watching',
|
||||||
'profile.activity.streaming': 'Streaming',
|
'profile.activity.streaming': 'Streaming',
|
||||||
'profile.activity.elapsed': '{time} elapsed',
|
'profile.activity.elapsed': '{time} elapsed',
|
||||||
|
// Channel settings
|
||||||
|
'channel.settings.title': 'Channel Settings',
|
||||||
|
'channel.tab.overview': 'Overview',
|
||||||
|
'channel.tab.permissions': 'Permissions',
|
||||||
|
'channel.name.label': 'Channel Name',
|
||||||
|
'channel.name.placeholder': 'new-channel',
|
||||||
|
'channel.name.willBecome': 'Will be saved as {name}',
|
||||||
|
'channel.name.empty': 'The name cannot be empty.',
|
||||||
|
'channel.topic.label': 'Topic',
|
||||||
|
'channel.topic.placeholder': "What's this channel about?",
|
||||||
|
'channel.save': 'Save Changes',
|
||||||
|
'channel.saving': 'Saving...',
|
||||||
|
'channel.saved': 'Saved',
|
||||||
|
'channel.discard': 'Discard',
|
||||||
|
'channel.saveFailed': 'Could not save the channel.',
|
||||||
|
'channel.private.title': 'Private Channel',
|
||||||
|
'channel.private.description': 'Only selected members and roles will be able to view this channel.',
|
||||||
|
'channel.private.note': 'This channel is hidden from members without explicit access. Users with the Administrator permission or space owners can always see all channels.',
|
||||||
|
'channel.private.loadFailed': 'Failed to load channel overrides',
|
||||||
|
'channel.private.updateFailed': 'Failed to update channel privacy',
|
||||||
|
'channel.danger': 'Danger Zone',
|
||||||
|
'channel.delete': 'Delete Channel',
|
||||||
|
'channel.delete.title': 'Delete #{name}?',
|
||||||
|
'channel.delete.body': 'This will permanently delete #{name} and all of its messages.',
|
||||||
|
'channel.delete.voiceNote': 'Any users currently in this voice channel will be disconnected.',
|
||||||
|
'channel.delete.irreversible': 'This action cannot be undone.',
|
||||||
|
'channel.delete.failed': 'Failed to delete channel',
|
||||||
|
|
||||||
|
// Channel permissions — override editor labels
|
||||||
|
'channel.perm.viewChannel': 'View Channel',
|
||||||
|
'channel.perm.sendMessages': 'Send Messages',
|
||||||
|
'channel.perm.manageMessages': 'Manage Messages',
|
||||||
|
'channel.perm.attachFiles': 'Attach Files',
|
||||||
|
'channel.perm.readHistory': 'Read Message History',
|
||||||
|
'channel.perm.addReactions': 'Add Reactions',
|
||||||
|
'channel.perm.connect': 'Connect',
|
||||||
|
'channel.perm.speak': 'Speak',
|
||||||
|
'channel.perm.stream': 'Stream',
|
||||||
|
'channel.perm.muteMembers': 'Mute Members',
|
||||||
|
'channel.perm.deafenMembers': 'Deafen Members',
|
||||||
|
'channel.perm.moveMembers': 'Move Members',
|
||||||
|
'channel.perm.disconnectMembers': 'Disconnect Members',
|
||||||
|
// Friends — tabs and lists
|
||||||
|
'friends.title': 'Friends',
|
||||||
|
'friends.tab.online': 'Online',
|
||||||
|
'friends.tab.all': 'All',
|
||||||
|
'friends.tab.pending': 'Pending',
|
||||||
|
'friends.tab.add': 'Add Friend',
|
||||||
|
'friends.tab.activity': 'Activity',
|
||||||
|
'friends.status.online': 'Online',
|
||||||
|
'friends.status.idle': 'Idle',
|
||||||
|
'friends.status.dnd': 'Do Not Disturb',
|
||||||
|
'friends.status.offline': 'Offline',
|
||||||
|
'friends.heading.online': 'Online — {count}',
|
||||||
|
'friends.heading.all': 'All Friends — {count}',
|
||||||
|
'friends.heading.pending': 'Pending — {count}',
|
||||||
|
'friends.heading.active': 'Active — {count}',
|
||||||
|
'friends.heading.offline': 'Offline — {count}',
|
||||||
|
'friends.empty.online': "No one's online right now.",
|
||||||
|
'friends.empty.all': 'No friends yet — add someone!',
|
||||||
|
'friends.empty.pending': 'No pending requests — Nori is napping.',
|
||||||
|
'friends.empty.activity': "It's quiet for now... When friends start an activity, we'll show it here!",
|
||||||
|
'friends.request.incoming': 'Incoming Friend Request',
|
||||||
|
'friends.request.outgoing': 'Outgoing Friend Request',
|
||||||
|
'friends.action.message': 'Message',
|
||||||
|
'friends.action.accept': 'Accept',
|
||||||
|
'friends.action.decline': 'Decline',
|
||||||
|
'friends.action.cancel': 'Cancel Request',
|
||||||
|
'friends.action.remove': 'Remove Friend',
|
||||||
|
'friends.remove.title': 'Remove Friend',
|
||||||
|
'friends.remove.body': 'Are you sure you want to remove {name} as a friend? You can always send them a new friend request later.',
|
||||||
|
'friends.remove.someone': 'this user',
|
||||||
|
'friends.remove.confirm': 'Remove',
|
||||||
|
|
||||||
|
// Friends — add tab
|
||||||
|
'friends.add.title': 'Find People',
|
||||||
|
'friends.add.hint': 'Search by username or use {handle} to add someone directly.',
|
||||||
|
'friends.add.searchPlaceholder': 'Search or add by username...',
|
||||||
|
'friends.add.directTo': 'Send friend request to',
|
||||||
|
'friends.add.send': 'Send Request',
|
||||||
|
'friends.add.sending': 'Sending...',
|
||||||
|
'friends.add.sent': 'Friend request sent!',
|
||||||
|
'friends.add.discover': 'Discover People',
|
||||||
|
'friends.add.empty.search': 'No users match your search.',
|
||||||
|
'friends.add.empty.discover': 'No discoverable users yet — invite people to join!',
|
||||||
|
'friends.add.sendRequest': 'Send Friend Request',
|
||||||
|
'friends.add.cancelling': 'Cancelling...',
|
||||||
|
'friends.add.pending': 'Request Pending',
|
||||||
|
'friends.add.failed': 'Failed to send request',
|
||||||
|
'friends.accept.failed': 'Failed to accept request',
|
||||||
|
'friends.decline.failed': 'Failed to decline request',
|
||||||
|
'friends.cancel.failed': 'Failed to cancel request',
|
||||||
|
|
||||||
|
// DM roster — group members
|
||||||
|
'dm.remove.title': 'Remove from Group',
|
||||||
|
'dm.remove.body': "Remove {name} from this group? They won't be able to see new messages.",
|
||||||
|
'dm.remove.confirm': 'Remove',
|
||||||
|
'dm.remove.failed': 'Failed to remove member',
|
||||||
|
'dm.transfer.title': 'Transfer Ownership',
|
||||||
|
'dm.transfer.body': "Transfer ownership to {name}? You'll lose owner privileges.",
|
||||||
|
'dm.transfer.confirm': 'Transfer',
|
||||||
|
'dm.transfer.failed': 'Failed to transfer ownership',
|
||||||
|
'dm.removeFriend.failed': 'Failed to remove friend',
|
||||||
|
|
||||||
|
// Invite modal
|
||||||
|
'invite.title': 'Invite Friends',
|
||||||
|
'invite.subtitle': 'Send to friends, or share a link.',
|
||||||
|
'invite.requestOnly': 'This space uses join requests — people join by requesting approval from a manager, so it has no invite link to share.',
|
||||||
|
'invite.gotIt': 'Got it',
|
||||||
|
'invite.searchPlaceholder': 'Search friends...',
|
||||||
|
'invite.remove': 'Remove {name}',
|
||||||
|
'invite.alreadyInSpace': 'Already in space',
|
||||||
|
'invite.noMatch': 'No friends match your search',
|
||||||
|
'invite.noFriends': 'No friends yet',
|
||||||
|
'invite.select': 'Select Friends',
|
||||||
|
'invite.send': 'Send {count} Invite',
|
||||||
|
'invite.sendPlural': 'Send {count} Invites',
|
||||||
|
'invite.sending': 'Sending...',
|
||||||
|
'invite.sent': 'Sent',
|
||||||
|
'invite.retryFailed': 'Retry failed',
|
||||||
|
'invite.done': 'Done',
|
||||||
|
'invite.orLink': 'Or share a link',
|
||||||
|
'invite.generating': 'Generating...',
|
||||||
|
'invite.copy': 'Copy',
|
||||||
|
'invite.copied': 'Copied!',
|
||||||
|
'invite.linkFailed': 'Failed to generate invite link',
|
||||||
|
'invite.fail.invite_invalid': 'Invite link no longer valid',
|
||||||
|
'invite.fail.not_a_friend': 'Not a friend',
|
||||||
|
'invite.fail.user_not_found': 'User not found',
|
||||||
|
'invite.fail.already_member': 'Already a member',
|
||||||
|
'invite.fail.upstream': "Couldn't verify invite — try again",
|
||||||
|
'invite.fail.cannot_invite_self': 'Cannot invite yourself',
|
||||||
|
'invite.fail.invalid_body': 'Invalid request',
|
||||||
|
'invite.fail.invalid_target': 'Invalid target',
|
||||||
|
'invite.fail.network': "Couldn't reach your instance",
|
||||||
|
'invite.fail.generic': "Couldn't send (server error)",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TranslationKey = keyof typeof en;
|
export type TranslationKey = keyof typeof en;
|
||||||
|
|||||||
@@ -226,4 +226,140 @@ export const ptBR: Partial<Dictionary> = {
|
|||||||
'profile.activity.watching': 'Assistindo',
|
'profile.activity.watching': 'Assistindo',
|
||||||
'profile.activity.streaming': 'Transmitindo',
|
'profile.activity.streaming': 'Transmitindo',
|
||||||
'profile.activity.elapsed': '{time} decorrido',
|
'profile.activity.elapsed': '{time} decorrido',
|
||||||
|
// Configurações do canal
|
||||||
|
'channel.settings.title': 'Configurações do canal',
|
||||||
|
'channel.tab.overview': 'Visão geral',
|
||||||
|
'channel.tab.permissions': 'Permissões',
|
||||||
|
'channel.name.label': 'Nome do canal',
|
||||||
|
'channel.name.placeholder': 'novo-canal',
|
||||||
|
'channel.name.willBecome': 'Será salvo como {name}',
|
||||||
|
'channel.name.empty': 'O nome não pode ficar vazio.',
|
||||||
|
'channel.topic.label': 'Tópico',
|
||||||
|
'channel.topic.placeholder': 'Sobre o que é este canal?',
|
||||||
|
'channel.save': 'Salvar alterações',
|
||||||
|
'channel.saving': 'Salvando...',
|
||||||
|
'channel.saved': 'Salvo',
|
||||||
|
'channel.discard': 'Descartar',
|
||||||
|
'channel.saveFailed': 'Não foi possível salvar o canal.',
|
||||||
|
'channel.private.title': 'Canal privado',
|
||||||
|
'channel.private.description': 'Só os membros e cargos escolhidos vão conseguir ver este canal.',
|
||||||
|
'channel.private.note': 'Este canal fica escondido de quem não tem acesso explícito. Quem tem a permissão de Administrador e os donos do servidor sempre veem todos os canais.',
|
||||||
|
'channel.private.loadFailed': 'Não foi possível carregar as permissões do canal',
|
||||||
|
'channel.private.updateFailed': 'Não foi possível alterar a privacidade do canal',
|
||||||
|
'channel.danger': 'Zona de perigo',
|
||||||
|
'channel.delete': 'Excluir canal',
|
||||||
|
'channel.delete.title': 'Excluir #{name}?',
|
||||||
|
'channel.delete.body': 'Isto apaga o #{name} e todas as mensagens dele para sempre.',
|
||||||
|
'channel.delete.voiceNote': 'Quem estiver neste canal de voz agora será desconectado.',
|
||||||
|
'channel.delete.irreversible': 'Não dá para desfazer.',
|
||||||
|
'channel.delete.failed': 'Não foi possível excluir o canal',
|
||||||
|
|
||||||
|
// Permissões do canal — rótulos do editor de exceções
|
||||||
|
'channel.perm.viewChannel': 'Ver canal',
|
||||||
|
'channel.perm.sendMessages': 'Enviar mensagens',
|
||||||
|
'channel.perm.manageMessages': 'Gerenciar mensagens',
|
||||||
|
'channel.perm.attachFiles': 'Anexar arquivos',
|
||||||
|
'channel.perm.readHistory': 'Ler histórico de mensagens',
|
||||||
|
'channel.perm.addReactions': 'Adicionar reações',
|
||||||
|
'channel.perm.connect': 'Conectar',
|
||||||
|
'channel.perm.speak': 'Falar',
|
||||||
|
'channel.perm.stream': 'Transmitir',
|
||||||
|
'channel.perm.muteMembers': 'Silenciar membros',
|
||||||
|
'channel.perm.deafenMembers': 'Ensurdecer membros',
|
||||||
|
'channel.perm.moveMembers': 'Mover membros',
|
||||||
|
'channel.perm.disconnectMembers': 'Desconectar membros',
|
||||||
|
// Amigos — abas e listas
|
||||||
|
'friends.title': 'Amigos',
|
||||||
|
'friends.tab.online': 'Online',
|
||||||
|
'friends.tab.all': 'Todos',
|
||||||
|
'friends.tab.pending': 'Pendentes',
|
||||||
|
'friends.tab.add': 'Adicionar amigo',
|
||||||
|
'friends.tab.activity': 'Atividade',
|
||||||
|
'friends.status.online': 'Online',
|
||||||
|
'friends.status.idle': 'Ausente',
|
||||||
|
'friends.status.dnd': 'Não perturbe',
|
||||||
|
'friends.status.offline': 'Offline',
|
||||||
|
'friends.heading.online': 'Online — {count}',
|
||||||
|
'friends.heading.all': 'Todos os amigos — {count}',
|
||||||
|
'friends.heading.pending': 'Pendentes — {count}',
|
||||||
|
'friends.heading.active': 'Ativos — {count}',
|
||||||
|
'friends.heading.offline': 'Offline — {count}',
|
||||||
|
'friends.empty.online': 'Ninguém online agora.',
|
||||||
|
'friends.empty.all': 'Nenhum amigo ainda — chama alguém!',
|
||||||
|
'friends.empty.pending': 'Nenhum pedido pendente — o Nori está tirando um cochilo.',
|
||||||
|
'friends.empty.activity': 'Tudo quieto por aqui... Quando um amigo começar alguma coisa, aparece aqui!',
|
||||||
|
'friends.request.incoming': 'Pedido de amizade recebido',
|
||||||
|
'friends.request.outgoing': 'Pedido de amizade enviado',
|
||||||
|
'friends.action.message': 'Mensagem',
|
||||||
|
'friends.action.accept': 'Aceitar',
|
||||||
|
'friends.action.decline': 'Recusar',
|
||||||
|
'friends.action.cancel': 'Cancelar pedido',
|
||||||
|
'friends.action.remove': 'Remover amigo',
|
||||||
|
'friends.remove.title': 'Remover amigo',
|
||||||
|
'friends.remove.body': 'Quer mesmo remover {name} dos seus amigos? Dá para mandar um novo pedido depois.',
|
||||||
|
'friends.remove.someone': 'esta pessoa',
|
||||||
|
'friends.remove.confirm': 'Remover',
|
||||||
|
|
||||||
|
// Amigos — aba de adicionar
|
||||||
|
'friends.add.title': 'Encontrar pessoas',
|
||||||
|
'friends.add.hint': 'Busque pelo nome de usuário ou use {handle} para adicionar alguém direto.',
|
||||||
|
'friends.add.searchPlaceholder': 'Buscar ou adicionar por nome de usuário...',
|
||||||
|
'friends.add.directTo': 'Enviar pedido de amizade para',
|
||||||
|
'friends.add.send': 'Enviar pedido',
|
||||||
|
'friends.add.sending': 'Enviando...',
|
||||||
|
'friends.add.sent': 'Pedido de amizade enviado!',
|
||||||
|
'friends.add.discover': 'Descobrir pessoas',
|
||||||
|
'friends.add.empty.search': 'Nenhum usuário encontrado.',
|
||||||
|
'friends.add.empty.discover': 'Ninguém para descobrir ainda — chame o pessoal!',
|
||||||
|
'friends.add.sendRequest': 'Enviar pedido de amizade',
|
||||||
|
'friends.add.cancelling': 'Cancelando...',
|
||||||
|
'friends.add.pending': 'Pedido pendente',
|
||||||
|
'friends.add.failed': 'Não foi possível enviar o pedido',
|
||||||
|
'friends.accept.failed': 'Não foi possível aceitar o pedido',
|
||||||
|
'friends.decline.failed': 'Não foi possível recusar o pedido',
|
||||||
|
'friends.cancel.failed': 'Não foi possível cancelar o pedido',
|
||||||
|
|
||||||
|
// Lista de membros da DM em grupo
|
||||||
|
'dm.remove.title': 'Remover do grupo',
|
||||||
|
'dm.remove.body': 'Remover {name} deste grupo? A pessoa não vai mais ver as mensagens novas.',
|
||||||
|
'dm.remove.confirm': 'Remover',
|
||||||
|
'dm.remove.failed': 'Não foi possível remover o membro',
|
||||||
|
'dm.transfer.title': 'Transferir propriedade',
|
||||||
|
'dm.transfer.body': 'Transferir a propriedade para {name}? Você perde os privilégios de dono.',
|
||||||
|
'dm.transfer.confirm': 'Transferir',
|
||||||
|
'dm.transfer.failed': 'Não foi possível transferir a propriedade',
|
||||||
|
'dm.removeFriend.failed': 'Não foi possível remover o amigo',
|
||||||
|
|
||||||
|
// Modal de convite
|
||||||
|
'invite.title': 'Convidar amigos',
|
||||||
|
'invite.subtitle': 'Envie para amigos ou compartilhe um link.',
|
||||||
|
'invite.requestOnly': 'Este servidor usa pedidos de entrada — as pessoas entram pedindo aprovação a um moderador, então não há link de convite para compartilhar.',
|
||||||
|
'invite.gotIt': 'Entendi',
|
||||||
|
'invite.searchPlaceholder': 'Buscar amigos...',
|
||||||
|
'invite.remove': 'Remover {name}',
|
||||||
|
'invite.alreadyInSpace': 'Já está no servidor',
|
||||||
|
'invite.noMatch': 'Nenhum amigo encontrado',
|
||||||
|
'invite.noFriends': 'Nenhum amigo ainda',
|
||||||
|
'invite.select': 'Escolha os amigos',
|
||||||
|
'invite.send': 'Enviar {count} convite',
|
||||||
|
'invite.sendPlural': 'Enviar {count} convites',
|
||||||
|
'invite.sending': 'Enviando...',
|
||||||
|
'invite.sent': 'Enviado',
|
||||||
|
'invite.retryFailed': 'Tentar de novo',
|
||||||
|
'invite.done': 'Pronto',
|
||||||
|
'invite.orLink': 'Ou compartilhe um link',
|
||||||
|
'invite.generating': 'Gerando...',
|
||||||
|
'invite.copy': 'Copiar',
|
||||||
|
'invite.copied': 'Copiado!',
|
||||||
|
'invite.linkFailed': 'Não foi possível gerar o link de convite',
|
||||||
|
'invite.fail.invite_invalid': 'O link de convite não vale mais',
|
||||||
|
'invite.fail.not_a_friend': 'Não é seu amigo',
|
||||||
|
'invite.fail.user_not_found': 'Usuário não encontrado',
|
||||||
|
'invite.fail.already_member': 'Já é membro',
|
||||||
|
'invite.fail.upstream': 'Não deu para verificar o convite — tente de novo',
|
||||||
|
'invite.fail.cannot_invite_self': 'Você não pode se convidar',
|
||||||
|
'invite.fail.invalid_body': 'Pedido inválido',
|
||||||
|
'invite.fail.invalid_target': 'Destino inválido',
|
||||||
|
'invite.fail.network': 'Não deu para falar com a sua instância',
|
||||||
|
'invite.fail.generic': 'Não deu para enviar (erro no servidor)',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from './spaceSt
|
|||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { isElectron } from '../platform/platform';
|
import { isElectron } from '../platform/platform';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starting volume for someone else's screen share, on the same 0-200 scale as
|
||||||
|
* every other volume here (100 = unity). Streams arrive loud — the publisher
|
||||||
|
* boosts system audio before sending — so a viewer who never touches the
|
||||||
|
* slider starts at half instead of blasting. Only the default: the per-stream
|
||||||
|
* slider still covers the full range, and a value the user set is persisted.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_STREAM_VOLUME = 50;
|
||||||
|
|
||||||
export interface ScreenShareConfig {
|
export interface ScreenShareConfig {
|
||||||
height: number | 'native';
|
height: number | 'native';
|
||||||
fps: number;
|
fps: number;
|
||||||
@@ -58,7 +67,7 @@ interface VoiceState {
|
|||||||
participantMutes: Map<string, boolean>;
|
participantMutes: Map<string, boolean>;
|
||||||
setParticipantMute: (userId: string, muted: boolean) => void;
|
setParticipantMute: (userId: string, muted: boolean) => void;
|
||||||
// Stream widget state
|
// Stream widget state
|
||||||
streamVolumes: Map<string, number>; // userId → 0-200 (100 default)
|
streamVolumes: Map<string, number>; // userId → 0-200 (DEFAULT_STREAM_VOLUME when unset)
|
||||||
streamMutes: Map<string, boolean>; // userId → muted?
|
streamMutes: Map<string, boolean>; // userId → muted?
|
||||||
watchingStreams: Set<string>; // userIds we're watching
|
watchingStreams: Set<string>; // userIds we're watching
|
||||||
unwatchedCameras: Set<string>; // userIds whose cameras we've opted out of
|
unwatchedCameras: Set<string>; // userIds whose cameras we've opted out of
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { useVoiceStore } from '../stores/voiceStore';
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
|
|
||||||
/** Base gain applied to every sound effect before the user's SFX slider. */
|
/**
|
||||||
export const SFX_BASE_VOLUME = 0.8;
|
* Base gain applied to every sound effect before the user's SFX slider.
|
||||||
|
*
|
||||||
|
* Halved from 0.8 on owner feedback: the cues read as harsh/clipped at the
|
||||||
|
* previous level. The generated .ogg files peak below full scale, so this is a
|
||||||
|
* playback-level change, not a re-render of the assets — one constant to move
|
||||||
|
* if the balance needs another pass.
|
||||||
|
*/
|
||||||
|
export const SFX_BASE_VOLUME = 0.4;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effective sound-effect gain: the base SFX volume scaled by the user's SFX
|
* Effective sound-effect gain: the base SFX volume scaled by the user's SFX
|
||||||
|
|||||||
@@ -30,3 +30,12 @@ Para regerar: `python3 <script>.py` produz `.wav`, depois converter com
|
|||||||
|
|
||||||
Ajustes comuns: `HARM` muda o timbre, `tau` a duração, `step` a velocidade do
|
Ajustes comuns: `HARM` muda o timbre, `tau` a duração, `step` a velocidade do
|
||||||
arpejo, e o alvo em `write()` o volume (pico RMS).
|
arpejo, e o alvo em `write()` o volume (pico RMS).
|
||||||
|
|
||||||
|
## Volume de reprodução
|
||||||
|
|
||||||
|
O nível com que os arquivos tocam **não** está aqui: está em
|
||||||
|
`packages/web/src/utils/sfx.ts`, na constante `SFX_BASE_VOLUME`, multiplicada
|
||||||
|
pelo controle de efeitos sonoros do usuário. Em 2026-09-02 ela caiu de `0.8`
|
||||||
|
para `0.4` — o dono achou os sons estourados. Os `.ogg` não foram regerados:
|
||||||
|
eles já têm pico abaixo do máximo, então o problema era de nível, não de
|
||||||
|
distorção gravada. Mexa nessa constante, não nos alvos de RMS daqui.
|
||||||
|
|||||||
Reference in New Issue
Block a user