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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,21 +28,21 @@
type="button"
(click)="setMobilePage('conversations')"
class="grid h-11 w-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Back to conversations"
[attr.aria-label]="'dm.workspace.backToConversations' | translate"
>
<ng-icon
name="lucideChevronLeft"
class="h-5 w-5"
/>
</button>
<p class="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">Direct messages</p>
<p class="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{{ 'dm.workspace.directMessages' | translate }}</p>
@if (activeCall()) {
<button
type="button"
(click)="openActiveCall()"
class="grid h-11 w-11 place-items-center rounded-lg text-emerald-600 transition-colors hover:bg-emerald-500/10 hover:text-emerald-500"
aria-label="Return to call"
title="Return to call"
[attr.aria-label]="'dm.workspace.returnToCall' | translate"
[title]="'dm.workspace.returnToCall' | translate"
>
<ng-icon
name="lucidePhoneCall"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@
<button
type="button"
class="inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground md:h-8 md:w-8"
aria-label="Back to settings"
aria-label="{{ 'plugins.manager.backToSettings' | translate }}"
(click)="close()"
>
<ng-icon
@@ -32,7 +32,7 @@
name="lucidePlay"
size="16"
/>
Activate ready plugins
{{ 'plugins.manager.activateReady' | translate }}
</button>
<button
type="button"
@@ -43,14 +43,14 @@
name="lucideStore"
size="16"
/>
Open Plugin Store
{{ 'plugins.manager.openStore' | translate }}
</button>
</div>
</header>
<nav
class="no-scrollbar flex gap-2 overflow-x-auto border-b border-border px-3 py-2 md:px-4"
aria-label="Plugin manager sections"
aria-label="{{ 'plugins.manager.sectionsAria' | translate }}"
>
<button
type="button"
@@ -62,7 +62,7 @@
name="lucidePackage"
size="16"
/>
Installed
{{ 'plugins.manager.tabs.installed' | translate }}
</button>
<button
type="button"
@@ -74,7 +74,7 @@
name="lucideSettings"
size="16"
/>
Extension points
{{ 'plugins.manager.tabs.extensions' | translate }}
</button>
<button
type="button"
@@ -86,7 +86,7 @@
name="lucideShield"
size="16"
/>
Requirements
{{ 'plugins.manager.tabs.requirements' | translate }}
</button>
<button
type="button"
@@ -98,7 +98,7 @@
name="lucideSettings"
size="16"
/>
Settings
{{ 'plugins.manager.tabs.settings' | translate }}
</button>
<button
type="button"
@@ -110,7 +110,7 @@
name="lucidePackage"
size="16"
/>
Docs
{{ 'plugins.manager.tabs.docs' | translate }}
</button>
<button
type="button"
@@ -122,7 +122,7 @@
name="lucideBug"
size="16"
/>
Logs
{{ 'plugins.manager.tabs.logs' | translate }}
</button>
</nav>
@@ -134,20 +134,7 @@
class="grid gap-3 md:grid-cols-2 xl:grid-cols-4"
data-testid="plugin-extension-counts"
>
@for (
item of [
{ label: 'Settings pages', value: extensionCounts().settingsPages },
{ label: 'App pages', value: extensionCounts().appPages },
{ label: 'Side panels', value: extensionCounts().sidePanels },
{ label: 'Channel sections', value: extensionCounts().channelSections },
{ label: 'Composer actions', value: extensionCounts().composerActions },
{ label: 'Profile actions', value: extensionCounts().profileActions },
{ label: 'Toolbar actions', value: extensionCounts().toolbarActions },
{ label: 'Slash commands', value: extensionCounts().slashCommands },
{ label: 'Embed renderers', value: extensionCounts().embeds }
];
track item.label
) {
@for (item of extensionCountItems(); track item.label) {
<article class="rounded-lg border border-border bg-card p-3">
<p class="text-sm text-muted-foreground">{{ item.label }}</p>
<p class="mt-2 text-2xl font-semibold">{{ item.value }}</p>
@@ -159,17 +146,19 @@
class="rounded-lg border border-border bg-card p-4"
data-testid="plugin-conflict-diagnostics"
>
<h3 class="text-sm font-semibold">Conflict diagnostics</h3>
<h3 class="text-sm font-semibold">{{ 'plugins.manager.conflicts.title' | translate }}</h3>
@if (uiConflicts().length === 0) {
<p class="mt-2 text-sm text-muted-foreground">
No duplicate route, action, embed, channel, panel, or settings contribution ids detected.
{{ 'plugins.manager.conflicts.none' | translate }}
</p>
} @else {
<div class="mt-3 space-y-2">
@for (conflict of uiConflicts(); track conflict.kind + conflict.contributionId) {
<div class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm">
<span class="font-medium">{{ conflict.kind }} / {{ conflict.contributionId }}</span>
<span class="text-muted-foreground"> conflicts in {{ conflict.pluginIds.join(', ') }}</span>
<span class="text-muted-foreground">
{{ 'plugins.manager.conflicts.conflictsIn' | translate: { plugins: conflict.pluginIds.join(', ') } }}
</span>
</div>
}
</div>
@@ -184,7 +173,7 @@
>
@if (requirementComparisons().length === 0) {
<p class="rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground">
No server plugin requirements for the current room.
{{ 'plugins.manager.requirements.empty' | translate }}
</p>
} @else {
@for (comparison of requirementComparisons(); track comparison.pluginId) {
@@ -197,9 +186,13 @@
<span class="rounded bg-muted px-2 py-1 text-xs text-muted-foreground">{{ comparison.status }}</span>
</div>
@if (comparison.requirement) {
<p class="mt-3 text-sm text-muted-foreground">Server status: {{ comparison.requirement.status }}</p>
<p class="mt-3 text-sm text-muted-foreground">
{{ 'plugins.manager.requirements.serverStatus' | translate: { status: comparison.requirement.status } }}
</p>
@if (comparison.requirement.versionRange) {
<p class="mt-1 text-sm text-muted-foreground">Version range: {{ comparison.requirement.versionRange }}</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ 'plugins.manager.requirements.versionRange' | translate: { range: comparison.requirement.versionRange } }}
</p>
}
@if (comparison.requirement.reason) {
<p class="mt-1 text-sm text-muted-foreground">{{ comparison.requirement.reason }}</p>
@@ -229,7 +222,7 @@
</div>
<section class="rounded-lg border border-border bg-card p-3 md:p-4">
@if (selectedPlugin(); as plugin) {
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }} settings</h3>
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }} {{ 'plugins.manager.settings.settingsSuffix' | translate }}</h3>
@if (selectedSettingsPages().length > 0) {
<div class="mt-4 space-y-3">
@for (page of selectedSettingsPages(); track page.id) {
@@ -243,7 +236,7 @@
@if (selectedSettingsSchema()) {
<pre class="mt-3 max-h-[420px] overflow-auto rounded-md bg-muted p-3 text-xs">{{ selectedSettingsSchema() | json }}</pre>
} @else {
<p class="mt-2 text-sm text-muted-foreground">This plugin does not declare a settings schema.</p>
<p class="mt-2 text-sm text-muted-foreground">{{ 'plugins.manager.settings.noSchema' | translate }}</p>
}
}
</section>
@@ -289,7 +282,7 @@
@case ('logs') {
<div class="space-y-3">
@if (!selectedPlugin()) {
<p class="text-sm text-muted-foreground">No plugins installed.</p>
<p class="text-sm text-muted-foreground">{{ 'plugins.manager.logs.noPlugins' | translate }}</p>
} @else {
<div class="flex flex-wrap gap-2">
@for (entry of entries(); track trackEntry($index, entry)) {
@@ -305,7 +298,7 @@
</div>
<div class="rounded-lg border border-border bg-card">
@if (selectedLogs().length === 0) {
<p class="p-4 text-sm text-muted-foreground">No logs for selected plugin.</p>
<p class="p-4 text-sm text-muted-foreground">{{ 'plugins.manager.logs.noLogs' | translate }}</p>
} @else {
@for (log of selectedLogs(); track log.timestamp) {
<div class="border-b border-border px-4 py-3 last:border-b-0">
@@ -360,7 +353,7 @@
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted md:h-8 md:min-h-0"
(click)="selectPlugin(entry.manifest.id)"
>
Select
{{ 'plugins.manager.installed.select' | translate }}
</button>
<button
type="button"
@@ -371,7 +364,7 @@
[name]="entry.enabled ? 'lucideX' : 'lucideCheck'"
size="14"
/>
{{ entry.enabled ? 'Disable' : 'Enable' }}
{{ (entry.enabled ? 'plugins.manager.installed.disable' : 'plugins.manager.installed.enable') | translate }}
</button>
<button
type="button"
@@ -383,7 +376,7 @@
name="lucidePlay"
size="14"
/>
Activate
{{ 'plugins.manager.installed.activate' | translate }}
</button>
<button
type="button"
@@ -395,7 +388,7 @@
name="lucideRefreshCw"
size="14"
/>
Reload
{{ 'plugins.manager.installed.reload' | translate }}
</button>
<button
type="button"
@@ -407,7 +400,7 @@
name="lucideX"
size="14"
/>
Unload
{{ 'plugins.manager.installed.unload' | translate }}
</button>
</div>
</div>
@@ -426,17 +419,17 @@
name="lucideShield"
size="18"
/>
<h3 class="text-sm font-semibold">Capabilities</h3>
<h3 class="text-sm font-semibold">{{ 'plugins.manager.capabilities.title' | translate }}</h3>
</div>
@if ((plugin.manifest.capabilities?.length ?? 0) === 0) {
<p class="mt-3 text-sm text-muted-foreground">Plugin requests no capabilities.</p>
<p class="mt-3 text-sm text-muted-foreground">{{ 'plugins.manager.capabilities.none' | translate }}</p>
} @else {
<button
type="button"
class="mt-3 min-h-11 rounded-md border border-border px-3 text-sm hover:bg-muted md:h-8 md:min-h-0"
(click)="grantAll(plugin)"
>
Grant all requested
{{ 'plugins.manager.capabilities.grantAll' | translate }}
</button>
<div class="mt-3 space-y-2">
@for (capability of plugin.manifest.capabilities; track trackCapability($index, capability)) {
@@ -453,7 +446,9 @@
</div>
}
@if (missingCapabilities().length > 0) {
<p class="mt-3 text-xs text-muted-foreground">Missing: {{ missingCapabilities().join(', ') }}</p>
<p class="mt-3 text-xs text-muted-foreground">
{{ 'plugins.manager.capabilities.missing' | translate: { capabilities: missingCapabilities().join(', ') } }}
</p>
}
}
</aside>

View File

@@ -23,6 +23,7 @@ import {
lucideX
} from '@ng-icons/lucide';
import type { PluginCapabilityId, TojuPluginInstallScope } from '../../../../shared-kernel';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
import { PluginHostService } from '../../application/services/plugin-host.service';
import { PluginLoggerService } from '../../application/services/plugin-logger.service';
@@ -41,7 +42,8 @@ type PluginManagerTab = 'docs' | 'extensions' | 'installed' | 'logs' | 'requirem
imports: [
CommonModule,
NgIcon,
PluginRenderHostComponent
PluginRenderHostComponent,
...APP_TRANSLATE_IMPORTS
],
templateUrl: './plugin-manager.component.html',
viewProviders: [
@@ -72,16 +74,19 @@ export class PluginManagerComponent {
readonly requirementState = inject(PluginRequirementStateService);
readonly router = inject(Router);
readonly uiRegistry = inject(PluginUiRegistryService);
private readonly appI18n = inject(AppI18nService);
readonly activeTab = signal<PluginManagerTab>('installed');
readonly busyPluginId = signal<string | null>(null);
readonly busyAll = signal(false);
readonly selectedPluginId = signal<string | null>(null);
readonly allEntries = this.registry.entries;
readonly entries = computed(() => this.allEntries().filter((entry) => this.entryBelongsToScope(entry)));
readonly managerTitle = computed(() => this.scope() === 'server' ? 'Server plugins' : 'Client plugins');
readonly managerTitle = computed(() => this.scope() === 'server'
? this.appI18n.instant('plugins.manager.serverTitle')
: this.appI18n.instant('plugins.manager.clientTitle'));
readonly managerDescription = computed(() => this.scope() === 'server'
? 'Plugins installed for the current chat server.'
: 'Global client plugins installed on this device.');
? this.appI18n.instant('plugins.manager.serverDescription')
: this.appI18n.instant('plugins.manager.clientDescription'));
readonly selectedPlugin = computed(() => {
const selectedPluginId = this.selectedPluginId();
@@ -109,6 +114,21 @@ export class PluginManagerComponent {
slashCommands: this.uiRegistry.slashCommandRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length
}));
readonly extensionCountItems = computed(() => {
const counts = this.extensionCounts();
return [
{ label: this.appI18n.instant('plugins.manager.extensionCounts.settingsPages'), value: counts.settingsPages },
{ label: this.appI18n.instant('plugins.manager.extensionCounts.appPages'), value: counts.appPages },
{ label: this.appI18n.instant('plugins.manager.extensionCounts.sidePanels'), value: counts.sidePanels },
{ label: this.appI18n.instant('plugins.manager.extensionCounts.channelSections'), value: counts.channelSections },
{ label: this.appI18n.instant('plugins.manager.extensionCounts.composerActions'), value: counts.composerActions },
{ label: this.appI18n.instant('plugins.manager.extensionCounts.profileActions'), value: counts.profileActions },
{ label: this.appI18n.instant('plugins.manager.extensionCounts.toolbarActions'), value: counts.toolbarActions },
{ label: this.appI18n.instant('plugins.manager.extensionCounts.slashCommands'), value: counts.slashCommands },
{ label: this.appI18n.instant('plugins.manager.extensionCounts.embedRenderers'), value: counts.embeds }
];
});
readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []);
readonly uiConflicts = computed(() => this.uiRegistry.conflicts()
.filter((conflict) => conflict.pluginIds.some((pluginId) => this.hasVisiblePlugin(pluginId))));
@@ -125,10 +145,12 @@ export class PluginManagerComponent {
? this.uiRegistry.settingsPageRecords().filter((record) => record.pluginId === selectedPlugin.manifest.id)
: [];
});
readonly emptyTitle = computed(() => this.scope() === 'server' ? 'No server plugins installed.' : 'No client plugins installed.');
readonly emptyTitle = computed(() => this.scope() === 'server'
? this.appI18n.instant('plugins.manager.empty.serverTitle')
: this.appI18n.instant('plugins.manager.empty.clientTitle'));
readonly emptyBody = computed(() => this.scope() === 'server'
? 'Server-scoped plugins use scope: server in toju-plugin.json.'
: 'Client-scoped plugins use scope: client or omit scope in toju-plugin.json.');
? this.appI18n.instant('plugins.manager.empty.serverBody')
: this.appI18n.instant('plugins.manager.empty.clientBody'));
readonly selectedDocs = computed(() => {
const manifest = this.selectedPlugin()?.manifest;
@@ -137,10 +159,10 @@ export class PluginManagerComponent {
}
return [
{ label: 'Readme', url: manifest.readme },
{ label: 'Homepage', url: manifest.homepage },
{ label: 'Changelog', url: manifest.changelog },
{ label: 'Support', url: manifest.bugs }
{ label: this.appI18n.instant('plugins.manager.docs.readme'), url: manifest.readme },
{ label: this.appI18n.instant('plugins.manager.docs.homepage'), url: manifest.homepage },
{ label: this.appI18n.instant('plugins.manager.docs.changelog'), url: manifest.changelog },
{ label: this.appI18n.instant('plugins.manager.docs.support'), url: manifest.bugs }
].filter((item): item is { label: string; url: string } => typeof item.url === 'string' && item.url.length > 0);
});

View File

@@ -2,7 +2,7 @@
<a
routerLink="/dashboard"
class="text-sm text-muted-foreground hover:text-foreground"
>Back</a
>{{ 'plugins.pageHost.back' | translate }}</a
>
@if (page(); as pageRecord) {
<section class="mx-auto mt-6 max-w-5xl">
@@ -14,8 +14,8 @@
</section>
} @else {
<section class="mx-auto mt-6 max-w-2xl rounded-lg border border-border bg-card p-8 text-center">
<h1 class="text-xl font-semibold">Plugin page unavailable</h1>
<p class="mt-2 text-sm text-muted-foreground">The plugin page is not registered or the plugin is not loaded.</p>
<h1 class="text-xl font-semibold">{{ 'plugins.pageHost.unavailableTitle' | translate }}</h1>
<p class="mt-2 text-sm text-muted-foreground">{{ 'plugins.pageHost.unavailableBody' | translate }}</p>
</section>
}
</main>

View File

@@ -8,6 +8,7 @@ import {
import { ActivatedRoute, RouterLink } from '@angular/router';
import { map } from 'rxjs/operators';
import { PluginUiRegistryService } from '../../application/services/plugin-ui-registry.service';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { PluginRenderHostComponent } from '../plugin-render-host/plugin-render-host.component';
@Component({
@@ -16,7 +17,8 @@ import { PluginRenderHostComponent } from '../plugin-render-host/plugin-render-h
imports: [
CommonModule,
RouterLink,
PluginRenderHostComponent
PluginRenderHostComponent,
...APP_TRANSLATE_IMPORTS
],
templateUrl: './plugin-page-host.component.html'
})

View File

@@ -9,7 +9,7 @@
type="button"
(click)="goBack()"
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Back to app"
[title]="'plugins.store.backToApp' | translate"
>
<ng-icon
name="lucideArrowLeft"
@@ -25,9 +25,18 @@
</div>
<div class="min-w-0">
<h1 class="truncate text-xl font-semibold leading-7">Plugin Store</h1>
<h1 class="truncate text-xl font-semibold leading-7">{{ 'plugins.store.title' | translate }}</h1>
<p class="truncate text-sm text-muted-foreground">
{{ installedCount() }} installed for {{ store.installScopeLabel() }} · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources
{{
'plugins.store.summary'
| translate
: {
installed: installedCount(),
scope: store.installScopeLabel(),
available: totalSourcePlugins(),
sources: sourceCount()
}
}}
</p>
</div>
</div>
@@ -42,7 +51,7 @@
name="lucideSettings"
class="h-4 w-4"
/>
Manage Plugins
{{ 'plugins.store.manage' | translate }}
</button>
<button
type="button"
@@ -55,7 +64,7 @@
class="h-4 w-4"
[class.animate-spin]="store.isLoading()"
/>
Refresh
{{ 'plugins.store.refresh' | translate }}
</button>
</div>
</header>
@@ -67,8 +76,8 @@
type="text"
[(ngModel)]="newSourceUrl"
(keyup.enter)="addSourceUrl()"
placeholder="https://example.com/plugins.json or /home/me/plugins/source.json"
aria-label="Plugin source manifest URL"
[placeholder]="'plugins.store.sourcePlaceholder' | translate"
[attr.aria-label]="'plugins.store.sourceAria' | translate"
class="min-h-9 w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</label>
@@ -82,7 +91,7 @@
name="lucidePlus"
class="h-4 w-4"
/>
Add Source
{{ 'plugins.store.addSource' | translate }}
</button>
</div>
@@ -97,11 +106,11 @@
>
<aside
class="grid gap-3 xl:sticky xl:top-3"
aria-label="Plugin sources and filters"
[attr.aria-label]="'plugins.store.sourcesAndFiltersAria' | translate"
>
<section class="grid min-w-0 gap-1 rounded-lg border border-border bg-card p-3">
<div class="mb-1 flex items-center justify-between gap-2">
<h2 class="text-xs font-bold uppercase text-foreground">Sources</h2>
<h2 class="text-xs font-bold uppercase text-foreground">{{ 'plugins.store.sources' | translate }}</h2>
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">{{ sourceCount() }}</span>
</div>
@@ -112,7 +121,7 @@
[class.text-foreground]="selectedSourceUrl() === null"
(click)="selectSource(null)"
>
<span class="truncate">All sources</span>
<span class="truncate">{{ 'plugins.store.allSources' | translate }}</span>
<strong class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">{{ totalSourcePlugins() }}</strong>
</button>
@@ -132,7 +141,7 @@
type="button"
(click)="removeSourceUrl(source.url)"
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
title="Remove source"
[title]="'plugins.store.removeSource' | translate"
>
<ng-icon
name="lucideTrash2"
@@ -161,7 +170,7 @@
type="button"
(click)="removeSourceUrl(sourceUrl)"
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
title="Remove source"
[title]="'plugins.store.removeSource' | translate"
>
<ng-icon
name="lucideTrash2"
@@ -174,7 +183,7 @@
<section class="grid min-w-0 gap-2 rounded-lg border border-border bg-card p-3">
<div class="flex items-center justify-between gap-2">
<h2 class="text-xs font-bold uppercase text-foreground">Filters</h2>
<h2 class="text-xs font-bold uppercase text-foreground">{{ 'plugins.store.filters' | translate }}</h2>
</div>
<button
type="button"
@@ -183,14 +192,14 @@
[class.text-foreground]="showInstalledOnly()"
(click)="toggleInstalledOnly()"
>
<span>Installed only</span>
<span>{{ 'plugins.store.installedOnly' | translate }}</span>
<strong class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">{{ installedCount() }}</strong>
</button>
</section>
<section class="grid min-w-0 gap-2 rounded-lg border border-border bg-card p-3">
<div class="flex items-center justify-between gap-2">
<h2 class="text-xs font-bold uppercase text-foreground">Install server</h2>
<h2 class="text-xs font-bold uppercase text-foreground">{{ 'plugins.store.installServer' | translate }}</h2>
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">{{ manageableServers().length }}</span>
</div>
@@ -209,13 +218,15 @@
></span>
<span class="min-w-0">
<span class="block truncate text-sm font-semibold text-foreground">{{ server.name }}</span>
<span class="block truncate text-xs text-muted-foreground">{{ server.sourceUrl || 'Default endpoint' }}</span>
<span class="block truncate text-xs text-muted-foreground">{{
server.sourceUrl || ('plugins.store.defaultEndpoint' | translate)
}}</span>
</span>
</button>
}
} @else {
<p class="rounded-md border border-border bg-secondary/40 px-2 py-2 text-xs leading-5 text-muted-foreground">
No server is available for plugin installs. Owner or Manage Server access is required.
{{ 'plugins.store.noServerForInstall' | translate }}
</p>
}
</section>
@@ -223,7 +234,7 @@
<section
class="grid min-w-0 gap-3 rounded-lg border border-border bg-card p-3"
aria-label="Available plugins"
[attr.aria-label]="'plugins.store.availablePluginsAria' | translate"
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label class="relative flex min-w-0 flex-1 sm:max-w-xl">
@@ -235,13 +246,13 @@
type="search"
[ngModel]="searchTerm()"
(ngModelChange)="searchTerm.set($event)"
placeholder="Search plugins, authors, ids"
aria-label="Search plugins"
[placeholder]="'plugins.store.searchPlaceholder' | translate"
[attr.aria-label]="'plugins.store.searchAria' | translate"
class="min-h-9 w-full rounded-lg border border-border bg-secondary py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</label>
<div class="text-sm text-muted-foreground">{{ filteredPlugins().length }} shown</div>
<div class="text-sm text-muted-foreground">{{ 'plugins.store.shown' | translate: { count: filteredPlugins().length } }}</div>
</div>
@if (actionError()) {
@@ -280,19 +291,21 @@
<div class="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-2">
<div class="min-w-0">
<h2 class="truncate text-base font-semibold leading-6">{{ plugin.title }}</h2>
<p class="truncate text-sm text-muted-foreground">{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</p>
<p class="truncate text-sm text-muted-foreground">
{{ plugin.author || ('plugins.store.unknownAuthor' | translate) }} · v{{ plugin.version }}
</p>
</div>
@if (getPluginInstallState(plugin) === 'updateAvailable') {
<span
class="self-start whitespace-nowrap rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-primary"
>Update</span
>{{ 'plugins.store.updateBadge' | translate }}</span
>
}
@if (getPluginInstallState(plugin) === 'installed') {
<span
class="self-start whitespace-nowrap rounded-full bg-emerald-600/10 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-600"
>Installed</span
>{{ 'plugins.store.installedBadge' | translate }}</span
>
}
</div>
@@ -312,11 +325,10 @@
[title]="serverInstallButtonTitle(plugin)"
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-primary bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-55"
[ngClass]="{
'border-destructive/35':
getPrimaryActionLabel(plugin) === 'Uninstall' || getPrimaryActionLabel(plugin) === 'Remove from Server',
'bg-destructive/10': getPrimaryActionLabel(plugin) === 'Uninstall' || getPrimaryActionLabel(plugin) === 'Remove from Server'
'border-destructive/35': isDestructivePrimaryAction(plugin),
'bg-destructive/10': isDestructivePrimaryAction(plugin)
}"
[class.text-destructive]="getPrimaryActionLabel(plugin) === 'Uninstall' || getPrimaryActionLabel(plugin) === 'Remove from Server'"
[class.text-destructive]="isDestructivePrimaryAction(plugin)"
>
<ng-icon
[name]="primaryActionIcon(plugin)"
@@ -331,9 +343,9 @@
type="button"
(click)="loadReadme(plugin)"
class="inline-flex min-h-8 items-center justify-center rounded-lg px-3 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Load readme"
[title]="'plugins.store.loadReadme' | translate"
>
{{ isReadmeLoading(plugin) ? 'Loading' : 'Readme' }}
{{ (isReadmeLoading(plugin) ? 'plugins.store.loadingReadme' : 'plugins.store.readme') | translate }}
</button>
}
@@ -342,7 +354,7 @@
type="button"
(click)="openExternal(plugin.githubUrl)"
class="grid h-8 w-8 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Open GitHub"
[title]="'plugins.store.openGitHub' | translate"
>
<ng-icon
name="lucideExternalLink"
@@ -362,9 +374,9 @@
name="lucidePackage"
class="h-7 w-7 text-muted-foreground"
/>
<h2 class="text-base font-semibold">No plugins found</h2>
<h2 class="text-base font-semibold">{{ 'plugins.store.emptyTitle' | translate }}</h2>
<p class="max-w-md text-sm text-muted-foreground">
{{ sourceCount() ? 'Adjust filters or add another source manifest.' : 'Add a plugin source manifest URL to populate the catalog.' }}
{{ (sourceCount() ? 'plugins.store.emptyWithSources' : 'plugins.store.emptyNoSources') | translate }}
</p>
</div>
</section>
@@ -374,14 +386,16 @@
@if (readme()) {
<aside
class="grid min-w-0 gap-3 rounded-lg border border-border bg-card p-3 xl:sticky xl:top-3 xl:max-h-[calc(100vh-6rem)]"
aria-label="Plugin readme"
[attr.aria-label]="'plugins.store.readmePanelAria' | translate"
>
<div class="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
<div class="min-w-0">
<p class="mb-1 text-xs font-bold uppercase text-primary">Readme</p>
<p class="mb-1 text-xs font-bold uppercase text-primary">{{ 'plugins.store.readmeLabel' | translate }}</p>
<h2 class="truncate text-base font-semibold">{{ readme()!.title }}</h2>
@if (selectedReadmePlugin(); as plugin) {
<span class="block truncate text-sm text-muted-foreground">{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</span>
<span class="block truncate text-sm text-muted-foreground">
{{ plugin.author || ('plugins.store.unknownAuthor' | translate) }} · v{{ plugin.version }}
</span>
}
</div>
<div class="flex items-start gap-2">
@@ -390,13 +404,13 @@
(click)="toggleReadmeRawMode()"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-border bg-secondary px-3 py-1.5 text-xs font-semibold text-foreground transition-colors hover:bg-secondary/80"
>
{{ readmeRawMode() ? 'Parsed' : 'Raw' }}
{{ (readmeRawMode() ? 'plugins.store.parsed' : 'plugins.store.raw') | translate }}
</button>
<button
type="button"
(click)="closeReadme()"
class="grid h-8 w-8 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Close readme"
[title]="'plugins.store.closeReadme' | translate"
>
<ng-icon
name="lucideX"
@@ -427,7 +441,7 @@
name="lucideExternalLink"
class="h-4 w-4"
/>
Open source readme
{{ 'plugins.store.openSourceReadme' | translate }}
</button>
</aside>
}
@@ -446,7 +460,7 @@
>
<header class="flex items-start justify-between gap-4 border-b border-border p-4">
<div class="min-w-0">
<p class="text-sm text-muted-foreground">Server plugin install</p>
<p class="text-sm text-muted-foreground">{{ 'plugins.store.serverInstall.title' | translate }}</p>
<h2
id="server-plugin-install-title"
class="mt-1 truncate text-lg font-semibold"
@@ -458,7 +472,7 @@
type="button"
(click)="closeServerInstallDialog()"
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Cancel install"
[title]="'plugins.store.serverInstall.cancelInstall' | translate"
>
<ng-icon
name="lucideX"
@@ -469,7 +483,7 @@
<div class="grid min-h-0 gap-4 overflow-auto p-4">
<label class="grid gap-2">
<span class="text-sm text-muted-foreground">Install to server</span>
<span class="text-sm text-muted-foreground">{{ 'plugins.store.serverInstall.installToServer' | translate }}</span>
<select
[value]="dialog.selectedServerId"
[disabled]="serverInstallBusy()"
@@ -489,12 +503,12 @@
[disabled]="serverInstallBusy()"
(change)="serverInstallOptional.set($any($event.target).checked)"
/>
<span>Optional for server members</span>
<span>{{ 'plugins.store.serverInstall.optionalForMembers' | translate }}</span>
</label>
<div class="grid gap-2">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold">Capabilities</h3>
<h3 class="text-sm font-semibold">{{ 'plugins.store.serverInstall.capabilities' | translate }}</h3>
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">{{ dialog.manifest.capabilities?.length ?? 0 }}</span>
</div>
@@ -511,7 +525,7 @@
</label>
}
} @else {
<p class="text-sm text-muted-foreground">This plugin requests no capabilities.</p>
<p class="text-sm text-muted-foreground">{{ 'plugins.store.serverInstall.noCapabilities' | translate }}</p>
}
</div>
@@ -527,7 +541,7 @@
[disabled]="serverInstallBusy()"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
>
Cancel
{{ 'plugins.store.serverInstall.cancel' | translate }}
</button>
<button
type="button"
@@ -540,7 +554,7 @@
class="h-4 w-4"
[class.animate-spin]="serverInstallBusy()"
/>
Install and Activate
{{ 'plugins.store.serverInstall.installAndActivate' | translate }}
</button>
</footer>
</section>

View File

@@ -39,6 +39,7 @@ import type {
import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ModalBackdropComponent } from '../../../../shared';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
import { PluginStoreService } from '../../application/services/plugin-store.service';
import type {
@@ -62,7 +63,8 @@ interface ServerPluginInstallDialog {
FormsModule,
ChatMessageMarkdownComponent,
NgIcon,
ModalBackdropComponent
ModalBackdropComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -83,6 +85,7 @@ interface ServerPluginInstallDialog {
export class PluginStoreComponent implements OnInit {
readonly store = inject(PluginStoreService);
readonly capabilities = inject(PluginCapabilityService);
private readonly appI18n = inject(AppI18nService);
readonly ngrxStore = inject(NgRxStore);
readonly savedRooms = this.ngrxStore.selectSignal(selectSavedRooms);
readonly currentRoom = this.ngrxStore.selectSignal(selectCurrentRoom);
@@ -237,7 +240,7 @@ export class PluginStoreComponent implements OnInit {
return;
}
this.sourceError.set(error instanceof Error ? error.message : 'Unable to add plugin source');
this.sourceError.set(error instanceof Error ? error.message : this.appI18n.instant('plugins.store.errors.addSource'));
}
}
@@ -255,7 +258,7 @@ export class PluginStoreComponent implements OnInit {
return;
}
this.sourceError.set(error instanceof Error ? error.message : 'Unable to remove plugin source');
this.sourceError.set(error instanceof Error ? error.message : this.appI18n.instant('plugins.store.errors.removeSource'));
}
}
@@ -269,22 +272,24 @@ export class PluginStoreComponent implements OnInit {
return;
}
this.sourceError.set(error instanceof Error ? error.message : 'Unable to refresh plugin sources');
this.sourceError.set(error instanceof Error ? error.message : this.appI18n.instant('plugins.store.errors.refreshSources'));
}
}
async runPrimaryAction(plugin: PluginStoreEntry): Promise<void> {
const action = this.getPrimaryActionLabel(plugin);
const state = this.getPluginInstallState(plugin);
this.actionError.set(null);
this.actionBusyPluginId.set(plugin.id);
try {
if (action === 'Uninstall') {
await this.store.uninstallPlugin(plugin.id, plugin.scope);
} else if (action === 'Remove from Server') {
await this.store.uninstallPlugin(plugin.id, plugin.scope, { serverId: this.selectedStoreServerId() ?? undefined });
await this.refreshSelectedServerInstalledPlugins();
if (state === 'installed') {
if (this.isServerScopedPlugin(plugin)) {
await this.store.uninstallPlugin(plugin.id, plugin.scope, { serverId: this.selectedStoreServerId() ?? undefined });
await this.refreshSelectedServerInstalledPlugins();
} else {
await this.store.uninstallPlugin(plugin.id, plugin.scope);
}
} else if (this.isServerScopedPlugin(plugin)) {
await this.openServerInstallDialog(plugin);
} else {
@@ -295,7 +300,7 @@ export class PluginStoreComponent implements OnInit {
return;
}
this.actionError.set(error instanceof Error ? error.message : 'Unable to update plugin installation');
this.actionError.set(error instanceof Error ? error.message : this.appI18n.instant('plugins.store.errors.updateInstallation'));
} finally {
if (!this.destroyed) {
this.actionBusyPluginId.set(null);
@@ -321,7 +326,7 @@ export class PluginStoreComponent implements OnInit {
return;
}
this.readmeError.set(error instanceof Error ? error.message : 'Unable to load readme');
this.readmeError.set(error instanceof Error ? error.message : this.appI18n.instant('plugins.store.errors.loadReadme'));
} finally {
if (!this.destroyed) {
this.readmeLoadingPluginId.set(null);
@@ -348,7 +353,7 @@ export class PluginStoreComponent implements OnInit {
const selectedServerId = this.selectedStoreServerId();
if (!selectedServerId) {
throw new Error('You need owner or Manage Server access on a chat server before installing server plugins');
throw new Error(this.appI18n.instant('plugins.store.errors.noServerAccess'));
}
this.selectedCapabilityIds.set(new Set(manifest.capabilities ?? []));
@@ -359,7 +364,7 @@ export class PluginStoreComponent implements OnInit {
return;
}
this.actionError.set(error instanceof Error ? error.message : 'Unable to prepare server plugin install');
this.actionError.set(error instanceof Error ? error.message : this.appI18n.instant('plugins.store.errors.prepareServerInstall'));
} finally {
if (!this.destroyed) {
this.actionBusyPluginId.set(null);
@@ -441,7 +446,7 @@ export class PluginStoreComponent implements OnInit {
return;
}
this.serverInstallError.set(error instanceof Error ? error.message : 'Unable to install server plugin');
this.serverInstallError.set(error instanceof Error ? error.message : this.appI18n.instant('plugins.store.errors.installServerPlugin'));
} finally {
if (!this.destroyed) {
this.serverInstallBusy.set(false);
@@ -521,24 +526,20 @@ export class PluginStoreComponent implements OnInit {
const state = this.getPluginInstallState(plugin);
if (state === 'updateAvailable') {
return 'Update Server';
return this.appI18n.instant('plugins.store.actions.updateServer');
}
return state === 'installed' ? 'Remove from Server' : 'Install to Server';
return state === 'installed'
? this.appI18n.instant('plugins.store.actions.removeFromServer')
: this.appI18n.instant('plugins.store.actions.installToServer');
}
isDestructivePrimaryAction(plugin: PluginStoreEntry): boolean {
return this.getPluginInstallState(plugin) === 'installed';
}
primaryActionIcon(plugin: PluginStoreEntry): string {
const action = this.getPrimaryActionLabel(plugin);
if (action === 'Uninstall') {
return 'lucideTrash2';
}
if (action === 'Remove from Server') {
return 'lucideTrash2';
}
return 'lucidePlus';
return this.isDestructivePrimaryAction(plugin) ? 'lucideTrash2' : 'lucidePlus';
}
trackPlugin(index: number, plugin: PluginStoreEntry): string {
@@ -577,7 +578,7 @@ export class PluginStoreComponent implements OnInit {
serverInstallButtonTitle(plugin: PluginStoreEntry): string {
return this.isServerScopedPlugin(plugin) && this.manageableServers().length === 0
? 'Requires owner or Manage Server access on a chat server'
? this.appI18n.instant('plugins.store.errors.requiresServerAccess')
: this.getPrimaryActionLabel(plugin);
}

View File

@@ -1,6 +1,6 @@
<app-modal-backdrop
[zIndex]="112"
ariaLabel="Close profile image editor"
ariaLabel="{{ 'profile.avatarEditor.closeAria' | translate }}"
(dismissed)="cancelled.emit(undefined)"
/>
@@ -12,7 +12,7 @@
tabindex="-1"
>
<div class="border-b border-border p-5">
<h3 class="text-lg font-semibold text-foreground">Adjust profile picture</h3>
<h3 class="text-lg font-semibold text-foreground">{{ 'profile.avatarEditor.title' | translate }}</h3>
<p class="mt-1 text-sm text-muted-foreground">
@if (preservesAnimation()) {
Animated GIF and WebP avatars keep their original animation and framing.
@@ -133,7 +133,7 @@
(click)="cancelled.emit(undefined)"
[disabled]="processing()"
>
Cancel
{{ 'shared.dialog.cancel' | translate }}
</button>
<button
type="button"
@@ -141,7 +141,7 @@
(click)="confirm()"
[disabled]="processing()"
>
{{ processing() ? 'Saving...' : 'Apply picture' }}
{{ processing() ? ('profile.avatarEditor.saving' | translate) : ('profile.avatarEditor.apply' | translate) }}
</button>
</div>
</div>

View File

@@ -10,6 +10,7 @@ import {
import { CommonModule } from '@angular/common';
import { ProfileAvatarFacade } from '../../application/services/profile-avatar.facade';
import { ModalBackdropComponent } from '../../../../shared';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
import {
EditableProfileAvatarSource,
ProcessedProfileAvatar,
@@ -22,12 +23,14 @@ import {
@Component({
selector: 'app-profile-avatar-editor',
standalone: true,
imports: [CommonModule, ModalBackdropComponent],
imports: [CommonModule, ModalBackdropComponent, ...APP_TRANSLATE_IMPORTS],
templateUrl: './profile-avatar-editor.component.html'
})
export class ProfileAvatarEditorComponent {
readonly source = input.required<EditableProfileAvatarSource>();
private readonly appI18n = inject(AppI18nService);
private readonly avatar = inject(ProfileAvatarFacade);
readonly source = input.required<EditableProfileAvatarSource>();
readonly cancelled = output<undefined>();
readonly confirmed = output<ProcessedProfileAvatar>();
@@ -47,7 +50,6 @@ export class ProfileAvatarEditorComponent {
return `translate(-50%, -50%) translate(${transform.offsetX}px, ${transform.offsetY}px) scale(${scale})`;
});
private readonly avatar = inject(ProfileAvatarFacade);
private dragPointerId: number | null = null;
private dragOrigin: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
@@ -150,7 +152,7 @@ export class ProfileAvatarEditorComponent {
this.confirmed.emit(avatar);
} catch {
this.errorMessage.set('Failed to process profile image.');
this.errorMessage.set(this.appI18n.instant('profile.avatarEditor.processFailed'));
} finally {
this.processing.set(false);
}

View File

@@ -22,16 +22,12 @@
name="lucideMonitor"
class="w-4 h-4"
/>
@if (activeScreenSharer()) {
<span class="text-sm font-medium"> {{ activeScreenSharer()?.displayName }} is sharing their screen </span>
} @else {
<span class="text-sm font-medium">Someone is sharing their screen</span>
}
<span class="text-sm font-medium">{{ sharingLabel() }}</span>
</div>
<div class="flex items-center gap-3">
<!-- Viewer volume -->
<div class="flex items-center gap-2 text-white">
<span class="text-xs opacity-80">Volume: {{ screenVolume() }}%</span>
<span class="text-xs opacity-80">{{ 'screenShare.viewer.volume' | translate: { volume: screenVolume() } }}</span>
<input
type="range"
min="0"
@@ -63,7 +59,7 @@
(click)="stopSharing()"
type="button"
class="grid h-9 w-9 place-items-center rounded-lg bg-destructive transition-colors hover:bg-destructive/90"
title="Stop sharing"
[title]="'screenShare.viewer.stopSharing' | translate"
>
<ng-icon
name="lucideX"
@@ -75,7 +71,7 @@
(click)="stopWatching()"
type="button"
class="grid h-9 w-9 place-items-center rounded-lg bg-destructive transition-colors hover:bg-destructive/90"
title="Stop watching"
[title]="'screenShare.viewer.stopWatching' | translate"
>
<ng-icon
name="lucideX"
@@ -95,7 +91,7 @@
name="lucideMonitor"
class="w-12 h-12 mx-auto mb-2 opacity-50"
/>
<p>Waiting for screen share...</p>
<p>{{ 'screenShare.viewer.waiting' | translate }}</p>
</div>
</div>
}

View File

@@ -24,11 +24,16 @@ import { selectOnlineUsers } from '../../../../store/users/users.selectors';
import { User } from '../../../../shared-kernel';
import { DEFAULT_VOLUME } from '../../../../core/constants';
import { VoicePlaybackService } from '../../../../domains/voice-connection';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
@Component({
selector: 'app-screen-share-viewer',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [
CommonModule,
NgIcon,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
lucideMaximize,
@@ -49,6 +54,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
private readonly screenShareService = inject(ScreenShareFacade);
private readonly voicePlayback = inject(VoicePlaybackService);
private readonly store = inject(Store);
private readonly appI18n = inject(AppI18nService);
private remoteStreamSub: Subscription | null = null;
onlineUsers = this.store.selectSignal(selectOnlineUsers);
@@ -262,6 +268,16 @@ export class ScreenShareViewerComponent implements OnDestroy {
}
/** Handle volume slider changes, applying only to remote streams. */
sharingLabel(): string {
const sharer = this.activeScreenSharer();
if (sharer?.displayName) {
return this.appI18n.instant('screenShare.viewer.userSharing', { name: sharer.displayName });
}
return this.appI18n.instant('screenShare.viewer.someoneSharing');
}
onScreenVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement;
const val = Math.max(0, Math.min(200, parseInt(input.value, 10)));

View File

@@ -5,8 +5,8 @@
-->
@if (isMobile()) {
<app-bottom-sheet
title="Create a server"
ariaLabel="Create a server"
[title]="'servers.create.title' | translate"
[ariaLabel]="'servers.create.title' | translate"
(dismissed)="cancel()"
>
<ng-container *ngTemplateOutlet="form" />
@@ -16,7 +16,7 @@
class="min-h-11 flex-1 rounded-lg bg-secondary px-3 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
(click)="cancel()"
>
Cancel
{{ 'common.actions.cancel' | translate }}
</button>
<button
type="button"
@@ -24,7 +24,7 @@
[disabled]="!canCreate"
(click)="create()"
>
Create server
{{ 'servers.create.submit' | translate }}
</button>
</div>
</app-bottom-sheet>
@@ -32,7 +32,7 @@
<!-- Backdrop -->
<app-modal-backdrop
[zIndex]="40"
ariaLabel="Close dialog"
[ariaLabel]="'common.actions.closeDialog' | translate"
(dismissed)="cancel()"
/>
@@ -42,8 +42,8 @@
class="fixed left-1/2 top-1/2 z-50 flex max-h-[88vh] w-[460px] max-w-[92vw] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-border bg-card shadow-lg"
>
<div class="border-b border-border p-4">
<h4 class="font-semibold text-foreground">Create a server</h4>
<p class="mt-0.5 text-xs text-muted-foreground">Your server is where you and your community hang out.</p>
<h4 class="font-semibold text-foreground">{{ 'servers.create.title' | translate }}</h4>
<p class="mt-0.5 text-xs text-muted-foreground">{{ 'servers.create.subtitle' | translate }}</p>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
@@ -56,7 +56,7 @@
class="flex-1 rounded-lg bg-secondary px-3 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
(click)="cancel()"
>
Cancel
{{ 'common.actions.cancel' | translate }}
</button>
<button
id="create-server-dialog-submit"
@@ -65,7 +65,7 @@
[disabled]="!canCreate"
(click)="create()"
>
Create server
{{ 'servers.create.submit' | translate }}
</button>
</div>
</div>
@@ -75,7 +75,7 @@
<ng-template #form>
<div class="space-y-5 p-4">
<div>
<span class="mb-2 block text-sm font-medium text-foreground">Pick a category</span>
<span class="mb-2 block text-sm font-medium text-foreground">{{ 'servers.create.pickCategory' | translate }}</span>
<div class="flex flex-wrap gap-2">
@for (category of categories; track category.id) {
<button
@@ -89,7 +89,7 @@
[class.text-foreground]="selectedCategoryId() !== category.id"
(click)="selectCategory(category)"
>
{{ category.label }}
{{ 'servers.create.categories.' + category.id | translate }}
</button>
}
</div>
@@ -99,14 +99,14 @@
<label
for="create-server-dialog-name"
class="mb-1 block text-sm font-medium text-foreground"
>Server name</label
>{{ 'servers.create.serverName' | translate }}</label
>
<input
id="create-server-dialog-name"
type="text"
[ngModel]="name()"
(ngModelChange)="name.set($event)"
placeholder="My Awesome Server"
[placeholder]="'servers.create.namePlaceholder' | translate"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@@ -115,13 +115,13 @@
<label
for="create-server-dialog-description"
class="mb-1 block text-sm font-medium text-foreground"
>Description (optional)</label
>{{ 'servers.create.descriptionOptional' | translate }}</label
>
<textarea
id="create-server-dialog-description"
[ngModel]="description()"
(ngModelChange)="description.set($event)"
placeholder="What's your server about?"
[placeholder]="'servers.create.descriptionPlaceholder' | translate"
rows="3"
class="w-full resize-none rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
></textarea>
@@ -134,7 +134,7 @@
[attr.aria-expanded]="showAdvanced()"
(click)="toggleAdvanced()"
>
<span>Advanced settings</span>
<span>{{ 'servers.create.advancedSettings' | translate }}</span>
<ng-icon
[name]="showAdvanced() ? 'lucideChevronUp' : 'lucideChevronDown'"
class="h-4 w-4 text-muted-foreground"
@@ -147,14 +147,14 @@
<label
for="create-server-dialog-topic"
class="mb-1 block text-sm font-medium text-foreground"
>Topic (optional)</label
>{{ 'servers.create.topicOptional' | translate }}</label
>
<input
id="create-server-dialog-topic"
type="text"
[ngModel]="topic()"
(ngModelChange)="topic.set($event)"
placeholder="gaming, music, coding..."
[placeholder]="'servers.create.topicPlaceholder' | translate"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@@ -163,7 +163,7 @@
<label
for="create-server-dialog-signal-endpoint"
class="mb-1 block text-sm font-medium text-foreground"
>Signal server endpoint</label
>{{ 'servers.create.signalEndpoint' | translate }}</label
>
<select
id="create-server-dialog-signal-endpoint"
@@ -175,7 +175,7 @@
<option [value]="endpoint.id">{{ endpoint.name }} ({{ endpoint.url }})</option>
}
</select>
<p class="mt-1 text-xs text-muted-foreground">This endpoint handles all signaling for this server.</p>
<p class="mt-1 text-xs text-muted-foreground">{{ 'servers.create.signalEndpointHint' | translate }}</p>
</div>
<div class="flex items-center gap-2">
@@ -189,7 +189,7 @@
<label
for="create-server-dialog-private"
class="text-sm text-foreground"
>Private server</label
>{{ 'servers.create.privateServer' | translate }}</label
>
</div>
@@ -197,17 +197,17 @@
<label
for="create-server-dialog-password"
class="mb-1 block text-sm font-medium text-foreground"
>Password (optional)</label
>{{ 'servers.create.passwordOptional' | translate }}</label
>
<input
id="create-server-dialog-password"
type="password"
[ngModel]="password()"
(ngModelChange)="password.set($event)"
placeholder="Leave blank to allow joining without a password"
[placeholder]="'servers.create.passwordPlaceholder' | translate"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<p class="mt-1 text-xs text-muted-foreground">Users who already joined keep access even if you change the password later.</p>
<p class="mt-1 text-xs text-muted-foreground">{{ 'servers.create.passwordHint' | translate }}</p>
</div>
</div>
}

View File

@@ -13,6 +13,7 @@ import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronDown, lucideChevronUp } from '@ng-icons/lucide';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { ThemeNodeDirective } from '../../../theme';
@@ -35,7 +36,8 @@ import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create-
NgIcon,
ThemeNodeDirective,
BottomSheetComponent,
ModalBackdropComponent
ModalBackdropComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucideChevronDown, lucideChevronUp })],
templateUrl: './create-server-dialog.component.html',

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