feat: Rename to Toju and add translation
Some checks failed
Deploy Web Apps / deploy (push) Successful in 5m52s
Build Android APK / build-android-apk (push) Failing after 23m15s
Queue Release Build / prepare (push) Successful in 1m42s
Queue Release Build / build-linux (push) Failing after 9m33s
Queue Release Build / build-windows (push) Successful in 26m5s
Queue Release Build / finalize (push) Has been skipped

This commit is contained in:
2026-06-05 17:13:03 +02:00
parent 8ecfc9a1fe
commit ee293d7daf
301 changed files with 8247 additions and 2218 deletions

View File

@@ -4,12 +4,12 @@
<header class="min-w-0 space-y-1">
<h1 class="text-2xl font-semibold text-foreground">
@if (currentUser()) {
Welcome back, {{ currentUser()!.displayName || 'there' }}
{{ 'dashboard.welcomeBack' | translate: { name: currentUser()!.displayName || ('dashboard.welcomeGuestFallback' | translate) } }}
} @else {
Welcome to MetoYou
{{ 'dashboard.welcomeTitle' | translate }}
}
</h1>
<p class="text-sm text-muted-foreground">Find people, discover servers, or start your own community.</p>
<p class="text-sm text-muted-foreground">{{ 'dashboard.subtitle' | translate }}</p>
</header>
<div class="min-w-0">
@@ -21,9 +21,9 @@
<input
#searchInput
type="text"
aria-label="Search people, servers, and invites"
[attr.aria-label]="'dashboard.searchAriaLabel' | translate"
class="h-12 w-full min-w-0 rounded-xl border border-border bg-secondary py-2 pl-11 pr-4 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary sm:pr-20"
[placeholder]="isMobile() ? 'Search people, servers, invites...' : 'Search for people, servers, or paste an invite...'"
[placeholder]="isMobile() ? ('dashboard.searchPlaceholderMobile' | translate) : ('dashboard.searchPlaceholderDesktop' | translate)"
[ngModel]="searchQuery()"
(ngModelChange)="onSearchChange($event)"
(keydown.enter)="submitSearch()"
@@ -31,13 +31,13 @@
<kbd
class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-1 rounded border border-border bg-card px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:flex"
>
Ctrl K
{{ 'dashboard.searchShortcut' | translate }}
</kbd>
</div>
@if (!isSearchMode() && recentSearches().length > 0) {
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs font-medium text-muted-foreground">Recent:</span>
<span class="text-xs font-medium text-muted-foreground">{{ 'dashboard.recent' | translate }}</span>
@for (term of recentSearches(); track term) {
<span
class="group inline-flex items-center gap-1 rounded-full border border-border bg-secondary py-1 pl-3 pr-1 text-xs text-foreground"
@@ -52,7 +52,7 @@
<button
type="button"
class="grid h-4 w-4 place-items-center rounded-full text-muted-foreground hover:bg-card hover:text-foreground"
[attr.aria-label]="'Remove ' + term"
[attr.aria-label]="'dashboard.removeRecent' | translate: { term }"
(click)="removeRecentSearch(term)"
>
<ng-icon
@@ -67,7 +67,7 @@
class="text-xs font-medium text-muted-foreground hover:text-foreground hover:underline"
(click)="clearRecentSearches()"
>
Clear
{{ 'common.clear' | translate }}
</button>
</div>
}
@@ -77,7 +77,7 @@
<section class="space-y-5">
@if (inviteResult(); as invite) {
<div>
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Invite</h2>
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ 'dashboard.invite' | translate }}</h2>
<button
type="button"
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
@@ -90,7 +90,7 @@
/>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">Open invite</p>
<p class="truncate text-sm font-semibold text-foreground">{{ 'dashboard.openInvite' | translate }}</p>
<p class="truncate text-xs text-muted-foreground">{{ invite }}</p>
</div>
<ng-icon
@@ -104,11 +104,11 @@
@if (topServerResults().length > 0) {
<div>
<div class="mb-2 flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Servers</h2>
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ 'dashboard.servers' | translate }}</h2>
<a
routerLink="/servers"
class="text-xs font-medium text-primary hover:underline"
>View all</a
>{{ 'common.viewAll' | translate }}</a
>
</div>
<div class="space-y-2">
@@ -148,11 +148,11 @@
@if (topPeopleResults().length > 0) {
<div>
<div class="mb-2 flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">People</h2>
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ 'dashboard.people' | translate }}</h2>
<a
routerLink="/people"
class="text-xs font-medium text-primary hover:underline"
>View all</a
>{{ 'common.viewAll' | translate }}</a
>
</div>
<div class="space-y-2">
@@ -170,7 +170,7 @@
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ personLabel(person) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
<p class="text-xs text-muted-foreground">{{ (isOnline(person) ? 'common.online' : 'common.offline') | translate }}</p>
</div>
<app-friend-button [user]="person" />
</a>
@@ -181,7 +181,7 @@
@if (hasNoQuickResults() && !isSearching()) {
<div class="rounded-lg border border-border bg-card px-4 py-8 text-center text-sm text-muted-foreground">
No people, servers, or invites match
{{ 'dashboard.noResults' | translate }}
<span class="font-medium text-foreground">{{ searchQuery() }}</span
>.
</div>
@@ -201,8 +201,8 @@
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">Find People</p>
<p class="mt-0.5 text-xs text-muted-foreground">Connect with friends.</p>
<p class="text-sm font-semibold text-foreground">{{ 'dashboard.findPeople.title' | translate }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{{ 'dashboard.findPeople.subtitle' | translate }}</p>
</div>
<ng-icon
name="lucideArrowRight"
@@ -221,8 +221,8 @@
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">Find Servers</p>
<p class="mt-0.5 text-xs text-muted-foreground">Browse communities.</p>
<p class="text-sm font-semibold text-foreground">{{ 'dashboard.findServers.title' | translate }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{{ 'dashboard.findServers.subtitle' | translate }}</p>
</div>
<ng-icon
name="lucideArrowRight"
@@ -241,8 +241,8 @@
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">Create Server</p>
<p class="mt-0.5 text-xs text-muted-foreground">Start your own.</p>
<p class="text-sm font-semibold text-foreground">{{ 'dashboard.createServer.title' | translate }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{{ 'dashboard.createServer.subtitle' | translate }}</p>
</div>
<ng-icon
name="lucideArrowRight"
@@ -259,9 +259,9 @@
class="h-6 w-6 text-muted-foreground"
/>
</div>
<h2 class="text-base font-semibold text-foreground">Get started</h2>
<h2 class="text-base font-semibold text-foreground">{{ 'dashboard.getStarted.title' | translate }}</h2>
<p class="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">
You have not joined any servers yet. Find a community to join, or create your own to invite friends.
{{ 'dashboard.getStarted.description' | translate }}
</p>
</section>
}
@@ -270,11 +270,11 @@
<section class="grid min-w-0 gap-4 lg:grid-cols-2">
<div class="min-w-0 rounded-xl border border-border bg-card/40 p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">People you might know</h2>
<h2 class="text-sm font-semibold text-foreground">{{ 'dashboard.peopleYouMightKnow' | translate }}</h2>
<a
routerLink="/people"
class="text-xs font-medium text-primary hover:underline"
>See all</a
>{{ 'common.seeAll' | translate }}</a
>
</div>
@if (peopleYouMightKnow().length > 0) {
@@ -290,7 +290,7 @@
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(person) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
<p class="text-xs text-muted-foreground">{{ (isOnline(person) ? 'common.online' : 'common.offline') | translate }}</p>
</div>
<app-friend-button
class="shrink-0"
@@ -300,17 +300,17 @@
}
</div>
} @else {
<p class="py-6 text-center text-sm text-muted-foreground">No people to suggest yet.</p>
<p class="py-6 text-center text-sm text-muted-foreground">{{ 'dashboard.noPeopleSuggestions' | translate }}</p>
}
</div>
<div class="min-w-0 rounded-xl border border-border bg-card/40 p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">Popular Servers</h2>
<h2 class="text-sm font-semibold text-foreground">{{ 'dashboard.popularServers' | translate }}</h2>
<a
routerLink="/servers"
class="text-xs font-medium text-primary hover:underline"
>See all</a
>{{ 'common.seeAll' | translate }}</a
>
</div>
@if (popularServers().length > 0) {
@@ -339,13 +339,13 @@
class="shrink-0 rounded-md bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
(click)="openServer(server)"
>
Join
{{ 'common.join' | translate }}
</button>
</div>
}
</div>
} @else {
<p class="py-6 text-center text-sm text-muted-foreground">No popular servers right now.</p>
<p class="py-6 text-center text-sm text-muted-foreground">{{ 'dashboard.noPopularServers' | translate }}</p>
}
</div>
</section>
@@ -354,11 +354,11 @@
@if (friends().length > 0) {
<section>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">Your Friends</h2>
<h2 class="text-sm font-semibold text-foreground">{{ 'dashboard.yourFriends' | translate }}</h2>
<a
routerLink="/people"
class="text-xs font-medium text-primary hover:underline"
>Manage</a
>{{ 'common.manage' | translate }}</a
>
</div>
<div class="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
@@ -373,7 +373,7 @@
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(friend) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(friend) ? 'Online' : 'Offline' }}</p>
<p class="text-xs text-muted-foreground">{{ (isOnline(friend) ? 'common.online' : 'common.offline') | translate }}</p>
</div>
<app-friend-button [user]="friend" />
</div>
@@ -385,7 +385,7 @@
<!-- Recently active servers -->
@if (recentlyActiveServers().length > 0) {
<section>
<h2 class="mb-3 text-sm font-semibold text-foreground">Recently Active Servers</h2>
<h2 class="mb-3 text-sm font-semibold text-foreground">{{ 'dashboard.recentlyActiveServers' | translate }}</h2>
<div class="grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
@for (room of recentlyActiveServers(); track room.id) {
<button
@@ -407,7 +407,7 @@
}
</div>
<p class="w-full truncate text-sm font-medium text-foreground">{{ room.name }}</p>
<p class="text-xs text-muted-foreground">{{ room.userCount }} members</p>
<p class="text-xs text-muted-foreground">{{ 'dashboard.roomMembers' | translate: { count: room.userCount } }}</p>
</button>
}
</div>

View File

