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

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

View File

@@ -5,6 +5,7 @@ import {
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideTranslateService } from '@ngx-translate/core';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
@@ -24,6 +25,7 @@ import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects'
import { RoomStateSyncEffects } from './store/rooms/room-state-sync.effects';
import { RoomSettingsEffects } from './store/rooms/room-settings.effects';
import { STORE_DEVTOOLS_MAX_AGE } from './core/constants';
import { DEFAULT_APP_LOCALE } from './core/i18n';
/** Root application configuration providing routing, HTTP, NgRx store, and devtools. */
export const appConfig: ApplicationConfig = {
@@ -31,6 +33,10 @@ export const appConfig: ApplicationConfig = {
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(),
provideTranslateService({
fallbackLang: DEFAULT_APP_LOCALE,
lang: DEFAULT_APP_LOCALE
}),
provideStore({
messages: messagesReducer,
users: usersReducer,

View File

@@ -34,7 +34,7 @@
@if (themeStudioFullscreenComponent()) {
<ng-container *ngComponentOutlet="themeStudioFullscreenComponent()" />
} @else {
<div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">Loading Theme Studio...</div>
<div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">{{ 'app.themeStudio.loading' | translate }}</div>
}
</div>
} @else { @if (showDesktopUpdateNotice()) {
@@ -45,7 +45,7 @@
type="button"
(click)="dismissDesktopUpdateNotice()"
class="absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Dismiss update notice"
[attr.aria-label]="'app.desktopUpdate.dismissAriaLabel' | translate"
>
<ng-icon
name="lucideX"
@@ -54,15 +54,15 @@
</button>
<div class="pr-10">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">Update Ready</p>
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">{{ 'app.desktopUpdate.readyBadge' | translate }}</p>
<p class="mt-1 text-sm font-semibold text-foreground">
Restart to install {{ desktopUpdateState().targetVersion || 'the latest update' }}
@if (desktopUpdateState().targetVersion) { {{ 'app.desktopUpdate.restartTitle' | translate:{ version:
desktopUpdateState().targetVersion } }} } @else { {{ 'app.desktopUpdate.restartTitle' | translate:{ version:
('app.desktopUpdate.latestUpdateFallback' | translate) } }} }
</p>
</div>
<p class="mt-1 pr-10 text-xs leading-5 text-muted-foreground">
The update has already been downloaded. Restart the app when you're ready to finish applying it.
</p>
<p class="mt-1 pr-10 text-xs leading-5 text-muted-foreground">{{ 'app.desktopUpdate.downloadedMessage' | translate }}</p>
<div class="mt-3 flex flex-wrap gap-2">
<button
@@ -70,7 +70,7 @@
(click)="openUpdatesSettings()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Update settings
{{ 'app.desktopUpdate.updateSettings' | translate }}
</button>
<button
@@ -78,7 +78,7 @@
(click)="restartToApplyUpdate()"
class="inline-flex items-center rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
Restart now
{{ 'app.desktopUpdate.restartNow' | translate }}
</button>
</div>
</div>
@@ -114,7 +114,7 @@
[class.cursor-grabbing]="isDraggingThemeStudioControls()"
(pointerdown)="startThemeStudioControlsDrag($event, themeStudioControlsRef)"
>
Theme Studio
{{ 'app.themeStudio.title' | translate }}
</div>
<button
@@ -122,7 +122,7 @@
(click)="minimizeThemeStudio()"
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Minimize
{{ 'app.themeStudio.minimize' | translate }}
</button>
<button
@@ -130,7 +130,7 @@
(click)="closeThemeStudio()"
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Close
{{ 'common.close' | translate }}
</button>
</div>
</div>
@@ -138,8 +138,8 @@
<div class="pointer-events-none absolute bottom-4 right-4 z-[80]">
<div class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-3 shadow-lg backdrop-blur">
<div class="min-w-0">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">Theme Studio</p>
<p class="mt-1 text-sm font-medium text-foreground">Minimized</p>
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">{{ 'app.themeStudio.title' | translate }}</p>
<p class="mt-1 text-sm font-medium text-foreground">{{ 'app.themeStudio.minimized' | translate }}</p>
</div>
<button
@@ -147,7 +147,7 @@
(click)="reopenThemeStudio()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
Re-open
{{ 'app.themeStudio.reopen' | translate }}
</button>
<button
@@ -155,7 +155,7 @@
(click)="dismissMinimizedThemeStudio()"
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Dismiss
{{ 'common.dismiss' | translate }}
</button>
</div>
</div>

View File

@@ -64,6 +64,7 @@ import {
ThemePickerOverlayComponent,
ThemeService
} from './domains/theme';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from './core/i18n';
@Component({
selector: 'app-root',
@@ -81,7 +82,8 @@ import {
NativeContextMenuComponent,
PrivateCallComponent,
ThemeNodeDirective,
ThemePickerOverlayComponent
ThemePickerOverlayComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -189,6 +191,7 @@ export class App implements OnInit, OnDestroy {
};
});
private readonly appI18n = inject(AppI18nService);
private readonly mobilePersistence = inject(MobilePersistenceService);
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
private readonly mobileUpdates = inject(MobileAppUpdateService);
@@ -198,6 +201,8 @@ export class App implements OnInit, OnDestroy {
private themeStudioControlsBounds: { width: number; height: number } | null = null;
constructor() {
this.appI18n.initialize();
effect(() => {
if (!this.isThemeStudioFullscreen() || this.themeStudioFullscreenComponent()) {
return;

View File

@@ -0,0 +1,72 @@
import {
readFileSync,
readdirSync,
statSync
} from 'node:fs';
import { join } from 'node:path';
import {
describe,
expect,
it
} from 'vitest';
import {
extractTranslationKeysFromSource,
findMissingCatalogKeys,
flattenCatalogKeys
} from './app-i18n-catalog.rules';
const APP_ROOT = join(import.meta.dirname, '../../..');
const EN_CATALOG_PATH = join(APP_ROOT, '../public/i18n/en.json');
function listSourceFiles(directory: string): string[] {
const entries = readdirSync(directory);
const files: string[] = [];
for (const entry of entries) {
const fullPath = join(directory, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
files.push(...listSourceFiles(fullPath));
continue;
}
if (fullPath.endsWith('.html') || (fullPath.endsWith('.ts') && !fullPath.endsWith('.spec.ts'))) {
files.push(fullPath);
}
}
return files;
}
describe('app-i18n-catalog.rules', () => {
it('defines every translation key referenced in app templates and instant() calls', () => {
const catalog = JSON.parse(readFileSync(EN_CATALOG_PATH, 'utf8')) as Record<string, unknown>;
const definedKeys = flattenCatalogKeys(catalog);
const usedKeys = new Set<string>();
for (const filePath of listSourceFiles(join(APP_ROOT, 'app'))) {
const source = readFileSync(filePath, 'utf8');
for (const key of extractTranslationKeysFromSource(source)) {
usedKeys.add(key);
}
}
const missingKeys = findMissingCatalogKeys(definedKeys, usedKeys);
expect(missingKeys, `Missing i18n keys: ${missingKeys.join(', ')}`).toEqual([]);
});
it('nests extracted theme registry labels under theme.registry', () => {
const catalog = JSON.parse(readFileSync(EN_CATALOG_PATH, 'utf8')) as Record<string, unknown>;
const theme = catalog['theme'] as Record<string, unknown> | undefined;
const registry = theme?.['registry'] as Record<string, { label?: string }> | undefined;
expect(theme?.['registry']).toBeDefined();
expect(catalog['theme.registry']).toBeUndefined();
expect(registry?.['appRoot']?.label).toBe('App Root');
expect(Object.keys(registry ?? {}).length).toBeGreaterThanOrEqual(60);
});
});

View File

@@ -0,0 +1,47 @@
const TRANSLATE_PIPE_KEY_PATTERN = /['"]([a-z][a-zA-Z0-9_.]*)['"]\s*\|\s*translate/g;
const INSTANT_KEY_PATTERN = /\.instant\(\s*['"]([a-z][a-zA-Z0-9_.]*)['"]/g;
export function flattenCatalogKeys(
catalog: Record<string, unknown>,
prefix = ''
): Set<string> {
const keys = new Set<string>();
for (const [key, value] of Object.entries(catalog)) {
const path = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
for (const nestedKey of flattenCatalogKeys(value as Record<string, unknown>, path)) {
keys.add(nestedKey);
}
continue;
}
keys.add(path);
}
return keys;
}
export function extractTranslationKeysFromSource(source: string): Set<string> {
const keys = new Set<string>();
for (const pattern of [TRANSLATE_PIPE_KEY_PATTERN, INSTANT_KEY_PATTERN]) {
pattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
keys.add(match[1]);
}
}
return keys;
}
export function findMissingCatalogKeys(definedKeys: Set<string>, usedKeys: Set<string>): string[] {
return [...usedKeys]
.filter((key) => !definedKeys.has(key))
.sort();
}

View File

@@ -0,0 +1,29 @@
import {
describe,
expect,
it
} from 'vitest';
import {
DEFAULT_APP_LOCALE,
SUPPORTED_APP_LOCALES,
resolveAppLocale
} from './app-i18n.rules';
describe('app-i18n.rules', () => {
it('ships only English as the default locale', () => {
expect(DEFAULT_APP_LOCALE).toBe('en');
expect(SUPPORTED_APP_LOCALES).toEqual(['en']);
});
it('resolves unknown locale candidates to the default locale', () => {
expect(resolveAppLocale(null)).toBe('en');
expect(resolveAppLocale(undefined)).toBe('en');
expect(resolveAppLocale('')).toBe('en');
expect(resolveAppLocale('fr')).toBe('en');
});
it('accepts supported locale candidates', () => {
expect(resolveAppLocale('en')).toBe('en');
});
});

View File

@@ -0,0 +1,13 @@
export const DEFAULT_APP_LOCALE = 'en' as const;
export const SUPPORTED_APP_LOCALES = [DEFAULT_APP_LOCALE] as const;
export type AppLocale = (typeof SUPPORTED_APP_LOCALES)[number];
export function resolveAppLocale(candidate: string | null | undefined): AppLocale {
if (candidate && SUPPORTED_APP_LOCALES.includes(candidate as AppLocale)) {
return candidate as AppLocale;
}
return DEFAULT_APP_LOCALE;
}

View File

@@ -0,0 +1,40 @@
import { createEnvironmentInjector } from '@angular/core';
import { TranslateService, provideTranslateService } from '@ngx-translate/core';
import { AppI18nService } from './app-i18n.service';
describe('AppI18nService', () => {
let injector: ReturnType<typeof createEnvironmentInjector>;
let service: AppI18nService;
let translate: TranslateService;
beforeEach(() => {
injector = createEnvironmentInjector([
provideTranslateService({
fallbackLang: 'en',
lang: 'en'
}),
AppI18nService
]);
service = injector.get(AppI18nService);
translate = injector.get(TranslateService);
});
afterEach(() => {
injector.destroy();
});
it('loads bundled English translations on initialize', () => {
service.initialize();
expect(translate.getCurrentLang()).toBe('en');
expect(translate.instant('common.brand')).toBe('Toju');
});
it('falls back to English for unsupported locale requests', () => {
service.initialize('fr');
expect(translate.getCurrentLang()).toBe('en');
});
});

View File

@@ -0,0 +1,22 @@
import { Injectable, inject } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import translationsEn from '../../../../public/i18n/en.json';
import { DEFAULT_APP_LOCALE, resolveAppLocale } from './app-i18n.rules';
@Injectable({ providedIn: 'root' })
export class AppI18nService {
private readonly translate = inject(TranslateService);
initialize(locale?: string | null): void {
const resolvedLocale = resolveAppLocale(locale);
this.translate.setTranslation(DEFAULT_APP_LOCALE, translationsEn);
this.translate.setFallbackLang(DEFAULT_APP_LOCALE);
this.translate.use(resolvedLocale);
}
instant(key: string, params?: Record<string, unknown>): string {
return this.translate.instant(key, params);
}
}

View File

@@ -0,0 +1,19 @@
import { Injector, Provider } from '@angular/core';
import { provideTranslateService } from '@ngx-translate/core';
import { AppI18nService } from './app-i18n.service';
/** Vitest/Injector harness providers for components and services that inject AppI18nService. */
export function provideAppI18nForTests(): Provider[] {
return [
provideTranslateService({
fallbackLang: 'en',
lang: 'en'
}),
AppI18nService
];
}
export function initializeAppI18nForTests(injector: Injector): void {
injector.get(AppI18nService).initialize();
}

View File

@@ -0,0 +1,4 @@
import { TranslateModule } from '@ngx-translate/core';
/** Standalone component imports for templates using the `translate` pipe. */
export const APP_TRANSLATE_IMPORTS = [TranslateModule] as const;

View File

@@ -0,0 +1,5 @@
export * from './app-i18n.rules';
export * from './app-i18n-catalog.rules';
export * from './app-i18n.service';
export * from './app-translate.imports';
export * from './app-i18n.testing';

View File

@@ -5,6 +5,7 @@ import {
RoomRoleAssignment
} from '../../../../shared-kernel';
import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
import { localizeSystemRoleDisplayName } from './role-display.rules';
import type { MemberIdentity } from '../models/access-control.model';
import {
buildRoleLookup,
@@ -106,13 +107,19 @@ export function getAssignedRoleIds(assignments: readonly RoomRoleAssignment[] |
return uniqueStrings(assignment?.roleIds ?? []);
}
export function getDisplayRoleName(room: Room, member: MemberIdentity | null | undefined): string {
export function getDisplayRoleName(
room: Room,
member: MemberIdentity | null | undefined,
translate?: (key: string) => string
): string {
const translateOr = (key: string, fallback: string) => translate?.(key) ?? fallback;
if (!member) {
return 'Member';
return translateOr('shared.leaveServer.roles.member', 'Member');
}
if (room.hostId === member.id || room.hostId === member.oderId) {
return 'Owner';
return translateOr('shared.leaveServer.roles.owner', 'Owner');
}
const roles = normalizeRoomRoles(room.roles, room.permissions);
@@ -121,8 +128,13 @@ export function getDisplayRoleName(room: Room, member: MemberIdentity | null | u
.map((roleId) => roleLookup.get(roleId))
.filter((role): role is RoomRole => !!role)
.sort(roleSortDescending);
const roleName = assignedRoles[0]?.name;
return assignedRoles[0]?.name || '@everyone';
if (!roleName) {
return translateOr('shared.accessControl.roles.everyone', '@everyone');
}
return translate ? localizeSystemRoleDisplayName(roleName, translate) : roleName;
}
export function getAssignedRoles(room: Room, identity: MemberIdentity | null | undefined): RoomRole[] {

View File

@@ -0,0 +1,37 @@
import {
describe,
expect,
it
} from 'vitest';
import { localizeSystemRoleDisplayName, resolveSystemRoleDisplayI18nKey } from './role-display.rules';
describe('role-display.rules', () => {
describe('resolveSystemRoleDisplayI18nKey', () => {
it('maps built-in role display names to i18n keys', () => {
expect(resolveSystemRoleDisplayI18nKey('Member')).toBe('shared.leaveServer.roles.member');
expect(resolveSystemRoleDisplayI18nKey('Owner')).toBe('shared.leaveServer.roles.owner');
expect(resolveSystemRoleDisplayI18nKey('@everyone')).toBe('shared.accessControl.roles.everyone');
expect(resolveSystemRoleDisplayI18nKey('Moderator')).toBe('shared.leaveServer.roles.moderator');
expect(resolveSystemRoleDisplayI18nKey('Admin')).toBe('shared.leaveServer.roles.admin');
});
it('returns null for custom role names', () => {
expect(resolveSystemRoleDisplayI18nKey('Custom Role')).toBeNull();
});
});
describe('localizeSystemRoleDisplayName', () => {
it('translates known system role names', () => {
const translate = (key: string) => `translated:${key}`;
expect(localizeSystemRoleDisplayName('Admin', translate)).toBe('translated:shared.leaveServer.roles.admin');
});
it('returns custom role names unchanged', () => {
const translate = (key: string) => `translated:${key}`;
expect(localizeSystemRoleDisplayName('Event Host', translate)).toBe('Event Host');
});
});
});

View File

@@ -0,0 +1,20 @@
const SYSTEM_ROLE_DISPLAY_I18N_KEYS: Readonly<Record<string, string>> = {
Member: 'shared.leaveServer.roles.member',
Owner: 'shared.leaveServer.roles.owner',
'@everyone': 'shared.accessControl.roles.everyone',
Moderator: 'shared.leaveServer.roles.moderator',
Admin: 'shared.leaveServer.roles.admin'
};
export function resolveSystemRoleDisplayI18nKey(displayName: string): string | null {
return SYSTEM_ROLE_DISPLAY_I18N_KEYS[displayName] ?? null;
}
export function localizeSystemRoleDisplayName(
displayName: string,
translate: (key: string) => string
): string {
const key = resolveSystemRoleDisplayI18nKey(displayName);
return key ? translate(key) : displayName;
}

View File

@@ -2,6 +2,7 @@ export * from './domain/models/access-control.model';
export * from './domain/constants/access-control.constants';
export * from './domain/rules/role.rules';
export * from './domain/rules/role-assignment.rules';
export * from './domain/rules/role-display.rules';
export * from './domain/rules/permission.rules';
export * from './domain/rules/room.rules';
export * from './domain/rules/ban.rules';

View File

@@ -3,6 +3,7 @@ import { take } from 'rxjs';
import { Store } from '@ngrx/store';
import { recordDebugNetworkFileChunk } from '../../../../infrastructure/realtime/logging/debug-network-metrics';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { AppI18nService } from '../../../../core/i18n';
import { selectCurrentUserId } from '../../../../store/users/users.selectors';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
@@ -13,9 +14,14 @@ import {
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
DEFAULT_ATTACHMENT_MIME_TYPE,
FILE_NOT_FOUND_REQUEST_ERROR,
NO_CONNECTED_PEERS_REQUEST_ERROR,
UPLOADER_LOCAL_FILE_MISSING_ERROR
ATTACHMENT_DOWNLOAD_FAILED_KEY,
ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY,
ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY,
ATTACHMENT_PREPARE_DOWNLOAD_FAILED_KEY,
ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY,
FILE_NOT_FOUND_REQUEST_ERROR_KEY,
NO_CONNECTED_PEERS_REQUEST_ERROR_KEY,
UPLOADER_LOCAL_FILE_MISSING_ERROR_KEY
} from '../../domain/constants/attachment-transfer.constants';
import {
type FileAnnounceEvent,
@@ -53,6 +59,7 @@ interface ValidFileChunkPayload {
export class AttachmentTransferService {
private readonly ngrxStore = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly appI18n = inject(AppI18nService);
private readonly runtimeStore = inject(AttachmentRuntimeStore);
private readonly attachmentStorage = inject(AttachmentStorageService);
private readonly persistence = inject(AttachmentPersistenceService);
@@ -147,8 +154,8 @@ export class AttachmentTransferService {
if (connectedPeers.length === 0) {
attachment.requestError = isUploader
? UPLOADER_LOCAL_FILE_MISSING_ERROR
: NO_CONNECTED_PEERS_REQUEST_ERROR;
? this.appI18n.instant(UPLOADER_LOCAL_FILE_MISSING_ERROR_KEY)
: this.appI18n.instant(NO_CONNECTED_PEERS_REQUEST_ERROR_KEY);
this.runtimeStore.touch();
console.warn('[Attachments] No connected peers to request file from');
@@ -177,7 +184,7 @@ export class AttachmentTransferService {
const didSendRequest = this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId);
if (!didSendRequest && attachment) {
attachment.requestError = FILE_NOT_FOUND_REQUEST_ERROR;
attachment.requestError = this.appI18n.instant(FILE_NOT_FOUND_REQUEST_ERROR_KEY);
this.runtimeStore.touch();
}
}
@@ -716,7 +723,7 @@ export class AttachmentTransferService {
const assembly = await this.getOrCreateDiskReceiveAssembly(attachment, assemblyKey, payload.total);
if (!assembly) {
throw new Error('Could not prepare media download on disk.');
throw new Error(this.appI18n.instant(ATTACHMENT_PREPARE_DOWNLOAD_FAILED_KEY));
}
if (assembly.receivedIndexes.has(payload.index)) {
@@ -724,13 +731,13 @@ export class AttachmentTransferService {
}
if (payload.index !== assembly.receivedCount) {
throw new Error('Received media chunks out of order. Retry the download.');
throw new Error(this.appI18n.instant(ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY));
}
const didAppend = await this.attachmentStorage.appendBase64(assembly.path, payload.data);
if (!didAppend) {
throw new Error('Could not write media download to disk.');
throw new Error(this.appI18n.instant(ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY));
}
assembly.receivedIndexes.add(payload.index);
@@ -747,7 +754,7 @@ export class AttachmentTransferService {
const restoredForDisplay = await this.persistence.ensureInlineDisplayObjectUrl(attachment);
if (!restoredForDisplay) {
throw new Error('Could not open completed media download from disk.');
throw new Error(this.appI18n.instant(ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY));
}
attachment.available = true;
@@ -801,7 +808,7 @@ export class AttachmentTransferService {
attachment.lastUpdateMs = undefined;
attachment.requestError = error instanceof Error && error.message
? error.message
: 'Media download failed. Retry the download.';
: this.appI18n.instant(ATTACHMENT_DOWNLOAD_FAILED_KEY);
this.runtimeStore.touch();
}

View File

@@ -13,12 +13,12 @@ export const DEFAULT_ATTACHMENT_MIME_TYPE = 'application/octet-stream';
/** localStorage key used by the legacy attachment store during migration. */
export const LEGACY_ATTACHMENTS_STORAGE_KEY = 'metoyou_attachments';
/** User-facing error when no peers are available for a request. */
export const NO_CONNECTED_PEERS_REQUEST_ERROR = 'No connected peers are available to provide this file right now.';
/** User-facing error when connected peers cannot provide a requested file. */
export const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.';
/** User-facing error when the uploader's local copy cannot be restored. */
export const UPLOADER_LOCAL_FILE_MISSING_ERROR =
'Your original upload could not be found on this device. Re-upload the file to restore playback.';
/** i18n keys for user-facing attachment transfer errors. */
export const NO_CONNECTED_PEERS_REQUEST_ERROR_KEY = 'attachment.errors.noConnectedPeers';
export const FILE_NOT_FOUND_REQUEST_ERROR_KEY = 'attachment.errors.fileNotFound';
export const UPLOADER_LOCAL_FILE_MISSING_ERROR_KEY = 'attachment.errors.uploaderLocalMissing';
export const ATTACHMENT_PREPARE_DOWNLOAD_FAILED_KEY = 'attachment.errors.prepareDownloadFailed';
export const ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY = 'attachment.errors.chunksOutOfOrder';
export const ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY = 'attachment.errors.writeDownloadFailed';
export const ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY = 'attachment.errors.openDownloadFailed';
export const ATTACHMENT_DOWNLOAD_FAILED_KEY = 'attachment.errors.downloadFailed';

View File

@@ -5,7 +5,7 @@
name="lucideLogIn"
class="w-5 h-5 text-primary"
/>
<h1 class="text-lg font-semibold text-foreground">Login</h1>
<h1 class="text-lg font-semibold text-foreground">{{ 'auth.login.title' | translate }}</h1>
</div>
<div class="space-y-3">
@@ -13,7 +13,7 @@
<label
for="login-username"
class="block text-xs text-muted-foreground mb-1"
>Username</label
>{{ 'auth.login.username' | translate }}</label
>
<input
[(ngModel)]="username"
@@ -26,7 +26,7 @@
<label
for="login-password"
class="block text-xs text-muted-foreground mb-1"
>Password</label
>{{ 'auth.login.password' | translate }}</label
>
<input
[(ngModel)]="password"
@@ -39,7 +39,7 @@
<label
for="login-server"
class="block text-xs text-muted-foreground mb-1"
>Server App</label
>{{ 'auth.login.serverApp' | translate }}</label
>
<select
[(ngModel)]="serverId"
@@ -59,16 +59,16 @@
type="button"
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
>
Login
{{ 'auth.login.submit' | translate }}
</button>
<div class="text-xs text-muted-foreground text-center mt-2">
No account?
{{ 'auth.login.noAccount' | translate }}
<button
type="button"
(click)="goRegister()"
class="text-primary hover:underline"
>
Register
{{ 'auth.login.registerLink' | translate }}
</button>
</div>
</div>

View File

@@ -18,6 +18,7 @@ import { ServerDirectoryFacade } from '../../../server-directory';
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules';
import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../../shared-kernel';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
@Component({
selector: 'app-login',
@@ -25,7 +26,8 @@ import { User } from '../../../../shared-kernel';
imports: [
CommonModule,
FormsModule,
NgIcon
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucideLogIn })],
templateUrl: './login.component.html'
@@ -42,6 +44,7 @@ export class LoginComponent {
serverId: string | undefined = this.serversSvc.activeServer()?.id;
error = signal<string | null>(null);
private readonly appI18n = inject(AppI18nService);
private auth = inject(AuthenticationService);
private actions$ = inject(Actions);
private store = inject(Store);
@@ -95,7 +98,7 @@ export class LoginComponent {
await this.router.navigate(['/dashboard']);
},
error: (err) => {
this.error.set(err?.error?.error || 'Login failed');
this.error.set(err?.error?.error || this.appI18n.instant('auth.login.failed'));
}
});
}

View File

@@ -5,7 +5,7 @@
name="lucideUserPlus"
class="w-5 h-5 text-primary"
/>
<h1 class="text-lg font-semibold text-foreground">Register</h1>
<h1 class="text-lg font-semibold text-foreground">{{ 'auth.register.title' | translate }}</h1>
</div>
<div class="space-y-3">
@@ -13,7 +13,7 @@
<label
for="register-username"
class="block text-xs text-muted-foreground mb-1"
>Username</label
>{{ 'auth.register.username' | translate }}</label
>
<input
[(ngModel)]="username"
@@ -26,7 +26,7 @@
<label
for="register-display-name"
class="block text-xs text-muted-foreground mb-1"
>Display Name</label
>{{ 'auth.register.displayName' | translate }}</label
>
<input
[(ngModel)]="displayName"
@@ -39,7 +39,7 @@
<label
for="register-password"
class="block text-xs text-muted-foreground mb-1"
>Password</label
>{{ 'auth.register.password' | translate }}</label
>
<input
[(ngModel)]="password"
@@ -52,7 +52,7 @@
<label
for="register-server"
class="block text-xs text-muted-foreground mb-1"
>Server App</label
>{{ 'auth.register.serverApp' | translate }}</label
>
<select
[(ngModel)]="serverId"
@@ -72,16 +72,16 @@
type="button"
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
>
Create Account
{{ 'auth.register.submit' | translate }}
</button>
<div class="text-xs text-muted-foreground text-center mt-2">
Have an account?
{{ 'auth.register.haveAccount' | translate }}
<button
type="button"
(click)="goLogin()"
class="text-primary hover:underline"
>
Login
{{ 'auth.register.loginLink' | translate }}
</button>
</div>
</div>

View File

@@ -18,6 +18,7 @@ import { ServerDirectoryFacade } from '../../../server-directory';
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules';
import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../../shared-kernel';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
@Component({
selector: 'app-register',
@@ -25,7 +26,8 @@ import { User } from '../../../../shared-kernel';
imports: [
CommonModule,
FormsModule,
NgIcon
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucideUserPlus })],
templateUrl: './register.component.html'
@@ -43,6 +45,7 @@ export class RegisterComponent {
serverId: string | undefined = this.serversSvc.activeServer()?.id;
error = signal<string | null>(null);
private readonly appI18n = inject(AppI18nService);
private auth = inject(AuthenticationService);
private actions$ = inject(Actions);
private store = inject(Store);
@@ -97,7 +100,7 @@ export class RegisterComponent {
await this.router.navigate(['/dashboard']);
},
error: (err) => {
this.error.set(err?.error?.error || 'Registration failed');
this.error.set(err?.error?.error || this.appI18n.instant('auth.register.failed'));
}
});
}

View File

@@ -27,7 +27,7 @@
name="lucideLogIn"
class="w-3 h-3"
/>
Login
{{ 'auth.userBar.login' | translate }}
</button>
<button
type="button"
@@ -38,7 +38,7 @@
name="lucideUserPlus"
class="w-3 h-3"
/>
Register
{{ 'auth.userBar.register' | translate }}
</button>
</div>
}

View File

@@ -7,6 +7,7 @@ import { lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ProfileCardService } from '../../../../shared/components/profile-card/profile-card.service';
import { UserAvatarComponent } from '../../../../shared';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
@Component({
selector: 'app-user-bar',
@@ -14,7 +15,8 @@ import { UserAvatarComponent } from '../../../../shared';
imports: [
CommonModule,
NgIcon,
UserAvatarComponent
UserAvatarComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({

View File

@@ -150,15 +150,15 @@ The composer renders a Discord-style autocomplete menu when the user types `/`.
## Domain rules
| Function | Purpose |
|---|---|
| `canEditMessage(msg, userId)` | Only the sender can edit their own message |
| `normaliseDeletedMessage(msg)` | Strips content and reactions from deleted messages |
| `getMessageTimestamp(msg)` | Returns `editedAt` if present, otherwise `timestamp` |
| `getLatestTimestamp(msgs)` | Max timestamp across a batch, used for sync ordering |
| `chunkArray(items, size)` | Splits arrays into fixed-size chunks for batched transfer |
| `findMissingIds(remote, local)` | Compares inventories and returns IDs to request |
| `resolveAutoScrollBehavior(input)` | Decides `instant` / `smooth` / `none` when the message count changes |
| Function | Purpose |
| --------------------------------------- | -------------------------------------------------------------------------------------- |
| `canEditMessage(msg, userId)` | Only the sender can edit their own message |
| `normaliseDeletedMessage(msg)` | Strips content and reactions from deleted messages |
| `getMessageTimestamp(msg)` | Returns `editedAt` if present, otherwise `timestamp` |
| `getLatestTimestamp(msgs)` | Max timestamp across a batch, used for sync ordering |
| `chunkArray(items, size)` | Splits arrays into fixed-size chunks for batched transfer |
| `findMissingIds(remote, local)` | Compares inventories and returns IDs to request |
| `resolveAutoScrollBehavior(input)` | Decides `instant` / `smooth` / `none` when the message count changes |
| `isStuckToBottom(distance, threshold?)` | True while the list is close enough to the bottom to keep auto-pinning (default 300px) |
## Auto-scroll

View File

@@ -10,6 +10,7 @@ import {
throwError
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { AppI18nService } from '../../../../core/i18n';
import {
ServerDirectoryFacade,
type RoomSignalSourceInput,
@@ -48,6 +49,7 @@ interface KlipyAvailabilityState {
@Injectable({ providedIn: 'root' })
export class KlipyService {
private readonly http = inject(HttpClient);
private readonly appI18n = inject(AppI18nService);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly availabilityByKey = signal<Record<string, KlipyAvailabilityState>>({});
@@ -63,32 +65,21 @@ export class KlipyService {
const selector = this.getSourceSelector(source);
const key = this.getAvailabilityKey(selector);
this.setAvailabilityState(key, { enabled: false,
loading: true });
this.setAvailabilityState(key, { enabled: false, loading: true });
try {
const response = await firstValueFrom(
this.http.get<KlipyAvailabilityResponse>(
`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/config`
)
);
const response = await firstValueFrom(this.http.get<KlipyAvailabilityResponse>(`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/config`));
this.setAvailabilityState(key, {
enabled: response.enabled === true,
loading: false
});
} catch {
this.setAvailabilityState(key, { enabled: false,
loading: false });
this.setAvailabilityState(key, { enabled: false, loading: false });
}
}
searchGifs(
query: string,
page = 1,
perPage = DEFAULT_PAGE_SIZE,
source?: RoomSignalSourceInput | null
): Observable<KlipyGifSearchResponse> {
searchGifs(query: string, page = 1, perPage = DEFAULT_PAGE_SIZE, source?: RoomSignalSourceInput | null): Observable<KlipyGifSearchResponse> {
const selector = this.getSourceSelector(source);
let params = new HttpParams()
@@ -108,18 +99,14 @@ export class KlipyService {
params = params.set('locale', locale);
}
return this.http
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/gifs`, { params })
.pipe(
map((response) => ({
enabled: response.enabled !== false,
results: Array.isArray(response.results) ? response.results : [],
hasNext: response.hasNext === true
})),
catchError((error) =>
throwError(() => new Error(this.extractErrorMessage(error)))
)
);
return this.http.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/gifs`, { params }).pipe(
map((response) => ({
enabled: response.enabled !== false,
results: Array.isArray(response.results) ? response.results : [],
hasNext: response.hasNext === true
})),
catchError((error) => throwError(() => new Error(this.extractErrorMessage(error))))
);
}
normalizeMediaUrl(url: string): string {
@@ -151,9 +138,7 @@ export class KlipyService {
}
private getAvailabilityState(source?: RoomSignalSourceInput | null): KlipyAvailabilityState {
return this.availabilityByKey()[this.getAvailabilityKey(this.getSourceSelector(source))]
?? { enabled: false,
loading: true };
return this.availabilityByKey()[this.getAvailabilityKey(this.getSourceSelector(source))] ?? { enabled: false, loading: true };
}
private setAvailabilityState(key: string, state: KlipyAvailabilityState): void {
@@ -199,9 +184,8 @@ export class KlipyService {
if (existing?.trim())
return existing;
const created = window.crypto?.randomUUID?.()
?? `klipy-${Date.now().toString(36)}-${Math.random().toString(36)
.slice(2, 10)}`;
const created = window.crypto?.randomUUID?.() ?? `klipy-${Date.now().toString(36)}-${Math.random().toString(36)
.slice(2, 10)}`;
window.localStorage.setItem(KLIPY_CUSTOMER_ID_STORAGE_KEY, created);
return created;
@@ -228,6 +212,6 @@ export class KlipyService {
if (typeof httpError?.message === 'string')
return httpError.message;
return 'Failed to load GIFs from KLIPY.';
return this.appI18n.instant('chat.gifPicker.loadFailed');
}
}

View File

@@ -18,12 +18,7 @@ export class LinkMetadataService {
async fetchMetadata(url: string): Promise<LinkMetadata> {
try {
const apiBase = this.serverDirectory.getApiBaseUrl();
const result = await firstValueFrom(
this.http.get<Omit<LinkMetadata, 'url'>>(
`${apiBase}/link-metadata`,
{ params: { url } }
)
);
const result = await firstValueFrom(this.http.get<Omit<LinkMetadata, 'url'>>(`${apiBase}/link-metadata`, { params: { url } }));
return { url, ...result };
} catch {

View File

@@ -14,38 +14,26 @@ describe('resolveAutoScrollBehavior', () => {
it('jumps instantly for the local user own send regardless of grace', () => {
expect(resolveAutoScrollBehavior({ ...base, forceLocalSend: true })).toBe('instant');
expect(
resolveAutoScrollBehavior({ ...base, forceLocalSend: true, withinInitialGrace: true })
).toBe('instant');
expect(resolveAutoScrollBehavior({ ...base, forceLocalSend: true, withinInitialGrace: true })).toBe('instant');
});
it('jumps instantly when near bottom while settling after a channel switch', () => {
expect(
resolveAutoScrollBehavior({ ...base, distanceFromBottom: 40, withinInitialGrace: true })
).toBe('instant');
expect(resolveAutoScrollBehavior({ ...base, distanceFromBottom: 40, withinInitialGrace: true })).toBe('instant');
});
it('animates smoothly for live messages once settled and near bottom', () => {
expect(
resolveAutoScrollBehavior({ ...base, distanceFromBottom: 40, withinInitialGrace: false })
).toBe('smooth');
expect(resolveAutoScrollBehavior({ ...base, distanceFromBottom: 40, withinInitialGrace: false })).toBe('smooth');
});
it('shows the indicator (no scroll) when far from the bottom', () => {
expect(resolveAutoScrollBehavior({ ...base, distanceFromBottom: 800 })).toBe('none');
expect(
resolveAutoScrollBehavior({ ...base, distanceFromBottom: 800, withinInitialGrace: true })
).toBe('none');
expect(resolveAutoScrollBehavior({ ...base, distanceFromBottom: 800, withinInitialGrace: true })).toBe('none');
});
it('honours a custom sticky threshold', () => {
expect(
resolveAutoScrollBehavior({ ...base, distanceFromBottom: 150, stickyThreshold: 100 })
).toBe('none');
expect(resolveAutoScrollBehavior({ ...base, distanceFromBottom: 150, stickyThreshold: 100 })).toBe('none');
expect(
resolveAutoScrollBehavior({ ...base, distanceFromBottom: 80, stickyThreshold: 100 })
).toBe('smooth');
expect(resolveAutoScrollBehavior({ ...base, distanceFromBottom: 80, stickyThreshold: 100 })).toBe('smooth');
});
});

View File

@@ -54,9 +54,6 @@ export const STICKY_BOTTOM_THRESHOLD = 300;
* This is the predicate the message list uses to decide whether a content
* height change (late image/embed/plugin render) should re-pin to bottom.
*/
export function isStuckToBottom(
distanceFromBottom: number,
threshold: number = STICKY_BOTTOM_THRESHOLD
): boolean {
export function isStuckToBottom(distanceFromBottom: number, threshold: number = STICKY_BOTTOM_THRESHOLD): boolean {
return distanceFromBottom <= threshold;
}

View File

@@ -51,12 +51,7 @@ export function findMissingIds(
for (const item of remoteItems) {
const local = localMap.get(item.id);
if (
!local ||
item.ts > local.ts ||
(item.rc !== undefined && item.rc !== local.rc) ||
(item.ac !== undefined && item.ac !== local.ac)
) {
if (!local || item.ts > local.ts || (item.rc !== undefined && item.rc !== local.rc) || (item.ac !== undefined && item.ac !== local.ac)) {
missing.push(item.id);
}
}

View File

@@ -7,10 +7,7 @@ export function getMessageTimestamp(msg: Message): number {
/** Computes the most recent timestamp across a batch of messages. */
export function getLatestTimestamp(messages: Message[]): number {
return messages.reduce(
(max, msg) => Math.max(max, getMessageTimestamp(msg)),
0
);
return messages.reduce((max, msg) => Math.max(max, getMessageTimestamp(msg)), 0);
}
/** Strips sensitive content from a deleted message. */

View File

@@ -63,7 +63,7 @@
(keydown.space)="closeKlipyGifPicker()"
tabindex="0"
role="button"
aria-label="Close GIF picker"
[attr.aria-label]="'chat.gifPicker.closeOverlayAria' | translate"
style="-webkit-app-region: no-drag"
></div>

View File

@@ -29,6 +29,7 @@ import {
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { Message } from '../../../../shared-kernel';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ThemeNodeDirective } from '../../../theme';
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
@@ -56,7 +57,8 @@ import {
ChatMessageListComponent,
ChatMessageOverlaysComponent,
BottomSheetComponent,
ThemeNodeDirective
ThemeNodeDirective,
...APP_TRANSLATE_IMPORTS
],
templateUrl: './chat-messages.component.html',
styleUrl: './chat-messages.component.scss'
@@ -87,9 +89,7 @@ export class ChatMessagesComponent {
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
readonly conversationExhausted = toSignal(
toObservable(this.conversationKey).pipe(
switchMap((key) => this.store.select(selectConversationExhausted(key)))
),
toObservable(this.conversationKey).pipe(switchMap((key) => this.store.select(selectConversationExhausted(key)))),
{ initialValue: false }
);
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));

View File

@@ -7,6 +7,7 @@ import {
import {
BUILT_IN_SLASH_COMMANDS,
BUILT_IN_SLASH_COMMAND_SOURCE,
BUILT_IN_SLASH_COMMAND_SOURCE_KEY,
buildBuiltInSlashCommandEntries
} from './chat-builtin-slash-commands.rules';
@@ -18,28 +19,34 @@ describe('built-in slash commands', () => {
});
it('adapts definitions to global slash command entries tagged as built-in', () => {
const entries = buildBuiltInSlashCommandEntries(() => {});
const entries = buildBuiltInSlashCommandEntries(
() => {},
(key) => `i18n:${key}`
);
const lenny = entries.find((entry) => entry.contribution.name === 'lenny');
expect(lenny?.pluginId).toBe(BUILT_IN_SLASH_COMMAND_SOURCE);
expect(lenny?.pluginId).toBe(`i18n:${BUILT_IN_SLASH_COMMAND_SOURCE_KEY}`);
expect(lenny?.id).toBe(`${BUILT_IN_SLASH_COMMAND_SOURCE}:lenny`);
expect(lenny?.contribution.scope).toBe('global');
expect(lenny?.contribution.description).toBe('i18n:chat.slashCommand.lennyDescription');
});
it('runs the command by sending its text', () => {
const sendText = vi.fn();
const entries = buildBuiltInSlashCommandEntries(sendText);
entries.find((entry) => entry.contribution.name === 'lenny')?.contribution.run({
args: {},
command: 'lenny',
rawArgs: '',
server: null,
source: 'slashCommand',
textChannel: null,
user: null,
voiceChannel: null
});
entries
.find((entry) => entry.contribution.name === 'lenny')
?.contribution.run({
args: {},
command: 'lenny',
rawArgs: '',
server: null,
source: 'slashCommand',
textChannel: null,
user: null,
voiceChannel: null
});
expect(sendText).toHaveBeenCalledWith('( ͡° ͜ʖ ͡°)');
});

View File

@@ -1,11 +1,14 @@
import type { SlashCommandEntry } from '../../../../../plugins';
/** Source label shown for built-in commands in the slash command menu. */
export const BUILT_IN_SLASH_COMMAND_SOURCE = 'Built-in';
/** Plugin id tag for built-in commands in the slash command menu. */
export const BUILT_IN_SLASH_COMMAND_SOURCE = 'built-in';
/** i18n key for the built-in command source badge. */
export const BUILT_IN_SLASH_COMMAND_SOURCE_KEY = 'chat.slashCommand.builtInSource';
/** A first-party slash command that inserts fixed text into the chat as a message. */
export interface BuiltInSlashCommand {
description: string;
descriptionKey: string;
icon?: string;
name: string;
text: string;
@@ -18,7 +21,7 @@ export interface BuiltInSlashCommand {
export const BUILT_IN_SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
{
name: 'lenny',
description: 'Send the Lenny face ( ͡° ͜ʖ ͡°)',
descriptionKey: 'chat.slashCommand.lennyDescription',
text: '( ͡° ͜ʖ ͡°)'
}
];
@@ -28,16 +31,19 @@ export const BUILT_IN_SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
* by the composer menu. Each entry's `run` sends the command's text through the
* provided callback so it posts as a normal chat message.
*/
export function buildBuiltInSlashCommandEntries(sendText: (text: string) => void): SlashCommandEntry[] {
export function buildBuiltInSlashCommandEntries(
sendText: (text: string) => void,
instant: (key: string) => string = (key) => key
): SlashCommandEntry[] {
return BUILT_IN_SLASH_COMMANDS.map((command) => ({
contribution: {
description: command.description,
description: instant(command.descriptionKey),
icon: command.icon,
name: command.name,
run: () => sendText(command.text),
scope: 'global'
},
id: `${BUILT_IN_SLASH_COMMAND_SOURCE}:${command.name}`,
pluginId: BUILT_IN_SLASH_COMMAND_SOURCE
pluginId: instant(BUILT_IN_SLASH_COMMAND_SOURCE_KEY)
}));
}

View File

@@ -24,7 +24,7 @@
class="h-4 w-4 text-muted-foreground"
/>
<span class="flex-1 text-sm text-muted-foreground">
Replying to <span class="font-semibold">{{ replyTo()?.senderName }}</span>
{{ 'chat.composer.replyingTo' | translate: { name: replyTo()?.senderName } }}
</span>
<button
(click)="clearReply()"
@@ -98,43 +98,43 @@
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyPrefix('> ')"
>
Quote
{{ 'chat.composer.toolbar.quote' | translate }}
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyPrefix('- ')"
>
• List
{{ 'chat.composer.toolbar.bulletList' | translate }}
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyOrderedList()"
>
1. List
{{ 'chat.composer.toolbar.orderedList' | translate }}
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyCodeBlock()"
>
Code
{{ 'chat.composer.toolbar.code' | translate }}
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyLink()"
>
Link
{{ 'chat.composer.toolbar.link' | translate }}
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyImage()"
>
Image
{{ 'chat.composer.toolbar.image' | translate }}
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyHorizontalRule()"
>
HR
{{ 'chat.composer.toolbar.horizontalRule' | translate }}
</button>
</div>
</div>
@@ -176,8 +176,8 @@
[class.opacity-100]="inputHovered() || showComposerMediaMenu() || showEmojiPicker() || showKlipyGifPicker()"
[class.opacity-70]="!inputHovered() && !showComposerMediaMenu() && !showEmojiPicker() && !showKlipyGifPicker()"
[class.text-primary]="showComposerMediaMenu() || showEmojiPicker() || showKlipyGifPicker()"
aria-label="Add attachment, GIF, or emoji"
title="Add attachment, GIF, or emoji"
[attr.aria-label]="'chat.composer.addAttachmentGifEmoji' | translate"
[title]="'chat.composer.addAttachmentGifEmoji' | translate"
>
<ng-icon
name="lucidePlus"
@@ -187,8 +187,8 @@
@if (showComposerMediaMenu()) {
<app-bottom-sheet
title="Add to message"
ariaLabel="Add to message"
[title]="'chat.composer.addToMessage' | translate"
[ariaLabel]="'chat.composer.addToMessage' | translate"
(dismissed)="closeComposerMediaMenu()"
>
<div class="flex flex-col py-1">
@@ -218,7 +218,7 @@
/>
}
}
<span>{{ option.label }}</span>
<span>{{ option.labelKey | translate }}</span>
</button>
}
</div>
@@ -227,8 +227,8 @@
@if (showEmojiPicker()) {
<app-bottom-sheet
title="Emoji"
ariaLabel="Emoji picker"
[title]="'chat.composer.emoji' | translate"
[ariaLabel]="'chat.composer.emojiPickerAria' | translate"
(dismissed)="closeEmojiPicker()"
>
<app-custom-emoji-picker
@@ -249,8 +249,8 @@
class="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-border/70 bg-secondary/55 text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground md:h-10 md:w-10"
[class.opacity-100]="inputHovered()"
[class.opacity-70]="!inputHovered()"
aria-label="Attach files"
title="Attach files"
[attr.aria-label]="'chat.composer.attachFiles' | translate"
[title]="'chat.composer.attachFiles' | translate"
>
<ng-icon
name="lucidePaperclip"
@@ -270,14 +270,14 @@
[class.opacity-70]="!inputHovered() && !showKlipyGifPicker()"
[class.shadow-none]="!inputHovered() && !showKlipyGifPicker()"
[class.text-primary]="showKlipyGifPicker()"
aria-label="Search KLIPY GIFs"
title="Search KLIPY GIFs"
[attr.aria-label]="'chat.composer.searchKlipyGifs' | translate"
[title]="'chat.composer.searchKlipyGifs' | translate"
>
<ng-icon
name="lucideImage"
class="h-4 w-4"
/>
<span class="hidden sm:inline">GIF</span>
<span class="hidden sm:inline">{{ 'chat.composer.gif' | translate }}</span>
</button>
}
@@ -290,8 +290,8 @@
[class.opacity-100]="inputHovered() || showEmojiPicker()"
[class.opacity-60]="!inputHovered() && !showEmojiPicker()"
[class.grayscale-0]="showEmojiPicker()"
aria-label="Open emoji selector"
title="Open emoji selector"
[attr.aria-label]="'chat.composer.openEmojiSelector' | translate"
[title]="'chat.composer.openEmojiSelector' | translate"
>
{{ emojiButton() }}
</button>
@@ -314,8 +314,8 @@
(click)="sendMessage()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
class="send-btn visible inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-1 ring-primary/20 transition-all duration-200 hover:-translate-y-0.5 hover:bg-primary/90 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground disabled:shadow-none disabled:ring-0"
aria-label="Send message"
title="Send message"
[attr.aria-label]="'chat.composer.sendMessage' | translate"
[title]="'chat.composer.sendMessage' | translate"
>
<ng-icon
name="lucideSend"
@@ -339,7 +339,7 @@
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
placeholder="Type a message..."
[placeholder]="'chat.composer.placeholder' | translate"
class="chat-textarea w-full min-w-0 border-0 pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none"
[class.chat-textarea-expanded]="textareaExpanded()"
[class.ctrl-resize]="ctrlHeld()"
@@ -348,7 +348,7 @@
@if (dragActive()) {
<div class="pointer-events-none absolute inset-0 flex items-center justify-center border-2 border-dashed border-primary bg-primary/5">
<div class="text-sm text-muted-foreground">Drop files to attach</div>
<div class="text-sm text-muted-foreground">{{ 'chat.composer.dropFilesToAttach' | translate }}</div>
</div>
}
@@ -359,20 +359,20 @@
<img
[appChatImageProxyFallback]="pendingKlipyGif()!.previewUrl || pendingKlipyGif()!.url"
[signalSource]="klipySignalSource()"
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
[alt]="pendingKlipyGif()!.title || ('chat.composer.klipyGif' | translate)"
class="h-full w-full object-cover"
loading="lazy"
/>
<span
class="absolute bottom-1 left-1 rounded bg-black/70 px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-[0.18em] text-white/90"
>
KLIPY
{{ 'chat.composer.klipy' | translate }}
</span>
</div>
<div class="min-w-0">
<div class="text-xs font-medium text-foreground">GIF ready to send</div>
<div class="text-xs font-medium text-foreground">{{ 'chat.composer.gifReadyToSend' | translate }}</div>
<div class="max-w-[12rem] truncate text-[10px] text-muted-foreground">
{{ pendingKlipyGif()!.title || 'KLIPY GIF' }}
{{ pendingKlipyGif()!.title || ('chat.composer.klipyGif' | translate) }}
</div>
</div>
<button
@@ -380,7 +380,7 @@
(click)="removePendingKlipyGif()"
class="rounded px-2 py-1 text-[10px] text-destructive transition-colors hover:bg-destructive/10"
>
Remove
{{ 'chat.composer.remove' | translate }}
</button>
</div>
</div>
@@ -396,7 +396,7 @@
(click)="removePendingFile(file)"
class="rounded bg-destructive/20 px-1 py-0.5 text-[10px] text-destructive opacity-70 group-hover:opacity-100"
>
Remove
{{ 'chat.composer.remove' | translate }}
</button>
</div>
}

View File

@@ -54,6 +54,7 @@ import {
} from '../../../../../custom-emoji';
import { annotateLocalFilePath } from '../../../../../attachment';
import { BottomSheetComponent } from '../../../../../../shared';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import { ViewportService } from '../../../../../../core/platform/viewport.service';
import { MobileMediaService, MobilePlatformService } from '../../../../../../infrastructure/mobile';
import {
@@ -83,7 +84,8 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
TypingIndicatorComponent,
ThemeNodeDirective,
BottomSheetComponent,
ChatSlashCommandMenuComponent
ChatSlashCommandMenuComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -132,13 +134,12 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly mobileMedia = inject(MobileMediaService);
private readonly viewport = inject(ViewportService);
private readonly appI18n = inject(AppI18nService);
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
readonly shouldShowAttachmentButton = this.mobilePlatform.shouldShowAttachmentButton;
readonly mergeComposerMediaActions = computed(() => shouldMergeComposerMediaActions(this.viewport.isMobile()));
readonly composerMediaMenuOptions = computed(() =>
buildComposerMediaMenuOptions(this.shouldShowAttachmentButton(), this.klipyEnabled())
);
readonly composerMediaMenuOptions = computed(() => buildComposerMediaMenuOptions(this.shouldShowAttachmentButton(), this.klipyEnabled()));
readonly composerTextareaPaddingClass = computed(() =>
resolveComposerTextareaPaddingClass({
isMobileViewport: this.viewport.isMobile(),
@@ -152,12 +153,12 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
readonly pluginComposerActions = this.pluginUi.composerActionRecords;
readonly slashQuery = signal<string | null>(null);
readonly slashActiveIndex = signal(0);
private readonly builtInSlashEntries = buildBuiltInSlashCommandEntries((text) => this.sendBuiltInSlashText(text));
private readonly builtInSlashEntries = buildBuiltInSlashCommandEntries(
(text) => this.sendBuiltInSlashText(text),
(key) => this.appI18n.instant(key)
);
readonly availableSlashCommands = computed(() =>
selectAvailableSlashCommands(
[...this.builtInSlashEntries, ...this.pluginUi.slashCommandRecords()],
this.commandSurface()
)
selectAvailableSlashCommands([...this.builtInSlashEntries, ...this.pluginUi.slashCommandRecords()], this.commandSurface())
);
readonly slashCommandResults = computed(() => {
const query = this.slashQuery();
@@ -377,8 +378,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
}
runPluginComposerAction(action: PluginApiActionContribution): void {
void Promise.resolve()
.then(() => action.run(this.pluginApi.createActionContext('composerAction')));
void Promise.resolve().then(() => action.run(this.pluginApi.createActionContext('composerAction')));
}
updateSlashCommandMenu(): void {
@@ -542,22 +542,22 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
}
formatBytes(bytes: number): string {
const units = [
'B',
'KB',
'MB',
'GB'
const unitKeys = [
'chat.units.b',
'chat.units.kb',
'chat.units.mb',
'chat.units.gb'
];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
while (size >= 1024 && unitIndex < unitKeys.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
return `${size.toFixed(1)} ${this.appI18n.instant(unitKeys[unitIndex])}`;
}
removePendingFile(file: File): void {
@@ -880,9 +880,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
const payloadPath = payload.path;
return annotateLocalFilePath(file, {
getPathForFile: payloadPath
? () => payloadPath
: this.electronBridge.getApi()?.getPathForFile
getPathForFile: payloadPath ? () => payloadPath : this.electronBridge.getApi()?.getPathForFile
});
}

View File

@@ -2,10 +2,10 @@
<div
class="overflow-hidden rounded-2xl border border-border bg-card/95 shadow-2xl shadow-black/30 backdrop-blur-xl"
role="listbox"
aria-label="Slash commands"
[attr.aria-label]="'chat.slashCommand.ariaLabel' | translate"
>
<div class="flex items-center justify-between border-b border-border/60 px-4 py-2">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Commands</span>
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">{{ 'chat.slashCommand.commands' | translate }}</span>
<span class="text-[11px] text-muted-foreground">{{ commands().length }}</span>
</div>

View File

@@ -1,4 +1,5 @@
import { CommonModule } from '@angular/common';
import { APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import {
Component,
ElementRef,
@@ -13,7 +14,7 @@ import type { SlashCommandEntry } from '../../../../../plugins';
@Component({
selector: 'app-chat-slash-command-menu',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, ...APP_TRANSLATE_IMPORTS],
templateUrl: './chat-slash-command-menu.component.html',
host: {
class: 'block'
@@ -42,9 +43,7 @@ export class ChatSlashCommandMenuComponent {
}
usage(entry: SlashCommandEntry): string {
return (entry.contribution.options ?? [])
.map((option) => this.formatOption(option))
.join(' ');
return (entry.contribution.options ?? []).map((option) => this.formatOption(option)).join(' ');
}
pick(entry: SlashCommandEntry): void {

View File

@@ -18,14 +18,17 @@ describe('composer-media-menu.rules', () => {
describe('buildComposerMediaMenuOptions', () => {
it('includes attachment, gif, and emoji when all are available', () => {
expect(buildComposerMediaMenuOptions(true, true)).toEqual([
{ action: 'attachment', label: 'Attach files' },
{ action: 'gif', label: 'GIF' },
{ action: 'emoji', label: 'Emoji' }
{ action: 'attachment', labelKey: 'chat.mediaMenu.attachFiles' },
{ action: 'gif', labelKey: 'chat.mediaMenu.gif' },
{ action: 'emoji', labelKey: 'chat.mediaMenu.emoji' }
]);
});
it('omits attachment when the picker is unavailable', () => {
expect(buildComposerMediaMenuOptions(false, true)).toEqual([{ action: 'gif', label: 'GIF' }, { action: 'emoji', label: 'Emoji' }]);
const gifOption = { action: 'gif' as const, labelKey: 'chat.mediaMenu.gif' };
const emojiOption = { action: 'emoji' as const, labelKey: 'chat.mediaMenu.emoji' };
expect(buildComposerMediaMenuOptions(false, true)).toEqual([gifOption, emojiOption]);
});
it('omits gif when klipy is disabled', () => {
@@ -35,7 +38,7 @@ describe('composer-media-menu.rules', () => {
});
it('always includes emoji even when attachment and gif are unavailable', () => {
expect(buildComposerMediaMenuOptions(false, false)).toEqual([{ action: 'emoji', label: 'Emoji' }]);
expect(buildComposerMediaMenuOptions(false, false)).toEqual([{ action: 'emoji', labelKey: 'chat.mediaMenu.emoji' }]);
});
});

View File

@@ -2,7 +2,7 @@ export type ComposerMediaMenuAction = 'attachment' | 'gif' | 'emoji';
export interface ComposerMediaMenuOption {
action: ComposerMediaMenuAction;
label: string;
labelKey: string;
}
export interface ComposerTextareaPaddingInput {
@@ -17,21 +17,18 @@ export function shouldMergeComposerMediaActions(isMobileViewport: boolean): bool
}
/** Build the actions shown in the merged mobile composer media menu. */
export function buildComposerMediaMenuOptions(
showAttachment: boolean,
klipyEnabled: boolean
): ComposerMediaMenuOption[] {
export function buildComposerMediaMenuOptions(showAttachment: boolean, klipyEnabled: boolean): ComposerMediaMenuOption[] {
const options: ComposerMediaMenuOption[] = [];
if (showAttachment) {
options.push({ action: 'attachment', label: 'Attach files' });
options.push({ action: 'attachment', labelKey: 'chat.mediaMenu.attachFiles' });
}
if (klipyEnabled) {
options.push({ action: 'gif', label: 'GIF' });
options.push({ action: 'gif', labelKey: 'chat.mediaMenu.gif' });
}
options.push({ action: 'emoji', label: 'Emoji' });
options.push({ action: 'emoji', labelKey: 'chat.mediaMenu.emoji' });
return options;
}

View File

@@ -18,7 +18,7 @@
@if (meta.imageUrl) {
<img
[src]="meta.imageUrl"
[alt]="meta.title || 'Link preview'"
[alt]="meta.title || ('chat.linkEmbed.previewAlt' | translate)"
class="hidden h-auto w-28 flex-shrink-0 object-cover sm:block"
loading="lazy"
referrerpolicy="no-referrer"

View File

@@ -3,6 +3,7 @@ import {
input,
output
} from '@angular/core';
import { APP_TRANSLATE_IMPORTS } from '../../../../../../../core/i18n';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideX } from '@ng-icons/lucide';
import { LinkMetadata } from '../../../../../../../shared-kernel';
@@ -10,7 +11,7 @@ import { LinkMetadata } from '../../../../../../../shared-kernel';
@Component({
selector: 'app-chat-link-embed',
standalone: true,
imports: [NgIcon],
imports: [NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [provideIcons({ lucideX })],
templateUrl: './chat-link-embed.component.html'
})

View File

@@ -57,7 +57,7 @@
<span class="font-medium">{{ reply.senderName }}</span>
<span class="max-w-[200px] truncate">{{ reply.isDeleted ? deletedMessageContent : formatMessagePreview(reply.content) }}</span>
} @else {
<span class="italic">Original message not found</span>
<span class="italic">{{ 'chat.message.originalNotFound' | translate }}</span>
}
</div>
}
@@ -70,7 +70,7 @@
>
<span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span>
@if (msg.editedAt && !msg.isDeleted) {
<span class="text-xs text-muted-foreground">(edited)</span>
<span class="text-xs text-muted-foreground">{{ 'chat.message.edited' | translate }}</span>
}
</div>
@@ -126,13 +126,13 @@
@if (missingPluginEmbed(); as missingEmbed) {
<article class="mt-2 max-w-lg rounded-md border border-border bg-secondary/30 p-3 text-sm text-muted-foreground">
Required plugin is not installed to view this content, visit the
{{ 'chat.message.missingPluginPrefix' | translate }}
<button
type="button"
class="font-semibold text-primary underline-offset-4 hover:underline"
(click)="openMissingPluginStore(missingEmbed)"
>
store</button
{{ 'chat.message.store' | translate }}</button
>.
</article>
}
@@ -213,7 +213,7 @@
class="chat-image-grid-retry"
(click)="retryImageRequest(gridImage)"
>
Retry
{{ 'chat.message.retry' | translate }}
</button>
</div>
}
@@ -222,7 +222,7 @@
<button
type="button"
class="chat-image-grid-cell chat-image-grid-overflow"
[attr.aria-label]="'View all ' + displayableImages().length + ' images'"
[attr.aria-label]="viewAllImagesAriaLabel(displayableImages().length)"
(click)="openImageGallery()"
>
<span class="chat-image-grid-overflow-label">{{ imageOverflowLabel(cell.hiddenCount) }}</span>
@@ -250,7 +250,7 @@
<button
(click)="openLightbox(att); $event.stopPropagation()"
class="grid h-7 w-7 place-items-center rounded-md bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="View full size"
[title]="'chat.message.viewFullSize' | translate"
>
<ng-icon
name="lucideExpand"
@@ -260,7 +260,7 @@
<button
(click)="downloadAttachment(att); $event.stopPropagation()"
class="grid h-7 w-7 place-items-center rounded-md bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Download"
[title]="'chat.message.download' | translate"
>
<ng-icon
name="lucideDownload"
@@ -316,7 +316,7 @@
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
>
{{ att.requestError || 'Waiting for image source...' }}
{{ att.requestError || ('chat.message.waitingForImage' | translate) }}
</div>
</div>
</div>
@@ -324,7 +324,7 @@
(click)="retryImageRequest(att)"
class="mt-2 w-full rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
>
Retry
{{ 'chat.message.retry' | translate }}
</button>
</div>
}
@@ -359,7 +359,7 @@
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
>
Cancel
{{ 'chat.message.cancel' | translate }}
</button>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
@@ -432,14 +432,14 @@
class="rounded bg-secondary px-2 py-1 text-xs text-foreground"
(click)="requestAttachment(att)"
>
{{ att.requestError ? 'Retry' : 'Request' }}
{{ attachmentRequestLabel(!!att.requestError) }}
</button>
} @else {
<button
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
>
Cancel
{{ 'chat.message.cancel' | translate }}
</button>
}
} @else {
@@ -452,7 +452,7 @@
name="lucideExternalLink"
class="h-3.5 w-3.5"
/>
Open
{{ 'chat.message.open' | translate }}
</button>
}
@if (att.canUseExperimentalPlayer) {
@@ -464,18 +464,18 @@
name="lucidePlay"
class="h-3.5 w-3.5"
/>
Play
{{ 'chat.message.play' | translate }}
</button>
}
<button
class="rounded bg-primary px-2 py-1 text-xs text-primary-foreground"
(click)="downloadAttachment(att)"
>
Download
{{ 'chat.message.download' | translate }}
</button>
}
} @else {
<div class="text-xs text-muted-foreground">Shared from your device</div>
<div class="text-xs text-muted-foreground">{{ 'chat.message.sharedFromDevice' | translate }}</div>
@if (att.canOpenExternally) {
<button
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
@@ -485,7 +485,7 @@
name="lucideExternalLink"
class="h-3.5 w-3.5"
/>
Open
{{ 'chat.message.open' | translate }}
</button>
}
@if (att.canUseExperimentalPlayer) {
@@ -497,7 +497,7 @@
name="lucidePlay"
class="h-3.5 w-3.5"
/>
Play
{{ 'chat.message.play' | translate }}
</button>
}
}
@@ -523,7 +523,7 @@
/>
} @loading {
<div class="mt-2 max-w-xl rounded-md border border-border bg-secondary/20 p-3 text-xs text-muted-foreground">
Loading experimental player...
{{ 'chat.message.loadingExperimentalPlayer' | translate }}
</div>
}
}
@@ -550,7 +550,7 @@
@if (isCustomEmojiToken(reaction.emoji) && customEmojiUrl(reaction.emoji); as reactionEmojiUrl) {
<img
[src]="reactionEmojiUrl"
alt="Custom emoji"
[alt]="'chat.message.customEmojiAlt' | translate"
class="h-6 w-6 object-contain"
/>
} @else {
@@ -628,13 +628,13 @@
<ng-template #mobileSheetTpl>
<app-bottom-sheet
title="Message"
ariaLabel="Message actions"
[title]="'chat.message.mobileSheetTitle' | translate"
[ariaLabel]="'chat.message.mobileSheetAria' | translate"
(dismissed)="closeMobileActions()"
>
<div class="flex flex-col py-1">
<div class="px-3 pb-2 pt-1">
<p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">React</p>
<p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{{ 'chat.message.react' | translate }}</p>
<div class="mt-2 grid grid-cols-8 gap-1">
@for (entry of emojiShortcuts(); track entry.key) {
<button
@@ -669,7 +669,7 @@
name="lucideReply"
class="h-5 w-5 text-muted-foreground"
/>
<span>Reply</span>
<span>{{ 'chat.message.reply' | translate }}</span>
</button>
<button
@@ -681,7 +681,7 @@
name="lucideCopy"
class="h-5 w-5 text-muted-foreground"
/>
<span>Copy message content</span>
<span>{{ 'chat.message.copyContent' | translate }}</span>
</button>
@if (isOwnMessage()) {
@@ -694,7 +694,7 @@
name="lucideEdit"
class="h-5 w-5 text-muted-foreground"
/>
<span>Edit</span>
<span>{{ 'chat.message.edit' | translate }}</span>
</button>
}
@@ -708,7 +708,7 @@
name="lucideTrash2"
class="h-5 w-5"
/>
<span>Delete</span>
<span>{{ 'chat.message.delete' | translate }}</span>
</button>
}
</div>

View File

@@ -41,17 +41,29 @@
}
}
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0.5em 0 0.25em;
color: hsl(var(--foreground));
font-weight: 700;
}
h1 { font-size: 1.5em; }
h2 { font-size: 1.3em; }
h3 { font-size: 1.15em; }
h1 {
font-size: 1.5em;
}
h2 {
font-size: 1.3em;
}
h3 {
font-size: 1.15em;
}
ul, ol {
ul,
ol {
margin: 0.25em 0;
padding-left: 1.5em;
}
@@ -143,7 +155,8 @@
font-size: 0.875em;
}
th, td {
th,
td {
border: 1px solid hsl(var(--border));
padding: 0.35em 0.75em;
text-align: left;

View File

@@ -52,11 +52,8 @@ import { ExperimentalMediaSettingsService } from '../../../../../experimental-me
import { ExperimentalVlcPlayerComponent } from '../../../../../experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component';
import { KlipyService } from '../../../../application/services/klipy.service';
import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules';
import {
DELETED_MESSAGE_CONTENT,
Message,
User
} from '../../../../../../shared-kernel';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import { Message, User } from '../../../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../../../theme';
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins';
@@ -142,7 +139,8 @@ interface MissingPluginEmbedFallback {
PluginRenderHostComponent,
ExperimentalVlcPlayerComponent,
ThemeNodeDirective,
BottomSheetComponent
BottomSheetComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -184,6 +182,7 @@ export class ChatMessageItemComponent implements OnDestroy {
private readonly viewport = inject(ViewportService);
private readonly overlay = inject(Overlay);
private readonly viewContainerRef = inject(ViewContainerRef);
private readonly appI18n = inject(AppI18nService);
private mobileSheetOverlayRef: OverlayRef | null = null;
private longPressTimer: number | null = null;
readonly isMobile = this.viewport.isMobile;
@@ -211,7 +210,7 @@ export class ChatMessageItemComponent implements OnDestroy {
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
readonly emojiShortcuts = this.customEmoji.shortcutEntries;
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
readonly deletedMessageContent = this.appI18n.instant('chat.message.deleted');
readonly pluginEmbedToken = computed(() => parsePluginEmbedToken(this.message().content));
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.pluginEmbedToken()));
readonly missingPluginEmbed = computed(() => this.resolveMissingPluginEmbed());
@@ -252,16 +251,10 @@ export class ChatMessageItemComponent implements OnDestroy {
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) => this.buildAttachmentViewModel(attachment));
});
readonly imageAttachments = computed(() =>
dedupeImageAttachmentsForDisplay(
this.attachmentViewModels().filter((attachment) => isImageAttachment(attachment))
)
);
readonly displayableImages = computed(() =>
this.imageAttachments().filter((attachment) => isInlineDisplayableImage(attachment))
);
readonly nonImageAttachments = computed(() =>
this.attachmentViewModels().filter((attachment) => !attachment.isImage)
dedupeImageAttachmentsForDisplay(this.attachmentViewModels().filter((attachment) => isImageAttachment(attachment)))
);
readonly displayableImages = computed(() => this.imageAttachments().filter((attachment) => isInlineDisplayableImage(attachment)));
readonly nonImageAttachments = computed(() => this.attachmentViewModels().filter((attachment) => !attachment.isImage));
readonly imageGridLayout = computed(() => buildChatMessageImageGridLayout(this.imageAttachments().length));
private readonly hydrateMessageImages = effect(() => {
const messageId = this.message().id;
@@ -315,7 +308,8 @@ export class ChatMessageItemComponent implements OnDestroy {
const payload = parseEmbedPayload(token.payloadText);
return this.pluginUi.embedRecords()
return this.pluginUi
.embedRecords()
.filter((record) => record.contribution.embedType === token.embedType)
.map((record) => ({
...record,
@@ -330,11 +324,12 @@ export class ChatMessageItemComponent implements OnDestroy {
return null;
}
const missingRequirement = this.pluginRequirements.missingRequiredRequirements()
.find((requirement) => requirement.pluginId === token.embedType || requirement.manifest?.id === token.embedType)
?? this.pluginRequirements.missingRequiredRequirements()
.find((requirement) => requirement.manifest?.capabilities?.includes('ui.embeds'))
?? this.pluginRequirements.missingRequiredRequirements()[0];
const missingRequirement =
this.pluginRequirements
.missingRequiredRequirements()
.find((requirement) => requirement.pluginId === token.embedType || requirement.manifest?.id === token.embedType) ??
this.pluginRequirements.missingRequiredRequirements().find((requirement) => requirement.manifest?.capabilities?.includes('ui.embeds')) ??
this.pluginRequirements.missingRequiredRequirements()[0];
const pluginName = missingRequirement?.manifest?.title ?? missingRequirement?.pluginId ?? pluginNameFromEmbedType(token.embedType);
return {
@@ -613,7 +608,7 @@ export class ChatMessageItemComponent implements OnDestroy {
return time;
if (dayDiff === 1)
return 'Yesterday ' + time;
return this.appI18n.instant('chat.message.timestampYesterday', { time });
if (dayDiff < 7) {
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
@@ -630,8 +625,7 @@ export class ChatMessageItemComponent implements OnDestroy {
}
requiresRichMarkdown(content: string): boolean {
return isSingleUnicodeEmojiOnlyMessage(content)
|| RICH_MARKDOWN_PATTERNS.some((pattern) => pattern.test(content));
return isSingleUnicodeEmojiOnlyMessage(content) || RICH_MARKDOWN_PATTERNS.some((pattern) => pattern.test(content));
}
formatMessagePreview(content: string): string {
@@ -639,44 +633,44 @@ export class ChatMessageItemComponent implements OnDestroy {
}
formatBytes(bytes: number): string {
const units = [
'B',
'KB',
'MB',
'GB'
const unitKeys = [
'chat.units.b',
'chat.units.kb',
'chat.units.mb',
'chat.units.gb'
];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
while (size >= 1024 && unitIndex < unitKeys.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
return `${size.toFixed(1)} ${this.appI18n.instant(unitKeys[unitIndex])}`;
}
formatSpeed(bytesPerSecond?: number): string {
if (!bytesPerSecond || bytesPerSecond <= 0)
return '0 B/s';
return `0 ${this.appI18n.instant('chat.units.bPerSec')}`;
const units = [
'B/s',
'KB/s',
'MB/s',
'GB/s'
const unitKeys = [
'chat.units.bPerSec',
'chat.units.kbPerSec',
'chat.units.mbPerSec',
'chat.units.gbPerSec'
];
let speed = bytesPerSecond;
let unitIndex = 0;
while (speed >= 1024 && unitIndex < units.length - 1) {
while (speed >= 1024 && unitIndex < unitKeys.length - 1) {
speed /= 1024;
unitIndex++;
}
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[unitIndex]}`;
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${this.appI18n.instant(unitKeys[unitIndex])}`;
}
isVideoAttachment(attachment: Attachment): boolean {
@@ -696,20 +690,20 @@ export class ChatMessageItemComponent implements OnDestroy {
return attachment.requestError;
if (this.requiresMediaDownloadAcceptance(attachment)) {
return this.isVideoAttachment(attachment)
? 'Large video. Accept the download to watch it in chat.'
: 'Large audio file. Accept the download to play it in chat.';
return this.isVideoAttachment(attachment) ? this.appI18n.instant('chat.message.largeVideo') : this.appI18n.instant('chat.message.largeAudio');
}
return this.isVideoAttachment(attachment) ? 'Waiting for video source...' : 'Waiting for audio source...';
return this.isVideoAttachment(attachment)
? this.appI18n.instant('chat.message.waitingForVideo')
: this.appI18n.instant('chat.message.waitingForAudio');
}
getMediaAttachmentActionLabel(attachment: Attachment): string {
if (this.requiresMediaDownloadAcceptance(attachment)) {
return attachment.requestError ? 'Retry download' : 'Accept download';
return attachment.requestError ? this.appI18n.instant('chat.message.retryDownload') : this.appI18n.instant('chat.message.acceptDownload');
}
return attachment.requestError ? 'Retry' : 'Request';
return attachment.requestError ? this.appI18n.instant('chat.message.retry') : this.appI18n.instant('chat.message.request');
}
isUploader(attachment: Attachment): boolean {
@@ -793,6 +787,14 @@ export class ChatMessageItemComponent implements OnDestroy {
return formatChatMessageImageOverflowLabel(hiddenCount);
}
viewAllImagesAriaLabel(count: number): string {
return this.appI18n.instant('chat.message.viewAllImages', { count });
}
attachmentRequestLabel(hasError: boolean): string {
return hasError ? this.appI18n.instant('chat.message.retry') : this.appI18n.instant('chat.message.request');
}
openImageContextMenu(event: MouseEvent, attachment: Attachment): void {
event.preventDefault();
event.stopPropagation();
@@ -835,13 +837,13 @@ export class ChatMessageItemComponent implements OnDestroy {
const isRawAudio = this.isAudioAttachment(attachment);
const isRawPlayableMedia = isRawVideo || isRawAudio;
const isNativePlayableMedia = this.canPlayMediaType(attachment.mime);
const shouldUseDefaultFileInterface = isRawPlayableMedia &&
(!isNativePlayableMedia ||
(this.platform.isBrowser && attachment.size > MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES));
const shouldUseDefaultFileInterface =
isRawPlayableMedia && (!isNativePlayableMedia || (this.platform.isBrowser && attachment.size > MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES));
const isVideo = isRawVideo && !shouldUseDefaultFileInterface;
const isAudio = isRawAudio && !shouldUseDefaultFileInterface;
const requiresMediaDownloadAcceptance = (isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
const canUseExperimentalPlayer = this.experimentalMedia.vlcJsPlaybackEnabled() &&
const canUseExperimentalPlayer =
this.experimentalMedia.vlcJsPlaybackEnabled() &&
shouldUseDefaultFileInterface &&
isRawPlayableMedia &&
attachment.available &&
@@ -857,20 +859,20 @@ export class ChatMessageItemComponent implements OnDestroy {
isVideo,
mediaActionLabel: requiresMediaDownloadAcceptance
? attachment.requestError
? 'Retry download'
: 'Accept download'
? this.appI18n.instant('chat.message.retryDownload')
: this.appI18n.instant('chat.message.acceptDownload')
: attachment.requestError
? 'Retry'
: 'Request',
? this.appI18n.instant('chat.message.retry')
: this.appI18n.instant('chat.message.request'),
mediaStatusText: attachment.requestError
? attachment.requestError
: requiresMediaDownloadAcceptance
? isVideo
? 'Large video. Accept the download to watch it in chat.'
: 'Large audio file. Accept the download to play it in chat.'
? this.appI18n.instant('chat.message.largeVideo')
: this.appI18n.instant('chat.message.largeAudio')
: isVideo
? 'Waiting for video source...'
: 'Waiting for audio source...',
? this.appI18n.instant('chat.message.waitingForVideo')
: this.appI18n.instant('chat.message.waitingForAudio'),
progressPercent: attachment.size > 0 ? ((attachment.receivedBytes || 0) * 100) / attachment.size : 0
};
}
@@ -919,7 +921,8 @@ function parsePluginEmbedToken(content: string): PluginEmbedToken | null {
function pluginNameFromEmbedType(embedType: string): string {
const parts = embedType.split(/[.:_-]/).filter(Boolean);
const pluginParts = parts.length > 2 ? parts.slice(0, -1) : parts;
const label = pluginParts.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
const label = pluginParts
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
.trim();

View File

@@ -29,7 +29,7 @@
>
<img
[appChatImageProxyFallback]="node.url"
[alt]="getCustomEmojiAlt(node.alt) || 'Shared image'"
[alt]="getCustomEmojiAlt(node.alt) || ('chat.markdown.sharedImage' | translate)"
class="block object-contain"
[class.chat-custom-emoji-image]="isCustomEmojiDataUrl(node.url)"
[style.height]="isCustomEmojiDataUrl(node.url) ? (largeCustomEmoji() ? '46px' : '1.2em') : null"
@@ -50,7 +50,7 @@
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
{{ 'chat.markdown.klipy' | translate }}
</span>
}
</div>

View File

@@ -16,6 +16,7 @@ import {
isSpotifyUrl,
isYoutubeUrl
} from '../../../../../domain/rules/link-embed.rules';
import { APP_TRANSLATE_IMPORTS } from '../../../../../../../core/i18n';
import { ChatImageProxyFallbackDirective } from '../../../../chat-image-proxy-fallback.directive';
import { ChatSoundcloudEmbedComponent } from '../chat-soundcloud-embed/chat-soundcloud-embed.component';
import { ChatSpotifyEmbedComponent } from '../chat-spotify-embed/chat-spotify-embed.component';
@@ -50,8 +51,7 @@ const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
};
const KLIPY_MEDIA_URL_PATTERN = /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i;
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
const REMARK_PROCESSOR = unified()
.use(remarkParse)
const REMARK_PROCESSOR = unified().use(remarkParse)
.use(remarkGfm)
.use(remarkBreaks);
@@ -65,7 +65,8 @@ const REMARK_PROCESSOR = unified()
ChatImageProxyFallbackDirective,
ChatSpotifyEmbedComponent,
ChatSoundcloudEmbedComponent,
ChatYoutubeEmbedComponent
ChatYoutubeEmbedComponent,
...APP_TRANSLATE_IMPORTS
],
templateUrl: './chat-message-markdown.component.html'
})

View File

@@ -5,7 +5,7 @@
[style.height.px]="embedHeight()"
class="w-full border-0"
loading="lazy"
title="SoundCloud player"
[title]="'chat.embeds.soundcloudPlayer' | translate"
></iframe>
</div>
}

View File

@@ -5,11 +5,13 @@ import {
input
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { APP_TRANSLATE_IMPORTS } from '../../../../../../../core/i18n';
import { extractSoundcloudResource } from '../../../../../domain/rules/link-embed.rules';
@Component({
selector: 'app-chat-soundcloud-embed',
standalone: true,
imports: [...APP_TRANSLATE_IMPORTS],
templateUrl: './chat-soundcloud-embed.component.html'
})
export class ChatSoundcloudEmbedComponent {
@@ -17,7 +19,7 @@ export class ChatSoundcloudEmbedComponent {
readonly resource = computed(() => extractSoundcloudResource(this.url()));
readonly embedHeight = computed(() => this.resource()?.type === 'playlist' ? 352 : 166);
readonly embedHeight = computed(() => (this.resource()?.type === 'playlist' ? 352 : 166));
readonly embedUrl = computed(() => {
const resource = this.resource();

View File

@@ -5,7 +5,7 @@
[style.height.px]="embedHeight()"
class="w-full border-0"
loading="lazy"
title="Spotify player"
[title]="'chat.embeds.spotifyPlayer' | translate"
allowfullscreen
></iframe>
</div>

View File

@@ -5,11 +5,13 @@ import {
input
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { APP_TRANSLATE_IMPORTS } from '../../../../../../../core/i18n';
import { extractSpotifyResource } from '../../../../../domain/rules/link-embed.rules';
@Component({
selector: 'app-chat-spotify-embed',
standalone: true,
imports: [...APP_TRANSLATE_IMPORTS],
templateUrl: './chat-spotify-embed.component.html'
})
export class ChatSpotifyEmbedComponent {

View File

@@ -17,9 +17,7 @@ function resolveYoutubeClientOrigin(): string {
const origin = window.location.origin;
return /^https?:\/\//.test(origin)
? origin
: YOUTUBE_EMBED_FALLBACK_ORIGIN;
return /^https?:\/\//.test(origin) ? origin : YOUTUBE_EMBED_FALLBACK_ORIGIN;
}
@Component({
@@ -44,9 +42,7 @@ export class ChatYoutubeEmbedComponent {
embedUrl.searchParams.set('origin', clientOrigin);
embedUrl.searchParams.set('widget_referrer', clientOrigin);
return this.sanitizer.bypassSecurityTrustResourceUrl(
embedUrl.toString()
);
return this.sanitizer.bypassSecurityTrustResourceUrl(embedUrl.toString());
});
private readonly sanitizer = inject(DomSanitizer);

View File

@@ -8,13 +8,15 @@
@if (syncing() && !loading()) {
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
<div class="h-3 w-3 animate-spin rounded-full border-b-2 border-primary"></div>
<span>Syncing messages...</span>
<span>{{ 'chat.messageList.syncing' | translate }}</span>
</div>
}
@if (refreshLoading()) {
<div class="pointer-events-none sticky top-0 z-10 flex justify-center py-1">
<div class="rounded-full border border-border bg-background/85 px-2.5 py-1 text-[11px] text-muted-foreground shadow-sm">Loading...</div>
<div class="rounded-full border border-border bg-background/85 px-2.5 py-1 text-[11px] text-muted-foreground shadow-sm">
{{ 'chat.messageList.loading' | translate }}
</div>
</div>
}
@@ -24,8 +26,8 @@
</div>
} @else if (messages().length === 0) {
<div class="flex h-full flex-col items-center justify-center text-muted-foreground">
<p class="text-lg">No messages yet</p>
<p class="text-sm">Be the first to say something!</p>
<p class="text-lg">{{ 'chat.messageList.emptyTitle' | translate }}</p>
<p class="text-sm">{{ 'chat.messageList.emptySubtitle' | translate }}</p>
</div>
} @else {
<div
@@ -42,7 +44,7 @@
(click)="loadMore()"
class="rounded-md px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
>
Load older messages
{{ 'chat.messageList.loadOlder' | translate }}
</button>
}
</div>
@@ -90,13 +92,13 @@
appThemeNode="chatNewMessagesBar"
class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow"
>
<span class="text-sm text-muted-foreground">New messages</span>
<span class="text-sm text-muted-foreground">{{ 'chat.messageList.newMessages' | translate }}</span>
<button
type="button"
(click)="readLatest()"
class="rounded bg-primary px-2 py-1 text-sm text-primary-foreground hover:bg-primary/90"
>
Read latest
{{ 'chat.messageList.readLatest' | translate }}
</button>
</div>
</div>

View File

@@ -34,6 +34,7 @@ import {
ChatMessageReplyEvent
} from '../../models/chat-messages.model';
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
import { APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import { ThemeNodeDirective } from '../../../../../theme';
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
@@ -53,7 +54,8 @@ declare global {
imports: [
CommonModule,
ChatMessageItemComponent,
ThemeNodeDirective
ThemeNodeDirective,
...APP_TRANSLATE_IMPORTS
],
templateUrl: './chat-message-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -378,10 +380,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
if (element.scrollTop < 150 && !this.loadingMore()) {
const canFetchOlderFromDb =
!this.hasMoreMessages()
&& !this.conversationExhausted()
&& !this.loadingOlder()
&& this.channelMessages().length > 0;
!this.hasMoreMessages() && !this.conversationExhausted() && !this.loadingOlder() && this.channelMessages().length > 0;
if (this.hasMoreMessages() || canFetchOlderFromDb) {
this.loadMore();

View File

@@ -2,7 +2,7 @@
@if (galleryAttachments()) {
<app-modal-backdrop
[zIndex]="100"
ariaLabel="Close image gallery"
[ariaLabel]="'chat.overlays.closeGalleryAria' | translate"
(dismissed)="closeGallery()"
/>
<div class="pointer-events-none fixed inset-0 z-[101] flex items-center justify-center p-4">
@@ -12,15 +12,17 @@
>
<div class="flex items-center justify-between border-b border-border px-4 py-3">
<div>
<h2 class="text-sm font-semibold text-foreground">View images</h2>
<p class="text-xs text-muted-foreground">{{ galleryAttachments()!.length }} images</p>
<h2 class="text-sm font-semibold text-foreground">{{ 'chat.overlays.viewImages' | translate }}</h2>
<p class="text-xs text-muted-foreground">
{{ 'chat.overlays.imageCount' | translate: { count: galleryAttachments()!.length } }}
</p>
</div>
<button
type="button"
(click)="closeGallery()"
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Close"
aria-label="Close image gallery"
[title]="'chat.overlays.close' | translate"
[attr.aria-label]="'chat.overlays.closeGalleryAria' | translate"
>
<ng-icon
name="lucideX"
@@ -34,7 +36,7 @@
<button
type="button"
class="group/gallery relative aspect-square overflow-hidden rounded-md bg-secondary/40"
[attr.aria-label]="'Open ' + attachment.filename"
[attr.aria-label]="openImageAriaLabel(attachment.filename)"
(click)="openGalleryImage(attachment)"
(contextmenu)="openImageContextMenu($event, attachment)"
>
@@ -55,7 +57,7 @@
@if (lightboxAttachment()) {
<app-modal-backdrop
[zIndex]="109"
ariaLabel="Close image preview"
[ariaLabel]="'chat.overlays.closePreviewAria' | translate"
(dismissed)="closeLightbox()"
/>
<div class="pointer-events-none fixed inset-0 z-[110] flex items-center justify-center p-4">
@@ -70,8 +72,8 @@
type="button"
(click)="showPreviousLightboxImage()"
class="lightbox-chrome absolute left-0 top-1/2 z-10 grid h-10 w-10 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Previous image"
aria-label="Previous image"
[title]="'chat.overlays.previousImage' | translate"
[attr.aria-label]="'chat.overlays.previousImage' | translate"
>
<ng-icon
name="lucideChevronLeft"
@@ -92,8 +94,8 @@
type="button"
(click)="showNextLightboxImage()"
class="lightbox-chrome absolute right-0 top-1/2 z-10 grid h-10 w-10 -translate-y-1/2 translate-x-1/2 place-items-center rounded-full bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Next image"
aria-label="Next image"
[title]="'chat.overlays.nextImage' | translate"
[attr.aria-label]="'chat.overlays.nextImage' | translate"
>
<ng-icon
name="lucideChevronRight"
@@ -107,7 +109,7 @@
type="button"
(click)="downloadAttachment(lightboxAttachment()!)"
class="grid h-9 w-9 place-items-center rounded-lg bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Download"
[title]="'chat.overlays.download' | translate"
>
<ng-icon
name="lucideDownload"
@@ -118,8 +120,8 @@
type="button"
(click)="closeLightbox()"
class="grid h-9 w-9 place-items-center rounded-lg bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Close"
aria-label="Close image preview"
[title]="'chat.overlays.close' | translate"
[attr.aria-label]="'chat.overlays.closePreviewAria' | translate"
>
<ng-icon
name="lucideX"
@@ -156,7 +158,7 @@
name="lucideCopy"
class="h-4 w-4 text-muted-foreground"
/>
Copy Image
{{ 'chat.overlays.copyImage' | translate }}
</button>
<button
(click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()"
@@ -166,7 +168,7 @@
name="lucideDownload"
class="h-4 w-4 text-muted-foreground"
/>
Save Image
{{ 'chat.overlays.saveImage' | translate }}
</button>
</app-context-menu>
}

View File

@@ -5,6 +5,7 @@ import {
OnDestroy,
computed,
effect,
inject,
input,
output,
signal
@@ -19,6 +20,7 @@ import {
} from '@ng-icons/lucide';
import { Attachment } from '../../../../../attachment';
import { canStepLightbox } from '../../../../domain/rules/chat-message-lightbox.rules';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import { ContextMenuComponent, ModalBackdropComponent } from '../../../../../../shared';
import {
ChatLightboxState,
@@ -34,7 +36,8 @@ import {
CommonModule,
NgIcon,
ContextMenuComponent,
ModalBackdropComponent
ModalBackdropComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -106,6 +109,7 @@ export class ChatMessageOverlaysComponent implements OnDestroy {
return `${state.index + 1} / ${state.attachments.length}`;
});
private readonly appI18n = inject(AppI18nService);
private readonly LIGHTBOX_CONTROLS_IDLE_MS = 2200;
private lightboxControlsHideTimer: ReturnType<typeof setTimeout> | null = null;
@@ -219,22 +223,26 @@ export class ChatMessageOverlaysComponent implements OnDestroy {
}
formatBytes(bytes: number): string {
const units = [
'B',
'KB',
'MB',
'GB'
const unitKeys = [
'chat.units.b',
'chat.units.kb',
'chat.units.mb',
'chat.units.gb'
];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
while (size >= 1024 && unitIndex < unitKeys.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
return `${size.toFixed(1)} ${this.appI18n.instant(unitKeys[unitIndex])}`;
}
openImageAriaLabel(filename: string): string {
return this.appI18n.instant('chat.overlays.openImage', { filename });
}
ngOnDestroy(): void {

View File

@@ -19,13 +19,9 @@ export class ChatMarkdownService {
const selected = content.slice(start, end);
const after = content.slice(end);
const newText = `${before}${token}${selected}${token}${after}`;
const cursor = selected.length === 0
? before.length + token.length
: before.length + token.length + selected.length + token.length;
const cursor = selected.length === 0 ? before.length + token.length : before.length + token.length + selected.length + token.length;
return { text: newText,
selectionStart: cursor,
selectionEnd: cursor };
return { text: newText, selectionStart: cursor, selectionEnd: cursor };
}
applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult {
@@ -33,14 +29,12 @@ export class ChatMarkdownService {
const before = content.slice(0, start);
const selected = content.slice(start, end);
const after = content.slice(end);
const lines = selected.split('\n').map(line => `${prefix}${line}`);
const lines = selected.split('\n').map((line) => `${prefix}${line}`);
const newSelected = lines.join('\n');
const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyHeading(content: string, selection: SelectionRange, level: number): ComposeResult {
@@ -55,9 +49,7 @@ export class ChatMarkdownService {
const text = `${before}${block}${after}`;
const cursor = before.length + block.length - (needsTrailingNewline ? 1 : 0);
return { text,
selectionStart: cursor,
selectionEnd: cursor };
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyOrderedList(content: string, selection: SelectionRange): ComposeResult {
@@ -70,9 +62,7 @@ export class ChatMarkdownService {
const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyCodeBlock(content: string, selection: SelectionRange): ComposeResult {
@@ -80,17 +70,11 @@ export class ChatMarkdownService {
const before = content.slice(0, start);
const selected = content.slice(start, end);
const after = content.slice(end);
const fenced = selected.length === 0
? '```\n\n```\n\n'
: `\`\`\`\n${selected}\n\`\`\`\n\n`;
const fenced = selected.length === 0 ? '```\n\n```\n\n' : `\`\`\`\n${selected}\n\`\`\`\n\n`;
const text = `${before}${fenced}${after}`;
const cursor = selected.length === 0
? before.length + 4
: before.length + fenced.length;
const cursor = selected.length === 0 ? before.length + 4 : before.length + fenced.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyLink(content: string, selection: SelectionRange): ComposeResult {
@@ -100,13 +84,9 @@ export class ChatMarkdownService {
const after = content.slice(end);
const link = `[${selected}]()`;
const text = `${before}${link}${after}`;
const cursor = selected.length === 0
? before.length + 1
: before.length + 1 + selected.length + 2;
const cursor = selected.length === 0 ? before.length + 1 : before.length + 1 + selected.length + 2;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyImage(content: string, selection: SelectionRange): ComposeResult {
@@ -116,13 +96,9 @@ export class ChatMarkdownService {
const after = content.slice(end);
const img = `![${selected}]()`;
const text = `${before}${img}${after}`;
const cursor = selected.length === 0
? before.length + 2
: before.length + 2 + selected.length + 2;
const cursor = selected.length === 0 ? before.length + 2 : before.length + 2 + selected.length + 2;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult {
@@ -133,13 +109,11 @@ export class ChatMarkdownService {
const text = `${before}${hr}${after}`;
const cursor = before.length + hr.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
return { text, selectionStart: cursor, selectionEnd: cursor };
}
appendImageMarkdown(content: string): string {
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/gi;
const urls = new Set<string>();
let match: RegExpExecArray | null;

View File

@@ -1,16 +1,16 @@
<div
class="flex h-[min(70vh,42rem)] w-full flex-col overflow-hidden rounded-[1.65rem] border border-border/80 shadow-2xl ring-1 ring-white/5"
role="dialog"
aria-label="KLIPY GIF picker"
[attr.aria-label]="'chat.gifPicker.ariaLabel' | translate"
style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)"
>
@if (!isMobile()) {
<div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">{{ 'chat.gifPicker.klipy' | translate }}</div>
<h3 class="mt-1 text-lg font-semibold text-foreground">{{ 'chat.gifPicker.chooseGif' | translate }}</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
{{ searchQuery.trim() ? ('chat.gifPicker.searchResults' | translate) : ('chat.gifPicker.trending' | translate) }}
</p>
</div>
@@ -18,7 +18,7 @@
type="button"
(click)="close()"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary/80 hover:text-foreground"
aria-label="Close GIF picker"
[attr.aria-label]="'chat.gifPicker.closeAria' | translate"
>
<ng-icon
name="lucideX"
@@ -39,7 +39,7 @@
type="text"
[ngModel]="searchQuery"
(ngModelChange)="onSearchQueryChanged($event)"
[placeholder]="isMobile() ? 'Search KLIPY and add a gif to the chat' : 'Search KLIPY'"
[placeholder]="isMobile() ? ('chat.gifPicker.searchMobile' | translate) : ('chat.gifPicker.searchDesktop' | translate)"
class="relative z-0 w-full rounded-xl border border-border/80 bg-background/70 px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</label>
@@ -56,7 +56,7 @@
(click)="retry()"
class="rounded-lg bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
Retry
{{ 'chat.gifPicker.retry' | translate }}
</button>
</div>
}
@@ -64,7 +64,7 @@
@if (loading() && results().length === 0) {
<div class="flex h-full min-h-56 flex-col items-center justify-center gap-3 text-muted-foreground">
<span class="h-6 w-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
<p class="text-sm">Loading GIFs from KLIPY...</p>
<p class="text-sm">{{ 'chat.gifPicker.loading' | translate }}</p>
</div>
} @else if (results().length === 0) {
<div
@@ -77,8 +77,8 @@
/>
</div>
<div>
<p class="text-sm font-medium text-foreground">No GIFs found</p>
<p class="mt-1 text-sm">Try another search term or clear the search to browse trending GIFs.</p>
<p class="text-sm font-medium text-foreground">{{ 'chat.gifPicker.noGifsFound' | translate }}</p>
<p class="mt-1 text-sm">{{ 'chat.gifPicker.noGifsHint' | translate }}</p>
</div>
</div>
} @else {
@@ -101,22 +101,22 @@
<img
[appChatImageProxyFallback]="gif.previewUrl || gif.url"
[signalSource]="signalSource()"
[alt]="gif.title || 'KLIPY GIF'"
[alt]="gif.title || ('chat.gifPicker.klipyGif' | translate)"
class="h-full w-full object-contain p-1.5 transition-transform duration-200 group-hover:scale-[1.03]"
loading="lazy"
/>
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
{{ 'chat.gifPicker.klipy' | translate }}
</span>
</div>
@if (!isMobile()) {
<div class="px-3 py-2">
<p class="truncate text-xs font-medium text-foreground">
{{ gif.title || 'KLIPY GIF' }}
{{ gif.title || ('chat.gifPicker.klipyGif' | translate) }}
</p>
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">{{ 'chat.gifPicker.clickToSelect' | translate }}</p>
</div>
}
</button>
@@ -130,7 +130,7 @@
(click)="loadMore()"
[disabled]="loading()"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border/80 bg-background/60 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
[attr.aria-label]="loading() ? 'Loading more GIFs' : 'Load more GIFs'"
[attr.aria-label]="loading() ? ('chat.gifPicker.loadingMoreAria' | translate) : ('chat.gifPicker.loadMoreAria' | translate)"
>
@if (loading()) {
<span class="h-4 w-4 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
@@ -148,7 +148,7 @@
@if (!isMobile()) {
<div class="flex items-center justify-between gap-4 border-t border-border/70 bg-secondary/10 px-5 py-4">
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p>
<p class="text-xs text-muted-foreground">{{ 'chat.gifPicker.footer' | translate }}</p>
@if (hasNext()) {
<button
@@ -157,7 +157,7 @@
[disabled]="loading()"
class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{{ loading() ? 'Loading...' : 'Load more' }}
{{ loading() ? ('chat.gifPicker.loadingMore' | translate) : ('chat.gifPicker.loadMore' | translate) }}
</button>
}
</div>

View File

@@ -25,6 +25,7 @@ import {
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import type { RoomSignalSourceInput } from '../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ViewportService } from '../../../../core/platform';
const KLIPY_CARD_MIN_WIDTH = 140;
@@ -40,7 +41,8 @@ const KLIPY_CARD_FALLBACK_SIZE = 160;
CommonModule,
FormsModule,
NgIcon,
ChatImageProxyFallbackDirective
ChatImageProxyFallbackDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -62,6 +64,7 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
private readonly klipy = inject(KlipyService);
private readonly viewport = inject(ViewportService);
private readonly appI18n = inject(AppI18nService);
readonly isMobile = this.viewport.isMobile;
private currentPage = 1;
private searchTimer: ReturnType<typeof setTimeout> | null = null;
@@ -136,29 +139,19 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
this.errorMessage.set('');
try {
const response = await firstValueFrom(
this.klipy.searchGifs(this.searchQuery, this.currentPage, undefined, this.signalSource())
);
const response = await firstValueFrom(this.klipy.searchGifs(this.searchQuery, this.currentPage, undefined, this.signalSource()));
if (requestId !== this.requestId)
return;
this.results.set(
reset
? response.results
: this.mergeResults(this.results(), response.results)
);
this.results.set(reset ? response.results : this.mergeResults(this.results(), response.results));
this.hasNext.set(response.hasNext);
} catch (error) {
if (requestId !== this.requestId)
return;
this.errorMessage.set(
error instanceof Error
? error.message
: 'Failed to load GIFs from KLIPY.'
);
this.errorMessage.set(error instanceof Error ? error.message : this.appI18n.instant('chat.gifPicker.loadFailed'));
if (reset) {
this.results.set([]);
@@ -202,17 +195,9 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
};
}
const maxScale = Math.min(
KLIPY_CARD_MAX_WIDTH / gif.width,
KLIPY_CARD_MAX_HEIGHT / gif.height
);
const minScale = Math.max(
KLIPY_CARD_MIN_WIDTH / gif.width,
KLIPY_CARD_MIN_HEIGHT / gif.height
);
const scale = minScale <= maxScale
? Math.min(maxScale, Math.max(minScale, 1))
: maxScale;
const maxScale = Math.min(KLIPY_CARD_MAX_WIDTH / gif.width, KLIPY_CARD_MAX_HEIGHT / gif.height);
const minScale = Math.max(KLIPY_CARD_MIN_WIDTH / gif.width, KLIPY_CARD_MIN_HEIGHT / gif.height);
const scale = minScale <= maxScale ? Math.min(maxScale, Math.max(minScale, 1)) : maxScale;
const scaledWidth = Math.round(gif.width * scale);
const scaledHeight = Math.round(gif.height * scale);

View File

@@ -1,12 +1,7 @@
@if (typingDisplay().length > 0) {
@if (typingLabel()) {
<div class="px-4 py-2 backdrop-blur-sm bg-background/60">
<span class="inline-block px-3 py-1 rounded-full text-sm text-muted-foreground">
{{ typingDisplay().join(', ') }}
@if (typingOthersCount() > 0) {
and {{ typingOthersCount() }} others are typing...
} @else {
{{ typingDisplay().length === 1 ? 'is' : 'are' }} typing...
}
{{ typingLabel() }}
</span>
</div>
}

View File

@@ -1,11 +1,13 @@
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, */
import {
Component,
computed,
inject,
signal,
DestroyRef,
effect
} from '@angular/core';
import { AppI18nService } from '../../../../core/i18n';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { RealtimeSessionFacade } from '../../../../core/realtime';
@@ -36,8 +38,8 @@ interface TypingSignalingMessage {
standalone: true,
templateUrl: './typing-indicator.component.html',
host: {
'class': 'block',
'style': 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));'
class: 'block',
style: 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));'
}
})
export class TypingIndicatorComponent {
@@ -50,23 +52,44 @@ export class TypingIndicatorComponent {
typingDisplay = signal<string[]>([]);
typingOthersCount = signal<number>(0);
private readonly appI18n = inject(AppI18nService);
readonly typingLabel = computed(() => {
const names = this.typingDisplay();
const others = this.typingOthersCount();
if (names.length === 0) {
return '';
}
const namesText = names.join(', ');
if (others > 0) {
return this.appI18n.instant('chat.typing.andOthers', {
names: namesText,
count: others
});
}
if (names.length === 1) {
return this.appI18n.instant('chat.typing.one', { names: namesText });
}
return this.appI18n.instant('chat.typing.many', { names: namesText });
});
constructor() {
const webrtc = inject(RealtimeSessionFacade);
const destroyRef = inject(DestroyRef);
const typing$ = webrtc.onSignalingMessage.pipe(
filter((msg): msg is TypingSignalingMessage =>
msg?.type === 'user_typing' &&
typeof msg.displayName === 'string' &&
typeof msg.oderId === 'string' &&
typeof msg.serverId === 'string'
filter(
(msg): msg is TypingSignalingMessage =>
msg?.type === 'user_typing' && typeof msg.displayName === 'string' && typeof msg.oderId === 'string' && typeof msg.serverId === 'string'
),
filter((msg) => msg.serverId === this.currentRoom()?.id),
tap((msg) => {
const now = Date.now();
const channelId = typeof msg.channelId === 'string' && msg.channelId.trim()
? msg.channelId.trim()
: 'general';
const channelId = typeof msg.channelId === 'string' && msg.channelId.trim() ? msg.channelId.trim() : 'general';
const typingKey = `${channelId}:${msg.oderId}`;
if (msg.isTyping === false) {

View File

@@ -1,7 +1,9 @@
<!-- Header -->
<div class="p-4 border-b border-border">
<h3 class="font-semibold text-foreground">Members</h3>
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
<h3 class="font-semibold text-foreground">{{ 'chat.userList.members' | translate }}</h3>
<p class="text-xs text-muted-foreground">
{{ 'chat.userList.onlineInVoice' | translate: { online: onlineUsers().length, inVoice: voiceUsers().length } }}
</p>
@if (voiceUsers().length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (v of voiceUsers(); track v.id) {
@@ -62,7 +64,7 @@
[class.text-red-500]="user.status === 'busy'"
[class.text-muted-foreground]="user.status === 'offline'"
>
{{ user.status === 'busy' ? 'Do Not Disturb' : (user.status | titlecase) }}
{{ statusLabel(user.status) | translate }}
</span>
}
</div>
@@ -73,7 +75,7 @@
type="button"
class="grid h-7 w-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-card hover:text-foreground"
[class.hidden]="isCurrentUser(user)"
title="Message"
[title]="'chat.userList.message' | translate"
(click)="$event.stopPropagation(); openDirectMessage(user)"
>
<ng-icon
@@ -127,13 +129,13 @@
name="lucideVolume2"
class="w-4 h-4"
/>
<span>Unmute</span>
<span>{{ 'chat.userList.unmute' | translate }}</span>
} @else {
<ng-icon
name="lucideVolumeX"
class="w-4 h-4"
/>
<span>Mute</span>
<span>{{ 'chat.userList.mute' | translate }}</span>
}
</button>
}
@@ -146,7 +148,7 @@
name="lucideUserX"
class="w-4 h-4"
/>
<span>Kick</span>
<span>{{ 'chat.userList.kick' | translate }}</span>
</button>
<button
type="button"
@@ -157,7 +159,7 @@
name="lucideBan"
class="w-4 h-4"
/>
<span>Ban</span>
<span>{{ 'chat.userList.ban' | translate }}</span>
</button>
</div>
}
@@ -165,35 +167,34 @@
}
@if (onlineUsers().length === 0) {
<div class="text-center py-8 text-muted-foreground text-sm">No users online</div>
<div class="text-center py-8 text-muted-foreground text-sm">{{ 'chat.userList.noUsersOnline' | translate }}</div>
}
</div>
<!-- Ban Dialog -->
@if (showBanDialog()) {
<app-confirm-dialog
title="Ban User"
confirmLabel="Ban User"
[title]="'chat.userList.banUserTitle' | translate"
[confirmLabel]="'chat.userList.banUserConfirm' | translate"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="confirmBan()"
(cancelled)="closeBanDialog()"
>
<p class="mb-4">
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span
>?
{{ 'chat.userList.banConfirmMessage' | translate: { name: userToBan()?.displayName } }}
</p>
<div class="mb-4">
<label
for="ban-reason-input"
class="block text-sm font-medium text-foreground mb-1"
>Reason (optional)</label
>{{ 'chat.userList.banReasonLabel' | translate }}</label
>
<input
type="text"
[(ngModel)]="banReason"
placeholder="Enter ban reason..."
[placeholder]="'chat.userList.banReasonPlaceholder' | translate"
id="ban-reason-input"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
@@ -203,18 +204,18 @@
<label
for="ban-duration-select"
class="block text-sm font-medium text-foreground mb-1"
>Duration</label
>{{ 'chat.userList.banDurationLabel' | translate }}</label
>
<select
[(ngModel)]="banDuration"
id="ban-duration-select"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="3600000">1 hour</option>
<option value="86400000">1 day</option>
<option value="604800000">1 week</option>
<option value="2592000000">30 days</option>
<option value="0">Permanent</option>
<option value="3600000">{{ 'chat.userList.banDuration1Hour' | translate }}</option>
<option value="86400000">{{ 'chat.userList.banDuration1Day' | translate }}</option>
<option value="604800000">{{ 'chat.userList.banDuration1Week' | translate }}</option>
<option value="2592000000">{{ 'chat.userList.banDuration30Days' | translate }}</option>
<option value="0">{{ 'chat.userList.banDurationPermanent' | translate }}</option>
</select>
</div>
</app-confirm-dialog>

View File

@@ -32,6 +32,7 @@ import {
} from '../../../../store/users/users.selectors';
import { User } from '../../../../shared-kernel';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { DirectMessageService } from '../../../direct-message';
@Component({
@@ -42,7 +43,8 @@ import { DirectMessageService } from '../../../direct-message';
FormsModule,
NgIcon,
UserAvatarComponent,
ConfirmDialogComponent
ConfirmDialogComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -80,6 +82,19 @@ export class UserListComponent {
banReason = '';
banDuration = '86400000'; // Default 1 day
statusLabel(status: User['status']): string {
switch (status) {
case 'busy':
return 'chat.userList.statusBusy';
case 'away':
return 'chat.userList.statusAway';
case 'offline':
return 'chat.userList.statusOffline';
default:
return 'chat.userList.statusAway';
}
}
/** Toggle the context menu for a specific user. */
toggleUserMenu(userId: string): void {
this.showUserMenu.update((current) => (current === userId ? null : userId));

View File

@@ -23,8 +23,8 @@
<button
type="button"
class="grid h-10 w-10 place-items-center rounded text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Open emoji selector"
title="Open emoji selector"
aria-label="{{ 'emoji.picker.openAria' | translate }}"
title="{{ 'emoji.picker.openAria' | translate }}"
(click)="openModal()"
>
<ng-icon
@@ -48,7 +48,7 @@
>
@if (!inline()) {
<div class="mb-2 flex items-center justify-between gap-2">
<p class="text-sm font-semibold text-foreground">Emoji</p>
<p class="text-sm font-semibold text-foreground">{{ 'emoji.picker.title' | translate }}</p>
@if (compact()) {
<button
type="button"
@@ -73,8 +73,8 @@
<input
type="search"
class="w-full rounded-md border border-border bg-background py-2 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Search emoji"
aria-label="Search emoji"
placeholder="{{ 'emoji.picker.searchPlaceholder' | translate }}"
aria-label="{{ 'emoji.picker.searchAria' | translate }}"
[value]="searchQuery()"
(input)="onSearchInput($event)"
/>
@@ -87,7 +87,7 @@
name="lucideUpload"
class="h-4 w-4"
/>
<span>{{ uploading() ? 'Uploading...' : 'Upload emoji' }}</span>
<span>{{ uploading() ? ('emoji.picker.uploading' | translate) : ('emoji.picker.upload' | translate) }}</span>
<input
type="file"
class="hidden"
@@ -105,7 +105,7 @@
@if (showEmptySearchState()) {
<div class="mb-3 rounded-md border border-border/70 bg-secondary/20 px-3 py-4 text-center text-xs text-muted-foreground">
No emoji match your search.
{{ 'emoji.picker.emptySearch' | translate }}
</div>
}

View File

@@ -8,6 +8,7 @@ import {
} from '@angular/core';
import { CustomEmoji } from '../../../../shared-kernel';
import { CustomEmojiService } from '../../application/custom-emoji.service';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import { CustomEmojiPickerComponent } from './custom-emoji-picker.component';
const savedEmojis: CustomEmoji[] = [
@@ -67,6 +68,7 @@ describe('CustomEmojiPickerComponent', () => {
): CustomEmojiPickerComponent {
const injector = Injector.create({
providers: [
...provideAppI18nForTests(),
CustomEmojiPickerComponent,
{
provide: ChangeDetectionScheduler,
@@ -92,6 +94,7 @@ describe('CustomEmojiPickerComponent', () => {
}
]
});
initializeAppI18nForTests(injector);
return runInInjectionContext(injector, () => injector.get(CustomEmojiPickerComponent));
}

View File

@@ -21,6 +21,7 @@ import {
} from '@ng-icons/lucide';
import { CustomEmoji, EmojiShortcutEntry } from '../../../../shared-kernel';
import { CustomEmojiService } from '../../application/custom-emoji.service';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
import {
CUSTOM_EMOJI_ACCEPT_ATTRIBUTE,
UNICODE_EMOJI_PICKER_ENTRIES,
@@ -33,12 +34,13 @@ import {
@Component({
selector: 'app-custom-emoji-picker',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [provideIcons({ lucidePlus, lucideSearch, lucideSmile, lucideUpload, lucideX })],
templateUrl: './custom-emoji-picker.component.html'
})
export class CustomEmojiPickerComponent {
private readonly customEmoji = inject(CustomEmojiService);
private readonly appI18n = inject(AppI18nService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
readonly currentUserId = input<string | null>(null);
@@ -154,7 +156,7 @@ export class CustomEmojiPickerComponent {
this.selectCustom(emoji);
} catch (error) {
this.uploadError.set(error instanceof Error ? error.message : 'Unable to upload emoji.');
this.uploadError.set(error instanceof Error ? error.message : this.appI18n.instant('emoji.picker.uploadFailed'));
} finally {
this.uploading.set(false);
}

View File

@@ -9,7 +9,8 @@ import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { MobileCallSessionService, MobileNotificationsService } from '../../../../infrastructure/mobile';
import { MobileCallSessionService, MobileMediaService, MobileNotificationsService } from '../../../../infrastructure/mobile';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import { ViewportService } from '../../../../core/platform';
import {
VoiceActivityService,
@@ -564,9 +565,17 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
startActiveCall: vi.fn(async () => undefined),
endActiveCall: vi.fn(async () => undefined)
}
}
},
{
provide: MobileMediaService,
useValue: {
setSpeakerphoneEnabled: vi.fn(async () => undefined)
}
},
...provideAppI18nForTests()
]
});
initializeAppI18nForTests(injector);
return {
audio,

View File

@@ -8,6 +8,7 @@ import {
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { AppI18nService } from '../../../../core/i18n';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { ViewportService } from '../../../../core/platform';
import {
@@ -48,6 +49,7 @@ export class DirectCallService {
private readonly mobileNotifications = inject(MobileNotificationsService);
private readonly mobileCallSession = inject(MobileCallSessionService);
private readonly mobileMedia = inject(MobileMediaService);
private readonly i18n = inject(AppI18nService);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly users = this.store.selectSignal(selectAllUsers);
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
@@ -229,7 +231,7 @@ export class DirectCallService {
const peerId = conversation.participants.find((participantId) => participantId !== meId);
if (!peerId) {
throw new Error('Direct message conversation has no recipient to call.');
throw new Error(this.i18n.instant('call.errors.noRecipient'));
}
const peer = this.userForParticipant(peerId) ?? participantToUser(this.participantFromConversation(conversation, peerId));
@@ -992,7 +994,7 @@ export class DirectCallService {
return remoteNames.join(', ');
}
return 'Call in progress';
return this.i18n.instant('call.notifications.inProgress');
}
private uniqueParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
@@ -1026,7 +1028,7 @@ export class DirectCallService {
const user = this.currentUser();
if (!user) {
throw new Error('Cannot use calls without a current user.');
throw new Error(this.i18n.instant('call.errors.noCurrentUser'));
}
return user;

View File

@@ -35,12 +35,12 @@
</div>
</div>
<p class="mt-5 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">Incoming call</p>
<p class="mt-5 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">{{ 'call.incoming.badge' | translate }}</p>
<h2
id="incoming-call-title"
class="mt-2 text-xl font-semibold text-foreground"
>
{{ callerName() }} is calling
{{ callerCallingLabel() }}
</h2>
<p class="mt-1 text-sm text-muted-foreground">{{ callKindLabel() }}</p>
@@ -54,7 +54,7 @@
name="lucidePhoneOff"
class="h-4 w-4"
/>
Decline
{{ 'call.incoming.decline' | translate }}
</button>
<button
@@ -67,7 +67,7 @@
name="lucidePhone"
class="h-4 w-4"
/>
Answer
{{ 'call.incoming.answer' | translate }}
</button>
</div>
</div>

View File

@@ -9,6 +9,7 @@ import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePhoneOff } from '@ng-icons/lucide';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ModalBackdropComponent, UserAvatarComponent } from '../../../../shared';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { User } from '../../../../shared-kernel';
@@ -22,7 +23,8 @@ import { DirectCallSession, participantToUser } from '../../domain/models/direct
CommonModule,
NgIcon,
UserAvatarComponent,
ModalBackdropComponent
ModalBackdropComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -34,6 +36,7 @@ import { DirectCallSession, participantToUser } from '../../domain/models/direct
})
export class IncomingCallModalComponent {
readonly calls = inject(DirectCallService);
private readonly i18n = inject(AppI18nService);
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
readonly session = this.calls.incomingCall;
readonly answering = signal(false);
@@ -50,11 +53,16 @@ export class IncomingCallModalComponent {
return (callerId ? this.calls.userForParticipant(callerId) : null)
?? (participant ? participantToUser(participant) : null);
});
readonly callerName = computed(() => this.caller()?.displayName || 'Someone');
readonly callerName = computed(() => this.caller()?.displayName || this.i18n.instant('call.incoming.someone'));
readonly callerCallingLabel = computed(() =>
this.i18n.instant('call.incoming.callerCalling', { name: this.callerName() })
);
readonly callKindLabel = computed(() => {
const participantCount = this.session()?.participantIds.length ?? 0;
return participantCount > 2 ? `${participantCount} person call` : 'Direct call';
return participantCount > 2
? this.i18n.instant('call.incoming.groupCall', { count: participantCount })
: this.i18n.instant('call.incoming.directCall');
});
@HostListener('document:keydown.escape')

View File

@@ -10,8 +10,8 @@
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-3 rounded-md py-1 pr-2 text-left transition-colors hover:bg-secondary/60 focus:outline-none focus:ring-2 focus:ring-primary/50"
[attr.aria-label]="'Open profile for ' + peerName()"
[title]="'Open profile for ' + peerName()"
[attr.aria-label]="openProfileLabel(peerName())"
[title]="openProfileLabel(peerName())"
(click)="openHeaderProfileCard($event)"
>
<app-user-avatar
@@ -23,7 +23,7 @@
/>
<div class="min-w-0 flex-1">
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
<p class="text-xs text-muted-foreground">Direct Message</p>
<p class="text-xs text-muted-foreground">{{ 'dm.chat.directMessage' | translate }}</p>
</div>
</button>
} @else {
@@ -36,7 +36,9 @@
/>
<div class="min-w-0 flex-1">
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
<p class="text-xs text-muted-foreground">{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}</p>
<p class="text-xs text-muted-foreground">
{{ isGroupConversation() ? ('dm.chat.groupChat' | translate) : ('dm.chat.directMessage' | translate) }}
</p>
</div>
}
@if (showCallButton() && conversation()) {
@@ -44,8 +46,8 @@
type="button"
class="grid h-9 w-9 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
[disabled]="!canCallConversation()"
[attr.aria-label]="'Call ' + peerName()"
[title]="'Call ' + peerName()"
[attr.aria-label]="callPeerLabel(peerName())"
[title]="callPeerLabel(peerName())"
(click)="callConversation()"
>
<ng-icon
@@ -101,7 +103,7 @@
data-testid="dm-typing-indicator"
class="px-4 pb-1 text-xs text-muted-foreground"
>
{{ typingUsers().join(', ') }} {{ typingUsers().length === 1 ? 'is' : 'are' }} typing...
{{ typingIndicatorLabel(typingUsers()) }}
</div>
}
@@ -135,7 +137,7 @@
class="fixed inset-0 z-[89]"
tabindex="0"
role="button"
aria-label="Close GIF picker"
[attr.aria-label]="'dm.chat.closeGifPicker' | translate"
(click)="closeGifPicker()"
(keydown.enter)="closeGifPicker()"
(keydown.space)="closeGifPicker()"
@@ -171,6 +173,6 @@
(imageContextMenuRequested)="openImageContextMenu($event)"
/>
} @else {
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">Select a direct message from the rail.</div>
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">{{ 'dm.chat.selectPrompt' | translate }}</div>
}
</section>

View File

@@ -14,6 +14,7 @@ import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform';
import {
@@ -71,7 +72,8 @@ interface DmStatusLabel {
BottomSheetComponent,
NgIcon,
ThemeNodeDirective,
UserAvatarComponent
UserAvatarComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucidePhone, lucidePhoneCall })],
templateUrl: './dm-chat.component.html',
@@ -92,6 +94,7 @@ export class DmChatComponent {
private readonly viewport = inject(ViewportService);
private readonly metadataRequestKeys = new Set<string>();
private openedConversationId: string | null = null;
private readonly i18n = inject(AppI18nService);
readonly isMobile = this.viewport.isMobile;
readonly directCalls = inject(DirectCallService);
readonly directMessages = inject(DirectMessageService);
@@ -193,7 +196,7 @@ export class DmChatComponent {
roomId: conversation.id,
channelId: 'direct-message',
senderId: message.senderId,
senderName: knownUser?.displayName || participant?.displayName || (message.senderId === this.currentUserId() ? 'You' : message.senderId),
senderName: knownUser?.displayName || participant?.displayName || (message.senderId === this.currentUserId() ? this.i18n.instant('common.labels.you') : message.senderId),
content: message.content,
timestamp: message.timestamp,
kind: message.kind,
@@ -216,7 +219,7 @@ export class DmChatComponent {
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : this.i18n.instant('dm.chat.defaultTitle');
});
readonly peerCallIcon = computed(() => {
const conversation = this.conversation();
@@ -382,6 +385,22 @@ export class DmChatComponent {
}
}
openProfileLabel(name: string): string {
return this.i18n.instant('dm.chat.openProfile', { name });
}
callPeerLabel(name: string): string {
return this.i18n.instant('dm.chat.callPeer', { name });
}
typingIndicatorLabel(names: string[]): string {
const verb = names.length === 1
? this.i18n.instant('dm.chat.typingOne')
: this.i18n.instant('dm.chat.typingMany');
return `${names.join(', ')} ${verb}`;
}
closeGifPicker(): void {
this.showGifPicker.set(false);
}

View File

@@ -4,8 +4,8 @@
<button
type="button"
class="relative z-10 flex h-11 w-11 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent text-muted-foreground transition-[border-radius,box-shadow,background-color,color] duration-100 hover:rounded-lg hover:bg-card hover:text-foreground md:h-10 md:w-10"
title="Direct Messages"
aria-label="Direct Messages"
[title]="'dm.rail.title' | translate"
[attr.aria-label]="'dm.rail.ariaLabel' | translate"
[ngClass]="isOnDirectMessages() ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10 text-foreground' : 'rounded-xl bg-card'"
[attr.aria-current]="isOnDirectMessages() ? 'page' : null"
(click)="openDirectMessages()"

View File

@@ -20,6 +20,7 @@ import {
lucideUsers
} from '@ng-icons/lucide';
import { filter, map } from 'rxjs';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ContextMenuComponent } from '../../../../shared';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { FriendService } from '../../application/services/friend.service';
@@ -51,7 +52,8 @@ const EXIT_ANIMATION_MS = 160;
imports: [
CommonModule,
ContextMenuComponent,
NgIcon
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucideLogOut, lucideMessageCircle, lucideTrash2, lucideUser, lucideUsers })],
templateUrl: './dm-rail.component.html',
@@ -61,6 +63,7 @@ export class DmRailComponent implements OnDestroy {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly exitTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly i18n = inject(AppI18nService);
readonly directMessages = inject(DirectMessageService);
readonly friends = inject(FriendService);
readonly users = this.store.selectSignal(selectAllUsers);
@@ -198,7 +201,7 @@ export class DmRailComponent implements OnDestroy {
const peerId = conversation.participants.find((participantId) => participantId !== this.currentUserId());
return peerId ? conversation.participantProfiles[peerId]?.displayName || peerId : 'DM';
return peerId ? conversation.participantProfiles[peerId]?.displayName || peerId : this.i18n.instant('dm.chat.railFallback');
}
initial(label: string): string {
@@ -263,7 +266,9 @@ export class DmRailComponent implements OnDestroy {
}
forgetContextLabel(item: DmRailItem): string {
return item.conversation && this.isGroupConversation(item.conversation) ? 'Leave chat' : 'Forget chat';
return item.conversation && this.isGroupConversation(item.conversation)
? this.i18n.instant('dm.rail.leaveChat')
: this.i18n.instant('dm.rail.forgetChat');
}
forgetContextIcon(item: DmRailItem): string {

View File

@@ -30,8 +30,8 @@
type="button"
class="invisible grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition hover:bg-emerald-500/10 hover:text-emerald-600 focus:visible focus:opacity-100 group-focus-within:visible group-focus-within:opacity-100 group-hover:visible group-hover:opacity-100 disabled:group-focus-within:opacity-30 disabled:group-hover:opacity-30"
[disabled]="!canCall()"
[attr.aria-label]="'Call ' + peerName()"
[title]="'Call ' + peerName()"
[attr.aria-label]="callPeerLabel(peerName())"
[title]="callPeerLabel(peerName())"
(click)="callConversationPeer($event)"
>
<ng-icon
@@ -42,8 +42,8 @@
<button
type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition hover:bg-destructive/10 hover:text-destructive focus:opacity-100 group-hover:opacity-100"
[attr.aria-label]="'Forget ' + peerName()"
[title]="'Forget ' + peerName()"
[attr.aria-label]="forgetPeerLabel(peerName())"
[title]="forgetPeerLabel(peerName())"
(click)="forgetConversation($event)"
>
<ng-icon

View File

@@ -18,6 +18,7 @@ import {
lucideTrash2
} from '@ng-icons/lucide';
import { map } from 'rxjs';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { UserAvatarComponent } from '../../../../shared';
import { ThemeNodeDirective } from '../../../theme';
import { AttachmentFacade } from '../../../attachment';
@@ -35,7 +36,8 @@ import { DirectMessageService } from '../../application/services/direct-message.
CommonModule,
NgIcon,
UserAvatarComponent,
ThemeNodeDirective
ThemeNodeDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucidePhone, lucidePhoneCall, lucideTrash2 })],
host: { class: 'block' },
@@ -48,6 +50,7 @@ export class DmConversationItemComponent {
private readonly attachments = inject(AttachmentFacade);
private readonly directMessages = inject(DirectMessageService);
private readonly directCalls = inject(DirectCallService);
private readonly i18n = inject(AppI18nService);
readonly conversation = input.required<DirectMessageConversation>();
readonly conversationOpened = output<string>();
readonly users = this.store.selectSignal(selectAllUsers);
@@ -95,6 +98,14 @@ export class DmConversationItemComponent {
await this.directCalls.startConversationCall(this.conversation());
}
callPeerLabel(name: string): string {
return this.i18n.instant('dm.chat.callPeer', { name });
}
forgetPeerLabel(name: string): string {
return this.i18n.instant('dm.chat.forgetPeer', { name });
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}
@@ -107,7 +118,7 @@ export class DmConversationItemComponent {
const peerId = this.peerId(conversation);
const knownUser = this.peerUser(conversation);
return peerId ? knownUser?.displayName || conversation.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
return peerId ? knownUser?.displayName || conversation.participantProfiles[peerId]?.displayName || peerId : this.i18n.instant('dm.chat.defaultTitle');
}
private resolvePeerAvatarUrl(conversation: DirectMessageConversation): string | undefined {
@@ -125,15 +136,15 @@ export class DmConversationItemComponent {
const lastMessage = conversation.messages.at(-1);
if (!lastMessage) {
return 'No messages yet';
return this.i18n.instant('dm.previews.noMessages');
}
if (lastMessage.isDeleted) {
return 'Message deleted';
return this.i18n.instant('dm.previews.deleted');
}
if (this.isKlipyGif(lastMessage.content)) {
return 'Sent a GIF';
return this.i18n.instant('dm.previews.gif');
}
this.attachments.updated();
@@ -143,7 +154,7 @@ export class DmConversationItemComponent {
return this.attachmentPreview(attachments);
}
return lastMessage.content || 'Attachment';
return lastMessage.content || this.i18n.instant('dm.previews.attachment');
}
private conversationCallIcon(conversation: DirectMessageConversation): string {
@@ -203,17 +214,19 @@ export class DmConversationItemComponent {
private attachmentPreview(attachments: Attachment[]): string {
if (attachments.some((attachment) => attachment.mime.startsWith('image/'))) {
return 'Sent an image';
return this.i18n.instant('dm.previews.image');
}
if (attachments.some((attachment) => attachment.mime.startsWith('video/'))) {
return 'Sent a video';
return this.i18n.instant('dm.previews.video');
}
if (attachments.some((attachment) => attachment.mime.startsWith('audio/'))) {
return 'Sent audio';
return this.i18n.instant('dm.previews.audio');
}
return attachments.length === 1 ? 'Sent an attachment' : 'Sent attachments';
return attachments.length === 1
? this.i18n.instant('dm.previews.oneAttachment')
: this.i18n.instant('dm.previews.manyAttachments');
}
}

View File

@@ -15,8 +15,8 @@
/>
</div>
<div class="min-w-0">
<h1 class="truncate text-sm font-semibold text-foreground">Direct Messages</h1>
<p class="text-xs text-muted-foreground">{{ directMessages.conversations().length }} chats</p>
<h1 class="truncate text-sm font-semibold text-foreground">{{ 'dm.conversations.title' | translate }}</h1>
<p class="text-xs text-muted-foreground">{{ 'dm.conversations.chatCount' | translate: { count: directMessages.conversations().length } }}</p>
</div>
</header>
@@ -25,7 +25,9 @@
class="flex min-h-0 flex-1 flex-col"
>
@if (directMessages.conversations().length === 0) {
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">
{{ 'dm.conversations.empty' | translate }}
</div>
} @else {
<app-virtual-list
class="block h-full p-2"

View File

@@ -8,6 +8,7 @@ import {
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageCircle } from '@ng-icons/lucide';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ThemeNodeDirective, ThemeService } from '../../../theme';
import { VoiceControlsComponent } from '../../../voice-session';
import { VirtualListComponent } from '../../../../shared/components/virtual-list';
@@ -24,7 +25,8 @@ import { DmConversationItemComponent } from './dm-conversation-item.component';
NgIcon,
ThemeNodeDirective,
VirtualListComponent,
VoiceControlsComponent
VoiceControlsComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucideMessageCircle })],
host: { class: 'contents' },

View File

@@ -28,21 +28,21 @@
type="button"
(click)="setMobilePage('conversations')"
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 conversations"
[attr.aria-label]="'dm.workspace.backToConversations' | translate"
>
<ng-icon
name="lucideChevronLeft"
class="h-5 w-5"
/>
</button>
<p class="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">Direct messages</p>
<p class="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{{ 'dm.workspace.directMessages' | translate }}</p>
@if (activeCall()) {
<button
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]="'dm.workspace.returnToCall' | translate"
[title]="'dm.workspace.returnToCall' | translate"
>
<ng-icon
name="lucidePhoneCall"

View File

@@ -17,6 +17,7 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronLeft, lucidePhoneCall } from '@ng-icons/lucide';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ServersRailComponent } from '../../../../features/servers/servers-rail/servers-rail.component';
import { ViewportService } from '../../../../core/platform';
import { ThemeService } from '../../../theme';
@@ -46,7 +47,8 @@ interface SwiperElement extends HTMLElement {
NgIcon,
DmChatPanelComponent,
DmConversationsPanelComponent,
ServersRailComponent
ServersRailComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucideChevronLeft, lucidePhoneCall })],
schemas: [CUSTOM_ELEMENTS_SCHEMA],

View File

@@ -3,7 +3,7 @@
<header class="flex items-center gap-3 border-b border-border px-4 py-3">
<a
routerLink="/dashboard"
aria-label="Back to dashboard"
[attr.aria-label]="'common.actions.backToDashboard' | translate"
class="grid h-9 w-9 shrink-0 place-items-center rounded-lg border border-border bg-secondary text-muted-foreground transition-colors hover:bg-secondary/80"
>
<ng-icon
@@ -12,8 +12,8 @@
/>
</a>
<div class="min-w-0">
<h1 class="truncate text-lg font-semibold text-foreground">Find people</h1>
<p class="truncate text-xs text-muted-foreground">Search for people you share servers with.</p>
<h1 class="truncate text-lg font-semibold text-foreground">{{ 'dm.find.title' | translate }}</h1>
<p class="truncate text-xs text-muted-foreground">{{ 'dm.find.subtitle' | translate }}</p>
</div>
</header>
@@ -25,9 +25,9 @@
/>
<input
type="text"
aria-label="Search people"
[attr.aria-label]="'dm.find.searchAriaLabel' | translate"
class="h-10 w-full rounded-lg border border-border bg-secondary py-2 pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Search people..."
[placeholder]="'dm.find.searchPlaceholder' | translate"
[ngModel]="searchQuery()"
(ngModelChange)="onSearchChange($event)"
/>
@@ -47,13 +47,13 @@
class="h-7 w-7 text-muted-foreground"
/>
</div>
<p class="text-base font-semibold text-foreground">No people to show yet</p>
<p class="mt-1 max-w-sm text-sm">Join servers to discover people with shared interests.</p>
<p class="text-base font-semibold text-foreground">{{ 'dm.find.emptyTitle' | translate }}</p>
<p class="mt-1 max-w-sm text-sm">{{ 'dm.find.emptyMessage' | translate }}</p>
<a
routerLink="/servers"
class="mt-5 inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
Find servers
{{ 'dm.find.findServers' | translate }}
</a>
</div>
}

View File

@@ -16,6 +16,7 @@ import {
lucideUsers
} from '@ng-icons/lucide';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { UserSearchListComponent } from '../user-search-list/user-search-list.component';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
@@ -33,7 +34,8 @@ import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
FormsModule,
RouterLink,
NgIcon,
UserSearchListComponent
UserSearchListComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucideArrowLeft, lucideSearch, lucideUsers })],
templateUrl: './find-people.component.html',

View File

@@ -3,8 +3,8 @@
[attr.data-testid]="'friend-button-' + userId()"
class="grid h-8 w-8 place-items-center rounded-md border border-border bg-secondary text-foreground transition-colors hover:bg-secondary/80"
[attr.aria-pressed]="isFriend()"
[attr.aria-label]="isFriend() ? 'Remove friend' : 'Add friend'"
[title]="isFriend() ? 'Remove friend' : 'Add friend'"
[attr.aria-label]="ariaLabel()"
[title]="ariaLabel()"
(click)="toggle($event)"
>
<ng-icon

View File

@@ -8,22 +8,29 @@ import {
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideUserCheck, lucideUserPlus } from '@ng-icons/lucide';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { FriendService } from '../../application/services/friend.service';
import type { User } from '../../../../shared-kernel';
@Component({
selector: 'app-friend-button',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [provideIcons({ lucideUserCheck, lucideUserPlus })],
templateUrl: './friend-button.component.html'
})
export class FriendButtonComponent {
private readonly friends = inject(FriendService);
private readonly i18n = inject(AppI18nService);
readonly user = input.required<User>();
readonly userId = computed(() => this.user().oderId || this.user().id);
readonly isFriend = computed(() => this.friends.isFriend(this.userId()));
readonly ariaLabel = computed(() =>
this.isFriend()
? this.i18n.instant('dm.friend.remove')
: this.i18n.instant('dm.friend.add')
);
toggle(event: Event): void {
event.stopPropagation();

View File

@@ -1,13 +1,13 @@
<section class="min-h-full p-3">
<div class="mb-2 flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-foreground">People</h3>
<h3 class="text-sm font-semibold text-foreground">{{ 'dm.search.peopleTitle' | translate }}</h3>
<span class="text-xs text-muted-foreground">{{ matchingUsers().length }}</span>
</div>
@if (friendResults().length > 0) {
<div class="mb-3">
<div class="mb-1 flex items-center justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Friends</h4>
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ 'dm.search.friendsTitle' | translate }}</h4>
<span class="text-xs text-muted-foreground">{{ friendResults().length }}</span>
</div>
@@ -38,8 +38,8 @@
type="button"
[attr.data-testid]="'call-friend-' + userKey(user)"
class="grid h-8 w-8 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
[attr.aria-label]="'Call ' + user.displayName"
[title]="'Call ' + user.displayName"
[attr.aria-label]="callUserLabel(user.displayName)"
[title]="callUserLabel(user.displayName)"
(click)="callUser(user)"
>
<ng-icon
@@ -51,8 +51,8 @@
type="button"
[attr.data-testid]="'message-friend-' + userKey(user)"
class="grid h-8 w-8 place-items-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
[attr.aria-label]="'Message ' + user.displayName"
[title]="'Message ' + user.displayName"
[attr.aria-label]="messageUserLabel(user.displayName)"
[title]="messageUserLabel(user.displayName)"
(click)="messageUser(user)"
>
<ng-icon
@@ -69,7 +69,7 @@
@if (friendResults().length > 0) {
<div class="mb-1 flex items-center justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Others</h4>
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ 'dm.search.othersTitle' | translate }}</h4>
<span class="text-xs text-muted-foreground">{{ results().length }}</span>
</div>
}
@@ -80,7 +80,7 @@
name="lucideSearch"
class="h-4 w-4"
/>
No users found
{{ 'dm.search.noUsersFound' | translate }}
</div>
} @else {
<div class="space-y-1.5">
@@ -115,8 +115,8 @@
type="button"
[attr.data-testid]="'call-user-' + userKey(user)"
class="grid h-8 w-8 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
[attr.aria-label]="'Call ' + user.displayName"
[title]="'Call ' + user.displayName"
[attr.aria-label]="callUserLabel(user.displayName)"
[title]="callUserLabel(user.displayName)"
(click)="callUser(user)"
>
<ng-icon
@@ -128,8 +128,8 @@
type="button"
[attr.data-testid]="'message-user-' + userKey(user)"
class="grid h-8 w-8 place-items-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
[attr.aria-label]="'Message ' + user.displayName"
[title]="'Message ' + user.displayName"
[attr.aria-label]="messageUserLabel(user.displayName)"
[title]="messageUserLabel(user.displayName)"
(click)="messageUser(user)"
>
<ng-icon

View File

@@ -15,6 +15,7 @@ import {
lucidePhoneCall,
lucideSearch
} from '@ng-icons/lucide';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import { UserAvatarComponent } from '../../../../shared';
@@ -31,7 +32,8 @@ import type { User } from '../../../../shared-kernel';
CommonModule,
NgIcon,
UserAvatarComponent,
FriendButtonComponent
FriendButtonComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucideMessageCircle, lucidePhone, lucidePhoneCall, lucideSearch })],
templateUrl: './user-search-list.component.html'
@@ -42,6 +44,7 @@ export class UserSearchListComponent {
private readonly directMessages = inject(DirectMessageService);
readonly directCalls = inject(DirectCallService);
readonly friends = inject(FriendService);
private readonly i18n = inject(AppI18nService);
readonly searchQuery = input('');
readonly users = this.store.selectSignal(selectAllUsers);
readonly savedRooms = this.store.selectSignal(selectSavedRooms);
@@ -108,6 +111,14 @@ export class UserSearchListComponent {
return this.directCalls.isCallingUser(user) ? 'lucidePhoneCall' : 'lucidePhone';
}
callUserLabel(name: string): string {
return this.i18n.instant('dm.search.callUser', { name });
}
messageUserLabel(name: string): string {
return this.i18n.instant('dm.search.messageUser', { name });
}
userKey(user: User): string {
return user.oderId || user.id;
}

View File

@@ -9,8 +9,8 @@
<button
type="button"
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Download"
aria-label="Download"
title="{{ 'experimental.vlcPlayer.download' | translate }}"
aria-label="{{ 'experimental.vlcPlayer.download' | translate }}"
(click)="requestDownload()"
>
<ng-icon
@@ -22,8 +22,8 @@
<button
type="button"
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Close"
aria-label="Close"
title="{{ 'experimental.vlcPlayer.close' | translate }}"
aria-label="{{ 'experimental.vlcPlayer.close' | translate }}"
(click)="close()"
>
<ng-icon
@@ -41,7 +41,9 @@
></div>
@if (status() === 'loading') {
<div class="absolute inset-0 grid place-items-center bg-black/80 px-4 text-sm text-white/80">Loading experimental player...</div>
<div class="absolute inset-0 grid place-items-center bg-black/80 px-4 text-sm text-white/80">
{{ 'experimental.vlcPlayer.loading' | translate }}
</div>
}
@if (status() === 'error') {
@@ -57,7 +59,7 @@
name="lucideRefreshCw"
class="h-3.5 w-3.5"
/>
Retry
{{ 'experimental.vlcPlayer.retry' | translate }}
</button>
<button
type="button"

View File

@@ -17,12 +17,13 @@ import {
lucideX
} from '@ng-icons/lucide';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ExperimentalVlcPlayerHandle, ExperimentalVlcRuntimeService } from '../../infrastructure/services/experimental-vlc-runtime.service';
@Component({
selector: 'app-experimental-vlc-player',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [
provideIcons({
lucideDownload,

View File

@@ -20,6 +20,7 @@ import type {
MatchedGame,
User
} from '../../../shared-kernel';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../core/i18n/app-i18n.testing';
import { GameActivityService } from './game-activity.service';
const alice = createUser('alice-id', 'alice-oder', 'Alice');
@@ -199,6 +200,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
: options.electronApi;
const injector = Injector.create({
providers: [
...provideAppI18nForTests(),
{
provide: ElectronBridgeService,
useValue: { getApi: () => electronApi }
@@ -230,6 +232,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
}
]
});
initializeAppI18nForTests(injector);
const service = runInInjectionContext(injector, () => new GameActivityService());
const context = {
incomingMessages,

View File

@@ -7,6 +7,7 @@ import {
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { Subscription, firstValueFrom } from 'rxjs';
import { AppI18nService } from '../../../core/i18n';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { jsonStorage } from '../../../infrastructure/persistence/json-storage.service';
import { RealtimeSessionFacade } from '../../../core/realtime';
@@ -87,6 +88,7 @@ const IGNORED_PROCESS_PATTERNS = [
@Injectable({ providedIn: 'root' })
export class GameActivityService implements OnDestroy {
private readonly i18n = inject(AppI18nService);
private readonly electron = inject(ElectronBridgeService);
private readonly http = inject(HttpClient);
private readonly jsonStorage = jsonStorage;
@@ -518,7 +520,7 @@ export class GameActivityService implements OnDestroy {
this.webrtc.broadcastMessage({
type: 'game-activity',
oderId: user?.oderId || user?.id,
displayName: user?.displayName || 'User',
displayName: user?.displayName || this.i18n.instant('common.defaultUser'),
gameActivity: activity
});
}
@@ -562,7 +564,7 @@ export class GameActivityService implements OnDestroy {
this.webrtc.sendToPeer(peerId, {
type: 'game-activity',
oderId: user.oderId || user.id,
displayName: user.displayName || 'User',
displayName: user.displayName || this.i18n.instant('common.defaultUser'),
gameActivity: this.currentActivity
});
}

View File

@@ -21,6 +21,7 @@ import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { DesktopNotificationService } from '../../infrastructure/services/desktop-notification.service';
import { NotificationSettingsStorageService } from '../../infrastructure/services/notification-settings-storage.service';
import { createDefaultNotificationSettings, type NotificationsSettings } from '../../domain/models/notification.model';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import { NotificationsService } from './notifications.service';
const alice: User = {
@@ -165,6 +166,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
const injector = Injector.create({
providers: [
...provideAppI18nForTests(),
{
provide: DatabaseService,
useValue: {
@@ -228,6 +230,8 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
]
});
initializeAppI18nForTests(injector);
return {
service: runInInjectionContext(injector, () => new NotificationsService())
};

View File

@@ -8,6 +8,7 @@ import {
import { Store } from '@ngrx/store';
import type { Message, Room } from '../../../../shared-kernel';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { AppI18nService } from '../../../../core/i18n';
import { TimeSyncService } from '../../../../core/services/time-sync.service';
import { DatabaseService } from '../../../../infrastructure/persistence';
import {
@@ -48,6 +49,7 @@ export class NotificationsService {
private readonly store = inject(Store);
private readonly db = inject(DatabaseService);
private readonly audio = inject(NotificationAudioService);
private readonly appI18n = inject(AppI18nService);
private readonly timeSync = inject(TimeSyncService);
private readonly desktopNotifications = inject(DesktopNotificationService);
private readonly storage = inject(NotificationSettingsStorageService);
@@ -221,7 +223,8 @@ export class NotificationsService {
message,
room,
this._settings(),
!context.isWindowFocused || !context.isDocumentVisible
!context.isWindowFocused || !context.isDocumentVisible,
(key, params) => this.appI18n.instant(key, params)
);
if (this.shouldPlayNotificationSound()) {

View File

@@ -10,6 +10,8 @@ export const DEFAULT_TEXT_CHANNEL_ID = 'general';
const MESSAGE_PREVIEW_LIMIT = 140;
export type AppTranslateFn = (key: string, params?: Record<string, string | number>) => string;
export function resolveMessageChannelId(message: Pick<Message, 'channelId'>): string {
return message.channelId || DEFAULT_TEXT_CHANNEL_ID;
}
@@ -98,17 +100,18 @@ export function buildNotificationDisplayPayload(
message: Pick<Message, 'channelId' | 'content' | 'senderName'>,
room: Room | null,
settings: NotificationsSettings,
requestAttention: boolean
requestAttention: boolean,
translate: AppTranslateFn
): NotificationDisplayPayload {
const channelId = resolveMessageChannelId(message);
const roomName = room?.name || 'Server';
const roomName = room?.name || translate('notifications.display.defaultServerName');
const channelLabel = getChannelLabel(room, channelId);
return {
title: `${roomName} · #${channelLabel}`,
body: settings.showPreview
? formatMessagePreview(message.senderName, message.content)
: `${message.senderName} sent a new message`,
? formatMessagePreview(message.senderName, message.content, translate)
: translate('notifications.display.newMessageHidden', { sender: message.senderName }),
requestAttention
};
}
@@ -147,16 +150,16 @@ export function calculateUnreadForRoom(
};
}
function formatMessagePreview(senderName: string, content: string): string {
function formatMessagePreview(senderName: string, content: string, translate: AppTranslateFn): string {
const normalisedContent = content.replace(/\s+/g, ' ').trim();
if (!normalisedContent) {
return `${senderName} sent a new message`;
return translate('notifications.display.newMessageEmpty', { sender: senderName });
}
const preview = normalisedContent.length > MESSAGE_PREVIEW_LIMIT
? `${normalisedContent.slice(0, MESSAGE_PREVIEW_LIMIT - 1)}...`
: normalisedContent;
return `${senderName}: ${preview}`;
return translate('notifications.display.preview', { sender: senderName, content: preview });
}

View File

@@ -9,10 +9,9 @@
</div>
<div class="min-w-0 flex-1">
<h4 class="text-base font-semibold text-foreground">Delivery</h4>
<h4 class="text-base font-semibold text-foreground">{{ 'notifications.delivery.title' | translate }}</h4>
<p class="mt-1 text-sm text-muted-foreground">
Desktop alerts use the system notification center on Linux and request taskbar attention when the app is not focused. Maximized app window
suppress system popups, and only play the configured notification sound while the app is in the background.
{{ 'notifications.delivery.description' | translate }}
</p>
</div>
</div>
@@ -20,8 +19,8 @@
<div class="mt-5 space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="font-medium text-foreground">Enable notifications</p>
<p class="text-sm text-muted-foreground">Mute every server and channel notification without affecting unread indicators.</p>
<p class="font-medium text-foreground">{{ 'notifications.enable.label' | translate }}</p>
<p class="text-sm text-muted-foreground">{{ 'notifications.enable.description' | translate }}</p>
</div>
<label class="relative inline-flex cursor-pointer items-center">
@@ -45,8 +44,8 @@
/>
<div>
<p class="font-medium text-foreground">Show message preview</p>
<p class="text-sm text-muted-foreground">Include a short message preview in desktop notifications when content privacy allows it.</p>
<p class="font-medium text-foreground">{{ 'notifications.showPreview.label' | translate }}</p>
<p class="text-sm text-muted-foreground">{{ 'notifications.showPreview.description' | translate }}</p>
</div>
</div>
@@ -71,8 +70,8 @@
/>
<div>
<p class="font-medium text-foreground">Respect busy status</p>
<p class="text-sm text-muted-foreground">Suppress desktop alerts while your user presence is set to busy.</p>
<p class="font-medium text-foreground">{{ 'notifications.respectBusy.label' | translate }}</p>
<p class="text-sm text-muted-foreground">{{ 'notifications.respectBusy.description' | translate }}</p>
</div>
</div>
@@ -101,16 +100,16 @@
</div>
<div class="min-w-0 flex-1">
<h4 class="text-base font-semibold text-foreground">Server Overrides</h4>
<h4 class="text-base font-semibold text-foreground">{{ 'notifications.serverOverrides.title' | translate }}</h4>
<p class="mt-1 text-sm text-muted-foreground">
Right-click actions mirror these switches. Muted servers and channels still collect unread badges so you can catch up later.
{{ 'notifications.serverOverrides.description' | translate }}
</p>
</div>
</div>
@if (rooms().length === 0) {
<div class="mt-5 rounded-lg border border-dashed border-border bg-secondary/20 p-4 text-sm text-muted-foreground">
Join a server to configure notification overrides.
{{ 'notifications.serverOverrides.empty' | translate }}
</div>
} @else {
<div class="mt-5 space-y-4">
@@ -122,11 +121,13 @@
<p class="truncate font-medium text-foreground">{{ room.name }}</p>
@if (roomUnreadCount(room.id) > 0) {
<span class="rounded-full bg-amber-400/20 px-2 py-0.5 text-[11px] font-semibold text-amber-300">
{{ formatUnreadCount(roomUnreadCount(room.id)) }} unread
{{ 'notifications.serverOverrides.unread' | translate: { count: formatUnreadCount(roomUnreadCount(room.id)) } }}
</span>
}
</div>
<p class="mt-1 text-sm text-muted-foreground">{{ room.description || 'Notifications for every text channel in this server.' }}</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ room.description || ('notifications.serverOverrides.defaultRoomDescription' | translate) }}
</p>
</div>
<label class="relative inline-flex cursor-pointer items-center">
@@ -144,8 +145,8 @@
<div class="mt-4 rounded-lg border border-border/70 bg-background/50 p-3">
<div class="mb-2 flex items-center justify-between">
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Channels</p>
<p class="text-xs text-muted-foreground">Unread badges remain visible even if muted.</p>
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ 'notifications.channels.title' | translate }}</p>
<p class="text-xs text-muted-foreground">{{ 'notifications.channels.mutedHint' | translate }}</p>
</div>
<div class="space-y-2">

View File

@@ -16,11 +16,12 @@ import {
import { selectSavedRooms } from '../../../../../store/rooms/rooms.selectors';
import type { Room } from '../../../../../shared-kernel';
import { NotificationsFacade } from '../../../application/facades/notifications.facade';
import { APP_TRANSLATE_IMPORTS } from '../../../../../core/i18n';
@Component({
selector: 'app-notifications-settings',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [
provideIcons({
lucideBell,

View File

@@ -1,5 +1,7 @@
import { Injector } from '@angular/core';
import { provideTranslateService } from '@ngx-translate/core';
import { environment } from '../../../../../environments/environment';
import { AppI18nService } from '../../../../core/i18n';
import type { TojuPluginManifest } from '../../../../shared-kernel';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PluginStoreService } from './plugin-store.service';
@@ -259,6 +261,11 @@ function createService(
): PluginStoreService {
const injector = Injector.create({
providers: [
provideTranslateService({
fallbackLang: 'en',
lang: 'en'
}),
AppI18nService,
PluginStoreService,
{
provide: ElectronBridgeService,
@@ -299,7 +306,10 @@ function createService(
]
});
return injector.get(PluginStoreService);
const service = injector.get(PluginStoreService);
injector.get(AppI18nService).initialize();
return service;
}
function toBase64(value: string): string {

View File

@@ -12,6 +12,7 @@ import { Store } from '@ngrx/store';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../../../environments/environment';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { AppI18nService } from '../../../../core/i18n';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
@@ -79,6 +80,7 @@ export class PluginStoreService {
private readonly registry = inject(PluginRegistryService);
private readonly serverDirectory = inject(ServerDirectoryFacade, { optional: true });
private readonly store = inject(Store, { optional: true });
private readonly appI18n = inject(AppI18nService);
private readonly currentRoom = this.store?.selectSignal(selectCurrentRoom) ?? null;
private readonly currentRoomId = this.store?.selectSignal(selectCurrentRoomId) ?? null;
private readonly currentRoomName = this.store?.selectSignal(selectCurrentRoomName) ?? null;
@@ -442,19 +444,25 @@ export class PluginStoreService {
: 'installed';
}
getActionLabel(plugin: PluginStoreEntry): PluginStoreActionLabel {
getActionLabel(plugin: PluginStoreEntry): string {
const state = this.getInstallState(plugin);
const serverScoped = getStoreEntryInstallScope(plugin) === 'server';
if (state === 'updateAvailable') {
return serverScoped ? 'Update Server' : 'Update';
return serverScoped
? this.appI18n.instant('plugins.store.actions.updateServer')
: this.appI18n.instant('plugins.store.actions.update');
}
if (state === 'installed') {
return serverScoped ? 'Remove from Server' : 'Uninstall';
return serverScoped
? this.appI18n.instant('plugins.store.actions.removeFromServer')
: this.appI18n.instant('plugins.store.actions.uninstall');
}
return serverScoped ? 'Install to Server' : 'Install';
return serverScoped
? this.appI18n.instant('plugins.store.actions.installToServer')
: this.appI18n.instant('plugins.store.actions.install');
}
canInstallPlugin(plugin: PluginStoreEntry): boolean {

View File

@@ -2,19 +2,21 @@
appThemeNode="contextMenuSurface"
class="w-80 rounded-lg border border-border bg-card p-3 shadow-xl"
role="menu"
aria-label="Plugin actions"
[attr.aria-label]="'plugins.actionMenu.menuAria' | translate"
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
>
<div class="mb-3 flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-semibold text-foreground">Plugins</p>
<p class="truncate text-xs text-muted-foreground">{{ actions().length }} available actions</p>
<p class="text-sm font-semibold text-foreground">{{ 'plugins.actionMenu.title' | translate }}</p>
<p class="truncate text-xs text-muted-foreground">
{{ 'plugins.actionMenu.availableActions' | translate: { count: actions().length } }}
</p>
</div>
<button
type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Close plugin menu"
title="Close"
[attr.aria-label]="'plugins.actionMenu.closeAria' | translate"
[title]="'plugins.actionMenu.close' | translate"
(click)="close()"
>
<ng-icon
@@ -56,7 +58,7 @@
</div>
} @else {
<p class="rounded-md border border-dashed border-border bg-background/40 px-3 py-4 text-center text-sm text-muted-foreground">
No plugin actions available.
{{ 'plugins.actionMenu.empty' | translate }}
</p>
}
</div>

View File

@@ -16,6 +16,7 @@ import { PluginRegistryService } from '../../application/services/plugin-registr
import type { PluginUiContributionRecord } from '../../application/services/plugin-ui-registry.service';
import { PluginUiRegistryService } from '../../application/services/plugin-ui-registry.service';
import { ThemeNodeDirective } from '../../../theme';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
@Component({
selector: 'app-plugin-action-menu',
@@ -23,7 +24,8 @@ import { ThemeNodeDirective } from '../../../theme';
imports: [
CommonModule,
NgIcon,
ThemeNodeDirective
ThemeNodeDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucideX })],
templateUrl: './plugin-action-menu.component.html'

Some files were not shown because too many files have changed in this diff Show More