diff --git a/docs/roadmap-resenha.md b/docs/roadmap-resenha.md index ca9bdcfe..db2d78a4 100644 --- a/docs/roadmap-resenha.md +++ b/docs/roadmap-resenha.md @@ -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 | | 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 | +| 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 | ## 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) - Busca (rótulos, filtros e sintaxe embutida) - 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 -Mensagens e composer · lista de membros · servidores e canais · amigos e DMs · -modais de convite · configurações restantes · telas de erro +Configurações restantes (conta, vídeo, atalhos, instância) · configurações do +servidor e cargos · telas de erro · explorar servidores · DMs em grupo +(`GroupDmSettings`, `AddDmMemberModal`) ## Correções pendentes diff --git a/packages/server/src/routes/channels.ts b/packages/server/src/routes/channels.ts index 805b1329..b30a9e71 100644 --- a/packages/server/src/routes/channels.ts +++ b/packages/server/src/routes/channels.ts @@ -15,6 +15,11 @@ import type { Channel, ChannelCategory, } 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 { return { @@ -190,8 +195,8 @@ export async function channelRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'Channel name is required', statusCode: 400 }); } - const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-'); - if (trimmedName.length < 1 || trimmedName.length > 100) { + const trimmedName = normalizeChannelName(name); + 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 }); } @@ -296,8 +301,8 @@ export async function channelRoutes(app: FastifyInstance): Promise { const updates: Partial = {}; if (name !== undefined) { - const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-'); - if (trimmedName.length < 1 || trimmedName.length > 100) { + const trimmedName = normalizeChannelName(name); + 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 }); } updates.name = trimmedName; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 1a8d9b40..d33e2052 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -45,3 +45,20 @@ export const GROUP_DM_NAME_MAX_LENGTH = 50; 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_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, '-'); +} diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index 6d237226..1d008f41 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -22,8 +22,14 @@ import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils import { useCanonicalUserView } from '../../utils/userViewLookup'; import { Username } from '../ui/Username'; import { ConfirmDialog } from '../ui/ConfirmDialog'; +import { useT, type TranslationKey } from '../../i18n'; -const statusLabel: Record = { online: 'Online', idle: 'Idle', dnd: 'Do Not Disturb', offline: 'Offline' }; +const statusLabelKey: Record = { + online: 'friends.status.online', + idle: 'friends.status.idle', + dnd: 'friends.status.dnd', + offline: 'friends.status.offline', +}; function ActivityFriendItem({ friend, @@ -98,6 +104,7 @@ interface FriendsPageProps { } export function FriendsPage({ mobile }: FriendsPageProps) { + const t = useT(); const [activeTab, setActiveTab] = useState('online'); const [pendingUnfriend, setPendingUnfriend] = useState<{ id: string; name: string } | null>(null); const navigate = useNavigate(); @@ -172,12 +179,12 @@ export function FriendsPage({ mobile }: FriendsPageProps) { return (

- Online — {onlineFriends.length} + {t('friends.heading.online', { count: onlineFriends.length })}