@@ -27,6 +27,7 @@ import {
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
import type { ServerInfo } from '../../domains/server-directory/domain/models/server-directory.model';
import type { Room, User } from '../../shared-kernel';
import { AppI18nService } from '../../core/i18n';
interface HarnessOptions {
searchResults?: ServerInfo[];
@@ -81,6 +82,19 @@ function createHarness(options: HarnessOptions = {}) {
const serverDirectory = { getFeaturedServers, getTrendingServers } as unknown as ServerDirectoryFacade;
const friendIds = new Set<string>(options.friendIds ?? []);
const friendService = { friendIds: () => friendIds, friends: () => [] } as unknown as FriendService;
const appI18n = {
instant: (key: string, params?: Record<string, unknown>) => {
if (key === 'dashboard.serverMeta.member') {
return `${params?.count} member`;
}
if (key === 'dashboard.serverMeta.members') {
return `${params?.count} members`;
}
return key;
}
} as unknown as AppI18nService;
const injector = Injector.create({
providers: [
DashboardComponent,
@@ -88,7 +102,8 @@ function createHarness(options: HarnessOptions = {}) {
{ provide: Router, useValue: router },
{ provide: ServerDirectoryFacade, useValue: serverDirectory },
{ provide: FriendService, useValue: friendService },
{ provide: ViewportService, useValue: { isMobile: signal(options.isMobile ?? false) } }
{ provide: ViewportService, useValue: { isMobile: signal(options.isMobile ?? false) } },
{ provide: AppI18nService, useValue: appI18n }
]
});
const component = runInInjectionContext(injector, () => injector.get(DashboardComponent));

View File

@@ -45,6 +45,7 @@ import { FriendService } from '../../domains/direct-message/application/services
import { FriendButtonComponent } from '../../domains/direct-message/feature/friend-button/friend-button.component';
import { UserAvatarComponent } from '../../shared/components/user-avatar/user-avatar.component';
import { parseInviteQuery } from './invite-query.util';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../core/i18n';
/** Maximum quick-search rows shown per group on the dashboard. */
const QUICK_RESULT_LIMIT = 5;
@@ -70,7 +71,8 @@ const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches';
RouterLink,
NgIcon,
FriendButtonComponent,
UserAvatarComponent
UserAvatarComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -90,6 +92,7 @@ const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches';
}
})
export class DashboardComponent implements OnInit {
private readonly appI18n = inject(AppI18nService);
private store = inject(Store);
private router = inject(Router);
private serverDirectory = inject(ServerDirectoryFacade);
@@ -267,7 +270,11 @@ export class DashboardComponent implements OnInit {
}
serverMetaLabel(server: ServerInfo): string {
const members = `${server.userCount ?? 0} ${server.userCount === 1 ? 'member' : 'members'}`;
const count = server.userCount ?? 0;
const members = this.appI18n.instant(
count === 1 ? 'dashboard.serverMeta.member' : 'dashboard.serverMeta.members',
{ count }
);
const detail = server.description?.trim();
return detail ? `${members}${detail}` : members;

View File

@@ -9,7 +9,7 @@
name="lucidePhone"
class="h-5 w-5"
/>
Join call
{{ 'call.joinCall' | translate }}
</button>
}
@@ -24,8 +24,8 @@
[class.hover:bg-destructive/15]="muted()"
[disabled]="!connected()"
(click)="muteToggled.emit()"
[attr.aria-label]="muted() ? 'Unmute' : 'Mute'"
[title]="muted() ? 'Unmute' : 'Mute'"
[attr.aria-label]="(muted() ? 'call.unmute' : 'call.mute') | translate"
[title]="(muted() ? 'call.unmute' : 'call.mute') | translate"
>
<ng-icon
[name]="muted() ? 'lucideMicOff' : 'lucideMic'"
@@ -44,8 +44,8 @@
[class.hover:bg-destructive/15]="deafened()"
[disabled]="!connected()"
(click)="deafenToggled.emit()"
[attr.aria-label]="deafened() ? 'Undeafen' : 'Deafen'"
[title]="deafened() ? 'Undeafen' : 'Deafen'"
[attr.aria-label]="(deafened() ? 'call.undeafen' : 'call.deafen') | translate"
[title]="(deafened() ? 'call.undeafen' : 'call.deafen') | translate"
>
<ng-icon
name="lucideHeadphones"
@@ -61,8 +61,8 @@
[class.ring-primary]="speakerphoneEnabled()"
[disabled]="!connected()"
(click)="speakerphoneToggled.emit()"
[attr.aria-label]="speakerphoneEnabled() ? 'Use earpiece' : 'Use speakerphone'"
[title]="speakerphoneEnabled() ? 'Use earpiece' : 'Use speakerphone'"
[attr.aria-label]="(speakerphoneEnabled() ? 'call.useEarpiece' : 'call.useSpeakerphone') | translate"
[title]="(speakerphoneEnabled() ? 'call.useEarpiece' : 'call.useSpeakerphone') | translate"
>
<ng-icon
name="lucideVolume2"
@@ -76,8 +76,8 @@
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
[disabled]="!connected()"
(click)="cameraToggled.emit()"
[attr.aria-label]="cameraEnabled() ? 'Turn camera off' : 'Turn camera on'"
[title]="cameraEnabled() ? 'Turn camera off' : 'Turn camera on'"
[attr.aria-label]="(cameraEnabled() ? 'call.turnCameraOff' : 'call.turnCameraOn') | translate"
[title]="(cameraEnabled() ? 'call.turnCameraOff' : 'call.turnCameraOn') | translate"
>
<ng-icon
[name]="cameraEnabled() ? 'lucideVideoOff' : 'lucideVideo'"
@@ -90,8 +90,8 @@
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
[disabled]="!connected()"
(click)="screenShareToggled.emit()"
[attr.aria-label]="screenSharing() ? 'Stop sharing screen' : 'Share screen'"
[title]="screenSharing() ? 'Stop sharing screen' : 'Share screen'"
[attr.aria-label]="(screenSharing() ? 'call.stopSharingScreen' : 'call.shareScreen') | translate"
[title]="(screenSharing() ? 'call.stopSharingScreen' : 'call.shareScreen') | translate"
>
<ng-icon
[name]="screenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
@@ -104,8 +104,8 @@
class="grid h-12 w-12 place-items-center rounded-full bg-destructive/10 text-destructive transition-colors hover:bg-destructive/15 disabled:opacity-45"
[disabled]="!connected()"
(click)="leaveRequested.emit()"
aria-label="Leave call"
title="Leave call"
[attr.aria-label]="'call.leaveCall' | translate"
[title]="'call.leaveCall' | translate"
>
<ng-icon
name="lucidePhoneOff"

View File

@@ -17,10 +17,12 @@ import {
lucideVolume2
} from '@ng-icons/lucide';
import { APP_TRANSLATE_IMPORTS } from '../../core/i18n';
@Component({
selector: 'app-private-call-controls',
standalone: true,
imports: [NgIcon],
imports: [NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [
provideIcons({
lucideHeadphones,

View File

@@ -34,12 +34,12 @@
</div>
<div class="min-w-0">
<h1 class="truncate text-base font-semibold text-foreground">Private Call</h1>
<h1 class="truncate text-base font-semibold text-foreground">{{ 'call.private.title' | translate }}</h1>
<p class="truncate text-xs text-muted-foreground">
@if (session()) {
{{ participantUsers().length }} participants
{{ 'call.private.participants' | translate: { count: participantUsers().length } }}
} @else {
Call not found
{{ 'call.private.notFound' | translate }}
}
</p>
</div>
@@ -52,8 +52,8 @@
type="button"
class="grid h-10 w-10 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80"
(click)="minimizeCall()"
aria-label="Minimize call"
title="Minimize call"
[attr.aria-label]="'call.private.minimize' | translate"
[title]="'call.private.minimize' | translate"
>
<ng-icon
name="lucideX"
@@ -65,9 +65,9 @@
class="hidden h-9 max-w-44 rounded-md border border-border bg-secondary px-2 text-sm text-foreground sm:block"
[ngModel]="inviteUserId()"
(ngModelChange)="inviteUserId.set($event)"
aria-label="Add user to call"
[attr.aria-label]="'call.private.addUserAria' | translate"
>
<option value="">Add user</option>
<option value="">{{ 'call.private.addUser' | translate }}</option>
@for (user of inviteCandidates(); track userKey(user)) {
<option [value]="userKey(user)">{{ user.displayName }}</option>
}
@@ -77,8 +77,8 @@
class="hidden h-9 w-9 place-items-center rounded-md bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-50 sm:grid"
[disabled]="!inviteUserId()"
(click)="inviteSelectedUser()"
aria-label="Add user"
title="Add user"
[attr.aria-label]="'call.private.addUserButton' | translate"
[title]="'call.private.addUserButton' | translate"
>
<ng-icon
name="lucideUserPlus"
@@ -100,14 +100,14 @@
type="button"
data-testid="private-call-show-all-streams"
class="inline-flex h-10 items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 text-xs font-medium text-white/80 backdrop-blur transition hover:bg-black/65 hover:text-white"
title="Show all streams"
[title]="'call.private.showAllStreams' | translate"
(click)="showAllStreams()"
>
<ng-icon
name="lucideUsers"
class="h-3.5 w-3.5"
/>
All streams
{{ 'call.private.allStreams' | translate }}
</button>
</div>
}
@@ -212,7 +212,7 @@
</div>
</div>
} @else {
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">No active call for this route.</div>
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">{{ 'call.private.noActiveCall' | translate }}</div>
}
</main>
@@ -221,7 +221,7 @@
class="group absolute inset-y-0 left-0 z-10 w-3 -translate-x-1/2 cursor-col-resize bg-transparent"
role="separator"
aria-orientation="vertical"
title="Resize chat"
[title]="'call.private.resizeChat' | translate"
data-testid="private-call-chat-resizer"
(mousedown)="startChatResize($event)"
>

View File

@@ -24,6 +24,7 @@ import {
lucideUserPlus
} from '@ng-icons/lucide';
import { map } from 'rxjs';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../core/i18n';
import {
DirectCallService,
participantToUser,
@@ -63,7 +64,8 @@ import { PrivateCallParticipantCardComponent } from './private-call-participant-
PrivateCallControlsComponent,
PrivateCallParticipantCardComponent,
ScreenShareQualityDialogComponent,
VoiceWorkspaceStreamTileComponent
VoiceWorkspaceStreamTileComponent,
...APP_TRANSLATE_IMPORTS
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
host: { class: 'block h-full w-full' },
@@ -91,6 +93,7 @@ export class PrivateCallComponent {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly mobileMedia = inject(MobileMediaService);
private chatResizing = false;
private readonly i18n = inject(AppI18nService);
readonly allUsers = this.store.selectSignal(selectAllUsers);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
@@ -446,7 +449,7 @@ export class PrivateCallComponent {
}
participantIssueLabel(user: User): string | null {
return this.isParticipantConnected(user) ? null : 'Waiting';
return this.isParticipantConnected(user) ? null : this.i18n.instant('call.private.waiting');
}
streamLabel(share: VoiceWorkspaceStreamItem): string {
@@ -454,7 +457,9 @@ export class PrivateCallComponent {
return share.user.displayName;
}
return share.kind === 'camera' ? 'Your camera' : 'Your screen';
return share.kind === 'camera'
? this.i18n.instant('call.private.yourCamera')
: this.i18n.instant('call.private.yourScreen');
}
focusShare(shareId: string): void {

View File

@@ -31,7 +31,7 @@
type="button"
(click)="setMobilePage('channels')"
class="grid h-11 w-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Back to channels"
[attr.aria-label]="'room.mobile.backToChannels' | translate"
>
<ng-icon
name="lucideChevronLeft"
@@ -58,8 +58,8 @@
type="button"
(click)="openActiveCall()"
class="grid h-11 w-11 place-items-center rounded-lg text-emerald-600 transition-colors hover:bg-emerald-500/10 hover:text-emerald-500"
aria-label="Return to call"
title="Return to call"
[attr.aria-label]="'room.mobile.returnToCall' | translate"
[title]="'room.mobile.returnToCall' | translate"
>
<ng-icon
name="lucidePhoneCall"
@@ -71,7 +71,7 @@
type="button"
(click)="setMobilePage('members')"
class="grid h-11 w-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Show members"
[attr.aria-label]="'room.mobile.showMembers' | translate"
>
<ng-icon
name="lucideUsers"
@@ -93,8 +93,8 @@
name="lucideHash"
class="mx-auto mb-4 h-16 w-16 opacity-30"
/>
<h2 class="mb-2 text-xl font-medium text-foreground">No text channels</h2>
<p class="text-sm">There are no existing text channels currently.</p>
<h2 class="mb-2 text-xl font-medium text-foreground">{{ 'room.empty.noTextChannels' | translate }}</h2>
<p class="text-sm">{{ 'room.empty.noTextChannelsDescription' | translate }}</p>
</div>
</div>
}
@@ -112,14 +112,14 @@
type="button"
(click)="setMobilePage('main')"
class="grid h-11 w-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Back to chat"
[attr.aria-label]="'room.mobile.backToChat' | translate"
>
<ng-icon
name="lucideChevronLeft"
class="h-5 w-5"
/>
</button>
<p class="truncate text-sm font-semibold text-foreground">Members</p>
<p class="truncate text-sm font-semibold text-foreground">{{ 'room.mobile.members' | translate }}</p>
</div>
<app-rooms-side-panel
panelMode="users"
@@ -174,9 +174,9 @@
data-theme-slot="text"
class="mb-2 text-xl font-medium text-foreground"
>
No text channels
{{ 'room.empty.noTextChannels' | translate }}
</h2>
<p class="text-sm">There are no existing text channels currently.</p>
<p class="text-sm">{{ 'room.empty.noTextChannelsDescription' | translate }}</p>
</div>
</div>
}
@@ -216,9 +216,9 @@
data-theme-slot="text"
class="mb-2 text-xl font-medium"
>
No room selected
{{ 'room.empty.noRoomSelected' | translate }}
</h2>
<p class="text-sm">Select or create a room to start chatting</p>
<p class="text-sm">{{ 'room.empty.noRoomSelectedDescription' | translate }}</p>
</div>
</div>
}

View File

@@ -40,6 +40,7 @@ import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { VoiceWorkspaceService } from '../../../domains/voice-session';
import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
import { DirectCallService } from '../../../domains/direct-call';
import { APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
/** Mobile-only page identifier within the chat-room view. */
export type ChatRoomMobilePage = 'channels' | 'main' | 'members';
@@ -69,7 +70,8 @@ interface SwiperElement extends HTMLElement {
VoiceWorkspaceComponent,
RoomsSidePanelComponent,
ServersRailComponent,
ThemeNodeDirective
ThemeNodeDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({

View File

@@ -19,8 +19,10 @@
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ currentRoom()?.name || 'Server' }}</p>
<p class="truncate text-xs text-muted-foreground">{{ currentRoom()?.description || 'Choose a text channel or jump into voice.' }}</p>
<p class="truncate text-sm font-semibold text-foreground">{{ currentRoom()?.name || ('room.panel.serverFallback' | translate) }}</p>
<p class="truncate text-xs text-muted-foreground">
{{ currentRoom()?.description || ('room.panel.serverDescriptionFallback' | translate) }}
</p>
</div>
</div>
} @else {
@@ -33,8 +35,10 @@
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">{{ knownUserCount() }} members</p>
<p class="text-xs text-muted-foreground">{{ onlineRoomUsers().length + (currentUser() ? 1 : 0) }} online right now</p>
<p class="text-sm font-semibold text-foreground">{{ 'room.panel.membersCount' | translate: { count: knownUserCount() } }}</p>
<p class="text-xs text-muted-foreground">
{{ 'room.panel.onlineNow' | translate: { count: onlineRoomUsers().length + (currentUser() ? 1 : 0) } }}
</p>
</div>
</div>
}
@@ -49,12 +53,12 @@
class="px-2 py-3"
>
<div class="mb-2 flex items-center justify-between px-1">
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Text Channels</h4>
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{{ 'room.panel.textChannels' | translate }}</h4>
@if (canManageChannels()) {
<button
(click)="createChannel('text')"
class="grid h-7 w-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Create Text Channel"
[title]="'room.panel.createTextChannel' | translate"
>
<ng-icon
name="lucidePlus"
@@ -96,7 +100,7 @@
type="text"
[value]="ch.name"
[class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()"
[title]="renamingChannelId() === ch.id ? (channelNameError() ?? '') : ''"
[title]="renamingChannelId() === ch.id ? (channelNameError() ? (channelNameError()! | translate) : '') : ''"
(keydown.enter)="confirmRename($event)"
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
@@ -124,12 +128,12 @@
class="border-t border-border px-2 py-3"
>
<div class="mb-2 flex items-center justify-between px-1">
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Voice Channels</h4>
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{{ 'room.panel.voiceChannels' | translate }}</h4>
@if (canManageChannels()) {
<button
(click)="createChannel('voice')"
class="grid h-7 w-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Create Voice Channel"
[title]="'room.panel.createVoiceChannel' | translate"
>
<ng-icon
name="lucidePlus"
@@ -139,7 +143,7 @@
}
</div>
@if (!voiceEnabled()) {
<p class="px-2 py-2 text-sm text-muted-foreground">Voice is disabled by host</p>
<p class="px-2 py-2 text-sm text-muted-foreground">{{ 'room.panel.voiceDisabled' | translate }}</p>
}
<div class="space-y-1">
@if (showVoiceChannelSkeleton()) {
@@ -169,7 +173,7 @@
(contextmenu)="openChannelContextMenu($event, ch)"
[class.bg-secondary]="isCurrentRoom(ch.id)"
[disabled]="!voiceEnabled()"
[title]="isCurrentRoom(ch.id) ? 'Open stream workspace' : 'Join voice channel'"
[title]="isCurrentRoom(ch.id) ? ('room.panel.openStreamWorkspace' | translate) : ('room.panel.joinVoiceChannel' | translate)"
data-channel-type="voice"
[attr.data-channel-name]="ch.name"
>
@@ -184,7 +188,7 @@
type="text"
[value]="ch.name"
[class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()"
[title]="renamingChannelId() === ch.id ? (channelNameError() ?? '') : ''"
[title]="renamingChannelId() === ch.id ? (channelNameError() ? (channelNameError()! | translate) : '') : ''"
(keydown.enter)="confirmRename($event)"
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
@@ -199,7 +203,7 @@
@if (isCurrentRoom(ch.id)) {
<span class="rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
{{ isVoiceWorkspaceExpanded() ? 'Open' : 'View' }}
{{ isVoiceWorkspaceExpanded() ? ('room.panel.open' | translate) : ('room.panel.view' | translate) }}
</span>
} @else if (voiceOccupancy(ch.id) > 0) {
<span class="text-xs text-muted-foreground">{{ voiceOccupancy(ch.id) }}</span>
@@ -234,7 +238,7 @@
<ng-icon
name="lucideAlertTriangle"
class="w-3.5 h-3.5 text-amber-500 shrink-0"
title="Connection issue - this user may not hear all participants. Consider adding a TURN server in Settings -> Network."
[title]="'room.panel.connectionIssue' | translate"
/>
}
<!-- Ping latency indicator -->
@@ -242,7 +246,7 @@
<span
class="w-2 h-2 rounded-full shrink-0"
[class]="getPingColorClass(u)"
[title]="getPeerLatency(u) !== null ? getPeerLatency(u) + ' ms' : 'Measuring...'"
[title]="peerLatencyTitle(u)"
></span>
}
@if (isUserStreaming(u.oderId || u.id)) {
@@ -254,7 +258,7 @@
[name]="getUserLiveIconName(u.oderId || u.id)"
class="w-2.5 h-2.5"
/>
LIVE
{{ 'common.live' | translate }}
</button>
}
@if (u.voiceState?.isMuted) {
@@ -267,7 +271,7 @@
<ng-icon
name="lucideVolumeX"
class="w-4 h-4 text-destructive"
title="Muted by you"
[title]="'room.panel.mutedByYou' | translate"
/>
}
</div>
@@ -285,19 +289,19 @@
data-testid="plugin-room-side-panel"
>
<div class="mb-2 flex items-center justify-between gap-2 px-1">
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Plugins</h4>
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{{ 'room.panel.plugins' | translate }}</h4>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground"
aria-haspopup="menu"
title="View plugins"
[title]="'room.panel.viewPlugins' | translate"
(click)="openPluginActionMenu($event)"
>
<ng-icon
name="lucidePackage"
class="h-3.5 w-3.5"
/>
<span>View plugins</span>
<span>{{ 'room.panel.viewPlugins' | translate }}</span>
</button>
</div>
@@ -361,7 +365,7 @@
<!-- Current User (You) -->
@if (currentUser()) {
<div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">{{ 'room.panel.you' | translate }}</h4>
<div
class="flex items-center gap-2 rounded-md bg-secondary/60 px-3 py-2 hover:bg-secondary/80 transition-colors cursor-pointer"
role="button"
@@ -394,10 +398,10 @@
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
>
Playing {{ activity.name }}
{{ 'room.panel.playing' | translate: { game: activity.name } }}
</button>
} @else {
<span class="truncate">Playing {{ activity.name }}</span>
<span class="truncate">{{ 'room.panel.playing' | translate: { game: activity.name } }}</span>
}
<span class="shrink-0">{{ gameActivityElapsed(currentUser()) }}</span>
</p>
@@ -409,7 +413,7 @@
name="lucideMic"
class="w-2.5 h-2.5"
/>
In voice
{{ 'room.panel.inVoice' | translate }}
</p>
}
@if (currentUser() && isUserStreaming(currentUser()!.oderId || currentUser()!.id)) {
@@ -421,7 +425,7 @@
[name]="getUserLiveIconName(currentUser()!.oderId || currentUser()!.id)"
class="w-2.5 h-2.5"
/>
LIVE
{{ 'common.live' | translate }}
</button>
}
</div>
@@ -433,7 +437,9 @@
<!-- Other Online Users -->
@if (onlineRoomUsers().length > 0) {
<div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Online - {{ onlineRoomUsers().length }}</h4>
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">
{{ 'room.panel.online' | translate: { count: onlineRoomUsers().length } }}
</h4>
<div class="space-y-1">
@for (user of onlineRoomUsers(); track user.id) {
<div
@@ -458,11 +464,17 @@
<div class="flex items-center gap-1.5">
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
@if (user.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">{{
'room.panel.roles.owner' | translate
}}</span>
} @else if (user.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">{{
'room.panel.roles.admin' | translate
}}</span>
} @else if (user.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">{{
'room.panel.roles.mod' | translate
}}</span>
}
</div>
@if (user.gameActivity; as activity) {
@@ -480,10 +492,10 @@
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
>
Playing {{ activity.name }}
{{ 'room.panel.playing' | translate: { game: activity.name } }}
</button>
} @else {
<span class="truncate">Playing {{ activity.name }}</span>
<span class="truncate">{{ 'room.panel.playing' | translate: { game: activity.name } }}</span>
}
<span class="shrink-0">{{ gameActivityElapsed(user) }}</span>
</p>
@@ -495,7 +507,7 @@
name="lucideMic"
class="w-2.5 h-2.5"
/>
In voice
{{ 'room.panel.inVoice' | translate }}
</p>
}
@if (isUserStreaming(user.oderId || user.id)) {
@@ -507,7 +519,7 @@
[name]="getUserLiveIconName(user.oderId || user.id)"
class="w-2.5 h-2.5"
/>
LIVE
{{ 'common.live' | translate }}
</button>
}
</div>
@@ -515,8 +527,8 @@
<button
type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-card hover:text-foreground group-hover/user:opacity-100 focus:opacity-100"
title="Message"
[attr.aria-label]="'Message ' + user.displayName"
[title]="'room.panel.message' | translate"
[attr.aria-label]="messageAriaLabel(user.displayName)"
(click)="openDirectMessage($event, user)"
(dblclick)="$event.stopPropagation()"
>
@@ -534,7 +546,9 @@
<!-- Offline Users -->
@if (offlineRoomMembers().length > 0) {
<div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Offline - {{ offlineRoomMembers().length }}</h4>
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">
{{ 'room.panel.offline' | translate: { count: offlineRoomMembers().length } }}
</h4>
<div class="space-y-1">
@for (member of offlineRoomMembers(); track member.oderId || member.id) {
<div
@@ -558,20 +572,26 @@
<div class="flex items-center gap-1.5">
<p class="text-sm text-foreground/80 truncate">{{ member.displayName }}</p>
@if (member.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">{{
'room.panel.roles.owner' | translate
}}</span>
} @else if (member.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">{{
'room.panel.roles.admin' | translate
}}</span>
} @else if (member.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">{{
'room.panel.roles.mod' | translate
}}</span>
}
</div>
<p class="text-[10px] text-muted-foreground">Offline</p>
<p class="text-[10px] text-muted-foreground">{{ 'common.offline' | translate }}</p>
</div>
<button
type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-card hover:text-foreground group-hover/user:opacity-100 focus:opacity-100"
title="Message"
[attr.aria-label]="'Message ' + member.displayName"
[title]="'room.panel.message' | translate"
[attr.aria-label]="messageAriaLabel(member.displayName)"
(click)="openDirectMessageForMember($event, member)"
(dblclick)="$event.stopPropagation()"
>
@@ -589,7 +609,7 @@
<!-- No other users message -->
@if (onlineRoomUsers().length === 0 && offlineRoomMembers().length === 0) {
<div class="text-center py-4 text-muted-foreground">
<p class="text-sm">No other users in this server</p>
<p class="text-sm">{{ 'room.panel.noOtherUsers' | translate }}</p>
</div>
}
</div>
@@ -603,7 +623,7 @@
name="lucideAlertTriangle"
class="w-4 h-4 shrink-0"
/>
<span>You may have connectivity issues. Adding a TURN server in Settings -> Network may help.</span>
<span>{{ 'room.panel.connectivityWarning' | translate }}</span>
</div>
}
<div
@@ -627,14 +647,14 @@
(click)="resyncMessages()"
class="context-menu-item"
>
Resync Messages
{{ 'room.channel.resyncMessages' | translate }}
</button>
@if (contextChannel()?.type === 'text') {
<button
(click)="toggleChannelNotifications()"
class="context-menu-item"
>
{{ isContextChannelMuted() ? 'Unmute Notifications' : 'Mute Notifications' }}
{{ isContextChannelMuted() ? ('room.channel.unmuteNotifications' | translate) : ('room.channel.muteNotifications' | translate) }}
</button>
}
@if (canManageChannels()) {
@@ -643,13 +663,13 @@
(click)="startRename()"
class="context-menu-item"
>
Rename Channel
{{ 'room.channel.rename' | translate }}
</button>
<button
(click)="deleteChannel()"
class="context-menu-item-danger"
>
Delete Channel
{{ 'room.channel.delete' | translate }}
</button>
}
</app-context-menu>
@@ -668,13 +688,13 @@
(click)="changeUserRole('moderator')"
class="context-menu-item"
>
Promote to Moderator
{{ 'room.userMenu.promoteModerator' | translate }}
</button>
<button
(click)="changeUserRole('admin')"
class="context-menu-item"
>
Promote to Admin
{{ 'room.userMenu.promoteAdmin' | translate }}
</button>
}
@if (canChangeUserRole(selectedUser) && selectedUser.role === 'moderator') {
@@ -682,13 +702,13 @@
(click)="changeUserRole('admin')"
class="context-menu-item"
>
Promote to Admin
{{ 'room.userMenu.promoteAdmin' | translate }}
</button>
<button
(click)="changeUserRole('member')"
class="context-menu-item"
>
Demote to Member
{{ 'room.userMenu.demoteMember' | translate }}
</button>
}
@if (canChangeUserRole(selectedUser) && selectedUser.role === 'admin') {
@@ -696,7 +716,7 @@
(click)="changeUserRole('member')"
class="context-menu-item"
>
Demote to Member
{{ 'room.userMenu.demoteMember' | translate }}
</button>
}
@if (canChangeUserRole(selectedUser) && canKickUser(selectedUser)) {
@@ -707,11 +727,11 @@
(click)="kickUserAction()"
class="context-menu-item-danger"
>
Kick User
{{ 'room.userMenu.kickUser' | translate }}
</button>
}
@if (!canChangeUserRole(selectedUser) && !canKickUser(selectedUser)) {
<div class="context-menu-empty">No actions available</div>
<div class="context-menu-empty">{{ 'room.userMenu.noActions' | translate }}</div>
}
}
</app-context-menu>
@@ -731,22 +751,22 @@
<!-- Create channel dialog -->
@if (panelMode() === 'channels' && showCreateChannelDialog()) {
<app-confirm-dialog
[title]="'Create ' + (createChannelType() === 'text' ? 'Text' : 'Voice') + ' Channel'"
confirmLabel="Create"
[title]="createChannelDialogTitle()"
[confirmLabel]="'common.create' | translate"
(confirmed)="confirmCreateChannel()"
(cancelled)="cancelCreateChannel()"
>
<input
type="text"
[(ngModel)]="newChannelName"
placeholder="Channel name"
[placeholder]="'room.channel.namePlaceholder' | translate"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[class.border-destructive]="!!channelNameError()"
(ngModelChange)="clearChannelNameError()"
(keydown.enter)="confirmCreateChannel()"
/>
@if (channelNameError()) {
<p class="mt-2 text-sm text-destructive">{{ channelNameError() }}</p>
<p class="mt-2 text-sm text-destructive">{{ channelNameError()! | translate }}</p>
}
</app-confirm-dialog>
}

View File

