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

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:
2026-09-02 11:59:25 -03:00
co-authored by Claude Opus 5
parent 293378293d
commit 860f02b2c1
14 changed files with 672 additions and 157 deletions
@@ -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<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({
friend,
@@ -98,6 +104,7 @@ interface FriendsPageProps {
}
export function FriendsPage({ mobile }: FriendsPageProps) {
const t = useT();
const [activeTab, setActiveTab] = useState<Tab>('online');
const [pendingUnfriend, setPendingUnfriend] = useState<{ id: string; name: string } | null>(null);
const navigate = useNavigate();
@@ -172,12 +179,12 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
return (
<div className="flex-1 overflow-y-auto p-4">
<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>
{onlineFriends.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full opacity-80">
<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>
) : (
<>
@@ -192,12 +199,12 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
return (
<div className="flex-1 overflow-y-auto p-4">
<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>
{friends.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full opacity-80">
<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>
) : (
<>
@@ -212,12 +219,12 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
return (
<div className="flex-1 overflow-y-auto p-4">
<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>
{[...pendingIncoming, ...pendingOutgoing].length === 0 ? (
<div className="flex flex-col items-center justify-center h-full opacity-80">
<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>
) : (
<>
@@ -292,7 +299,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
<div className="flex flex-col items-center justify-center py-16 text-center">
<Mascot state="sleeping" className="w-[100px] h-[100px]" />
<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>
) : (
@@ -300,7 +307,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
{activeFriends.length > 0 && (
<div className="mb-4">
<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>
{activeFriends.map(f => renderActivityFriend(f))}
</div>
@@ -308,7 +315,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
{idleFriends.length > 0 && (
<div className="mb-4">
<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>
{idleFriends.map(f => renderActivityFriend(f))}
</div>
@@ -316,7 +323,7 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
{offlineActivityFriends.length > 0 && (
<div>
<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>
{offlineActivityFriends.map(f => renderActivityFriend(f, true))}
</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" />
</svg>
</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 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">
<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>
<span className="font-bold text-txt-primary">Friends</span>
<span className="font-bold text-txt-primary">{t('friends.title')}</span>
</div>
<div className="w-[1px] h-6 bg-surface-elevated mx-2" />
<div className="flex items-center gap-4 ml-2">
<TabButton active={activeTab === 'online'} onClick={() => setActiveTab('online')}>Online</TabButton>
<TabButton active={activeTab === 'all'} onClick={() => setActiveTab('all')}>All</TabButton>
<TabButton active={activeTab === 'online'} onClick={() => setActiveTab('online')}>{t('friends.tab.online')}</TabButton>
<TabButton active={activeTab === 'all'} onClick={() => setActiveTab('all')}>{t('friends.tab.all')}</TabButton>
<TabButton active={activeTab === 'pending'} onClick={() => setActiveTab('pending')}>
Pending
{t('friends.tab.pending')}
{(pendingIncoming.length > 0) && (
<span className="ml-2 px-1.5 py-0.5 bg-accent-rose text-white text-[10px] rounded-full leading-none">
{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')}
</button>
</div>
<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'
}`}
>
{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 && (
<span className="ml-1.5 px-1.5 py-0.5 text-xs bg-notification text-white rounded-full">
{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"
/>
</div>
@@ -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 (
<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">
<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>
<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>
<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 */}
<div className="relative mb-4">
<input
type="text"
placeholder="Search or add by username..."
placeholder={t('friends.add.searchPlaceholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
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" />
</svg>
<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>
<button
onClick={handleDirectAdd}
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"
>
{directAddLoading ? 'Sending...' : 'Send Request'}
{directAddLoading ? t('friends.add.sending') : t('friends.add.send')}
</button>
</div>
)}
@@ -611,7 +635,7 @@ function AddFriendTab({
<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" />
</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>
)}
@@ -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')}
</button>
)}
{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')}
</button>
)}
{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
<span className="text-txt-tertiary text-[13px] opacity-60 group-hover:opacity-100 transition-opacity font-medium">@{friend.username}</span>
</div>
<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 && (
<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
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"
title="Message"
title={t('friends.action.message')}
>
<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" />
@@ -933,7 +959,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove
<button
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"
title="Remove Friend"
title={t('friends.action.remove')}
>
<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" />
@@ -951,6 +977,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
onDecline?: () => void;
onCancel?: () => void;
}) {
const t = useT();
const rawUser = request.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);
@@ -970,7 +997,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
<span className="text-txt-tertiary text-xs">@{user.username}</span>
</div>
<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 && (
<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
onClick={() => onAccept?.()}
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">
<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
onClick={() => onDecline?.()}
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">
<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
onClick={() => onCancel?.()}
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">
<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" />