{onlineFriends.length === 0 ? (
-

No one's online right now.

+

{t('friends.empty.online')}

) : ( <> @@ -192,12 +199,12 @@ export function FriendsPage({ mobile }: FriendsPageProps) { return (

- All Friends — {friends.length} + {t('friends.heading.all', { count: friends.length })}

{friends.length === 0 ? (
-

No friends yet — add someone!

+

{t('friends.empty.all')}

) : ( <> @@ -212,12 +219,12 @@ export function FriendsPage({ mobile }: FriendsPageProps) { return (

- Pending — {pendingIncoming.length + pendingOutgoing.length} + {t('friends.heading.pending', { count: pendingIncoming.length + pendingOutgoing.length })}

{[...pendingIncoming, ...pendingOutgoing].length === 0 ? (
-

No pending requests — Nori is napping.

+

{t('friends.empty.pending')}

) : ( <> @@ -292,7 +299,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
- It's quiet for now... When friends start an activity, we'll show it here! + {t('friends.empty.activity')}
) : ( @@ -300,7 +307,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) { {activeFriends.length > 0 && (

- Active — {activeFriends.length} + {t('friends.heading.active', { count: activeFriends.length })}

{activeFriends.map(f => renderActivityFriend(f))}
@@ -308,7 +315,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) { {idleFriends.length > 0 && (

- Online — {idleFriends.length} + {t('friends.heading.online', { count: idleFriends.length })}

{idleFriends.map(f => renderActivityFriend(f))}
@@ -316,7 +323,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) { {offlineActivityFriends.length > 0 && (

- Offline — {offlineActivityFriends.length} + {t('friends.heading.offline', { count: offlineActivityFriends.length })}

{offlineActivityFriends.map(f => renderActivityFriend(f, true))}
@@ -341,7 +348,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) { - Friends + {t('friends.title')}
) : (
@@ -349,14 +356,14 @@ export function FriendsPage({ mobile }: FriendsPageProps) { - Friends + {t('friends.title')}
- setActiveTab('online')}>Online - setActiveTab('all')}>All + setActiveTab('online')}>{t('friends.tab.online')} + setActiveTab('all')}>{t('friends.tab.all')} setActiveTab('pending')}> - Pending + {t('friends.tab.pending')} {(pendingIncoming.length > 0) && ( {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' }`} > - Add Friend + {t('friends.tab.add')}
@@ -391,7 +398,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) { : '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 && ( {pendingIncoming.length} @@ -413,9 +420,11 @@ export function FriendsPage({ mobile }: FriendsPageProps) { setPendingUnfriend(null); } }} - title="Remove Friend" - 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.`} - confirmLabel="Remove" + title={t('friends.remove.title')} + description={t('friends.remove.body', { + name: pendingUnfriend?.name ?? t('friends.remove.someone'), + })} + confirmLabel={t('friends.remove.confirm')} variant="danger" />
@@ -429,6 +438,7 @@ function AddFriendTab({ }: { onOpenDm: (userId: string, homeUserId?: string, homeInstance?: string | null) => void; }) { + const t = useT(); const searchUsers = useSocialStore((s) => s.searchUsers); const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest); const friends = useSocialStore((s) => s.friends); @@ -527,7 +537,7 @@ function AddFriendTab({ setDirectAddLoading(true); try { await sendFriendRequest(query.trim()); - addToast('Friend request sent!', 'success'); + addToast(t('friends.add.sent'), 'success'); setQuery(''); } catch (err) { // The shared API client throws `new Error(body.error)` for non-2xx @@ -548,8 +558,8 @@ function AddFriendTab({ const displayUsers = isSearchMode ? enrichedSearchResults : discoverUsers; const displayLoading = isSearchMode ? searchLoading : discoverLoading; const emptyLabel = isSearchMode - ? 'No users match your search.' - : 'No discoverable users yet — invite people to join!'; + ? t('friends.add.empty.search') + : t('friends.add.empty.discover'); return (
@@ -559,15 +569,29 @@ function AddFriendTab({ -

Find People

+

{t('friends.add.title')}

-

Search by username or use user@instance to add someone directly.

+

+ {/* 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} + user@instance + {after} + + ); + })()} +

{/* Unified search input */}
setQuery(e.target.value)} className="input-search w-full px-4 py-3 rounded-lg" @@ -591,14 +615,14 @@ function AddFriendTab({
- Send friend request to {directAddDisplay} + {t('friends.add.directTo')} {directAddDisplay}
)} @@ -611,7 +635,7 @@ function AddFriendTab({ - Discover People + {t('friends.add.discover')}
)} @@ -665,6 +689,7 @@ function UserDiscoverCard({ onOpenDm: (userId: string, homeUserId?: string, homeInstance?: string | null) => void; onRelationshipChange: (userId: string, origin: string, relationship: TaggedDiscoverUser['relationship'], requestId?: string) => void; }) { + const t = useT(); const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest); const updateFriendRequest = useSocialStore((s) => s.updateFriendRequest); const openModal = useUIStore((s) => s.openModal); @@ -695,7 +720,7 @@ function UserDiscoverCard({ } catch (err) { // See handleDirectAdd above — err.message is the server error code. 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 { setActionLoading(false); } @@ -709,7 +734,7 @@ function UserDiscoverCard({ await updateFriendRequest(user.requestId, 'accepted'); onRelationshipChange(user.id, user._instanceOrigin, 'friends'); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to accept request'); + setError(err instanceof Error ? err.message : t('friends.accept.failed')); } finally { setActionLoading(false); } @@ -723,7 +748,7 @@ function UserDiscoverCard({ await updateFriendRequest(user.requestId, 'declined'); onRelationshipChange(user.id, user._instanceOrigin, 'none'); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to decline request'); + setError(err instanceof Error ? err.message : t('friends.decline.failed')); } finally { setActionLoading(false); } @@ -741,7 +766,7 @@ function UserDiscoverCard({ await client.social.cancelRequest(user.requestId); onRelationshipChange(user.id, user._instanceOrigin, 'none'); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to cancel request'); + setError(err instanceof Error ? err.message : t('friends.cancel.failed')); } finally { setActionLoading(false); } @@ -840,7 +865,7 @@ function UserDiscoverCard({ 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" > - {actionLoading ? 'Sending...' : 'Send Friend Request'} + {actionLoading ? t('friends.add.sending') : t('friends.add.sendRequest')} )} {user.relationship === 'outbound_pending' && ( @@ -849,7 +874,7 @@ function UserDiscoverCard({ 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" > - {actionLoading ? 'Cancelling...' : 'Request Pending'} + {actionLoading ? t('friends.add.cancelling') : t('friends.add.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 }) { + const t = useT(); const canonical = useCanonicalUserView(friend as unknown as User); const instanceLabel = friend._instanceOrigin ? (() => { try { return new URL(friend._instanceOrigin).host; } catch { return friend._instanceOrigin; } })() : ''; const { baseName: friendBaseName } = parseFederatedUsername(canonical.username); @@ -913,7 +939,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove @{friend.username}
- {statusLabel[friend.status] ?? friend.status} + {statusLabelKey[friend.status] ? t(statusLabelKey[friend.status]!) : friend.status} {instanceLabel && ( via {instanceLabel} )} @@ -924,7 +950,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove
- {type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'} + {t(type === 'incoming' ? 'friends.request.incoming' : 'friends.request.outgoing')} {instanceLabel && ( via {instanceLabel} )} @@ -983,7 +1010,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: { + {isDirty && ( + + )} +
+ )} + + {(error || saveError) && (
- {error} + {error || saveError}
)}
-
Private Channel
+
{t('channel.private.title')}
- Only selected members and roles will be able to view this channel. + {t('channel.private.description')}
@@ -100,20 +236,18 @@ function OverviewTab({ - - This channel is hidden from members without explicit access. Users with the Administrator permission or space owners can always see all channels. - + {t('channel.private.note')}
)} {canManageChannels && (
- +
)} @@ -131,6 +265,7 @@ export function ChannelSettingsModal() { const channels = useSpaceStore((s) => s.channels); const spaces = useSpaceStore((s) => s.spaces); const spacePermissions = useSpaceStore((s) => s.spacePermissions); + const t = useT(); const [tab, setTab] = useState<'overview' | 'permissions'>('overview'); const [isPrivate, setIsPrivate] = useState(false); @@ -181,7 +316,7 @@ export function ChannelSettingsModal() { } }) .catch((err: Error) => { - setError(err.message || 'Failed to load channel overrides'); + setError(err.message || translate(useLocaleStore.getState().locale, 'channel.private.loadFailed')); }) .finally(() => { setIsFetching(false); @@ -224,12 +359,21 @@ export function ChannelSettingsModal() { // Re-fetch to keep in sync fetchPrivateState(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to update channel privacy'); + setError(err instanceof Error ? err.message : t('channel.private.updateFailed')); } finally { 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 () => { if (!channelId || !currentSpaceId) return; setIsDeleting(true); @@ -238,7 +382,7 @@ export function ChannelSettingsModal() { await channelApi.channels.delete(channelId); closeModal(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to delete channel'); + setError(err instanceof Error ? err.message : t('channel.delete.failed')); setIsDeleting(false); } }; @@ -252,17 +396,17 @@ export function ChannelSettingsModal() { return ( <> - + {showTabs ? (
{/* Tabs */}
@@ -271,8 +415,8 @@ export function ChannelSettingsModal() {
{tab === 'overview' && ( setShowDeleteConfirm(true)} + onSaveDetails={handleSaveDetails} /> )} {tab === 'permissions' && ( @@ -288,7 +433,9 @@ export function ChannelSettingsModal() { entityId={channelId} spaceId={currentSpaceId} 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={() => { const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); return channelApi.channels.getOverrides(channelId); @@ -307,8 +454,8 @@ export function ChannelSettingsModal() {
) : ( setShowDeleteConfirm(true)} + onSaveDetails={handleSaveDetails} /> )} @@ -325,13 +473,13 @@ export function ChannelSettingsModal() { isOpen={showDeleteConfirm} onClose={() => setShowDeleteConfirm(false)} onConfirm={handleDeleteChannel} - title={`Delete #${channel.name}?`} + title={t('channel.delete.title', { name: channel.name })} description={<> - This will permanently delete #{channel.name} and all of its messages. - {channel.type === 'voice' && ' Any users currently in this voice channel will be disconnected.'} - {' '}This action cannot be undone. + {t('channel.delete.body', { name: channel.name })} + {channel.type === 'voice' && ` ${t('channel.delete.voiceNote')}`} + {' '}{t('channel.delete.irreversible')} } - confirmLabel="Delete Channel" + confirmLabel={t('channel.delete')} variant="danger" loading={isDeleting} /> diff --git a/packages/web/src/components/modals/InviteModal.tsx b/packages/web/src/components/modals/InviteModal.tsx index 93892327..d8846df9 100644 --- a/packages/web/src/components/modals/InviteModal.tsx +++ b/packages/web/src/components/modals/InviteModal.tsx @@ -9,36 +9,41 @@ import { api } from '../../api/client'; import { isSelf, parseFederatedUsername } from '../../utils/identity'; import { useCanonicalUserView } from '../../utils/userViewLookup'; import type { Friend, MemberWithUser, SpaceInviteRequest, User } from '@backspace/shared'; +import { useT, type TranslationKey } from '../../i18n'; type SendStatus = | { kind: 'pending' } | { kind: 'success' } - | { kind: 'failure'; reason: string }; + | { kind: 'failure'; reasonKey: TranslationKey }; -const FAILURE_COPY: Record = { - invite_invalid: 'Invite link no longer valid', - not_a_friend: 'Not a friend', - user_not_found: 'User not found', - already_member: 'Already a member', - upstream: "Couldn't verify invite — try again", - cannot_invite_self: 'Cannot invite yourself', - invalid_body: 'Invalid request', - invalid_target: 'Invalid target', +const FAILURE_KEYS: Record = { + invite_invalid: 'invite.fail.invite_invalid', + not_a_friend: 'invite.fail.not_a_friend', + user_not_found: 'invite.fail.user_not_found', + already_member: 'invite.fail.already_member', + upstream: 'invite.fail.upstream', + cannot_invite_self: 'invite.fail.cannot_invite_self', + invalid_body: 'invite.fail.invalid_body', + 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) { // The api client throws Error(message) where message is the server's // `error` field (e.g. 'invite_invalid'). Fall back to message-based // network detection for transport failures. const msg = error.message; - const mapped = FAILURE_COPY[msg]; + const mapped = FAILURE_KEYS[msg]; if (mapped) return mapped; 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({ @@ -48,6 +53,7 @@ function InviteResultFriendRow({ friend: Friend; status: SendStatus | undefined; }) { + const t = useT(); const canonical = useCanonicalUserView(friend as unknown as User); const { baseName } = parseFederatedUsername(canonical.username); const dn = canonical.displayName ?? baseName; @@ -65,10 +71,10 @@ function InviteResultFriendRow({
@{canonical.username}
{status?.kind === 'success' && ( - ✓ Sent + ✓ {t('invite.sent')} )} {status?.kind === 'failure' && ( - ✗ {status.reason} + ✗ {t(status.reasonKey)} )} {status?.kind === 'pending' && ( ... @@ -90,6 +96,7 @@ function InviteSelectFriendRow({ sending: boolean; onToggle: (id: string, friend: Friend) => void; }) { + const t = useT(); const canonical = useCanonicalUserView(friend as unknown as User); const { baseName } = parseFederatedUsername(canonical.username); const dn = canonical.displayName ?? baseName; @@ -116,7 +123,7 @@ function InviteSelectFriendRow({
{dn}
- {alreadyMember ? 'Already in space' : `@${canonical.username}`} + {alreadyMember ? t('invite.alreadyInSpace') : `@${canonical.username}`}
{!alreadyMember && ( @@ -153,6 +160,7 @@ export function InviteModal() { // the /invite endpoint 403s. Show an explanatory notice instead of the invite // affordances, and skip the invite-code fetch entirely. const isRequestOnly = currentSpace?.visibility === 'request'; + const t = useT(); const [inviteCode, setInviteCode] = useState(''); const [codeError, setCodeError] = useState(''); @@ -180,7 +188,7 @@ export function InviteModal() { setCodeLoading(false); }, (err) => { - setCodeError((err as Error)?.message ?? 'Failed to generate invite link'); + setCodeError((err as Error)?.message ?? t('invite.linkFailed')); setCodeLoading(false); }, ); @@ -276,7 +284,7 @@ export function InviteModal() { } catch (err) { return { 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 submitLabel = selectedFriends.length === 0 - ? 'Select Friends' - : `Send ${selectedFriends.length} Invite${selectedFriends.length > 1 ? 's' : ''}`; + ? t('invite.select') + : t(selectedFriends.length > 1 ? 'invite.sendPlural' : 'invite.send', { + count: selectedFriends.length, + }); const hasFailures = inResultsView && selectedFriends.some((f) => results.get(f.id)?.kind === 'failure'); @@ -334,26 +344,25 @@ export function InviteModal() { {isRequestOnly ? (

- This space uses join requests — people join by requesting approval - from a manager, so it has no invite link to share. + {t('invite.requestOnly')}

) : (

- Send to friends, or share a link. + {t('invite.subtitle')}

{/* Selected chips — hidden in results view */} @@ -368,7 +377,7 @@ export function InviteModal() { @@ -384,7 +393,7 @@ export function InviteModal() { type="text" value={query} onChange={(e) => setQuery(e.target.value)} - placeholder="Search friends..." + placeholder={t('invite.searchPlaceholder')} className="input-search w-full py-2 text-[14px]" /> )} @@ -403,9 +412,7 @@ export function InviteModal() { <> {filteredFriends.length === 0 && (
- {query.trim() - ? 'No friends match your search' - : 'No friends yet'} + {query.trim() ? t('invite.noMatch') : t('invite.noFriends')}
)} {filteredFriends.map((friend) => ( @@ -431,14 +438,14 @@ export function InviteModal() { 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" > - Retry failed + {t('invite.retryFailed')} )}
) : ( @@ -447,14 +454,14 @@ export function InviteModal() { 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" > - {sending ? 'Sending...' : submitLabel} + {sending ? t('invite.sending') : submitLabel} )} {/* Share-link footer */}

- Or share a link + {t('invite.orLink')}

{codeError && (
{codeError}
@@ -462,7 +469,7 @@ export function InviteModal() {
@@ -473,7 +480,7 @@ export function InviteModal() { linkCopied ? 'text-accent-mint' : 'text-txt-primary' }`} > - {linkCopied ? 'Copied!' : 'Copy'} + {linkCopied ? t('invite.copied') : t('invite.copy')}
diff --git a/packages/web/src/components/voice/GlobalAudioRenderer.tsx b/packages/web/src/components/voice/GlobalAudioRenderer.tsx index a08202b1..cc3da6a6 100644 --- a/packages/web/src/components/voice/GlobalAudioRenderer.tsx +++ b/packages/web/src/components/voice/GlobalAudioRenderer.tsx @@ -1,5 +1,5 @@ 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 { useAudioTrackPlayer } from '../../hooks/useAudioTrackPlayer'; import type { ParticipantInfo } from '../../hooks/useLiveKit'; @@ -97,7 +97,7 @@ export function GlobalAudioRenderer() { {remoteParticipants.map((p: ParticipantInfo) => { const micVolume = participantVolumes.get(p.userId) ?? 100; 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; return ( diff --git a/packages/web/src/components/voice/StreamTile.tsx b/packages/web/src/components/voice/StreamTile.tsx index d5b8b359..30dc1e73 100644 --- a/packages/web/src/components/voice/StreamTile.tsx +++ b/packages/web/src/components/voice/StreamTile.tsx @@ -1,6 +1,6 @@ import React, { useRef, useEffect, useState, useCallback } from 'react'; 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 { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit'; import { stopScreenShare, changeScreenShare } from '../../utils/screenShare'; @@ -60,7 +60,7 @@ function StreamQualityItem() { /** Wrapper component for stream volume slider — needs store subscription. */ 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); return ( diff --git a/packages/web/src/i18n/locales/en.ts b/packages/web/src/i18n/locales/en.ts index 78133b05..3790a5e8 100644 --- a/packages/web/src/i18n/locales/en.ts +++ b/packages/web/src/i18n/locales/en.ts @@ -227,6 +227,142 @@ export const en = { 'profile.activity.watching': 'Watching', 'profile.activity.streaming': 'Streaming', '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; export type TranslationKey = keyof typeof en; diff --git a/packages/web/src/i18n/locales/pt-BR.ts b/packages/web/src/i18n/locales/pt-BR.ts index b595f2d1..1d0e2927 100644 --- a/packages/web/src/i18n/locales/pt-BR.ts +++ b/packages/web/src/i18n/locales/pt-BR.ts @@ -226,4 +226,140 @@ export const ptBR: Partial = { 'profile.activity.watching': 'Assistindo', 'profile.activity.streaming': 'Transmitindo', '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)', }; diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index b5be4b5f..83114181 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -6,6 +6,15 @@ import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from './spaceSt import { useAuthStore } from './authStore'; 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 { height: number | 'native'; fps: number; @@ -58,7 +67,7 @@ interface VoiceState { participantMutes: Map; setParticipantMute: (userId: string, muted: boolean) => void; // Stream widget state - streamVolumes: Map; // userId → 0-200 (100 default) + streamVolumes: Map; // userId → 0-200 (DEFAULT_STREAM_VOLUME when unset) streamMutes: Map; // userId → muted? watchingStreams: Set; // userIds we're watching unwatchedCameras: Set; // userIds whose cameras we've opted out of diff --git a/packages/web/src/utils/sfx.ts b/packages/web/src/utils/sfx.ts index 7e16b93e..0f09dd4c 100644 --- a/packages/web/src/utils/sfx.ts +++ b/packages/web/src/utils/sfx.ts @@ -1,7 +1,14 @@ 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 diff --git a/tools/sfx/README.md b/tools/sfx/README.md index acd367a3..5bcad058 100644 --- a/tools/sfx/README.md +++ b/tools/sfx/README.md @@ -30,3 +30,12 @@ Para regerar: `python3