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

@@ -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>