diff --git a/agents-docs/LESSONS.md b/agents-docs/LESSONS.md index 506e8c5..36e0d06 100644 --- a/agents-docs/LESSONS.md +++ b/agents-docs/LESSONS.md @@ -25,6 +25,34 @@ Durable rules for AI agents working on this project. Read this file at session s ## Lessons +### Do not override Tailwind with box-sizing inherit [mobile] [css] + +- **Trigger:** mobile pages still overflow horizontally until devtools disables `*, *::before, *::after { box-sizing: inherit }` in global styles. +- **Rule:** in `src/styles.scss` keep `box-sizing: border-box` on the universal selector (matching Tailwind preflight); never replace it with `inherit` from `html`. +- **Why:** `inherit` overrides preflight and some nested component hosts resolve to `content-box`, so `w-full` plus padding becomes wider than the parent — especially visible on the mobile dashboard beside the servers rail. +- **Example:** `src/styles.scss` `@layer base` universal rule uses `border-box`, not `inherit`. + +### Use the app-shell servers rail for mobile discovery pages [mobile] [layout] + +- **Trigger:** patching `min-w-0` / `overflow-x-hidden` on the dashboard (or find-people/find-servers) while the page still renders wider than the phone beside an embedded servers rail. +- **Rule:** on mobile discovery routes (`/dashboard`, `/people`, `/servers`, …) show the global `app.html` servers rail and render the page full-width in `appWorkspace`; keep embedded swiper+rail stacks only for chat/DM/call routes (`shouldShowMobileAppServersRail` in `mobile-shell-layout.rules.ts`). +- **Why:** nesting a second rail+Swiper stack inside `router-outlet` fights the shell flex width and content keeps sizing to intrinsic width, clipping cards and inputs on every viewport. +- **Example:** `hideAppServersRail()` in `app.html` + dashboard `pageContent` only (no local ``). + +### Defer attachment blob hydration on Electron startup [attachments] [electron] + +- **Trigger:** fixing inline attachment display by eagerly calling `tryRestoreAttachmentFromLocal()` for every persisted attachment during `initFromDatabase()`. +- **Rule:** load attachment metadata at startup, but hydrate blob URLs only for the watched room on demand; read disk files through chunked IPC (`readFileChunk`) and yield between chunks/attachments so large images never block the renderer. +- **Why:** restoring every saved attachment as a single base64 round-trip plus synchronous `atob()` can freeze Electron for seconds even after the shell paints. +- **Example:** `runInitFromDatabase()` stops at `loadFromDatabase()`; `restoreLocalAttachmentsForRoom()` hydrates lazily via `restoreAttachmentBlobFromDiskPath()`. + +### Lazy-load Capacitor modules on Electron/desktop [mobile] [electron] + +- **Trigger:** adding mobile facades that statically import Capacitor adapters or `@capacitor/*` plugins into shared Angular services used by the desktop app. +- **Rule:** keep web/electron shells on web adapters synchronously and load Capacitor adapters/plugins only through dynamic `import()` after `runtime === 'capacitor'` — never top-level `import '@capacitor/...'` in code reachable from `app.ts` / `DirectCallService`. +- **Why:** bundlers evaluate static Capacitor imports during Electron startup, which can freeze the renderer before first paint even when runtime detection would have chosen the web adapter. +- **Example:** `resolveMobileAdapter()` in `mobile-capacitor-adapter.rules.ts` plus async `capacitor-plugin-loader.ts` / `loadMetoyouMobilePlugin()`. + ### Use the upgrade transaction during IndexedDB schema migrations [persistence] [browser] - **Trigger:** bumping `BROWSER_DATABASE_VERSION` and opening existing stores via `database.transaction(...)` inside `onupgradeneeded`. diff --git a/agents-docs/features/mobile-capacitor.md b/agents-docs/features/mobile-capacitor.md index 3a25acc..53086d8 100644 --- a/agents-docs/features/mobile-capacitor.md +++ b/agents-docs/features/mobile-capacitor.md @@ -4,7 +4,7 @@ Cross-context mobile shell for the Angular product client (`toju-app/`). Wraps t ## Responsibilities -- Detect runtime shell (`browser`, `capacitor`, `electron`) without importing native plugins in domain code. +- Detect runtime shell (`browser`, `capacitor`, `electron`) without importing native plugins in domain code. Capacitor packages and adapters load only on `capacitor` shells via dynamic `import()` so Electron/desktop startup never evaluates `@capacitor/*` modules. - Expose facades for notifications, in-call controls, media/attachments, stream pop-out, background audio session, CallKit, and native persistence. - Integrate with direct-call, voice-workspace, and chat composer flows. diff --git a/project-files/themes/toju-default-dark-11.json b/project-files/themes/toju-default-dark-11.json new file mode 100644 index 0000000..db7fd53 --- /dev/null +++ b/project-files/themes/toju-default-dark-11.json @@ -0,0 +1,165 @@ +{ + "meta": { + "name": "Toju Default Dark 11", + "version": "2.0.0", + "description": "Built-in dark glass theme for the full Toju app shell." + }, + "css": "/* Dark glass app shell surfaces */\napp-chat-messages .chat-layout,\napp-dm-chat .chat-layout {\n background-image: linear-gradient(180deg, hsl(var(--workspace-background) / 0.96), hsl(var(--background)) 38rem) !important;\n}\n\napp-chat-message-list > div {\n background: transparent !important;\n}\n\napp-chat-message-item > div[data-message-id] {\n margin: 8px 0 !important;\n border: 1px solid hsl(var(--border) / 0.56) !important;\n border-radius: 0.7rem !important;\n background: hsl(var(--card) / 0.72) !important;\n box-shadow: 0 14px 34px rgb(0 0 0 / 0.2) !important;\n backdrop-filter: blur(12px) saturate(120%) !important;\n -webkit-backdrop-filter: blur(12px) saturate(120%) !important;\n}\n\napp-chat-message-item > div[data-message-id]:hover {\n border-color: hsl(var(--primary) / 0.42) !important;\n background: hsl(var(--secondary) / 0.88) !important;\n}\n\napp-chat-message-composer,\n.chat-bottom-bar {\n border-top: 0 !important;\n background: hsl(var(--background) / 0.84) !important;\n backdrop-filter: blur(16px) saturate(125%) !important;\n -webkit-backdrop-filter: blur(16px) saturate(125%) !important;\n}\n\napp-chat-message-composer textarea,\napp-chat-message-composer [contenteditable=\"true\"] {\n background: transparent !important;\n}\n", + "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%" + }, + "spacing": {}, + "radii": { + "radius": "0.875rem", + "surface": "1.35rem", + "pill": "999px" + }, + "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": { + "serversRail": { + "container": "appShell", + "grid": { + "x": 0, + "y": 0, + "w": 1, + "h": 1 + } + }, + "appWorkspace": { + "container": "appShell", + "grid": { + "x": 1, + "y": 0, + "w": 19, + "h": 1 + } + }, + "chatRoomChannelsPanel": { + "container": "roomLayout", + "grid": { + "x": 0, + "y": 0, + "w": 4, + "h": 12 + } + }, + "chatRoomMainPanel": { + "container": "roomLayout", + "grid": { + "x": 4, + "y": 0, + "w": 12, + "h": 12 + } + }, + "chatRoomMembersPanel": { + "container": "roomLayout", + "grid": { + "x": 16, + "y": 0, + "w": 4, + "h": 12 + } + }, + "dmConversationsPanel": { + "container": "dmLayout", + "grid": { + "x": 0, + "y": 0, + "w": 4, + "h": 12 + } + }, + "dmChatPanel": { + "container": "dmLayout", + "grid": { + "x": 4, + "y": 0, + "w": 16, + "h": 12 + } + } + }, + "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)" + }, + "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))" + } + }, + "animations": {} +} diff --git a/toju-app/AGENTS.md b/toju-app/AGENTS.md index e17d9b5..0719547 100644 --- a/toju-app/AGENTS.md +++ b/toju-app/AGENTS.md @@ -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 (`` / `` 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 ``/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 (`` / `` 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 `` 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. diff --git a/toju-app/src/app/app.html b/toju-app/src/app/app.html index 77c6eb8..3bbbe8a 100644 --- a/toju-app/src/app/app.html +++ b/toju-app/src/app/app.html @@ -10,11 +10,12 @@ >
} -
+
} diff --git a/toju-app/src/app/app.ts b/toju-app/src/app/app.ts index a50e698..5be0a48 100644 --- a/toju-app/src/app/app.ts +++ b/toju-app/src/app/app.ts @@ -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(); diff --git a/toju-app/src/app/core/platform/mobile-shell-layout.rules.spec.ts b/toju-app/src/app/core/platform/mobile-shell-layout.rules.spec.ts new file mode 100644 index 0000000..bf302a8 --- /dev/null +++ b/toju-app/src/app/core/platform/mobile-shell-layout.rules.spec.ts @@ -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); + }); +}); diff --git a/toju-app/src/app/core/platform/mobile-shell-layout.rules.ts b/toju-app/src/app/core/platform/mobile-shell-layout.rules.ts new file mode 100644 index 0000000..1458092 --- /dev/null +++ b/toju-app/src/app/core/platform/mobile-shell-layout.rules.ts @@ -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; +} diff --git a/toju-app/src/app/domains/attachment/application/facades/attachment.facade.ts b/toju-app/src/app/domains/attachment/application/facades/attachment.facade.ts index f06afa2..e4a0cae 100644 --- a/toju-app/src/app/domains/attachment/application/facades/attachment.facade.ts +++ b/toju-app/src/app/domains/attachment/application/facades/attachment.facade.ts @@ -69,6 +69,12 @@ export class AttachmentFacade { return this.manager.requestImageFromAnyPeer(...args); } + tryRestoreAttachmentFromLocal( + ...args: Parameters + ): ReturnType { + return this.manager.tryRestoreAttachmentFromLocal(...args); + } + requestFile( ...args: Parameters ): ReturnType { diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts index 79faaae..bf0ebb6 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts @@ -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 { + const restored = await this.persistence.tryRestoreAttachmentFromLocal(attachment); + + if (restored) { + this.runtimeStore.touch(); + } + + return restored; + } + requestFile(messageId: string, attachment: Attachment): Promise { 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(); } } } diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.spec.ts b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.spec.ts new file mode 100644 index 0000000..5122414 --- /dev/null +++ b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.spec.ts @@ -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>; + getAllAttachments: ReturnType; + getMessageById: ReturnType; + saveAttachment: ReturnType; + deleteAttachmentsForMessage: ReturnType; + }; + let attachmentStorage: { + resolveExistingPath: ReturnType; + resolveCanonicalStoredPath: ReturnType; + readFile: ReturnType; + readFileChunk: ReturnType; + getFileSize: ReturnType; + canReadFileChunks: ReturnType; + }; + + 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(); + }); +}); diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts index 51a560c..6597813 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts @@ -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 { @@ -263,30 +264,54 @@ export class AttachmentPersistenceService { private async runInitFromDatabase(): Promise { await this.loadFromDatabase(); await this.migrateFromLocalStorage(); - await this.tryLoadSavedFiles(); } - private async tryLoadSavedFiles(): Promise { - try { - let hasChanges = false; + private async restoreAttachmentBlobFromDiskPath(attachment: Attachment, diskPath: string): Promise { + 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; - } } diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts index 4a09b11..318ac26 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts @@ -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 diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.spec.ts new file mode 100644 index 0000000..1aa065c --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.spec.ts @@ -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]); + }); +}); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.ts new file mode 100644 index 0000000..70b10ed --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.ts @@ -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 { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-image.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-image.rules.spec.ts new file mode 100644 index 0000000..f134f57 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-image.rules.spec.ts @@ -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'); + }); +}); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-image.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-image.rules.ts new file mode 100644 index 0000000..bcf01b0 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-image.rules.ts @@ -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): boolean { + return attachment.isImage || + attachment.mime.startsWith('image/') || + hasImageFilename(attachment.filename); +} + +export function isInlineDisplayableImage( + attachment: Pick +): boolean { + return attachment.available && + !!attachment.objectUrl && + !needsBlobObjectUrlForInlineDisplay(attachment.objectUrl); +} + +export function imageAttachmentDisplayRank( + attachment: Pick +): 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(attachments: readonly T[]): T[] { + const byFilename = new Map(); + + 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): boolean { + return file.type.startsWith('image/') || hasImageFilename(file.name); +} diff --git a/toju-app/src/app/domains/chat/domain/rules/chat-message-image-grid.rules.spec.ts b/toju-app/src/app/domains/chat/domain/rules/chat-message-image-grid.rules.spec.ts new file mode 100644 index 0000000..0fee39e --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/chat-message-image-grid.rules.spec.ts @@ -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'); + }); +}); diff --git a/toju-app/src/app/domains/chat/domain/rules/chat-message-image-grid.rules.ts b/toju-app/src/app/domains/chat/domain/rules/chat-message-image-grid.rules.ts new file mode 100644 index 0000000..d694d42 --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/chat-message-image-grid.rules.ts @@ -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}`; +} diff --git a/toju-app/src/app/domains/chat/domain/rules/chat-message-lightbox.rules.spec.ts b/toju-app/src/app/domains/chat/domain/rules/chat-message-lightbox.rules.spec.ts new file mode 100644 index 0000000..07a7a2f --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/chat-message-lightbox.rules.spec.ts @@ -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); + }); +}); diff --git a/toju-app/src/app/domains/chat/domain/rules/chat-message-lightbox.rules.ts b/toju-app/src/app/domains/chat/domain/rules/chat-message-lightbox.rules.ts new file mode 100644 index 0000000..d519b3b --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/chat-message-lightbox.rules.ts @@ -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; +} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html index e998133..171721f 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html @@ -1,6 +1,6 @@
diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.scss b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.scss index ba38476..c66b140 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.scss +++ b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.scss @@ -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); diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts index 889a6a0..3c498f7 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts @@ -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(null); readonly showKlipyGifPicker = signal(false); - readonly lightboxAttachment = signal(null); + readonly lightboxState = signal(null); + readonly galleryAttachments = signal(null); readonly imageContextMenu = signal(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 { diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html index 78b4510..d93341e 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html @@ -1,7 +1,7 @@
@if (replyTo()) {
} -
+
Drop files to attach
diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.scss b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.scss index 4482471..6fc0eb4 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.scss +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.scss @@ -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; diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts index f5adeb3..1a062bd 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts @@ -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)' } diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html index 8c27192..0dcc439 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html @@ -168,8 +168,70 @@ @if (attachmentsList.length > 0) {
+ @if (imageGridLayout().useGrid) { +
+ @for (cell of imageGridLayout().cells; track imageGridCellTrack(cell)) { + @if (cell.kind === 'image') { + @let gridImage = imageAttachmentAt(cell.index); + @if (gridImage) { + @if (isDisplayableImage(gridImage)) { +
+ +
+
+ } @else if ((gridImage.receivedBytes || 0) > 0) { +
+ + {{ ((gridImage.receivedBytes || 0) * 100) / gridImage.size | number: '1.0-0' }}% +
+ } @else { +
+ + +
+ } + } + } @else { + + } + } +
+ } @for (att of attachmentsList; track att.id) { - @if (att.isImage) { + @if (shouldShowAttachmentInList(att)) { + @if (isImageLikeAttachment(att) && !imageGridLayout().useGrid) { @if (att.available && att.objectUrl) {
} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.scss b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.scss index de74bc4..0fb3b26 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.scss +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.scss @@ -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; diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts index 4baaaf6..65a70d6 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts @@ -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(); readonly referenceRequested = output(); readonly downloadRequested = output(); - readonly imageOpened = output(); + readonly imageOpened = output(); + readonly imageGalleryOpened = output(); readonly imageContextMenuRequested = output(); readonly embedRemoved = output(); @@ -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 { diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html index f181bc7..7b281d6 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html @@ -76,6 +76,7 @@ (referenceRequested)="handleReferenceRequested($event)" (downloadRequested)="handleDownloadRequested($event)" (imageOpened)="handleImageOpened($event)" + (imageGalleryOpened)="handleImageGalleryOpened($event)" (imageContextMenuRequested)="handleImageContextMenuRequested($event)" (embedRemoved)="handleEmbedRemoved($event)" /> diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts index 148c7f2..718fe59 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts @@ -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(); readonly reactionToggled = output(); readonly downloadRequested = output(); - readonly imageOpened = output(); + readonly imageOpened = output(); + readonly imageGalleryOpened = output(); readonly imageContextMenuRequested = output(); readonly embedRemoved = output(); /** @@ -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 { diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.html index afe8dbd..7a69186 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-overlays/chat-message-overlays.component.html @@ -1,24 +1,110 @@ -@if (lightboxAttachment()) { -
+@if (galleryAttachments()) { + +
+
+
+

View images

+

{{ galleryAttachments()!.length }} images

+
+ +
+
+
+ @for (attachment of galleryAttachments(); track attachment.id) { + + } +
+
+
+
+} + +@if (lightboxAttachment()) { + +
+