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
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:
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & 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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
Reference in New Issue
Block a user