feat: Add deafen to pc, fix mobiel view, fix freeze on startup

This commit is contained in:
2026-06-05 15:27:06 +02:00
parent 35f52b0356
commit a675f12e61
85 changed files with 2499 additions and 519 deletions

View File

@@ -33,7 +33,8 @@ This package is the Angular 21 renderer for the Toju/MetoYou product client.
- Use `ViewportService` from `src/app/core/platform/viewport.service.ts` for mobile/touch detection. Breakpoint is `md` (max-width 767.98px); exposes `isMobile`, `isTouch`, `isDesktop` signals.
- Theme-driven grid layouts (`appShell`, `roomLayout`, `dmLayout`) are bypassed on mobile. Do not introduce mobile-specific theme layouts; gate via `@if (isMobile())` in templates instead.
- The mobile chat-room shell (`features/room/chat-room`) is a 3-page stack (channels -> main -> members); the DM workspace (`domains/direct-message/feature/dm-workspace`) is 2-page (conversations -> chat). Page state is a component-local signal kept in sync with a Swiper carousel (`<swiper-container>` / `<swiper-slide>` from `swiper/element/bundle`, registered in `src/main.ts`); both components declare `CUSTOM_ELEMENTS_SCHEMA`.
- On mobile discovery routes (`/dashboard`, `/people`, `/servers`, …) the global `app.html` servers rail stays visible (`shouldShowMobileAppServersRail` in `core/platform/mobile-shell-layout.rules.ts`); routed pages render full-width in `appWorkspace` and must not embed a second `<app-servers-rail>`/Swiper stack.
- The mobile chat-room shell (`features/room/chat-room`) is a 3-page stack (channels -> main -> members); the DM workspace (`domains/direct-message/feature/dm-workspace`) is 2-page (conversations -> chat). Page state is a component-local signal kept in sync with a Swiper carousel (`<swiper-container>` / `<swiper-slide>` from `swiper/element/bundle`, registered in `src/main.ts`); both components declare `CUSTOM_ELEMENTS_SCHEMA`. Chat/DM/call routes keep their embedded rail inside Swiper and hide the global app-shell rail.
- The Electron-style title bar is hidden on mobile. Screen-share UI must stay hidden on mobile (browsers do not support it reliably on touch devices).
- Context menus and modal dialogs auto-render as bottom sheets on mobile. `ContextMenuComponent` and `ConfirmDialogComponent` (in `src/app/shared/components/`) inject `ViewportService` and switch their templates between the desktop popover/centered modal and `BottomSheetComponent` (`src/app/shared/components/bottom-sheet/`) on phone-sized viewports. New menus/dialogs should reuse these components rather than rolling their own `fixed inset-0` overlay. For one-off bespoke surfaces, render `<app-bottom-sheet>` directly when `isMobile()`.
- Tap targets on interactive controls should be at least 44px on mobile. Use `min-h-11` (or explicit `h-11 w-11`) for icon buttons that are tap-only on mobile; desktop sizes can remain smaller via `md:` overrides.

View File

@@ -10,11 +10,12 @@
>
<aside
appThemeNode="serversRail"
class="min-h-0 overflow-hidden bg-transparent"
[class.hidden]="isThemeStudioFullscreen() || isMobile()"
class="min-h-0 shrink-0 overflow-hidden bg-transparent"
[class.hidden]="hideAppServersRail()"
[class.w-16]="showMobileAppServersRail()"
[ngStyle]="isMobile() ? null : serversRailLayoutStyles()"
>
<app-servers-rail class="block h-full" />
<app-servers-rail class="block h-full w-full" />
</aside>
<main
@@ -85,7 +86,7 @@
</div>
}
<div class="absolute inset-0 overflow-auto">
<div class="absolute inset-0 min-w-0 overflow-x-hidden overflow-y-auto">
<router-outlet />
</div>
}

View File

