feat: Rename to Toju and add translation
Some checks failed
Deploy Web Apps / deploy (push) Successful in 5m52s
Build Android APK / build-android-apk (push) Failing after 23m15s
Queue Release Build / prepare (push) Successful in 1m42s
Queue Release Build / build-linux (push) Failing after 9m33s
Queue Release Build / build-windows (push) Successful in 26m5s
Queue Release Build / finalize (push) Has been skipped
Some checks failed
Deploy Web Apps / deploy (push) Successful in 5m52s
Build Android APK / build-android-apk (push) Failing after 23m15s
Queue Release Build / prepare (push) Successful in 1m42s
Queue Release Build / build-linux (push) Failing after 9m33s
Queue Release Build / build-windows (push) Successful in 26m5s
Queue Release Build / finalize (push) Has been skipped
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
72
toju-app/src/app/core/i18n/app-i18n-catalog.rules.spec.ts
Normal file
72
toju-app/src/app/core/i18n/app-i18n-catalog.rules.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
47
toju-app/src/app/core/i18n/app-i18n-catalog.rules.ts
Normal file
47
toju-app/src/app/core/i18n/app-i18n-catalog.rules.ts
Normal 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();
|
||||
}
|
||||
29
toju-app/src/app/core/i18n/app-i18n.rules.spec.ts
Normal file
29
toju-app/src/app/core/i18n/app-i18n.rules.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
13
toju-app/src/app/core/i18n/app-i18n.rules.ts
Normal file
13
toju-app/src/app/core/i18n/app-i18n.rules.ts
Normal 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;
|
||||
}
|
||||
40
toju-app/src/app/core/i18n/app-i18n.service.spec.ts
Normal file
40
toju-app/src/app/core/i18n/app-i18n.service.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
22
toju-app/src/app/core/i18n/app-i18n.service.ts
Normal file
22
toju-app/src/app/core/i18n/app-i18n.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
19
toju-app/src/app/core/i18n/app-i18n.testing.ts
Normal file
19
toju-app/src/app/core/i18n/app-i18n.testing.ts
Normal 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();
|
||||
}
|
||||
4
toju-app/src/app/core/i18n/app-translate.imports.ts
Normal file
4
toju-app/src/app/core/i18n/app-translate.imports.ts
Normal 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;
|
||||
5
toju-app/src/app/core/i18n/index.ts
Normal file
5
toju-app/src/app/core/i18n/index.ts
Normal 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';
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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('( ͡° ͜ʖ ͡°)');
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
};
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user