feat: Add deafen to pc, fix mobiel view, fix freeze on startup
This commit is contained in:
@@ -25,6 +25,34 @@ Durable rules for AI agents working on this project. Read this file at session s
|
|||||||
|
|
||||||
## Lessons
|
## 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 `<app-servers-rail>`).
|
||||||
|
|
||||||
|
### 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]
|
### Use the upgrade transaction during IndexedDB schema migrations [persistence] [browser]
|
||||||
|
|
||||||
- **Trigger:** bumping `BROWSER_DATABASE_VERSION` and opening existing stores via `database.transaction(...)` inside `onupgradeneeded`.
|
- **Trigger:** bumping `BROWSER_DATABASE_VERSION` and opening existing stores via `database.transaction(...)` inside `onupgradeneeded`.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Cross-context mobile shell for the Angular product client (`toju-app/`). Wraps t
|
|||||||
|
|
||||||
## Responsibilities
|
## 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.
|
- 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.
|
- Integrate with direct-call, voice-workspace, and chat composer flows.
|
||||||
|
|
||||||
|
|||||||
165
project-files/themes/toju-default-dark-11.json
Normal file
165
project-files/themes/toju-default-dark-11.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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.
|
- 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.
|
- Theme-driven grid layouts (`appShell`, `roomLayout`, `dmLayout`) are bypassed on mobile. Do not introduce mobile-specific theme layouts; gate via `@if (isMobile())` in templates instead.
|
||||||
- The mobile chat-room shell (`features/room/chat-room`) is a 3-page stack (channels -> main -> members); the DM workspace (`domains/direct-message/feature/dm-workspace`) is 2-page (conversations -> chat). Page state is a component-local signal kept in sync with a Swiper carousel (`<swiper-container>` / `<swiper-slide>` from `swiper/element/bundle`, registered in `src/main.ts`); both components declare `CUSTOM_ELEMENTS_SCHEMA`.
|
- On mobile discovery routes (`/dashboard`, `/people`, `/servers`, …) the global `app.html` servers rail stays visible (`shouldShowMobileAppServersRail` in `core/platform/mobile-shell-layout.rules.ts`); routed pages render full-width in `appWorkspace` and must not embed a second `<app-servers-rail>`/Swiper stack.
|
||||||
|
- The mobile chat-room shell (`features/room/chat-room`) is a 3-page stack (channels -> main -> members); the DM workspace (`domains/direct-message/feature/dm-workspace`) is 2-page (conversations -> chat). Page state is a component-local signal kept in sync with a Swiper carousel (`<swiper-container>` / `<swiper-slide>` from `swiper/element/bundle`, registered in `src/main.ts`); both components declare `CUSTOM_ELEMENTS_SCHEMA`. Chat/DM/call routes keep their embedded rail inside Swiper and hide the global app-shell rail.
|
||||||
- The Electron-style title bar is hidden on mobile. Screen-share UI must stay hidden on mobile (browsers do not support it reliably on touch devices).
|
- The Electron-style title bar is hidden on mobile. Screen-share UI must stay hidden on mobile (browsers do not support it reliably on touch devices).
|
||||||
- Context menus and modal dialogs auto-render as bottom sheets on mobile. `ContextMenuComponent` and `ConfirmDialogComponent` (in `src/app/shared/components/`) inject `ViewportService` and switch their templates between the desktop popover/centered modal and `BottomSheetComponent` (`src/app/shared/components/bottom-sheet/`) on phone-sized viewports. New menus/dialogs should reuse these components rather than rolling their own `fixed inset-0` overlay. For one-off bespoke surfaces, render `<app-bottom-sheet>` directly when `isMobile()`.
|
- Context menus and modal dialogs auto-render as bottom sheets on mobile. `ContextMenuComponent` and `ConfirmDialogComponent` (in `src/app/shared/components/`) inject `ViewportService` and switch their templates between the desktop popover/centered modal and `BottomSheetComponent` (`src/app/shared/components/bottom-sheet/`) on phone-sized viewports. New menus/dialogs should reuse these components rather than rolling their own `fixed inset-0` overlay. For one-off bespoke surfaces, render `<app-bottom-sheet>` directly when `isMobile()`.
|
||||||
- Tap targets on interactive controls should be at least 44px on mobile. Use `min-h-11` (or explicit `h-11 w-11`) for icon buttons that are tap-only on mobile; desktop sizes can remain smaller via `md:` overrides.
|
- 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.
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
>
|
>
|
||||||
<aside
|
<aside
|
||||||
appThemeNode="serversRail"
|
appThemeNode="serversRail"
|
||||||
class="min-h-0 overflow-hidden bg-transparent"
|
class="min-h-0 shrink-0 overflow-hidden bg-transparent"
|
||||||
[class.hidden]="isThemeStudioFullscreen() || isMobile()"
|
[class.hidden]="hideAppServersRail()"
|
||||||
|
[class.w-16]="showMobileAppServersRail()"
|
||||||
[ngStyle]="isMobile() ? null : serversRailLayoutStyles()"
|
[ngStyle]="isMobile() ? null : serversRailLayoutStyles()"
|
||||||
>
|
>
|
||||||
<app-servers-rail class="block h-full" />
|
<app-servers-rail class="block h-full w-full" />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="absolute inset-0 overflow-auto">
|
<div class="absolute inset-0 min-w-0 overflow-x-hidden overflow-y-auto">
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { NotificationsFacade } from './domains/notifications';
|
|||||||
import { TimeSyncService } from './core/services/time-sync.service';
|
import { TimeSyncService } from './core/services/time-sync.service';
|
||||||
import { VoiceSessionFacade } from './domains/voice-session';
|
import { VoiceSessionFacade } from './domains/voice-session';
|
||||||
import { ExternalLinkService, ViewportService } from './core/platform';
|
import { ExternalLinkService, ViewportService } from './core/platform';
|
||||||
|
import { shouldShowMobileAppServersRail } from './core/platform/mobile-shell-layout.rules';
|
||||||
import { SettingsModalService } from './core/services/settings-modal.service';
|
import { SettingsModalService } from './core/services/settings-modal.service';
|
||||||
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
||||||
import { UserStatusService } from './core/services/user-status.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');
|
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(() => {
|
readonly desktopUpdateNoticeKey = computed(() => {
|
||||||
const updateState = this.desktopUpdateState();
|
const updateState = this.desktopUpdateState();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
16
toju-app/src/app/core/platform/mobile-shell-layout.rules.ts
Normal file
16
toju-app/src/app/core/platform/mobile-shell-layout.rules.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const MOBILE_NO_APP_RAIL_PREFIXES = ['/login', '/register'] as const;
|
||||||
|
|
||||||
|
const MOBILE_EMBEDDED_RAIL_PREFIXES = ['/room/', '/dm', '/pm', '/call'] as const;
|
||||||
|
|
||||||
|
/** Whether the mobile app shell should render the global servers rail for a route. */
|
||||||
|
export function shouldShowMobileAppServersRail(routePath: string): boolean {
|
||||||
|
if (MOBILE_NO_APP_RAIL_PREFIXES.some((prefix) => routePath.startsWith(prefix))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MOBILE_EMBEDDED_RAIL_PREFIXES.some((prefix) => routePath.startsWith(prefix))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -69,6 +69,12 @@ export class AttachmentFacade {
|
|||||||
return this.manager.requestImageFromAnyPeer(...args);
|
return this.manager.requestImageFromAnyPeer(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tryRestoreAttachmentFromLocal(
|
||||||
|
...args: Parameters<AttachmentManagerService['tryRestoreAttachmentFromLocal']>
|
||||||
|
): ReturnType<AttachmentManagerService['tryRestoreAttachmentFromLocal']> {
|
||||||
|
return this.manager.tryRestoreAttachmentFromLocal(...args);
|
||||||
|
}
|
||||||
|
|
||||||
requestFile(
|
requestFile(
|
||||||
...args: Parameters<AttachmentManagerService['requestFile']>
|
...args: Parameters<AttachmentManagerService['requestFile']>
|
||||||
): ReturnType<AttachmentManagerService['requestFile']> {
|
): ReturnType<AttachmentManagerService['requestFile']> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { NavigationEnd, Router } from '@angular/router';
|
import { NavigationEnd, Router } from '@angular/router';
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||||
|
import { yieldToAttachmentHydrationLoop } from '../../domain/logic/attachment-blob.rules';
|
||||||
import {
|
import {
|
||||||
getWatchedAttachmentRoomIdFromUrl,
|
getWatchedAttachmentRoomIdFromUrl,
|
||||||
isDirectMessageAttachmentRoomId,
|
isDirectMessageAttachmentRoomId,
|
||||||
@@ -141,6 +142,16 @@ export class AttachmentManagerService {
|
|||||||
return this.transfer.requestImageFromAnyPeer(messageId, attachment);
|
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> {
|
requestFile(messageId: string, attachment: Attachment): Promise<void> {
|
||||||
return this.transfer.requestFile(messageId, attachment);
|
return this.transfer.requestFile(messageId, attachment);
|
||||||
}
|
}
|
||||||
@@ -194,12 +205,14 @@ export class AttachmentManagerService {
|
|||||||
await this.persistence.whenReady();
|
await this.persistence.whenReady();
|
||||||
|
|
||||||
const messageIds = await this.collectMessageIdsForRoom(roomId);
|
const messageIds = await this.collectMessageIdsForRoom(roomId);
|
||||||
|
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
|
|
||||||
for (const messageId of messageIds) {
|
for (const messageId of messageIds) {
|
||||||
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
|
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
|
||||||
if (await this.persistence.tryRestoreAttachmentFromLocal(attachment)) {
|
if (await this.persistence.tryRestoreAttachmentFromLocal(attachment)) {
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
|
await yieldToAttachmentHydrationLoop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,11 @@ import { DatabaseService } from '../../../../infrastructure/persistence';
|
|||||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||||
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
|
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
|
||||||
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../../domain/constants/attachment-transfer.constants';
|
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 { isBlobObjectUrl, needsBlobObjectUrlForInlineDisplay } from '../../domain/logic/attachment-display-url.rules';
|
||||||
import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules';
|
import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules';
|
||||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||||
@@ -144,15 +149,11 @@ export class AttachmentPersistenceService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const base64 = await this.attachmentStorage.readFile(diskPath);
|
|
||||||
|
|
||||||
if (!base64) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.revokeAttachmentObjectUrl(attachment);
|
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> {
|
async persistUploadCopyFromSourcePath(attachment: Attachment, sourcePath: string): Promise<string | null> {
|
||||||
@@ -263,30 +264,54 @@ export class AttachmentPersistenceService {
|
|||||||
private async runInitFromDatabase(): Promise<void> {
|
private async runInitFromDatabase(): Promise<void> {
|
||||||
await this.loadFromDatabase();
|
await this.loadFromDatabase();
|
||||||
await this.migrateFromLocalStorage();
|
await this.migrateFromLocalStorage();
|
||||||
await this.tryLoadSavedFiles();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tryLoadSavedFiles(): Promise<void> {
|
private async restoreAttachmentBlobFromDiskPath(attachment: Attachment, diskPath: string): Promise<boolean> {
|
||||||
try {
|
if (this.attachmentStorage.canReadFileChunks()) {
|
||||||
let hasChanges = false;
|
const fileSize = await this.attachmentStorage.getFileSize(diskPath);
|
||||||
|
|
||||||
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
|
if (!fileSize || fileSize < 1) {
|
||||||
for (const attachment of attachments) {
|
return false;
|
||||||
if (await this.tryRestoreAttachmentFromLocal(attachment)) {
|
|
||||||
hasChanges = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.applyAttachmentBlob(attachment, new Blob(blobParts as BlobPart[], { type: attachment.mime }));
|
||||||
this.runtimeStore.touch();
|
return true;
|
||||||
} catch { /* startup load is best-effort */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private restoreAttachmentFromDisk(attachment: Attachment, base64: string): void {
|
const base64 = await this.attachmentStorage.readFile(diskPath);
|
||||||
const bytes = this.base64ToUint8Array(base64);
|
|
||||||
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime });
|
|
||||||
|
|
||||||
|
if (!base64) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = decodeBase64ToUint8Array(base64);
|
||||||
|
|
||||||
|
this.applyAttachmentBlob(
|
||||||
|
attachment,
|
||||||
|
new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime })
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyAttachmentBlob(attachment: Attachment, blob: Blob): void {
|
||||||
attachment.objectUrl = URL.createObjectURL(blob);
|
attachment.objectUrl = URL.createObjectURL(blob);
|
||||||
attachment.available = true;
|
attachment.available = true;
|
||||||
|
|
||||||
@@ -335,14 +360,4 @@ export class AttachmentPersistenceService {
|
|||||||
return retainedSavedPaths;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { RealtimeSessionFacade } from '../../../../core/realtime';
|
|||||||
import { selectCurrentUserId } from '../../../../store/users/users.selectors';
|
import { selectCurrentUserId } from '../../../../store/users/users.selectors';
|
||||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
|
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 { shouldCopyUploaderMediaToAppData, shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic';
|
||||||
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
|
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
|
||||||
import {
|
import {
|
||||||
@@ -208,7 +209,7 @@ export class AttachmentTransferService {
|
|||||||
filename: file.name,
|
filename: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
mime: file.type || DEFAULT_ATTACHMENT_MIME_TYPE,
|
mime: file.type || DEFAULT_ATTACHMENT_MIME_TYPE,
|
||||||
isImage: file.type.startsWith('image/'),
|
isImage: resolvePublishAttachmentIsImage(file),
|
||||||
uploaderPeerId,
|
uploaderPeerId,
|
||||||
filePath: (file as LocalFileWithPath).path,
|
filePath: (file as LocalFileWithPath).path,
|
||||||
available: false
|
available: false
|
||||||
@@ -309,7 +310,11 @@ export class AttachmentTransferService {
|
|||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
mime: file.mime,
|
mime: file.mime,
|
||||||
|
isImage: isImageAttachment({
|
||||||
|
filename: file.filename,
|
||||||
isImage: !!file.isImage,
|
isImage: !!file.isImage,
|
||||||
|
mime: file.mime
|
||||||
|
}),
|
||||||
uploaderPeerId: file.uploaderPeerId,
|
uploaderPeerId: file.uploaderPeerId,
|
||||||
available: false,
|
available: false,
|
||||||
receivedBytes: 0
|
receivedBytes: 0
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<div
|
<div
|
||||||
appThemeNode="chatSurface"
|
appThemeNode="chatSurface"
|
||||||
class="chat-layout relative h-full"
|
class="chat-layout relative h-full min-w-0 overflow-x-hidden"
|
||||||
>
|
>
|
||||||
<app-chat-message-list
|
<app-chat-message-list
|
||||||
[allMessages]="roomMessages()"
|
[allMessages]="roomMessages()"
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
(reactionToggled)="handleReactionToggled($event)"
|
(reactionToggled)="handleReactionToggled($event)"
|
||||||
(downloadRequested)="downloadAttachment($event)"
|
(downloadRequested)="downloadAttachment($event)"
|
||||||
(imageOpened)="openLightbox($event)"
|
(imageOpened)="openLightbox($event)"
|
||||||
|
(imageGalleryOpened)="openImageGallery($event)"
|
||||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||||
(embedRemoved)="handleEmbedRemoved($event)"
|
(embedRemoved)="handleEmbedRemoved($event)"
|
||||||
(loadOlderRequested)="handleLoadOlderRequested($event)"
|
(loadOlderRequested)="handleLoadOlderRequested($event)"
|
||||||
@@ -84,12 +85,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<app-chat-message-overlays
|
<app-chat-message-overlays
|
||||||
[lightboxAttachment]="lightboxAttachment()"
|
[lightboxState]="lightboxState()"
|
||||||
|
[galleryAttachments]="galleryAttachments()"
|
||||||
[imageContextMenu]="imageContextMenu()"
|
[imageContextMenu]="imageContextMenu()"
|
||||||
(lightboxClosed)="closeLightbox()"
|
(lightboxClosed)="closeLightbox()"
|
||||||
|
(lightboxStepRequested)="stepLightbox($event)"
|
||||||
|
(galleryClosed)="closeImageGallery()"
|
||||||
(contextMenuClosed)="closeImageContextMenu()"
|
(contextMenuClosed)="closeImageContextMenu()"
|
||||||
(downloadRequested)="downloadAttachment($event)"
|
(downloadRequested)="downloadAttachment($event)"
|
||||||
(copyRequested)="copyImageToClipboard($event)"
|
(copyRequested)="copyImageToClipboard($event)"
|
||||||
|
(imageOpened)="openLightbox($event)"
|
||||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
.chat-bottom-bar {
|
.chat-bottom-bar {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
right: 8px;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
min-width: 0;
|
||||||
background: hsl(var(--background) / 0.85);
|
background: hsl(var(--background) / 0.85);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
|||||||
@@ -34,12 +34,15 @@ import { ChatMessageComposerComponent } from './components/message-composer/chat
|
|||||||
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
|
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
|
||||||
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
|
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
|
||||||
import { ChatMessageOverlaysComponent } from './components/message-overlays/chat-message-overlays.component';
|
import { ChatMessageOverlaysComponent } from './components/message-overlays/chat-message-overlays.component';
|
||||||
|
import { stepLightboxIndex } from '../../domain/rules/chat-message-lightbox.rules';
|
||||||
import {
|
import {
|
||||||
|
ChatLightboxState,
|
||||||
ChatMessageComposerSubmitEvent,
|
ChatMessageComposerSubmitEvent,
|
||||||
ChatMessageDeleteEvent,
|
ChatMessageDeleteEvent,
|
||||||
ChatMessageEditEvent,
|
ChatMessageEditEvent,
|
||||||
ChatMessageEmbedRemoveEvent,
|
ChatMessageEmbedRemoveEvent,
|
||||||
ChatMessageImageContextMenuEvent,
|
ChatMessageImageContextMenuEvent,
|
||||||
|
ChatMessageImageLightboxEvent,
|
||||||
ChatMessageReactionEvent,
|
ChatMessageReactionEvent,
|
||||||
ChatMessageReplyEvent
|
ChatMessageReplyEvent
|
||||||
} from './models/chat-messages.model';
|
} from './models/chat-messages.model';
|
||||||
@@ -94,7 +97,8 @@ export class ChatMessagesComponent {
|
|||||||
readonly klipyGifPickerAnchorRight = signal(16);
|
readonly klipyGifPickerAnchorRight = signal(16);
|
||||||
readonly replyTo = signal<Message | null>(null);
|
readonly replyTo = signal<Message | null>(null);
|
||||||
readonly showKlipyGifPicker = signal(false);
|
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);
|
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -284,14 +288,55 @@ export class ChatMessagesComponent {
|
|||||||
return Math.max(0, viewportWidth - 32);
|
return Math.max(0, viewportWidth - 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
openLightbox(attachment: Attachment): void {
|
openLightbox(event: ChatMessageImageLightboxEvent): void {
|
||||||
if (attachment.available && attachment.objectUrl) {
|
const attachments = event.attachments.filter((attachment) => attachment.available && attachment.objectUrl);
|
||||||
this.lightboxAttachment.set(attachment);
|
const index = attachments.findIndex((attachment) => attachment.id === event.attachment.id);
|
||||||
|
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.lightboxState.set({
|
||||||
|
attachments,
|
||||||
|
index
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
closeLightbox(): void {
|
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 {
|
openImageContextMenu(event: ChatMessageImageContextMenuEvent): void {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
||||||
<div
|
<div
|
||||||
#composerRoot
|
#composerRoot
|
||||||
appThemeNode="chatComposerBar"
|
class="min-w-0 w-full"
|
||||||
>
|
>
|
||||||
@if (replyTo()) {
|
@if (replyTo()) {
|
||||||
<div
|
<div
|
||||||
@@ -129,10 +129,10 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="border-border p-4">
|
<div class="min-w-0 w-full px-3 py-3 sm:p-4">
|
||||||
<div
|
<div
|
||||||
appThemeNode="chatComposerInput"
|
appThemeNode="chatComposerInput"
|
||||||
class="chat-input-wrapper relative"
|
class="chat-input-wrapper relative min-w-0 w-full"
|
||||||
(mouseenter)="inputHovered.set(true)"
|
(mouseenter)="inputHovered.set(true)"
|
||||||
(mouseleave)="inputHovered.set(false)"
|
(mouseleave)="inputHovered.set(false)"
|
||||||
(dragenter)="onDragEnter($event)"
|
(dragenter)="onDragEnter($event)"
|
||||||
@@ -328,9 +328,7 @@
|
|||||||
(dragleave)="onDragLeave($event)"
|
(dragleave)="onDragLeave($event)"
|
||||||
(drop)="onDrop($event)"
|
(drop)="onDrop($event)"
|
||||||
placeholder="Type a message..."
|
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="chat-textarea w-full min-w-0 border-0 pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||||
[class.border-dashed]="dragActive()"
|
|
||||||
[class.border-primary]="dragActive()"
|
|
||||||
[class.chat-textarea-expanded]="textareaExpanded()"
|
[class.chat-textarea-expanded]="textareaExpanded()"
|
||||||
[class.ctrl-resize]="ctrlHeld()"
|
[class.ctrl-resize]="ctrlHeld()"
|
||||||
[ngClass]="composerTextareaPaddingClass()"
|
[ngClass]="composerTextareaPaddingClass()"
|
||||||
@@ -338,7 +336,7 @@
|
|||||||
|
|
||||||
@if (dragActive()) {
|
@if (dragActive()) {
|
||||||
<div
|
<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 class="text-sm text-muted-foreground">Drop files to attach</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
|
.chat-input-wrapper {
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: hsl(var(--primary)) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chat-textarea {
|
.chat-textarea {
|
||||||
--textarea-bg: hsl(40deg 3.7% 15.9% / 25%);
|
|
||||||
--textarea-collapsed-padding-y: 18px;
|
--textarea-collapsed-padding-y: 18px;
|
||||||
--textarea-expanded-padding-y: 8px;
|
--textarea-expanded-padding-y: 8px;
|
||||||
|
|
||||||
background: var(--textarea-bg);
|
background: transparent;
|
||||||
height: 62px;
|
height: 62px;
|
||||||
min-height: 62px;
|
min-height: 62px;
|
||||||
max-height: 520px;
|
max-height: 520px;
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
|
|||||||
templateUrl: './chat-message-composer.component.html',
|
templateUrl: './chat-message-composer.component.html',
|
||||||
styleUrl: './chat-message-composer.component.scss',
|
styleUrl: './chat-message-composer.component.scss',
|
||||||
host: {
|
host: {
|
||||||
|
class: 'block min-w-0 w-full',
|
||||||
'(document:keydown)': 'onDocKeydown($event)',
|
'(document:keydown)': 'onDocKeydown($event)',
|
||||||
'(document:keyup)': 'onDocKeyup($event)'
|
'(document:keyup)': 'onDocKeyup($event)'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,8 +168,70 @@
|
|||||||
|
|
||||||
@if (attachmentsList.length > 0) {
|
@if (attachmentsList.length > 0) {
|
||||||
<div class="mt-2 space-y-2">
|
<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) {
|
@for (att of attachmentsList; track att.id) {
|
||||||
@if (att.isImage) {
|
@if (shouldShowAttachmentInList(att)) {
|
||||||
|
@if (isImageLikeAttachment(att) && !imageGridLayout().useGrid) {
|
||||||
@if (att.available && att.objectUrl) {
|
@if (att.available && att.objectUrl) {
|
||||||
<div
|
<div
|
||||||
class="group/img relative inline-block"
|
class="group/img relative inline-block"
|
||||||
@@ -465,6 +527,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
.edit-textarea {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
max-height: 520px;
|
max-height: 520px;
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ import {
|
|||||||
MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES,
|
MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES,
|
||||||
MAX_AUTO_SAVE_SIZE_BYTES
|
MAX_AUTO_SAVE_SIZE_BYTES
|
||||||
} from '../../../../../attachment';
|
} from '../../../../../attachment';
|
||||||
|
import {
|
||||||
|
dedupeImageAttachmentsForDisplay,
|
||||||
|
isImageAttachment,
|
||||||
|
isInlineDisplayableImage
|
||||||
|
} from '../../../../../attachment/domain/logic/attachment-image.rules';
|
||||||
import { PlatformService, ViewportService } from '../../../../../../core/platform';
|
import { PlatformService, ViewportService } from '../../../../../../core/platform';
|
||||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||||
import { ExperimentalMediaSettingsService } from '../../../../../experimental-media';
|
import { ExperimentalMediaSettingsService } from '../../../../../experimental-media';
|
||||||
@@ -72,11 +77,18 @@ import {
|
|||||||
} from '../../../../../../shared';
|
} from '../../../../../../shared';
|
||||||
import { ChatMessageMarkdownComponent } from './chat-message-markdown/chat-message-markdown.component';
|
import { ChatMessageMarkdownComponent } from './chat-message-markdown/chat-message-markdown.component';
|
||||||
import { ChatLinkEmbedComponent } from './chat-link-embed/chat-link-embed.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 {
|
import {
|
||||||
ChatMessageDeleteEvent,
|
ChatMessageDeleteEvent,
|
||||||
ChatMessageEditEvent,
|
ChatMessageEditEvent,
|
||||||
ChatMessageEmbedRemoveEvent,
|
ChatMessageEmbedRemoveEvent,
|
||||||
ChatMessageImageContextMenuEvent,
|
ChatMessageImageContextMenuEvent,
|
||||||
|
ChatMessageImageGalleryEvent,
|
||||||
|
ChatMessageImageLightboxEvent,
|
||||||
ChatMessageReactionEvent,
|
ChatMessageReactionEvent,
|
||||||
ChatMessageReplyEvent
|
ChatMessageReplyEvent
|
||||||
} from '../../models/chat-messages.model';
|
} from '../../models/chat-messages.model';
|
||||||
@@ -193,7 +205,8 @@ export class ChatMessageItemComponent implements OnDestroy {
|
|||||||
readonly reactionToggled = output<ChatMessageReactionEvent>();
|
readonly reactionToggled = output<ChatMessageReactionEvent>();
|
||||||
readonly referenceRequested = output<string>();
|
readonly referenceRequested = output<string>();
|
||||||
readonly downloadRequested = output<Attachment>();
|
readonly downloadRequested = output<Attachment>();
|
||||||
readonly imageOpened = output<Attachment>();
|
readonly imageOpened = output<ChatMessageImageLightboxEvent>();
|
||||||
|
readonly imageGalleryOpened = output<ChatMessageImageGalleryEvent>();
|
||||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||||
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
|
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));
|
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(() => {
|
private readonly syncAttachmentVersion = effect(() => {
|
||||||
const version = this.attachmentsSvc.updated();
|
const version = this.attachmentsSvc.updated();
|
||||||
|
|
||||||
@@ -694,9 +743,54 @@ export class ChatMessageItemComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openLightbox(attachment: Attachment): void {
|
openLightbox(attachment: Attachment): void {
|
||||||
if (attachment.available && attachment.objectUrl) {
|
const attachments = this.displayableImages();
|
||||||
this.imageOpened.emit(attachment);
|
|
||||||
|
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 {
|
openImageContextMenu(event: MouseEvent, attachment: Attachment): void {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
(referenceRequested)="handleReferenceRequested($event)"
|
(referenceRequested)="handleReferenceRequested($event)"
|
||||||
(downloadRequested)="handleDownloadRequested($event)"
|
(downloadRequested)="handleDownloadRequested($event)"
|
||||||
(imageOpened)="handleImageOpened($event)"
|
(imageOpened)="handleImageOpened($event)"
|
||||||
|
(imageGalleryOpened)="handleImageGalleryOpened($event)"
|
||||||
(imageContextMenuRequested)="handleImageContextMenuRequested($event)"
|
(imageContextMenuRequested)="handleImageContextMenuRequested($event)"
|
||||||
(embedRemoved)="handleEmbedRemoved($event)"
|
(embedRemoved)="handleEmbedRemoved($event)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
ChatMessageEditEvent,
|
ChatMessageEditEvent,
|
||||||
ChatMessageEmbedRemoveEvent,
|
ChatMessageEmbedRemoveEvent,
|
||||||
ChatMessageImageContextMenuEvent,
|
ChatMessageImageContextMenuEvent,
|
||||||
|
ChatMessageImageGalleryEvent,
|
||||||
|
ChatMessageImageLightboxEvent,
|
||||||
ChatMessageReactionEvent,
|
ChatMessageReactionEvent,
|
||||||
ChatMessageReplyEvent
|
ChatMessageReplyEvent
|
||||||
} from '../../models/chat-messages.model';
|
} from '../../models/chat-messages.model';
|
||||||
@@ -87,7 +89,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
readonly reactionAdded = output<ChatMessageReactionEvent>();
|
readonly reactionAdded = output<ChatMessageReactionEvent>();
|
||||||
readonly reactionToggled = output<ChatMessageReactionEvent>();
|
readonly reactionToggled = output<ChatMessageReactionEvent>();
|
||||||
readonly downloadRequested = output<Attachment>();
|
readonly downloadRequested = output<Attachment>();
|
||||||
readonly imageOpened = output<Attachment>();
|
readonly imageOpened = output<ChatMessageImageLightboxEvent>();
|
||||||
|
readonly imageGalleryOpened = output<ChatMessageImageGalleryEvent>();
|
||||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||||
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
|
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
|
||||||
/**
|
/**
|
||||||
@@ -499,8 +502,12 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
this.downloadRequested.emit(attachment);
|
this.downloadRequested.emit(attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleImageOpened(attachment: Attachment): void {
|
handleImageOpened(event: ChatMessageImageLightboxEvent): void {
|
||||||
this.imageOpened.emit(attachment);
|
this.imageOpened.emit(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleImageGalleryOpened(attachments: ChatMessageImageGalleryEvent): void {
|
||||||
|
this.imageGalleryOpened.emit(attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleImageContextMenuRequested(event: ChatMessageImageContextMenuEvent): void {
|
handleImageContextMenuRequested(event: ChatMessageImageContextMenuEvent): void {
|
||||||
|
|||||||
@@ -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 -->
|
<!-- 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()) {
|
@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
|
<div
|
||||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
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)="closeLightbox()"
|
|
||||||
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
|
|
||||||
(keydown.escape)="closeLightbox()"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="relative max-h-[90vh] max-w-[90vw]"
|
|
||||||
(click)="$event.stopPropagation()"
|
(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
|
<img
|
||||||
[src]="lightboxAttachment()!.objectUrl"
|
[src]="lightboxAttachment()!.objectUrl"
|
||||||
[alt]="lightboxAttachment()!.filename"
|
[alt]="lightboxAttachment()!.filename"
|
||||||
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
|
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
|
||||||
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!); $event.stopPropagation()"
|
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!); $event.stopPropagation()"
|
||||||
/>
|
/>
|
||||||
<div class="absolute right-3 top-3 flex gap-2">
|
|
||||||
|
@if (canShowNextLightboxImage()) {
|
||||||
<button
|
<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()!)"
|
(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"
|
class="grid h-9 w-9 place-items-center rounded-lg bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
|
||||||
title="Download"
|
title="Download"
|
||||||
@@ -29,9 +115,11 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
(click)="closeLightbox()"
|
(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"
|
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"
|
title="Close"
|
||||||
|
aria-label="Close image preview"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideX"
|
name="lucideX"
|
||||||
@@ -39,11 +127,16 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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="text-sm text-white">{{ lightboxAttachment()!.filename }}</span>
|
||||||
<span class="ml-2 text-xs text-white/60">{{ formatBytes(lightboxAttachment()!.size) }}</span>
|
<span class="ml-2 text-xs text-white/60">{{ formatBytes(lightboxAttachment()!.size) }}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,18 +1,31 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
HostListener,
|
||||||
|
OnDestroy,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
input,
|
input,
|
||||||
output
|
output,
|
||||||
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
|
lucideChevronLeft,
|
||||||
|
lucideChevronRight,
|
||||||
lucideCopy,
|
lucideCopy,
|
||||||
lucideDownload,
|
lucideDownload,
|
||||||
lucideX
|
lucideX
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
import { Attachment } from '../../../../../attachment';
|
import { Attachment } from '../../../../../attachment';
|
||||||
import { ContextMenuComponent } from '../../../../../../shared';
|
import { canStepLightbox } from '../../../../domain/rules/chat-message-lightbox.rules';
|
||||||
import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.model';
|
import { ContextMenuComponent, ModalBackdropComponent } from '../../../../../../shared';
|
||||||
|
import {
|
||||||
|
ChatLightboxState,
|
||||||
|
ChatMessageImageGalleryEvent,
|
||||||
|
ChatMessageImageContextMenuEvent,
|
||||||
|
ChatMessageImageLightboxEvent
|
||||||
|
} from '../../models/chat-messages.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-message-overlays',
|
selector: 'app-chat-message-overlays',
|
||||||
@@ -20,34 +33,169 @@ import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.mod
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
ContextMenuComponent
|
ContextMenuComponent,
|
||||||
|
ModalBackdropComponent
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
|
lucideChevronLeft,
|
||||||
|
lucideChevronRight,
|
||||||
lucideCopy,
|
lucideCopy,
|
||||||
lucideDownload,
|
lucideDownload,
|
||||||
lucideX
|
lucideX
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
templateUrl: './chat-message-overlays.component.html',
|
templateUrl: './chat-message-overlays.component.html',
|
||||||
|
styleUrl: './chat-message-overlays.component.scss',
|
||||||
host: {
|
host: {
|
||||||
style: 'display: contents;'
|
style: 'display: contents;'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class ChatMessageOverlaysComponent {
|
export class ChatMessageOverlaysComponent implements OnDestroy {
|
||||||
readonly lightboxAttachment = input<Attachment | null>(null);
|
readonly lightboxControlsVisible = signal(true);
|
||||||
|
readonly lightboxState = input<ChatLightboxState | null>(null);
|
||||||
|
readonly galleryAttachments = input<ChatMessageImageGalleryEvent | null>(null);
|
||||||
readonly imageContextMenu = input<ChatMessageImageContextMenuEvent | null>(null);
|
readonly imageContextMenu = input<ChatMessageImageContextMenuEvent | null>(null);
|
||||||
|
|
||||||
readonly lightboxClosed = output();
|
readonly lightboxClosed = output();
|
||||||
|
readonly lightboxStepRequested = output<number>();
|
||||||
|
readonly galleryClosed = output();
|
||||||
readonly contextMenuClosed = output();
|
readonly contextMenuClosed = output();
|
||||||
readonly downloadRequested = output<Attachment>();
|
readonly downloadRequested = output<Attachment>();
|
||||||
readonly copyRequested = output<Attachment>();
|
readonly copyRequested = output<Attachment>();
|
||||||
|
readonly imageOpened = output<ChatMessageImageLightboxEvent>();
|
||||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
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 {
|
closeLightbox(): void {
|
||||||
this.lightboxClosed.emit();
|
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 {
|
closeImageContextMenu(): void {
|
||||||
this.contextMenuClosed.emit();
|
this.contextMenuClosed.emit();
|
||||||
}
|
}
|
||||||
@@ -88,4 +236,24 @@ export class ChatMessageOverlaysComponent {
|
|||||||
|
|
||||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ export interface ChatMessageImageContextMenuEvent {
|
|||||||
attachment: Attachment;
|
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 ChatMessageReplyEvent = Message;
|
||||||
export type ChatMessageDeleteEvent = Message;
|
export type ChatMessageDeleteEvent = Message;
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<section
|
<section
|
||||||
appThemeNode="dmChatSurface"
|
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
|
<header
|
||||||
appThemeNode="dmChatHeader"
|
appThemeNode="dmChatHeader"
|
||||||
@@ -78,6 +78,7 @@
|
|||||||
(reactionToggled)="handleReactionToggled($event)"
|
(reactionToggled)="handleReactionToggled($event)"
|
||||||
(downloadRequested)="downloadAttachment($event)"
|
(downloadRequested)="downloadAttachment($event)"
|
||||||
(imageOpened)="openLightbox($event)"
|
(imageOpened)="openLightbox($event)"
|
||||||
|
(imageGalleryOpened)="openImageGallery($event)"
|
||||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||||
(embedRemoved)="handleEmbedRemoved($event)"
|
(embedRemoved)="handleEmbedRemoved($event)"
|
||||||
/>
|
/>
|
||||||
@@ -93,7 +94,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
appThemeNode="chatComposerBar"
|
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) {
|
@if (typingUsers().length > 0) {
|
||||||
<div
|
<div
|
||||||
@@ -156,12 +157,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<app-chat-message-overlays
|
<app-chat-message-overlays
|
||||||
[lightboxAttachment]="lightboxAttachment()"
|
[lightboxState]="lightboxState()"
|
||||||
|
[galleryAttachments]="galleryAttachments()"
|
||||||
[imageContextMenu]="imageContextMenu()"
|
[imageContextMenu]="imageContextMenu()"
|
||||||
(lightboxClosed)="closeLightbox()"
|
(lightboxClosed)="closeLightbox()"
|
||||||
|
(lightboxStepRequested)="stepLightbox($event)"
|
||||||
|
(galleryClosed)="closeImageGallery()"
|
||||||
(contextMenuClosed)="closeImageContextMenu()"
|
(contextMenuClosed)="closeImageContextMenu()"
|
||||||
(downloadRequested)="downloadAttachment($event)"
|
(downloadRequested)="downloadAttachment($event)"
|
||||||
(copyRequested)="copyImageToClipboard($event)"
|
(copyRequested)="copyImageToClipboard($event)"
|
||||||
|
(imageOpened)="openLightbox($event)"
|
||||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ import {
|
|||||||
LinkMetadataService,
|
LinkMetadataService,
|
||||||
type ChatMessageEmbedRemoveEvent
|
type ChatMessageEmbedRemoveEvent
|
||||||
} from '../../../chat';
|
} 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 {
|
import type {
|
||||||
DirectMessageStatus,
|
DirectMessageStatus,
|
||||||
LinkMetadata,
|
LinkMetadata,
|
||||||
@@ -102,7 +104,8 @@ export class DmChatComponent {
|
|||||||
readonly gifPickerAnchorRight = signal(16);
|
readonly gifPickerAnchorRight = signal(16);
|
||||||
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
|
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
|
||||||
readonly replyTo = signal<Message | null>(null);
|
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 imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
||||||
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
||||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
||||||
@@ -395,14 +398,55 @@ export class DmChatComponent {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
openLightbox(attachment: Attachment): void {
|
openLightbox(event: ChatMessageImageLightboxEvent): void {
|
||||||
if (attachment.available && attachment.objectUrl) {
|
const attachments = event.attachments.filter((attachment) => attachment.available && attachment.objectUrl);
|
||||||
this.lightboxAttachment.set(attachment);
|
const index = attachments.findIndex((attachment) => attachment.id === event.attachment.id);
|
||||||
|
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.lightboxState.set({
|
||||||
|
attachments,
|
||||||
|
index
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
closeLightbox(): void {
|
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 {
|
openImageContextMenu(event: ChatMessageImageContextMenuEvent): void {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<swiper-slide class="block h-full w-full">
|
<swiper-slide class="block h-full w-full">
|
||||||
<div class="flex h-full w-full min-h-0 overflow-hidden">
|
<div class="flex h-full w-full min-h-0 overflow-hidden">
|
||||||
<app-servers-rail class="block h-full shrink-0" />
|
<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
|
<app-dm-conversations-panel
|
||||||
(conversationSelected)="setMobilePage('chat')"
|
(conversationSelected)="setMobilePage('chat')"
|
||||||
class="block h-full w-full"
|
class="block h-full w-full"
|
||||||
|
|||||||
@@ -60,24 +60,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</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" />
|
<ng-container [ngTemplateOutlet]="pageContent" />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</swiper-slide>
|
|
||||||
</swiper-container>
|
|
||||||
} @else {
|
|
||||||
<ng-container [ngTemplateOutlet]="pageContent" />
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
|
|
||||||
import { FindPeopleComponent } from './find-people.component';
|
import { FindPeopleComponent } from './find-people.component';
|
||||||
import { ViewportService } from '../../../../core/platform';
|
|
||||||
import { selectAllUsers } from '../../../../store/users/users.selectors';
|
import { selectAllUsers } from '../../../../store/users/users.selectors';
|
||||||
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||||
import type { User, Room } from '../../../../shared-kernel';
|
import type { User, Room } from '../../../../shared-kernel';
|
||||||
@@ -21,7 +20,6 @@ import type { User, Room } from '../../../../shared-kernel';
|
|||||||
interface HarnessOptions {
|
interface HarnessOptions {
|
||||||
users?: User[];
|
users?: User[];
|
||||||
saved?: Room[];
|
saved?: Room[];
|
||||||
isMobile?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHarness(options: HarnessOptions = {}) {
|
function createHarness(options: HarnessOptions = {}) {
|
||||||
@@ -44,8 +42,7 @@ function createHarness(options: HarnessOptions = {}) {
|
|||||||
const injector = Injector.create({
|
const injector = Injector.create({
|
||||||
providers: [
|
providers: [
|
||||||
FindPeopleComponent,
|
FindPeopleComponent,
|
||||||
{ provide: Store, useValue: store },
|
{ provide: Store, useValue: store }
|
||||||
{ provide: ViewportService, useValue: { isMobile: signal(options.isMobile ?? false) } }
|
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
const component = runInInjectionContext(injector, () => injector.get(FindPeopleComponent));
|
const component = runInInjectionContext(injector, () => injector.get(FindPeopleComponent));
|
||||||
@@ -54,11 +51,6 @@ function createHarness(options: HarnessOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('FindPeopleComponent', () => {
|
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', () => {
|
it('has no discoverable people for a brand-new account', () => {
|
||||||
const { component } = createHarness();
|
const { component } = createHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
CUSTOM_ELEMENTS_SCHEMA,
|
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
@@ -18,16 +17,13 @@ import {
|
|||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { UserSearchListComponent } from '../user-search-list/user-search-list.component';
|
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 { selectAllUsers } from '../../../../store/users/users.selectors';
|
||||||
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dedicated people-discovery page. Wraps {@link UserSearchListComponent} with a search
|
* 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.
|
* 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
|
* On mobile the global app-shell servers rail stays visible beside this page.
|
||||||
* primary navigation stays reachable, matching the chat-room and DM workspace layouts.
|
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-find-people',
|
selector: 'app-find-people',
|
||||||
@@ -37,18 +33,16 @@ import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
UserSearchListComponent,
|
UserSearchListComponent
|
||||||
ServersRailComponent
|
|
||||||
],
|
],
|
||||||
viewProviders: [provideIcons({ lucideArrowLeft, lucideSearch, lucideUsers })],
|
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 {
|
export class FindPeopleComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private readonly viewport = inject(ViewportService);
|
|
||||||
|
|
||||||
readonly isMobile = this.viewport.isMobile;
|
|
||||||
searchQuery = signal('');
|
searchQuery = signal('');
|
||||||
private users = this.store.selectSignal(selectAllUsers);
|
private users = this.store.selectSignal(selectAllUsers);
|
||||||
private savedRooms = this.store.selectSignal(selectSavedRooms);
|
private savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
|
|||||||
@@ -28,24 +28,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</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" />
|
<ng-container [ngTemplateOutlet]="pageContent" />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</swiper-slide>
|
|
||||||
</swiper-container>
|
|
||||||
} @else {
|
|
||||||
<ng-container [ngTemplateOutlet]="pageContent" />
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { Store } from '@ngrx/store';
|
|||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
import { FindServersComponent } from './find-servers.component';
|
import { FindServersComponent } from './find-servers.component';
|
||||||
import { ViewportService } from '../../../../core/platform';
|
|
||||||
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||||
import type { ServerInfo } from '../../domain/models/server-directory.model';
|
import type { ServerInfo } from '../../domain/models/server-directory.model';
|
||||||
@@ -32,7 +31,6 @@ interface HarnessOptions {
|
|||||||
saved?: Room[];
|
saved?: Room[];
|
||||||
featured?: ServerInfo[];
|
featured?: ServerInfo[];
|
||||||
trending?: ServerInfo[];
|
trending?: ServerInfo[];
|
||||||
isMobile?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHarness(options: HarnessOptions = {}) {
|
function createHarness(options: HarnessOptions = {}) {
|
||||||
@@ -49,8 +47,7 @@ function createHarness(options: HarnessOptions = {}) {
|
|||||||
providers: [
|
providers: [
|
||||||
FindServersComponent,
|
FindServersComponent,
|
||||||
{ provide: Store, useValue: store },
|
{ provide: Store, useValue: store },
|
||||||
{ provide: ServerDirectoryFacade, useValue: serverDirectory },
|
{ provide: ServerDirectoryFacade, useValue: serverDirectory }
|
||||||
{ provide: ViewportService, useValue: { isMobile: signal(options.isMobile ?? false) } }
|
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
const component = runInInjectionContext(injector, () => injector.get(FindServersComponent));
|
const component = runInInjectionContext(injector, () => injector.get(FindServersComponent));
|
||||||
@@ -59,11 +56,6 @@ function createHarness(options: HarnessOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('FindServersComponent', () => {
|
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', () => {
|
it('builds featured and trending sections after init', () => {
|
||||||
const { component } = createHarness({
|
const { component } = createHarness({
|
||||||
featured: [makeServer('f1')],
|
featured: [makeServer('f1')],
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
CUSTOM_ELEMENTS_SCHEMA,
|
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
@@ -14,8 +13,6 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
|
|||||||
import { lucideArrowLeft } from '@ng-icons/lucide';
|
import { lucideArrowLeft } from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { ServerBrowserComponent, type ServerDiscoverySection } from '../server-browser/server-browser.component';
|
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 { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||||
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||||
import type { Room } from '../../../../shared-kernel';
|
import type { Room } from '../../../../shared-kernel';
|
||||||
@@ -27,8 +24,7 @@ const RECENT_SERVER_LIMIT = 6;
|
|||||||
/**
|
/**
|
||||||
* Dedicated server-discovery page. Hosts the reusable {@link ServerBrowserComponent}
|
* Dedicated server-discovery page. Hosts the reusable {@link ServerBrowserComponent}
|
||||||
* and feeds it featured, trending, and recently-active discovery sections. On mobile the
|
* 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
|
* global app-shell servers rail stays visible beside this page.
|
||||||
* navigation stays reachable, matching the chat-room and DM workspace layouts.
|
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-find-servers',
|
selector: 'app-find-servers',
|
||||||
@@ -37,19 +33,17 @@ const RECENT_SERVER_LIMIT = 6;
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
ServerBrowserComponent,
|
ServerBrowserComponent
|
||||||
ServersRailComponent
|
|
||||||
],
|
],
|
||||||
viewProviders: [provideIcons({ lucideArrowLeft })],
|
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 {
|
export class FindServersComponent implements OnInit {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private serverDirectory = inject(ServerDirectoryFacade);
|
private serverDirectory = inject(ServerDirectoryFacade);
|
||||||
private readonly viewport = inject(ViewportService);
|
|
||||||
|
|
||||||
readonly isMobile = this.viewport.isMobile;
|
|
||||||
featured = signal<ServerInfo[]>([]);
|
featured = signal<ServerInfo[]>([]);
|
||||||
trending = signal<ServerInfo[]>([]);
|
trending = signal<ServerInfo[]>([]);
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ theme/
|
|||||||
|
|
||||||
## Built-in presets
|
## 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
|
## Layer composition
|
||||||
|
|
||||||
|
|||||||
@@ -36,55 +36,55 @@ describe('ThemeService theme application', () => {
|
|||||||
vi.unstubAllGlobals();
|
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>;
|
const defaultTheme = JSON.parse(DEFAULT_THEME_JSON) as Record<string, unknown>;
|
||||||
|
|
||||||
expect(defaultTheme['css']).toEqual(expect.not.stringContaining('radial-gradient'));
|
expect(defaultTheme['css']).toEqual(expect.not.stringContaining('radial-gradient'));
|
||||||
expect(defaultTheme).toEqual(expect.objectContaining({
|
expect(defaultTheme).toEqual(expect.objectContaining({
|
||||||
meta: {
|
meta: {
|
||||||
name: 'Toju Website Dark',
|
name: 'Toju Default Dark 11',
|
||||||
version: '1.0.0',
|
version: '2.0.0',
|
||||||
description: 'Website-inspired dark app theme using the charcoal, green, and amber palette from the public Toju site.'
|
description: 'Built-in dark glass theme for the full Toju app shell.'
|
||||||
},
|
},
|
||||||
tokens: {
|
tokens: {
|
||||||
colors: {
|
colors: {
|
||||||
background: '210 18% 7%',
|
background: '225 6% 12%',
|
||||||
foreground: '42 33% 94%',
|
foreground: '210 40% 96%',
|
||||||
card: '210 17% 10%',
|
card: '220 6% 18%',
|
||||||
cardForeground: '42 33% 94%',
|
cardForeground: '210 40% 96%',
|
||||||
popover: '210 17% 9%',
|
popover: '220 6% 18%',
|
||||||
popoverForeground: '42 33% 94%',
|
popoverForeground: '210 40% 96%',
|
||||||
primary: '154 49% 55%',
|
primary: '234 85% 64%',
|
||||||
primaryForeground: '210 18% 7%',
|
primaryForeground: '222 47% 11%',
|
||||||
secondary: '210 14% 15%',
|
secondary: '222 10% 24%',
|
||||||
secondaryForeground: '42 33% 94%',
|
secondaryForeground: '210 40% 96%',
|
||||||
muted: '210 14% 15%',
|
muted: '223 18% 14%',
|
||||||
mutedForeground: '42 13% 67%',
|
mutedForeground: '215 20% 70%',
|
||||||
accent: '38 64% 61%',
|
accent: '234 32% 28%',
|
||||||
accentForeground: '210 18% 7%',
|
accentForeground: '210 40% 98%',
|
||||||
destructive: '0 72% 55%',
|
destructive: '358 82% 59%',
|
||||||
destructiveForeground: '0 0% 100%',
|
destructiveForeground: '0 0% 100%',
|
||||||
border: '210 13% 22%',
|
border: '222 18% 22%',
|
||||||
input: '210 13% 22%',
|
input: '222 18% 22%',
|
||||||
ring: '154 49% 55%',
|
ring: '234 85% 64%',
|
||||||
railBackground: '210 19% 6%',
|
railBackground: '225 6% 12%',
|
||||||
workspaceBackground: '210 18% 8%',
|
workspaceBackground: '220 6% 18%',
|
||||||
panelBackground: '210 17% 10%',
|
panelBackground: '220 6% 18%',
|
||||||
panelBackgroundAlt: '210 14% 13%',
|
panelBackgroundAlt: '225 6% 15%',
|
||||||
titleBarBackground: '210 19% 6%',
|
titleBarBackground: '225 6% 9%',
|
||||||
surfaceHighlight: '154 49% 55%',
|
surfaceHighlight: '234 85% 64%',
|
||||||
surfaceHighlightAlt: '38 64% 61%'
|
surfaceHighlightAlt: '261 82% 72%'
|
||||||
},
|
},
|
||||||
spacing: {},
|
spacing: {},
|
||||||
radii: {
|
radii: {
|
||||||
radius: '0.6rem',
|
radius: '0.875rem',
|
||||||
surface: '0.85rem',
|
surface: '1.35rem',
|
||||||
pill: '999px'
|
pill: '999px'
|
||||||
},
|
},
|
||||||
effects: {
|
effects: {
|
||||||
panelShadow: '0 28px 64px rgba(0, 0, 0, 0.34)',
|
panelShadow: '0 24px 60px rgba(0, 0, 0, 0.42)',
|
||||||
softShadow: '0 16px 36px rgba(0, 0, 0, 0.22)',
|
softShadow: '0 14px 36px rgba(0, 0, 0, 0.28)',
|
||||||
glassBlur: 'blur(16px) saturate(125%)'
|
glassBlur: 'blur(18px) saturate(135%)'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
layout: expect.objectContaining({
|
layout: expect.objectContaining({
|
||||||
@@ -116,9 +116,13 @@ describe('ThemeService theme application', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes both built-in theme presets and applies the legacy default preset', () => {
|
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 Website Dark', 'Toju Default Dark']);
|
expect(BUILT_IN_THEME_PRESETS.map((preset) => preset.theme.meta.name)).toEqual([
|
||||||
expect(service.activeThemeName()).toBe('Toju Website Dark');
|
'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');
|
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', () => {
|
it('resets to the default dark 11 preset as the built-in default', () => {
|
||||||
expect(service.applyBuiltInPreset('Toju Default Dark')).toBe(true);
|
expect(service.applyBuiltInPreset('Toju Website Dark')).toBe(true);
|
||||||
|
|
||||||
service.resetToDefault();
|
service.resetToDefault();
|
||||||
|
|
||||||
expect(service.activeThemeName()).toBe('Toju Website Dark');
|
expect(service.activeThemeName()).toBe('Toju Default Dark 11');
|
||||||
expect(service.getHostStyles('appRoot')).toMatchObject({
|
expect(service.getHostStyles('appRoot')).toMatchObject({
|
||||||
'--background': '210 18% 7%',
|
'--background': '225 6% 12%',
|
||||||
'--primary': '154 49% 55%',
|
'--primary': '234 85% 64%',
|
||||||
'--accent': '38 64% 61%'
|
'--secondary': '222 10% 24%',
|
||||||
|
'--accent': '234 32% 28%'
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(service.activeThemeText()).not.toContain('radial-gradient');
|
expect(service.activeThemeText()).not.toContain('radial-gradient');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the importable website dark artifact aligned with the default preset', () => {
|
it('keeps hover tokens visually distinct from the background surface', () => {
|
||||||
const artifact = JSON.parse(readFileSync(resolve('../project-files/themes/toju-website-dark.json'), 'utf8')) as unknown;
|
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;
|
const defaultTheme = JSON.parse(DEFAULT_THEME_JSON) as unknown;
|
||||||
|
|
||||||
expect(artifact).toEqual(defaultTheme);
|
expect(artifact).toEqual(defaultTheme);
|
||||||
@@ -165,14 +180,14 @@ describe('ThemeService theme application', () => {
|
|||||||
const applied = service.applyCssOnlyTheme('.css-only-theme { background: hsl(var(--background)); }');
|
const applied = service.applyCssOnlyTheme('.css-only-theme { background: hsl(var(--background)); }');
|
||||||
|
|
||||||
expect(applied).toBe(true);
|
expect(applied).toBe(true);
|
||||||
expect(service.activeThemeName()).toBe('Toju Website Dark');
|
expect(service.activeThemeName()).toBe('Toju Default Dark 11');
|
||||||
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
|
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
|
||||||
gridColumn: '5 / span 16',
|
gridColumn: '5 / span 16',
|
||||||
gridRow: '1 / span 12'
|
gridRow: '1 / span 12'
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(service.getHostStyles('appRoot')).toMatchObject({
|
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);
|
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', () => {
|
it('validates the dedicated DM workspace layout container', () => {
|
||||||
@@ -437,16 +452,17 @@ describe('ThemeService theme application', () => {
|
|||||||
expect(service.activeThemeText()).not.toContain('"elements"');
|
expect(service.activeThemeText()).not.toContain('"elements"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads the website dark saved-theme artifact', () => {
|
it('loads the default dark 11 saved-theme artifact', () => {
|
||||||
const themeText = readFileSync(resolve('../project-files/themes/toju-website-dark.json'), 'utf8');
|
const themeText = readFileSync(resolve('../project-files/themes/toju-default-dark-11.json'), 'utf8');
|
||||||
const loaded = service.loadThemeText(themeText, 'apply', 'Theme applied.', 'website dark saved theme');
|
const loaded = service.loadThemeText(themeText, 'apply', 'Theme applied.', 'default dark 11 saved theme');
|
||||||
|
|
||||||
expect(loaded).toBe(true);
|
expect(loaded).toBe(true);
|
||||||
expect(service.activeThemeName()).toBe('Toju Website Dark');
|
expect(service.activeThemeName()).toBe('Toju Default Dark 11');
|
||||||
expect(service.getHostStyles('appRoot')).toMatchObject({
|
expect(service.getHostStyles('appRoot')).toMatchObject({
|
||||||
'--background': '210 18% 7%',
|
'--background': '225 6% 12%',
|
||||||
'--primary': '154 49% 55%',
|
'--primary': '234 85% 64%',
|
||||||
'--accent': '38 64% 61%'
|
'--secondary': '222 10% 24%',
|
||||||
|
'--accent': '234 32% 28%'
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(service.getHostStyles('titleBar')).toMatchObject({
|
expect(service.getHostStyles('titleBar')).toMatchObject({
|
||||||
@@ -460,7 +476,7 @@ describe('ThemeService theme application', () => {
|
|||||||
expect(service.getHostStyles('chatRoomMembersPanel')).toMatchObject({ border: '0' });
|
expect(service.getHostStyles('chatRoomMembersPanel')).toMatchObject({ border: '0' });
|
||||||
expect(service.getHostStyles('chatComposerBar')).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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,98 @@ import {
|
|||||||
} from '../models/theme.model';
|
} from '../models/theme.model';
|
||||||
import { THEME_LAYOUT_CONTAINERS, getLayoutEditableThemeKeys } from './theme-registry.logic';
|
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> {
|
function createProvidedDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||||
return {
|
return {
|
||||||
serversRail: {
|
serversRail: {
|
||||||
@@ -171,46 +263,9 @@ function createLegacyDefaultDarkThemeDocument(): ThemeDocument {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultThemeDocument(): ThemeDocument {
|
export function createWebsiteDarkThemeDocument(): ThemeDocument {
|
||||||
return {
|
return {
|
||||||
css: `/* Website-inspired app shell surfaces */
|
css: DEFAULT_SHELL_CSS.replace('Dark glass app shell surfaces', '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;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
meta: {
|
meta: {
|
||||||
name: 'Toju Website Dark',
|
name: 'Toju Website Dark',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
@@ -258,56 +313,62 @@ app-chat-message-composer [contenteditable="true"] {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
layout: createDefaultThemeLayout(),
|
layout: createDefaultThemeLayout(),
|
||||||
elements: {
|
elements: createDefaultShellElements(),
|
||||||
titleBar: {
|
animations: {}
|
||||||
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)'
|
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.'
|
||||||
},
|
},
|
||||||
serversRail: {
|
tokens: {
|
||||||
border: '0',
|
colors: {
|
||||||
backgroundColor: 'hsl(var(--rail-background) / 0.94)',
|
background: '225 6% 12%',
|
||||||
boxShadow: 'var(--theme-effect-panel-shadow)'
|
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%'
|
||||||
},
|
},
|
||||||
appWorkspace: {
|
spacing: {},
|
||||||
backgroundColor: 'hsl(var(--workspace-background))'
|
radii: {
|
||||||
|
radius: '0.875rem',
|
||||||
|
surface: '1.35rem',
|
||||||
|
pill: '999px'
|
||||||
},
|
},
|
||||||
chatRoomChannelsPanel: {
|
effects: {
|
||||||
border: '0',
|
panelShadow: '0 24px 60px rgba(0, 0, 0, 0.42)',
|
||||||
backgroundColor: 'hsl(var(--panel-background) / 0.86)',
|
softShadow: '0 14px 36px rgba(0, 0, 0, 0.28)',
|
||||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
glassBlur: 'blur(18px) saturate(135%)'
|
||||||
},
|
|
||||||
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))'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
layout: createDefaultThemeLayout(),
|
||||||
|
elements: createDefaultShellElements(),
|
||||||
animations: {}
|
animations: {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -330,9 +391,13 @@ export function isLegacyDefaultThemeDocument(document: ThemeDocument): boolean {
|
|||||||
export const DEFAULT_THEME_DOCUMENT: ThemeDocument = createDefaultThemeDocument();
|
export const DEFAULT_THEME_DOCUMENT: ThemeDocument = createDefaultThemeDocument();
|
||||||
export const BUILT_IN_THEME_PRESETS: readonly BuiltInThemePreset[] = [
|
export const BUILT_IN_THEME_PRESETS: readonly BuiltInThemePreset[] = [
|
||||||
{
|
{
|
||||||
key: 'toju-website-dark',
|
key: 'toju-default-dark-11',
|
||||||
theme: DEFAULT_THEME_DOCUMENT
|
theme: DEFAULT_THEME_DOCUMENT
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'toju-website-dark',
|
||||||
|
theme: createWebsiteDarkThemeDocument()
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'toju-default-dark',
|
key: 'toju-default-dark',
|
||||||
theme: createLegacyDefaultDarkThemeDocument()
|
theme: createLegacyDefaultDarkThemeDocument()
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
<p class="mt-1 font-mono text-[11px] text-muted-foreground">{{ preset.key }}</p>
|
<p class="mt-1 font-mono text-[11px] text-muted-foreground">{{ preset.key }}</p>
|
||||||
</div>
|
</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>
|
<span class="rounded-full bg-primary/12 px-2 py-0.5 text-[10px] font-medium text-primary">Default</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<ng-template #pageContent>
|
<ng-template #pageContent>
|
||||||
<div class="h-full min-h-0 overflow-y-auto bg-background text-foreground">
|
<div class="h-full min-h-0 min-w-0 w-full overflow-x-hidden overflow-y-auto bg-background text-foreground">
|
||||||
<div class="mx-auto w-full max-w-5xl space-y-8 p-4 sm:p-6 lg:py-8">
|
<div class="mx-auto w-full min-w-0 max-w-5xl space-y-8 p-4 sm:p-6 lg:py-8">
|
||||||
<header class="space-y-1">
|
<header class="min-w-0 space-y-1">
|
||||||
<h1 class="text-2xl font-semibold text-foreground">
|
<h1 class="text-2xl font-semibold text-foreground">
|
||||||
@if (currentUser()) {
|
@if (currentUser()) {
|
||||||
Welcome back, {{ currentUser()!.displayName || 'there' }}
|
Welcome back, {{ currentUser()!.displayName || 'there' }}
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
<p class="text-sm text-muted-foreground">Find people, discover servers, or start your own community.</p>
|
<p class="text-sm text-muted-foreground">Find people, discover servers, or start your own community.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<div class="relative">
|
<div class="relative min-w-0">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideSearch"
|
name="lucideSearch"
|
||||||
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
|
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
#searchInput
|
#searchInput
|
||||||
type="text"
|
type="text"
|
||||||
aria-label="Search people, servers, and invites"
|
aria-label="Search people, servers, and invites"
|
||||||
class="h-12 w-full rounded-xl border border-border bg-secondary py-2 pl-11 pr-20 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
class="h-12 w-full min-w-0 rounded-xl border border-border bg-secondary py-2 pl-11 pr-4 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary sm:pr-20"
|
||||||
placeholder="Search for people, servers, or paste an invite..."
|
[placeholder]="isMobile() ? 'Search people, servers, invites...' : 'Search for people, servers, or paste an invite...'"
|
||||||
[ngModel]="searchQuery()"
|
[ngModel]="searchQuery()"
|
||||||
(ngModelChange)="onSearchChange($event)"
|
(ngModelChange)="onSearchChange($event)"
|
||||||
(keydown.enter)="submitSearch()"
|
(keydown.enter)="submitSearch()"
|
||||||
@@ -189,10 +189,10 @@
|
|||||||
</section>
|
</section>
|
||||||
} @else {
|
} @else {
|
||||||
<!-- Primary actions -->
|
<!-- Primary actions -->
|
||||||
<section class="grid gap-3 sm:grid-cols-3">
|
<section class="grid min-w-0 gap-3 sm:grid-cols-3">
|
||||||
<a
|
<a
|
||||||
routerLink="/people"
|
routerLink="/people"
|
||||||
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
|
class="group flex min-w-0 w-full items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
|
||||||
>
|
>
|
||||||
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-purple-500/15 text-purple-400">
|
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-purple-500/15 text-purple-400">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
routerLink="/servers"
|
routerLink="/servers"
|
||||||
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
|
class="group flex min-w-0 w-full items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
|
||||||
>
|
>
|
||||||
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-blue-500/15 text-blue-400">
|
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-blue-500/15 text-blue-400">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
@@ -232,7 +232,7 @@
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
routerLink="/create-server"
|
routerLink="/create-server"
|
||||||
class="group flex items-center gap-3 rounded-xl border border-emerald-500/40 bg-emerald-500/10 p-4 transition-colors hover:bg-emerald-500/15"
|
class="group flex min-w-0 w-full items-center gap-3 rounded-xl border border-emerald-500/40 bg-emerald-500/10 p-4 transition-colors hover:bg-emerald-500/15"
|
||||||
>
|
>
|
||||||
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-emerald-500/20 text-emerald-400">
|
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-emerald-500/20 text-emerald-400">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
@@ -267,8 +267,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- People + Popular servers -->
|
<!-- People + Popular servers -->
|
||||||
<section class="grid gap-4 lg:grid-cols-2">
|
<section class="grid min-w-0 gap-4 lg:grid-cols-2">
|
||||||
<div class="rounded-xl border border-border bg-card/40 p-4">
|
<div class="min-w-0 rounded-xl border border-border bg-card/40 p-4">
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div class="mb-3 flex items-center justify-between">
|
||||||
<h2 class="text-sm font-semibold text-foreground">People you might know</h2>
|
<h2 class="text-sm font-semibold text-foreground">People you might know</h2>
|
||||||
<a
|
<a
|
||||||
@@ -280,7 +280,7 @@
|
|||||||
@if (peopleYouMightKnow().length > 0) {
|
@if (peopleYouMightKnow().length > 0) {
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
@for (person of peopleYouMightKnow(); track person.id) {
|
@for (person of peopleYouMightKnow(); track person.id) {
|
||||||
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
|
<div class="flex min-w-0 items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="personLabel(person)"
|
[name]="personLabel(person)"
|
||||||
[avatarUrl]="person.avatarUrl"
|
[avatarUrl]="person.avatarUrl"
|
||||||
@@ -292,7 +292,10 @@
|
|||||||
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(person) }}</p>
|
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(person) }}</p>
|
||||||
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
|
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<app-friend-button [user]="person" />
|
<app-friend-button
|
||||||
|
class="shrink-0"
|
||||||
|
[user]="person"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -301,7 +304,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-border bg-card/40 p-4">
|
<div class="min-w-0 rounded-xl border border-border bg-card/40 p-4">
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div class="mb-3 flex items-center justify-between">
|
||||||
<h2 class="text-sm font-semibold text-foreground">Popular Servers</h2>
|
<h2 class="text-sm font-semibold text-foreground">Popular Servers</h2>
|
||||||
<a
|
<a
|
||||||
@@ -313,7 +316,7 @@
|
|||||||
@if (popularServers().length > 0) {
|
@if (popularServers().length > 0) {
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
@for (server of popularServers(); track server.id) {
|
@for (server of popularServers(); track server.id) {
|
||||||
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
|
<div class="flex min-w-0 items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
|
||||||
<div
|
<div
|
||||||
class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground"
|
class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground"
|
||||||
>
|
>
|
||||||
@@ -358,9 +361,9 @@
|
|||||||
>Manage</a
|
>Manage</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
@for (friend of friends(); track friend.id) {
|
@for (friend of friends(); track friend.id) {
|
||||||
<div class="flex items-center gap-3 rounded-xl border border-border bg-card p-3">
|
<div class="flex min-w-0 items-center gap-3 rounded-xl border border-border bg-card p-3">
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="personLabel(friend)"
|
[name]="personLabel(friend)"
|
||||||
[avatarUrl]="friend.avatarUrl"
|
[avatarUrl]="friend.avatarUrl"
|
||||||
@@ -383,11 +386,11 @@
|
|||||||
@if (recentlyActiveServers().length > 0) {
|
@if (recentlyActiveServers().length > 0) {
|
||||||
<section>
|
<section>
|
||||||
<h2 class="mb-3 text-sm font-semibold text-foreground">Recently Active Servers</h2>
|
<h2 class="mb-3 text-sm font-semibold text-foreground">Recently Active Servers</h2>
|
||||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
<div class="grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
@for (room of recentlyActiveServers(); track room.id) {
|
@for (room of recentlyActiveServers(); track room.id) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50 hover:bg-card/80"
|
class="flex min-w-0 flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50 hover:bg-card/80"
|
||||||
(click)="openSavedRoom(room)"
|
(click)="openSavedRoom(room)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -415,24 +418,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</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" />
|
<ng-container [ngTemplateOutlet]="pageContent" />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</swiper-slide>
|
|
||||||
</swiper-container>
|
|
||||||
} @else {
|
|
||||||
<ng-container [ngTemplateOutlet]="pageContent" />
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
CUSTOM_ELEMENTS_SCHEMA,
|
|
||||||
Component,
|
Component,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
HostListener,
|
HostListener,
|
||||||
@@ -41,7 +40,6 @@ import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selec
|
|||||||
import type { Room, User } from '../../shared-kernel';
|
import type { Room, User } from '../../shared-kernel';
|
||||||
import type { ServerInfo } from '../../domains/server-directory/domain/models/server-directory.model';
|
import type { ServerInfo } from '../../domains/server-directory/domain/models/server-directory.model';
|
||||||
import { ServerDirectoryFacade } from '../../domains/server-directory/application/facades/server-directory.facade';
|
import { ServerDirectoryFacade } from '../../domains/server-directory/application/facades/server-directory.facade';
|
||||||
import { ServersRailComponent } from '../servers/servers-rail/servers-rail.component';
|
|
||||||
import { ViewportService } from '../../core/platform';
|
import { ViewportService } from '../../core/platform';
|
||||||
import { FriendService } from '../../domains/direct-message/application/services/friend.service';
|
import { FriendService } from '../../domains/direct-message/application/services/friend.service';
|
||||||
import { FriendButtonComponent } from '../../domains/direct-message/feature/friend-button/friend-button.component';
|
import { FriendButtonComponent } from '../../domains/direct-message/feature/friend-button/friend-button.component';
|
||||||
@@ -72,8 +70,7 @@ const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches';
|
|||||||
RouterLink,
|
RouterLink,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
FriendButtonComponent,
|
FriendButtonComponent,
|
||||||
UserAvatarComponent,
|
UserAvatarComponent
|
||||||
ServersRailComponent
|
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
@@ -87,8 +84,10 @@ const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches';
|
|||||||
lucideX
|
lucideX
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
templateUrl: './dashboard.component.html',
|
||||||
templateUrl: './dashboard.component.html'
|
host: {
|
||||||
|
class: 'block h-full min-h-0 min-w-0 w-full overflow-hidden'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
export class DashboardComponent implements OnInit {
|
export class DashboardComponent implements OnInit {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
|
|||||||
@@ -15,7 +15,13 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
|
class="grid h-12 w-12 place-items-center rounded-full transition-colors disabled:opacity-45"
|
||||||
|
[class.bg-secondary]="!muted()"
|
||||||
|
[class.text-foreground]="!muted()"
|
||||||
|
[class.hover:bg-secondary/80]="!muted()"
|
||||||
|
[class.bg-destructive/10]="muted()"
|
||||||
|
[class.text-destructive]="muted()"
|
||||||
|
[class.hover:bg-destructive/15]="muted()"
|
||||||
[disabled]="!connected()"
|
[disabled]="!connected()"
|
||||||
(click)="muteToggled.emit()"
|
(click)="muteToggled.emit()"
|
||||||
[attr.aria-label]="muted() ? 'Unmute' : 'Mute'"
|
[attr.aria-label]="muted() ? 'Unmute' : 'Mute'"
|
||||||
@@ -27,6 +33,26 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grid h-12 w-12 place-items-center rounded-full transition-colors disabled:opacity-45"
|
||||||
|
[class.bg-secondary]="!deafened()"
|
||||||
|
[class.text-foreground]="!deafened()"
|
||||||
|
[class.hover:bg-secondary/80]="!deafened()"
|
||||||
|
[class.bg-destructive/10]="deafened()"
|
||||||
|
[class.text-destructive]="deafened()"
|
||||||
|
[class.hover:bg-destructive/15]="deafened()"
|
||||||
|
[disabled]="!connected()"
|
||||||
|
(click)="deafenToggled.emit()"
|
||||||
|
[attr.aria-label]="deafened() ? 'Undeafen' : 'Deafen'"
|
||||||
|
[title]="deafened() ? 'Undeafen' : 'Deafen'"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideHeadphones"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
@if (showSpeakerphoneButton()) {
|
@if (showSpeakerphoneButton()) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Injector, runInInjectionContext } from '@angular/core';
|
||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import { PrivateCallControlsComponent } from './private-call-controls.component';
|
||||||
|
|
||||||
|
function createComponent(): PrivateCallControlsComponent {
|
||||||
|
const injector = Injector.create({ providers: [PrivateCallControlsComponent] });
|
||||||
|
|
||||||
|
return runInInjectionContext(injector, () => injector.get(PrivateCallControlsComponent));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PrivateCallControlsComponent', () => {
|
||||||
|
it('exposes deafened input and deafenToggled output', () => {
|
||||||
|
const component = createComponent();
|
||||||
|
|
||||||
|
expect(component.deafened).toBeDefined();
|
||||||
|
expect(component.deafenToggled).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits deafenToggled when the output is triggered', () => {
|
||||||
|
const component = createComponent();
|
||||||
|
const handler = vi.fn();
|
||||||
|
|
||||||
|
component.deafenToggled.subscribe(handler);
|
||||||
|
component.deafenToggled.emit();
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
|
lucideHeadphones,
|
||||||
lucideMic,
|
lucideMic,
|
||||||
lucideMicOff,
|
lucideMicOff,
|
||||||
lucideMonitor,
|
lucideMonitor,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
imports: [NgIcon],
|
imports: [NgIcon],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
|
lucideHeadphones,
|
||||||
lucideMic,
|
lucideMic,
|
||||||
lucideMicOff,
|
lucideMicOff,
|
||||||
lucideMonitor,
|
lucideMonitor,
|
||||||
@@ -38,6 +40,7 @@ import {
|
|||||||
export class PrivateCallControlsComponent {
|
export class PrivateCallControlsComponent {
|
||||||
readonly connected = input.required<boolean>();
|
readonly connected = input.required<boolean>();
|
||||||
readonly muted = input.required<boolean>();
|
readonly muted = input.required<boolean>();
|
||||||
|
readonly deafened = input.required<boolean>();
|
||||||
readonly cameraEnabled = input.required<boolean>();
|
readonly cameraEnabled = input.required<boolean>();
|
||||||
readonly screenSharing = input.required<boolean>();
|
readonly screenSharing = input.required<boolean>();
|
||||||
readonly showSpeakerphoneButton = input(false);
|
readonly showSpeakerphoneButton = input(false);
|
||||||
@@ -45,6 +48,7 @@ export class PrivateCallControlsComponent {
|
|||||||
|
|
||||||
readonly joinRequested = output();
|
readonly joinRequested = output();
|
||||||
readonly muteToggled = output();
|
readonly muteToggled = output();
|
||||||
|
readonly deafenToggled = output();
|
||||||
readonly cameraToggled = output();
|
readonly cameraToggled = output();
|
||||||
readonly screenShareToggled = output();
|
readonly screenShareToggled = output();
|
||||||
readonly speakerphoneToggled = output();
|
readonly speakerphoneToggled = output();
|
||||||
|
|||||||
@@ -196,12 +196,14 @@
|
|||||||
class="mx-auto block w-full max-w-5xl"
|
class="mx-auto block w-full max-w-5xl"
|
||||||
[connected]="isConnected()"
|
[connected]="isConnected()"
|
||||||
[muted]="isMuted()"
|
[muted]="isMuted()"
|
||||||
|
[deafened]="isDeafened()"
|
||||||
[cameraEnabled]="isCameraEnabled()"
|
[cameraEnabled]="isCameraEnabled()"
|
||||||
[screenSharing]="isScreenSharing()"
|
[screenSharing]="isScreenSharing()"
|
||||||
[showSpeakerphoneButton]="showSpeakerphoneButton()"
|
[showSpeakerphoneButton]="showSpeakerphoneButton()"
|
||||||
[speakerphoneEnabled]="speakerphoneEnabled()"
|
[speakerphoneEnabled]="speakerphoneEnabled()"
|
||||||
(joinRequested)="join()"
|
(joinRequested)="join()"
|
||||||
(muteToggled)="toggleMute()"
|
(muteToggled)="toggleMute()"
|
||||||
|
(deafenToggled)="toggleDeafen()"
|
||||||
(cameraToggled)="toggleCamera()"
|
(cameraToggled)="toggleCamera()"
|
||||||
(screenShareToggled)="toggleScreenShare()"
|
(screenShareToggled)="toggleScreenShare()"
|
||||||
(speakerphoneToggled)="toggleSpeakerphone()"
|
(speakerphoneToggled)="toggleSpeakerphone()"
|
||||||
|
|||||||
@@ -496,18 +496,27 @@ export class PrivateCallComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.dispatch(
|
const voiceState = {
|
||||||
UsersActions.updateVoiceState({
|
|
||||||
userId: user.id,
|
|
||||||
voiceState: {
|
|
||||||
isConnected: this.isConnected(),
|
isConnected: this.isConnected(),
|
||||||
isMuted: this.isMuted(),
|
isMuted: this.isMuted(),
|
||||||
isDeafened: this.isDeafened(),
|
isDeafened: this.isDeafened(),
|
||||||
roomId: session.callId,
|
roomId: session.callId,
|
||||||
serverId: session.callId
|
serverId: session.callId
|
||||||
}
|
};
|
||||||
|
|
||||||
|
this.store.dispatch(
|
||||||
|
UsersActions.updateVoiceState({
|
||||||
|
userId: user.id,
|
||||||
|
voiceState
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.voice.broadcastMessage({
|
||||||
|
type: 'voice-state',
|
||||||
|
oderId: user.oderId || user.id,
|
||||||
|
displayName: user.displayName || 'User',
|
||||||
|
voiceState
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private remoteParticipantPeerIds(session: DirectCallSession, currentUserId: string): string[] {
|
private remoteParticipantPeerIds(session: DirectCallSession, currentUserId: string): string[] {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<swiper-slide class="block h-full w-full">
|
<swiper-slide class="block h-full w-full">
|
||||||
<div class="flex h-full w-full min-h-0 overflow-hidden">
|
<div class="flex h-full w-full min-h-0 overflow-hidden">
|
||||||
<app-servers-rail class="block h-full shrink-0" />
|
<app-servers-rail class="block h-full shrink-0" />
|
||||||
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border bg-card">
|
<div class="flex min-h-0 min-w-0 flex-1 overflow-hidden border-l border-border bg-card">
|
||||||
<app-rooms-side-panel
|
<app-rooms-side-panel
|
||||||
panelMode="channels"
|
panelMode="channels"
|
||||||
(textChannelSelected)="setMobilePage('main')"
|
(textChannelSelected)="setMobilePage('main')"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<nav class="relative flex h-full min-w-16 flex-col items-center gap-2 border-r border-border bg-secondary/35 px-0 py-3 md:min-w-0 md:w-full">
|
<nav class="relative flex h-full w-16 min-w-16 max-w-16 flex-col items-center gap-2 border-r border-border bg-secondary/35 px-0 py-3 md:min-w-0 md:max-w-none md:w-full">
|
||||||
<!-- Home / dashboard button -->
|
<!-- Home / dashboard button -->
|
||||||
<button
|
<button
|
||||||
appThemeNode="serversRailCreateButton"
|
appThemeNode="serversRailCreateButton"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export class CapacitorMobileAppLifecycleAdapter implements MobileAppLifecycleAda
|
|||||||
private handler: ((isActive: boolean) => void) | null = null;
|
private handler: ((isActive: boolean) => void) | null = null;
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
const App = loadCapacitorAppPlugin();
|
const App = await loadCapacitorAppPlugin();
|
||||||
|
|
||||||
if (!App) {
|
if (!App) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import type { MobileCallKitAdapter } from '../../contracts/mobile.contracts';
|
import type { MobileCallKitAdapter } from '../../contracts/mobile.contracts';
|
||||||
import { MetoyouMobile } from './metoyou-mobile.plugin';
|
import { loadMetoyouMobilePlugin } from './metoyou-mobile.plugin';
|
||||||
|
|
||||||
/** iOS CallKit bridge via the MetoyouMobile native plugin. */
|
/** iOS CallKit bridge via the MetoyouMobile native plugin. */
|
||||||
export class CapacitorMobileCallKitAdapter implements MobileCallKitAdapter {
|
export class CapacitorMobileCallKitAdapter implements MobileCallKitAdapter {
|
||||||
async startActiveCall(callId: string, displayName: string): Promise<void> {
|
async startActiveCall(callId: string, displayName: string): Promise<void> {
|
||||||
|
const MetoyouMobile = await loadMetoyouMobilePlugin();
|
||||||
|
|
||||||
|
if (!MetoyouMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await MetoyouMobile.startCallKitSession({ callId, displayName });
|
const result = await MetoyouMobile.startCallKitSession({ callId, displayName });
|
||||||
|
|
||||||
@@ -16,6 +22,12 @@ export class CapacitorMobileCallKitAdapter implements MobileCallKitAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async endActiveCall(callId: string): Promise<void> {
|
async endActiveCall(callId: string): Promise<void> {
|
||||||
|
const MetoyouMobile = await loadMetoyouMobilePlugin();
|
||||||
|
|
||||||
|
if (!MetoyouMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await MetoyouMobile.endCallKitSession({ callId });
|
await MetoyouMobile.endCallKitSession({ callId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { MobileMediaAdapter } from '../../contracts/mobile.contracts';
|
import type { MobileMediaAdapter } from '../../contracts/mobile.contracts';
|
||||||
import { loadCapacitorAudioSessionPlugin } from './capacitor-plugin-loader';
|
import { loadCapacitorAudioSessionPlugin } from './capacitor-plugin-loader';
|
||||||
import { MetoyouMobile } from './metoyou-mobile.plugin';
|
import { loadMetoyouMobilePlugin } from './metoyou-mobile.plugin';
|
||||||
import { WebMobileMediaAdapter } from '../web/web-mobile-media.adapter';
|
import { WebMobileMediaAdapter } from '../web/web-mobile-media.adapter';
|
||||||
|
|
||||||
/** Capacitor media adapter with native speaker routing and background voice session hooks. */
|
/** Capacitor media adapter with native speaker routing and background voice session hooks. */
|
||||||
@@ -8,14 +8,18 @@ export class CapacitorMobileMediaAdapter extends WebMobileMediaAdapter implement
|
|||||||
private backgroundSessionActive = false;
|
private backgroundSessionActive = false;
|
||||||
|
|
||||||
override async setSpeakerphoneEnabled(enabled: boolean): Promise<void> {
|
override async setSpeakerphoneEnabled(enabled: boolean): Promise<void> {
|
||||||
|
const MetoyouMobile = await loadMetoyouMobilePlugin();
|
||||||
|
|
||||||
|
if (MetoyouMobile) {
|
||||||
try {
|
try {
|
||||||
await MetoyouMobile.setSpeakerphoneEnabled({ enabled });
|
await MetoyouMobile.setSpeakerphoneEnabled({ enabled });
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// Android plugin unavailable in web builds; fall through to iOS audio session.
|
// Android plugin unavailable in web builds; fall through to iOS audio session.
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const AudioSession = loadCapacitorAudioSessionPlugin();
|
const AudioSession = await loadCapacitorAudioSessionPlugin();
|
||||||
|
|
||||||
if (!AudioSession) {
|
if (!AudioSession) {
|
||||||
return;
|
return;
|
||||||
@@ -31,6 +35,12 @@ export class CapacitorMobileMediaAdapter extends WebMobileMediaAdapter implement
|
|||||||
|
|
||||||
this.backgroundSessionActive = true;
|
this.backgroundSessionActive = true;
|
||||||
|
|
||||||
|
const MetoyouMobile = await loadMetoyouMobilePlugin();
|
||||||
|
|
||||||
|
if (!MetoyouMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await MetoyouMobile.startVoiceForegroundService();
|
await MetoyouMobile.startVoiceForegroundService();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -45,6 +55,12 @@ export class CapacitorMobileMediaAdapter extends WebMobileMediaAdapter implement
|
|||||||
|
|
||||||
this.backgroundSessionActive = false;
|
this.backgroundSessionActive = false;
|
||||||
|
|
||||||
|
const MetoyouMobile = await loadMetoyouMobilePlugin();
|
||||||
|
|
||||||
|
if (!MetoyouMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await MetoyouMobile.stopVoiceForegroundService();
|
await MetoyouMobile.stopVoiceForegroundService();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
|
|||||||
private listenersRegistered = false;
|
private listenersRegistered = false;
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
|
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
|
||||||
const PushNotifications = loadCapacitorPushNotificationsPlugin();
|
const PushNotifications = await loadCapacitorPushNotificationsPlugin();
|
||||||
|
|
||||||
if (!LocalNotifications) {
|
if (!LocalNotifications) {
|
||||||
return;
|
return;
|
||||||
@@ -72,7 +72,7 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
|
|||||||
}
|
}
|
||||||
|
|
||||||
async requestPermission(): Promise<boolean> {
|
async requestPermission(): Promise<boolean> {
|
||||||
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
|
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
|
||||||
|
|
||||||
if (!LocalNotifications) {
|
if (!LocalNotifications) {
|
||||||
return false;
|
return false;
|
||||||
@@ -90,7 +90,7 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
|
|||||||
}
|
}
|
||||||
|
|
||||||
async showCallNotification(payload: CallNotificationPayload): Promise<void> {
|
async showCallNotification(payload: CallNotificationPayload): Promise<void> {
|
||||||
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
|
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
|
||||||
|
|
||||||
if (!LocalNotifications) {
|
if (!LocalNotifications) {
|
||||||
return;
|
return;
|
||||||
@@ -122,7 +122,7 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
|
|||||||
}
|
}
|
||||||
|
|
||||||
async dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void> {
|
async dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void> {
|
||||||
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
|
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
|
||||||
|
|
||||||
if (!LocalNotifications) {
|
if (!LocalNotifications) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MobilePictureInPictureAdapter } from '../../contracts/mobile.contracts';
|
import type { MobilePictureInPictureAdapter } from '../../contracts/mobile.contracts';
|
||||||
import { MetoyouMobile } from './metoyou-mobile.plugin';
|
import { loadMetoyouMobilePlugin } from './metoyou-mobile.plugin';
|
||||||
import { WebMobilePictureInPictureAdapter } from '../web/web-mobile-picture-in-picture.adapter';
|
import { WebMobilePictureInPictureAdapter } from '../web/web-mobile-picture-in-picture.adapter';
|
||||||
|
|
||||||
/** Capacitor PiP adapter with Document PiP first and native Android PiP fallback. */
|
/** Capacitor PiP adapter with Document PiP first and native Android PiP fallback. */
|
||||||
@@ -20,6 +20,12 @@ export class CapacitorMobilePictureInPictureAdapter extends WebMobilePictureInPi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MetoyouMobile = await loadMetoyouMobilePlugin();
|
||||||
|
|
||||||
|
if (!MetoyouMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await MetoyouMobile.enterNativePictureInPicture();
|
const result = await MetoyouMobile.enterNativePictureInPicture();
|
||||||
|
|
||||||
this.nativeSupported = result.supported;
|
this.nativeSupported = result.supported;
|
||||||
@@ -38,6 +44,12 @@ export class CapacitorMobilePictureInPictureAdapter extends WebMobilePictureInPi
|
|||||||
await super.exit();
|
await super.exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MetoyouMobile = await loadMetoyouMobilePlugin();
|
||||||
|
|
||||||
|
if (!MetoyouMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await MetoyouMobile.exitNativePictureInPicture().catch(() => {});
|
await MetoyouMobile.exitNativePictureInPicture().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ const capacitorState = vi.hoisted(() => ({
|
|||||||
isPluginAvailable: true,
|
isPluginAvailable: true,
|
||||||
platform: 'android'
|
platform: 'android'
|
||||||
}));
|
}));
|
||||||
|
const appPlugin = vi.hoisted(() => ({
|
||||||
|
addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() }))
|
||||||
|
}));
|
||||||
|
const localNotificationsPlugin = vi.hoisted(() => ({
|
||||||
|
checkPermissions: vi.fn(() => Promise.resolve({ display: 'granted' }))
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@capacitor/core', () => ({
|
vi.mock('@capacitor/core', () => ({
|
||||||
Capacitor: {
|
Capacitor: {
|
||||||
@@ -22,24 +28,26 @@ vi.mock('@capacitor/core', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@capacitor/app', () => ({
|
vi.mock('@capacitor/app', () => ({
|
||||||
App: {
|
App: appPlugin
|
||||||
addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() }))
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@capacitor/local-notifications', () => ({
|
vi.mock('@capacitor/local-notifications', () => ({
|
||||||
LocalNotifications: {
|
LocalNotifications: localNotificationsPlugin
|
||||||
checkPermissions: vi.fn(() => Promise.resolve({ display: 'granted' }))
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { App } from '@capacitor/app';
|
|
||||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
|
||||||
import { loadCapacitorAppPlugin, loadCapacitorLocalNotificationsPlugin } from './capacitor-plugin-loader';
|
import { loadCapacitorAppPlugin, loadCapacitorLocalNotificationsPlugin } from './capacitor-plugin-loader';
|
||||||
|
|
||||||
|
function stubCapacitorWindow(): void {
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
Capacitor: {
|
||||||
|
isNativePlatform: () => capacitorState.isNativePlatform
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe('capacitor-plugin-loader', () => {
|
describe('capacitor-plugin-loader', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('window', {});
|
stubCapacitorWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -49,27 +57,28 @@ describe('capacitor-plugin-loader', () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns registered plugin instances synchronously without wrapping them in a Promise', () => {
|
it('returns registered plugin instances from dynamic imports on native shells', async () => {
|
||||||
const appPlugin = loadCapacitorAppPlugin();
|
const resolvedAppPlugin = await loadCapacitorAppPlugin();
|
||||||
const notificationsPlugin = loadCapacitorLocalNotificationsPlugin();
|
const resolvedNotificationsPlugin = await loadCapacitorLocalNotificationsPlugin();
|
||||||
|
|
||||||
expect(appPlugin).toBe(App);
|
expect(resolvedAppPlugin).toBe(appPlugin);
|
||||||
expect(notificationsPlugin).toBe(LocalNotifications);
|
expect(resolvedNotificationsPlugin).toBe(localNotificationsPlugin);
|
||||||
expect(appPlugin).not.toBeInstanceOf(Promise);
|
expect(resolvedAppPlugin).not.toBeInstanceOf(Promise);
|
||||||
expect(notificationsPlugin).not.toBeInstanceOf(Promise);
|
expect(resolvedNotificationsPlugin).not.toBeInstanceOf(Promise);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when the plugin is unavailable on the active native shell', () => {
|
it('returns null when the plugin is unavailable on the active native shell', async () => {
|
||||||
capacitorState.isPluginAvailable = false;
|
capacitorState.isPluginAvailable = false;
|
||||||
|
|
||||||
expect(loadCapacitorAppPlugin()).toBeNull();
|
expect(await loadCapacitorAppPlugin()).toBeNull();
|
||||||
expect(loadCapacitorLocalNotificationsPlugin()).toBeNull();
|
expect(await loadCapacitorLocalNotificationsPlugin()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null on non-native shells', () => {
|
it('returns null on non-native shells without importing Capacitor plugins', async () => {
|
||||||
capacitorState.isNativePlatform = false;
|
capacitorState.isNativePlatform = false;
|
||||||
|
stubCapacitorWindow();
|
||||||
|
|
||||||
expect(loadCapacitorAppPlugin()).toBeNull();
|
expect(await loadCapacitorAppPlugin()).toBeNull();
|
||||||
expect(loadCapacitorLocalNotificationsPlugin()).toBeNull();
|
expect(await loadCapacitorLocalNotificationsPlugin()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,32 @@
|
|||||||
import { App } from '@capacitor/app';
|
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
|
||||||
import { Capacitor } from '@capacitor/core';
|
|
||||||
import { Device } from '@capacitor/device';
|
|
||||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
|
||||||
import { PushNotifications } from '@capacitor/push-notifications';
|
|
||||||
import { AudioSession } from '@capgo/capacitor-audio-session';
|
|
||||||
|
|
||||||
function resolveCapacitorPlugin<T>(pluginName: string, plugin: T): T | null {
|
type CapacitorCoreModule = typeof import('@capacitor/core');
|
||||||
if (typeof window === 'undefined' || !Capacitor.isNativePlatform()) {
|
|
||||||
|
let capacitorCoreModulePromise: Promise<CapacitorCoreModule> | null = null;
|
||||||
|
|
||||||
|
const pluginPromises = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
async function loadCapacitorCore(): Promise<CapacitorCoreModule['Capacitor'] | null> {
|
||||||
|
if (typeof window === 'undefined' || !isCapacitorNativeRuntime()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!capacitorCoreModulePromise) {
|
||||||
|
capacitorCoreModulePromise = import('@capacitor/core');
|
||||||
|
}
|
||||||
|
|
||||||
|
const module = await capacitorCoreModulePromise;
|
||||||
|
|
||||||
|
return module.Capacitor;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCapacitorPlugin<T>(
|
||||||
|
pluginName: string,
|
||||||
|
loader: () => Promise<T>
|
||||||
|
): Promise<T | null> {
|
||||||
|
const Capacitor = await loadCapacitorCore();
|
||||||
|
|
||||||
|
if (!Capacitor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,30 +35,54 @@ function resolveCapacitorPlugin<T>(pluginName: string, plugin: T): T | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return plugin;
|
if (!pluginPromises.has(pluginName)) {
|
||||||
|
pluginPromises.set(pluginName, loader());
|
||||||
|
}
|
||||||
|
|
||||||
|
return pluginPromises.get(pluginName) as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the Capacitor App plugin on native shells; returns null on web/electron or when unavailable. */
|
/** Resolve the Capacitor App plugin on native shells; returns null on web/electron or when unavailable. */
|
||||||
export function loadCapacitorAppPlugin(): typeof App | null {
|
export async function loadCapacitorAppPlugin(): Promise<import('@capacitor/app').AppPlugin | null> {
|
||||||
return resolveCapacitorPlugin('App', App);
|
return resolveCapacitorPlugin('App', async () => {
|
||||||
|
const module = await import('@capacitor/app');
|
||||||
|
|
||||||
|
return module.App;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the Capacitor LocalNotifications plugin on native shells. */
|
/** Resolve the Capacitor LocalNotifications plugin on native shells. */
|
||||||
export function loadCapacitorLocalNotificationsPlugin(): typeof LocalNotifications | null {
|
export async function loadCapacitorLocalNotificationsPlugin(): Promise<import('@capacitor/local-notifications').LocalNotificationsPlugin | null> {
|
||||||
return resolveCapacitorPlugin('LocalNotifications', LocalNotifications);
|
return resolveCapacitorPlugin('LocalNotifications', async () => {
|
||||||
|
const module = await import('@capacitor/local-notifications');
|
||||||
|
|
||||||
|
return module.LocalNotifications;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the Capacitor PushNotifications plugin on native shells. */
|
/** Resolve the Capacitor PushNotifications plugin on native shells. */
|
||||||
export function loadCapacitorPushNotificationsPlugin(): typeof PushNotifications | null {
|
export async function loadCapacitorPushNotificationsPlugin(): Promise<import('@capacitor/push-notifications').PushNotificationsPlugin | null> {
|
||||||
return resolveCapacitorPlugin('PushNotifications', PushNotifications);
|
return resolveCapacitorPlugin('PushNotifications', async () => {
|
||||||
|
const module = await import('@capacitor/push-notifications');
|
||||||
|
|
||||||
|
return module.PushNotifications;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the Capacitor Device plugin on native shells. */
|
/** Resolve the Capacitor Device plugin on native shells. */
|
||||||
export function loadCapacitorDevicePlugin(): typeof Device | null {
|
export async function loadCapacitorDevicePlugin(): Promise<import('@capacitor/device').DevicePlugin | null> {
|
||||||
return resolveCapacitorPlugin('Device', Device);
|
return resolveCapacitorPlugin('Device', async () => {
|
||||||
|
const module = await import('@capacitor/device');
|
||||||
|
|
||||||
|
return module.Device;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the Capacitor AudioSession plugin on native shells. */
|
/** Resolve the Capacitor AudioSession plugin on native shells. */
|
||||||
export function loadCapacitorAudioSessionPlugin(): typeof AudioSession | null {
|
export async function loadCapacitorAudioSessionPlugin(): Promise<import('@capgo/capacitor-audio-session').AudioSessionPlugin | null> {
|
||||||
return resolveCapacitorPlugin('AudioSession', AudioSession);
|
return resolveCapacitorPlugin('AudioSession', async () => {
|
||||||
|
const module = await import('@capgo/capacitor-audio-session');
|
||||||
|
|
||||||
|
return module.AudioSession;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { registerPlugin } from '@capacitor/core';
|
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
|
||||||
|
|
||||||
export interface MetoyouMobilePlugin {
|
export interface MetoyouMobilePlugin {
|
||||||
setSpeakerphoneEnabled(options: { enabled: boolean }): Promise<void>;
|
setSpeakerphoneEnabled(options: { enabled: boolean }): Promise<void>;
|
||||||
@@ -11,4 +11,19 @@ export interface MetoyouMobilePlugin {
|
|||||||
isRemotePushConfigured(): Promise<{ configured: boolean }>;
|
isRemotePushConfigured(): Promise<{ configured: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MetoyouMobile = registerPlugin<MetoyouMobilePlugin>('MetoyouMobile');
|
let metoyouMobilePluginPromise: Promise<MetoyouMobilePlugin | null> | null = null;
|
||||||
|
|
||||||
|
/** Lazily register the MetoyouMobile Capacitor plugin on native shells only. */
|
||||||
|
export async function loadMetoyouMobilePlugin(): Promise<MetoyouMobilePlugin | null> {
|
||||||
|
if (typeof window === 'undefined' || !isCapacitorNativeRuntime()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metoyouMobilePluginPromise) {
|
||||||
|
metoyouMobilePluginPromise = import('@capacitor/core')
|
||||||
|
.then(({ registerPlugin }) => registerPlugin<MetoyouMobilePlugin>('MetoyouMobile'))
|
||||||
|
.catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return metoyouMobilePluginPromise;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import { resolveMobileAdapter } from './mobile-capacitor-adapter.rules';
|
||||||
|
|
||||||
|
describe('resolveMobileAdapter', () => {
|
||||||
|
it('returns the web adapter on electron and browser shells', async () => {
|
||||||
|
const loadCapacitorAdapter = vi.fn(() => Promise.resolve('capacitor'));
|
||||||
|
|
||||||
|
await expect(resolveMobileAdapter('electron', 'web', loadCapacitorAdapter)).resolves.toBe('web');
|
||||||
|
await expect(resolveMobileAdapter('browser', 'web', loadCapacitorAdapter)).resolves.toBe('web');
|
||||||
|
expect(loadCapacitorAdapter).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the Capacitor adapter only on native mobile shells', async () => {
|
||||||
|
const loadCapacitorAdapter = vi.fn(() => Promise.resolve('capacitor'));
|
||||||
|
|
||||||
|
await expect(resolveMobileAdapter('capacitor', 'web', loadCapacitorAdapter)).resolves.toBe('capacitor');
|
||||||
|
expect(loadCapacitorAdapter).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { RuntimePlatform } from './platform-detection.rules';
|
||||||
|
|
||||||
|
/** Lazily loads a Capacitor-only adapter while keeping web/electron shells on the web fallback. */
|
||||||
|
export async function resolveMobileAdapter<TWeb, TCapacitor>(
|
||||||
|
runtime: RuntimePlatform,
|
||||||
|
webAdapter: TWeb,
|
||||||
|
loadCapacitorAdapter: () => Promise<TCapacitor>
|
||||||
|
): Promise<TWeb | TCapacitor> {
|
||||||
|
if (runtime !== 'capacitor') {
|
||||||
|
return webAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadCapacitorAdapter();
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
|
||||||
|
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
||||||
import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts';
|
import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts';
|
||||||
import { CapacitorMobileAppLifecycleAdapter } from '../adapters/capacitor/capacitor-mobile-app-lifecycle.adapter';
|
|
||||||
import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter';
|
import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter';
|
||||||
import { MobilePlatformService } from './mobile-platform.service';
|
import { MobilePlatformService } from './mobile-platform.service';
|
||||||
|
|
||||||
@@ -9,7 +9,8 @@ import { MobilePlatformService } from './mobile-platform.service';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class MobileAppLifecycleService {
|
export class MobileAppLifecycleService {
|
||||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||||
private readonly adapter: MobileAppLifecycleAdapter = this.createAdapter();
|
private adapter: MobileAppLifecycleAdapter = new WebMobileAppLifecycleAdapter();
|
||||||
|
private adapterReady: Promise<MobileAppLifecycleAdapter> | null = null;
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
@@ -17,7 +18,9 @@ export class MobileAppLifecycleService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.adapter.initialize();
|
const adapter = await this.ensureAdapter();
|
||||||
|
|
||||||
|
await adapter.initialize();
|
||||||
this.mobilePlatform.refreshRuntimeDetection();
|
this.mobilePlatform.refreshRuntimeDetection();
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
@@ -26,9 +29,22 @@ export class MobileAppLifecycleService {
|
|||||||
this.adapter.onAppStateChange(handler);
|
this.adapter.onAppStateChange(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
private createAdapter(): MobileAppLifecycleAdapter {
|
private ensureAdapter(): Promise<MobileAppLifecycleAdapter> {
|
||||||
return this.mobilePlatform.isCapacitor()
|
if (!this.adapterReady) {
|
||||||
? new CapacitorMobileAppLifecycleAdapter()
|
this.adapterReady = resolveMobileAdapter(
|
||||||
: new WebMobileAppLifecycleAdapter();
|
this.mobilePlatform.runtime(),
|
||||||
|
this.adapter,
|
||||||
|
async () => {
|
||||||
|
const { CapacitorMobileAppLifecycleAdapter } = await import('../adapters/capacitor/capacitor-mobile-app-lifecycle.adapter');
|
||||||
|
|
||||||
|
return new CapacitorMobileAppLifecycleAdapter();
|
||||||
|
}
|
||||||
|
).then((adapter) => {
|
||||||
|
this.adapter = adapter;
|
||||||
|
return adapter;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adapterReady;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,17 +43,7 @@ export class MobileCallSessionService {
|
|||||||
|
|
||||||
this.wired = true;
|
this.wired = true;
|
||||||
|
|
||||||
void this.notifications.initialize();
|
void this.bootstrap();
|
||||||
void this.lifecycle.initialize();
|
|
||||||
|
|
||||||
this.notifications.onCallAction(({ callId, intent }) => {
|
|
||||||
void this.router.navigate(['/call', callId]);
|
|
||||||
this.actionHandler?.(intent, callId);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.lifecycle.onAppStateChange((isActive) => {
|
|
||||||
void this.handleAppStateChange(isActive);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.destroyRef.onDestroy(() => {
|
this.destroyRef.onDestroy(() => {
|
||||||
this.activeSession = null;
|
this.activeSession = null;
|
||||||
@@ -117,6 +107,20 @@ export class MobileCallSessionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async bootstrap(): Promise<void> {
|
||||||
|
await this.notifications.initialize();
|
||||||
|
await this.lifecycle.initialize();
|
||||||
|
|
||||||
|
this.notifications.onCallAction(({ callId, intent }) => {
|
||||||
|
void this.router.navigate(['/call', callId]);
|
||||||
|
this.actionHandler?.(intent, callId);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.lifecycle.onAppStateChange((isActive) => {
|
||||||
|
void this.handleAppStateChange(isActive);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private shouldHandleMobileCalls(): boolean {
|
private shouldHandleMobileCalls(): boolean {
|
||||||
return this.mobilePlatform.isNativeMobile();
|
return this.mobilePlatform.isNativeMobile();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
|
||||||
|
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
||||||
import type { MobileCallKitAdapter } from '../contracts/mobile.contracts';
|
import type { MobileCallKitAdapter } from '../contracts/mobile.contracts';
|
||||||
import { CapacitorMobileCallKitAdapter } from '../adapters/capacitor/capacitor-mobile-callkit.adapter';
|
|
||||||
import { WebMobileCallKitAdapter } from '../adapters/web/web-mobile-callkit.adapter';
|
import { WebMobileCallKitAdapter } from '../adapters/web/web-mobile-callkit.adapter';
|
||||||
import { MobilePlatformService } from './mobile-platform.service';
|
import { MobilePlatformService } from './mobile-platform.service';
|
||||||
|
|
||||||
@@ -9,14 +9,15 @@ import { MobilePlatformService } from './mobile-platform.service';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class MobileCallKitService {
|
export class MobileCallKitService {
|
||||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||||
private readonly adapter: MobileCallKitAdapter = this.createAdapter();
|
private adapter: MobileCallKitAdapter = new WebMobileCallKitAdapter();
|
||||||
|
private adapterReady: Promise<MobileCallKitAdapter> | null = null;
|
||||||
|
|
||||||
startActiveCall(callId: string, displayName: string): Promise<void> {
|
startActiveCall(callId: string, displayName: string): Promise<void> {
|
||||||
if (!this.mobilePlatform.isCapacitor()) {
|
if (!this.mobilePlatform.isCapacitor()) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.adapter.startActiveCall(callId, displayName);
|
return this.ensureAdapter().then((adapter) => adapter.startActiveCall(callId, displayName));
|
||||||
}
|
}
|
||||||
|
|
||||||
endActiveCall(callId: string): Promise<void> {
|
endActiveCall(callId: string): Promise<void> {
|
||||||
@@ -24,12 +25,25 @@ export class MobileCallKitService {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.adapter.endActiveCall(callId);
|
return this.ensureAdapter().then((adapter) => adapter.endActiveCall(callId));
|
||||||
}
|
}
|
||||||
|
|
||||||
private createAdapter(): MobileCallKitAdapter {
|
private ensureAdapter(): Promise<MobileCallKitAdapter> {
|
||||||
return this.mobilePlatform.isCapacitor()
|
if (!this.adapterReady) {
|
||||||
? new CapacitorMobileCallKitAdapter()
|
this.adapterReady = resolveMobileAdapter(
|
||||||
: new WebMobileCallKitAdapter();
|
this.mobilePlatform.runtime(),
|
||||||
|
this.adapter,
|
||||||
|
async () => {
|
||||||
|
const { CapacitorMobileCallKitAdapter } = await import('../adapters/capacitor/capacitor-mobile-callkit.adapter');
|
||||||
|
|
||||||
|
return new CapacitorMobileCallKitAdapter();
|
||||||
|
}
|
||||||
|
).then((adapter) => {
|
||||||
|
this.adapter = adapter;
|
||||||
|
return adapter;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adapterReady;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
inject
|
inject
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
|
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
||||||
import type { MobileMediaAdapter } from '../contracts/mobile.contracts';
|
import type { MobileMediaAdapter } from '../contracts/mobile.contracts';
|
||||||
import { CapacitorMobileMediaAdapter } from '../adapters/capacitor/capacitor-mobile-media.adapter';
|
|
||||||
import { WebMobileMediaAdapter } from '../adapters/web/web-mobile-media.adapter';
|
import { WebMobileMediaAdapter } from '../adapters/web/web-mobile-media.adapter';
|
||||||
import { MobilePlatformService } from './mobile-platform.service';
|
import { MobilePlatformService } from './mobile-platform.service';
|
||||||
|
|
||||||
@@ -16,27 +16,41 @@ export class MobileMediaService {
|
|||||||
readonly isPictureInPictureSupported = computed(() => this.adapter.isPictureInPictureSupported());
|
readonly isPictureInPictureSupported = computed(() => this.adapter.isPictureInPictureSupported());
|
||||||
|
|
||||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||||
private readonly adapter: MobileMediaAdapter = this.createAdapter();
|
private adapter: MobileMediaAdapter = new WebMobileMediaAdapter();
|
||||||
|
private adapterReady: Promise<MobileMediaAdapter> | null = null;
|
||||||
|
|
||||||
pickAttachments(): Promise<File[]> {
|
pickAttachments(): Promise<File[]> {
|
||||||
return this.adapter.pickAttachments();
|
return this.ensureAdapter().then((adapter) => adapter.pickAttachments());
|
||||||
}
|
}
|
||||||
|
|
||||||
setSpeakerphoneEnabled(enabled: boolean): Promise<void> {
|
setSpeakerphoneEnabled(enabled: boolean): Promise<void> {
|
||||||
return this.adapter.setSpeakerphoneEnabled(enabled);
|
return this.ensureAdapter().then((adapter) => adapter.setSpeakerphoneEnabled(enabled));
|
||||||
}
|
}
|
||||||
|
|
||||||
startBackgroundAudioSession(): Promise<void> {
|
startBackgroundAudioSession(): Promise<void> {
|
||||||
return this.adapter.startBackgroundAudioSession();
|
return this.ensureAdapter().then((adapter) => adapter.startBackgroundAudioSession());
|
||||||
}
|
}
|
||||||
|
|
||||||
stopBackgroundAudioSession(): Promise<void> {
|
stopBackgroundAudioSession(): Promise<void> {
|
||||||
return this.adapter.stopBackgroundAudioSession();
|
return this.ensureAdapter().then((adapter) => adapter.stopBackgroundAudioSession());
|
||||||
}
|
}
|
||||||
|
|
||||||
private createAdapter(): MobileMediaAdapter {
|
private ensureAdapter(): Promise<MobileMediaAdapter> {
|
||||||
return this.mobilePlatform.isCapacitor()
|
if (!this.adapterReady) {
|
||||||
? new CapacitorMobileMediaAdapter()
|
this.adapterReady = resolveMobileAdapter(
|
||||||
: new WebMobileMediaAdapter();
|
this.mobilePlatform.runtime(),
|
||||||
|
this.adapter,
|
||||||
|
async () => {
|
||||||
|
const { CapacitorMobileMediaAdapter } = await import('../adapters/capacitor/capacitor-mobile-media.adapter');
|
||||||
|
|
||||||
|
return new CapacitorMobileMediaAdapter();
|
||||||
|
}
|
||||||
|
).then((adapter) => {
|
||||||
|
this.adapter = adapter;
|
||||||
|
return adapter;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adapterReady;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { Injectable, inject } from '@angular/core';
|
|||||||
|
|
||||||
import type { CallNotificationActionIntent } from '../logic/call-notification.rules';
|
import type { CallNotificationActionIntent } from '../logic/call-notification.rules';
|
||||||
import { buildIncomingCallNotification, buildInCallNotification } from '../logic/call-notification.rules';
|
import { buildIncomingCallNotification, buildInCallNotification } from '../logic/call-notification.rules';
|
||||||
|
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
||||||
import type { MobileNotificationAdapter } from '../contracts/mobile.contracts';
|
import type { MobileNotificationAdapter } from '../contracts/mobile.contracts';
|
||||||
import { CapacitorMobileNotificationsAdapter } from '../adapters/capacitor/capacitor-mobile-notifications.adapter';
|
|
||||||
import { WebMobileNotificationsAdapter } from '../adapters/web/web-mobile-notifications.adapter';
|
import { WebMobileNotificationsAdapter } from '../adapters/web/web-mobile-notifications.adapter';
|
||||||
import { MobilePlatformService } from './mobile-platform.service';
|
import { MobilePlatformService } from './mobile-platform.service';
|
||||||
import { MobilePushRegistrationService } from './mobile-push-registration.service';
|
import { MobilePushRegistrationService } from './mobile-push-registration.service';
|
||||||
@@ -13,7 +13,9 @@ import { MobilePushRegistrationService } from './mobile-push-registration.servic
|
|||||||
export class MobileNotificationsService {
|
export class MobileNotificationsService {
|
||||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||||
private readonly pushRegistration = inject(MobilePushRegistrationService);
|
private readonly pushRegistration = inject(MobilePushRegistrationService);
|
||||||
private readonly adapter: MobileNotificationAdapter = this.createAdapter();
|
private adapter: MobileNotificationAdapter = new WebMobileNotificationsAdapter();
|
||||||
|
private adapterReady: Promise<MobileNotificationAdapter> | null = null;
|
||||||
|
private callActionHandler: ((input: { callId: string; intent: CallNotificationActionIntent }) => void) | null = null;
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
@@ -21,36 +23,65 @@ export class MobileNotificationsService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.adapter.initialize();
|
const adapter = await this.ensureAdapter();
|
||||||
|
|
||||||
|
await adapter.initialize();
|
||||||
this.pushRegistration.initialize();
|
this.pushRegistration.initialize();
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async showIncomingCall(displayName: string, callId: string): Promise<void> {
|
async showIncomingCall(displayName: string, callId: string): Promise<void> {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
await this.adapter.showCallNotification(buildIncomingCallNotification(displayName, callId));
|
const adapter = await this.ensureAdapter();
|
||||||
|
|
||||||
|
await adapter.showCallNotification(buildIncomingCallNotification(displayName, callId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async showActiveCall(input: { callId: string; displayName: string; isMuted: boolean }): Promise<void> {
|
async showActiveCall(input: { callId: string; displayName: string; isMuted: boolean }): Promise<void> {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
await this.adapter.showCallNotification(buildInCallNotification(input));
|
const adapter = await this.ensureAdapter();
|
||||||
|
|
||||||
|
await adapter.showCallNotification(buildInCallNotification(input));
|
||||||
}
|
}
|
||||||
|
|
||||||
async dismissIncomingCall(callId: string): Promise<void> {
|
async dismissIncomingCall(callId: string): Promise<void> {
|
||||||
await this.adapter.dismissCallNotification(callId, 'incoming');
|
const adapter = await this.ensureAdapter();
|
||||||
|
|
||||||
|
await adapter.dismissCallNotification(callId, 'incoming');
|
||||||
}
|
}
|
||||||
|
|
||||||
async dismissActiveCall(callId: string): Promise<void> {
|
async dismissActiveCall(callId: string): Promise<void> {
|
||||||
await this.adapter.dismissCallNotification(callId, 'active');
|
const adapter = await this.ensureAdapter();
|
||||||
|
|
||||||
|
await adapter.dismissCallNotification(callId, 'active');
|
||||||
}
|
}
|
||||||
|
|
||||||
onCallAction(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void {
|
onCallAction(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void {
|
||||||
|
this.callActionHandler = handler;
|
||||||
this.adapter.onActionSelected(handler);
|
this.adapter.onActionSelected(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
private createAdapter(): MobileNotificationAdapter {
|
private ensureAdapter(): Promise<MobileNotificationAdapter> {
|
||||||
return this.mobilePlatform.isCapacitor()
|
if (!this.adapterReady) {
|
||||||
? new CapacitorMobileNotificationsAdapter()
|
this.adapterReady = resolveMobileAdapter(
|
||||||
: new WebMobileNotificationsAdapter();
|
this.mobilePlatform.runtime(),
|
||||||
|
this.adapter,
|
||||||
|
async () => {
|
||||||
|
const { CapacitorMobileNotificationsAdapter } = await import('../adapters/capacitor/capacitor-mobile-notifications.adapter');
|
||||||
|
|
||||||
|
return new CapacitorMobileNotificationsAdapter();
|
||||||
|
}
|
||||||
|
).then((adapter) => {
|
||||||
|
this.adapter = adapter;
|
||||||
|
|
||||||
|
if (this.callActionHandler) {
|
||||||
|
this.adapter.onActionSelected(this.callActionHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
return adapter;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adapterReady;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
|
||||||
|
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
||||||
import type { MobilePersistenceAdapter } from '../contracts/mobile.contracts';
|
import type { MobilePersistenceAdapter } from '../contracts/mobile.contracts';
|
||||||
import { CapacitorMobilePersistenceAdapter } from '../adapters/capacitor/capacitor-mobile-persistence.adapter';
|
|
||||||
import { WebMobilePersistenceAdapter } from '../adapters/web/web-mobile-persistence.adapter';
|
import { WebMobilePersistenceAdapter } from '../adapters/web/web-mobile-persistence.adapter';
|
||||||
import { MobilePlatformService } from './mobile-platform.service';
|
import { MobilePlatformService } from './mobile-platform.service';
|
||||||
import { MobileSqliteConnectionService } from './mobile-sqlite-connection.service';
|
import { MobileSqliteConnectionService } from './mobile-sqlite-connection.service';
|
||||||
@@ -11,19 +11,33 @@ import { MobileSqliteConnectionService } from './mobile-sqlite-connection.servic
|
|||||||
export class MobilePersistenceService {
|
export class MobilePersistenceService {
|
||||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||||
private readonly sqliteConnection = inject(MobileSqliteConnectionService);
|
private readonly sqliteConnection = inject(MobileSqliteConnectionService);
|
||||||
private readonly adapter: MobilePersistenceAdapter = this.createAdapter();
|
private adapter: MobilePersistenceAdapter = new WebMobilePersistenceAdapter();
|
||||||
|
private adapterReady: Promise<MobilePersistenceAdapter> | null = null;
|
||||||
|
|
||||||
get isNativeSqlite(): boolean {
|
get isNativeSqlite(): boolean {
|
||||||
return this.adapter.isNativeSqlite;
|
return this.adapter.isNativeSqlite;
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize(): Promise<void> {
|
initialize(): Promise<void> {
|
||||||
return this.adapter.initialize();
|
return this.ensureAdapter().then((adapter) => adapter.initialize());
|
||||||
}
|
}
|
||||||
|
|
||||||
private createAdapter(): MobilePersistenceAdapter {
|
private ensureAdapter(): Promise<MobilePersistenceAdapter> {
|
||||||
return this.mobilePlatform.isCapacitor()
|
if (!this.adapterReady) {
|
||||||
? new CapacitorMobilePersistenceAdapter(this.sqliteConnection)
|
this.adapterReady = resolveMobileAdapter(
|
||||||
: new WebMobilePersistenceAdapter();
|
this.mobilePlatform.runtime(),
|
||||||
|
this.adapter,
|
||||||
|
async () => {
|
||||||
|
const { CapacitorMobilePersistenceAdapter } = await import('../adapters/capacitor/capacitor-mobile-persistence.adapter');
|
||||||
|
|
||||||
|
return new CapacitorMobilePersistenceAdapter(this.sqliteConnection);
|
||||||
|
}
|
||||||
|
).then((adapter) => {
|
||||||
|
this.adapter = adapter;
|
||||||
|
return adapter;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adapterReady;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
|
||||||
|
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
||||||
import type { MobilePictureInPictureAdapter } from '../contracts/mobile.contracts';
|
import type { MobilePictureInPictureAdapter } from '../contracts/mobile.contracts';
|
||||||
import { CapacitorMobilePictureInPictureAdapter } from '../adapters/capacitor/capacitor-mobile-picture-in-picture.adapter';
|
|
||||||
import { WebMobilePictureInPictureAdapter } from '../adapters/web/web-mobile-picture-in-picture.adapter';
|
import { WebMobilePictureInPictureAdapter } from '../adapters/web/web-mobile-picture-in-picture.adapter';
|
||||||
import { MobilePlatformService } from './mobile-platform.service';
|
import { MobilePlatformService } from './mobile-platform.service';
|
||||||
|
|
||||||
@@ -9,23 +9,37 @@ import { MobilePlatformService } from './mobile-platform.service';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class MobilePictureInPictureService {
|
export class MobilePictureInPictureService {
|
||||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||||
private readonly adapter: MobilePictureInPictureAdapter = this.createAdapter();
|
private adapter: MobilePictureInPictureAdapter = new WebMobilePictureInPictureAdapter();
|
||||||
|
private adapterReady: Promise<MobilePictureInPictureAdapter> | null = null;
|
||||||
|
|
||||||
isSupported(): boolean {
|
isSupported(): boolean {
|
||||||
return this.adapter.isSupported();
|
return this.adapter.isSupported();
|
||||||
}
|
}
|
||||||
|
|
||||||
enter(videoElement: HTMLVideoElement): Promise<void> {
|
enter(videoElement: HTMLVideoElement): Promise<void> {
|
||||||
return this.adapter.enter(videoElement);
|
return this.ensureAdapter().then((adapter) => adapter.enter(videoElement));
|
||||||
}
|
}
|
||||||
|
|
||||||
exit(): Promise<void> {
|
exit(): Promise<void> {
|
||||||
return this.adapter.exit();
|
return this.ensureAdapter().then((adapter) => adapter.exit());
|
||||||
}
|
}
|
||||||
|
|
||||||
private createAdapter(): MobilePictureInPictureAdapter {
|
private ensureAdapter(): Promise<MobilePictureInPictureAdapter> {
|
||||||
return this.mobilePlatform.isCapacitor()
|
if (!this.adapterReady) {
|
||||||
? new CapacitorMobilePictureInPictureAdapter()
|
this.adapterReady = resolveMobileAdapter(
|
||||||
: new WebMobilePictureInPictureAdapter();
|
this.mobilePlatform.runtime(),
|
||||||
|
this.adapter,
|
||||||
|
async () => {
|
||||||
|
const { CapacitorMobilePictureInPictureAdapter } = await import('../adapters/capacitor/capacitor-mobile-picture-in-picture.adapter');
|
||||||
|
|
||||||
|
return new CapacitorMobilePictureInPictureAdapter();
|
||||||
|
}
|
||||||
|
).then((adapter) => {
|
||||||
|
this.adapter = adapter;
|
||||||
|
return adapter;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adapterReady;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,17 +26,17 @@ const remotePushState = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../adapters/capacitor/capacitor-plugin-loader', () => ({
|
vi.mock('../adapters/capacitor/capacitor-plugin-loader', () => ({
|
||||||
loadCapacitorPushNotificationsPlugin: () => pushState,
|
loadCapacitorPushNotificationsPlugin: vi.fn(() => Promise.resolve(pushState)),
|
||||||
loadCapacitorDevicePlugin: () => deviceState
|
loadCapacitorDevicePlugin: vi.fn(() => Promise.resolve(deviceState))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../adapters/capacitor/metoyou-mobile.plugin', () => ({
|
vi.mock('../adapters/capacitor/metoyou-mobile.plugin', () => ({
|
||||||
MetoyouMobile: {
|
loadMetoyouMobilePlugin: vi.fn(() => Promise.resolve({
|
||||||
isRemotePushConfigured: vi.fn(() => Promise.resolve({ configured: remotePushState.configured }))
|
isRemotePushConfigured: vi.fn(() => Promise.resolve({ configured: remotePushState.configured }))
|
||||||
}
|
}))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { MetoyouMobile } from '../adapters/capacitor/metoyou-mobile.plugin';
|
import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin';
|
||||||
import { MobilePlatformService } from './mobile-platform.service';
|
import { MobilePlatformService } from './mobile-platform.service';
|
||||||
import { MobilePushRegistrationService } from './mobile-push-registration.service';
|
import { MobilePushRegistrationService } from './mobile-push-registration.service';
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ describe('MobilePushRegistrationService', () => {
|
|||||||
remotePushState.configured = true;
|
remotePushState.configured = true;
|
||||||
pushState.register.mockClear();
|
pushState.register.mockClear();
|
||||||
pushState.addListener.mockClear();
|
pushState.addListener.mockClear();
|
||||||
vi.mocked(MetoyouMobile.isRemotePushConfigured).mockClear();
|
vi.mocked(loadMetoyouMobilePlugin).mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -95,7 +95,7 @@ describe('MobilePushRegistrationService', () => {
|
|||||||
expect(pushState.register).toHaveBeenCalledTimes(1);
|
expect(pushState.register).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(MetoyouMobile.isRemotePushConfigured).toHaveBeenCalled();
|
expect(loadMetoyouMobilePlugin).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not wire listeners on non-capacitor shells', () => {
|
it('does not wire listeners on non-capacitor shells', () => {
|
||||||
@@ -106,6 +106,6 @@ describe('MobilePushRegistrationService', () => {
|
|||||||
service.initialize();
|
service.initialize();
|
||||||
|
|
||||||
expect(pushState.register).not.toHaveBeenCalled();
|
expect(pushState.register).not.toHaveBeenCalled();
|
||||||
expect(MetoyouMobile.isRemotePushConfigured).not.toHaveBeenCalled();
|
expect(loadMetoyouMobilePlugin).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getStoredCurrentUserId } from '../../../core/storage/current-user-stora
|
|||||||
import { buildPushDeviceTokenRegistrationPayload, normalizePushPlatform } from '../logic/mobile-push-token.rules';
|
import { buildPushDeviceTokenRegistrationPayload, normalizePushPlatform } from '../logic/mobile-push-token.rules';
|
||||||
import { buildRemotePushSkipMessage, resolveRemotePushSkipReason } from '../logic/mobile-push-registration.rules';
|
import { buildRemotePushSkipMessage, resolveRemotePushSkipReason } from '../logic/mobile-push-registration.rules';
|
||||||
import { loadCapacitorDevicePlugin, loadCapacitorPushNotificationsPlugin } from '../adapters/capacitor/capacitor-plugin-loader';
|
import { loadCapacitorDevicePlugin, loadCapacitorPushNotificationsPlugin } from '../adapters/capacitor/capacitor-plugin-loader';
|
||||||
import { MetoyouMobile } from '../adapters/capacitor/metoyou-mobile.plugin';
|
import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin';
|
||||||
import { MobilePlatformService } from './mobile-platform.service';
|
import { MobilePlatformService } from './mobile-platform.service';
|
||||||
|
|
||||||
/** Registers FCM/APNs device tokens with the signaling server on Capacitor shells. */
|
/** Registers FCM/APNs device tokens with the signaling server on Capacitor shells. */
|
||||||
@@ -32,8 +32,8 @@ export class MobilePushRegistrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async registerPushListeners(): Promise<void> {
|
private async registerPushListeners(): Promise<void> {
|
||||||
const PushNotifications = loadCapacitorPushNotificationsPlugin();
|
const PushNotifications = await loadCapacitorPushNotificationsPlugin();
|
||||||
const Device = loadCapacitorDevicePlugin();
|
const Device = await loadCapacitorDevicePlugin();
|
||||||
const remotePushConfigured = await this.isRemotePushConfigured();
|
const remotePushConfigured = await this.isRemotePushConfigured();
|
||||||
const skipReason = resolveRemotePushSkipReason({
|
const skipReason = resolveRemotePushSkipReason({
|
||||||
hasPushPlugin: !!PushNotifications,
|
hasPushPlugin: !!PushNotifications,
|
||||||
@@ -75,6 +75,12 @@ export class MobilePushRegistrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async isRemotePushConfigured(): Promise<boolean> {
|
private async isRemotePushConfigured(): Promise<boolean> {
|
||||||
|
const MetoyouMobile = await loadMetoyouMobilePlugin();
|
||||||
|
|
||||||
|
if (!MetoyouMobile) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await MetoyouMobile.isRemotePushConfigured();
|
const result = await MetoyouMobile.isRemotePushConfigured();
|
||||||
|
|
||||||
@@ -91,7 +97,7 @@ export class MobilePushRegistrationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Device = loadCapacitorDevicePlugin();
|
const Device = await loadCapacitorDevicePlugin();
|
||||||
const deviceInfo = Device ? await Device.getInfo() : null;
|
const deviceInfo = Device ? await Device.getInfo() : null;
|
||||||
const platform = normalizePushPlatform(deviceInfo?.platform ?? '');
|
const platform = normalizePushPlatform(deviceInfo?.platform ?? '');
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
<div class="video-controls-row">
|
<div class="video-controls-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="togglePlayback()"
|
(click)="onControlPlaybackClick($event)"
|
||||||
class="video-control-btn"
|
class="video-control-btn"
|
||||||
[title]="isPlaying() ? 'Pause' : 'Play'"
|
[title]="isPlaying() ? 'Pause' : 'Play'"
|
||||||
[attr.aria-label]="isPlaying() ? 'Pause video' : 'Play video'"
|
[attr.aria-label]="isPlaying() ? 'Pause video' : 'Play video'"
|
||||||
@@ -97,6 +97,7 @@
|
|||||||
[max]="seekSliderSteps"
|
[max]="seekSliderSteps"
|
||||||
step="any"
|
step="any"
|
||||||
[value]="seekSliderValue()"
|
[value]="seekSliderValue()"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
(input)="onSeek($event)"
|
(input)="onSeek($event)"
|
||||||
class="seek-slider"
|
class="seek-slider"
|
||||||
[style.--progress.%]="progressPercent()"
|
[style.--progress.%]="progressPercent()"
|
||||||
@@ -106,7 +107,7 @@
|
|||||||
<div class="video-volume-group">
|
<div class="video-volume-group">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="toggleMute()"
|
(click)="toggleMute(); $event.stopPropagation()"
|
||||||
class="video-control-btn"
|
class="video-control-btn"
|
||||||
[title]="isMuted() ? 'Unmute' : 'Mute'"
|
[title]="isMuted() ? 'Unmute' : 'Mute'"
|
||||||
[attr.aria-label]="isMuted() ? 'Unmute video' : 'Mute video'"
|
[attr.aria-label]="isMuted() ? 'Unmute video' : 'Mute video'"
|
||||||
@@ -122,6 +123,7 @@
|
|||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
[value]="isMuted() ? 0 : volumePercent()"
|
[value]="isMuted() ? 0 : volumePercent()"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
(input)="onVolumeInput($event)"
|
(input)="onVolumeInput($event)"
|
||||||
class="volume-slider"
|
class="volume-slider"
|
||||||
[style.--progress.%]="volumeProgressPercent()"
|
[style.--progress.%]="volumeProgressPercent()"
|
||||||
@@ -131,7 +133,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="toggleFullscreen()"
|
(click)="toggleFullscreen(); $event.stopPropagation()"
|
||||||
class="video-control-btn"
|
class="video-control-btn"
|
||||||
[title]="isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
|
[title]="isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
|
||||||
[attr.aria-label]="isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'"
|
[attr.aria-label]="isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'"
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
ElementRef,
|
||||||
|
Injector,
|
||||||
|
runInInjectionContext
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import { ChatVideoPlayerComponent } from './chat-video-player.component';
|
||||||
|
|
||||||
|
function createComponent(): ChatVideoPlayerComponent {
|
||||||
|
const injector = Injector.create({ providers: [ChatVideoPlayerComponent] });
|
||||||
|
|
||||||
|
return runInInjectionContext(injector, () => injector.get(ChatVideoPlayerComponent));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createVideoStub(initialPaused: boolean): HTMLVideoElement {
|
||||||
|
const video = {
|
||||||
|
paused: initialPaused,
|
||||||
|
ended: false,
|
||||||
|
play: vi.fn().mockImplementation(function play(this: { paused: boolean }) {
|
||||||
|
this.paused = false;
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
pause: vi.fn().mockImplementation(function pause(this: { paused: boolean }) {
|
||||||
|
this.paused = true;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
return video as unknown as HTMLVideoElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachVideo(component: ChatVideoPlayerComponent, video: HTMLVideoElement): void {
|
||||||
|
component.videoRef = { nativeElement: video } as ElementRef<HTMLVideoElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatVideoPlayerComponent playback clicks', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules stage playback toggle after a short delay', () => {
|
||||||
|
const component = createComponent();
|
||||||
|
const video = createVideoStub(true);
|
||||||
|
|
||||||
|
attachVideo(component, video);
|
||||||
|
|
||||||
|
component.onVideoClick();
|
||||||
|
|
||||||
|
expect(video.play).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
expect(video.play).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('control playback click stops propagation so stage toggle is not scheduled', () => {
|
||||||
|
const component = createComponent();
|
||||||
|
const video = createVideoStub(false);
|
||||||
|
const stopPropagation = vi.fn();
|
||||||
|
|
||||||
|
attachVideo(component, video);
|
||||||
|
|
||||||
|
component.onControlPlaybackClick({ stopPropagation } as unknown as MouseEvent);
|
||||||
|
|
||||||
|
expect(stopPropagation).toHaveBeenCalled();
|
||||||
|
expect(video.pause).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pauses once when control playback is used without a bubbling stage click', () => {
|
||||||
|
const component = createComponent();
|
||||||
|
const video = createVideoStub(false);
|
||||||
|
|
||||||
|
attachVideo(component, video);
|
||||||
|
|
||||||
|
component.togglePlayback();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
expect(video.pause).toHaveBeenCalledTimes(1);
|
||||||
|
expect(video.play).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('double-toggles when control playback bubbles into the stage click handler', () => {
|
||||||
|
const component = createComponent();
|
||||||
|
const video = createVideoStub(false);
|
||||||
|
|
||||||
|
attachVideo(component, video);
|
||||||
|
|
||||||
|
component.togglePlayback();
|
||||||
|
component.onVideoClick();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
expect(video.pause).toHaveBeenCalledTimes(1);
|
||||||
|
expect(video.play).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -126,6 +126,11 @@ export class ChatVideoPlayerComponent implements OnInit, OnDestroy {
|
|||||||
this.togglePlayback();
|
this.togglePlayback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onControlPlaybackClick(event: MouseEvent): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.togglePlayback();
|
||||||
|
}
|
||||||
|
|
||||||
togglePlayback(): void {
|
togglePlayback(): void {
|
||||||
const video = this.videoRef?.nativeElement;
|
const video = this.videoRef?.nativeElement;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { portalHostElementToBody } from './portal-host-to-body.logic';
|
||||||
|
|
||||||
|
interface MockElement {
|
||||||
|
parentElement: MockElement | null;
|
||||||
|
children: MockElement[];
|
||||||
|
appendChild(child: MockElement): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockElement(parent: MockElement | null = null): MockElement {
|
||||||
|
const element: MockElement = {
|
||||||
|
parentElement: parent,
|
||||||
|
children: [],
|
||||||
|
appendChild(child: MockElement): void {
|
||||||
|
if (child.parentElement) {
|
||||||
|
child.parentElement.children = child.parentElement.children.filter((entry) => entry !== child);
|
||||||
|
}
|
||||||
|
|
||||||
|
child.parentElement = this;
|
||||||
|
this.children.push(child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
parent?.appendChild(element);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('portalHostElementToBody', () => {
|
||||||
|
it('appends the host to document.body when it is nested elsewhere', () => {
|
||||||
|
const body = createMockElement();
|
||||||
|
const container = createMockElement(body);
|
||||||
|
const host = createMockElement(container);
|
||||||
|
|
||||||
|
expect(portalHostElementToBody(host as unknown as HTMLElement, body as unknown as HTMLElement)).toBe(true);
|
||||||
|
expect(host.parentElement).toBe(body);
|
||||||
|
expect(container.children).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the host is already attached to document.body', () => {
|
||||||
|
const body = createMockElement();
|
||||||
|
const host = createMockElement(body);
|
||||||
|
|
||||||
|
expect(portalHostElementToBody(host as unknown as HTMLElement, body as unknown as HTMLElement)).toBe(false);
|
||||||
|
expect(host.parentElement).toBe(body);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Move a modal host element to `document.body` so `position: fixed` covers the viewport. */
|
||||||
|
export function portalHostElementToBody(host: HTMLElement, body: HTMLElement): boolean {
|
||||||
|
if (host.parentElement === body) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.appendChild(host);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
|
AfterViewInit,
|
||||||
Component,
|
Component,
|
||||||
|
ElementRef,
|
||||||
HostListener,
|
HostListener,
|
||||||
OnInit,
|
OnInit,
|
||||||
|
inject,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
signal
|
signal
|
||||||
@@ -9,14 +13,20 @@ import {
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ScreenShareQuality, SCREEN_SHARE_QUALITY_OPTIONS } from '../../../domains/screen-share';
|
import { ScreenShareQuality, SCREEN_SHARE_QUALITY_OPTIONS } from '../../../domains/screen-share';
|
||||||
import { ModalBackdropComponent } from '../modal-backdrop/modal-backdrop.component';
|
import { ModalBackdropComponent } from '../modal-backdrop/modal-backdrop.component';
|
||||||
|
import { portalHostElementToBody } from '../portal-host-to-body.logic';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-screen-share-quality-dialog',
|
selector: 'app-screen-share-quality-dialog',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ModalBackdropComponent],
|
imports: [CommonModule, ModalBackdropComponent],
|
||||||
templateUrl: './screen-share-quality-dialog.component.html'
|
templateUrl: './screen-share-quality-dialog.component.html',
|
||||||
|
host: {
|
||||||
|
style: 'display: contents;'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
export class ScreenShareQualityDialogComponent implements OnInit {
|
export class ScreenShareQualityDialogComponent implements OnInit, AfterViewInit {
|
||||||
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
selectedQuality = input.required<ScreenShareQuality>();
|
selectedQuality = input.required<ScreenShareQuality>();
|
||||||
includeSystemAudio = input(false);
|
includeSystemAudio = input(false);
|
||||||
|
|
||||||
@@ -35,6 +45,10 @@ export class ScreenShareQualityDialogComponent implements OnInit {
|
|||||||
this.activeQuality.set(this.selectedQuality());
|
this.activeQuality.set(this.selectedQuality());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
portalHostElementToBody(this.host.nativeElement, this.document.body);
|
||||||
|
}
|
||||||
|
|
||||||
chooseQuality(quality: ScreenShareQuality): void {
|
chooseQuality(quality: ScreenShareQuality): void {
|
||||||
this.activeQuality.set(quality);
|
this.activeQuality.set(quality);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
box-sizing: border-box;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||||||
padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
||||||
@@ -133,10 +132,15 @@
|
|||||||
padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Keep border-box on every element (Tailwind preflight does this too). Do not use
|
||||||
|
* `box-sizing: inherit` here — it overrides preflight and lets nested hosts fall back to
|
||||||
|
* content-box, so `w-full` + padding overflows the mobile workspace beside the servers rail.
|
||||||
|
*/
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
box-sizing: inherit;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -498,3 +502,23 @@
|
|||||||
0 0 0 3px hsl(var(--primary) / 0.18),
|
0 0 0 3px hsl(var(--primary) / 0.18),
|
||||||
inset 0 0 0 9999px hsl(var(--primary) / 0.06);
|
inset 0 0 0 9999px hsl(var(--primary) / 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mobile page stacks use Swiper custom elements. Without explicit width caps the slide
|
||||||
|
* can expand to the intrinsic width of nested content instead of the viewport.
|
||||||
|
*/
|
||||||
|
swiper-container {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
swiper-slide {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user