@@ -31,6 +31,7 @@ import { NotificationsFacade } from './domains/notifications';
import { TimeSyncService } from './core/services/time-sync.service';
import { VoiceSessionFacade } from './domains/voice-session';
import { ExternalLinkService, ViewportService } from './core/platform';
import { shouldShowMobileAppServersRail } from './core/platform/mobile-shell-layout.rules';
import { SettingsModalService } from './core/services/settings-modal.service';
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
import { UserStatusService } from './core/services/user-status.service';
@@ -136,6 +137,17 @@ export class App implements OnInit, OnDestroy {
return routePath.startsWith('/dm') || routePath.startsWith('/pm') || routePath.startsWith('/call');
});
readonly showMobileAppServersRail = computed(() => {
if (!this.isMobile()) {
return false;
}
return shouldShowMobileAppServersRail(this.getRoutePath(this.currentRouteUrl()));
});
readonly hideAppServersRail = computed(() => {
return this.isThemeStudioFullscreen()
|| (this.isMobile() && !this.showMobileAppServersRail());
});
readonly desktopUpdateNoticeKey = computed(() => {
const updateState = this.desktopUpdateState();

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { shouldShowMobileAppServersRail } from './mobile-shell-layout.rules';
describe('shouldShowMobileAppServersRail', () => {
it('shows the rail on mobile discovery routes', () => {
expect(shouldShowMobileAppServersRail('/dashboard')).toBe(true);
expect(shouldShowMobileAppServersRail('/people')).toBe(true);
expect(shouldShowMobileAppServersRail('/servers')).toBe(true);
expect(shouldShowMobileAppServersRail('/create-server')).toBe(true);
});
it('hides the rail on routes that embed their own mobile navigation rail', () => {
expect(shouldShowMobileAppServersRail('/room/abc')).toBe(false);
expect(shouldShowMobileAppServersRail('/dm')).toBe(false);
expect(shouldShowMobileAppServersRail('/dm/123')).toBe(false);
expect(shouldShowMobileAppServersRail('/pm')).toBe(false);
expect(shouldShowMobileAppServersRail('/call/abc')).toBe(false);
});
it('hides the rail on auth routes', () => {
expect(shouldShowMobileAppServersRail('/login')).toBe(false);
expect(shouldShowMobileAppServersRail('/register')).toBe(false);
});
});

View File

@@ -0,0 +1,16 @@
const MOBILE_NO_APP_RAIL_PREFIXES = ['/login', '/register'] as const;
const MOBILE_EMBEDDED_RAIL_PREFIXES = ['/room/', '/dm', '/pm', '/call'] as const;
/** Whether the mobile app shell should render the global servers rail for a route. */
export function shouldShowMobileAppServersRail(routePath: string): boolean {
if (MOBILE_NO_APP_RAIL_PREFIXES.some((prefix) => routePath.startsWith(prefix))) {
return false;
}
if (MOBILE_EMBEDDED_RAIL_PREFIXES.some((prefix) => routePath.startsWith(prefix))) {
return false;
}
return true;
}

View File

@@ -69,6 +69,12 @@ export class AttachmentFacade {
return this.manager.requestImageFromAnyPeer(...args);
}
tryRestoreAttachmentFromLocal(
...args: Parameters<AttachmentManagerService['tryRestoreAttachmentFromLocal']>
): ReturnType<AttachmentManagerService['tryRestoreAttachmentFromLocal']> {
return this.manager.tryRestoreAttachmentFromLocal(...args);
}
requestFile(
...args: Parameters<AttachmentManagerService['requestFile']>
): ReturnType<AttachmentManagerService['requestFile']> {

View File

@@ -6,6 +6,7 @@ import {
import { NavigationEnd, Router } from '@angular/router';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { yieldToAttachmentHydrationLoop } from '../../domain/logic/attachment-blob.rules';
import {
getWatchedAttachmentRoomIdFromUrl,
isDirectMessageAttachmentRoomId,
@@ -141,6 +142,16 @@ export class AttachmentManagerService {
return this.transfer.requestImageFromAnyPeer(messageId, attachment);
}
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
const restored = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
if (restored) {
this.runtimeStore.touch();
}
return restored;
}
requestFile(messageId: string, attachment: Attachment): Promise<void> {
return this.transfer.requestFile(messageId, attachment);
}
@@ -194,12 +205,14 @@ export class AttachmentManagerService {
await this.persistence.whenReady();
const messageIds = await this.collectMessageIdsForRoom(roomId);
let hasChanges = false;
for (const messageId of messageIds) {
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
if (await this.persistence.tryRestoreAttachmentFromLocal(attachment)) {
hasChanges = true;
await yieldToAttachmentHydrationLoop();
}
}
}

View File

@@ -0,0 +1,115 @@
import '@angular/compiler';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import {
Injector,
runInInjectionContext,
signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
describe('AttachmentPersistenceService', () => {
let database: {
isReady: ReturnType<typeof signal<boolean>>;
getAllAttachments: ReturnType<typeof vi.fn>;
getMessageById: ReturnType<typeof vi.fn>;
saveAttachment: ReturnType<typeof vi.fn>;
deleteAttachmentsForMessage: ReturnType<typeof vi.fn>;
};
let attachmentStorage: {
resolveExistingPath: ReturnType<typeof vi.fn>;
resolveCanonicalStoredPath: ReturnType<typeof vi.fn>;
readFile: ReturnType<typeof vi.fn>;
readFileChunk: ReturnType<typeof vi.fn>;
getFileSize: ReturnType<typeof vi.fn>;
canReadFileChunks: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
database = {
isReady: signal(true),
getAllAttachments: vi.fn(() => Promise.resolve([
{
id: 'att-1',
messageId: 'msg-1',
filename: 'photo.png',
size: 1_500_000,
mime: 'image/png',
isImage: true,
savedPath: '/appdata/photo.png'
}
])),
getMessageById: vi.fn(() => Promise.resolve(null)),
saveAttachment: vi.fn(() => Promise.resolve()),
deleteAttachmentsForMessage: vi.fn(() => Promise.resolve())
};
attachmentStorage = {
resolveExistingPath: vi.fn(() => Promise.resolve('/appdata/photo.png')),
resolveCanonicalStoredPath: vi.fn(() => Promise.resolve(null)),
readFile: vi.fn(() => Promise.resolve('QUJD')),
readFileChunk: vi.fn(() => Promise.resolve('QUJD')),
getFileSize: vi.fn(() => Promise.resolve(3)),
canReadFileChunks: vi.fn(() => true)
};
});
function createService(): AttachmentPersistenceService {
const injector = Injector.create({
providers: [
AttachmentPersistenceService,
AttachmentRuntimeStore,
{ provide: DatabaseService, useValue: database },
{ provide: AttachmentStorageService, useValue: attachmentStorage },
{ provide: Store, useValue: { select: () => ({ pipe: () => ({ subscribe: () => {} }) }) } }
]
});
return runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService));
}
it('loads attachment metadata at startup without eagerly hydrating blobs from disk', async () => {
const service = createService();
await service.initFromDatabase();
expect(database.getAllAttachments).toHaveBeenCalledTimes(1);
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
expect(attachmentStorage.getFileSize).not.toHaveBeenCalled();
});
it('hydrates blob URLs on demand for a single attachment', async () => {
const service = createService();
await service.initFromDatabase();
const attachment = {
id: 'att-1',
messageId: 'msg-1',
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
savedPath: '/appdata/photo.png',
available: false
};
await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true);
expect(attachment.available).toBe(true);
expect(attachment.objectUrl).toMatch(/^blob:/);
expect(attachmentStorage.getFileSize).toHaveBeenCalledWith('/appdata/photo.png');
expect(attachmentStorage.readFileChunk).toHaveBeenCalled();
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
});
});

View File

@@ -6,6 +6,11 @@ import { DatabaseService } from '../../../../infrastructure/persistence';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../../domain/constants/attachment-transfer.constants';
import {
ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES,
decodeBase64ToUint8Array,
yieldToAttachmentHydrationLoop
} from '../../domain/logic/attachment-blob.rules';
import { isBlobObjectUrl, needsBlobObjectUrlForInlineDisplay } from '../../domain/logic/attachment-display-url.rules';
import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
@@ -144,15 +149,11 @@ export class AttachmentPersistenceService {
return false;
}
const base64 = await this.attachmentStorage.readFile(diskPath);
if (!base64) {
return false;
}
this.revokeAttachmentObjectUrl(attachment);
this.restoreAttachmentFromDisk(attachment, base64);
return true;
const restored = await this.restoreAttachmentBlobFromDiskPath(attachment, diskPath);
return restored;
}
async persistUploadCopyFromSourcePath(attachment: Attachment, sourcePath: string): Promise<string | null> {
@@ -263,30 +264,54 @@ export class AttachmentPersistenceService {
private async runInitFromDatabase(): Promise<void> {
await this.loadFromDatabase();
await this.migrateFromLocalStorage();
await this.tryLoadSavedFiles();
}
private async tryLoadSavedFiles(): Promise<void> {
try {
let hasChanges = false;
private async restoreAttachmentBlobFromDiskPath(attachment: Attachment, diskPath: string): Promise<boolean> {
if (this.attachmentStorage.canReadFileChunks()) {
const fileSize = await this.attachmentStorage.getFileSize(diskPath);
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
for (const attachment of attachments) {
if (await this.tryRestoreAttachmentFromLocal(attachment)) {
hasChanges = true;
}
if (!fileSize || fileSize < 1) {
return false;
}
const blobParts: Uint8Array[] = [];
for (let start = 0; start < fileSize; start += ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES) {
const end = Math.min(start + ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES, fileSize);
const chunkBase64 = await this.attachmentStorage.readFileChunk(diskPath, start, end);
if (!chunkBase64) {
return false;
}
blobParts.push(decodeBase64ToUint8Array(chunkBase64));
if (end < fileSize) {
await yieldToAttachmentHydrationLoop();
}
}
if (hasChanges)
this.runtimeStore.touch();
} catch { /* startup load is best-effort */ }
this.applyAttachmentBlob(attachment, new Blob(blobParts as BlobPart[], { type: attachment.mime }));
return true;
}
const base64 = await this.attachmentStorage.readFile(diskPath);
if (!base64) {
return false;
}
const bytes = decodeBase64ToUint8Array(base64);
this.applyAttachmentBlob(
attachment,
new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime })
);
return true;
}
private restoreAttachmentFromDisk(attachment: Attachment, base64: string): void {
const bytes = this.base64ToUint8Array(base64);
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime });
private applyAttachmentBlob(attachment: Attachment, blob: Blob): void {
attachment.objectUrl = URL.createObjectURL(blob);
attachment.available = true;
@@ -335,14 +360,4 @@ export class AttachmentPersistenceService {
return retainedSavedPaths;
}
private base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
}

View File

@@ -6,6 +6,7 @@ import { RealtimeSessionFacade } from '../../../../core/realtime';
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';
import { isImageAttachment, resolvePublishAttachmentIsImage } from '../../domain/logic/attachment-image.rules';
import { shouldCopyUploaderMediaToAppData, shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic';
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import {
@@ -208,7 +209,7 @@ export class AttachmentTransferService {
filename: file.name,
size: file.size,
mime: file.type || DEFAULT_ATTACHMENT_MIME_TYPE,
isImage: file.type.startsWith('image/'),
isImage: resolvePublishAttachmentIsImage(file),
uploaderPeerId,
filePath: (file as LocalFileWithPath).path,
available: false
@@ -309,7 +310,11 @@ export class AttachmentTransferService {
filename: file.filename,
size: file.size,
mime: file.mime,
isImage: !!file.isImage,
isImage: isImageAttachment({
filename: file.filename,
isImage: !!file.isImage,
mime: file.mime
}),
uploaderPeerId: file.uploaderPeerId,
available: false,
receivedBytes: 0

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { decodeBase64ToUint8Array } from './attachment-blob.rules';
describe('attachment blob rules', () => {
it('decodes base64 payloads into byte arrays', () => {
const bytes = decodeBase64ToUint8Array('QUJD');
expect(Array.from(bytes)).toEqual([65, 66, 67]);
});
});

View File

@@ -0,0 +1,21 @@
/** Chunk size used when rebuilding attachment blobs from disk without blocking the UI thread. */
export const ATTACHMENT_BLOB_READ_CHUNK_SIZE_BYTES = 256 * 1024;
/** Decode a base64 payload into bytes for Blob construction. */
export function decodeBase64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
/** Yield control back to the browser so long attachment hydration cannot freeze Electron. */
export function yieldToAttachmentHydrationLoop(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}

View File

@@ -0,0 +1,63 @@
import {
describe,
expect,
it
} from 'vitest';
import {
dedupeImageAttachmentsForDisplay,
hasImageFilename,
isImageAttachment,
isInlineDisplayableImage,
resolvePublishAttachmentIsImage
} from './attachment-image.rules';
describe('attachment-image rules', () => {
it('detects images from mime, flag, or filename extension', () => {
expect(isImageAttachment({
id: '1',
filename: 'logo.PNG',
mime: 'application/octet-stream',
isImage: false,
available: false
})).toBe(true);
expect(hasImageFilename('photo.jpeg')).toBe(true);
expect(resolvePublishAttachmentIsImage({ name: 'photo.png', type: '' })).toBe(true);
});
it('treats file protocol urls as not inline displayable', () => {
expect(isInlineDisplayableImage({
available: true,
objectUrl: 'file:///tmp/photo.png'
})).toBe(false);
expect(isInlineDisplayableImage({
available: true,
objectUrl: 'blob:http://localhost/abc'
})).toBe(true);
});
it('dedupes image attachments by filename and prefers displayable copies', () => {
const deduped = dedupeImageAttachmentsForDisplay([
{
id: 'a',
filename: 'photo.png',
mime: 'image/png',
isImage: true,
available: false
},
{
id: 'b',
filename: 'photo.png',
mime: 'application/octet-stream',
isImage: false,
available: true,
objectUrl: 'blob:http://localhost/photo'
}
]);
expect(deduped).toHaveLength(1);
expect(deduped[0]?.id).toBe('b');
});
});

View File

@@ -0,0 +1,96 @@
import { needsBlobObjectUrlForInlineDisplay } from './attachment-display-url.rules';
const IMAGE_EXTENSIONS = new Set([
'.apng',
'.avif',
'.bmp',
'.gif',
'.heic',
'.heif',
'.jpg',
'.jpeg',
'.png',
'.svg',
'.webp'
]);
export interface ImageAttachmentCandidate {
available: boolean;
filename: string;
filePath?: string;
id: string;
isImage: boolean;
mime: string;
objectUrl?: string;
savedPath?: string;
}
export function hasImageFilename(filename: string): boolean {
const normalized = filename.trim().toLowerCase();
const extensionIndex = normalized.lastIndexOf('.');
if (extensionIndex <= 0) {
return false;
}
return IMAGE_EXTENSIONS.has(normalized.slice(extensionIndex));
}
export function isImageAttachment(attachment: Pick<ImageAttachmentCandidate, 'filename' | 'isImage' | 'mime'>): boolean {
return attachment.isImage ||
attachment.mime.startsWith('image/') ||
hasImageFilename(attachment.filename);
}
export function isInlineDisplayableImage(
attachment: Pick<ImageAttachmentCandidate, 'available' | 'objectUrl'>
): boolean {
return attachment.available &&
!!attachment.objectUrl &&
!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl);
}
export function imageAttachmentDisplayRank(
attachment: Pick<ImageAttachmentCandidate, 'available' | 'filePath' | 'isImage' | 'objectUrl' | 'savedPath'>
): number {
if (isInlineDisplayableImage(attachment)) {
return 4;
}
if (attachment.savedPath || attachment.filePath) {
return 3;
}
if (attachment.available && attachment.objectUrl) {
return 2;
}
if (attachment.isImage) {
return 1;
}
return 0;
}
export function dedupeImageAttachmentsForDisplay<T extends ImageAttachmentCandidate>(attachments: readonly T[]): T[] {
const byFilename = new Map<string, T>();
for (const attachment of attachments) {
if (!isImageAttachment(attachment)) {
continue;
}
const key = attachment.filename.trim().toLowerCase();
const existing = byFilename.get(key);
if (!existing || imageAttachmentDisplayRank(attachment) > imageAttachmentDisplayRank(existing)) {
byFilename.set(key, attachment);
}
}
return Array.from(byFilename.values());
}
export function resolvePublishAttachmentIsImage(file: Pick<File, 'name' | 'type'>): boolean {
return file.type.startsWith('image/') || hasImageFilename(file.name);
}

View File

@@ -0,0 +1,67 @@
import {
describe,
expect,
it
} from 'vitest';
import { buildChatMessageImageGridLayout, formatChatMessageImageOverflowLabel } from './chat-message-image-grid.rules';
describe('chat-message-image-grid rules', () => {
it('keeps a single image outside the grid', () => {
expect(buildChatMessageImageGridLayout(1)).toEqual({
useGrid: false,
variant: 'none',
cells: []
});
});
it('lays out two images in a pair grid', () => {
expect(buildChatMessageImageGridLayout(2)).toEqual({
useGrid: true,
variant: 'pair',
cells: [{ kind: 'image', index: 0 }, { kind: 'image', index: 1 }]
});
});
it('lays out three images in a triple grid', () => {
expect(buildChatMessageImageGridLayout(3)).toEqual({
useGrid: true,
variant: 'triple',
cells: [
{ kind: 'image', index: 0 },
{ kind: 'image', index: 1 },
{ kind: 'image', index: 2 }
]
});
});
it('lays out four images in a quad grid', () => {
expect(buildChatMessageImageGridLayout(4)).toEqual({
useGrid: true,
variant: 'quad',
cells: [
{ kind: 'image', index: 0 },
{ kind: 'image', index: 1 },
{ kind: 'image', index: 2 },
{ kind: 'image', index: 3 }
]
});
});
it('replaces the last grid cell with an overflow tile when more than four images exist', () => {
expect(buildChatMessageImageGridLayout(7)).toEqual({
useGrid: true,
variant: 'quad',
cells: [
{ kind: 'image', index: 0 },
{ kind: 'image', index: 1 },
{ kind: 'image', index: 2 },
{ kind: 'overflow', hiddenCount: 4 }
]
});
});
it('formats overflow labels as amount plus', () => {
expect(formatChatMessageImageOverflowLabel(4)).toBe('+4');
});
});

View File

@@ -0,0 +1,83 @@
export const CHAT_MESSAGE_IMAGE_GRID_MIN_COUNT = 2;
export const CHAT_MESSAGE_IMAGE_GRID_MAX_VISIBLE = 4;
export type ChatMessageImageGridVariant = 'none' | 'pair' | 'triple' | 'quad';
export interface ChatMessageImageGridImageCell {
kind: 'image';
index: number;
}
export interface ChatMessageImageGridOverflowCell {
kind: 'overflow';
hiddenCount: number;
}
export type ChatMessageImageGridCell = ChatMessageImageGridImageCell | ChatMessageImageGridOverflowCell;
export interface ChatMessageImageGridLayout {
useGrid: boolean;
variant: ChatMessageImageGridVariant;
cells: ChatMessageImageGridCell[];
}
export function buildChatMessageImageGridLayout(imageCount: number): ChatMessageImageGridLayout {
if (imageCount < CHAT_MESSAGE_IMAGE_GRID_MIN_COUNT) {
return {
useGrid: false,
variant: 'none',
cells: []
};
}
if (imageCount === 2) {
return {
useGrid: true,
variant: 'pair',
cells: [{ kind: 'image', index: 0 }, { kind: 'image', index: 1 }]
};
}
if (imageCount === 3) {
return {
useGrid: true,
variant: 'triple',
cells: [
{ kind: 'image', index: 0 },
{ kind: 'image', index: 1 },
{ kind: 'image', index: 2 }
]
};
}
if (imageCount === CHAT_MESSAGE_IMAGE_GRID_MAX_VISIBLE) {
return {
useGrid: true,
variant: 'quad',
cells: [
{ kind: 'image', index: 0 },
{ kind: 'image', index: 1 },
{ kind: 'image', index: 2 },
{ kind: 'image', index: 3 }
]
};
}
return {
useGrid: true,
variant: 'quad',
cells: [
{ kind: 'image', index: 0 },
{ kind: 'image', index: 1 },
{ kind: 'image', index: 2 },
{
kind: 'overflow',
hiddenCount: imageCount - 3
}
]
};
}
export function formatChatMessageImageOverflowLabel(hiddenCount: number): string {
return `+${hiddenCount}`;
}

View File

@@ -0,0 +1,29 @@
import {
describe,
expect,
it
} from 'vitest';
import { canStepLightbox, stepLightboxIndex } from './chat-message-lightbox.rules';
describe('chat-message-lightbox rules', () => {
it('steps forward and backward within bounds', () => {
expect(stepLightboxIndex(1, 1, 3)).toBe(2);
expect(stepLightboxIndex(1, -1, 3)).toBe(0);
});
it('returns null when stepping past the ends', () => {
expect(stepLightboxIndex(0, -1, 3)).toBeNull();
expect(stepLightboxIndex(2, 1, 3)).toBeNull();
});
it('returns null when there is only one image', () => {
expect(stepLightboxIndex(0, 1, 1)).toBeNull();
});
it('reports whether a step is available', () => {
expect(canStepLightbox(0, -1, 3)).toBe(false);
expect(canStepLightbox(0, 1, 3)).toBe(true);
expect(canStepLightbox(2, 1, 3)).toBe(false);
});
});

View File

@@ -0,0 +1,17 @@
export function stepLightboxIndex(currentIndex: number, delta: number, total: number): number | null {
if (total <= 1) {
return null;
}
const nextIndex = currentIndex + delta;
if (nextIndex < 0 || nextIndex >= total) {
return null;
}
return nextIndex;
}
export function canStepLightbox(currentIndex: number, delta: number, total: number): boolean {
return stepLightboxIndex(currentIndex, delta, total) !== null;
}

View File

@@ -1,6 +1,6 @@
<div
appThemeNode="chatSurface"
class="chat-layout relative h-full"
class="chat-layout relative h-full min-w-0 overflow-x-hidden"
>
<app-chat-message-list
[allMessages]="roomMessages()"
@@ -20,6 +20,7 @@
(reactionToggled)="handleReactionToggled($event)"
(downloadRequested)="downloadAttachment($event)"
(imageOpened)="openLightbox($event)"
(imageGalleryOpened)="openImageGallery($event)"
(imageContextMenuRequested)="openImageContextMenu($event)"
(embedRemoved)="handleEmbedRemoved($event)"
(loadOlderRequested)="handleLoadOlderRequested($event)"
@@ -84,12 +85,16 @@
}
<app-chat-message-overlays
[lightboxAttachment]="lightboxAttachment()"
[lightboxState]="lightboxState()"
[galleryAttachments]="galleryAttachments()"
[imageContextMenu]="imageContextMenu()"
(lightboxClosed)="closeLightbox()"
(lightboxStepRequested)="stepLightbox($event)"
(galleryClosed)="closeImageGallery()"
(contextMenuClosed)="closeImageContextMenu()"
(downloadRequested)="downloadAttachment($event)"
(copyRequested)="copyImageToClipboard($event)"
(imageOpened)="openLightbox($event)"
(imageContextMenuRequested)="openImageContextMenu($event)"
/>
</div>

View File

@@ -5,7 +5,9 @@
.chat-bottom-bar {
pointer-events: auto;
right: 8px;
left: 0;
right: 0;
min-width: 0;
background: hsl(var(--background) / 0.85);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);

View File

@@ -34,12 +34,15 @@ import { ChatMessageComposerComponent } from './components/message-composer/chat
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
import { ChatMessageOverlaysComponent } from './components/message-overlays/chat-message-overlays.component';
import { stepLightboxIndex } from '../../domain/rules/chat-message-lightbox.rules';
import {
ChatLightboxState,
ChatMessageComposerSubmitEvent,
ChatMessageDeleteEvent,
ChatMessageEditEvent,
ChatMessageEmbedRemoveEvent,
ChatMessageImageContextMenuEvent,
ChatMessageImageLightboxEvent,
ChatMessageReactionEvent,
ChatMessageReplyEvent
} from './models/chat-messages.model';
@@ -94,7 +97,8 @@ export class ChatMessagesComponent {
readonly klipyGifPickerAnchorRight = signal(16);
readonly replyTo = signal<Message | null>(null);
readonly showKlipyGifPicker = signal(false);
readonly lightboxAttachment = signal<Attachment | null>(null);
readonly lightboxState = signal<ChatLightboxState | null>(null);
readonly galleryAttachments = signal<Attachment[] | null>(null);
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
constructor() {
@@ -284,14 +288,55 @@ export class ChatMessagesComponent {
return Math.max(0, viewportWidth - 32);
}
openLightbox(attachment: Attachment): void {
if (attachment.available && attachment.objectUrl) {
this.lightboxAttachment.set(attachment);
openLightbox(event: ChatMessageImageLightboxEvent): void {
const attachments = event.attachments.filter((attachment) => attachment.available && attachment.objectUrl);
const index = attachments.findIndex((attachment) => attachment.id === event.attachment.id);
if (index < 0) {
return;
}
this.lightboxState.set({
attachments,
index
});
}
closeLightbox(): void {
this.lightboxAttachment.set(null);
this.lightboxState.set(null);
}
stepLightbox(delta: number): void {
const state = this.lightboxState();
if (!state) {
return;
}
const nextIndex = stepLightboxIndex(state.index, delta, state.attachments.length);
if (nextIndex === null) {
return;
}
this.lightboxState.set({
attachments: state.attachments,
index: nextIndex
});
}
openImageGallery(attachments: Attachment[]): void {
const availableImages = attachments.filter((attachment) => attachment.available && attachment.objectUrl);
if (availableImages.length < 2) {
return;
}
this.galleryAttachments.set(availableImages);
}
closeImageGallery(): void {
this.galleryAttachments.set(null);
}
openImageContextMenu(event: ChatMessageImageContextMenuEvent): void {

View File

@@ -1,7 +1,7 @@
<!-- eslint-disable @angular-eslint/template/button-has-type -->
<div
#composerRoot
appThemeNode="chatComposerBar"
class="min-w-0 w-full"
>
@if (replyTo()) {
<div
@@ -129,10 +129,10 @@
</div>
}
<div class="border-border p-4">
<div class="min-w-0 w-full px-3 py-3 sm:p-4">
<div
appThemeNode="chatComposerInput"
class="chat-input-wrapper relative"
class="chat-input-wrapper relative min-w-0 w-full"
(mouseenter)="inputHovered.set(true)"
(mouseleave)="inputHovered.set(false)"
(dragenter)="onDragEnter($event)"
@@ -328,9 +328,7 @@
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
placeholder="Type a message..."
class="chat-textarea w-full rounded-md border border-border pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[class.border-dashed]="dragActive()"
[class.border-primary]="dragActive()"
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()"
[ngClass]="composerTextareaPaddingClass()"
@@ -338,7 +336,7 @@
@if (dragActive()) {
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md border-2 border-dashed border-primary bg-primary/5"
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>

View File

@@ -1,9 +1,16 @@
.chat-input-wrapper {
transition: border-color 0.15s ease;
&:focus-within {
border-color: hsl(var(--primary)) !important;
}
}
.chat-textarea {
--textarea-bg: hsl(40deg 3.7% 15.9% / 25%);
--textarea-collapsed-padding-y: 18px;
--textarea-expanded-padding-y: 8px;
background: var(--textarea-bg);
background: transparent;
height: 62px;
min-height: 62px;
max-height: 520px;

View File

@@ -88,6 +88,7 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
templateUrl: './chat-message-composer.component.html',
styleUrl: './chat-message-composer.component.scss',
host: {
class: 'block min-w-0 w-full',
'(document:keydown)': 'onDocKeydown($event)',
'(document:keyup)': 'onDocKeyup($event)'
}

View File

@@ -168,8 +168,70 @@
@if (attachmentsList.length > 0) {
<div class="mt-2 space-y-2">
@if (imageGridLayout().useGrid) {
<div
class="chat-image-grid"
[class.chat-image-grid--pair]="imageGridLayout().variant === 'pair'"
[class.chat-image-grid--triple]="imageGridLayout().variant === 'triple'"
[class.chat-image-grid--quad]="imageGridLayout().variant === 'quad'"
>
@for (cell of imageGridLayout().cells; track imageGridCellTrack(cell)) {
@if (cell.kind === 'image') {
@let gridImage = imageAttachmentAt(cell.index);
@if (gridImage) {
@if (isDisplayableImage(gridImage)) {
<div
class="chat-image-grid-cell group/img"
(contextmenu)="openImageContextMenu($event, gridImage)"
>
<img
[src]="gridImage.objectUrl"
[alt]="gridImage.filename"
class="chat-image-grid-image"
(click)="openLightbox(gridImage)"
/>
<div class="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover/img:bg-black/20"></div>
</div>
} @else if ((gridImage.receivedBytes || 0) > 0) {
<div class="chat-image-grid-cell chat-image-grid-loading">
<ng-icon
name="lucideImage"
class="h-5 w-5 text-primary"
/>
<span class="chat-image-grid-loading-label">{{ ((gridImage.receivedBytes || 0) * 100) / gridImage.size | number: '1.0-0' }}%</span>
</div>
} @else {
<div class="chat-image-grid-cell chat-image-grid-loading">
<ng-icon
name="lucideImage"
class="h-5 w-5 text-muted-foreground"
/>
<button
type="button"
class="chat-image-grid-retry"
(click)="retryImageRequest(gridImage)"
>
Retry
</button>
</div>
}
}
} @else {
<button
type="button"
class="chat-image-grid-cell chat-image-grid-overflow"
[attr.aria-label]="'View all ' + displayableImages().length + ' images'"
(click)="openImageGallery()"
>
<span class="chat-image-grid-overflow-label">{{ imageOverflowLabel(cell.hiddenCount) }}</span>
</button>
}
}
</div>
}
@for (att of attachmentsList; track att.id) {
@if (att.isImage) {
@if (shouldShowAttachmentInList(att)) {
@if (isImageLikeAttachment(att) && !imageGridLayout().useGrid) {
@if (att.available && att.objectUrl) {
<div
class="group/img relative inline-block"
@@ -464,6 +526,7 @@
}
}
}
}
}
</div>
}

View File

@@ -191,6 +191,96 @@
}
}
.chat-image-grid {
display: grid;
gap: 4px;
width: min(100%, 22rem);
overflow: hidden;
border-radius: var(--radius);
}
.chat-image-grid--pair {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.chat-image-grid--triple {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.chat-image-grid--quad {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.chat-image-grid--triple .chat-image-grid-cell:last-child {
grid-column: 1 / -1;
}
.chat-image-grid-cell {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: calc(var(--radius) - 2px);
background: hsl(var(--secondary) / 0.45);
}
.chat-image-grid-image {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
object-fit: cover;
}
.chat-image-grid-overflow {
display: grid;
place-items: center;
border: none;
background: hsl(var(--secondary) / 0.8);
color: hsl(var(--foreground));
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: hsl(var(--secondary));
}
}
.chat-image-grid-overflow-label {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.chat-image-grid-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem;
background: hsl(var(--secondary) / 0.45);
}
.chat-image-grid-loading-label {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--primary));
}
.chat-image-grid-retry {
border: none;
border-radius: calc(var(--radius) - 4px);
background: hsl(var(--secondary));
padding: 0.25rem 0.5rem;
font-size: 0.6875rem;
color: hsl(var(--foreground));
cursor: pointer;
&:hover {
background: hsl(var(--secondary) / 0.8);
}
}
.edit-textarea {
min-height: 42px;
max-height: 520px;

View File

@@ -41,6 +41,11 @@ import {
MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES,
MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../../../attachment';
import {
dedupeImageAttachmentsForDisplay,
isImageAttachment,
isInlineDisplayableImage
} from '../../../../../attachment/domain/logic/attachment-image.rules';
import { PlatformService, ViewportService } from '../../../../../../core/platform';
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { ExperimentalMediaSettingsService } from '../../../../../experimental-media';
@@ -72,11 +77,18 @@ import {
} from '../../../../../../shared';
import { ChatMessageMarkdownComponent } from './chat-message-markdown/chat-message-markdown.component';
import { ChatLinkEmbedComponent } from './chat-link-embed/chat-link-embed.component';
import {
buildChatMessageImageGridLayout,
formatChatMessageImageOverflowLabel,
type ChatMessageImageGridCell
} from '../../../../domain/rules/chat-message-image-grid.rules';
import {
ChatMessageDeleteEvent,
ChatMessageEditEvent,
ChatMessageEmbedRemoveEvent,
ChatMessageImageContextMenuEvent,
ChatMessageImageGalleryEvent,
ChatMessageImageLightboxEvent,
ChatMessageReactionEvent,
ChatMessageReplyEvent
} from '../../models/chat-messages.model';
@@ -193,7 +205,8 @@ export class ChatMessageItemComponent implements OnDestroy {
readonly reactionToggled = output<ChatMessageReactionEvent>();
readonly referenceRequested = output<string>();
readonly downloadRequested = output<Attachment>();
readonly imageOpened = output<Attachment>();
readonly imageOpened = output<ChatMessageImageLightboxEvent>();
readonly imageGalleryOpened = output<ChatMessageImageGalleryEvent>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
@@ -238,6 +251,42 @@ 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)
);
readonly imageGridLayout = computed(() => buildChatMessageImageGridLayout(this.imageAttachments().length));
private readonly hydrateMessageImages = effect(() => {
const messageId = this.message().id;
const images = this.imageAttachments();
void this.attachmentVersion();
for (const image of images) {
if (isInlineDisplayableImage(image)) {
continue;
}
const liveAttachment = this.getLiveAttachment(image.id);
if (!liveAttachment) {
continue;
}
void this.attachmentsSvc.tryRestoreAttachmentFromLocal(liveAttachment);
}
if (images.some((image) => !isInlineDisplayableImage(image))) {
void this.attachmentsSvc.queueAutoDownloadsForMessage(messageId);
}
});
private readonly syncAttachmentVersion = effect(() => {
const version = this.attachmentsSvc.updated();
@@ -694,9 +743,54 @@ export class ChatMessageItemComponent implements OnDestroy {
}
openLightbox(attachment: Attachment): void {
if (attachment.available && attachment.objectUrl) {
this.imageOpened.emit(attachment);
const attachments = this.displayableImages();
if (!attachment.available || !attachment.objectUrl || attachments.length === 0) {
return;
}
this.imageOpened.emit({
attachment,
attachments
});
}
openImageGallery(): void {
const images = this.displayableImages();
if (images.length < 2) {
return;
}
this.imageGalleryOpened.emit(images);
}
imageAttachmentAt(index: number): ChatMessageAttachmentViewModel | undefined {
return this.imageAttachments()[index];
}
isDisplayableImage(attachment: ChatMessageAttachmentViewModel): boolean {
return isInlineDisplayableImage(attachment);
}
isImageLikeAttachment(attachment: ChatMessageAttachmentViewModel): boolean {
return isImageAttachment(attachment);
}
shouldShowAttachmentInList(attachment: ChatMessageAttachmentViewModel): boolean {
if (this.imageGridLayout().useGrid && isImageAttachment(attachment)) {
return false;
}
return true;
}
imageGridCellTrack(cell: ChatMessageImageGridCell): string {
return cell.kind === 'image' ? `image-${cell.index}` : `overflow-${cell.hiddenCount}`;
}
imageOverflowLabel(hiddenCount: number): string {
return formatChatMessageImageOverflowLabel(hiddenCount);
}
openImageContextMenu(event: MouseEvent, attachment: Attachment): void {

View File

@@ -76,6 +76,7 @@
(referenceRequested)="handleReferenceRequested($event)"
(downloadRequested)="handleDownloadRequested($event)"
(imageOpened)="handleImageOpened($event)"
(imageGalleryOpened)="handleImageGalleryOpened($event)"
(imageContextMenuRequested)="handleImageContextMenuRequested($event)"
(embedRemoved)="handleEmbedRemoved($event)"
/>

View File

@@ -28,6 +28,8 @@ import {
ChatMessageEditEvent,
ChatMessageEmbedRemoveEvent,
ChatMessageImageContextMenuEvent,
ChatMessageImageGalleryEvent,
ChatMessageImageLightboxEvent,
ChatMessageReactionEvent,
ChatMessageReplyEvent
} from '../../models/chat-messages.model';
@@ -87,7 +89,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
readonly reactionAdded = output<ChatMessageReactionEvent>();
readonly reactionToggled = output<ChatMessageReactionEvent>();
readonly downloadRequested = output<Attachment>();
readonly imageOpened = output<Attachment>();
readonly imageOpened = output<ChatMessageImageLightboxEvent>();
readonly imageGalleryOpened = output<ChatMessageImageGalleryEvent>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
/**
@@ -499,8 +502,12 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.downloadRequested.emit(attachment);
}
handleImageOpened(attachment: Attachment): void {
this.imageOpened.emit(attachment);
handleImageOpened(event: ChatMessageImageLightboxEvent): void {
this.imageOpened.emit(event);
}
handleImageGalleryOpened(attachments: ChatMessageImageGalleryEvent): void {
this.imageGalleryOpened.emit(attachments);
}
handleImageContextMenuRequested(event: ChatMessageImageContextMenuEvent): void {

View File

@@ -1,24 +1,110 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/prefer-ngsrc -->
@if (lightboxAttachment()) {
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
(click)="closeLightbox()"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
(keydown.escape)="closeLightbox()"
tabindex="0"
>
@if (galleryAttachments()) {
<app-modal-backdrop
[zIndex]="100"
ariaLabel="Close image gallery"
(dismissed)="closeGallery()"
/>
<div class="pointer-events-none fixed inset-0 z-[101] flex items-center justify-center p-4">
<div
class="relative max-h-[90vh] max-w-[90vw]"
class="pointer-events-auto relative flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
(click)="$event.stopPropagation()"
>
<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>
</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"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
<div class="overflow-y-auto p-4">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
@for (attachment of galleryAttachments(); track attachment.id) {
<button
type="button"
class="group/gallery relative aspect-square overflow-hidden rounded-md bg-secondary/40"
[attr.aria-label]="'Open ' + attachment.filename"
(click)="openGalleryImage(attachment)"
(contextmenu)="openImageContextMenu($event, attachment)"
>
<img
[src]="attachment.objectUrl"
[alt]="attachment.filename"
class="h-full w-full object-cover transition-transform duration-200 group-hover/gallery:scale-[1.02]"
/>
<div class="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover/gallery:bg-black/15"></div>
</button>
}
</div>
</div>
</div>
</div>
}
@if (lightboxAttachment()) {
<app-modal-backdrop
[zIndex]="109"
ariaLabel="Close image preview"
(dismissed)="closeLightbox()"
/>
<div class="pointer-events-none fixed inset-0 z-[110] flex items-center justify-center p-4">
<div
class="lightbox-stage pointer-events-auto relative max-h-[90vh] max-w-[90vw]"
[class.lightbox-chrome-hidden]="!lightboxControlsVisible()"
(click)="$event.stopPropagation()"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
>
@if (canShowPreviousLightboxImage()) {
<button
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"
>
<ng-icon
name="lucideChevronLeft"
class="h-5 w-5"
/>
</button>
}
<img
[src]="lightboxAttachment()!.objectUrl"
[alt]="lightboxAttachment()!.filename"
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!); $event.stopPropagation()"
/>
<div class="absolute right-3 top-3 flex gap-2">
@if (canShowNextLightboxImage()) {
<button
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"
>
<ng-icon
name="lucideChevronRight"
class="h-5 w-5"
/>
</button>
}
<div class="lightbox-chrome absolute right-3 top-3 flex gap-2">
<button
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"
@@ -29,9 +115,11 @@
/>
</button>
<button
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"
>
<ng-icon
name="lucideX"
@@ -39,11 +127,16 @@
/>
</button>
</div>
<div class="absolute bottom-3 left-3 right-3 flex items-center justify-between">
<div class="lightbox-chrome absolute bottom-3 left-3 right-3 flex items-center justify-between gap-3">
<div class="rounded-lg bg-black/60 px-3 py-1.5 backdrop-blur-sm">
<span class="text-sm text-white">{{ lightboxAttachment()!.filename }}</span>
<span class="ml-2 text-xs text-white/60">{{ formatBytes(lightboxAttachment()!.size) }}</span>
</div>
@if (lightboxPositionLabel()) {
<div class="rounded-lg bg-black/60 px-3 py-1.5 text-sm text-white backdrop-blur-sm">
{{ lightboxPositionLabel() }}
</div>
}
</div>
</div>
</div>

View File

@@ -0,0 +1,18 @@
.lightbox-stage {
cursor: default;
}
.lightbox-stage.lightbox-chrome-hidden {
cursor: none;
}
.lightbox-chrome {
opacity: 1;
pointer-events: auto;
transition: opacity 0.2s ease;
}
.lightbox-stage.lightbox-chrome-hidden .lightbox-chrome {
opacity: 0;
pointer-events: none;
}

View File

@@ -1,18 +1,31 @@
import { CommonModule } from '@angular/common';
import {
Component,
HostListener,
OnDestroy,
computed,
effect,
input,
output
output,
signal
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideChevronLeft,
lucideChevronRight,
lucideCopy,
lucideDownload,
lucideX
} from '@ng-icons/lucide';
import { Attachment } from '../../../../../attachment';
import { ContextMenuComponent } from '../../../../../../shared';
import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.model';
import { canStepLightbox } from '../../../../domain/rules/chat-message-lightbox.rules';
import { ContextMenuComponent, ModalBackdropComponent } from '../../../../../../shared';
import {
ChatLightboxState,
ChatMessageImageGalleryEvent,
ChatMessageImageContextMenuEvent,
ChatMessageImageLightboxEvent
} from '../../models/chat-messages.model';
@Component({
selector: 'app-chat-message-overlays',
@@ -20,34 +33,169 @@ import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.mod
imports: [
CommonModule,
NgIcon,
ContextMenuComponent
ContextMenuComponent,
ModalBackdropComponent
],
viewProviders: [
provideIcons({
lucideChevronLeft,
lucideChevronRight,
lucideCopy,
lucideDownload,
lucideX
})
],
templateUrl: './chat-message-overlays.component.html',
styleUrl: './chat-message-overlays.component.scss',
host: {
style: 'display: contents;'
}
})
export class ChatMessageOverlaysComponent {
readonly lightboxAttachment = input<Attachment | null>(null);
export class ChatMessageOverlaysComponent implements OnDestroy {
readonly lightboxControlsVisible = signal(true);
readonly lightboxState = input<ChatLightboxState | null>(null);
readonly galleryAttachments = input<ChatMessageImageGalleryEvent | null>(null);
readonly imageContextMenu = input<ChatMessageImageContextMenuEvent | null>(null);
readonly lightboxClosed = output();
readonly lightboxStepRequested = output<number>();
readonly galleryClosed = output();
readonly contextMenuClosed = output();
readonly downloadRequested = output<Attachment>();
readonly copyRequested = output<Attachment>();
readonly imageOpened = output<ChatMessageImageLightboxEvent>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly lightboxAttachment = computed(() => {
const state = this.lightboxState();
if (!state) {
return null;
}
return state.attachments[state.index] ?? null;
});
readonly canShowPreviousLightboxImage = computed(() => {
const state = this.lightboxState();
if (!state) {
return false;
}
return canStepLightbox(state.index, -1, state.attachments.length);
});
readonly canShowNextLightboxImage = computed(() => {
const state = this.lightboxState();
if (!state) {
return false;
}
return canStepLightbox(state.index, 1, state.attachments.length);
});
readonly lightboxPositionLabel = computed(() => {
const state = this.lightboxState();
if (!state || state.attachments.length <= 1) {
return '';
}
return `${state.index + 1} / ${state.attachments.length}`;
});
private readonly LIGHTBOX_CONTROLS_IDLE_MS = 2200;
private lightboxControlsHideTimer: ReturnType<typeof setTimeout> | null = null;
private readonly syncLightboxControls = effect(() => {
if (this.lightboxState()) {
this.revealLightboxControlsTemporarily();
return;
}
this.clearLightboxControlsHideTimer();
this.lightboxControlsVisible.set(true);
});
@HostListener('document:mousemove')
onDocumentMouseMove(): void {
if (!this.lightboxState()) {
return;
}
this.revealLightboxControlsTemporarily();
}
@HostListener('document:keydown.escape')
onEscapePressed(): void {
if (this.lightboxState()) {
this.closeLightbox();
return;
}
if (this.galleryAttachments()) {
this.closeGallery();
}
}
@HostListener('document:keydown.arrowleft')
onArrowLeftPressed(): void {
if (!this.lightboxState()) {
return;
}
this.showPreviousLightboxImage();
}
@HostListener('document:keydown.arrowright')
onArrowRightPressed(): void {
if (!this.lightboxState()) {
return;
}
this.showNextLightboxImage();
}
closeLightbox(): void {
this.lightboxClosed.emit();
}
closeGallery(): void {
this.galleryClosed.emit();
}
showPreviousLightboxImage(): void {
if (!this.canShowPreviousLightboxImage()) {
return;
}
this.lightboxStepRequested.emit(-1);
}
showNextLightboxImage(): void {
if (!this.canShowNextLightboxImage()) {
return;
}
this.lightboxStepRequested.emit(1);
}
openGalleryImage(attachment: Attachment): void {
const attachments = this.galleryAttachments()?.filter((entry) => entry.available && entry.objectUrl) ?? [];
if (!attachment.available || !attachment.objectUrl || attachments.length === 0) {
return;
}
this.imageOpened.emit({
attachment,
attachments
});
}
closeImageContextMenu(): void {
this.contextMenuClosed.emit();
}
@@ -88,4 +236,24 @@ export class ChatMessageOverlaysComponent {
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
ngOnDestroy(): void {
this.clearLightboxControlsHideTimer();
}
private revealLightboxControlsTemporarily(): void {
this.lightboxControlsVisible.set(true);
this.clearLightboxControlsHideTimer();
this.lightboxControlsHideTimer = setTimeout(() => {
this.lightboxControlsHideTimer = null;
this.lightboxControlsVisible.set(false);
}, this.LIGHTBOX_CONTROLS_IDLE_MS);
}
private clearLightboxControlsHideTimer(): void {
if (this.lightboxControlsHideTimer) {
clearTimeout(this.lightboxControlsHideTimer);
this.lightboxControlsHideTimer = null;
}
}
}

View File

@@ -27,6 +27,18 @@ export interface ChatMessageImageContextMenuEvent {
attachment: Attachment;
}
export type ChatMessageImageGalleryEvent = Attachment[];
export interface ChatMessageImageLightboxEvent {
attachment: Attachment;
attachments: Attachment[];
}
export interface ChatLightboxState {
attachments: Attachment[];
index: number;
}
export type ChatMessageReplyEvent = Message;
export type ChatMessageDeleteEvent = Message;

View File

@@ -8,7 +8,7 @@ Direct calls coordinate private voice sessions started from people cards, direct
2. The caller joins a call-scoped voice session and sends a `direct-call` ring event through `PeerDeliveryService`. Joining a direct call first leaves any other joined call or server voice channel.
3. The caller and recipient both record a direct-message `call-started` system entry for the call's conversation, so the chat history shows who started the call without creating a normal text message.
4. The recipient stores the incoming session, loops `assets/audio/call.wav`, shows an in-app answer/decline modal, and shows a desktop notification when permission allows. If the recipient is set to Do Not Disturb (`status: "busy"`), the session is stored silently without call audio, the in-app modal, or a desktop notification. Ring events received before the current user identity is hydrated are queued and replayed once identity is available. The ring stops when the recipient joins, declines, leaves, or the call ends; stale duplicate ring events for a locally ended call are ignored.
5. Opening `/call/:callId` shows the private call surface with portraits, voice indicators, media controls, screen/camera tiles, add-user control, and a narrow DM chat panel.
5. Opening `/call/:callId` shows the private call surface with portraits, voice indicators, media controls (mute, deafen, camera, screen share), screen/camera tiles, add-user control, and a narrow DM chat panel. Deafen mutes incoming audio and also mutes the local mic, matching voice-channel behavior.
6. If a third participant is invited, the call creates a fresh empty group conversation and switches the call chat panel to it. Existing one-to-one messages stay in the original PM and are not copied into the group chat.
7. Starting a call from a group chat uses the group conversation id as the call id and rings every other participant.
8. Joining, leaving, ending, participant additions, and call chat conversion updates are mirrored as `direct-call` events over the same P2P/signaling fallback path used by direct messages.

View File

@@ -1,6 +1,6 @@
<section
appThemeNode="dmChatSurface"
class="chat-layout relative h-full bg-background"
class="chat-layout relative h-full min-w-0 overflow-x-hidden bg-background"
>
<header
appThemeNode="dmChatHeader"
@@ -78,6 +78,7 @@
(reactionToggled)="handleReactionToggled($event)"
(downloadRequested)="downloadAttachment($event)"
(imageOpened)="openLightbox($event)"
(imageGalleryOpened)="openImageGallery($event)"
(imageContextMenuRequested)="openImageContextMenu($event)"
(embedRemoved)="handleEmbedRemoved($event)"
/>
@@ -93,7 +94,7 @@
<div
appThemeNode="chatComposerBar"
class="chat-bottom-bar absolute bottom-0 left-0 right-2 z-10 bg-background/85 backdrop-blur-md"
class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10 min-w-0 bg-background/85 backdrop-blur-md"
>
@if (typingUsers().length > 0) {
<div
@@ -156,12 +157,16 @@
}
<app-chat-message-overlays
[lightboxAttachment]="lightboxAttachment()"
[lightboxState]="lightboxState()"
[galleryAttachments]="galleryAttachments()"
[imageContextMenu]="imageContextMenu()"
(lightboxClosed)="closeLightbox()"
(lightboxStepRequested)="stepLightbox($event)"
(galleryClosed)="closeImageGallery()"
(contextMenuClosed)="closeImageContextMenu()"
(downloadRequested)="downloadAttachment($event)"
(copyRequested)="copyImageToClipboard($event)"
(imageOpened)="openLightbox($event)"
(imageContextMenuRequested)="openImageContextMenu($event)"
/>
} @else {

View File

@@ -45,6 +45,8 @@ import {
LinkMetadataService,
type ChatMessageEmbedRemoveEvent
} from '../../../chat';
import { stepLightboxIndex } from '../../../chat/domain/rules/chat-message-lightbox.rules';
import { ChatLightboxState, ChatMessageImageLightboxEvent } from '../../../chat/feature/chat-messages/models/chat-messages.model';
import type {
DirectMessageStatus,
LinkMetadata,
@@ -102,7 +104,8 @@ export class DmChatComponent {
readonly gifPickerAnchorRight = signal(16);
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
readonly replyTo = signal<Message | null>(null);
readonly lightboxAttachment = signal<Attachment | null>(null);
readonly lightboxState = signal<ChatLightboxState | null>(null);
readonly galleryAttachments = signal<Attachment[] | null>(null);
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId')
@@ -395,14 +398,55 @@ export class DmChatComponent {
}));
}
openLightbox(attachment: Attachment): void {
if (attachment.available && attachment.objectUrl) {
this.lightboxAttachment.set(attachment);
openLightbox(event: ChatMessageImageLightboxEvent): void {
const attachments = event.attachments.filter((attachment) => attachment.available && attachment.objectUrl);
const index = attachments.findIndex((attachment) => attachment.id === event.attachment.id);
if (index < 0) {
return;
}
this.lightboxState.set({
attachments,
index
});
}
closeLightbox(): void {
this.lightboxAttachment.set(null);
this.lightboxState.set(null);
}
stepLightbox(delta: number): void {
const state = this.lightboxState();
if (!state) {
return;
}
const nextIndex = stepLightboxIndex(state.index, delta, state.attachments.length);
if (nextIndex === null) {
return;
}
this.lightboxState.set({
attachments: state.attachments,
index: nextIndex
});
}
openImageGallery(attachments: Attachment[]): void {
const availableImages = attachments.filter((attachment) => attachment.available && attachment.objectUrl);
if (availableImages.length < 2) {
return;
}
this.galleryAttachments.set(availableImages);
}
closeImageGallery(): void {
this.galleryAttachments.set(null);
}
openImageContextMenu(event: ChatMessageImageContextMenuEvent): void {

View File

@@ -12,7 +12,7 @@
<swiper-slide class="block h-full w-full">
<div class="flex h-full w-full min-h-0 overflow-hidden">
<app-servers-rail class="block h-full shrink-0" />
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border">
<div class="flex min-h-0 min-w-0 flex-1 overflow-hidden border-l border-border">
<app-dm-conversations-panel
(conversationSelected)="setMobilePage('chat')"
class="block h-full w-full"

View File

@@ -60,24 +60,4 @@
</div>
</ng-template>
@if (isMobile()) {
<swiper-container
class="block h-full min-h-0 w-full bg-background"
slides-per-view="1"
space-between="0"
initial-slide="0"
threshold="10"
resistance-ratio="0"
>
<swiper-slide class="block h-full w-full">
<div class="flex h-full w-full min-h-0 overflow-hidden">
<app-servers-rail class="block h-full shrink-0" />
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border">
<ng-container [ngTemplateOutlet]="pageContent" />
</div>
</div>
</swiper-slide>
</swiper-container>
} @else {
<ng-container [ngTemplateOutlet]="pageContent" />
}
<ng-container [ngTemplateOutlet]="pageContent" />

View File

@@ -13,7 +13,6 @@ import {
import { Store } from '@ngrx/store';
import { FindPeopleComponent } from './find-people.component';
import { ViewportService } from '../../../../core/platform';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import type { User, Room } from '../../../../shared-kernel';
@@ -21,7 +20,6 @@ import type { User, Room } from '../../../../shared-kernel';
interface HarnessOptions {
users?: User[];
saved?: Room[];
isMobile?: boolean;
}
function createHarness(options: HarnessOptions = {}) {
@@ -44,8 +42,7 @@ function createHarness(options: HarnessOptions = {}) {
const injector = Injector.create({
providers: [
FindPeopleComponent,
{ provide: Store, useValue: store },
{ provide: ViewportService, useValue: { isMobile: signal(options.isMobile ?? false) } }
{ provide: Store, useValue: store }
]
});
const component = runInInjectionContext(injector, () => injector.get(FindPeopleComponent));
@@ -54,11 +51,6 @@ function createHarness(options: HarnessOptions = {}) {
}
describe('FindPeopleComponent', () => {
it('exposes the mobile viewport flag', () => {
expect(createHarness().component.isMobile()).toBe(false);
expect(createHarness({ isMobile: true }).component.isMobile()).toBe(true);
});
it('has no discoverable people for a brand-new account', () => {
const { component } = createHarness();

View File

@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
CUSTOM_ELEMENTS_SCHEMA,
Component,
computed,
inject,
@@ -18,16 +17,13 @@ import {
} from '@ng-icons/lucide';
import { UserSearchListComponent } from '../user-search-list/user-search-list.component';
import { ServersRailComponent } from '../../../../features/servers/servers-rail/servers-rail.component';
import { ViewportService } from '../../../../core/platform';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
/**
* Dedicated people-discovery page. Wraps {@link UserSearchListComponent} with a search
* field and an onboarding empty state for accounts that have not joined any servers yet.
* On mobile the page is mounted inside a Swiper slide alongside the servers rail so the
* primary navigation stays reachable, matching the chat-room and DM workspace layouts.
* On mobile the global app-shell servers rail stays visible beside this page.
*/
@Component({
selector: 'app-find-people',
@@ -37,18 +33,16 @@ import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
FormsModule,
RouterLink,
NgIcon,
UserSearchListComponent,
ServersRailComponent
UserSearchListComponent
],
viewProviders: [provideIcons({ lucideArrowLeft, lucideSearch, lucideUsers })],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './find-people.component.html'
templateUrl: './find-people.component.html',
host: {
class: 'block h-full min-h-0 min-w-0 w-full overflow-hidden'
}
})
export class FindPeopleComponent {
private store = inject(Store);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
searchQuery = signal('');
private users = this.store.selectSignal(selectAllUsers);
private savedRooms = this.store.selectSignal(selectSavedRooms);

View File

@@ -28,24 +28,4 @@
</div>
</ng-template>
@if (isMobile()) {
<swiper-container
class="block h-full min-h-0 w-full bg-background"
slides-per-view="1"
space-between="0"
initial-slide="0"
threshold="10"
resistance-ratio="0"
>
<swiper-slide class="block h-full w-full">
<div class="flex h-full w-full min-h-0 overflow-hidden">
<app-servers-rail class="block h-full shrink-0" />
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border">
<ng-container [ngTemplateOutlet]="pageContent" />
</div>
</div>
</swiper-slide>
</swiper-container>
} @else {
<ng-container [ngTemplateOutlet]="pageContent" />
}
<ng-container [ngTemplateOutlet]="pageContent" />

View File

@@ -14,7 +14,6 @@ import { Store } from '@ngrx/store';
import { of } from 'rxjs';
import { FindServersComponent } from './find-servers.component';
import { ViewportService } from '../../../../core/platform';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import type { ServerInfo } from '../../domain/models/server-directory.model';
@@ -32,7 +31,6 @@ interface HarnessOptions {
saved?: Room[];
featured?: ServerInfo[];
trending?: ServerInfo[];
isMobile?: boolean;
}
function createHarness(options: HarnessOptions = {}) {
@@ -49,8 +47,7 @@ function createHarness(options: HarnessOptions = {}) {
providers: [
FindServersComponent,
{ provide: Store, useValue: store },
{ provide: ServerDirectoryFacade, useValue: serverDirectory },
{ provide: ViewportService, useValue: { isMobile: signal(options.isMobile ?? false) } }
{ provide: ServerDirectoryFacade, useValue: serverDirectory }
]
});
const component = runInInjectionContext(injector, () => injector.get(FindServersComponent));
@@ -59,11 +56,6 @@ function createHarness(options: HarnessOptions = {}) {
}
describe('FindServersComponent', () => {
it('exposes the mobile viewport flag', () => {
expect(createHarness().component.isMobile()).toBe(false);
expect(createHarness({ isMobile: true }).component.isMobile()).toBe(true);
});
it('builds featured and trending sections after init', () => {
const { component } = createHarness({
featured: [makeServer('f1')],

View File

@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
CUSTOM_ELEMENTS_SCHEMA,
Component,
computed,
inject,
@@ -14,8 +13,6 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideArrowLeft } from '@ng-icons/lucide';
import { ServerBrowserComponent, type ServerDiscoverySection } from '../server-browser/server-browser.component';
import { ServersRailComponent } from '../../../../features/servers/servers-rail/servers-rail.component';
import { ViewportService } from '../../../../core/platform';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import type { Room } from '../../../../shared-kernel';
@@ -27,8 +24,7 @@ const RECENT_SERVER_LIMIT = 6;
/**
* Dedicated server-discovery page. Hosts the reusable {@link ServerBrowserComponent}
* and feeds it featured, trending, and recently-active discovery sections. On mobile the
* page is mounted inside a Swiper slide alongside the servers rail so the primary
* navigation stays reachable, matching the chat-room and DM workspace layouts.
* global app-shell servers rail stays visible beside this page.
*/
@Component({
selector: 'app-find-servers',
@@ -37,19 +33,17 @@ const RECENT_SERVER_LIMIT = 6;
CommonModule,
RouterLink,
NgIcon,
ServerBrowserComponent,
ServersRailComponent
ServerBrowserComponent
],
viewProviders: [provideIcons({ lucideArrowLeft })],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './find-servers.component.html'
templateUrl: './find-servers.component.html',
host: {
class: 'block h-full min-h-0 min-w-0 w-full overflow-hidden'
}
})
export class FindServersComponent implements OnInit {
private store = inject(Store);
private serverDirectory = inject(ServerDirectoryFacade);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
featured = signal<ServerInfo[]>([]);
trending = signal<ServerInfo[]>([]);
savedRooms = this.store.selectSignal(selectSavedRooms);

View File

@@ -44,9 +44,9 @@ theme/
## Built-in presets
`theme-defaults.logic.ts` exports `BUILT_IN_THEME_PRESETS` for themes that ship with the app and do not depend on the Electron saved-theme library. The default preset is `Toju Website Dark`, which mirrors the website palette and removes the previous green radial chat background bubble. The previous app default remains available as `Toju Default Dark` and can be applied from Theme Studio.
`theme-defaults.logic.ts` exports `BUILT_IN_THEME_PRESETS` for themes that ship with the app and do not depend on the Electron saved-theme library. The default preset is `Toju Default Dark 11`, a blue-accented dark glass shell with hover tokens (`secondary`, `accent`) lifted above the background and card surfaces. `Toju Website Dark` and the legacy cyan `Toju Default Dark` presets remain available from Theme Studio.
The importable artifact at `project-files/themes/toju-website-dark.json` is kept byte-for-byte aligned with `DEFAULT_THEME_JSON` by the ThemeService spec.
The importable artifact at `project-files/themes/toju-default-dark-11.json` is kept byte-for-byte aligned with `DEFAULT_THEME_JSON` by the ThemeService spec.
## Layer composition

View File

@@ -36,55 +36,55 @@ describe('ThemeService theme application', () => {
vi.unstubAllGlobals();
});
it('uses the website dark theme as the built-in default JSON', () => {
it('uses the default dark 11 theme as the built-in default JSON', () => {
const defaultTheme = JSON.parse(DEFAULT_THEME_JSON) as Record<string, unknown>;
expect(defaultTheme['css']).toEqual(expect.not.stringContaining('radial-gradient'));
expect(defaultTheme).toEqual(expect.objectContaining({
meta: {
name: 'Toju Website Dark',
version: '1.0.0',
description: 'Website-inspired dark app theme using the charcoal, green, and amber palette from the public Toju site.'
name: 'Toju Default Dark 11',
version: '2.0.0',
description: 'Built-in dark glass theme for the full Toju app shell.'
},
tokens: {
colors: {
background: '210 18% 7%',
foreground: '42 33% 94%',
card: '210 17% 10%',
cardForeground: '42 33% 94%',
popover: '210 17% 9%',
popoverForeground: '42 33% 94%',
primary: '154 49% 55%',
primaryForeground: '210 18% 7%',
secondary: '210 14% 15%',
secondaryForeground: '42 33% 94%',
muted: '210 14% 15%',
mutedForeground: '42 13% 67%',
accent: '38 64% 61%',
accentForeground: '210 18% 7%',
destructive: '0 72% 55%',
background: '225 6% 12%',
foreground: '210 40% 96%',
card: '220 6% 18%',
cardForeground: '210 40% 96%',
popover: '220 6% 18%',
popoverForeground: '210 40% 96%',
primary: '234 85% 64%',
primaryForeground: '222 47% 11%',
secondary: '222 10% 24%',
secondaryForeground: '210 40% 96%',
muted: '223 18% 14%',
mutedForeground: '215 20% 70%',
accent: '234 32% 28%',
accentForeground: '210 40% 98%',
destructive: '358 82% 59%',
destructiveForeground: '0 0% 100%',
border: '210 13% 22%',
input: '210 13% 22%',
ring: '154 49% 55%',
railBackground: '210 19% 6%',
workspaceBackground: '210 18% 8%',
panelBackground: '210 17% 10%',
panelBackgroundAlt: '210 14% 13%',
titleBarBackground: '210 19% 6%',
surfaceHighlight: '154 49% 55%',
surfaceHighlightAlt: '38 64% 61%'
border: '222 18% 22%',
input: '222 18% 22%',
ring: '234 85% 64%',
railBackground: '225 6% 12%',
workspaceBackground: '220 6% 18%',
panelBackground: '220 6% 18%',
panelBackgroundAlt: '225 6% 15%',
titleBarBackground: '225 6% 9%',
surfaceHighlight: '234 85% 64%',
surfaceHighlightAlt: '261 82% 72%'
},
spacing: {},
radii: {
radius: '0.6rem',
surface: '0.85rem',
radius: '0.875rem',
surface: '1.35rem',
pill: '999px'
},
effects: {
panelShadow: '0 28px 64px rgba(0, 0, 0, 0.34)',
softShadow: '0 16px 36px rgba(0, 0, 0, 0.22)',
glassBlur: 'blur(16px) saturate(125%)'
panelShadow: '0 24px 60px rgba(0, 0, 0, 0.42)',
softShadow: '0 14px 36px rgba(0, 0, 0, 0.28)',
glassBlur: 'blur(18px) saturate(135%)'
}
},
layout: expect.objectContaining({
@@ -116,9 +116,13 @@ describe('ThemeService theme application', () => {
}));
});
it('exposes both built-in theme presets and applies the legacy default preset', () => {
expect(BUILT_IN_THEME_PRESETS.map((preset) => preset.theme.meta.name)).toEqual(['Toju Website Dark', 'Toju Default Dark']);
expect(service.activeThemeName()).toBe('Toju Website Dark');
it('exposes all built-in theme presets and applies the legacy default preset', () => {
expect(BUILT_IN_THEME_PRESETS.map((preset) => preset.theme.meta.name)).toEqual([
'Toju Default Dark 11',
'Toju Website Dark',
'Toju Default Dark'
]);
expect(service.activeThemeName()).toBe('Toju Default Dark 11');
const applied = service.applyBuiltInPreset('Toju Default Dark');
@@ -130,23 +134,34 @@ describe('ThemeService theme application', () => {
});
});
it('resets to the website dark preset as the new default', () => {
expect(service.applyBuiltInPreset('Toju Default Dark')).toBe(true);
it('resets to the default dark 11 preset as the built-in default', () => {
expect(service.applyBuiltInPreset('Toju Website Dark')).toBe(true);
service.resetToDefault();
expect(service.activeThemeName()).toBe('Toju Website Dark');
expect(service.activeThemeName()).toBe('Toju Default Dark 11');
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '210 18% 7%',
'--primary': '154 49% 55%',
'--accent': '38 64% 61%'
'--background': '225 6% 12%',
'--primary': '234 85% 64%',
'--secondary': '222 10% 24%',
'--accent': '234 32% 28%'
});
expect(service.activeThemeText()).not.toContain('radial-gradient');
});
it('keeps the importable website dark artifact aligned with the default preset', () => {
const artifact = JSON.parse(readFileSync(resolve('../project-files/themes/toju-website-dark.json'), 'utf8')) as unknown;
it('keeps hover tokens visually distinct from the background surface', () => {
const defaultTheme = createDefaultThemeDocument();
const { background, card, secondary, accent } = defaultTheme.tokens.colors;
expect(secondary).not.toBe(background);
expect(secondary).not.toBe(card);
expect(accent).not.toBe(background);
expect(accent).not.toBe(card);
});
it('keeps the importable default dark 11 artifact aligned with the default preset', () => {
const artifact = JSON.parse(readFileSync(resolve('../project-files/themes/toju-default-dark-11.json'), 'utf8')) as unknown;
const defaultTheme = JSON.parse(DEFAULT_THEME_JSON) as unknown;
expect(artifact).toEqual(defaultTheme);
@@ -165,14 +180,14 @@ describe('ThemeService theme application', () => {
const applied = service.applyCssOnlyTheme('.css-only-theme { background: hsl(var(--background)); }');
expect(applied).toBe(true);
expect(service.activeThemeName()).toBe('Toju Website Dark');
expect(service.activeThemeName()).toBe('Toju Default Dark 11');
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
gridColumn: '5 / span 16',
gridRow: '1 / span 12'
});
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '210 18% 7%'
'--background': '225 6% 12%'
});
expect(styleElements.some((styleElement) => styleElement.textContent === '.css-only-theme { background: hsl(var(--background)); }')).toBe(true);
@@ -319,7 +334,7 @@ describe('ThemeService theme application', () => {
}
});
expect(service.activeThemeName()).toBe('Toju Website Dark');
expect(service.activeThemeName()).toBe('Toju Default Dark 11');
});
it('validates the dedicated DM workspace layout container', () => {
@@ -437,16 +452,17 @@ describe('ThemeService theme application', () => {
expect(service.activeThemeText()).not.toContain('"elements"');
});
it('loads the website dark saved-theme artifact', () => {
const themeText = readFileSync(resolve('../project-files/themes/toju-website-dark.json'), 'utf8');
const loaded = service.loadThemeText(themeText, 'apply', 'Theme applied.', 'website dark saved theme');
it('loads the default dark 11 saved-theme artifact', () => {
const themeText = readFileSync(resolve('../project-files/themes/toju-default-dark-11.json'), 'utf8');
const loaded = service.loadThemeText(themeText, 'apply', 'Theme applied.', 'default dark 11 saved theme');
expect(loaded).toBe(true);
expect(service.activeThemeName()).toBe('Toju Website Dark');
expect(service.activeThemeName()).toBe('Toju Default Dark 11');
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '210 18% 7%',
'--primary': '154 49% 55%',
'--accent': '38 64% 61%'
'--background': '225 6% 12%',
'--primary': '234 85% 64%',
'--secondary': '222 10% 24%',
'--accent': '234 32% 28%'
});
expect(service.getHostStyles('titleBar')).toMatchObject({
@@ -460,7 +476,7 @@ describe('ThemeService theme application', () => {
expect(service.getHostStyles('chatRoomMembersPanel')).toMatchObject({ border: '0' });
expect(service.getHostStyles('chatComposerBar')).toMatchObject({ border: '0' });
expect(service.activeThemeText()).toContain('Website-inspired app shell surfaces');
expect(service.activeThemeText()).toContain('Dark glass app shell surfaces');
});
});

View File

@@ -5,6 +5,98 @@ import {
} from '../models/theme.model';
import { THEME_LAYOUT_CONTAINERS, getLayoutEditableThemeKeys } from './theme-registry.logic';
const DEFAULT_SHELL_CSS = `/* Dark glass app shell surfaces */
app-chat-messages .chat-layout,
app-dm-chat .chat-layout {
background-image: linear-gradient(180deg, hsl(var(--workspace-background) / 0.96), hsl(var(--background)) 38rem) !important;
}
app-chat-message-list > div {
background: transparent !important;
}
app-chat-message-item > div[data-message-id] {
margin: 8px 0 !important;
border: 1px solid hsl(var(--border) / 0.56) !important;
border-radius: 0.7rem !important;
background: hsl(var(--card) / 0.72) !important;
box-shadow: 0 14px 34px rgb(0 0 0 / 0.2) !important;
backdrop-filter: blur(12px) saturate(120%) !important;
-webkit-backdrop-filter: blur(12px) saturate(120%) !important;
}
app-chat-message-item > div[data-message-id]:hover {
border-color: hsl(var(--primary) / 0.42) !important;
background: hsl(var(--secondary) / 0.88) !important;
}
app-chat-message-composer,
.chat-bottom-bar {
border-top: 0 !important;
background: hsl(var(--background) / 0.84) !important;
backdrop-filter: blur(16px) saturate(125%) !important;
-webkit-backdrop-filter: blur(16px) saturate(125%) !important;
}
app-chat-message-composer textarea,
app-chat-message-composer [contenteditable="true"] {
background: transparent !important;
}
`;
function createDefaultShellElements(): Record<string, ThemeElementStyles> {
return {
titleBar: {
border: '0',
backgroundColor: 'hsl(var(--title-bar-background) / 0.88)',
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.22)',
backdropFilter: 'var(--theme-effect-glass-blur)'
},
serversRail: {
border: '0',
backgroundColor: 'hsl(var(--rail-background) / 0.94)',
boxShadow: 'var(--theme-effect-panel-shadow)'
},
appWorkspace: {
backgroundColor: 'hsl(var(--workspace-background))'
},
chatRoomChannelsPanel: {
border: '0',
backgroundColor: 'hsl(var(--panel-background) / 0.86)',
backdropFilter: 'var(--theme-effect-glass-blur)'
},
chatRoomMembersPanel: {
border: '0',
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
backdropFilter: 'var(--theme-effect-glass-blur)'
},
chatRoomMainPanel: {
backgroundColor: 'hsl(var(--workspace-background))'
},
chatSurface: {
backgroundColor: 'transparent'
},
chatMessageBubble: {
border: '1px solid hsl(var(--border) / 0.56)',
borderRadius: '0.7rem',
backgroundColor: 'hsl(var(--card) / 0.72)',
boxShadow: 'var(--theme-effect-soft-shadow)',
backdropFilter: 'blur(12px) saturate(120%)'
},
chatComposerBar: {
border: '0',
backgroundColor: 'hsl(var(--background) / 0.84)',
backdropFilter: 'var(--theme-effect-glass-blur)'
},
chatComposerInput: {
border: '1px solid hsl(var(--border) / 0.6)',
borderRadius: '0.6rem',
backgroundColor: 'hsl(var(--panel-background-alt) / 0.8)',
color: 'hsl(var(--foreground))'
}
};
}
function createProvidedDefaultLayout(): Record<string, ThemeLayoutEntry> {
return {
serversRail: {
@@ -171,46 +263,9 @@ function createLegacyDefaultDarkThemeDocument(): ThemeDocument {
};
}
export function createDefaultThemeDocument(): ThemeDocument {
export function createWebsiteDarkThemeDocument(): ThemeDocument {
return {
css: `/* Website-inspired app shell surfaces */
app-chat-messages .chat-layout,
app-dm-chat .chat-layout {
background-image: linear-gradient(180deg, hsl(var(--workspace-background) / 0.96), hsl(var(--background)) 38rem) !important;
}
app-chat-message-list > div {
background: transparent !important;
}
app-chat-message-item > div[data-message-id] {
margin: 8px 0 !important;
border: 1px solid hsl(var(--border) / 0.56) !important;
border-radius: 0.7rem !important;
background: hsl(var(--card) / 0.72) !important;
box-shadow: 0 14px 34px rgb(0 0 0 / 0.2) !important;
backdrop-filter: blur(12px) saturate(120%) !important;
-webkit-backdrop-filter: blur(12px) saturate(120%) !important;
}
app-chat-message-item > div[data-message-id]:hover {
border-color: hsl(var(--primary) / 0.42) !important;
background: hsl(var(--card) / 0.84) !important;
}
app-chat-message-composer,
.chat-bottom-bar {
border-top: 0 !important;
background: hsl(var(--background) / 0.84) !important;
backdrop-filter: blur(16px) saturate(125%) !important;
-webkit-backdrop-filter: blur(16px) saturate(125%) !important;
}
app-chat-message-composer textarea,
app-chat-message-composer [contenteditable="true"] {
background: hsl(var(--panel-background-alt) / 0.8) !important;
}
`,
css: DEFAULT_SHELL_CSS.replace('Dark glass app shell surfaces', 'Website-inspired app shell surfaces'),
meta: {
name: 'Toju Website Dark',
version: '1.0.0',
@@ -258,56 +313,62 @@ app-chat-message-composer [contenteditable="true"] {
}
},
layout: createDefaultThemeLayout(),
elements: {
titleBar: {
border: '0',
backgroundColor: 'hsl(var(--title-bar-background) / 0.88)',
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.22)',
backdropFilter: 'var(--theme-effect-glass-blur)'
elements: createDefaultShellElements(),
animations: {}
};
}
export function createDefaultThemeDocument(): ThemeDocument {
return {
css: DEFAULT_SHELL_CSS,
meta: {
name: 'Toju Default Dark 11',
version: '2.0.0',
description: 'Built-in dark glass theme for the full Toju app shell.'
},
tokens: {
colors: {
background: '225 6% 12%',
foreground: '210 40% 96%',
card: '220 6% 18%',
cardForeground: '210 40% 96%',
popover: '220 6% 18%',
popoverForeground: '210 40% 96%',
primary: '234 85% 64%',
primaryForeground: '222 47% 11%',
secondary: '222 10% 24%',
secondaryForeground: '210 40% 96%',
muted: '223 18% 14%',
mutedForeground: '215 20% 70%',
accent: '234 32% 28%',
accentForeground: '210 40% 98%',
destructive: '358 82% 59%',
destructiveForeground: '0 0% 100%',
border: '222 18% 22%',
input: '222 18% 22%',
ring: '234 85% 64%',
railBackground: '225 6% 12%',
workspaceBackground: '220 6% 18%',
panelBackground: '220 6% 18%',
panelBackgroundAlt: '225 6% 15%',
titleBarBackground: '225 6% 9%',
surfaceHighlight: '234 85% 64%',
surfaceHighlightAlt: '261 82% 72%'
},
serversRail: {
border: '0',
backgroundColor: 'hsl(var(--rail-background) / 0.94)',
boxShadow: 'var(--theme-effect-panel-shadow)'
spacing: {},
radii: {
radius: '0.875rem',
surface: '1.35rem',
pill: '999px'
},
appWorkspace: {
backgroundColor: 'hsl(var(--workspace-background))'
},
chatRoomChannelsPanel: {
border: '0',
backgroundColor: 'hsl(var(--panel-background) / 0.86)',
backdropFilter: 'var(--theme-effect-glass-blur)'
},
chatRoomMembersPanel: {
border: '0',
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
backdropFilter: 'var(--theme-effect-glass-blur)'
},
chatRoomMainPanel: {
backgroundColor: 'hsl(var(--workspace-background))'
},
chatSurface: {
backgroundColor: 'transparent'
},
chatMessageBubble: {
border: '1px solid hsl(var(--border) / 0.56)',
borderRadius: '0.7rem',
backgroundColor: 'hsl(var(--card) / 0.72)',
boxShadow: 'var(--theme-effect-soft-shadow)',
backdropFilter: 'blur(12px) saturate(120%)'
},
chatComposerBar: {
border: '0',
backgroundColor: 'hsl(var(--background) / 0.84)',
backdropFilter: 'var(--theme-effect-glass-blur)'
},
chatComposerInput: {
border: '1px solid hsl(var(--border) / 0.6)',
borderRadius: '0.6rem',
backgroundColor: 'hsl(var(--panel-background-alt) / 0.8)',
color: 'hsl(var(--foreground))'
effects: {
panelShadow: '0 24px 60px rgba(0, 0, 0, 0.42)',
softShadow: '0 14px 36px rgba(0, 0, 0, 0.28)',
glassBlur: 'blur(18px) saturate(135%)'
}
},
layout: createDefaultThemeLayout(),
elements: createDefaultShellElements(),
animations: {}
};
}
@@ -330,9 +391,13 @@ export function isLegacyDefaultThemeDocument(document: ThemeDocument): boolean {
export const DEFAULT_THEME_DOCUMENT: ThemeDocument = createDefaultThemeDocument();
export const BUILT_IN_THEME_PRESETS: readonly BuiltInThemePreset[] = [
{
key: 'toju-website-dark',
key: 'toju-default-dark-11',
theme: DEFAULT_THEME_DOCUMENT
},
{
key: 'toju-website-dark',
theme: createWebsiteDarkThemeDocument()
},
{
key: 'toju-default-dark',
theme: createLegacyDefaultDarkThemeDocument()

View File

@@ -186,7 +186,7 @@
<p class="mt-1 font-mono text-[11px] text-muted-foreground">{{ preset.key }}</p>
</div>
@if (preset.theme.meta.name === 'Toju Website Dark') {
@if (preset.theme.meta.name === 'Toju Default Dark 11') {
<span class="rounded-full bg-primary/12 px-2 py-0.5 text-[10px] font-medium text-primary">Default</span>
}
</div>

View File

@@ -1,7 +1,7 @@
<ng-template #pageContent>
<div class="h-full min-h-0 overflow-y-auto bg-background text-foreground">
<div class="mx-auto w-full max-w-5xl space-y-8 p-4 sm:p-6 lg:py-8">
<header class="space-y-1">
<div class="h-full min-h-0 min-w-0 w-full overflow-x-hidden overflow-y-auto bg-background text-foreground">
<div class="mx-auto w-full min-w-0 max-w-5xl space-y-8 p-4 sm:p-6 lg:py-8">
<header class="min-w-0 space-y-1">
<h1 class="text-2xl font-semibold text-foreground">
@if (currentUser()) {
Welcome back, {{ currentUser()!.displayName || 'there' }}
@@ -12,8 +12,8 @@
<p class="text-sm text-muted-foreground">Find people, discover servers, or start your own community.</p>
</header>
<div>
<div class="relative">
<div class="min-w-0">
<div class="relative min-w-0">
<ng-icon
name="lucideSearch"
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
@@ -22,8 +22,8 @@
#searchInput
type="text"
aria-label="Search people, servers, and invites"
class="h-12 w-full rounded-xl border border-border bg-secondary py-2 pl-11 pr-20 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Search for people, servers, or paste an invite..."
class="h-12 w-full min-w-0 rounded-xl border border-border bg-secondary py-2 pl-11 pr-4 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary sm:pr-20"
[placeholder]="isMobile() ? 'Search people, servers, invites...' : 'Search for people, servers, or paste an invite...'"
[ngModel]="searchQuery()"
(ngModelChange)="onSearchChange($event)"
(keydown.enter)="submitSearch()"
@@ -189,10 +189,10 @@
</section>
} @else {
<!-- Primary actions -->
<section class="grid gap-3 sm:grid-cols-3">
<section class="grid min-w-0 gap-3 sm:grid-cols-3">
<a
routerLink="/people"
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
class="group flex min-w-0 w-full items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
>
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-purple-500/15 text-purple-400">
<ng-icon
@@ -212,7 +212,7 @@
<a
routerLink="/servers"
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
class="group flex min-w-0 w-full items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
>
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-blue-500/15 text-blue-400">
<ng-icon
@@ -232,7 +232,7 @@
<a
routerLink="/create-server"
class="group flex items-center gap-3 rounded-xl border border-emerald-500/40 bg-emerald-500/10 p-4 transition-colors hover:bg-emerald-500/15"
class="group flex min-w-0 w-full items-center gap-3 rounded-xl border border-emerald-500/40 bg-emerald-500/10 p-4 transition-colors hover:bg-emerald-500/15"
>
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-emerald-500/20 text-emerald-400">
<ng-icon
@@ -267,8 +267,8 @@
}
<!-- People + Popular servers -->
<section class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-border bg-card/40 p-4">
<section class="grid min-w-0 gap-4 lg:grid-cols-2">
<div class="min-w-0 rounded-xl border border-border bg-card/40 p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">People you might know</h2>
<a
@@ -280,7 +280,7 @@
@if (peopleYouMightKnow().length > 0) {
<div class="space-y-1">
@for (person of peopleYouMightKnow(); track person.id) {
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
<div class="flex min-w-0 items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
<app-user-avatar
[name]="personLabel(person)"
[avatarUrl]="person.avatarUrl"
@@ -292,7 +292,10 @@
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(person) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
</div>
<app-friend-button [user]="person" />
<app-friend-button
class="shrink-0"
[user]="person"
/>
</div>
}
</div>
@@ -301,7 +304,7 @@
}
</div>
<div class="rounded-xl border border-border bg-card/40 p-4">
<div class="min-w-0 rounded-xl border border-border bg-card/40 p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">Popular Servers</h2>
<a
@@ -313,7 +316,7 @@
@if (popularServers().length > 0) {
<div class="space-y-1">
@for (server of popularServers(); track server.id) {
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
<div class="flex min-w-0 items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
<div
class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground"
>
@@ -358,9 +361,9 @@
>Manage</a
>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
<div class="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
@for (friend of friends(); track friend.id) {
<div class="flex items-center gap-3 rounded-xl border border-border bg-card p-3">
<div class="flex min-w-0 items-center gap-3 rounded-xl border border-border bg-card p-3">
<app-user-avatar
[name]="personLabel(friend)"
[avatarUrl]="friend.avatarUrl"
@@ -383,11 +386,11 @@
@if (recentlyActiveServers().length > 0) {
<section>
<h2 class="mb-3 text-sm font-semibold text-foreground">Recently Active Servers</h2>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<div class="grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
@for (room of recentlyActiveServers(); track room.id) {
<button
type="button"
class="flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50 hover:bg-card/80"
class="flex min-w-0 flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50 hover:bg-card/80"
(click)="openSavedRoom(room)"
>
<div
@@ -415,24 +418,4 @@
</div>
</ng-template>
@if (isMobile()) {
<swiper-container
class="block h-full min-h-0 w-full bg-background"
slides-per-view="1"
space-between="0"
initial-slide="0"
threshold="10"
resistance-ratio="0"
>
<swiper-slide class="block h-full w-full">
<div class="flex h-full w-full min-h-0 overflow-hidden">
<app-servers-rail class="block h-full shrink-0" />
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border">
<ng-container [ngTemplateOutlet]="pageContent" />
</div>
</div>
</swiper-slide>
</swiper-container>
} @else {
<ng-container [ngTemplateOutlet]="pageContent" />
}
<ng-container [ngTemplateOutlet]="pageContent" />

View File

@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
CUSTOM_ELEMENTS_SCHEMA,
Component,
ElementRef,
HostListener,
@@ -41,7 +40,6 @@ import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selec
import type { Room, User } from '../../shared-kernel';
import type { ServerInfo } from '../../domains/server-directory/domain/models/server-directory.model';
import { ServerDirectoryFacade } from '../../domains/server-directory/application/facades/server-directory.facade';
import { ServersRailComponent } from '../servers/servers-rail/servers-rail.component';
import { ViewportService } from '../../core/platform';
import { FriendService } from '../../domains/direct-message/application/services/friend.service';
import { FriendButtonComponent } from '../../domains/direct-message/feature/friend-button/friend-button.component';
@@ -72,8 +70,7 @@ const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches';
RouterLink,
NgIcon,
FriendButtonComponent,
UserAvatarComponent,
ServersRailComponent
UserAvatarComponent
],
viewProviders: [
provideIcons({
@@ -87,8 +84,10 @@ const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches';
lucideX
})
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './dashboard.component.html'
templateUrl: './dashboard.component.html',
host: {
class: 'block h-full min-h-0 min-w-0 w-full overflow-hidden'
}
})
export class DashboardComponent implements OnInit {
private store = inject(Store);

View File

@@ -15,7 +15,13 @@
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
class="grid h-12 w-12 place-items-center rounded-full transition-colors disabled:opacity-45"
[class.bg-secondary]="!muted()"
[class.text-foreground]="!muted()"
[class.hover:bg-secondary/80]="!muted()"
[class.bg-destructive/10]="muted()"
[class.text-destructive]="muted()"
[class.hover:bg-destructive/15]="muted()"
[disabled]="!connected()"
(click)="muteToggled.emit()"
[attr.aria-label]="muted() ? 'Unmute' : 'Mute'"
@@ -27,6 +33,26 @@
/>
</button>
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full transition-colors disabled:opacity-45"
[class.bg-secondary]="!deafened()"
[class.text-foreground]="!deafened()"
[class.hover:bg-secondary/80]="!deafened()"
[class.bg-destructive/10]="deafened()"
[class.text-destructive]="deafened()"
[class.hover:bg-destructive/15]="deafened()"
[disabled]="!connected()"
(click)="deafenToggled.emit()"
[attr.aria-label]="deafened() ? 'Undeafen' : 'Deafen'"
[title]="deafened() ? 'Undeafen' : 'Deafen'"
>
<ng-icon
name="lucideHeadphones"
class="h-5 w-5"
/>
</button>
@if (showSpeakerphoneButton()) {
<button
type="button"

View File

@@ -0,0 +1,34 @@
import { Injector, runInInjectionContext } from '@angular/core';
import {
describe,
expect,
it,
vi
} from 'vitest';
import { PrivateCallControlsComponent } from './private-call-controls.component';
function createComponent(): PrivateCallControlsComponent {
const injector = Injector.create({ providers: [PrivateCallControlsComponent] });
return runInInjectionContext(injector, () => injector.get(PrivateCallControlsComponent));
}
describe('PrivateCallControlsComponent', () => {
it('exposes deafened input and deafenToggled output', () => {
const component = createComponent();
expect(component.deafened).toBeDefined();
expect(component.deafenToggled).toBeDefined();
});
it('emits deafenToggled when the output is triggered', () => {
const component = createComponent();
const handler = vi.fn();
component.deafenToggled.subscribe(handler);
component.deafenToggled.emit();
expect(handler).toHaveBeenCalledTimes(1);
});
});

View File

@@ -5,6 +5,7 @@ import {
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideHeadphones,
lucideMic,
lucideMicOff,
lucideMonitor,
@@ -22,6 +23,7 @@ import {
imports: [NgIcon],
viewProviders: [
provideIcons({
lucideHeadphones,
lucideMic,
lucideMicOff,
lucideMonitor,
@@ -38,6 +40,7 @@ import {
export class PrivateCallControlsComponent {
readonly connected = input.required<boolean>();
readonly muted = input.required<boolean>();
readonly deafened = input.required<boolean>();
readonly cameraEnabled = input.required<boolean>();
readonly screenSharing = input.required<boolean>();
readonly showSpeakerphoneButton = input(false);
@@ -45,6 +48,7 @@ export class PrivateCallControlsComponent {
readonly joinRequested = output();
readonly muteToggled = output();
readonly deafenToggled = output();
readonly cameraToggled = output();
readonly screenShareToggled = output();
readonly speakerphoneToggled = output();

View File

@@ -196,12 +196,14 @@
class="mx-auto block w-full max-w-5xl"
[connected]="isConnected()"
[muted]="isMuted()"
[deafened]="isDeafened()"
[cameraEnabled]="isCameraEnabled()"
[screenSharing]="isScreenSharing()"
[showSpeakerphoneButton]="showSpeakerphoneButton()"
[speakerphoneEnabled]="speakerphoneEnabled()"
(joinRequested)="join()"
(muteToggled)="toggleMute()"
(deafenToggled)="toggleDeafen()"
(cameraToggled)="toggleCamera()"
(screenShareToggled)="toggleScreenShare()"
(speakerphoneToggled)="toggleSpeakerphone()"

View File

@@ -496,18 +496,27 @@ export class PrivateCallComponent {
return;
}
const voiceState = {
isConnected: this.isConnected(),
isMuted: this.isMuted(),
isDeafened: this.isDeafened(),
roomId: session.callId,
serverId: session.callId
};
this.store.dispatch(
UsersActions.updateVoiceState({
userId: user.id,
voiceState: {
isConnected: this.isConnected(),
isMuted: this.isMuted(),
isDeafened: this.isDeafened(),
roomId: session.callId,
serverId: session.callId
}
voiceState
})
);
this.voice.broadcastMessage({
type: 'voice-state',
oderId: user.oderId || user.id,
displayName: user.displayName || 'User',
voiceState
});
}
private remoteParticipantPeerIds(session: DirectCallSession, currentUserId: string): string[] {

View File

@@ -14,7 +14,7 @@
<swiper-slide class="block h-full w-full">
<div class="flex h-full w-full min-h-0 overflow-hidden">
<app-servers-rail class="block h-full shrink-0" />
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border bg-card">
<div class="flex min-h-0 min-w-0 flex-1 overflow-hidden border-l border-border bg-card">
<app-rooms-side-panel
panelMode="channels"
(textChannelSelected)="setMobilePage('main')"

View File

@@ -1,4 +1,4 @@
<nav class="relative flex h-full min-w-16 flex-col items-center gap-2 border-r border-border bg-secondary/35 px-0 py-3 md:min-w-0 md:w-full">
<nav class="relative flex h-full w-16 min-w-16 max-w-16 flex-col items-center gap-2 border-r border-border bg-secondary/35 px-0 py-3 md:min-w-0 md:max-w-none md:w-full">
<!-- Home / dashboard button -->
<button
appThemeNode="serversRailCreateButton"

View File

@@ -6,7 +6,7 @@ export class CapacitorMobileAppLifecycleAdapter implements MobileAppLifecycleAda
private handler: ((isActive: boolean) => void) | null = null;
async initialize(): Promise<void> {
const App = loadCapacitorAppPlugin();
const App = await loadCapacitorAppPlugin();
if (!App) {
return;

View File

@@ -1,9 +1,15 @@
import type { MobileCallKitAdapter } from '../../contracts/mobile.contracts';
import { MetoyouMobile } from './metoyou-mobile.plugin';
import { loadMetoyouMobilePlugin } from './metoyou-mobile.plugin';
/** iOS CallKit bridge via the MetoyouMobile native plugin. */
export class CapacitorMobileCallKitAdapter implements MobileCallKitAdapter {
async startActiveCall(callId: string, displayName: string): Promise<void> {
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (!MetoyouMobile) {
return;
}
try {
const result = await MetoyouMobile.startCallKitSession({ callId, displayName });
@@ -16,6 +22,12 @@ export class CapacitorMobileCallKitAdapter implements MobileCallKitAdapter {
}
async endActiveCall(callId: string): Promise<void> {
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (!MetoyouMobile) {
return;
}
try {
await MetoyouMobile.endCallKitSession({ callId });
} catch (error) {

View File

@@ -1,6 +1,6 @@
import type { MobileMediaAdapter } from '../../contracts/mobile.contracts';
import { loadCapacitorAudioSessionPlugin } from './capacitor-plugin-loader';
import { MetoyouMobile } from './metoyou-mobile.plugin';
import { loadMetoyouMobilePlugin } from './metoyou-mobile.plugin';
import { WebMobileMediaAdapter } from '../web/web-mobile-media.adapter';
/** Capacitor media adapter with native speaker routing and background voice session hooks. */
@@ -8,14 +8,18 @@ export class CapacitorMobileMediaAdapter extends WebMobileMediaAdapter implement
private backgroundSessionActive = false;
override async setSpeakerphoneEnabled(enabled: boolean): Promise<void> {
try {
await MetoyouMobile.setSpeakerphoneEnabled({ enabled });
return;
} catch {
// Android plugin unavailable in web builds; fall through to iOS audio session.
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (MetoyouMobile) {
try {
await MetoyouMobile.setSpeakerphoneEnabled({ enabled });
return;
} catch {
// Android plugin unavailable in web builds; fall through to iOS audio session.
}
}
const AudioSession = loadCapacitorAudioSessionPlugin();
const AudioSession = await loadCapacitorAudioSessionPlugin();
if (!AudioSession) {
return;
@@ -31,6 +35,12 @@ export class CapacitorMobileMediaAdapter extends WebMobileMediaAdapter implement
this.backgroundSessionActive = true;
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (!MetoyouMobile) {
return;
}
try {
await MetoyouMobile.startVoiceForegroundService();
} catch (error) {
@@ -45,6 +55,12 @@ export class CapacitorMobileMediaAdapter extends WebMobileMediaAdapter implement
this.backgroundSessionActive = false;
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (!MetoyouMobile) {
return;
}
try {
await MetoyouMobile.stopVoiceForegroundService();
} catch (error) {

View File

@@ -12,8 +12,8 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
private listenersRegistered = false;
async initialize(): Promise<void> {
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
const PushNotifications = loadCapacitorPushNotificationsPlugin();
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
const PushNotifications = await loadCapacitorPushNotificationsPlugin();
if (!LocalNotifications) {
return;
@@ -72,7 +72,7 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
}
async requestPermission(): Promise<boolean> {
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
if (!LocalNotifications) {
return false;
@@ -90,7 +90,7 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
}
async showCallNotification(payload: CallNotificationPayload): Promise<void> {
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
if (!LocalNotifications) {
return;
@@ -122,7 +122,7 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
}
async dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void> {
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
if (!LocalNotifications) {
return;

View File

@@ -1,5 +1,5 @@
import type { MobilePictureInPictureAdapter } from '../../contracts/mobile.contracts';
import { MetoyouMobile } from './metoyou-mobile.plugin';
import { loadMetoyouMobilePlugin } from './metoyou-mobile.plugin';
import { WebMobilePictureInPictureAdapter } from '../web/web-mobile-picture-in-picture.adapter';
/** Capacitor PiP adapter with Document PiP first and native Android PiP fallback. */
@@ -20,6 +20,12 @@ export class CapacitorMobilePictureInPictureAdapter extends WebMobilePictureInPi
return;
}
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (!MetoyouMobile) {
return;
}
const result = await MetoyouMobile.enterNativePictureInPicture();
this.nativeSupported = result.supported;
@@ -38,6 +44,12 @@ export class CapacitorMobilePictureInPictureAdapter extends WebMobilePictureInPi
await super.exit();
}
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (!MetoyouMobile) {
return;
}
await MetoyouMobile.exitNativePictureInPicture().catch(() => {});
}
}

View File

@@ -12,6 +12,12 @@ const capacitorState = vi.hoisted(() => ({
isPluginAvailable: true,
platform: 'android'
}));
const appPlugin = vi.hoisted(() => ({
addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() }))
}));
const localNotificationsPlugin = vi.hoisted(() => ({
checkPermissions: vi.fn(() => Promise.resolve({ display: 'granted' }))
}));
vi.mock('@capacitor/core', () => ({
Capacitor: {
@@ -22,24 +28,26 @@ vi.mock('@capacitor/core', () => ({
}));
vi.mock('@capacitor/app', () => ({
App: {
addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() }))
}
App: appPlugin
}));
vi.mock('@capacitor/local-notifications', () => ({
LocalNotifications: {
checkPermissions: vi.fn(() => Promise.resolve({ display: 'granted' }))
}
LocalNotifications: localNotificationsPlugin
}));
import { App } from '@capacitor/app';
import { LocalNotifications } from '@capacitor/local-notifications';
import { loadCapacitorAppPlugin, loadCapacitorLocalNotificationsPlugin } from './capacitor-plugin-loader';
function stubCapacitorWindow(): void {
vi.stubGlobal('window', {
Capacitor: {
isNativePlatform: () => capacitorState.isNativePlatform
}
});
}
describe('capacitor-plugin-loader', () => {
beforeEach(() => {
vi.stubGlobal('window', {});
stubCapacitorWindow();
});
afterEach(() => {
@@ -49,27 +57,28 @@ describe('capacitor-plugin-loader', () => {
vi.unstubAllGlobals();
});
it('returns registered plugin instances synchronously without wrapping them in a Promise', () => {
const appPlugin = loadCapacitorAppPlugin();
const notificationsPlugin = loadCapacitorLocalNotificationsPlugin();
it('returns registered plugin instances from dynamic imports on native shells', async () => {
const resolvedAppPlugin = await loadCapacitorAppPlugin();
const resolvedNotificationsPlugin = await loadCapacitorLocalNotificationsPlugin();
expect(appPlugin).toBe(App);
expect(notificationsPlugin).toBe(LocalNotifications);
expect(appPlugin).not.toBeInstanceOf(Promise);
expect(notificationsPlugin).not.toBeInstanceOf(Promise);
expect(resolvedAppPlugin).toBe(appPlugin);
expect(resolvedNotificationsPlugin).toBe(localNotificationsPlugin);
expect(resolvedAppPlugin).not.toBeInstanceOf(Promise);
expect(resolvedNotificationsPlugin).not.toBeInstanceOf(Promise);
});
it('returns null when the plugin is unavailable on the active native shell', () => {
it('returns null when the plugin is unavailable on the active native shell', async () => {
capacitorState.isPluginAvailable = false;
expect(loadCapacitorAppPlugin()).toBeNull();
expect(loadCapacitorLocalNotificationsPlugin()).toBeNull();
expect(await loadCapacitorAppPlugin()).toBeNull();
expect(await loadCapacitorLocalNotificationsPlugin()).toBeNull();
});
it('returns null on non-native shells', () => {
it('returns null on non-native shells without importing Capacitor plugins', async () => {
capacitorState.isNativePlatform = false;
stubCapacitorWindow();
expect(loadCapacitorAppPlugin()).toBeNull();
expect(loadCapacitorLocalNotificationsPlugin()).toBeNull();
expect(await loadCapacitorAppPlugin()).toBeNull();
expect(await loadCapacitorLocalNotificationsPlugin()).toBeNull();
});
});

View File

@@ -1,12 +1,32 @@
import { App } from '@capacitor/app';
import { Capacitor } from '@capacitor/core';
import { Device } from '@capacitor/device';
import { LocalNotifications } from '@capacitor/local-notifications';
import { PushNotifications } from '@capacitor/push-notifications';
import { AudioSession } from '@capgo/capacitor-audio-session';
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
function resolveCapacitorPlugin<T>(pluginName: string, plugin: T): T | null {
if (typeof window === 'undefined' || !Capacitor.isNativePlatform()) {
type CapacitorCoreModule = typeof import('@capacitor/core');
let capacitorCoreModulePromise: Promise<CapacitorCoreModule> | null = null;
const pluginPromises = new Map<string, Promise<unknown>>();
async function loadCapacitorCore(): Promise<CapacitorCoreModule['Capacitor'] | null> {
if (typeof window === 'undefined' || !isCapacitorNativeRuntime()) {
return null;
}
if (!capacitorCoreModulePromise) {
capacitorCoreModulePromise = import('@capacitor/core');
}
const module = await capacitorCoreModulePromise;
return module.Capacitor;
}
async function resolveCapacitorPlugin<T>(
pluginName: string,
loader: () => Promise<T>
): Promise<T | null> {
const Capacitor = await loadCapacitorCore();
if (!Capacitor) {
return null;
}
@@ -15,30 +35,54 @@ function resolveCapacitorPlugin<T>(pluginName: string, plugin: T): T | null {
return null;
}
return plugin;
if (!pluginPromises.has(pluginName)) {
pluginPromises.set(pluginName, loader());
}
return pluginPromises.get(pluginName) as Promise<T>;
}
/** Resolve the Capacitor App plugin on native shells; returns null on web/electron or when unavailable. */
export function loadCapacitorAppPlugin(): typeof App | null {
return resolveCapacitorPlugin('App', App);
export async function loadCapacitorAppPlugin(): Promise<import('@capacitor/app').AppPlugin | null> {
return resolveCapacitorPlugin('App', async () => {
const module = await import('@capacitor/app');
return module.App;
});
}
/** Resolve the Capacitor LocalNotifications plugin on native shells. */
export function loadCapacitorLocalNotificationsPlugin(): typeof LocalNotifications | null {
return resolveCapacitorPlugin('LocalNotifications', LocalNotifications);
export async function loadCapacitorLocalNotificationsPlugin(): Promise<import('@capacitor/local-notifications').LocalNotificationsPlugin | null> {
return resolveCapacitorPlugin('LocalNotifications', async () => {
const module = await import('@capacitor/local-notifications');
return module.LocalNotifications;
});
}
/** Resolve the Capacitor PushNotifications plugin on native shells. */
export function loadCapacitorPushNotificationsPlugin(): typeof PushNotifications | null {
return resolveCapacitorPlugin('PushNotifications', PushNotifications);
export async function loadCapacitorPushNotificationsPlugin(): Promise<import('@capacitor/push-notifications').PushNotificationsPlugin | null> {
return resolveCapacitorPlugin('PushNotifications', async () => {
const module = await import('@capacitor/push-notifications');
return module.PushNotifications;
});
}
/** Resolve the Capacitor Device plugin on native shells. */
export function loadCapacitorDevicePlugin(): typeof Device | null {
return resolveCapacitorPlugin('Device', Device);
export async function loadCapacitorDevicePlugin(): Promise<import('@capacitor/device').DevicePlugin | null> {
return resolveCapacitorPlugin('Device', async () => {
const module = await import('@capacitor/device');
return module.Device;
});
}
/** Resolve the Capacitor AudioSession plugin on native shells. */
export function loadCapacitorAudioSessionPlugin(): typeof AudioSession | null {
return resolveCapacitorPlugin('AudioSession', AudioSession);
export async function loadCapacitorAudioSessionPlugin(): Promise<import('@capgo/capacitor-audio-session').AudioSessionPlugin | null> {
return resolveCapacitorPlugin('AudioSession', async () => {
const module = await import('@capgo/capacitor-audio-session');
return module.AudioSession;
});
}

View File

@@ -1,4 +1,4 @@
import { registerPlugin } from '@capacitor/core';
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
export interface MetoyouMobilePlugin {
setSpeakerphoneEnabled(options: { enabled: boolean }): Promise<void>;
@@ -11,4 +11,19 @@ export interface MetoyouMobilePlugin {
isRemotePushConfigured(): Promise<{ configured: boolean }>;
}
export const MetoyouMobile = registerPlugin<MetoyouMobilePlugin>('MetoyouMobile');
let metoyouMobilePluginPromise: Promise<MetoyouMobilePlugin | null> | null = null;
/** Lazily register the MetoyouMobile Capacitor plugin on native shells only. */
export async function loadMetoyouMobilePlugin(): Promise<MetoyouMobilePlugin | null> {
if (typeof window === 'undefined' || !isCapacitorNativeRuntime()) {
return null;
}
if (!metoyouMobilePluginPromise) {
metoyouMobilePluginPromise = import('@capacitor/core')
.then(({ registerPlugin }) => registerPlugin<MetoyouMobilePlugin>('MetoyouMobile'))
.catch(() => null);
}
return metoyouMobilePluginPromise;
}

View File

@@ -0,0 +1,25 @@
import {
describe,
expect,
it,
vi
} from 'vitest';
import { resolveMobileAdapter } from './mobile-capacitor-adapter.rules';
describe('resolveMobileAdapter', () => {
it('returns the web adapter on electron and browser shells', async () => {
const loadCapacitorAdapter = vi.fn(() => Promise.resolve('capacitor'));
await expect(resolveMobileAdapter('electron', 'web', loadCapacitorAdapter)).resolves.toBe('web');
await expect(resolveMobileAdapter('browser', 'web', loadCapacitorAdapter)).resolves.toBe('web');
expect(loadCapacitorAdapter).not.toHaveBeenCalled();
});
it('loads the Capacitor adapter only on native mobile shells', async () => {
const loadCapacitorAdapter = vi.fn(() => Promise.resolve('capacitor'));
await expect(resolveMobileAdapter('capacitor', 'web', loadCapacitorAdapter)).resolves.toBe('capacitor');
expect(loadCapacitorAdapter).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,14 @@
import type { RuntimePlatform } from './platform-detection.rules';
/** Lazily loads a Capacitor-only adapter while keeping web/electron shells on the web fallback. */
export async function resolveMobileAdapter<TWeb, TCapacitor>(
runtime: RuntimePlatform,
webAdapter: TWeb,
loadCapacitorAdapter: () => Promise<TCapacitor>
): Promise<TWeb | TCapacitor> {
if (runtime !== 'capacitor') {
return webAdapter;
}
return loadCapacitorAdapter();
}

View File

@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobileAppLifecycleAdapter } from '../adapters/capacitor/capacitor-mobile-app-lifecycle.adapter';
import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter';
import { MobilePlatformService } from './mobile-platform.service';
@@ -9,7 +9,8 @@ import { MobilePlatformService } from './mobile-platform.service';
@Injectable({ providedIn: 'root' })
export class MobileAppLifecycleService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly adapter: MobileAppLifecycleAdapter = this.createAdapter();
private adapter: MobileAppLifecycleAdapter = new WebMobileAppLifecycleAdapter();
private adapterReady: Promise<MobileAppLifecycleAdapter> | null = null;
private initialized = false;
async initialize(): Promise<void> {
@@ -17,7 +18,9 @@ export class MobileAppLifecycleService {
return;
}
await this.adapter.initialize();
const adapter = await this.ensureAdapter();
await adapter.initialize();
this.mobilePlatform.refreshRuntimeDetection();
this.initialized = true;
}
@@ -26,9 +29,22 @@ export class MobileAppLifecycleService {
this.adapter.onAppStateChange(handler);
}
private createAdapter(): MobileAppLifecycleAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobileAppLifecycleAdapter()
: new WebMobileAppLifecycleAdapter();
private ensureAdapter(): Promise<MobileAppLifecycleAdapter> {
if (!this.adapterReady) {
this.adapterReady = resolveMobileAdapter(
this.mobilePlatform.runtime(),
this.adapter,
async () => {
const { CapacitorMobileAppLifecycleAdapter } = await import('../adapters/capacitor/capacitor-mobile-app-lifecycle.adapter');
return new CapacitorMobileAppLifecycleAdapter();
}
).then((adapter) => {
this.adapter = adapter;
return adapter;
});
}
return this.adapterReady;
}
}

View File

@@ -43,17 +43,7 @@ export class MobileCallSessionService {
this.wired = true;
void this.notifications.initialize();
void this.lifecycle.initialize();
this.notifications.onCallAction(({ callId, intent }) => {
void this.router.navigate(['/call', callId]);
this.actionHandler?.(intent, callId);
});
this.lifecycle.onAppStateChange((isActive) => {
void this.handleAppStateChange(isActive);
});
void this.bootstrap();
this.destroyRef.onDestroy(() => {
this.activeSession = null;
@@ -117,6 +107,20 @@ export class MobileCallSessionService {
};
}
private async bootstrap(): Promise<void> {
await this.notifications.initialize();
await this.lifecycle.initialize();
this.notifications.onCallAction(({ callId, intent }) => {
void this.router.navigate(['/call', callId]);
this.actionHandler?.(intent, callId);
});
this.lifecycle.onAppStateChange((isActive) => {
void this.handleAppStateChange(isActive);
});
}
private shouldHandleMobileCalls(): boolean {
return this.mobilePlatform.isNativeMobile();
}

View File

@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import type { MobileCallKitAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobileCallKitAdapter } from '../adapters/capacitor/capacitor-mobile-callkit.adapter';
import { WebMobileCallKitAdapter } from '../adapters/web/web-mobile-callkit.adapter';
import { MobilePlatformService } from './mobile-platform.service';
@@ -9,14 +9,15 @@ import { MobilePlatformService } from './mobile-platform.service';
@Injectable({ providedIn: 'root' })
export class MobileCallKitService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly adapter: MobileCallKitAdapter = this.createAdapter();
private adapter: MobileCallKitAdapter = new WebMobileCallKitAdapter();
private adapterReady: Promise<MobileCallKitAdapter> | null = null;
startActiveCall(callId: string, displayName: string): Promise<void> {
if (!this.mobilePlatform.isCapacitor()) {
return Promise.resolve();
}
return this.adapter.startActiveCall(callId, displayName);
return this.ensureAdapter().then((adapter) => adapter.startActiveCall(callId, displayName));
}
endActiveCall(callId: string): Promise<void> {
@@ -24,12 +25,25 @@ export class MobileCallKitService {
return Promise.resolve();
}
return this.adapter.endActiveCall(callId);
return this.ensureAdapter().then((adapter) => adapter.endActiveCall(callId));
}
private createAdapter(): MobileCallKitAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobileCallKitAdapter()
: new WebMobileCallKitAdapter();
private ensureAdapter(): Promise<MobileCallKitAdapter> {
if (!this.adapterReady) {
this.adapterReady = resolveMobileAdapter(
this.mobilePlatform.runtime(),
this.adapter,
async () => {
const { CapacitorMobileCallKitAdapter } = await import('../adapters/capacitor/capacitor-mobile-callkit.adapter');
return new CapacitorMobileCallKitAdapter();
}
).then((adapter) => {
this.adapter = adapter;
return adapter;
});
}
return this.adapterReady;
}
}

View File

@@ -4,8 +4,8 @@ import {
inject
} from '@angular/core';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import type { MobileMediaAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobileMediaAdapter } from '../adapters/capacitor/capacitor-mobile-media.adapter';
import { WebMobileMediaAdapter } from '../adapters/web/web-mobile-media.adapter';
import { MobilePlatformService } from './mobile-platform.service';
@@ -16,27 +16,41 @@ export class MobileMediaService {
readonly isPictureInPictureSupported = computed(() => this.adapter.isPictureInPictureSupported());
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly adapter: MobileMediaAdapter = this.createAdapter();
private adapter: MobileMediaAdapter = new WebMobileMediaAdapter();
private adapterReady: Promise<MobileMediaAdapter> | null = null;
pickAttachments(): Promise<File[]> {
return this.adapter.pickAttachments();
return this.ensureAdapter().then((adapter) => adapter.pickAttachments());
}
setSpeakerphoneEnabled(enabled: boolean): Promise<void> {
return this.adapter.setSpeakerphoneEnabled(enabled);
return this.ensureAdapter().then((adapter) => adapter.setSpeakerphoneEnabled(enabled));
}
startBackgroundAudioSession(): Promise<void> {
return this.adapter.startBackgroundAudioSession();
return this.ensureAdapter().then((adapter) => adapter.startBackgroundAudioSession());
}
stopBackgroundAudioSession(): Promise<void> {
return this.adapter.stopBackgroundAudioSession();
return this.ensureAdapter().then((adapter) => adapter.stopBackgroundAudioSession());
}
private createAdapter(): MobileMediaAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobileMediaAdapter()
: new WebMobileMediaAdapter();
private ensureAdapter(): Promise<MobileMediaAdapter> {
if (!this.adapterReady) {
this.adapterReady = resolveMobileAdapter(
this.mobilePlatform.runtime(),
this.adapter,
async () => {
const { CapacitorMobileMediaAdapter } = await import('../adapters/capacitor/capacitor-mobile-media.adapter');
return new CapacitorMobileMediaAdapter();
}
).then((adapter) => {
this.adapter = adapter;
return adapter;
});
}
return this.adapterReady;
}
}

View File

@@ -2,8 +2,8 @@ import { Injectable, inject } from '@angular/core';
import type { CallNotificationActionIntent } from '../logic/call-notification.rules';
import { buildIncomingCallNotification, buildInCallNotification } from '../logic/call-notification.rules';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import type { MobileNotificationAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobileNotificationsAdapter } from '../adapters/capacitor/capacitor-mobile-notifications.adapter';
import { WebMobileNotificationsAdapter } from '../adapters/web/web-mobile-notifications.adapter';
import { MobilePlatformService } from './mobile-platform.service';
import { MobilePushRegistrationService } from './mobile-push-registration.service';
@@ -13,7 +13,9 @@ import { MobilePushRegistrationService } from './mobile-push-registration.servic
export class MobileNotificationsService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly pushRegistration = inject(MobilePushRegistrationService);
private readonly adapter: MobileNotificationAdapter = this.createAdapter();
private adapter: MobileNotificationAdapter = new WebMobileNotificationsAdapter();
private adapterReady: Promise<MobileNotificationAdapter> | null = null;
private callActionHandler: ((input: { callId: string; intent: CallNotificationActionIntent }) => void) | null = null;
private initialized = false;
async initialize(): Promise<void> {
@@ -21,36 +23,65 @@ export class MobileNotificationsService {
return;
}
await this.adapter.initialize();
const adapter = await this.ensureAdapter();
await adapter.initialize();
this.pushRegistration.initialize();
this.initialized = true;
}
async showIncomingCall(displayName: string, callId: string): Promise<void> {
await this.initialize();
await this.adapter.showCallNotification(buildIncomingCallNotification(displayName, callId));
const adapter = await this.ensureAdapter();
await adapter.showCallNotification(buildIncomingCallNotification(displayName, callId));
}
async showActiveCall(input: { callId: string; displayName: string; isMuted: boolean }): Promise<void> {
await this.initialize();
await this.adapter.showCallNotification(buildInCallNotification(input));
const adapter = await this.ensureAdapter();
await adapter.showCallNotification(buildInCallNotification(input));
}
async dismissIncomingCall(callId: string): Promise<void> {
await this.adapter.dismissCallNotification(callId, 'incoming');
const adapter = await this.ensureAdapter();
await adapter.dismissCallNotification(callId, 'incoming');
}
async dismissActiveCall(callId: string): Promise<void> {
await this.adapter.dismissCallNotification(callId, 'active');
const adapter = await this.ensureAdapter();
await adapter.dismissCallNotification(callId, 'active');
}
onCallAction(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void {
this.callActionHandler = handler;
this.adapter.onActionSelected(handler);
}
private createAdapter(): MobileNotificationAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobileNotificationsAdapter()
: new WebMobileNotificationsAdapter();
private ensureAdapter(): Promise<MobileNotificationAdapter> {
if (!this.adapterReady) {
this.adapterReady = resolveMobileAdapter(
this.mobilePlatform.runtime(),
this.adapter,
async () => {
const { CapacitorMobileNotificationsAdapter } = await import('../adapters/capacitor/capacitor-mobile-notifications.adapter');
return new CapacitorMobileNotificationsAdapter();
}
).then((adapter) => {
this.adapter = adapter;
if (this.callActionHandler) {
this.adapter.onActionSelected(this.callActionHandler);
}
return adapter;
});
}
return this.adapterReady;
}
}

View File

@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import type { MobilePersistenceAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobilePersistenceAdapter } from '../adapters/capacitor/capacitor-mobile-persistence.adapter';
import { WebMobilePersistenceAdapter } from '../adapters/web/web-mobile-persistence.adapter';
import { MobilePlatformService } from './mobile-platform.service';
import { MobileSqliteConnectionService } from './mobile-sqlite-connection.service';
@@ -11,19 +11,33 @@ import { MobileSqliteConnectionService } from './mobile-sqlite-connection.servic
export class MobilePersistenceService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly sqliteConnection = inject(MobileSqliteConnectionService);
private readonly adapter: MobilePersistenceAdapter = this.createAdapter();
private adapter: MobilePersistenceAdapter = new WebMobilePersistenceAdapter();
private adapterReady: Promise<MobilePersistenceAdapter> | null = null;
get isNativeSqlite(): boolean {
return this.adapter.isNativeSqlite;
}
initialize(): Promise<void> {
return this.adapter.initialize();
return this.ensureAdapter().then((adapter) => adapter.initialize());
}
private createAdapter(): MobilePersistenceAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobilePersistenceAdapter(this.sqliteConnection)
: new WebMobilePersistenceAdapter();
private ensureAdapter(): Promise<MobilePersistenceAdapter> {
if (!this.adapterReady) {
this.adapterReady = resolveMobileAdapter(
this.mobilePlatform.runtime(),
this.adapter,
async () => {
const { CapacitorMobilePersistenceAdapter } = await import('../adapters/capacitor/capacitor-mobile-persistence.adapter');
return new CapacitorMobilePersistenceAdapter(this.sqliteConnection);
}
).then((adapter) => {
this.adapter = adapter;
return adapter;
});
}
return this.adapterReady;
}
}

View File

@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import type { MobilePictureInPictureAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobilePictureInPictureAdapter } from '../adapters/capacitor/capacitor-mobile-picture-in-picture.adapter';
import { WebMobilePictureInPictureAdapter } from '../adapters/web/web-mobile-picture-in-picture.adapter';
import { MobilePlatformService } from './mobile-platform.service';
@@ -9,23 +9,37 @@ import { MobilePlatformService } from './mobile-platform.service';
@Injectable({ providedIn: 'root' })
export class MobilePictureInPictureService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly adapter: MobilePictureInPictureAdapter = this.createAdapter();
private adapter: MobilePictureInPictureAdapter = new WebMobilePictureInPictureAdapter();
private adapterReady: Promise<MobilePictureInPictureAdapter> | null = null;
isSupported(): boolean {
return this.adapter.isSupported();
}
enter(videoElement: HTMLVideoElement): Promise<void> {
return this.adapter.enter(videoElement);
return this.ensureAdapter().then((adapter) => adapter.enter(videoElement));
}
exit(): Promise<void> {
return this.adapter.exit();
return this.ensureAdapter().then((adapter) => adapter.exit());
}
private createAdapter(): MobilePictureInPictureAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobilePictureInPictureAdapter()
: new WebMobilePictureInPictureAdapter();
private ensureAdapter(): Promise<MobilePictureInPictureAdapter> {
if (!this.adapterReady) {
this.adapterReady = resolveMobileAdapter(
this.mobilePlatform.runtime(),
this.adapter,
async () => {
const { CapacitorMobilePictureInPictureAdapter } = await import('../adapters/capacitor/capacitor-mobile-picture-in-picture.adapter');
return new CapacitorMobilePictureInPictureAdapter();
}
).then((adapter) => {
this.adapter = adapter;
return adapter;
});
}
return this.adapterReady;
}
}

View File

@@ -26,17 +26,17 @@ const remotePushState = vi.hoisted(() => ({
}));
vi.mock('../adapters/capacitor/capacitor-plugin-loader', () => ({
loadCapacitorPushNotificationsPlugin: () => pushState,
loadCapacitorDevicePlugin: () => deviceState
loadCapacitorPushNotificationsPlugin: vi.fn(() => Promise.resolve(pushState)),
loadCapacitorDevicePlugin: vi.fn(() => Promise.resolve(deviceState))
}));
vi.mock('../adapters/capacitor/metoyou-mobile.plugin', () => ({
MetoyouMobile: {
loadMetoyouMobilePlugin: vi.fn(() => Promise.resolve({
isRemotePushConfigured: vi.fn(() => Promise.resolve({ configured: remotePushState.configured }))
}
}))
}));
import { MetoyouMobile } from '../adapters/capacitor/metoyou-mobile.plugin';
import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin';
import { MobilePlatformService } from './mobile-platform.service';
import { MobilePushRegistrationService } from './mobile-push-registration.service';
@@ -63,7 +63,7 @@ describe('MobilePushRegistrationService', () => {
remotePushState.configured = true;
pushState.register.mockClear();
pushState.addListener.mockClear();
vi.mocked(MetoyouMobile.isRemotePushConfigured).mockClear();
vi.mocked(loadMetoyouMobilePlugin).mockClear();
});
afterEach(() => {
@@ -95,7 +95,7 @@ describe('MobilePushRegistrationService', () => {
expect(pushState.register).toHaveBeenCalledTimes(1);
});
expect(MetoyouMobile.isRemotePushConfigured).toHaveBeenCalled();
expect(loadMetoyouMobilePlugin).toHaveBeenCalled();
});
it('does not wire listeners on non-capacitor shells', () => {
@@ -106,6 +106,6 @@ describe('MobilePushRegistrationService', () => {
service.initialize();
expect(pushState.register).not.toHaveBeenCalled();
expect(MetoyouMobile.isRemotePushConfigured).not.toHaveBeenCalled();
expect(loadMetoyouMobilePlugin).not.toHaveBeenCalled();
});
});

View File

@@ -4,7 +4,7 @@ import { getStoredCurrentUserId } from '../../../core/storage/current-user-stora
import { buildPushDeviceTokenRegistrationPayload, normalizePushPlatform } from '../logic/mobile-push-token.rules';
import { buildRemotePushSkipMessage, resolveRemotePushSkipReason } from '../logic/mobile-push-registration.rules';
import { loadCapacitorDevicePlugin, loadCapacitorPushNotificationsPlugin } from '../adapters/capacitor/capacitor-plugin-loader';
import { MetoyouMobile } from '../adapters/capacitor/metoyou-mobile.plugin';
import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin';
import { MobilePlatformService } from './mobile-platform.service';
/** Registers FCM/APNs device tokens with the signaling server on Capacitor shells. */
@@ -32,8 +32,8 @@ export class MobilePushRegistrationService {
}
private async registerPushListeners(): Promise<void> {
const PushNotifications = loadCapacitorPushNotificationsPlugin();
const Device = loadCapacitorDevicePlugin();
const PushNotifications = await loadCapacitorPushNotificationsPlugin();
const Device = await loadCapacitorDevicePlugin();
const remotePushConfigured = await this.isRemotePushConfigured();
const skipReason = resolveRemotePushSkipReason({
hasPushPlugin: !!PushNotifications,
@@ -75,6 +75,12 @@ export class MobilePushRegistrationService {
}
private async isRemotePushConfigured(): Promise<boolean> {
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (!MetoyouMobile) {
return false;
}
try {
const result = await MetoyouMobile.isRemotePushConfigured();
@@ -91,7 +97,7 @@ export class MobilePushRegistrationService {
return;
}
const Device = loadCapacitorDevicePlugin();
const Device = await loadCapacitorDevicePlugin();
const deviceInfo = Device ? await Device.getInfo() : null;
const platform = normalizePushPlatform(deviceInfo?.platform ?? '');

View File

@@ -78,7 +78,7 @@
<div class="video-controls-row">
<button
type="button"
(click)="togglePlayback()"
(click)="onControlPlaybackClick($event)"
class="video-control-btn"
[title]="isPlaying() ? 'Pause' : 'Play'"
[attr.aria-label]="isPlaying() ? 'Pause video' : 'Play video'"
@@ -97,6 +97,7 @@
[max]="seekSliderSteps"
step="any"
[value]="seekSliderValue()"
(click)="$event.stopPropagation()"
(input)="onSeek($event)"
class="seek-slider"
[style.--progress.%]="progressPercent()"
@@ -106,7 +107,7 @@
<div class="video-volume-group">
<button
type="button"
(click)="toggleMute()"
(click)="toggleMute(); $event.stopPropagation()"
class="video-control-btn"
[title]="isMuted() ? 'Unmute' : 'Mute'"
[attr.aria-label]="isMuted() ? 'Unmute video' : 'Mute video'"
@@ -122,6 +123,7 @@
min="0"
max="100"
[value]="isMuted() ? 0 : volumePercent()"
(click)="$event.stopPropagation()"
(input)="onVolumeInput($event)"
class="volume-slider"
[style.--progress.%]="volumeProgressPercent()"
@@ -131,7 +133,7 @@
<button
type="button"
(click)="toggleFullscreen()"
(click)="toggleFullscreen(); $event.stopPropagation()"
class="video-control-btn"
[title]="isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
[attr.aria-label]="isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'"

View File

@@ -0,0 +1,107 @@
import {
ElementRef,
Injector,
runInInjectionContext
} from '@angular/core';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { ChatVideoPlayerComponent } from './chat-video-player.component';
function createComponent(): ChatVideoPlayerComponent {
const injector = Injector.create({ providers: [ChatVideoPlayerComponent] });
return runInInjectionContext(injector, () => injector.get(ChatVideoPlayerComponent));
}
function createVideoStub(initialPaused: boolean): HTMLVideoElement {
const video = {
paused: initialPaused,
ended: false,
play: vi.fn().mockImplementation(function play(this: { paused: boolean }) {
this.paused = false;
return Promise.resolve();
}),
pause: vi.fn().mockImplementation(function pause(this: { paused: boolean }) {
this.paused = true;
})
};
return video as unknown as HTMLVideoElement;
}
function attachVideo(component: ChatVideoPlayerComponent, video: HTMLVideoElement): void {
component.videoRef = { nativeElement: video } as ElementRef<HTMLVideoElement>;
}
describe('ChatVideoPlayerComponent playback clicks', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('schedules stage playback toggle after a short delay', () => {
const component = createComponent();
const video = createVideoStub(true);
attachVideo(component, video);
component.onVideoClick();
expect(video.play).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(video.play).toHaveBeenCalledTimes(1);
});
it('control playback click stops propagation so stage toggle is not scheduled', () => {
const component = createComponent();
const video = createVideoStub(false);
const stopPropagation = vi.fn();
attachVideo(component, video);
component.onControlPlaybackClick({ stopPropagation } as unknown as MouseEvent);
expect(stopPropagation).toHaveBeenCalled();
expect(video.pause).toHaveBeenCalledTimes(1);
});
it('pauses once when control playback is used without a bubbling stage click', () => {
const component = createComponent();
const video = createVideoStub(false);
attachVideo(component, video);
component.togglePlayback();
vi.advanceTimersByTime(300);
expect(video.pause).toHaveBeenCalledTimes(1);
expect(video.play).not.toHaveBeenCalled();
});
it('double-toggles when control playback bubbles into the stage click handler', () => {
const component = createComponent();
const video = createVideoStub(false);
attachVideo(component, video);
component.togglePlayback();
component.onVideoClick();
vi.advanceTimersByTime(300);
expect(video.pause).toHaveBeenCalledTimes(1);
expect(video.play).toHaveBeenCalledTimes(1);
});
});

View File

@@ -126,6 +126,11 @@ export class ChatVideoPlayerComponent implements OnInit, OnDestroy {
this.togglePlayback();
}
onControlPlaybackClick(event: MouseEvent): void {
event.stopPropagation();
this.togglePlayback();
}
togglePlayback(): void {
const video = this.videoRef?.nativeElement;

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { portalHostElementToBody } from './portal-host-to-body.logic';
interface MockElement {
parentElement: MockElement | null;
children: MockElement[];
appendChild(child: MockElement): void;
}
function createMockElement(parent: MockElement | null = null): MockElement {
const element: MockElement = {
parentElement: parent,
children: [],
appendChild(child: MockElement): void {
if (child.parentElement) {
child.parentElement.children = child.parentElement.children.filter((entry) => entry !== child);
}
child.parentElement = this;
this.children.push(child);
}
};
parent?.appendChild(element);
return element;
}
describe('portalHostElementToBody', () => {
it('appends the host to document.body when it is nested elsewhere', () => {
const body = createMockElement();
const container = createMockElement(body);
const host = createMockElement(container);
expect(portalHostElementToBody(host as unknown as HTMLElement, body as unknown as HTMLElement)).toBe(true);
expect(host.parentElement).toBe(body);
expect(container.children).toEqual([]);
});
it('returns false when the host is already attached to document.body', () => {
const body = createMockElement();
const host = createMockElement(body);
expect(portalHostElementToBody(host as unknown as HTMLElement, body as unknown as HTMLElement)).toBe(false);
expect(host.parentElement).toBe(body);
});
});

View File

@@ -0,0 +1,9 @@
/** Move a modal host element to `document.body` so `position: fixed` covers the viewport. */
export function portalHostElementToBody(host: HTMLElement, body: HTMLElement): boolean {
if (host.parentElement === body) {
return false;
}
body.appendChild(host);
return true;
}

View File

@@ -1,7 +1,11 @@
import { DOCUMENT } from '@angular/common';
import {
AfterViewInit,
Component,
ElementRef,
HostListener,
OnInit,
inject,
input,
output,
signal
@@ -9,14 +13,20 @@ import {
import { CommonModule } from '@angular/common';
import { ScreenShareQuality, SCREEN_SHARE_QUALITY_OPTIONS } from '../../../domains/screen-share';
import { ModalBackdropComponent } from '../modal-backdrop/modal-backdrop.component';
import { portalHostElementToBody } from '../portal-host-to-body.logic';
@Component({
selector: 'app-screen-share-quality-dialog',
standalone: true,
imports: [CommonModule, ModalBackdropComponent],
templateUrl: './screen-share-quality-dialog.component.html'
templateUrl: './screen-share-quality-dialog.component.html',
host: {
style: 'display: contents;'
}
})
export class ScreenShareQualityDialogComponent implements OnInit {
export class ScreenShareQualityDialogComponent implements OnInit, AfterViewInit {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly document = inject(DOCUMENT);
selectedQuality = input.required<ScreenShareQuality>();
includeSystemAudio = input(false);
@@ -35,6 +45,10 @@ export class ScreenShareQualityDialogComponent implements OnInit {
this.activeQuality.set(this.selectedQuality());
}
ngAfterViewInit(): void {
portalHostElementToBody(this.host.nativeElement, this.document.body);
}
chooseQuality(quality: ScreenShareQuality): void {
this.activeQuality.set(quality);
}

View File

@@ -125,7 +125,6 @@
}
html {
box-sizing: border-box;
height: 100%;
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
@@ -133,10 +132,15 @@
padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
}
/*
* Keep border-box on every element (Tailwind preflight does this too). Do not use
* `box-sizing: inherit` here — it overrides preflight and lets nested hosts fall back to
* content-box, so `w-full` + padding overflows the mobile workspace beside the servers rail.
*/
*,
*::before,
*::after {
box-sizing: inherit;
box-sizing: border-box;
}
body {
@@ -498,3 +502,23 @@
0 0 0 3px hsl(var(--primary) / 0.18),
inset 0 0 0 9999px hsl(var(--primary) / 0.06);
}
/*
* Mobile page stacks use Swiper custom elements. Without explicit width caps the slide
* can expand to the intrinsic width of nested content instead of the viewport.
*/
swiper-container {
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 0;
}
swiper-slide {
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
height: 100%;
}