@@ -87,6 +87,7 @@ import {
} from '../../../shared-kernel';
import { v4 as uuidv4 } from 'uuid';
import { visibilityAwareInterval$ } from '../../../shared/rxjs';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
type PanelMode = 'channels' | 'users';
@@ -107,7 +108,8 @@ const SKELETON_REVEAL_DELAY_MS = 180;
PluginRenderHostComponent,
ThemeNodeDirective,
SkeletonComponent,
SkeletonListComponent
SkeletonListComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -146,6 +148,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
private readonly voiceActivity = inject(VoiceActivityService);
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
private readonly pluginUi = inject(PluginUiRegistryService);
private readonly appI18n = inject(AppI18nService);
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
private skeletonRevealTimer: ReturnType<typeof setTimeout> | null = null;
private readonly destroyRef = inject(DestroyRef);
@@ -608,7 +611,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
private getChannelNameError(name: string, excludeChannelId?: string): string | null {
if (!name) {
return 'Channel name is required.';
return 'room.channel.nameRequired';
}
const channels = this.currentRoom()?.channels ?? [];
@@ -619,7 +622,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
}
if (isChannelNameTaken(channels, name, channelType, excludeChannelId)) {
return 'Channel names must be unique within text or voice channels.';
return 'room.channel.nameUnique';
}
return null;
@@ -722,12 +725,12 @@ export class RoomsSidePanelComponent implements OnDestroy {
}
if (!room) {
this.voiceConnection.reportConnectionError('No active room selected for voice join.');
this.voiceConnection.reportConnectionError('room.voiceJoin.noActiveRoom');
return;
}
if (!this.canJoinRequestedVoiceRoom(room, current ?? null, roomId)) {
this.voiceConnection.reportConnectionError('You do not have permission to join this voice channel.');
this.voiceConnection.reportConnectionError('room.voiceJoin.noPermission');
return;
}
@@ -749,7 +752,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
}
private handleVoiceJoinFailure(error: unknown): void {
const message = error instanceof Error ? error.message : 'Failed to join voice channel.';
const message = error instanceof Error ? error.message : 'room.voiceJoin.failed';
this.voiceConnection.reportConnectionError(message);
}
@@ -1174,6 +1177,26 @@ export class RoomsSidePanelComponent implements OnDestroy {
return 'bg-red-500';
}
createChannelDialogTitle(): string {
const key = this.createChannelType() === 'text' ? 'room.channel.createText' : 'room.channel.createVoice';
return this.appI18n.instant(key);
}
peerLatencyTitle(user: User): string {
const latencyMs = this.getPeerLatency(user);
if (latencyMs !== null) {
return this.appI18n.instant('room.panel.latencyMs', { ms: latencyMs });
}
return this.appI18n.instant('room.panel.measuringLatency');
}
messageAriaLabel(displayName: string): string {
return this.appI18n.instant('room.panel.messageUser', { name: displayName });
}
private isVoiceUserSpeaking(user: User): boolean {
const userKey = user.oderId || user.id;

View File

@@ -1,12 +1,11 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
<div
#tileRoot
class="group relative flex h-full min-h-0 flex-col overflow-hidden bg-black/85 transition duration-200"
tabindex="0"
role="button"
[attr.aria-label]="
mini() ? 'Focus ' + displayName() + ' ' + streamBadgeLabel() : 'Open ' + displayName() + ' ' + streamBadgeLabel() + ' in widescreen mode'
"
[attr.title]="canToggleFullscreen() ? (isFullscreen() ? 'Double-click to exit fullscreen' : 'Double-click for fullscreen') : null"
[attr.aria-label]="tileAriaLabel()"
[attr.title]="fullscreenToggleTitle()"
[ngClass]="{
'ring-2 ring-primary/70': focused() && !immersive() && !mini() && !isFullscreen(),
'min-h-[24rem] rounded-[1.75rem] border border-white/10 shadow-2xl': featured() && !compact() && !immersive() && !mini() && !isFullscreen(),
@@ -68,7 +67,7 @@
<button
type="button"
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-white/75 transition hover:bg-white/10 hover:text-white"
[title]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
[title]="streamAudioToggleTitle()"
(click)="toggleMuted(); $event.stopPropagation()"
>
<ng-icon
@@ -83,12 +82,12 @@
max="100"
[value]="volume()"
class="w-20 accent-primary sm:w-28"
aria-label="Stream volume"
[attr.aria-label]="'voice.streamTile.streamVolume' | translate"
(click)="$event.stopPropagation()"
(input)="updateVolume($event)"
/>
<span class="w-9 text-right text-xs tabular-nums">{{ muted() ? 'Off' : volume() + '%' }}</span>
<span class="w-9 text-right text-xs tabular-nums">{{ volumeLabel() }}</span>
</div>
}
@@ -96,8 +95,8 @@
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white"
title="Rotate to landscape"
aria-label="Rotate to landscape"
[title]="'voice.streamTile.rotateLandscape' | translate"
[attr.aria-label]="'voice.streamTile.rotateLandscape' | translate"
(click)="enterLandscapeFullscreen($event)"
>
<ng-icon
@@ -110,7 +109,7 @@
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white"
title="Exit fullscreen"
[title]="'voice.streamTile.exitFullscreen' | translate"
(click)="exitFullscreen($event)"
>
<ng-icon
@@ -132,8 +131,8 @@
<button
type="button"
class="grid h-9 w-9 shrink-0 place-items-center rounded-full text-white/85 transition hover:bg-white/10 hover:text-white"
[title]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
[attr.aria-label]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
[title]="streamAudioToggleTitle()"
[attr.aria-label]="streamAudioToggleTitle()"
(click)="toggleMuted(); $event.stopPropagation()"
>
<ng-icon
@@ -148,22 +147,24 @@
max="100"
[value]="volume()"
class="min-w-0 flex-1 accent-primary"
aria-label="Screen share volume"
[attr.aria-label]="'voice.streamTile.screenShareVolume' | translate"
(click)="$event.stopPropagation()"
(input)="updateVolume($event)"
/>
<span class="w-10 text-right text-xs font-semibold tabular-nums text-white/70">{{ muted() ? 'Off' : volume() + '%' }}</span>
<span class="w-10 text-right text-xs font-semibold tabular-nums text-white/70">{{ volumeLabel() }}</span>
</div>
} @else {
<div class="min-w-0 flex-1 px-2 text-center text-xs font-medium text-white/65 sm:text-left">No screen audio</div>
<div class="min-w-0 flex-1 px-2 text-center text-xs font-medium text-white/65 sm:text-left">
{{ 'voice.streamTile.noScreenAudio' | translate }}
</div>
}
<button
type="button"
class="grid h-11 w-11 place-items-center rounded-full bg-white/10 text-white transition hover:bg-white/15"
[title]="isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
[attr.aria-label]="isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
[title]="isFullscreen() ? ('voice.streamTile.exitFullscreen' | translate) : ('voice.streamTile.fullscreen' | translate)"
[attr.aria-label]="isFullscreen() ? ('voice.streamTile.exitFullscreen' | translate) : ('voice.streamTile.fullscreen' | translate)"
(click)="toggleFullscreen($event)"
>
<ng-icon
@@ -176,8 +177,8 @@
<button
type="button"
class="grid h-11 w-11 place-items-center rounded-full bg-white/10 text-white transition hover:bg-white/15"
title="Rotate to landscape"
aria-label="Rotate to landscape"
[title]="'voice.streamTile.rotateLandscape' | translate"
[attr.aria-label]="'voice.streamTile.rotateLandscape' | translate"
(click)="enterLandscapeFullscreen($event)"
>
<ng-icon
@@ -248,7 +249,7 @@
[class.w-8]="compact()"
[class.h-10]="!compact()"
[class.w-10]="!compact()"
[title]="focused() ? 'Viewing in widescreen' : 'View in widescreen'"
[title]="widescreenButtonTitle()"
(click)="requestFocus(); $event.stopPropagation()"
>
<ng-icon
@@ -292,7 +293,7 @@
} @else if (item().hasAudio) {
@if (compact()) {
<div class="rounded-xl bg-black/50 px-3 py-2 text-[11px] text-white/80 backdrop-blur-md">
{{ muted() ? 'Muted' : volume() + '% audio' }}
{{ compactAudioLabel() }}
</div>
} @else {
<div class="rounded-2xl bg-black/50 px-4 py-3 backdrop-blur-md">
@@ -302,9 +303,9 @@
[name]="muted() ? 'lucideVolumeX' : 'lucideVolume2'"
class="h-3.5 w-3.5"
/>
Stream audio
{{ 'voice.streamTile.streamAudio' | translate }}
</span>
<span>{{ muted() ? 'Muted' : volume() + '%' }}</span>
<span>{{ muted() ? ('common.muted' | translate) : volume() + '%' }}</span>
</div>
<input

View File

@@ -28,6 +28,7 @@ import { ViewportService } from '../../../../core/platform';
import { MobileAppLifecycleService, MobilePictureInPictureService } from '../../../../infrastructure/mobile';
import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service';
import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
@Component({
selector: 'app-voice-workspace-stream-tile',
@@ -35,7 +36,8 @@ import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
imports: [
CommonModule,
NgIcon,
UserAvatarComponent
UserAvatarComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -58,6 +60,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
private readonly viewport = inject(ViewportService);
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
private readonly mobilePictureInPicture = inject(MobilePictureInPictureService);
private readonly appI18n = inject(AppI18nService);
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
readonly item = input.required<VoiceWorkspaceStreamItem>();
@@ -285,7 +288,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
}
displayName(): string {
return this.item().isLocal ? 'You' : this.item().user.displayName;
return this.item().isLocal ? this.appI18n.instant('common.you') : this.item().user.displayName;
}
streamIconName(): string {
@@ -293,25 +296,76 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
}
streamBadgeLabel(): string {
return this.item().kind === 'camera' ? 'Camera live' : 'Screen share live';
return this.item().kind === 'camera'
? this.appI18n.instant('voice.streamTile.cameraLive')
: this.appI18n.instant('voice.streamTile.screenShareLive');
}
tileAriaLabel(): string {
const name = this.displayName();
const badge = this.streamBadgeLabel();
if (this.mini()) {
return this.appI18n.instant('voice.streamTile.focusAria', { name, badge });
}
return this.appI18n.instant('voice.streamTile.openAria', { name, badge });
}
fullscreenToggleTitle(): string | null {
if (!this.canToggleFullscreen()) {
return null;
}
return this.isFullscreen()
? this.appI18n.instant('voice.streamTile.exitFullscreenTitle')
: this.appI18n.instant('voice.streamTile.enterFullscreenTitle');
}
streamAudioToggleTitle(): string {
return this.muted()
? this.appI18n.instant('voice.workspace.unmuteStreamAudio')
: this.appI18n.instant('voice.workspace.muteStreamAudio');
}
volumeLabel(): string {
if (this.muted()) {
return this.appI18n.instant('common.off');
}
return `${this.volume()}%`;
}
compactAudioLabel(): string {
if (this.muted()) {
return this.appI18n.instant('common.muted');
}
return this.appI18n.instant('voice.streamTile.audioPercent', { volume: this.volume() });
}
fullscreenDescription(): string {
if (this.item().isLocal) {
return this.item().kind === 'camera'
? 'Local camera preview in fullscreen'
: 'Local preview in fullscreen';
? this.appI18n.instant('voice.streamTile.localCameraFullscreen')
: this.appI18n.instant('voice.streamTile.localPreviewFullscreen');
}
return this.item().kind === 'camera'
? 'Fullscreen camera view'
: 'Fullscreen stream view';
? this.appI18n.instant('voice.streamTile.cameraFullscreen')
: this.appI18n.instant('voice.streamTile.streamFullscreen');
}
localPreviewDescription(): string {
return this.item().kind === 'camera'
? 'Your camera preview never captures audio.'
: 'Your preview stays muted locally to avoid audio feedback.';
? this.appI18n.instant('voice.streamTile.localCameraPreview')
: this.appI18n.instant('voice.streamTile.localPreviewMuted');
}
widescreenButtonTitle(): string {
return this.focused()
? this.appI18n.instant('voice.streamTile.viewingWidescreen')
: this.appI18n.instant('voice.streamTile.viewInWidescreen');
}
canControlStreamAudio(): boolean {

View File

@@ -30,16 +30,16 @@
<div class="flex flex-wrap items-center gap-2">
<h2 class="truncate text-sm font-semibold text-white sm:text-base">{{ connectedVoiceChannelName() }}</h2>
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">
Streams
{{ 'voice.workspace.streams' | translate }}
</span>
</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-white/65">
<span>{{ serverName() }}</span>
<span class="h-1 w-1 rounded-full bg-white/25"></span>
<span>{{ connectedVoiceUsers().length }} in voice</span>
<span>{{ 'voice.workspace.inVoice' | translate: { count: connectedVoiceUsers().length } }}</span>
<span class="h-1 w-1 rounded-full bg-white/25"></span>
<span>{{ liveShareCount() }} live {{ liveShareCount() === 1 ? 'stream' : 'streams' }}</span>
<span>{{ liveStreamCountLabel(liveShareCount()) }}</span>
</div>
</div>
</div>
@@ -74,7 +74,7 @@
<div class="min-w-0">
<p class="truncate text-xs font-semibold text-white">{{ focusedShareTitle() }}</p>
<p class="text-[10px] uppercase tracking-[0.18em] text-white/55">
{{ widescreenShare()!.isLocal ? 'Local preview' : 'Focused stream' }}
{{ widescreenShare()!.isLocal ? ('voice.workspace.localPreview' | translate) : ('voice.workspace.focusedStream' | translate) }}
</p>
</div>
@@ -85,7 +85,9 @@
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white"
[title]="focusedShareMuted() ? 'Unmute stream audio' : 'Mute stream audio'"
[title]="
focusedShareMuted() ? ('voice.workspace.unmuteStreamAudio' | translate) : ('voice.workspace.muteStreamAudio' | translate)
"
(click)="toggleFocusedShareMuted()"
>
<ng-icon
@@ -104,7 +106,7 @@
/>
<span class="w-10 text-right text-[11px] text-white/65">
{{ focusedShareMuted() ? 'Muted' : focusedShareVolume() + '%' }}
{{ focusedShareMuted() ? ('common.muted' | translate) : focusedShareVolume() + '%' }}
</span>
</div>
}
@@ -116,21 +118,21 @@
<button
type="button"
class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/35 px-3 py-2 text-xs font-medium text-white/80 transition hover:bg-black/55 hover:text-white"
title="Show all streams"
[title]="'voice.workspace.showAllStreams' | translate"
(click)="showAllStreams()"
>
<ng-icon
name="lucideUsers"
class="h-3.5 w-3.5"
/>
All streams
{{ 'voice.workspace.allStreams' | translate }}
</button>
}
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-black/35 text-white/70 transition hover:bg-black/55 hover:text-white"
title="Minimize stream workspace"
[title]="'voice.workspace.minimizeWorkspace' | translate"
(click)="minimizeWorkspace()"
>
<ng-icon
@@ -142,7 +144,7 @@
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-black/35 text-white/70 transition hover:bg-black/55 hover:text-white"
title="Return to chat"
[title]="'voice.workspace.returnToChat' | translate"
(click)="closeWorkspace()"
>
<ng-icon
@@ -165,7 +167,9 @@
[class.pointer-events-none]="!showWorkspaceHeader()"
>
<div class="mb-2 flex items-center justify-between px-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.18em] text-white/55">Other live streams</span>
<span class="text-[10px] font-semibold uppercase tracking-[0.18em] text-white/55">{{
'voice.workspace.otherLiveStreams' | translate
}}</span>
<span class="text-[10px] text-white/40">{{ thumbnailShares().length }}</span>
</div>
@@ -226,9 +230,9 @@
/>
</div>
<h2 class="text-2xl font-semibold text-foreground">No live streams yet</h2>
<h2 class="text-2xl font-semibold text-foreground">{{ 'voice.workspace.noLiveStreams' | translate }}</h2>
<p class="mx-auto mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
Turn on your camera, click Screen Share below, or wait for someone in {{ connectedVoiceChannelName() }} to go live.
{{ 'voice.workspace.noLiveStreamsDescription' | translate: { channel: connectedVoiceChannelName() } }}
</p>
@if (connectedVoiceUsers().length > 0) {
@@ -252,7 +256,7 @@
name="lucideUsers"
class="h-4 w-4"
/>
{{ connectedVoiceUsers().length }} participants ready
{{ 'voice.workspace.participantsReady' | translate: { count: connectedVoiceUsers().length } }}
</span>
<button
type="button"
@@ -264,7 +268,7 @@
name="lucideMonitor"
class="h-4 w-4"
/>
Start screen sharing
{{ 'voice.workspace.startScreenShare' | translate }}
</button>
</div>
</div>
@@ -298,14 +302,14 @@
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ connectedVoiceChannelName() }}</p>
<p class="truncate text-xs text-muted-foreground">
{{ liveShareCount() }} live {{ liveShareCount() === 1 ? 'stream' : 'streams' }} · double-click to expand
{{ miniWindowStreamHint(liveShareCount()) }}
</p>
</div>
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition hover:bg-black/30 hover:text-foreground"
title="Expand"
[title]="'voice.workspace.expand' | translate"
(click)="restoreWorkspace()"
>
<ng-icon
@@ -317,7 +321,7 @@
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition hover:bg-black/30 hover:text-foreground"
title="Close"
[title]="'voice.workspace.close' | translate"
(click)="closeWorkspace()"
>
<ng-icon
@@ -342,7 +346,7 @@
name="lucideMonitor"
class="mx-auto h-8 w-8 opacity-50"
/>
<p class="mt-2 text-sm">Waiting for a live stream</p>
<p class="mt-2 text-sm">{{ 'voice.workspace.waitingForStream' | translate }}</p>
</div>
</div>
}
@@ -351,7 +355,7 @@
<p class="truncate text-sm font-semibold">
{{ miniPreviewTitle() }}
</p>
<p class="truncate text-xs text-white/75">Connected to {{ serverName() }}</p>
<p class="truncate text-xs text-white/75">{{ 'voice.workspace.connectedTo' | translate: { server: serverName() } }}</p>
</div>
</div>
</div>

View File

@@ -52,6 +52,7 @@ import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.servic
import { VoiceWorkspaceStreamTileComponent } from './voice-workspace-stream-tile/voice-workspace-stream-tile.component';
import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
import { ThemeNodeDirective } from '../../../domains/theme';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
@Component({
selector: 'app-voice-workspace',
@@ -62,7 +63,8 @@ import { ThemeNodeDirective } from '../../../domains/theme';
ScreenShareQualityDialogComponent,
VoiceWorkspaceStreamTileComponent,
UserAvatarComponent,
ThemeNodeDirective
ThemeNodeDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -97,6 +99,7 @@ export class VoiceWorkspaceComponent {
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
private readonly voiceSession = inject(VoiceSessionFacade);
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
private readonly appI18n = inject(AppI18nService);
private readonly remoteStreamRevision = signal(0);
@@ -303,14 +306,16 @@ export class VoiceWorkspaceComponent {
const share = this.widescreenShare();
if (!share) {
return 'Focused stream';
return this.appI18n.instant('voice.workspace.focusedStreamFallback');
}
if (!share.isLocal) {
return share.user.displayName;
}
return share.kind === 'camera' ? 'Your camera' : 'Your screen';
return share.kind === 'camera'
? this.appI18n.instant('voice.workspace.yourCamera')
: this.appI18n.instant('voice.workspace.yourScreen');
});
readonly thumbnailShares = computed(() => {
const widescreenShareId = this.widescreenShareId();
@@ -328,14 +333,16 @@ export class VoiceWorkspaceComponent {
const previewShare = this.miniPreviewShare();
if (!previewShare) {
return 'Voice workspace';
return this.appI18n.instant('voice.workspace.voiceWorkspace');
}
if (!previewShare.isLocal) {
return previewShare.user.displayName;
}
return previewShare.kind === 'camera' ? 'Your camera' : 'Your screen';
return previewShare.kind === 'camera'
? this.appI18n.instant('voice.workspace.yourCamera')
: this.appI18n.instant('voice.workspace.yourScreen');
});
readonly liveShareCount = computed(() => this.activeShares().length);
readonly connectedVoiceChannelName = computed(() => {
@@ -352,12 +359,24 @@ export class VoiceWorkspaceComponent {
const sessionRoomName = this.voiceSessionInfo()?.roomName?.replace(/^🔊\s*/, '');
return sessionRoomName || 'Voice Lounge';
return sessionRoomName || this.appI18n.instant('voice.workspace.voiceLounge');
});
readonly serverName = computed(
() => this.currentRoom()?.name || this.voiceSessionInfo()?.serverName || 'Voice server'
() => this.currentRoom()?.name || this.voiceSessionInfo()?.serverName || this.appI18n.instant('voice.workspace.voiceServer')
);
liveStreamCountLabel(count: number): string {
const key = count === 1 ? 'voice.workspace.liveStream' : 'voice.workspace.liveStreams';
return this.appI18n.instant(key, { count });
}
miniWindowStreamHint(count: number): string {
const key = count === 1 ? 'voice.workspace.miniWindowHintSingle' : 'voice.workspace.miniWindowHint';
return this.appI18n.instant(key, { count });
}
constructor() {
this.destroyRef.onDestroy(() => {
this.clearHeaderHideTimeout();

View File

@@ -6,12 +6,12 @@
appThemeNode="serversRailCreateButton"
type="button"
class="flex h-12 w-12 items-center justify-center overflow-hidden rounded-2xl bg-primary transition-[border-radius,background-color] duration-150 ease-out hover:rounded-xl hover:bg-primary/90 active:rounded-lg md:h-11 md:w-11"
title="Dashboard"
[title]="'serversRail.dashboard' | translate"
(click)="goToDashboard()"
>
<img
src="toju-icon.png"
alt="Toju"
[attr.alt]="'common.brand' | translate"
class="h-full w-full object-contain p-1"
/>
</button>
@@ -35,7 +35,7 @@
: 'bg-emerald-500/15 text-emerald-600 hover:bg-emerald-500/25'
"
[attr.data-testid]="'server-rail-call-' + call.callId"
title="Open private call"
[title]="'serversRail.openPrivateCall' | translate"
[attr.aria-current]="isSelectedCall($index) ? 'page' : null"
(click)="openCall(call.callId)"
>
@@ -121,7 +121,7 @@
@if (voicePresenceCount(room.id) > 0) {
<span
class="absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500 text-white shadow-sm ring-2 ring-card"
[title]="voicePresenceCount(room.id) + (voicePresenceCount(room.id) === 1 ? ' user in voice' : ' users in voice')"
[title]="voicePresenceTitle(room.id)"
>
<svg
viewBox="0 0 16 16"
@@ -150,8 +150,8 @@
type="button"
class="relative z-10 grid h-12 w-12 place-items-center rounded-xl bg-card text-emerald-500 transition-[border-radius,background-color,color] duration-150 ease-out hover:rounded-lg hover:bg-emerald-500 hover:text-white active:rounded-2xl md:h-11 md:w-11"
data-testid="server-rail-create"
title="Create a server"
aria-label="Create a server"
[title]="'serversRail.createServer' | translate"
[attr.aria-label]="'serversRail.createServer' | translate"
(click)="openCreateDialog()"
>
<ng-icon
@@ -190,7 +190,7 @@
(click)="toggleRoomNotifications()"
class="context-menu-item"
>
{{ isRoomNotificationsMuted(contextRoom()?.id || '') ? 'Unmute Notifications' : 'Mute Notifications' }}
{{ (isRoomNotificationsMuted(contextRoom()?.id || '') ? 'serversRail.unmuteNotifications' : 'serversRail.muteNotifications') | translate }}
</button>
<div class="context-menu-divider"></div>
<button
@@ -198,49 +198,49 @@
(click)="openLeaveConfirm()"
class="context-menu-item"
>
Leave Server
{{ 'serversRail.leaveServer' | translate }}
</button>
</app-context-menu>
}
@if (showBannedDialog()) {
<app-confirm-dialog
title="Banned"
confirmLabel="OK"
cancelLabel="Close"
[title]="'serversRail.banned.title' | translate"
[confirmLabel]="'common.ok' | translate"
[cancelLabel]="'common.close' | translate"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="closeBannedDialog()"
(cancelled)="closeBannedDialog()"
>
<p>You are banned from {{ bannedServerName() || 'this server' }}.</p>
<p>{{ 'serversRail.banned.message' | translate: { server: bannedServerName() || ('common.thisServer' | translate) } }}</p>
</app-confirm-dialog>
}
@if (showPasswordDialog() && passwordPromptRoom()) {
<app-confirm-dialog
title="Password required"
confirmLabel="Join server"
cancelLabel="Cancel"
[title]="'serversRail.password.title' | translate"
[confirmLabel]="'serversRail.password.confirmLabel' | translate"
[cancelLabel]="'common.cancel' | translate"
[widthClass]="'w-[420px] max-w-[92vw]'"
(confirmed)="confirmPasswordJoin()"
(cancelled)="closePasswordDialog()"
>
<div class="space-y-3 text-left">
<p>Enter the password to rejoin {{ passwordPromptRoom()!.name }}.</p>
<p>{{ 'serversRail.password.prompt' | translate: { serverName: passwordPromptRoom()!.name } }}</p>
<div>
<label
for="rail-join-password"
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Server password
{{ 'serversRail.password.label' | translate }}
</label>
<input
id="rail-join-password"
type="password"
[(ngModel)]="joinPassword"
placeholder="Enter password"
[placeholder]="'serversRail.password.placeholder' | translate"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>

View File

@@ -45,6 +45,7 @@ import {
ContextMenuComponent,
LeaveServerDialogComponent
} from '../../../shared';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
const ACTIVATION_DEBOUNCE_MS = 150;
@@ -61,12 +62,14 @@ const ACTIVATION_DEBOUNCE_MS = 150;
DmRailComponent,
LeaveServerDialogComponent,
ThemeNodeDirective,
UserBarComponent
UserBarComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucidePhone, lucidePlus })],
templateUrl: './servers-rail.component.html'
})
export class ServersRailComponent {
private readonly appI18n = inject(AppI18nService);
private store = inject(Store);
private router = inject(Router);
private voiceSession = inject(VoiceSessionFacade);
@@ -420,6 +423,15 @@ export class ServersRailComponent {
return this.voicePresenceByRoom()[roomId] ?? 0;
}
voicePresenceTitle(roomId: string): string {
const count = this.voicePresenceCount(roomId);
return this.appI18n.instant(
count === 1 ? 'serversRail.voicePresence.oneUser' : 'serversRail.voicePresence.manyUsers',
{ count }
);
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}
@@ -627,7 +639,7 @@ export class ServersRailComponent {
status?: number;
};
const errorCode = serverError?.error?.errorCode;
const message = serverError?.error?.error || 'Failed to join server';
const message = serverError?.error?.error || this.appI18n.instant('serversRail.joinFailed');
if (errorCode === 'PASSWORD_REQUIRED') {
this.passwordPromptRoom.set(room);

View File

@@ -1,7 +1,7 @@
@if (server()) {
<div class="max-w-2xl space-y-3">
@if (bannedUsers().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">No banned users</p>
<p class="text-sm text-muted-foreground text-center py-8">{{ 'settings.bans.empty' | translate }}</p>
} @else {
@for (ban of bannedUsers(); track ban.oderId) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
@@ -10,15 +10,15 @@
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground truncate">
{{ ban.displayName || 'Unknown User' }}
{{ ban.displayName || ('settings.bans.unknownUser' | translate) }}
</p>
@if (ban.reason) {
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
<p class="text-xs text-muted-foreground truncate">{{ 'settings.bans.reason' | translate: { reason: ban.reason } }}</p>
}
@if (ban.expiresAt) {
<p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.bans.expires' | translate: { date: formatExpiry(ban.expiresAt) } }}</p>
} @else {
<p class="text-xs text-destructive">Permanent</p>
<p class="text-xs text-destructive">{{ 'settings.bans.permanent' | translate }}</p>
}
</div>
@if (isAdmin()) {
@@ -38,5 +38,5 @@
}
</div>
} @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">{{ 'settings.bans.selectServer' | translate }}</div>
}

View File

@@ -17,11 +17,12 @@ import { Room, BanEntry } from '../../../../shared-kernel';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
@Component({
selector: 'app-bans-settings',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [
provideIcons({
lucideX

View File

@@ -7,10 +7,10 @@
name="lucideDatabase"
class="h-5 w-5"
/>
<h4 class="text-base font-semibold text-foreground">Local data</h4>
<h4 class="text-base font-semibold text-foreground">{{ 'settings.data.localData.title' | translate }}</h4>
</div>
<p class="mt-2 text-sm text-muted-foreground">
Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.
{{ 'settings.data.localData.description' | translate }}
</p>
</div>
@@ -33,14 +33,14 @@
@if (!isElectron) {
<section class="rounded-lg border border-border bg-secondary/30 p-5">
<p class="text-sm text-muted-foreground">Data management is only available in the packaged Electron desktop app.</p>
<p class="text-sm text-muted-foreground">{{ 'settings.data.desktopOnly' | translate }}</p>
</section>
} @else {
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Current data folder</h5>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.currentFolder.title' | translate }}</h5>
<p class="mt-2 break-all rounded-lg border border-border bg-secondary/20 px-3 py-2 text-sm text-muted-foreground">
{{ dataPath() || 'Resolving data folder...' }}
{{ dataPath() || ('settings.data.currentFolder.resolving' | translate) }}
</p>
</div>
@@ -54,15 +54,15 @@
name="lucideFolderOpen"
class="h-4 w-4"
/>
{{ busyAction() === 'open' ? 'Opening...' : 'Open folder' }}
{{ busyAction() === 'open' ? ('settings.data.opening' | translate) : ('settings.data.openFolder' | translate) }}
</button>
</section>
<section class="grid gap-4 md:grid-cols-2">
<div class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Export data</h5>
<p class="mt-1 text-sm text-muted-foreground">Create a portable .dat archive that can be imported on another client.</p>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.export.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">{{ 'settings.data.export.description' | translate }}</p>
</div>
<button
@@ -75,14 +75,14 @@
name="lucideDownload"
class="h-4 w-4"
/>
{{ busyAction() === 'export' ? 'Exporting...' : 'Export data' }}
{{ busyAction() === 'export' ? ('settings.data.export.exporting' | translate) : ('settings.data.export.button' | translate) }}
</button>
</div>
<div class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Import all data</h5>
<p class="mt-1 text-sm text-muted-foreground">Restore a .dat archive. Existing local data is moved to a backup folder first.</p>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.import.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">{{ 'settings.data.import.description' | translate }}</p>
</div>
<button
@@ -95,15 +95,15 @@
name="lucideUpload"
class="h-4 w-4"
/>
{{ busyAction() === 'import' ? 'Importing...' : 'Import data' }}
{{ busyAction() === 'import' ? ('settings.data.import.importing' | translate) : ('settings.data.import.button' | translate) }}
</button>
</div>
</section>
<section class="space-y-4 rounded-lg border border-destructive/30 bg-destructive/10 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Erase user data</h5>
<p class="mt-1 text-sm text-muted-foreground">Remove local app data from this device and recreate an empty database.</p>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.erase.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">{{ 'settings.data.erase.description' | translate }}</p>
</div>
<button
@@ -116,7 +116,7 @@
name="lucideTrash2"
class="h-4 w-4"
/>
{{ busyAction() === 'erase' ? 'Erasing...' : 'Erase user data' }}
{{ busyAction() === 'erase' ? ('settings.data.erase.erasing' | translate) : ('settings.data.erase.button' | translate) }}
</button>
</section>

View File

@@ -16,13 +16,14 @@ import {
} from '@ng-icons/lucide';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
@Component({
selector: 'app-data-settings',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [
provideIcons({
lucideDatabase,
@@ -37,6 +38,7 @@ type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
})
export class DataSettingsComponent {
private readonly electron = inject(ElectronBridgeService);
private readonly appI18n = inject(AppI18nService);
readonly isElectron = this.electron.isAvailable;
readonly dataPath = signal<string | null>(null);
@@ -53,7 +55,9 @@ export class DataSettingsComponent {
await this.runAction('open', async () => {
const opened = await this.electron.requireApi().openCurrentDataFolder();
this.statusMessage.set(opened ? 'Opened the current data folder.' : 'Could not open the data folder.');
this.statusMessage.set(opened
? this.appI18n.instant('settings.data.messages.openedFolder')
: this.appI18n.instant('settings.data.messages.couldNotOpenFolder'));
});
}
@@ -62,16 +66,18 @@ export class DataSettingsComponent {
const result = await this.electron.requireApi().exportUserData();
if (result.cancelled) {
this.statusMessage.set('Export cancelled.');
this.statusMessage.set(this.appI18n.instant('settings.data.messages.exportCancelled'));
return;
}
this.statusMessage.set(result.filePath ? `Exported data to ${result.filePath}.` : 'Exported data.');
this.statusMessage.set(result.filePath
? this.appI18n.instant('settings.data.messages.exportedTo', { path: result.filePath })
: this.appI18n.instant('settings.data.messages.exported'));
});
}
async importData(): Promise<void> {
if (!window.confirm('Importing data replaces the current local data. Existing data will be moved to a backup folder first. Continue?')) {
if (!window.confirm(this.appI18n.instant('settings.data.import.confirm'))) {
return;
}
@@ -79,19 +85,19 @@ export class DataSettingsComponent {
const result = await this.electron.requireApi().importUserData();
if (result.cancelled) {
this.statusMessage.set('Import cancelled.');
this.statusMessage.set(this.appI18n.instant('settings.data.messages.importCancelled'));
return;
}
this.restartRequired.set(result.restartRequired);
this.statusMessage.set(result.backupPath
? `Imported data. Previous data was backed up to ${result.backupPath}.`
: 'Imported data.');
? this.appI18n.instant('settings.data.messages.importedWithBackup', { path: result.backupPath })
: this.appI18n.instant('settings.data.messages.imported'));
});
}
async eraseData(): Promise<void> {
if (!window.confirm('Erase all local MetoYou data on this device? This cannot be undone.')) {
if (!window.confirm(this.appI18n.instant('settings.data.erase.confirm'))) {
return;
}
@@ -99,7 +105,7 @@ export class DataSettingsComponent {
const result = await this.electron.requireApi().eraseUserData();
this.restartRequired.set(result.restartRequired);
this.statusMessage.set('Local data erased. Restart the app to finish resetting the session.');
this.statusMessage.set(this.appI18n.instant('settings.data.messages.erased'));
await this.loadDataPath();
});
}
@@ -130,7 +136,7 @@ export class DataSettingsComponent {
try {
await operation();
} catch (error) {
this.errorMessage.set(error instanceof Error ? error.message : 'Data operation failed.');
this.errorMessage.set(error instanceof Error ? error.message : this.appI18n.instant('settings.data.messages.operationFailed'));
} finally {
this.busyAction.set(null);
}

View File

@@ -10,9 +10,9 @@
</div>
<div>
<h4 class="text-sm font-semibold text-foreground">App-wide debugging</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.debugging.title' | translate }}</h4>
<p class="mt-1 text-sm text-muted-foreground">
Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.
{{ 'settings.debugging.description' | translate }}
</p>
</div>
</div>
@@ -39,11 +39,11 @@
name="lucideMemoryStick"
class="h-4 w-4"
/>
<span class="text-sm">Process RAM</span>
<span class="text-sm">{{ 'settings.debugging.processRam' | translate }}</span>
</div>
<span class="font-mono text-sm text-foreground">{{ ramLabel() ?? '-' }}</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">Live total working set from Electron app metrics. Updates every 2 seconds.</p>
<p class="mt-1 text-xs text-muted-foreground">{{ 'settings.debugging.ramHint' | translate }}</p>
</section>
}
@@ -54,10 +54,10 @@
name="lucideClock3"
class="h-4 w-4"
/>
<span class="text-xs font-medium uppercase tracking-wide">Captured events</span>
<span class="text-xs font-medium uppercase tracking-wide">{{ 'settings.debugging.capturedEvents' | translate }}</span>
</div>
<p class="mt-3 text-2xl font-semibold text-foreground">{{ entryCount() }}</p>
<p class="mt-1 text-xs text-muted-foreground">Last update: {{ lastUpdatedLabel() }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ 'settings.debugging.lastUpdate' | translate: { label: lastUpdatedLabel() } }}</p>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
@@ -66,10 +66,10 @@
name="lucideCircleAlert"
class="h-4 w-4"
/>
<span class="text-xs font-medium uppercase tracking-wide">Errors</span>
<span class="text-xs font-medium uppercase tracking-wide">{{ 'settings.debugging.errors' | translate }}</span>
</div>
<p class="mt-3 text-2xl font-semibold text-destructive">{{ errorCount() }}</p>
<p class="mt-1 text-xs text-muted-foreground">Unhandled runtime failures and rejected promises.</p>
<p class="mt-1 text-xs text-muted-foreground">{{ 'settings.debugging.errorsHint' | translate }}</p>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
@@ -78,19 +78,19 @@
name="lucideTriangleAlert"
class="h-4 w-4"
/>
<span class="text-xs font-medium uppercase tracking-wide">Warnings</span>
<span class="text-xs font-medium uppercase tracking-wide">{{ 'settings.debugging.warnings' | translate }}</span>
</div>
<p class="mt-3 text-2xl font-semibold text-yellow-400">{{ warningCount() }}</p>
<p class="mt-1 text-xs text-muted-foreground">Navigation cancellations, offline events, and other warnings.</p>
<p class="mt-1 text-xs text-muted-foreground">{{ 'settings.debugging.warningsHint' | translate }}</p>
</div>
</section>
<section class="rounded-lg border border-border bg-card/40 p-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h5 class="text-sm font-semibold text-foreground">Floating debug console</h5>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.debugging.console.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">
When debugging is enabled, a bug icon appears in the app so you can open the docked console without blocking the rest of the UI.
{{ 'settings.debugging.console.description' | translate }}
</p>
</div>
@@ -101,7 +101,7 @@
[disabled]="!enabled()"
class="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{{ isConsoleOpen() ? 'Console open' : 'Open console' }}
{{ isConsoleOpen() ? ('settings.debugging.console.openActive' | translate) : ('settings.debugging.console.open' | translate) }}
</button>
<button

View File

@@ -24,13 +24,14 @@ import { DebuggingService } from '../../../../core/services/debugging.service';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { formatAppRamLabel } from '../../../../core/platform/electron/electron-app-metrics.rules';
import { PlatformService } from '../../../../core/platform';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
const APP_METRICS_POLL_INTERVAL_MS = 2_000;
@Component({
selector: 'app-debugging-settings',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [
provideIcons({
lucideBug,
@@ -48,6 +49,7 @@ export class DebuggingSettingsComponent {
private readonly platform = inject(PlatformService);
private readonly electronBridge = inject(ElectronBridgeService);
readonly debugging = inject(DebuggingService);
private readonly appI18n = inject(AppI18nService);
readonly isElectron = this.platform.isElectron;
readonly ramLabel = signal<string | null>(null);
@@ -69,7 +71,7 @@ export class DebuggingSettingsComponent {
readonly lastUpdatedLabel = computed(() => {
const lastEntry = this.debugging.entries().at(-1);
return lastEntry ? lastEntry.timeLabel : 'No logs yet';
return lastEntry ? lastEntry.timeLabel : this.appI18n.instant('settings.debugging.noLogsYet');
});
constructor() {

View File

@@ -5,15 +5,15 @@
name="lucidePower"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Application</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.general.application' | translate }}</h4>
</div>
<div class="space-y-3">
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Reopen last chat on launch</p>
<p class="text-xs text-muted-foreground">Open the same server and text channel the next time MetoYou starts.</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.general.reopenLastChat.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.reopenLastChat.description' | translate }}</p>
</div>
<label class="relative inline-flex cursor-pointer items-center">
@@ -22,7 +22,7 @@
[checked]="reopenLastViewedChat()"
(change)="onReopenLastViewedChatChange($event)"
id="general-reopen-last-chat-toggle"
aria-label="Toggle reopen last chat on launch"
[attr.aria-label]="'settings.general.reopenLastChat.aria' | translate"
class="sr-only peer"
/>
<div
@@ -38,12 +38,12 @@
>
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Launch on system startup</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.general.autoStart.label' | translate }}</p>
@if (isElectron) {
<p class="text-xs text-muted-foreground">Automatically start MetoYou when you sign in</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.autoStart.description' | translate }}</p>
} @else {
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.autoStart.desktopOnly' | translate }}</p>
}
</div>
@@ -58,7 +58,7 @@
[disabled]="!isElectron || savingAutoStart()"
(change)="onAutoStartChange($event)"
id="general-auto-start-toggle"
aria-label="Toggle launch on startup"
[attr.aria-label]="'settings.general.autoStart.aria' | translate"
class="sr-only peer"
/>
<div
@@ -74,12 +74,12 @@
>
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Minimize to tray on close</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.general.closeToTray.label' | translate }}</p>
@if (isElectron) {
<p class="text-xs text-muted-foreground">Keep MetoYou running in the tray when you click the X button</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.closeToTray.description' | translate }}</p>
} @else {
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.autoStart.desktopOnly' | translate }}</p>
}
</div>
@@ -94,7 +94,7 @@
[disabled]="!isElectron || savingCloseToTray()"
(change)="onCloseToTrayChange($event)"
id="general-close-to-tray-toggle"
aria-label="Toggle minimize to tray on close"
[attr.aria-label]="'settings.general.closeToTray.aria' | translate"
class="sr-only peer"
/>
<div
@@ -107,13 +107,13 @@
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Experimental VLC.js playback</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.general.experimentalVlc.label' | translate }}</p>
@if (experimentalMedia.vlcJsRuntimeStatus() === 'checking') {
<p class="text-xs text-muted-foreground">Checking for a bundled VLC.js runtime...</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.experimentalVlc.checking' | translate }}</p>
} @else if (experimentalMedia.vlcJsRuntimeAvailable()) {
<p class="text-xs text-muted-foreground">Offer a manual player for unsupported downloaded audio and video files.</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.experimentalVlc.available' | translate }}</p>
} @else {
<p class="text-xs text-muted-foreground">No VLC.js runtime is bundled. Unsupported desktop media can be opened in the system player.</p>
<p class="text-xs text-muted-foreground">{{ 'settings.general.experimentalVlc.unavailable' | translate }}</p>
}
</div>
@@ -128,7 +128,7 @@
[disabled]="!experimentalMedia.vlcJsRuntimeAvailable()"
(change)="onExperimentalVlcPlaybackChange($event)"
id="general-experimental-vlc-playback-toggle"
aria-label="Toggle experimental VLC.js playback"
[attr.aria-label]="'settings.general.experimentalVlc.aria' | translate"
class="sr-only peer"
/>
<div
@@ -143,24 +143,23 @@
@if (isElectron) {
<section>
<div class="flex items-center gap-2 mb-3">
<h4 class="text-sm font-semibold text-foreground">Game detection</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.general.gameDetection.title' | translate }}</h4>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4 space-y-3">
<p class="text-xs text-muted-foreground">
MetoYou prefers the currently focused window when detecting your game. Add process names here to permanently hide apps that get mistakenly
identified as games (e.g. "spotify", "obs64"). Entries are matched case-insensitively against the executable name without its extension.
{{ 'settings.general.gameDetection.description' | translate }}
</p>
<div class="flex items-center gap-2">
<input
type="text"
class="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Process name (e.g. spotify)"
[placeholder]="'settings.general.gameDetection.processPlaceholder' | translate"
[value]="ignoredProcessDraft()"
(input)="onIgnoredProcessDraftChange($event)"
(keydown.enter)="addIgnoredProcess()"
aria-label="Process name to ignore"
[attr.aria-label]="'settings.general.gameDetection.processAria' | translate"
/>
<button
type="button"
@@ -168,12 +167,12 @@
[disabled]="savingIgnoredGameProcesses() || !ignoredProcessDraft().trim()"
(click)="addIgnoredProcess()"
>
Add
{{ 'settings.general.gameDetection.add' | translate }}
</button>
</div>
@if (ignoredGameProcesses().length === 0) {
<p class="text-xs text-muted-foreground italic">No ignored processes yet.</p>
<p class="text-xs text-muted-foreground italic">{{ 'settings.general.gameDetection.empty' | translate }}</p>
} @else {
<ul class="flex flex-wrap gap-2">
@for (entry of ignoredGameProcesses(); track entry) {
@@ -184,7 +183,7 @@
class="text-muted-foreground hover:text-foreground"
[disabled]="savingIgnoredGameProcesses()"
(click)="removeIgnoredProcess(entry)"
[attr.aria-label]="'Remove ' + entry + ' from ignore list'"
[attr.aria-label]="'settings.general.gameDetection.removeProcessAria' | translate: { name: entry }"
>
×
</button>

View File

@@ -13,11 +13,12 @@ import { loadGeneralSettingsFromStorage, saveGeneralSettingsToStorage } from '..
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PlatformService } from '../../../../core/platform';
import { ExperimentalMediaSettingsService } from '../../../../domains/experimental-media/application/services/experimental-media-settings.service';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
@Component({
selector: 'app-general-settings',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [
provideIcons({
lucidePower

View File

@@ -5,7 +5,7 @@
name="lucideShield"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">ICE Servers (STUN / TURN)</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.network.ice.title' | translate }}</h4>
</div>
<button
type="button"
@@ -17,13 +17,12 @@
name="lucideRotateCcw"
class="w-3.5 h-3.5"
/>
Restore Defaults
{{ 'settings.network.ice.restoreDefaults' | translate }}
</button>
</div>
<p class="text-xs text-muted-foreground mb-3">
ICE servers are used for NAT traversal. STUN discovers your public address; TURN relays traffic when direct connections fail. Higher entries have
priority.
{{ 'settings.network.ice.description' | translate }}
</p>
<!-- ICE Server List -->
@@ -52,7 +51,7 @@
<div class="flex-1 min-w-0">
<p class="text-sm text-foreground truncate">{{ entry.urls }}</p>
@if (entry.type === 'turn' && entry.username) {
<p class="text-[10px] text-muted-foreground truncate">User: {{ entry.username }}</p>
<p class="text-[10px] text-muted-foreground truncate">{{ 'settings.network.ice.turnUser' | translate: { username: entry.username } }}</p>
}
</div>
<div class="flex items-center gap-0.5 flex-shrink-0">
@@ -61,7 +60,7 @@
(click)="moveUp(i)"
[disabled]="i === 0"
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-secondary disabled:opacity-30"
title="Move up (higher priority)"
[title]="'settings.network.ice.moveUp' | translate"
>
<ng-icon
name="lucideArrowUp"
@@ -73,7 +72,7 @@
(click)="moveDown(i)"
[disabled]="i === entries().length - 1"
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-secondary disabled:opacity-30"
title="Move down (lower priority)"
[title]="'settings.network.ice.moveDown' | translate"
>
<ng-icon
name="lucideArrowDown"
@@ -84,7 +83,7 @@
type="button"
(click)="removeEntry(entry.id)"
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-destructive/10"
title="Remove"
[title]="'settings.network.ice.moveDown' | translate"
>
<ng-icon
name="lucideTrash2"
@@ -95,13 +94,13 @@
</div>
}
@if (entries().length === 0) {
<p class="text-xs text-muted-foreground italic py-2">No ICE servers configured. P2P connections may fail across networks.</p>
<p class="text-xs text-muted-foreground italic py-2">{{ 'settings.network.ice.empty' | translate }}</p>
}
</div>
<!-- Add New ICE Server -->
<div class="border-t border-border pt-3">
<h4 class="text-xs font-medium text-foreground mb-2">Add ICE Server</h4>
<h4 class="text-xs font-medium text-foreground mb-2">{{ 'settings.network.ice.addTitle' | translate }}</h4>
<div class="space-y-1.5">
<div class="flex gap-2">
<select
@@ -116,7 +115,7 @@
type="text"
[(ngModel)]="newUrl"
data-testid="ice-url-input"
[placeholder]="newType === 'stun' ? 'stun:stun.example.com:19302' : 'turn:turn.example.com:3478'"
[placeholder]="(newType === 'stun' ? 'settings.network.ice.stunPlaceholder' : 'settings.network.ice.turnPlaceholder') | translate"
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@@ -126,14 +125,14 @@
type="text"
[(ngModel)]="newUsername"
data-testid="ice-username-input"
placeholder="Username"
[placeholder]="'settings.network.ice.username' | translate"
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="password"
[(ngModel)]="newCredential"
data-testid="ice-credential-input"
placeholder="Credential"
[placeholder]="'settings.network.ice.credential' | translate"
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@@ -150,7 +149,7 @@
name="lucidePlus"
class="w-3.5 h-3.5"
/>
Add Server
{{ 'settings.network.ice.addServer' | translate }}
</button>
</div>
</div>

View File

@@ -17,6 +17,7 @@ import {
} from '@ng-icons/lucide';
import { IceServerSettingsService, IceServerEntry } from '../../../../infrastructure/realtime/ice-server-settings.service';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
@Component({
selector: 'app-ice-server-settings',
@@ -27,7 +28,8 @@ import { IceServerSettingsService, IceServerEntry } from '../../../../infrastruc
imports: [
CommonModule,
FormsModule,
NgIcon
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -43,6 +45,7 @@ import { IceServerSettingsService, IceServerEntry } from '../../../../infrastruc
})
export class IceServerSettingsComponent {
private iceSettings = inject(IceServerSettingsService);
private readonly appI18n = inject(AppI18nService);
entries = this.iceSettings.entries;
@@ -58,29 +61,33 @@ export class IceServerSettingsComponent {
const url = this.newUrl.trim();
if (!url) {
this.addError.set('URL is required');
this.addError.set(this.appI18n.instant('settings.network.ice.errors.urlRequired'));
return;
}
const prefix = this.newType === 'stun' ? 'stun:' : 'turn';
if (!url.startsWith(prefix) && !url.startsWith('turns:')) {
this.addError.set(`URL must start with ${this.newType === 'stun' ? 'stun:' : 'turn: or turns:'}`);
this.addError.set(this.appI18n.instant(
this.newType === 'stun'
? 'settings.network.ice.errors.urlPrefixStun'
: 'settings.network.ice.errors.urlPrefixTurn'
));
return;
}
if (this.newType === 'turn' && !this.newUsername.trim()) {
this.addError.set('Username is required for TURN servers');
this.addError.set(this.appI18n.instant('settings.network.ice.errors.usernameRequired'));
return;
}
if (this.newType === 'turn' && !this.newCredential.trim()) {
this.addError.set('Credential is required for TURN servers');
this.addError.set(this.appI18n.instant('settings.network.ice.errors.credentialRequired'));
return;
}
if (this.entries().some((entry) => entry.urls === url)) {
this.addError.set('This URL already exists');
this.addError.set(this.appI18n.instant('settings.network.ice.errors.duplicateUrl'));
return;
}

View File

@@ -2,7 +2,7 @@
<section class="rounded-lg border border-border bg-card/60 p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h4 class="text-base font-semibold text-foreground">Local HTTP API</h4>
<h4 class="text-base font-semibold text-foreground">{{ 'settings.localApi.title' | translate }}</h4>
<p class="mt-1 text-sm text-muted-foreground">
Expose your client to local automation tools and scripts. Authentication is verified against your signaling server, and access is off by
default.
@@ -17,13 +17,13 @@
@if (!isElectron) {
<section class="rounded-lg border border-border bg-secondary/30 p-5">
<p class="text-sm text-muted-foreground">The local API is only available in the packaged Electron desktop app.</p>
<p class="text-sm text-muted-foreground">{{ 'settings.localApi.desktopOnly' | translate }}</p>
</section>
} @else {
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Server</h5>
<p class="mt-1 text-sm text-muted-foreground">Enable to start a local HTTP server. By default it only listens on the loopback interface.</p>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.localApi.server.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">{{ 'settings.localApi.server.description' | translate }}</p>
</div>
<label class="flex items-start gap-3">
@@ -35,8 +35,8 @@
(change)="toggleEnabled($event)"
/>
<span class="text-sm text-foreground">
<span class="font-medium">Run local API server</span>
<span class="mt-1 block text-xs text-muted-foreground">Start the HTTP server on this machine.</span>
<span class="font-medium">{{ 'settings.localApi.server.run' | translate }}</span>
<span class="mt-1 block text-xs text-muted-foreground">{{ 'settings.localApi.server.runHint' | translate }}</span>
</span>
</label>
@@ -49,8 +49,8 @@
(change)="toggleDocusaurus($event)"
/>
<span class="text-sm text-foreground">
<span class="font-medium">Serve Docusaurus documentation at <code>/docusaurus</code></span>
<span class="mt-1 block text-xs text-muted-foreground"> Hosts the built app and plugin documentation from local desktop resources. </span>
<span class="font-medium">{{ 'settings.localApi.server.docusaurus' | translate }}</span>
<span class="mt-1 block text-xs text-muted-foreground">{{ 'settings.localApi.server.docusaurusHint' | translate }}</span>
</span>
</label>
@@ -63,16 +63,18 @@
(change)="toggleExposeOnLan($event)"
/>
<span class="text-sm text-foreground">
<span class="font-medium">Allow connections from your network</span>
<span class="font-medium">{{ 'settings.localApi.server.exposeLan' | translate }}</span>
<span class="mt-1 block text-xs text-muted-foreground">
Bind to all interfaces (0.0.0.0). Other devices on your LAN will be able to reach the API. Only enable this on networks you trust.
{{ 'settings.localApi.server.exposeLanHint' | translate }}
</span>
</span>
</label>
<div class="grid gap-3 md:grid-cols-[200px_1fr_auto] md:items-end">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Port</span>
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">{{
'settings.localApi.server.port' | translate
}}</span>
<input
type="number"
min="1"
@@ -84,7 +86,7 @@
/>
</label>
<p class="text-xs text-muted-foreground">Change the listening port if 17878 is in use. Press save to apply.</p>
<p class="text-xs text-muted-foreground">{{ 'settings.localApi.server.portHint' | translate }}</p>
<button
type="button"
@@ -99,17 +101,16 @@
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Authentication</h5>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.localApi.auth.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">
Bearer tokens are issued only after a username/password is verified against one of the signaling servers below. Add the full URL (including
<code>https://</code>) of every signaling server you trust.
{{ 'settings.localApi.auth.description' | translate }}
</p>
</div>
<textarea
rows="4"
spellcheck="false"
placeholder="https://signaling.example.com"
[placeholder]="'settings.localApi.auth.placeholder' | translate"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 font-mono text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
[value]="allowedServersText()"
[disabled]="busy()"
@@ -130,9 +131,9 @@
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Documentation</h5>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.localApi.docs.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">
Browse the API in a privacy-respecting locally hosted Scalar reference. No telemetry, no AI, no remote network calls.
{{ 'settings.localApi.docs.description' | translate }}
</p>
</div>
@@ -145,9 +146,9 @@
(change)="toggleScalar($event)"
/>
<span class="text-sm text-foreground">
<span class="font-medium">Serve Scalar documentation at <code>/docs</code></span>
<span class="font-medium">{{ 'settings.localApi.docs.scalar' | translate }}</span>
<span class="mt-1 block text-xs text-muted-foreground">
Loads from local app resources only. The OpenAPI document is always available at <code>/api/openapi.json</code>.
{{ 'settings.localApi.docs.scalarHint' | translate }}
</span>
</span>
</label>
@@ -183,7 +184,7 @@
@if (status().baseUrl) {
<p class="text-xs text-muted-foreground">
Listening at <code class="rounded bg-secondary px-2 py-1">{{ status().baseUrl }}</code>
{{ 'settings.localApi.docs.listeningAt' | translate }} <code class="rounded bg-secondary px-2 py-1">{{ status().baseUrl }}</code>
</p>
}
</section>

View File

@@ -16,15 +16,17 @@ import type {
LocalApiSettings,
LocalApiSnapshot
} from '../../../../core/platform/electron/electron-api.models';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
@Component({
selector: 'app-local-api-settings',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, ...APP_TRANSLATE_IMPORTS],
templateUrl: './local-api-settings.component.html'
})
export class LocalApiSettingsComponent implements OnInit, OnDestroy {
private readonly bridge = inject(ElectronBridgeService);
private readonly appI18n = inject(AppI18nService);
readonly isElectron = this.bridge.isAvailable;
@@ -60,14 +62,18 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
switch (snapshot.status) {
case 'running':
return `Running at ${snapshot.baseUrl ?? 'unknown'}`;
return this.appI18n.instant('settings.localApi.status.running', {
url: snapshot.baseUrl ?? this.appI18n.instant('settings.localApi.status.unknown')
});
case 'starting':
return 'Starting...';
return this.appI18n.instant('settings.localApi.status.starting');
case 'error':
return `Error: ${snapshot.error ?? 'unknown error'}`;
return this.appI18n.instant('settings.localApi.status.error', {
message: snapshot.error ?? this.appI18n.instant('settings.localApi.status.unknown')
});
case 'stopped':
default:
return 'Stopped';
return this.appI18n.instant('settings.localApi.status.stopped');
}
});
@@ -163,7 +169,7 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
const parsed = Number(this.portText());
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
this.errorMessage.set('Port must be an integer between 1 and 65535');
this.errorMessage.set(this.appI18n.instant('settings.localApi.errors.invalidPort'));
return;
}
@@ -198,7 +204,7 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
const result = await api.openLocalApiDocs();
if (result && !result.opened) {
this.errorMessage.set(result.reason ?? 'Could not open documentation');
this.errorMessage.set(result.reason ?? this.appI18n.instant('settings.localApi.errors.couldNotOpenDocs'));
} else {
this.errorMessage.set(null);
}
@@ -213,7 +219,7 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
const result = await api.openDocusaurusDocs();
if (result && !result.opened) {
this.errorMessage.set(result.reason ?? 'Could not open documentation');
this.errorMessage.set(result.reason ?? this.appI18n.instant('settings.localApi.errors.couldNotOpenDocs'));
} else {
this.errorMessage.set(null);
await this.refresh();
@@ -263,7 +269,7 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
await this.refreshStatus();
} catch (error) {
this.errorMessage.set((error as Error).message ?? 'Failed to update settings');
this.errorMessage.set((error as Error).message ?? this.appI18n.instant('settings.localApi.errors.updateFailed'));
} finally {
this.busy.set(false);
}

View File

@@ -1,7 +1,7 @@
@if (server()) {
<div class="space-y-3 max-w-3xl">
@if (members().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">No other members found for this server</p>
<p class="text-sm text-muted-foreground text-center py-8">{{ 'settings.members.empty' | translate }}</p>
} @else {
@for (member of members(); track member.oderId || member.id) {
<div class="space-y-3 rounded-lg bg-secondary/50 p-3">
@@ -16,7 +16,7 @@
{{ member.displayName }}
</p>
@if (member.isOnline) {
<span class="rounded bg-emerald-500/20 px-1 py-0.5 text-[10px] text-emerald-400">Online</span>
<span class="rounded bg-emerald-500/20 px-1 py-0.5 text-[10px] text-emerald-400">{{ 'settings.members.online' | translate }}</span>
}
<span class="rounded bg-primary/10 px-1 py-0.5 text-[10px] text-primary">{{ member.displayRoleName }}</span>
</div>
@@ -28,7 +28,7 @@
type="button"
(click)="kickMember(member)"
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
title="Kick"
[title]="'settings.members.kick' | translate"
>
<ng-icon
name="lucideUserX"
@@ -41,7 +41,7 @@
type="button"
(click)="banMember(member)"
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
title="Ban"
[title]="'settings.members.ban' | translate"
>
<ng-icon
name="lucideBan"
@@ -54,7 +54,7 @@
@if (assignableRoles().length > 0 && canChangeRoles(member)) {
<div class="space-y-2 border-t border-border/50 pt-3">
<p class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Assigned Roles</p>
<p class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{{ 'settings.members.assignedRoles' | translate }}</p>
<div class="flex flex-wrap gap-2">
@for (role of assignableRoles(); track role.id) {
<label class="flex items-center gap-2 rounded-full border border-border bg-background/70 px-3 py-1 text-xs text-foreground">
@@ -75,7 +75,7 @@
</div>
} @else if (assignableRoles().length > 0) {
<p class="border-t border-border/50 pt-3 text-xs text-muted-foreground">
You can view this member's roles, but you do not have permission to change them.
{{ 'settings.members.readOnlyRoles' | translate }}
</p>
}
</div>
@@ -83,5 +83,5 @@
}
</div>
} @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">{{ 'settings.members.selectServer' | translate }}</div>
}

View File

@@ -29,6 +29,7 @@ import {
normalizeRoomAccessControl,
setRoleAssignmentsForMember
} from '../../../../domains/access-control';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
interface ServerMemberView extends RoomMember {
assignedRoleIds: string[];
@@ -43,7 +44,8 @@ interface ServerMemberView extends RoomMember {
CommonModule,
FormsModule,
NgIcon,
UserAvatarComponent
UserAvatarComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -55,6 +57,7 @@ interface ServerMemberView extends RoomMember {
})
export class MembersSettingsComponent {
private store = inject(Store);
private readonly i18n = inject(AppI18nService);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
@@ -93,7 +96,7 @@ export class MembersSettingsComponent {
return {
...member,
assignedRoleIds: getRoleIdsForMember(room, member),
displayRoleName: getDisplayRoleName(room, member),
displayRoleName: getDisplayRoleName(room, member, (key) => this.i18n.instant(key)),
avatarUrl: liveUser?.avatarUrl || member.avatarUrl,
displayName: liveUser?.displayName || member.displayName,
isOnline: !!liveUser && (liveUser.isOnline === true || liveUser.status !== 'offline')

View File

@@ -7,7 +7,7 @@
name="lucideGlobe"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.network.serverEndpoints.title' | translate }}</h4>
</div>
<div class="flex items-center gap-2">
@if (hasMissingDefaultServers()) {
@@ -16,7 +16,7 @@
(click)="restoreDefaultServers()"
class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
>
Restore Defaults
{{ 'settings.network.serverEndpoints.restoreDefaults' | translate }}
</button>
}
<button
@@ -30,13 +30,13 @@
class="w-3.5 h-3.5"
[class.animate-spin]="isTesting()"
/>
Test All
{{ 'settings.network.serverEndpoints.testAll' | translate }}
</button>
</div>
</div>
<p class="text-xs text-muted-foreground mb-3">
Active server endpoints stay enabled at the same time. You pick the endpoint when creating a new server.
{{ 'settings.network.serverEndpoints.descriptionModal' | translate }}
</p>
<!-- Server List -->
@@ -61,7 +61,9 @@
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-foreground truncate">{{ server.name }}</span>
@if (server.isActive) {
<span class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full">Active</span>
<span class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full">{{
'settings.network.serverEndpoints.active' | translate
}}</span>
}
</div>
<p class="text-xs text-muted-foreground truncate">{{ server.url }}</p>
@@ -69,7 +71,7 @@
<p class="text-[10px] text-muted-foreground">{{ server.latency }}ms</p>
}
@if (server.status === 'incompatible') {
<p class="text-[10px] text-destructive">Update the client in order to connect to other users</p>
<p class="text-[10px] text-destructive">{{ 'settings.network.serverEndpoints.incompatible' | translate }}</p>
}
</div>
<div class="flex items-center gap-1 flex-shrink-0">
@@ -78,7 +80,7 @@
type="button"
(click)="setActiveServer(server.id)"
class="grid h-8 w-8 place-items-center rounded-lg transition-colors hover:bg-secondary"
title="Activate"
[title]="'settings.network.serverEndpoints.activate' | translate"
>
<ng-icon
name="lucideCheck"
@@ -91,7 +93,7 @@
type="button"
(click)="deactivateServer(server.id)"
class="grid h-8 w-8 place-items-center rounded-lg transition-colors hover:bg-secondary"
title="Deactivate"
[title]="'settings.network.serverEndpoints.deactivate' | translate"
>
<ng-icon
name="lucideX"
@@ -104,7 +106,7 @@
type="button"
(click)="removeServer(server.id)"
class="grid h-8 w-8 place-items-center rounded-lg transition-colors hover:bg-destructive/10"
title="Remove"
[title]="'settings.network.serverEndpoints.removeShort' | translate"
>
<ng-icon
name="lucideTrash2"
@@ -119,19 +121,19 @@
<!-- Add New Server -->
<div class="border-t border-border pt-3">
<h4 class="text-xs font-medium text-foreground mb-2">Add New Server</h4>
<h4 class="text-xs font-medium text-foreground mb-2">{{ 'settings.network.serverEndpoints.addNew' | translate }}</h4>
<div class="flex gap-2">
<div class="flex-1 space-y-1.5">
<input
type="text"
[(ngModel)]="newServerName"
placeholder="Server name"
[placeholder]="'settings.network.serverEndpoints.serverNamePlaceholderShort' | translate"
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="url"
[(ngModel)]="newServerUrl"
placeholder="Server URL (e.g., http://localhost:3001)"
[placeholder]="'settings.network.serverEndpoints.serverUrlPlaceholder' | translate"
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@@ -160,13 +162,13 @@
name="lucideServer"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Connection</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.network.connection.titleShort' | translate }}</h4>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Auto-reconnect</p>
<p class="text-xs text-muted-foreground">Reconnect when connection is lost</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.network.connection.autoReconnect.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.network.connection.autoReconnect.descriptionShort' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -182,8 +184,8 @@
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Search all servers</p>
<p class="text-xs text-muted-foreground">Search across all server directories</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.network.connection.searchAllServers.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.network.connection.searchAllServers.descriptionShort' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input

View File

@@ -21,6 +21,7 @@ import {
import { ServerDirectoryFacade } from '../../../../domains/server-directory';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-settings.component';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
@Component({
selector: 'app-network-settings',
@@ -29,7 +30,8 @@ import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-se
CommonModule,
FormsModule,
NgIcon,
IceServerSettingsComponent
IceServerSettingsComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -46,6 +48,7 @@ import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-se
})
export class NetworkSettingsComponent {
private serverDirectory = inject(ServerDirectoryFacade);
private readonly appI18n = inject(AppI18nService);
servers = this.serverDirectory.servers;
activeServers = this.serverDirectory.activeServers;
@@ -69,12 +72,12 @@ export class NetworkSettingsComponent {
try {
new URL(this.newServerUrl);
} catch {
this.addError.set('Please enter a valid URL');
this.addError.set(this.appI18n.instant('settings.network.serverEndpoints.errors.invalidUrl'));
return;
}
if (this.servers().some((serverEntry) => serverEntry.url === this.newServerUrl)) {
this.addError.set('This server URL already exists');
this.addError.set(this.appI18n.instant('settings.network.serverEndpoints.errors.duplicateUrl'));
return;
}

View File

@@ -6,7 +6,7 @@
role permissions.
</p>
@if (!canManageRoles()) {
<p class="mt-2 text-xs text-muted-foreground">You can inspect this server's access model, but only members with Manage Roles can edit it.</p>
<p class="mt-2 text-xs text-muted-foreground">{{ 'settings.permissions.readOnly' | translate }}</p>
}
</div>
@@ -14,8 +14,8 @@
<div class="space-y-3 rounded-lg bg-secondary/50 p-3">
<div class="flex items-center justify-between gap-2">
<div>
<p class="text-sm font-medium text-foreground">Roles</p>
<p class="text-xs text-muted-foreground">Higher roles appear first.</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.permissions.roles.title' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.permissions.roles.hint' | translate }}</p>
</div>
@if (canManageRoles()) {
<button
@@ -27,7 +27,7 @@
name="lucidePlus"
class="h-3.5 w-3.5"
/>
<span>Role</span>
<span>{{ 'settings.permissions.roles.add' | translate }}</span>
</button>
}
</div>
@@ -49,7 +49,9 @@
></span>
<span class="min-w-0 flex-1 truncate text-sm text-foreground">{{ role.name }}</span>
@if (role.isSystem) {
<span class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] uppercase tracking-[0.16em] text-primary">System</span>
<span class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] uppercase tracking-[0.16em] text-primary">{{
'settings.permissions.roles.system' | translate
}}</span>
}
</button>
}
@@ -60,8 +62,8 @@
<div class="rounded-lg bg-secondary/50 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Slow Mode</p>
<p class="text-xs text-muted-foreground">Sets the minimum delay between messages for everyone in the server.</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.permissions.slowMode.title' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.permissions.slowMode.description' | translate }}</p>
</div>
<select
[ngModel]="slowModeValue(room.slowModeInterval)"
@@ -69,12 +71,12 @@
[disabled]="!canManageServer()"
class="rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="0">Off</option>
<option value="5">5 seconds</option>
<option value="10">10 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
<option value="120">2 minutes</option>
<option value="0">{{ 'settings.permissions.slowMode.off' | translate }}</option>
<option value="5">{{ 'settings.permissions.slowMode.5s' | translate }}</option>
<option value="10">{{ 'settings.permissions.slowMode.10s' | translate }}</option>
<option value="30">{{ 'settings.permissions.slowMode.30s' | translate }}</option>
<option value="60">{{ 'settings.permissions.slowMode.1m' | translate }}</option>
<option value="120">{{ 'settings.permissions.slowMode.2m' | translate }}</option>
</select>
</div>
</div>
@@ -91,17 +93,21 @@
<p class="text-sm font-medium text-foreground">{{ role.name }}</p>
</div>
<p class="mt-1 text-xs text-muted-foreground">
Edit the role metadata here, then tune its global permissions and per-channel overrides below.
{{ 'settings.permissions.roles.editHint' | translate }}
</p>
</div>
@if (role.isSystem) {
<span class="rounded bg-primary/10 px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-primary">Protected role</span>
<span class="rounded bg-primary/10 px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-primary">{{
'settings.permissions.roles.protected' | translate
}}</span>
}
</div>
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr),8rem]">
<label class="space-y-1">
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Role Name</span>
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{{
'settings.permissions.roles.name' | translate
}}</span>
<input
type="text"
[ngModel]="roleName"
@@ -112,7 +118,9 @@
</label>
<label class="space-y-1">
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Color</span>
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{{
'settings.permissions.roles.color' | translate
}}</span>
<input
type="color"
[ngModel]="roleColor"
@@ -134,7 +142,7 @@
name="lucideCheck"
class="h-4 w-4"
/>
<span>Save Role</span>
<span>{{ 'settings.permissions.roles.save' | translate }}</span>
</button>
<button
@@ -147,7 +155,7 @@
name="lucideArrowUp"
class="h-4 w-4"
/>
<span>Move Up</span>
<span>{{ 'settings.permissions.roles.moveUp' | translate }}</span>
</button>
<button
@@ -160,7 +168,7 @@
name="lucideArrowDown"
class="h-4 w-4"
/>
<span>Move Down</span>
<span>{{ 'settings.permissions.roles.moveDown' | translate }}</span>
</button>
@if (!role.isSystem) {
@@ -174,26 +182,26 @@
name="lucideTrash2"
class="h-4 w-4"
/>
<span>Delete</span>
<span>{{ 'settings.permissions.roles.delete' | translate }}</span>
</button>
}
</div>
@if (role.isSystem) {
<p class="text-xs text-muted-foreground">
System roles can still have their permissions tuned, but their name, color, and membership in the base hierarchy stay fixed.
{{ 'settings.permissions.roles.systemHint' | translate }}
</p>
}
</div>
<div class="space-y-3 rounded-lg bg-secondary/50 p-4">
<div>
<p class="text-sm font-medium text-foreground">Base Permissions</p>
<p class="text-xs text-muted-foreground">These defaults apply everywhere unless a channel override changes them.</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.permissions.basePermissions.title' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.permissions.basePermissions.description' | translate }}</p>
</div>
<div class="space-y-2">
@for (permission of permissionDefinitions; track permission.key) {
@for (permission of permissionDefinitions(); track permission.key) {
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/50 bg-background/60 p-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">{{ permission.label }}</p>
@@ -207,7 +215,7 @@
>
@for (state of permissionStates; track state) {
<option [value]="state">
{{ state === 'inherit' ? 'Inherit' : state === 'allow' ? 'Allow' : 'Deny' }}
{{ permissionStateLabel(state) }}
</option>
}
</select>
@@ -218,18 +226,20 @@
<div class="space-y-3 rounded-lg bg-secondary/50 p-4">
<div>
<p class="text-sm font-medium text-foreground">Channel Overrides</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.permissions.channelOverrides.title' | translate }}</p>
<p class="text-xs text-muted-foreground">
Override the selected role inside a specific channel without changing the server-wide default.
{{ 'settings.permissions.channelOverrides.description' | translate }}
</p>
</div>
@if (channels().length === 0) {
<p class="text-sm text-muted-foreground">This server has no channels yet.</p>
<p class="text-sm text-muted-foreground">{{ 'settings.permissions.channelOverrides.noChannels' | translate }}</p>
} @else {
<div class="flex items-center gap-3">
<label class="min-w-0 flex-1 space-y-1">
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Channel</span>
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{{
'settings.permissions.channelOverrides.channel' | translate
}}</span>
<select
[ngModel]="selectedChannelKey"
(ngModelChange)="selectChannel($event)"
@@ -243,7 +253,7 @@
</div>
<div class="space-y-2">
@for (permission of permissionDefinitions; track permission.key) {
@for (permission of permissionDefinitions(); track permission.key) {
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/50 bg-background/60 p-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">{{ permission.label }}</p>
@@ -257,7 +267,7 @@
>
@for (state of permissionStates; track state) {
<option [value]="state">
{{ state === 'inherit' ? 'Inherit' : state === 'allow' ? 'Allow' : 'Deny' }}
{{ permissionStateLabel(state) }}
</option>
}
</select>
@@ -271,5 +281,5 @@
</div>
</div>
} @else {
<div class="flex h-40 items-center justify-center text-sm text-muted-foreground">Select a server from the sidebar to manage</div>
<div class="flex h-40 items-center justify-center text-sm text-muted-foreground">{{ 'settings.permissions.selectServer' | translate }}</div>
}

View File

@@ -38,6 +38,7 @@ import {
sortRolesForDisplay,
withUpdatedRole
} from '../../../../domains/access-control';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
function upsertRoleChannelOverride(
overrides: readonly ChannelPermissionOverride[] | undefined,
@@ -73,7 +74,8 @@ function upsertRoleChannelOverride(
imports: [
CommonModule,
FormsModule,
NgIcon
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -88,11 +90,18 @@ function upsertRoleChannelOverride(
})
export class PermissionsSettingsComponent {
private store = inject(Store);
private readonly appI18n = inject(AppI18nService);
server = input<Room | null>(null);
isAdmin = input(false);
currentUser = this.store.selectSignal(selectCurrentUser);
permissionDefinitions = ROOM_PERMISSION_DEFINITIONS;
permissionDefinitions = computed(() =>
ROOM_PERMISSION_DEFINITIONS.map((definition) => ({
key: definition.key,
label: this.appI18n.instant(`permissions.${definition.key}.label`),
description: this.appI18n.instant(`permissions.${definition.key}.description`)
}))
);
permissionStates: PermissionState[] = [
'inherit',
'allow',
@@ -195,7 +204,7 @@ export class PermissionsSettingsComponent {
if (!room || !this.canManageRoles())
return;
const role = createCustomRoomRole('New Role', room.roles ?? []);
const role = createCustomRoomRole(this.appI18n.instant('settings.permissions.newRole'), room.roles ?? []);
this.selectedRoleKey = role.id;
this.roleName = role.name;
@@ -233,6 +242,10 @@ export class PermissionsSettingsComponent {
return value === 'allow' || value === 'deny' || value === 'inherit' ? value : 'inherit';
}
permissionStateLabel(state: PermissionState): string {
return this.appI18n.instant(`settings.permissions.states.${state}`);
}
slowModeValue(interval: number | undefined): string {
return String(interval ?? 0);
}

View File

@@ -1,9 +1,9 @@
@if (serverData()) {
<div class="max-w-2xl space-y-5">
<section>
<h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4>
<h4 class="text-sm font-semibold text-foreground mb-3">{{ 'settings.server.title' | translate }}</h4>
@if (!isAdmin()) {
<p class="text-xs text-muted-foreground mb-3">You are viewing this server's details without server-management permission.</p>
<p class="text-xs text-muted-foreground mb-3">{{ 'settings.server.readOnly' | translate }}</p>
}
<div class="space-y-4">
<div class="rounded-lg border border-border bg-secondary/40 p-4">
@@ -24,8 +24,8 @@
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">Server Image</p>
<p class="text-xs text-muted-foreground">Synced to members and shown in server discovery.</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.server.image.title' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.server.image.description' | translate }}</p>
@if (iconError()) {
<p class="mt-1 text-xs text-destructive">{{ iconError() }}</p>
}
@@ -36,8 +36,8 @@
<label
for="server-icon-upload"
class="grid h-9 w-9 cursor-pointer place-items-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Upload image"
aria-label="Upload server image"
[title]="'settings.server.image.upload' | translate"
[attr.aria-label]="'settings.server.image.uploadAria' | translate"
>
<ng-icon
name="lucideUpload"
@@ -56,8 +56,8 @@
<button
type="button"
class="grid h-9 w-9 place-items-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Remove image"
aria-label="Remove server image"
[title]="'settings.server.image.remove' | translate"
[attr.aria-label]="'settings.server.image.removeAria' | translate"
(click)="removeServerIcon()"
>
<ng-icon
@@ -75,7 +75,7 @@
<label
for="room-name"
class="block text-xs font-medium text-muted-foreground mb-1"
>Room Name</label
>{{ 'settings.server.roomName' | translate }}</label
>
<input
type="text"
@@ -91,7 +91,7 @@
<label
for="room-description"
class="block text-xs font-medium text-muted-foreground mb-1"
>Description</label
>{{ 'settings.server.description' | translate }}</label
>
<textarea
[(ngModel)]="roomDescription"
@@ -106,8 +106,8 @@
@if (isAdmin()) {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Private Room</p>
<p class="text-xs text-muted-foreground">Require approval to join</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.server.private.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.server.private.description' | translate }}</p>
</div>
<button
(click)="togglePrivate()"
@@ -134,10 +134,12 @@
} @else {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Private Room</p>
<p class="text-xs text-muted-foreground">Require approval to join</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.server.private.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.server.private.description' | translate }}</p>
</div>
<span class="text-sm text-muted-foreground">{{ isPrivate() ? 'Yes' : 'No' }}</span>
<span class="text-sm text-muted-foreground">{{
isPrivate() ? ('settings.server.private.yes' | translate) : ('settings.server.private.no' | translate)
}}</span>
</div>
}
<div>
@@ -145,7 +147,7 @@
for="room-max-users"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Max Users (0 = unlimited)
{{ 'settings.server.maxUsers' | translate }}
</label>
<input
type="number"
@@ -163,12 +165,12 @@
<div class="rounded-lg border border-border bg-secondary/40 p-4 space-y-3">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Server Password</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.server.password.title' | translate }}</p>
<p class="text-xs text-muted-foreground">
@if (hasPassword() && passwordAction() !== 'remove') {
Joined members stay whitelisted until they are kicked or banned.
{{ 'settings.server.password.whitelisted' | translate }}
} @else {
Add an optional password so new members need it to join.
{{ 'settings.server.password.addOptional' | translate }}
}
</p>
</div>
@@ -194,11 +196,11 @@
<div class="text-xs text-muted-foreground">
@if (hasPassword() && passwordAction() !== 'remove') {
Password protection is currently enabled.
{{ 'settings.server.password.enabled' | translate }}
} @else if (hasPassword() && passwordAction() === 'remove') {
Password protection will be removed when you save.
{{ 'settings.server.password.willRemove' | translate }}
} @else {
Password protection is currently disabled.
{{ 'settings.server.password.disabled' | translate }}
}
</div>
@@ -207,7 +209,7 @@
for="room-password"
class="block text-xs font-medium text-muted-foreground mb-1"
>
{{ hasPassword() ? 'Set New Password' : 'Set Password' }}
{{ hasPassword() ? ('settings.server.password.setNew' | translate) : ('settings.server.password.set' | translate) }}
</label>
<input
type="password"
@@ -215,11 +217,15 @@
[ngModel]="roomPassword"
(ngModelChange)="onPasswordInput($event)"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
[placeholder]="hasPassword() ? 'Leave blank to keep the current password' : 'Optional password required for new joins'"
[placeholder]="
hasPassword()
? ('settings.server.password.keepCurrentPlaceholder' | translate)
: ('settings.server.password.optionalPlaceholder' | translate)
"
/>
@if (passwordAction() === 'update') {
<p class="mt-2 text-xs text-muted-foreground">The new password will replace the current one when you save.</p>
<p class="mt-2 text-xs text-muted-foreground">{{ 'settings.server.password.willReplace' | translate }}</p>
}
@if (passwordError()) {
@@ -230,10 +236,12 @@
} @else {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Server Password</p>
<p class="text-xs text-muted-foreground">Invite links bypass the password, but bans still apply.</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.server.password.title' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.server.password.viewerHint' | translate }}</p>
</div>
<span class="text-sm text-muted-foreground">{{ hasPassword() ? 'Enabled' : 'Disabled' }}</span>
<span class="text-sm text-muted-foreground">{{
hasPassword() ? ('settings.server.password.enabledShort' | translate) : ('settings.server.password.disabledShort' | translate)
}}</span>
</div>
}
</div>
@@ -251,13 +259,13 @@
name="lucideCheck"
class="w-4 h-4"
/>
{{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }}
{{ saveSuccess() === 'server' ? ('settings.server.saved' | translate) : ('settings.server.save' | translate) }}
</button>
@if (canDeleteServer()) {
<!-- Danger Zone -->
<div class="pt-4 border-t border-border">
<h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4>
<h4 class="text-sm font-medium text-destructive mb-3">{{ 'settings.server.dangerZone' | translate }}</h4>
<button
(click)="confirmDeleteRoom()"
type="button"
@@ -277,16 +285,16 @@
<!-- Delete Confirmation (sub-modal) -->
@if (showDeleteConfirm()) {
<app-confirm-dialog
title="Delete Room"
confirmLabel="Delete Room"
[title]="'settings.server.deleteConfirm.title' | translate"
[confirmLabel]="'settings.server.deleteConfirm.confirm' | translate"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="deleteRoom()"
(cancelled)="showDeleteConfirm.set(false)"
>
<p>Are you sure you want to delete this room? This action cannot be undone.</p>
<p>{{ 'settings.server.deleteConfirm.message' | translate }}</p>
</app-confirm-dialog>
}
} @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">{{ 'settings.server.selectServer' | translate }}</div>
}

View File

@@ -25,6 +25,7 @@ import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { ConfirmDialogComponent } from '../../../../shared';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { ServerIconImageService } from '../../../../domains/server-directory/infrastructure/services/server-icon-image.service';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
@Component({
selector: 'app-server-settings',
@@ -33,7 +34,8 @@ import { ServerIconImageService } from '../../../../domains/server-directory/inf
CommonModule,
FormsModule,
NgIcon,
ConfirmDialogComponent
ConfirmDialogComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -52,6 +54,7 @@ export class ServerSettingsComponent {
private store = inject(Store);
private modal = inject(SettingsModalService);
private serverIconImages = inject(ServerIconImageService);
private readonly appI18n = inject(AppI18nService);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
@@ -209,7 +212,7 @@ export class ServerSettingsComponent {
this.showSaveSuccess('icon');
} catch (error) {
this.iconError.set(error instanceof Error ? error.message : 'Could not read that image.');
this.iconError.set(error instanceof Error ? error.message : this.appI18n.instant('settings.server.image.readError'));
}
}

View File

@@ -10,7 +10,7 @@
(keydown.space)="onBackdropClick()"
role="button"
tabindex="0"
aria-label="Close settings"
[attr.aria-label]="'settings.closeAria' | translate"
></div>
<!-- Modal: full-screen page on mobile, centered dialog on desktop -->
@@ -41,13 +41,13 @@
id="settings-modal-title"
class="text-lg font-semibold text-foreground"
>
Settings
{{ 'settings.title' | translate }}
</h2>
@if (isMobile()) {
<button
(click)="close()"
type="button"
aria-label="Close settings"
[attr.aria-label]="'settings.closeAria' | translate"
class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground md:hidden"
>
<ng-icon
@@ -60,8 +60,10 @@
<div class="flex-1 overflow-y-auto py-2">
<!-- Global section -->
<p class="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">General</p>
@for (page of globalPages; track page.id) {
<p class="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{{ 'settings.sections.general' | translate }}
</p>
@for (page of globalPages(); track page.id) {
<button
(click)="navigate(page.id)"
type="button"
@@ -84,7 +86,9 @@
<!-- Server section -->
@if (manageableRooms().length > 0) {
<div class="mt-3 pt-3 border-t border-border">
<p class="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Server</p>
<p class="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{{ 'settings.sections.server' | translate }}
</p>
<!-- Server selector -->
<div class="px-3 pb-2">
@@ -93,7 +97,7 @@
[value]="selectedServerId() || ''"
(change)="onServerSelect($event)"
>
<option value="">Select a server...</option>
<option value="">{{ 'settings.selectServer' | translate }}</option>
@for (room of manageableRooms(); track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}
@@ -101,7 +105,7 @@
</div>
@if (selectedServerId() && canAccessSelectedServer()) {
@for (page of serverPages; track page.id) {
@for (page of serverPages(); track page.id) {
<button
(click)="navigate(page.id)"
type="button"
@@ -131,7 +135,7 @@
(click)="openThirdPartyLicenses()"
class="text-left text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline underline-offset-4"
>
Third-party licenses
{{ 'settings.thirdPartyLicenses.link' | translate }}
</button>
</div>
</nav>
@@ -151,7 +155,7 @@
<button
(click)="backToMenu()"
type="button"
aria-label="Back to settings menu"
[attr.aria-label]="'settings.backToMenuAria' | translate"
class="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground md:hidden"
>
<ng-icon
@@ -163,49 +167,49 @@
<h3 class="truncate text-lg font-semibold text-foreground">
@switch (activePage()) {
@case ('general') {
General
{{ 'settings.pages.general' | translate }}
}
@case ('plugins') {
Client Plugins
{{ 'settings.pages.clientPlugins' | translate }}
}
@case ('network') {
Network
{{ 'settings.pages.network' | translate }}
}
@case ('theme') {
Theme Studio
{{ 'settings.pages.theme' | translate }}
}
@case ('notifications') {
Notifications
{{ 'settings.pages.notifications' | translate }}
}
@case ('voice') {
Voice & Audio
{{ 'settings.pages.voice' | translate }}
}
@case ('updates') {
Updates
{{ 'settings.pages.updates' | translate }}
}
@case ('localApi') {
Local API
{{ 'settings.pages.localApi' | translate }}
}
@case ('data') {
Data
{{ 'settings.pages.data' | translate }}
}
@case ('debugging') {
Debugging
{{ 'settings.pages.debugging' | translate }}
}
@case ('server') {
Server Settings
{{ 'settings.pages.server' | translate }}
}
@case ('serverPlugins') {
Server Plugins
{{ 'settings.pages.serverPlugins' | translate }}
}
@case ('members') {
Members
{{ 'settings.pages.members' | translate }}
}
@case ('bans') {
Bans
{{ 'settings.pages.bans' | translate }}
}
@case ('permissions') {
Permissions
{{ 'settings.pages.permissions' | translate }}
}
}
</h3>
@@ -214,7 +218,7 @@
<button
(click)="close()"
type="button"
aria-label="Close settings"
[attr.aria-label]="'settings.closeAria' | translate"
class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
>
<ng-icon
@@ -248,17 +252,17 @@
<div class="max-w-3xl space-y-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">Active Theme</p>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">{{ 'settings.theme.activeTheme' | translate }}</p>
<h4 class="mt-2 text-lg font-semibold text-foreground">{{ activeThemeName() }}</h4>
<p class="mt-2 max-w-2xl text-sm text-muted-foreground">
Launch Theme Studio to edit the live draft, inspect themeable regions, or switch to a saved theme.
{{ 'settings.theme.description' | translate }}
</p>
</div>
@if (themeStudioMinimized()) {
<span class="rounded-full bg-primary/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary"
>Minimized</span
>
<span class="rounded-full bg-primary/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">{{
'settings.theme.minimized' | translate
}}</span>
}
</div>
@@ -268,7 +272,7 @@
for="settings-saved-theme-select"
class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground"
>
Saved Theme
{{ 'settings.theme.savedTheme' | translate }}
</label>
<div class="flex flex-wrap gap-2">
@@ -279,7 +283,11 @@
[disabled]="savedThemesBusy() && savedThemes().length === 0"
(change)="onSavedThemeSelect($event)"
>
<option value="">{{ savedThemes().length > 0 ? 'Choose saved theme' : 'No saved themes' }}</option>
<option value="">
{{
savedThemes().length > 0 ? ('settings.theme.chooseSavedTheme' | translate) : ('settings.theme.noSavedThemes' | translate)
}}
</option>
@for (savedTheme of savedThemes(); track savedTheme.fileName) {
<option
[value]="savedTheme.fileName"
@@ -296,7 +304,7 @@
[disabled]="!selectedSavedTheme()?.isValid || (savedThemesBusy() && savedThemes().length === 0)"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
>
Edit In Studio
{{ 'settings.theme.editInStudio' | translate }}
</button>
</div>
</div>
@@ -308,7 +316,7 @@
(click)="openThemeStudio()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
{{ themeStudioMinimized() ? 'Re-open Theme Studio' : 'Open Theme Studio' }}
{{ themeStudioMinimized() ? ('settings.theme.reopenStudio' | translate) : ('settings.theme.openStudio' | translate) }}
</button>
<button
@@ -316,7 +324,7 @@
(click)="restoreDefaultTheme()"
class="inline-flex items-center rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive transition-colors hover:bg-destructive/15"
>
Restore Default
{{ 'settings.theme.restoreDefault' | translate }}
</button>
</div>
</div>
@@ -338,7 +346,7 @@
<app-data-settings />
} @loading {
<section class="rounded-lg border border-border bg-card/60 p-5">
<p class="text-sm text-muted-foreground">Loading data settings...</p>
<p class="text-sm text-muted-foreground">{{ 'settings.dataLoading' | translate }}</p>
</section>
}
}
@@ -362,10 +370,12 @@
/>
} @else {
<section class="rounded-lg border border-border bg-card p-5">
<h4 class="text-sm font-semibold text-foreground">Open this server to manage plugins</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.serverPlugins.title' | translate }}</h4>
<p class="mt-2 text-sm text-muted-foreground">
Server plugin installs and activation are shown for the currently open chat server. Select or open
{{ selectedServer()?.name || 'this server' }} in the app, then return here.
{{
'settings.serverPlugins.description'
| translate: { serverName: selectedServer()?.name || ('settings.serverPlugins.thisServer' | translate) }
}}
</p>
</section>
}
@@ -402,7 +412,7 @@
(keydown.space)="closeThirdPartyLicenses()"
role="button"
tabindex="0"
aria-label="Close third-party licenses"
[attr.aria-label]="'settings.thirdPartyLicenses.closeAria' | translate"
></div>
<div class="pointer-events-none absolute inset-0 z-[11] flex justify-center p-4 sm:p-6">
@@ -411,15 +421,15 @@
>
<div class="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h4 class="text-base font-semibold text-foreground">Third-party licenses</h4>
<p class="mt-1 text-sm text-muted-foreground">License information for bundled third-party libraries used by the app.</p>
<h4 class="text-base font-semibold text-foreground">{{ 'settings.thirdPartyLicenses.title' | translate }}</h4>
<p class="mt-1 text-sm text-muted-foreground">{{ 'settings.thirdPartyLicenses.description' | translate }}</p>
</div>
<button
type="button"
(click)="closeThirdPartyLicenses()"
class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Close third-party licenses"
[attr.aria-label]="'settings.thirdPartyLicenses.closeAria' | translate"
>
<ng-icon
name="lucideX"
@@ -443,12 +453,14 @@
rel="noopener noreferrer"
class="text-xs font-medium text-primary hover:underline underline-offset-4"
>
View license
{{ 'settings.thirdPartyLicenses.viewLicense' | translate }}
</a>
</div>
<div class="mt-4 rounded-md bg-background/80 px-3 py-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Packages</p>
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{{ 'settings.thirdPartyLicenses.packages' | translate }}
</p>
<div class="mt-3 flex flex-wrap gap-2">
@for (packageName of license.packages; track packageName) {
<span class="rounded-full border border-border bg-card px-2.5 py-1 text-[11px] font-medium leading-4 text-foreground">
@@ -462,7 +474,9 @@
}
<div class="mt-4">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">License text</p>
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{{ 'settings.thirdPartyLicenses.licenseText' | translate }}
</p>
<pre
class="mt-2 whitespace-pre-wrap break-words rounded-md border border-border/70 bg-card px-3 py-3 text-[11px] leading-5 text-muted-foreground"
>{{ license.text }}</pre

View File

@@ -57,6 +57,7 @@ import {
ThemeNodeDirective,
ThemeService
} from '../../../domains/theme';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
@Component({
selector: 'app-settings-modal',
@@ -78,7 +79,8 @@ import {
MembersSettingsComponent,
BansSettingsComponent,
PermissionsSettingsComponent,
ThemeNodeDirective
ThemeNodeDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -107,6 +109,7 @@ export class SettingsModalComponent {
private theme = inject(ThemeService);
private themeLibrary = inject(ThemeLibraryService);
private viewport = inject(ViewportService);
private readonly appI18n = inject(AppI18nService);
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
private lastRequestedServerId: string | null = null;
@@ -138,25 +141,25 @@ export class SettingsModalComponent {
savedThemesBusy = this.themeLibrary.isBusy;
selectedSavedTheme = this.themeLibrary.selectedEntry;
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'general', label: 'General', icon: 'lucideSettings' },
{ id: 'plugins', label: 'Client plugins', icon: 'lucidePackage' },
{ id: 'theme', label: 'Theme Studio', icon: 'lucidePalette' },
{ id: 'network', label: 'Network', icon: 'lucideGlobe' },
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' },
{ id: 'updates', label: 'Updates', icon: 'lucideDownload' },
{ id: 'localApi', label: 'Local API', icon: 'lucideTerminal' },
{ id: 'data', label: 'Data', icon: 'lucideDownload' },
{ id: 'debugging', label: 'Debugging', icon: 'lucideBug' }
];
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'server', label: 'Server', icon: 'lucideSettings' },
{ id: 'serverPlugins', label: 'Server plugins', icon: 'lucidePackage' },
{ id: 'members', label: 'Members', icon: 'lucideUsers' },
{ id: 'bans', label: 'Bans', icon: 'lucideBan' },
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' }
];
readonly globalPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [
{ id: 'general', label: this.appI18n.instant('settings.nav.general'), icon: 'lucideSettings' },
{ id: 'plugins', label: this.appI18n.instant('settings.nav.plugins'), icon: 'lucidePackage' },
{ id: 'theme', label: this.appI18n.instant('settings.nav.theme'), icon: 'lucidePalette' },
{ id: 'network', label: this.appI18n.instant('settings.nav.network'), icon: 'lucideGlobe' },
{ id: 'notifications', label: this.appI18n.instant('settings.nav.notifications'), icon: 'lucideBell' },
{ id: 'voice', label: this.appI18n.instant('settings.nav.voice'), icon: 'lucideAudioLines' },
{ id: 'updates', label: this.appI18n.instant('settings.nav.updates'), icon: 'lucideDownload' },
{ id: 'localApi', label: this.appI18n.instant('settings.nav.localApi'), icon: 'lucideTerminal' },
{ id: 'data', label: this.appI18n.instant('settings.nav.data'), icon: 'lucideDownload' },
{ id: 'debugging', label: this.appI18n.instant('settings.nav.debugging'), icon: 'lucideBug' }
]);
readonly serverPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [
{ id: 'server', label: this.appI18n.instant('settings.nav.server'), icon: 'lucideSettings' },
{ id: 'serverPlugins', label: this.appI18n.instant('settings.nav.serverPlugins'), icon: 'lucidePackage' },
{ id: 'members', label: this.appI18n.instant('settings.nav.members'), icon: 'lucideUsers' },
{ id: 'bans', label: this.appI18n.instant('settings.nav.bans'), icon: 'lucideBan' },
{ id: 'permissions', label: this.appI18n.instant('settings.nav.permissions'), icon: 'lucideShield' }
]);
manageableRooms = computed<Room[]>(() => {
const user = this.currentUser();

View File

@@ -2,9 +2,9 @@
<section class="rounded-lg border border-border bg-card/60 p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h4 class="text-base font-semibold text-foreground">Desktop app updates</h4>
<h4 class="text-base font-semibold text-foreground">{{ 'settings.updates.desktop.title' | translate }}</h4>
<p class="mt-1 text-sm text-muted-foreground">
Use a hosted release manifest to check for new packaged desktop builds and apply them after a restart.
{{ 'settings.updates.desktop.description' | translate }}
</p>
</div>
@@ -18,9 +18,9 @@
<section class="rounded-lg border border-border bg-card/60 p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h4 class="text-base font-semibold text-foreground">Mobile app updates</h4>
<h4 class="text-base font-semibold text-foreground">{{ 'settings.updates.mobile.title' | translate }}</h4>
<p class="mt-1 text-sm text-muted-foreground">
Check the Play Store or App Store for newer native builds. Android can install in-app updates when Google Play allows it.
{{ 'settings.updates.mobile.description' | translate }}
</p>
</div>
@@ -32,33 +32,33 @@
@if (!mobileState().isSupported) {
<section class="rounded-lg border border-border bg-secondary/30 p-5">
<p class="text-sm text-muted-foreground">Store updates are only available in the packaged Android or iOS app.</p>
<p class="text-sm text-muted-foreground">{{ 'settings.updates.mobile.unsupported' | translate }}</p>
</section>
} @else {
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Installed</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">{{ 'settings.updates.installed' | translate }}</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ mobileState().currentVersion }}</p>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Store version</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ mobileState().availableVersion || 'Unknown' }}</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">{{ 'settings.updates.storeVersion' | translate }}</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ mobileState().availableVersion || ('settings.updates.unknown' | translate) }}</p>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Last checked</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">{{ 'settings.updates.lastChecked' | translate }}</p>
<p class="mt-2 text-sm font-medium text-foreground">
{{ mobileState().lastCheckedAt ? (mobileState().lastCheckedAt | date: 'medium') : 'Not checked yet' }}
{{ mobileState().lastCheckedAt ? (mobileState().lastCheckedAt | date: 'medium') : ('settings.updates.notCheckedYet' | translate) }}
</p>
</div>
</section>
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-sm font-medium text-foreground">Status</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.updates.status' | translate }}</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ mobileState().statusMessage || 'Waiting for the first store update check.' }}
{{ mobileState().statusMessage || ('settings.updates.waitingMobile' | translate) }}
</p>
</div>
@@ -107,66 +107,72 @@
@if (!isElectron && !isCapacitor) {
<section class="rounded-lg border border-border bg-secondary/30 p-5">
<p class="text-sm text-muted-foreground">Automatic updates are only available in the packaged Electron desktop app or native mobile app.</p>
<p class="text-sm text-muted-foreground">{{ 'settings.updates.unsupported' | translate }}</p>
</section>
}
@if (isElectron) {
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Installed</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">{{ 'settings.updates.installed' | translate }}</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().currentVersion }}</p>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Latest in manifest</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().latestVersion || 'Unknown' }}</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
{{ 'settings.updates.latestInManifest' | translate }}
</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().latestVersion || ('settings.updates.unknown' | translate) }}</p>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Target version</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().targetVersion || 'Automatic' }}</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">{{ 'settings.updates.targetVersion' | translate }}</p>
<p class="mt-2 text-lg font-semibold text-foreground">{{ state().targetVersion || ('settings.updates.automatic' | translate) }}</p>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Last checked</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">{{ 'settings.updates.lastChecked' | translate }}</p>
<p class="mt-2 text-sm font-medium text-foreground">
{{ state().lastCheckedAt ? (state().lastCheckedAt | date: 'medium') : 'Not checked yet' }}
{{ state().lastCheckedAt ? (state().lastCheckedAt | date: 'medium') : ('settings.updates.notCheckedYet' | translate) }}
</p>
</div>
</section>
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Update policy</h5>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.updates.policy.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">
Choose whether the app tracks the newest release, stays on a specific release, or turns updates off entirely.
{{ 'settings.updates.policy.description' | translate }}
</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Mode</span>
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">{{
'settings.updates.policy.mode' | translate
}}</span>
<select
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
[value]="state().autoUpdateMode"
(change)="onModeChange($event)"
>
<option value="auto">Newest release</option>
<option value="version">Specific version</option>
<option value="off">Turn off auto updates</option>
<option value="auto">{{ 'settings.updates.policy.modeAuto' | translate }}</option>
<option value="version">{{ 'settings.updates.policy.modeVersion' | translate }}</option>
<option value="off">{{ 'settings.updates.policy.modeOff' | translate }}</option>
</select>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Pinned version</span>
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">{{
'settings.updates.policy.pinnedVersion' | translate
}}</span>
<select
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-60"
[disabled]="state().autoUpdateMode !== 'version' || state().availableVersions.length === 0"
[value]="state().preferredVersion || ''"
(change)="onVersionChange($event)"
>
<option value="">Choose a release...</option>
<option value="">{{ 'settings.updates.policy.chooseRelease' | translate }}</option>
@for (version of state().availableVersions; track version) {
<option [value]="version">{{ version }}</option>
}
@@ -175,9 +181,9 @@
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-sm font-medium text-foreground">Status</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.updates.status' | translate }}</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ state().statusMessage || 'Waiting for release information from the active server.' }}
{{ state().statusMessage || ('settings.updates.waitingDesktop' | translate) }}
</p>
</div>
@@ -204,7 +210,7 @@
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Manifest URL priority</h5>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.updates.manifest.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">
Add one manifest URL per line. The app tries them from top to bottom and falls back to the next URL when a manifest cannot be loaded or is
invalid.
@@ -213,24 +219,30 @@
<div class="rounded-lg border border-border bg-secondary/20 p-4 text-sm text-muted-foreground">
<p class="font-medium text-foreground">
{{ isUsingConnectedServerDefaults() ? 'Using connected server defaults' : 'Using saved manifest URLs' }}
{{
isUsingConnectedServerDefaults()
? ('settings.updates.manifest.usingDefaults' | translate)
: ('settings.updates.manifest.usingSaved' | translate)
}}
</p>
<p class="mt-1">When this list is empty, the app automatically uses manifest URLs reported by your configured servers.</p>
<p class="mt-1">{{ 'settings.updates.manifest.emptyHint' | translate }}</p>
</div>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Manifest URLs</span>
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">{{
'settings.updates.manifest.urlsLabel' | translate
}}</span>
<textarea
rows="6"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
[value]="manifestUrlsText()"
(input)="onManifestUrlsInput($event)"
placeholder="https://example.com/releases/latest/download/release-manifest.json"
[placeholder]="'settings.updates.manifest.placeholder' | translate"
></textarea>
</label>
@if (!state().defaultManifestUrls.length && isUsingConnectedServerDefaults()) {
<p class="text-sm text-muted-foreground">None of your configured servers currently report a manifest URL.</p>
<p class="text-sm text-muted-foreground">{{ 'settings.updates.manifest.noServerManifest' | translate }}</p>
}
<div class="flex flex-wrap gap-3">
@@ -254,25 +266,31 @@
@if (state().serverBlocked) {
<section class="rounded-lg border border-red-500/30 bg-red-500/10 p-5">
<h5 class="text-sm font-semibold text-foreground">Server update required</h5>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.updates.serverBlocked.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">{{ state().serverBlockMessage }}</p>
<div class="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
<div>
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
<p class="mt-1">{{ state().serverVersion || 'Not reported' }}</p>
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">
{{ 'settings.updates.serverBlocked.connectedServer' | translate }}
</p>
<p class="mt-1">{{ state().serverVersion || ('settings.updates.serverBlocked.notReported' | translate) }}</p>
</div>
<div>
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
<p class="mt-1">{{ state().minimumServerVersion || 'Unknown' }}</p>
<p class="font-semibold uppercase tracking-wider text-muted-foreground/70">
{{ 'settings.updates.serverBlocked.requiredMinimum' | translate }}
</p>
<p class="mt-1">{{ state().minimumServerVersion || ('settings.updates.unknown' | translate) }}</p>
</div>
</div>
</section>
}
<section class="rounded-lg border border-border bg-secondary/20 p-4">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Resolved manifest URL</p>
<p class="mt-2 break-all text-sm text-muted-foreground">{{ state().manifestUrl || 'No working manifest URL has been resolved yet.' }}</p>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
{{ 'settings.updates.resolvedManifest.title' | translate }}
</p>
<p class="mt-2 break-all text-sm text-muted-foreground">{{ state().manifestUrl || ('settings.updates.resolvedManifest.empty' | translate) }}</p>
</section>
}
</div>

View File

@@ -7,7 +7,8 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { DesktopAppUpdateService } from '../../../../core/services/desktop-app-update.service';
import { MobileAppUpdateService, getMobileUpdateStatusLabel } from '../../../../infrastructure/mobile';
import { MobileAppUpdateService, type MobileUpdateStatus } from '../../../../infrastructure/mobile';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
type AutoUpdateMode = 'auto' | 'off' | 'version';
type DesktopUpdateStatus =
@@ -26,10 +27,11 @@ type DesktopUpdateStatus =
@Component({
selector: 'app-updates-settings',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, ...APP_TRANSLATE_IMPORTS],
templateUrl: './updates-settings.component.html'
})
export class UpdatesSettingsComponent {
private readonly appI18n = inject(AppI18nService);
readonly desktopUpdates = inject(DesktopAppUpdateService);
readonly mobileUpdates = inject(MobileAppUpdateService);
readonly isElectron = this.desktopUpdates.isElectron;
@@ -37,7 +39,7 @@ export class UpdatesSettingsComponent {
readonly state = this.desktopUpdates.state;
readonly mobileState = this.mobileUpdates.state;
readonly mobileStatusLabel = computed(() =>
getMobileUpdateStatusLabel(this.mobileState().status)
this.getMobileStatusLabel(this.mobileState().status)
);
readonly hasPendingManifestUrlChanges = signal(false);
readonly manifestUrlsText = signal('');
@@ -146,29 +148,34 @@ export class UpdatesSettingsComponent {
}
private getStatusLabel(status: DesktopUpdateStatus): string {
switch (status) {
case 'checking':
return 'Checking';
case 'downloading':
return 'Downloading';
case 'restart-required':
return 'Restart required';
case 'up-to-date':
return 'Up to date';
case 'disabled':
return 'Disabled';
case 'unsupported':
return 'Unsupported';
case 'no-manifest':
return 'Manifest missing';
case 'target-unavailable':
return 'Version unavailable';
case 'target-older-than-installed':
return 'Pinned below current';
case 'error':
return 'Error';
default:
return 'Idle';
}
const keyMap: Record<DesktopUpdateStatus, string> = {
idle: 'settings.updates.statusLabels.idle',
checking: 'settings.updates.statusLabels.checking',
downloading: 'settings.updates.statusLabels.downloading',
'restart-required': 'settings.updates.statusLabels.restartRequired',
'up-to-date': 'settings.updates.statusLabels.upToDate',
disabled: 'settings.updates.statusLabels.disabled',
unsupported: 'settings.updates.statusLabels.unsupported',
'no-manifest': 'settings.updates.statusLabels.manifestMissing',
'target-unavailable': 'settings.updates.statusLabels.versionUnavailable',
'target-older-than-installed': 'settings.updates.statusLabels.pinnedBelowCurrent',
error: 'settings.updates.statusLabels.error'
};
return this.appI18n.instant(keyMap[status] ?? keyMap['idle']);
}
private getMobileStatusLabel(status: MobileUpdateStatus): string {
const keyMap: Record<string, string> = {
idle: 'settings.updates.mobileStatusLabels.idle',
checking: 'settings.updates.mobileStatusLabels.checking',
downloading: 'settings.updates.mobileStatusLabels.downloading',
'up-to-date': 'settings.updates.mobileStatusLabels.upToDate',
'update-available': 'settings.updates.mobileStatusLabels.updateAvailable',
unsupported: 'settings.updates.mobileStatusLabels.unsupported',
error: 'settings.updates.mobileStatusLabels.error'
};
return this.appI18n.instant(keyMap[status] ?? keyMap['idle']);
}
}

View File

@@ -6,14 +6,14 @@
name="lucideMic"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Devices</h4>
<h4 class="text-sm font-semibold text-foreground">'settings.voice.devices.title' | translate</h4>
</div>
<div class="space-y-3">
<div>
<label
for="input-device-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Microphone</label
>{{ 'settings.voice.devices.microphone' | translate }}</label
>
<select
(change)="onInputDeviceChange($event)"
@@ -25,7 +25,7 @@
[value]="device.deviceId"
[selected]="device.deviceId === selectedInputDevice()"
>
{{ device.label || 'Microphone ' + $index }}
{{ device.label || ('settings.voice.devices.microphoneFallback' | translate: { index: $index }) }}
</option>
}
</select>
@@ -34,7 +34,7 @@
<label
for="output-device-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Speaker</label
>{{ 'settings.voice.devices.speaker' | translate }}</label
>
<select
(change)="onOutputDeviceChange($event)"
@@ -46,7 +46,7 @@
[value]="device.deviceId"
[selected]="device.deviceId === selectedOutputDevice()"
>
{{ device.label || 'Speaker ' + $index }}
{{ device.label || ('settings.voice.devices.speakerFallback' | translate: { index: $index }) }}
</option>
}
</select>
@@ -61,7 +61,7 @@
name="lucideHeadphones"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Volume</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.voice.volume.title' | translate }}</h4>
</div>
<div class="space-y-3">
<div>
@@ -69,7 +69,7 @@
for="input-volume-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Input Volume: {{ inputVolume() }}%
{{ 'settings.voice.volume.input' | translate: { value: inputVolume() } }}
</label>
<input
type="range"
@@ -86,7 +86,7 @@
for="output-volume-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Output Volume: {{ outputVolume() }}%
{{ 'settings.voice.volume.output' | translate: { value: outputVolume() } }}
</label>
<input
type="range"
@@ -103,7 +103,7 @@
for="notification-volume-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Notification Volume: {{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
{{ 'settings.voice.volume.notification' | translate: { value: notificationVolumePercent() } }}
</label>
<div class="flex items-center gap-2">
<input
@@ -120,12 +120,12 @@
(click)="previewNotificationSound()"
type="button"
class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors flex-shrink-0"
title="Preview notification sound"
[title]="'settings.voice.volume.previewAria' | translate"
>
Test
</button>
</div>
<p class="text-[10px] text-muted-foreground/60 mt-1">Controls join, leave &amp; notification sounds</p>
<p class="text-[10px] text-muted-foreground/60 mt-1">{{ 'settings.voice.volume.notificationHint' | translate }}</p>
</div>
</div>
</section>
@@ -137,14 +137,14 @@
name="lucideAudioLines"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Quality & Processing</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.voice.quality.title' | translate }}</h4>
</div>
<div class="space-y-3">
<div>
<label
for="latency-profile-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Latency Profile</label
>{{ 'settings.voice.quality.latencyProfile' | translate }}</label
>
<select
(change)="onLatencyProfileChange($event)"
@@ -176,7 +176,7 @@
for="audio-bitrate-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Audio Bitrate: {{ audioBitrate() }} kbps
{{ 'settings.voice.quality.audioBitrate' | translate: { value: audioBitrate() } }}
</label>
<input
type="range"
@@ -193,14 +193,14 @@
<label
for="screen-share-quality-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>Screen share quality</label
>{{ 'settings.voice.quality.screenShareQuality' | translate }}</label
>
<select
(change)="onScreenShareQualityChange($event)"
id="screen-share-quality-select"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (option of screenShareQualityOptions; track option.id) {
@for (option of screenShareQualityOptions(); track option.id) {
<option
[value]="option.id"
[selected]="screenShareQuality() === option.id"
@@ -215,8 +215,8 @@
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Ask before screen sharing</p>
<p class="text-xs text-muted-foreground">Let the user confirm quality before each new screen share</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.voice.quality.askScreenShare.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.voice.quality.askScreenShare.description' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -224,7 +224,7 @@
[checked]="askScreenShareQuality()"
(change)="onAskScreenShareQualityChange($event)"
id="ask-screen-share-quality-toggle"
aria-label="Toggle screen share quality prompt"
[attr.aria-label]="'settings.voice.quality.askScreenShare.aria' | translate"
class="sr-only peer"
/>
<div
@@ -234,8 +234,8 @@
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Noise reduction</p>
<p class="text-xs text-muted-foreground">Suppress background noise using RNNoise</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.voice.quality.noiseReduction.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.voice.quality.noiseReduction.description' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -243,7 +243,7 @@
[checked]="noiseReduction()"
(change)="onNoiseReductionChange()"
id="noise-reduction-toggle"
aria-label="Toggle noise reduction"
[attr.aria-label]="'settings.voice.quality.noiseReduction.aria' | translate"
class="sr-only peer"
/>
<div
@@ -253,8 +253,8 @@
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Screen share system audio</p>
<p class="text-xs text-muted-foreground">Share other computer audio while filtering MeToYou audio when supported</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.voice.quality.systemAudio.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.voice.quality.systemAudio.description' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -262,7 +262,7 @@
[checked]="includeSystemAudio()"
(change)="onIncludeSystemAudioChange($event)"
id="system-audio-toggle"
aria-label="Toggle system audio in screen share"
[attr.aria-label]="'settings.voice.quality.systemAudio.aria' | translate"
class="sr-only peer"
/>
<div
@@ -271,7 +271,7 @@
</label>
</div>
<p class="text-[10px] text-muted-foreground/60 -mt-1">
Your microphone stays on the normal voice channel. The shared screen audio should only contain desktop sound.
{{ 'settings.voice.quality.systemAudio.hint' | translate }}
</p>
</div>
</section>
@@ -283,13 +283,13 @@
name="lucideCpu"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Desktop Performance</h4>
<h4 class="text-sm font-semibold text-foreground">{{ 'settings.voice.desktopPerformance.title' | translate }}</h4>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Hardware acceleration</p>
<p class="text-xs text-muted-foreground">Use GPU acceleration for rendering and WebRTC when available</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.voice.desktopPerformance.hardwareAcceleration.label' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.voice.desktopPerformance.hardwareAcceleration.description' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -297,7 +297,7 @@
[checked]="hardwareAcceleration()"
(change)="onHardwareAccelerationChange($event)"
id="hardware-acceleration-toggle"
aria-label="Toggle hardware acceleration"
[attr.aria-label]="'settings.voice.desktopPerformance.hardwareAcceleration.aria' | translate"
class="sr-only peer"
/>
<div
@@ -309,8 +309,8 @@
@if (hardwareAccelerationRestartRequired()) {
<div class="rounded-lg border border-primary/30 bg-primary/10 p-3 flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-foreground">Restart required</p>
<p class="text-xs text-muted-foreground">Restart MeToYou to apply the new hardware acceleration setting.</p>
<p class="text-sm font-medium text-foreground">{{ 'settings.voice.desktopPerformance.restartRequired.title' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'settings.voice.desktopPerformance.restartRequired.description' | translate }}</p>
</div>
<button
type="button"

View File

@@ -20,10 +20,12 @@ import type { DesktopSettingsSnapshot } from '../../../../core/platform/electron
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../domains/screen-share';
import { screenShareQualityI18nKey } from '../../../../shared-kernel';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../domains/voice-session';
import { VoicePlaybackService } from '../../../../domains/voice-connection';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { PlatformService } from '../../../../core/platform';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
interface AudioDevice {
deviceId: string;
@@ -36,7 +38,8 @@ interface AudioDevice {
imports: [
CommonModule,
FormsModule,
NgIcon
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -54,9 +57,16 @@ export class VoiceSettingsComponent {
private voicePlayback = inject(VoicePlaybackService);
private electronBridge = inject(ElectronBridgeService);
private platform = inject(PlatformService);
private readonly appI18n = inject(AppI18nService);
readonly audioService = inject(NotificationAudioService);
readonly isElectron = this.platform.isElectron;
readonly screenShareQualityOptions = SCREEN_SHARE_QUALITY_OPTIONS;
readonly screenShareQualityOptions = computed(() =>
SCREEN_SHARE_QUALITY_OPTIONS.map((option) => ({
id: option.id,
label: this.appI18n.instant(screenShareQualityI18nKey(option.id, 'label')),
description: this.appI18n.instant(screenShareQualityI18nKey(option.id, 'description'))
}))
);
inputDevices = signal<AudioDevice[]>([]);
outputDevices = signal<AudioDevice[]>([]);
@@ -73,7 +83,10 @@ export class VoiceSettingsComponent {
hardwareAcceleration = signal(true);
hardwareAccelerationRestartRequired = signal(false);
readonly selectedScreenShareQualityDescription = computed(
() => this.screenShareQualityOptions.find((option) => option.id === this.screenShareQuality())?.description ?? ''
() => this.screenShareQualityOptions().find((option) => option.id === this.screenShareQuality())?.description ?? ''
);
readonly notificationVolumePercent = computed(() =>
String(Math.round(this.audioService.notificationVolume() * 100))
);
constructor() {

View File

@@ -4,7 +4,7 @@
type="button"
(click)="goBack()"
class="grid h-9 w-9 place-items-center rounded-lg transition-colors hover:bg-secondary"
title="Go back"
[title]="'settings.standalone.goBack' | translate"
>
<ng-icon
name="lucideArrowLeft"
@@ -15,7 +15,7 @@
name="lucideSettings"
class="w-6 h-6 text-primary"
/>
<h1 class="text-2xl font-bold text-foreground">Settings</h1>
<h1 class="text-2xl font-bold text-foreground">{{ 'settings.title' | translate }}</h1>
</div>
<button
@@ -27,7 +27,7 @@
name="lucidePackage"
class="h-4 w-4"
/>
Plugin Store
{{ 'settings.standalone.pluginStore' | translate }}
</button>
<!-- Server Endpoints Section -->
@@ -38,7 +38,7 @@
name="lucideGlobe"
class="w-5 h-5 text-muted-foreground"
/>
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
<h2 class="text-lg font-semibold text-foreground">{{ 'settings.network.serverEndpoints.title' | translate }}</h2>
</div>
<div class="flex items-center gap-2">
@if (hasMissingDefaultServers()) {
@@ -67,7 +67,7 @@
</div>
<p class="text-sm text-muted-foreground mb-4">
Active server endpoints stay enabled at the same time. You pick the endpoint when creating and registering a new server.
{{ 'settings.network.serverEndpoints.description' | translate }}
</p>
<!-- Server List -->
@@ -96,7 +96,9 @@
<div class="flex items-center gap-2">
<span class="font-medium text-foreground truncate">{{ server.name }}</span>
@if (server.isActive) {
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">Active</span>
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">{{
'settings.network.serverEndpoints.active' | translate
}}</span>
}
</div>
<p class="text-sm text-muted-foreground truncate">{{ server.url }}</p>
@@ -104,7 +106,7 @@
<p class="text-xs text-muted-foreground">{{ server.latency }}ms</p>
}
@if (server.status === 'incompatible') {
<p class="text-xs text-destructive">Update the client in order to connect to other users</p>
<p class="text-xs text-destructive">{{ 'settings.network.serverEndpoints.incompatible' | translate }}</p>
}
</div>
@@ -115,7 +117,7 @@
type="button"
(click)="setActiveServer(server.id)"
class="grid h-9 w-9 place-items-center rounded-lg transition-colors hover:bg-secondary"
title="Activate"
[title]="'settings.network.serverEndpoints.activate' | translate"
>
<ng-icon
name="lucideCheck"
@@ -128,7 +130,7 @@
type="button"
(click)="deactivateServer(server.id)"
class="grid h-9 w-9 place-items-center rounded-lg transition-colors hover:bg-secondary"
title="Deactivate"
[title]="'settings.network.serverEndpoints.deactivate' | translate"
>
<ng-icon
name="lucideX"
@@ -141,7 +143,7 @@
type="button"
(click)="removeServer(server.id)"
class="grid h-9 w-9 place-items-center rounded-lg transition-colors hover:bg-destructive/10"
title="Remove server"
[title]="'settings.network.serverEndpoints.remove' | translate"
>
<ng-icon
name="lucideTrash2"
@@ -156,19 +158,19 @@
<!-- Add New Server -->
<div class="border-t border-border pt-4">
<h3 class="text-sm font-medium text-foreground mb-3">Add New Server</h3>
<h3 class="text-sm font-medium text-foreground mb-3">{{ 'settings.network.serverEndpoints.addNew' | translate }}</h3>
<div class="flex gap-3">
<div class="flex-1 space-y-2">
<input
type="text"
[(ngModel)]="newServerName"
placeholder="Server name (e.g., My Server)"
[placeholder]="'settings.network.serverEndpoints.serverNamePlaceholder' | translate"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="url"
[(ngModel)]="newServerUrl"
placeholder="Server URL (e.g., http://localhost:3001)"
[placeholder]="'settings.network.serverEndpoints.serverUrlPlaceholder' | translate"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@@ -197,14 +199,14 @@
name="lucideServer"
class="w-5 h-5 text-muted-foreground"
/>
<h2 class="text-lg font-semibold text-foreground">Connection Settings</h2>
<h2 class="text-lg font-semibold text-foreground">{{ 'settings.network.connection.title' | translate }}</h2>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Auto-reconnect</p>
<p class="text-sm text-muted-foreground">Automatically reconnect when connection is lost</p>
<p class="font-medium text-foreground">{{ 'settings.network.connection.autoReconnect.label' | translate }}</p>
<p class="text-sm text-muted-foreground">{{ 'settings.network.connection.autoReconnect.description' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -221,8 +223,8 @@
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Search all servers</p>
<p class="text-sm text-muted-foreground">Search across all configured server directories</p>
<p class="font-medium text-foreground">{{ 'settings.network.connection.searchAllServers.label' | translate }}</p>
<p class="text-sm text-muted-foreground">{{ 'settings.network.connection.searchAllServers.description' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
@@ -246,7 +248,7 @@
name="lucideAudioLines"
class="w-5 h-5 text-muted-foreground"
/>
<h2 class="text-lg font-semibold text-foreground">Voice Settings</h2>
<h2 class="text-lg font-semibold text-foreground">{{ 'settings.voice.standalone.title' | translate }}</h2>
</div>
<div class="space-y-4">
@@ -254,8 +256,8 @@
<div>
<div class="flex items-center justify-between mb-2">
<div>
<p class="font-medium text-foreground">Notification volume</p>
<p class="text-sm text-muted-foreground">Volume for join, leave, and notification sounds</p>
<p class="font-medium text-foreground">{{ 'settings.voice.standalone.notificationVolume.label' | translate }}</p>
<p class="text-sm text-muted-foreground">{{ 'settings.voice.standalone.notificationVolume.description' | translate }}</p>
</div>
<span class="text-sm font-medium text-muted-foreground tabular-nums w-10 text-right">
{{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
@@ -275,7 +277,7 @@
type="button"
(click)="previewNotificationSound()"
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
title="Preview sound"
[title]="'settings.voice.volume.previewTitle' | translate"
>
Test
</button>
@@ -284,8 +286,8 @@
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Noise reduction</p>
<p class="text-sm text-muted-foreground">Use RNNoise to suppress background noise from your microphone</p>
<p class="font-medium text-foreground">{{ 'settings.voice.quality.noiseReduction.label' | translate }}</p>
<p class="text-sm text-muted-foreground">{{ 'settings.voice.quality.noiseReduction.descriptionLong' | translate }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input

View File

@@ -28,6 +28,7 @@ import { ServerDirectoryFacade } from '../../domains/server-directory';
import { VoiceConnectionFacade } from '../../domains/voice-connection';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../../core/constants';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../core/i18n';
@Component({
selector: 'app-settings',
@@ -35,7 +36,8 @@ import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../
imports: [
CommonModule,
FormsModule,
NgIcon
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -61,6 +63,7 @@ export class SettingsComponent implements OnInit {
private serverDirectory = inject(ServerDirectoryFacade);
private voiceConnection = inject(VoiceConnectionFacade);
private router = inject(Router);
private readonly appI18n = inject(AppI18nService);
audioService = inject(NotificationAudioService);
servers = this.serverDirectory.servers;
@@ -91,13 +94,13 @@ export class SettingsComponent implements OnInit {
try {
new URL(this.newServerUrl);
} catch {
this.addError.set('Please enter a valid URL');
this.addError.set(this.appI18n.instant('settings.network.serverEndpoints.errors.invalidUrl'));
return;
}
// Check for duplicates
if (this.servers().some((server) => server.url === this.newServerUrl)) {
this.addError.set('This server URL already exists');
this.addError.set(this.appI18n.instant('settings.network.serverEndpoints.errors.duplicateUrl'));
return;
}

View File

@@ -3,7 +3,7 @@
[x]="emojiMenu.posX"
[y]="emojiMenu.posY"
[width]="'w-56'"
sheetTitle="Emoji"
[sheetTitle]="'shell.contextMenu.emoji' | translate"
(closed)="close()"
>
@if (emojiMenu.action === 'add') {
@@ -12,7 +12,7 @@
class="context-menu-item"
(click)="addCustomEmojiToLibrary()"
>
Add to emoji library
{{ 'shell.contextMenu.addToEmojiLibrary' | translate }}
</button>
} @else {
<button
@@ -20,7 +20,7 @@
class="context-menu-item-danger"
(click)="removeCustomEmojiFromLibrary()"
>
Remove from emoji library
{{ 'shell.contextMenu.removeFromEmojiLibrary' | translate }}
</button>
}
</app-context-menu>
@@ -42,7 +42,7 @@
(pointerdown)="onActionPointerDown($event, 'cut')"
(click)="onActionClick($event, 'cut')"
>
Cut
{{ 'common.cut' | translate }}
</button>
<button
type="button"
@@ -52,7 +52,7 @@
(pointerdown)="onActionPointerDown($event, 'copy')"
(click)="onActionClick($event, 'copy')"
>
Copy
{{ 'common.copy' | translate }}
</button>
<button
type="button"
@@ -62,7 +62,7 @@
(pointerdown)="onActionPointerDown($event, 'paste')"
(click)="onActionClick($event, 'paste')"
>
Paste
{{ 'common.paste' | translate }}
</button>
<div class="context-menu-divider"></div>
<button
@@ -73,7 +73,7 @@
(pointerdown)="onActionPointerDown($event, 'selectAll')"
(click)="onActionClick($event, 'selectAll')"
>
Select All
{{ 'common.selectAll' | translate }}
</button>
} @else if (params()!.selectionText) {
<button
@@ -82,7 +82,7 @@
(pointerdown)="onActionPointerDown($event, 'copy')"
(click)="onActionClick($event, 'copy')"
>
Copy
{{ 'common.copy' | translate }}
</button>
}
@@ -96,7 +96,7 @@
(pointerdown)="onActionPointerDown($event, 'copyLink')"
(click)="onActionClick($event, 'copyLink')"
>
Copy Link
{{ 'common.copyLink' | translate }}
</button>
}
@@ -110,7 +110,7 @@
(pointerdown)="onActionPointerDown($event, 'copyImage')"
(click)="onActionClick($event, 'copyImage')"
>
Copy Image
{{ 'common.copyImage' | translate }}
</button>
}
</app-context-menu>

View File

@@ -16,6 +16,7 @@ import {
CustomEmojiService,
resolveCustomEmojiContextMenuTarget
} from '../../../domains/custom-emoji';
import { APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
type ContextMenuCommand = 'cut' | 'copy' | 'paste' | 'selectAll';
type ContextMenuAction = ContextMenuCommand | 'copyLink' | 'copyImage';
@@ -53,7 +54,7 @@ const NON_TEXT_INPUT_TYPES = new Set([
@Component({
selector: 'app-native-context-menu',
standalone: true,
imports: [ContextMenuComponent],
imports: [ContextMenuComponent, ...APP_TRANSLATE_IMPORTS],
templateUrl: './native-context-menu.component.html'
})
export class NativeContextMenuComponent implements OnInit, OnDestroy {

View File

@@ -47,7 +47,7 @@
name="lucideRefreshCw"
class="h-3.5 w-3.5 animate-spin"
/>
Reconnecting to signal server...
{{ 'shell.titleBar.reconnectingSignalServer' | translate }}
</span>
}
@@ -66,7 +66,7 @@
<span
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
[class.hidden]="!isReconnecting()"
>Reconnecting...</span
>{{ 'shell.titleBar.reconnecting' | translate }}</span
>
</div>
}
@@ -80,9 +80,9 @@
class="grid h-8 place-items-center rounded-md px-3 text-sm text-foreground transition-colors hover:bg-secondary"
[class.hidden]="isAuthed()"
(click)="goLogin()"
title="Login"
[title]="'shell.titleBar.login' | translate"
>
Login
{{ 'shell.titleBar.login' | translate }}
</button>
@if (hasServerPlugins()) {
@@ -90,8 +90,8 @@
type="button"
class="relative grid h-8 w-8 place-items-center rounded-md text-foreground transition-colors hover:bg-secondary"
(click)="openServerPlugins()"
title="Server plugins"
aria-label="Server plugins"
[title]="'shell.titleBar.serverPlugins' | translate"
[attr.aria-label]="'shell.titleBar.serverPlugins' | translate"
>
<ng-icon
name="lucideShield"
@@ -108,7 +108,7 @@
type="button"
(click)="toggleMenu()"
class="grid h-8 w-8 place-items-center rounded-md transition-colors hover:bg-secondary"
title="Menu"
[title]="'shell.titleBar.menu' | translate"
>
<ng-icon
name="lucideMenu"
@@ -129,9 +129,9 @@
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
@if (creatingInvite()) {
Creating Invite Link...
{{ 'shell.titleBar.creatingInviteLink' | translate }}
} @else {
Create Invite Link
{{ 'shell.titleBar.createInviteLink' | translate }}
}
</button>
<button
@@ -139,7 +139,7 @@
(click)="leaveServer()"
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
>
Leave Server
{{ 'shell.titleBar.leaveServer' | translate }}
</button>
}
<div
@@ -154,21 +154,21 @@
(click)="openPluginStore()"
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
>
Plugin Store
{{ 'shell.titleBar.pluginStore' | translate }}
</button>
<button
type="button"
(click)="openSettings()"
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
>
Settings
{{ 'common.settings' | translate }}
</button>
<button
type="button"
(click)="openDocumentation()"
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
>
Documentation
{{ 'common.documentation' | translate }}
</button>
<div class="mx-2 my-1 h-px bg-border"></div>
<button
@@ -176,7 +176,7 @@
(click)="logout()"
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
>
Logout
{{ 'common.logout' | translate }}
</button>
</div>
}
@@ -185,7 +185,7 @@
<button
type="button"
class="grid h-8 w-8 place-items-center rounded-md transition-colors hover:bg-secondary"
title="Minimize"
[title]="'common.minimize' | translate"
(click)="minimize()"
>
<ng-icon
@@ -196,7 +196,7 @@
<button
type="button"
class="grid h-8 w-8 place-items-center rounded-md transition-colors hover:bg-secondary"
title="Maximize"
[title]="'common.maximize' | translate"
(click)="maximize()"
>
<ng-icon
@@ -207,7 +207,7 @@
<button
type="button"
class="grid h-8 w-8 place-items-center rounded-md transition-colors hover:bg-destructive/10"
title="Close"
[title]="'common.close' | translate"
(click)="close()"
>
<ng-icon
@@ -232,10 +232,10 @@
class="h-4 w-4 shrink-0 text-primary"
/>
<p class="truncate">
Optional server plugin available:
{{ 'shell.titleBar.optionalPluginAvailable' | translate }}
<span class="font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</span>
@if (optionalPluginRequirementCount() > 1) {
<span class="text-muted-foreground">+{{ optionalPluginRequirementCount() - 1 }} more</span>
<span class="text-muted-foreground">{{ 'shell.titleBar.morePlugins' | translate: { count: optionalPluginRequirementCount() - 1 } }}</span>
}
</p>
</div>
@@ -249,7 +249,7 @@
[disabled]="pluginRequirementBusy()"
(click)="rejectOptionalServerPlugin(requirement)"
>
Reject
{{ 'common.reject' | translate }}
</button>
<button
type="button"
@@ -257,7 +257,7 @@
[disabled]="pluginRequirementBusy()"
(click)="hideOptionalServerPlugin(requirement)"
>
Don't show again
{{ 'shell.titleBar.dontShowAgain' | translate }}
</button>
<button
type="button"
@@ -265,7 +265,7 @@
[disabled]="pluginRequirementBusy()"
(click)="installOptionalServerPlugin(requirement)"
>
{{ pluginRequirementBusy() ? 'Installing' : 'Install' }}
{{ pluginRequirementBusy() ? ('common.installing' | translate) : ('common.install' | translate) }}
</button>
</div>
</section>
@@ -284,18 +284,18 @@
style="-webkit-app-region: no-drag"
>
<header class="border-b border-border p-4">
<p class="text-sm text-muted-foreground">Required server plugins</p>
<p class="text-sm text-muted-foreground">{{ 'shell.titleBar.requiredServerPlugins' | translate }}</p>
<h2
id="required-server-plugin-title"
class="mt-1 text-lg font-semibold"
>
{{ currentRoom()!.name }} requires a plugin update
{{ 'shell.titleBar.roomRequiresPluginUpdate' | translate: { roomName: currentRoom()!.name } }}
</h2>
</header>
<div class="min-h-0 space-y-3 overflow-auto p-4">
<p class="text-sm text-muted-foreground">
An admin added required plugins for this server. Install them to keep using the server, or leave the server.
{{ 'shell.titleBar.requiredPluginsDescription' | translate }}
</p>
@for (requirement of requiredPluginRequirements(); track requirement.pluginId) {
<article class="rounded-lg border border-border bg-background/50 px-3 py-2">
@@ -306,7 +306,9 @@
<p class="mt-1 text-xs text-muted-foreground">{{ requirement.reason }}</p>
}
</div>
<span class="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">Required</span>
<span class="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">{{
'common.required' | translate
}}</span>
</div>
</article>
}
@@ -322,7 +324,7 @@
[disabled]="pluginRequirementBusy()"
(click)="confirmLeave({})"
>
Leave server
{{ 'shell.titleBar.leaveServer' | translate }}
</button>
<button
type="button"
@@ -330,7 +332,7 @@
[disabled]="pluginRequirementBusy()"
(click)="installRequiredServerPlugins()"
>
{{ pluginRequirementBusy() ? 'Installing' : 'Install plugins' }}
{{ pluginRequirementBusy() ? ('common.installing' | translate) : ('shell.titleBar.installPlugins' | translate) }}
</button>
</footer>
</section>
@@ -344,7 +346,7 @@
(keydown.space)="closeMenu()"
tabindex="0"
role="button"
aria-label="Close menu overlay"
[attr.aria-label]="'shell.titleBar.closeMenuOverlay' | translate"
style="-webkit-app-region: no-drag"
></div>
}

View File

@@ -51,6 +51,7 @@ import {
PluginStoreService
} from '../../../domains/plugins';
import { getPluginInstallScope } from '../../../domains/plugins/domain/logic/plugin-install-scope.logic';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
@Component({
selector: 'app-title-bar',
@@ -60,7 +61,8 @@ import { getPluginInstallScope } from '../../../domains/plugins/domain/logic/plu
NgIcon,
LeaveServerDialogComponent,
ThemeNodeDirective,
ModalBackdropComponent
ModalBackdropComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({ lucideMinus,
@@ -79,6 +81,7 @@ import { getPluginInstallScope } from '../../../domains/plugins/domain/logic/plu
* Electron-style title bar with window controls, navigation, and server menu.
*/
export class TitleBarComponent {
private readonly appI18n = inject(AppI18nService);
private store = inject(Store);
private electronBridge = inject(ElectronBridgeService);
private serverDirectory = inject(ServerDirectoryFacade);
@@ -99,8 +102,8 @@ export class TitleBarComponent {
showMenuState = computed(() => false);
currentUser = this.store.selectSignal(selectCurrentUser);
username = computed(() => this.currentUser()?.displayName || 'Guest');
serverName = computed(() => this.serverDirectory.activeServer()?.name || 'No Server');
username = computed(() => this.currentUser()?.displayName || this.appI18n.instant('shell.titleBar.guest'));
serverName = computed(() => this.serverDirectory.activeServer()?.name || this.appI18n.instant('shell.titleBar.noServer'));
isConnected = computed(() => this.webrtc.isConnected());
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
isAuthed = computed(() => !!this.currentUser());
@@ -131,7 +134,7 @@ export class TitleBarComponent {
const textChannels = this.textChannels();
if (textChannels.length === 0) {
return 'No text channels';
return this.appI18n.instant('shell.titleBar.noTextChannels');
}
const id = this.activeChannelId();
@@ -143,17 +146,17 @@ export class TitleBarComponent {
const voiceChannelId = this.currentUser()?.voiceState?.roomId;
const voiceChannel = this.voiceChannels().find((channel) => channel.id === voiceChannelId);
return voiceChannel?.name || 'Voice Lounge';
return voiceChannel?.name || this.appI18n.instant('shell.titleBar.voiceLounge');
});
roomContextMeta = computed(() => {
if (!this.currentRoom()) {
return '';
}
const parts = [`${this.textChannels().length} text`];
const parts = [this.appI18n.instant('shell.titleBar.textChannelCount', { count: this.textChannels().length })];
if (this.voiceChannels().length > 0) {
parts.push(`${this.voiceChannels().length} voice`);
parts.push(this.appI18n.instant('shell.titleBar.voiceChannelCount', { count: this.voiceChannels().length }));
}
return parts.join(' | ');
@@ -246,7 +249,7 @@ export class TitleBarComponent {
const result = await api.openDocusaurusDocs();
if (result && !result.opened) {
this.inviteStatus.set(result.reason ?? 'Unable to open documentation.');
this.inviteStatus.set(result.reason ?? this.appI18n.instant('shell.titleBar.docsOpenFailed'));
}
}
@@ -275,7 +278,7 @@ export class TitleBarComponent {
}
this.creatingInvite.set(true);
this.inviteStatus.set('Creating invite link...');
this.inviteStatus.set(this.appI18n.instant('shell.titleBar.creatingInvite'));
try {
const invite = await firstValueFrom(this.serverDirectory.createInvite(
@@ -289,11 +292,11 @@ export class TitleBarComponent {
));
await this.copyInviteLink(invite.inviteUrl);
this.inviteStatus.set('Invite link copied to clipboard.');
this.inviteStatus.set(this.appI18n.instant('shell.titleBar.inviteCopied'));
} catch (error: unknown) {
const inviteError = error as { error?: { error?: string } };
this.inviteStatus.set(inviteError?.error?.error || 'Unable to create invite link.');
this.inviteStatus.set(inviteError?.error?.error || this.appI18n.instant('shell.titleBar.inviteCreateFailed'));
} finally {
this.creatingInvite.set(false);
}
@@ -362,7 +365,7 @@ export class TitleBarComponent {
try {
await this.pluginStore.installServerRequirementsLocally(room.id, requirements, { activate: true });
} catch (error) {
this.pluginRequirementError.set(error instanceof Error ? error.message : 'Unable to install server plugin');
this.pluginRequirementError.set(error instanceof Error ? error.message : this.appI18n.instant('shell.titleBar.pluginInstallFailed'));
} finally {
this.pluginRequirementBusy.set(false);
}
@@ -413,7 +416,7 @@ export class TitleBarComponent {
document.body.removeChild(textarea);
}
window.prompt('Copy this invite link', inviteUrl);
window.prompt(this.appI18n.instant('shell.titleBar.copyInvitePrompt'), inviteUrl);
}
private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } {