Compare commits

...
2 Commits
Author SHA1 Message Date
myxelium dea114aed0 feat: Response mobile layout support v1
Queue Release Build / prepare (push) Successful in 1m6s
Deploy Web Apps / deploy (push) Successful in 7m35s
Queue Release Build / build-linux (push) Successful in 46m28s
Queue Release Build / finalize (push) Successful in 49s
Queue Release Build / build-windows (push) Successful in 29m57s
2026-05-18 03:03:55 +02:00
myxelium ecb1a4b3a0 refactor: Remove hardcoded values
Queue Release Build / prepare (push) Successful in 2m28s
Deploy Web Apps / deploy (push) Successful in 7m58s
Queue Release Build / build-linux (push) Successful in 46m59s
Queue Release Build / build-windows (push) Successful in 26m2s
Queue Release Build / finalize (push) Successful in 23s
2026-05-17 18:18:14 +02:00
55 changed files with 2570 additions and 442 deletions
@@ -39,7 +39,6 @@ test.describe('Plugin API multi-user runtime', () => {
await closeSettingsModal(scenario.bob.page); await closeSettingsModal(scenario.bob.page);
await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 }); await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 }); await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
}); });
await test.step('Alice opens the plugin soundboard modal and plays a sound to voice', async () => { await test.step('Alice opens the plugin soundboard modal and plays a sound to voice', async () => {
@@ -150,7 +149,7 @@ async function installGrantAndActivatePlugin(page: Page, installFromStore: boole
await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL); await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL);
await page.getByRole('button', { name: 'Add Source' }).click(); await page.getByRole('button', { name: 'Add Source' }).click();
await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 }); await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click(); await page.locator('article', { hasText: PLUGIN_TITLE }).getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
await expect(page.getByRole('dialog', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole('dialog', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Install and Activate' }).click(); await page.getByRole('button', { name: 'Install and Activate' }).click();
await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('Installed')).toBeVisible({ timeout: 20_000 }); await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('Installed')).toBeVisible({ timeout: 20_000 });
+4 -2
View File
@@ -33,9 +33,11 @@ test.describe('Plugin manager UI', () => {
await page.getByLabel('Plugin source manifest URL').fill('http://localhost:4200/plugins/e2e-plugin-source.json'); await page.getByLabel('Plugin source manifest URL').fill('http://localhost:4200/plugins/e2e-plugin-source.json');
await page.getByRole('button', { name: 'Add Source' }).click(); await page.getByRole('button', { name: 'Add Source' }).click();
await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 });
await page.getByRole('button', { name: 'Readme' }).click(); const pluginCard = page.locator('article', { hasText: 'E2E All API Plugin' });
await pluginCard.getByRole('button', { name: 'Readme' }).click();
await expect(page.getByText('Fixture plugin for Playwright coverage.')).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('Fixture plugin for Playwright coverage.')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click(); await pluginCard.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
const installDialog = page.getByRole('dialog', { name: 'E2E All API Plugin' }); const installDialog = page.getByRole('dialog', { name: 'E2E All API Plugin' });
await expect(installDialog).toBeVisible({ timeout: 10_000 }); await expect(installDialog).toBeVisible({ timeout: 10_000 });
+32 -30
View File
@@ -90,10 +90,7 @@ test.describe('Direct private calls', () => {
}) })
.toBeGreaterThan(0); .toBeGreaterThan(0);
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click(); await answerIncomingCall(scenario.bob.page);
await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 }); await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
await expect await expect
@@ -176,10 +173,7 @@ test.describe('Direct private calls', () => {
}) })
.toBeGreaterThan(0); .toBeGreaterThan(0);
await charlie.page.getByRole('button', { name: 'Open private call' }).click(); await answerIncomingCall(charlie.page);
await expect(charlie.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await charlie.page.getByRole('button', { name: 'Join call' }).click();
await expect(charlie.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0); await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0);
await waitForConnectedPeerCount(scenario.alice.page, 2, 45_000); await waitForConnectedPeerCount(scenario.alice.page, 2, 45_000);
@@ -345,12 +339,7 @@ test.describe('Direct private calls', () => {
}) })
.toBeGreaterThan(charliePlayCountBeforeGroupCall); .toBeGreaterThan(charliePlayCountBeforeGroupCall);
await scenario.bob.page await answerIncomingCall(scenario.bob.page);
.getByRole('button', { name: 'Open private call' })
.last()
.click();
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
await expect await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), { .poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000, timeout: 10_000,
@@ -358,12 +347,7 @@ test.describe('Direct private calls', () => {
}) })
.toBe(0); .toBe(0);
await scenario.charlie.page await answerIncomingCall(scenario.charlie.page);
.getByRole('button', { name: 'Open private call' })
.last()
.click();
await scenario.charlie.page.getByRole('button', { name: 'Join call' }).click();
await expect await expect
.poll(async () => await getActiveCallAudioLoops(scenario.charlie.page), { .poll(async () => await getActiveCallAudioLoops(scenario.charlie.page), {
timeout: 10_000, timeout: 10_000,
@@ -378,10 +362,7 @@ test.describe('Direct private calls', () => {
await test.step('Alice starts a private call and Bob joins', async () => { await test.step('Alice starts a private call and Bob joins', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob'); await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click(); await answerIncomingCall(scenario.bob.page);
await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
await waitForConnectedPeerCount(scenario.alice.page, 1, 45_000); await waitForConnectedPeerCount(scenario.alice.page, 1, 45_000);
await waitForConnectedPeerCount(scenario.bob.page, 1, 45_000); await waitForConnectedPeerCount(scenario.bob.page, 1, 45_000);
@@ -413,12 +394,11 @@ test.describe('Direct private calls', () => {
await test.step('Caller leaving before answer clears recipient call route and rail icon', async () => { await test.step('Caller leaving before answer clears recipient call route and rail icon', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob'); await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click(); await expect(incomingCallDialog(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click(); await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 }); await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 }); await expect(incomingCallDialog(scenario.bob.page)).toHaveCount(0, { timeout: 20_000 });
await expect(scenario.bob.page.getByRole('button', { name: 'Open private call' })).toHaveCount(0); await expect(scenario.bob.page.getByRole('button', { name: 'Open private call' })).toHaveCount(0);
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(0); await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(0);
await expect await expect
@@ -431,9 +411,7 @@ test.describe('Direct private calls', () => {
await test.step('Leaving an answered call clears local ringing and returns to DM', async () => { await test.step('Leaving an answered call clears local ringing and returns to DM', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob'); await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click(); await answerIncomingCall(scenario.bob.page);
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click(); await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 }); await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
@@ -633,6 +611,30 @@ async function startCallFromSearch(page: Page, userId: string, displayName: stri
await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 }); await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
} }
function incomingCallDialog(page: Page) {
return page.getByRole('dialog', { name: /is calling/ });
}
async function answerIncomingCall(page: Page): Promise<void> {
const dialog = incomingCallDialog(page);
if (await dialog.isVisible({ timeout: 5_000 }).catch(() => false)) {
await dialog.getByRole('button', { name: 'Answer' }).click();
} else {
await page.getByRole('button', { name: 'Open private call' }).last().click();
await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
const joinButton = page.getByRole('button', { name: 'Join call' });
if (await joinButton.isVisible({ timeout: 5_000 }).catch(() => false)) {
await joinButton.click();
}
}
await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
await expect(page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
}
async function getCurrentUserId(page: Page): Promise<string> { async function getCurrentUserId(page: Page): Promise<string> {
return await page.evaluate(() => localStorage.getItem('metoyou_currentUserId') ?? ''); return await page.evaluate(() => localStorage.getItem('metoyou_currentUserId') ?? '');
} }
+20
View File
@@ -50,6 +50,7 @@
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"simple-peer": "^9.11.1", "simple-peer": "^9.11.1",
"sql.js": "^1.13.0", "sql.js": "^1.13.0",
"swiper": "^12.1.4",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"typeorm": "^0.3.28", "typeorm": "^0.3.28",
"uuid": "^13.0.0", "uuid": "^13.0.0",
@@ -31843,6 +31844,25 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
}, },
"node_modules/swiper": {
"version": "12.1.4",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.4.tgz",
"integrity": "sha512-bihiwoKMOQwW8FfdUbo1DgkVH25E+4ZELIq0oopL1KTKBteLuaTMi/wwFjMxtlhTkk45k3XQ89D1Fvv0spSqBA==",
"funding": [
{
"type": "custom",
"url": "https://sponsors.nolimits4web.com"
},
{
"type": "github",
"url": "https://github.com/sponsors/nolimits4web"
}
],
"license": "MIT",
"engines": {
"node": ">= 4.7.0"
}
},
"node_modules/swrv": { "node_modules/swrv": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/swrv/-/swrv-1.2.0.tgz", "resolved": "https://registry.npmjs.org/swrv/-/swrv-1.2.0.tgz",
+1
View File
@@ -101,6 +101,7 @@
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"simple-peer": "^9.11.1", "simple-peer": "^9.11.1",
"sql.js": "^1.13.0", "sql.js": "^1.13.0",
"swiper": "^12.1.4",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"typeorm": "^0.3.28", "typeorm": "^0.3.28",
"uuid": "^13.0.0", "uuid": "^13.0.0",
+10 -5
View File
@@ -3,14 +3,16 @@
class="workspace-bright-theme relative h-screen overflow-hidden bg-background text-foreground" class="workspace-bright-theme relative h-screen overflow-hidden bg-background text-foreground"
> >
<div <div
class="grid h-full min-h-0 min-w-0 overflow-hidden" class="h-full min-h-0 min-w-0 overflow-hidden"
[ngStyle]="appShellLayoutStyles()" [class.grid]="!isMobile()"
[class.flex]="isMobile()"
[ngStyle]="isMobile() ? null : appShellLayoutStyles()"
> >
<aside <aside
appThemeNode="serversRail" appThemeNode="serversRail"
class="min-h-0 overflow-hidden bg-transparent" class="min-h-0 overflow-hidden bg-transparent"
[class.hidden]="isThemeStudioFullscreen()" [class.hidden]="isThemeStudioFullscreen() || isMobile()"
[ngStyle]="serversRailLayoutStyles()" [ngStyle]="isMobile() ? null : serversRailLayoutStyles()"
> >
<app-servers-rail class="block h-full" /> <app-servers-rail class="block h-full" />
</aside> </aside>
@@ -18,9 +20,12 @@
<main <main
appThemeNode="appWorkspace" appThemeNode="appWorkspace"
class="relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background" class="relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background"
[ngStyle]="appWorkspaceShellStyles()" [class.flex-1]="isMobile()"
[ngStyle]="isMobile() ? null : appWorkspaceShellStyles()"
> >
@if (!isMobile()) {
<app-title-bar class="block shrink-0" /> <app-title-bar class="block shrink-0" />
}
<div class="relative min-h-0 flex-1 overflow-hidden"> <div class="relative min-h-0 flex-1 overflow-hidden">
@if (isThemeStudioFullscreen()) { @if (isThemeStudioFullscreen()) {
+3 -1
View File
@@ -30,7 +30,7 @@ import { ServerDirectoryFacade } from './domains/server-directory';
import { NotificationsFacade } from './domains/notifications'; 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 } from './core/platform'; import { ExternalLinkService, ViewportService } from './core/platform';
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';
@@ -99,6 +99,8 @@ export class App implements OnInit, OnDestroy {
readonly theme = inject(ThemeService); readonly theme = inject(ThemeService);
readonly voiceSession = inject(VoiceSessionFacade); readonly voiceSession = inject(VoiceSessionFacade);
readonly externalLinks = inject(ExternalLinkService); readonly externalLinks = inject(ExternalLinkService);
readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
readonly electronBridge = inject(ElectronBridgeService); readonly electronBridge = inject(ElectronBridgeService);
readonly userStatus = inject(UserStatusService); readonly userStatus = inject(UserStatusService);
readonly gameActivity = inject(GameActivityService); readonly gameActivity = inject(GameActivityService);
+1
View File
@@ -1,2 +1,3 @@
export * from './platform.service'; export * from './platform.service';
export * from './external-link.service'; export * from './external-link.service';
export * from './viewport.service';
@@ -0,0 +1,69 @@
import {
DestroyRef,
Injectable,
NgZone,
computed,
inject,
signal
} from '@angular/core';
/**
* Tracks viewport-level UX traits used to switch between desktop and mobile layouts.
*
* `isMobile` follows the Tailwind `md` breakpoint (max-width: 767.98px). It is the
* single source of truth for whether the UI should render in mobile mode - components
* and templates should use this signal rather than ad-hoc `window.innerWidth` checks.
*
* `isTouch` is a best-effort hint indicating coarse pointer / touch capability. It is
* stable for the lifetime of the page and does not flip when devices are connected.
*/
@Injectable({ providedIn: 'root' })
export class ViewportService {
/** Pixel breakpoint that separates mobile from tablet/desktop layouts. Matches Tailwind `md`. */
static readonly MOBILE_MAX_WIDTH = 767.98;
/** True when the viewport is in mobile mode (width <= MOBILE_MAX_WIDTH). */
readonly isMobile = computed(() => this.isMobileSignal());
/** True when the primary pointer is coarse (touch screen). */
readonly isTouch = computed(() => this.isTouchSignal());
/** Convenience: true when running on a non-mobile viewport. */
readonly isDesktop = computed(() => !this.isMobileSignal());
private readonly zone = inject(NgZone);
private readonly destroyRef = inject(DestroyRef);
private readonly mobileQuery: MediaQueryList | null;
private readonly touchQuery: MediaQueryList | null;
private readonly isMobileSignal = signal(false);
private readonly isTouchSignal = signal(false);
constructor() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
this.mobileQuery = null;
this.touchQuery = null;
return;
}
this.mobileQuery = window.matchMedia(`(max-width: ${ViewportService.MOBILE_MAX_WIDTH}px)`);
this.touchQuery = window.matchMedia('(pointer: coarse)');
this.isMobileSignal.set(this.mobileQuery.matches);
this.isTouchSignal.set(this.touchQuery.matches);
const onMobileChange = (event: MediaQueryListEvent) => {
this.zone.run(() => this.isMobileSignal.set(event.matches));
};
const onTouchChange = (event: MediaQueryListEvent) => {
this.zone.run(() => this.isTouchSignal.set(event.matches));
};
this.mobileQuery.addEventListener('change', onMobileChange);
this.touchQuery.addEventListener('change', onTouchChange);
this.destroyRef.onDestroy(() => {
this.mobileQuery?.removeEventListener('change', onMobileChange);
this.touchQuery?.removeEventListener('change', onTouchChange);
});
}
}
@@ -40,6 +40,17 @@
</div> </div>
@if (showKlipyGifPicker()) { @if (showKlipyGifPicker()) {
@if (isMobile()) {
<app-bottom-sheet (dismissed)="closeKlipyGifPicker()">
<div appThemeNode="chatGifPickerSurface">
<app-klipy-gif-picker
[signalSource]="currentRoom()"
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>
</div>
</app-bottom-sheet>
} @else {
<div <div
class="fixed inset-0 z-[89]" class="fixed inset-0 z-[89]"
(click)="closeKlipyGifPicker()" (click)="closeKlipyGifPicker()"
@@ -66,6 +77,7 @@
</div> </div>
</div> </div>
} }
}
<app-chat-message-overlays <app-chat-message-overlays
[lightboxAttachment]="lightboxAttachment()" [lightboxAttachment]="lightboxAttachment()"
@@ -10,6 +10,8 @@ import {
} from '@angular/core'; } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform';
import { BottomSheetComponent } from '../../../../shared';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { Attachment, AttachmentFacade } from '../../../attachment'; import { Attachment, AttachmentFacade } from '../../../attachment';
import { KlipyGif, KlipyService } from '../../application/services/klipy.service'; import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
@@ -45,6 +47,7 @@ import {
KlipyGifPickerComponent, KlipyGifPickerComponent,
ChatMessageListComponent, ChatMessageListComponent,
ChatMessageOverlaysComponent, ChatMessageOverlaysComponent,
BottomSheetComponent,
ThemeNodeDirective ThemeNodeDirective
], ],
templateUrl: './chat-messages.component.html', templateUrl: './chat-messages.component.html',
@@ -59,6 +62,9 @@ export class ChatMessagesComponent {
private readonly webrtc = inject(RealtimeSessionFacade); private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
readonly allMessages = this.store.selectSignal(selectAllMessages); readonly allMessages = this.store.selectSignal(selectAllMessages);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
@@ -6,6 +6,10 @@
[attr.data-message-id]="msg.id" [attr.data-message-id]="msg.id"
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30" class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
[class.opacity-50]="msg.isDeleted" [class.opacity-50]="msg.isDeleted"
(touchstart)="onMessageTouchStart($event)"
(touchend)="onMessageTouchEnd()"
(touchmove)="onMessageTouchEnd()"
(touchcancel)="onMessageTouchEnd()"
> >
<div <div
appThemeNode="chatMessageAvatar" appThemeNode="chatMessageAvatar"
@@ -469,7 +473,7 @@
} }
</div> </div>
@if (!msg.isDeleted) { @if (!msg.isDeleted && !isMobile()) {
<div <div
appThemeNode="chatMessageActions" appThemeNode="chatMessageActions"
class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100" class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
@@ -534,4 +538,83 @@
} }
</div> </div>
} }
<ng-template #mobileSheetTpl>
<app-bottom-sheet
title="Message"
ariaLabel="Message actions"
(dismissed)="closeMobileActions()"
>
<div class="flex flex-col py-1">
<div class="px-3 pb-2 pt-1">
<p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">React</p>
<div class="mt-2 grid grid-cols-8 gap-1">
@for (emoji of commonEmojis; track emoji) {
<button
type="button"
class="rounded p-1 text-xl transition-colors hover:bg-secondary"
(click)="onMobileReact(emoji)"
>
{{ emoji }}
</button>
}
</div>
</div>
<div class="my-1 h-px bg-border"></div>
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-foreground transition-colors hover:bg-secondary"
(click)="onMobileReply()"
>
<ng-icon
name="lucideReply"
class="h-5 w-5 text-muted-foreground"
/>
<span>Reply</span>
</button>
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-foreground transition-colors hover:bg-secondary"
(click)="onMobileCopy()"
>
<ng-icon
name="lucideCopy"
class="h-5 w-5 text-muted-foreground"
/>
<span>Copy message content</span>
</button>
@if (isOwnMessage()) {
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-foreground transition-colors hover:bg-secondary"
(click)="onMobileEdit()"
>
<ng-icon
name="lucideEdit"
class="h-5 w-5 text-muted-foreground"
/>
<span>Edit</span>
</button>
}
@if (isOwnMessage() || isAdmin()) {
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-destructive transition-colors hover:bg-destructive/10"
(click)="onMobileDelete()"
>
<ng-icon
name="lucideTrash2"
class="h-5 w-5"
/>
<span>Delete</span>
</button>
}
</div>
</app-bottom-sheet>
</ng-template>
</div> </div>
@@ -8,15 +8,21 @@ import {
effect, effect,
inject, inject,
input, input,
OnDestroy,
output, output,
signal, signal,
ViewChild TemplateRef,
ViewChild,
ViewContainerRef
} from '@angular/core'; } from '@angular/core';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import {
lucideCheck, lucideCheck,
lucideCopy,
lucideDownload, lucideDownload,
lucideEdit, lucideEdit,
lucideExpand, lucideExpand,
@@ -34,7 +40,7 @@ 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 { PlatformService } 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 { import {
ExperimentalMediaSettingsService ExperimentalMediaSettingsService
@@ -52,6 +58,7 @@ import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin
import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins'; import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins';
import { import {
BottomSheetComponent,
ChatAudioPlayerComponent, ChatAudioPlayerComponent,
ChatVideoPlayerComponent, ChatVideoPlayerComponent,
ProfileCardService, ProfileCardService,
@@ -125,11 +132,13 @@ interface MissingPluginEmbedFallback {
UserAvatarComponent, UserAvatarComponent,
PluginRenderHostComponent, PluginRenderHostComponent,
ExperimentalVlcPlayerComponent, ExperimentalVlcPlayerComponent,
ThemeNodeDirective ThemeNodeDirective,
BottomSheetComponent
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideCheck, lucideCheck,
lucideCopy,
lucideDownload, lucideDownload,
lucideEdit, lucideEdit,
lucideExpand, lucideExpand,
@@ -148,8 +157,9 @@ interface MissingPluginEmbedFallback {
style: 'display: contents;' style: 'display: contents;'
} }
}) })
export class ChatMessageItemComponent { export class ChatMessageItemComponent implements OnDestroy {
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>; @ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
@ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>;
private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
@@ -160,6 +170,13 @@ export class ChatMessageItemComponent {
private readonly experimentalMedia = inject(ExperimentalMediaSettingsService); private readonly experimentalMedia = inject(ExperimentalMediaSettingsService);
private readonly profileCard = inject(ProfileCardService); private readonly profileCard = inject(ProfileCardService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly viewport = inject(ViewportService);
private readonly overlay = inject(Overlay);
private readonly viewContainerRef = inject(ViewContainerRef);
private mobileSheetOverlayRef: OverlayRef | null = null;
private longPressTimer: number | null = null;
readonly isMobile = this.viewport.isMobile;
readonly mobileSheetOpen = signal(false);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated()); private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
private readonly experimentalPlayerAttachmentId = signal<string | null>(null); private readonly experimentalPlayerAttachmentId = signal<string | null>(null);
private readonly mediaSupportCache = new Map<string, boolean>(); private readonly mediaSupportCache = new Map<string, boolean>();
@@ -360,6 +377,116 @@ export class ChatMessageItemComponent {
this.deleteRequested.emit(this.message()); this.deleteRequested.emit(this.message());
} }
onMessageTouchStart(event: TouchEvent): void {
if (!this.isMobile() || this.message().isDeleted) {
return;
}
if (event.touches.length !== 1) {
this.clearLongPressTimer();
return;
}
if (this.isEditableTarget(event.target)) {
this.clearLongPressTimer();
return;
}
this.clearLongPressTimer();
this.longPressTimer = window.setTimeout(() => {
this.longPressTimer = null;
this.openMobileSheet();
}, 500);
}
onMessageTouchEnd(): void {
this.clearLongPressTimer();
}
private clearLongPressTimer(): void {
if (this.longPressTimer !== null) {
window.clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
}
private isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) {
return false;
}
return target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !== null;
}
closeMobileActions(): void {
this.detachMobileSheet();
}
private openMobileSheet(): void {
if (this.mobileSheetOverlayRef || !this.mobileSheetTpl) {
this.mobileSheetOpen.set(true);
return;
}
const overlayRef = this.overlay.create({
positionStrategy: this.overlay.position().global(),
scrollStrategy: this.overlay.scrollStrategies.block(),
hasBackdrop: false,
panelClass: 'metoyou-chat-actions-sheet-pane'
});
const portal = new TemplatePortal(this.mobileSheetTpl, this.viewContainerRef);
overlayRef.attach(portal);
this.mobileSheetOverlayRef = overlayRef;
this.mobileSheetOpen.set(true);
}
private detachMobileSheet(): void {
this.mobileSheetOpen.set(false);
if (this.mobileSheetOverlayRef) {
this.mobileSheetOverlayRef.dispose();
this.mobileSheetOverlayRef = null;
}
}
ngOnDestroy(): void {
this.clearLongPressTimer();
this.detachMobileSheet();
}
onMobileReact(emoji: string): void {
this.addReaction(emoji);
this.closeMobileActions();
}
onMobileReply(): void {
this.requestReply();
this.closeMobileActions();
}
onMobileEdit(): void {
this.startEdit();
this.closeMobileActions();
}
onMobileDelete(): void {
this.requestDelete();
this.closeMobileActions();
}
async onMobileCopy(): Promise<void> {
const text = this.message().content;
try {
await navigator.clipboard.writeText(text);
} catch {
// Clipboard API unavailable; silently ignore.
}
this.closeMobileActions();
}
removeEmbed(url: string): void { removeEmbed(url: string): void {
this.embedRemoved.emit({ this.embedRemoved.emit({
messageId: this.message().id, messageId: this.message().id,
@@ -5,9 +5,10 @@ import {
input input
} from '@angular/core'; } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
import { environment } from '../../../../../../../../environments/environment';
import { extractYoutubeVideoId } from '../../../../../domain/rules/link-embed.rules'; import { extractYoutubeVideoId } from '../../../../../domain/rules/link-embed.rules';
const YOUTUBE_EMBED_FALLBACK_ORIGIN = 'https://toju.app'; const YOUTUBE_EMBED_FALLBACK_ORIGIN = environment.publicOrigin;
function resolveYoutubeClientOrigin(): string { function resolveYoutubeClientOrigin(): string {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -4,6 +4,7 @@
aria-label="KLIPY GIF picker" aria-label="KLIPY GIF picker"
style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)" style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)"
> >
@if (!isMobile()) {
<div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4"> <div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4">
<div> <div>
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div> <div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
@@ -25,6 +26,7 @@
/> />
</button> </button>
</div> </div>
}
<div class="border-b border-border/70 bg-secondary/10 px-5 py-4"> <div class="border-b border-border/70 bg-secondary/10 px-5 py-4">
<label class="relative block"> <label class="relative block">
@@ -37,7 +39,7 @@
type="text" type="text"
[ngModel]="searchQuery" [ngModel]="searchQuery"
(ngModelChange)="onSearchQueryChanged($event)" (ngModelChange)="onSearchQueryChanged($event)"
placeholder="Search KLIPY" [placeholder]="isMobile() ? 'Search KLIPY and add a gif to the chat' : 'Search KLIPY'"
class="relative z-0 w-full rounded-xl border border-border/80 bg-background/70 px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary" class="relative z-0 w-full rounded-xl border border-border/80 bg-background/70 px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary"
/> />
</label> </label>
@@ -80,12 +82,14 @@
</div> </div>
</div> </div>
} @else { } @else {
<div class="columns-[12rem] gap-4"> <div [class]="isMobile() ? 'grid grid-cols-2 gap-2' : 'columns-[12rem] gap-4'">
@for (gif of results(); track gif.id) { @for (gif of results(); track gif.id) {
<button <button
type="button" type="button"
(click)="selectGif(gif)" (click)="selectGif(gif)"
class="group mx-auto mb-4 inline-block w-full max-w-[15.5rem] break-inside-avoid align-top overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30" [class]="isMobile()
? 'group block w-full overflow-hidden rounded-xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30'
: 'group mx-auto mb-4 inline-block w-full max-w-[15.5rem] break-inside-avoid align-top overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30'"
> >
<div <div
class="relative flex items-center justify-center overflow-hidden bg-secondary/30" class="relative flex items-center justify-center overflow-hidden bg-secondary/30"
@@ -104,18 +108,42 @@
KLIPY KLIPY
</span> </span>
</div> </div>
@if (!isMobile()) {
<div class="px-3 py-2"> <div class="px-3 py-2">
<p class="truncate text-xs font-medium text-foreground"> <p class="truncate text-xs font-medium text-foreground">
{{ gif.title || 'KLIPY GIF' }} {{ gif.title || 'KLIPY GIF' }}
</p> </p>
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p> <p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
</div> </div>
}
</button> </button>
} }
</div> </div>
@if (isMobile() && hasNext()) {
<div class="mt-3 flex justify-center">
<button
type="button"
(click)="loadMore()"
[disabled]="loading()"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border/80 bg-background/60 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
[attr.aria-label]="loading() ? 'Loading more GIFs' : 'Load more GIFs'"
>
@if (loading()) {
<span class="h-4 w-4 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
} @else {
<ng-icon
name="lucideChevronDown"
class="h-5 w-5"
/>
}
</button>
</div>
}
} }
</div> </div>
@if (!isMobile()) {
<div class="flex items-center justify-between gap-4 border-t border-border/70 bg-secondary/10 px-5 py-4"> <div class="flex items-center justify-between gap-4 border-t border-border/70 bg-secondary/10 px-5 py-4">
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p> <p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p>
@@ -130,4 +158,5 @@
</button> </button>
} }
</div> </div>
}
</div> </div>
@@ -17,6 +17,7 @@ import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import {
lucideChevronDown,
lucideImage, lucideImage,
lucideSearch, lucideSearch,
lucideX lucideX
@@ -24,6 +25,7 @@ import {
import { KlipyGif, KlipyService } from '../../application/services/klipy.service'; import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import type { RoomSignalSourceInput } from '../../../server-directory'; import type { RoomSignalSourceInput } from '../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive'; import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
import { ViewportService } from '../../../../core/platform';
const KLIPY_CARD_MIN_WIDTH = 140; const KLIPY_CARD_MIN_WIDTH = 140;
const KLIPY_CARD_MAX_WIDTH = 248; const KLIPY_CARD_MAX_WIDTH = 248;
@@ -42,6 +44,7 @@ const KLIPY_CARD_FALLBACK_SIZE = 160;
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideChevronDown,
lucideImage, lucideImage,
lucideSearch, lucideSearch,
lucideX lucideX
@@ -58,6 +61,8 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>; @ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
private currentPage = 1; private currentPage = 1;
private searchTimer: ReturnType<typeof setTimeout> | null = null; private searchTimer: ReturnType<typeof setTimeout> | null = null;
private requestId = 0; private requestId = 0;
@@ -97,6 +97,16 @@
</div> </div>
@if (showGifPicker()) { @if (showGifPicker()) {
@if (isMobile()) {
<app-bottom-sheet (dismissed)="closeGifPicker()">
<div appThemeNode="chatGifPickerSurface">
<app-klipy-gif-picker
(gifSelected)="handleGifSelected($event)"
(closed)="closeGifPicker()"
/>
</div>
</app-bottom-sheet>
} @else {
<div <div
class="fixed inset-0 z-[89]" class="fixed inset-0 z-[89]"
tabindex="0" tabindex="0"
@@ -121,6 +131,7 @@
</div> </div>
</div> </div>
} }
}
<app-chat-message-overlays <app-chat-message-overlays
[lightboxAttachment]="lightboxAttachment()" [lightboxAttachment]="lightboxAttachment()"
@@ -15,7 +15,8 @@ import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop'; import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs'; import { map } from 'rxjs';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { UserAvatarComponent } from '../../../../shared'; import { ViewportService } from '../../../../core/platform';
import { BottomSheetComponent, UserAvatarComponent } from '../../../../shared';
import { DirectCallService } from '../../../direct-call'; import { DirectCallService } from '../../../direct-call';
import { Attachment, AttachmentFacade } from '../../../attachment'; import { Attachment, AttachmentFacade } from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme'; import { ThemeNodeDirective } from '../../../theme';
@@ -61,6 +62,7 @@ interface DmStatusLabel {
ChatMessageListComponent, ChatMessageListComponent,
ChatMessageOverlaysComponent, ChatMessageOverlaysComponent,
KlipyGifPickerComponent, KlipyGifPickerComponent,
BottomSheetComponent,
NgIcon, NgIcon,
ThemeNodeDirective, ThemeNodeDirective,
UserAvatarComponent UserAvatarComponent
@@ -80,8 +82,10 @@ export class DmChatComponent {
private readonly attachments = inject(AttachmentFacade); private readonly attachments = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly linkMetadata = inject(LinkMetadataService); private readonly linkMetadata = inject(LinkMetadataService);
private readonly viewport = inject(ViewportService);
private readonly metadataRequestKeys = new Set<string>(); private readonly metadataRequestKeys = new Set<string>();
private openedConversationId: string | null = null; private openedConversationId: string | null = null;
readonly isMobile = this.viewport.isMobile;
readonly directCalls = inject(DirectCallService); readonly directCalls = inject(DirectCallService);
readonly directMessages = inject(DirectMessageService); readonly directMessages = inject(DirectMessageService);
readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly currentUser = this.store.selectSignal(selectCurrentUser);
@@ -1,6 +1,6 @@
<main <main
appThemeNode="dmChatPanel" appThemeNode="dmChatPanel"
class="relative min-h-0 min-w-0 overflow-hidden bg-background" class="relative h-full min-h-0 w-full min-w-0 overflow-hidden bg-background"
[ngStyle]="chatPanelStyles()" [ngStyle]="chatPanelStyles()"
> >
<app-dm-chat /> <app-dm-chat />
@@ -1,3 +1,47 @@
@if (isMobile()) {
<!-- Mobile: Swiper-driven page stack (conversations -> chat) -->
<swiper-container
#swiperEl
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">
<app-dm-conversations-panel class="block h-full w-full" />
</div>
</div>
</swiper-slide>
<swiper-slide class="block h-full w-full">
<div class="flex h-full w-full min-h-0 flex-col overflow-hidden">
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-card px-3 py-2">
<button
type="button"
(click)="setMobilePage('conversations')"
class="grid h-11 w-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Back to conversations"
>
<ng-icon
name="lucideChevronLeft"
class="h-5 w-5"
/>
</button>
<p class="truncate text-sm font-semibold text-foreground">Direct messages</p>
</div>
<div class="min-h-0 flex-1 overflow-hidden">
<app-dm-chat-panel class="block h-full w-full" />
</div>
</div>
</swiper-slide>
</swiper-container>
} @else {
<!-- Desktop: theme-driven 2-pane grid layout -->
<div <div
class="grid h-full min-h-0 overflow-hidden bg-background" class="grid h-full min-h-0 overflow-hidden bg-background"
[ngStyle]="layoutStyles()" [ngStyle]="layoutStyles()"
@@ -5,3 +49,5 @@
<app-dm-conversations-panel /> <app-dm-conversations-panel />
<app-dm-chat-panel /> <app-dm-chat-panel />
</div> </div>
}
@@ -1,46 +1,99 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { import {
CUSTOM_ELEMENTS_SCHEMA,
Component, Component,
ElementRef,
NgZone,
OnDestroy,
computed, computed,
effect, effect,
inject, inject,
OnDestroy signal,
viewChild
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop'; import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs'; import { map } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronLeft } from '@ng-icons/lucide';
import { ServersRailComponent } from '../../../../features/servers/servers-rail/servers-rail.component';
import { ViewportService } from '../../../../core/platform';
import { ThemeService } from '../../../theme'; import { ThemeService } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service'; import { DirectMessageService } from '../../application/services/direct-message.service';
import { DmChatPanelComponent } from './dm-chat-panel.component'; import { DmChatPanelComponent } from './dm-chat-panel.component';
import { DmConversationsPanelComponent } from './dm-conversations-panel.component'; import { DmConversationsPanelComponent } from './dm-conversations-panel.component';
/** Mobile-only page identifier within the DM workspace flow. */
export type DmWorkspaceMobilePage = 'conversations' | 'chat';
const PAGE_TO_INDEX: Record<DmWorkspaceMobilePage, number> = {
conversations: 0,
chat: 1
};
const INDEX_TO_PAGE: DmWorkspaceMobilePage[] = ['conversations', 'chat'];
interface SwiperElement extends HTMLElement {
swiper?: { activeIndex: number; slideTo: (index: number, speed?: number) => void };
}
@Component({ @Component({
selector: 'app-dm-workspace', selector: 'app-dm-workspace',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
NgIcon,
DmChatPanelComponent, DmChatPanelComponent,
DmConversationsPanelComponent DmConversationsPanelComponent,
ServersRailComponent
], ],
viewProviders: [provideIcons({ lucideChevronLeft })],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './dm-workspace.component.html' templateUrl: './dm-workspace.component.html'
}) })
export class DmWorkspaceComponent implements OnDestroy { export class DmWorkspaceComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly theme = inject(ThemeService); private readonly theme = inject(ThemeService);
private readonly viewport = inject(ViewportService);
private readonly zone = inject(NgZone);
private lastSeenConversationId: string | null = null;
private swiperListenerAttached: SwiperElement | null = null;
readonly directMessages = inject(DirectMessageService); readonly directMessages = inject(DirectMessageService);
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')
}); });
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout')); readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
readonly isMobile = this.viewport.isMobile;
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
readonly mobilePage = signal<DmWorkspaceMobilePage>('conversations');
constructor() { constructor() {
effect(() => { effect(() => {
const conversationId = this.routeConversationId(); const conversationId = this.routeConversationId();
const isMobile = this.isMobile();
if (conversationId) { if (conversationId) {
void this.directMessages.openConversation(conversationId); void this.directMessages.openConversation(conversationId);
// Only auto-advance to the chat page when the conversation actually changes.
// Without this, pressing Back to the conversations list immediately bounces
// us forward again because the conversation id is still the same.
if (isMobile && conversationId !== this.lastSeenConversationId) {
this.mobilePage.set('chat');
}
this.lastSeenConversationId = conversationId;
return;
}
this.lastSeenConversationId = null;
// On mobile, stay on the conversations list and let the user pick one explicitly.
if (isMobile) {
this.mobilePage.set('conversations');
return; return;
} }
@@ -50,9 +103,55 @@ export class DmWorkspaceComponent implements OnDestroy {
void this.router.navigate(['/dm', firstConversation.id], { replaceUrl: true }); void this.router.navigate(['/dm', firstConversation.id], { replaceUrl: true });
} }
}); });
// Mirror `mobilePage` into the Swiper instance so route-driven page changes and the
// header back button actually slide the carousel.
effect(() => {
const el = this.swiperRef()?.nativeElement;
const targetIndex = PAGE_TO_INDEX[this.mobilePage()];
if (el?.swiper && el.swiper.activeIndex !== targetIndex) {
el.swiper.slideTo(targetIndex);
}
});
// Bridge Swiper's slidechange event back into `mobilePage`.
effect((onCleanup) => {
const el = this.swiperRef()?.nativeElement;
if (!el || el === this.swiperListenerAttached) {
return;
}
const handler = (event: Event) => {
const detail = (event as CustomEvent).detail;
const swiper = Array.isArray(detail) ? detail[0] : detail;
const index = swiper?.activeIndex ?? 0;
const page = INDEX_TO_PAGE[index] ?? 'conversations';
this.zone.run(() => this.mobilePage.set(page));
};
el.addEventListener('swiperslidechange', handler);
this.swiperListenerAttached = el;
onCleanup(() => {
el.removeEventListener('swiperslidechange', handler);
if (this.swiperListenerAttached === el) {
this.swiperListenerAttached = null;
}
});
});
}
/** Set the active mobile page. No-op on desktop. */
setMobilePage(page: DmWorkspaceMobilePage): void {
this.mobilePage.set(page);
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.directMessages.closeConversationView(this.routeConversationId()); this.directMessages.closeConversationView(this.routeConversationId());
} }
} }
@@ -1,5 +1,6 @@
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { environment } from '../../../../../environments/environment';
export interface ExperimentalVlcPlayerOptions { export interface ExperimentalVlcPlayerOptions {
container: HTMLElement; container: HTMLElement;
@@ -23,7 +24,7 @@ declare global {
} }
} }
const VLC_RUNTIME_SCRIPT_URL = '/vlcjs/metoyou-vlc-player.js'; const VLC_RUNTIME_SCRIPT_URL = environment.experimentalMedia.vlcRuntimeScriptUrl;
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ExperimentalVlcRuntimeService { export class ExperimentalVlcRuntimeService {
@@ -60,6 +61,7 @@ export class ExperimentalVlcRuntimeService {
script.src = VLC_RUNTIME_SCRIPT_URL; script.src = VLC_RUNTIME_SCRIPT_URL;
script.async = true; script.async = true;
script.onload = () => { script.onload = () => {
const runtime = this.document.defaultView?.MetoYouVlcJs; const runtime = this.document.defaultView?.MetoYouVlcJs;
@@ -70,6 +72,7 @@ export class ExperimentalVlcRuntimeService {
resolve(runtime); resolve(runtime);
}; };
script.onerror = () => reject(new Error(`The experimental VLC.js runtime was not found at ${VLC_RUNTIME_SCRIPT_URL}.`)); script.onerror = () => reject(new Error(`The experimental VLC.js runtime was not found at ${VLC_RUNTIME_SCRIPT_URL}.`));
this.document.head.appendChild(script); this.document.head.appendChild(script);
+1 -1
View File
@@ -10,7 +10,7 @@ The standalone plugin store is available from the title bar Plugins button, the
The plugin manager UI is split between Settings -> Client plugins for global client plugins and Settings -> Server -> Server plugins for chat-server plugins. The two pages filter by manifest `scope` and include installed plugins, capability grant toggles, per-plugin activate/reload/unload actions, runtime logs, extension-point counts, server requirements, generated settings, and docs. The plugin manager UI is split between Settings -> Client plugins for global client plugins and Settings -> Server -> Server plugins for chat-server plugins. The two pages filter by manifest `scope` and include installed plugins, capability grant toggles, per-plugin activate/reload/unload actions, runtime logs, extension-point counts, server requirements, generated settings, and docs.
The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, `bundle`/`bundleUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. When a different user joins that server, required plugins block the join until the user accepts the download; optional and recommended plugins are offered as selectable downloads and can be skipped. Once a server has local server-scoped plugins installed, the title bar shows a compact Server plugins button for that server. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client. The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. New users and legacy source lists are seeded with the official Toju plugin repository at `https://raw.githubusercontent.com/Myxelium/official-toju-plugin-repository/refs/heads/master/plugin-source.json`, while source removal remains persisted after migration. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, `bundle`/`bundleUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. When a different user joins that server, required plugins block the join until the user accepts the download; optional and recommended plugins are offered as selectable downloads and can be skipped. Once a server has local server-scoped plugins installed, the title bar shows a compact Server plugins button for that server. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client.
Store plugins can be published as cached browser bundles by adding `bundle` or `bundleUrl` to the source manifest entry. The bundle is a browser-safe ESM JavaScript file. During install, Electron downloads the bundle into app data under `plugin-bundles/<plugin-id>/<version>/main.js`, writes a cached manifest next to it, and registers the plugin from that local cached manifest path. If no bundle URL is provided and the manifest entrypoint is a relative browser module, Electron caches that entrypoint path instead. Browser-only clients still load directly from the source URL; the renderer CSP allows HTTP(S), `file://` via local cache, and blob-backed plugin entrypoints. Saved store sources refresh during app bootstrap; when a source advertises a higher version for an installed plugin, the store attempts to update the local cached bundle and persisted install metadata automatically. Store plugins can be published as cached browser bundles by adding `bundle` or `bundleUrl` to the source manifest entry. The bundle is a browser-safe ESM JavaScript file. During install, Electron downloads the bundle into app data under `plugin-bundles/<plugin-id>/<version>/main.js`, writes a cached manifest next to it, and registers the plugin from that local cached manifest path. If no bundle URL is provided and the manifest entrypoint is a relative browser module, Electron caches that entrypoint path instead. Browser-only clients still load directly from the source URL; the renderer CSP allows HTTP(S), `file://` via local cache, and blob-backed plugin entrypoints. Saved store sources refresh during app bootstrap; when a source advertises a higher version for an installed plugin, the store attempts to update the local cached bundle and persisted install metadata automatically.
@@ -1,4 +1,5 @@
import { Injector } from '@angular/core'; import { Injector } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import type { TojuPluginManifest } from '../../../../shared-kernel'; import type { TojuPluginManifest } from '../../../../shared-kernel';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PluginStoreService } from './plugin-store.service'; import { PluginStoreService } from './plugin-store.service';
@@ -6,8 +7,11 @@ import { PluginHostService } from './plugin-host.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service'; import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginRequirementService } from './plugin-requirement.service'; import { PluginRequirementService } from './plugin-requirement.service';
import { PluginRegistryService } from './plugin-registry.service'; import { PluginRegistryService } from './plugin-registry.service';
import { PluginCapabilityService } from './plugin-capability.service';
import type { PluginStoreEntry } from '../../domain/models/plugin-store.models'; import type { PluginStoreEntry } from '../../domain/models/plugin-store.models';
const OFFICIAL_PLUGIN_SOURCE_URL = environment.pluginStore.defaultSourceUrls[0];
describe('PluginStoreService', () => { describe('PluginStoreService', () => {
let fetchMock: ReturnType<typeof vi.fn>; let fetchMock: ReturnType<typeof vi.fn>;
let registerLocalManifest: ReturnType<typeof vi.fn>; let registerLocalManifest: ReturnType<typeof vi.fn>;
@@ -36,7 +40,8 @@ describe('PluginStoreService', () => {
}); });
it('loads plugin entries from source manifests and resolves relative links', async () => { it('loads plugin entries from source manifests and resolves relative links', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ mockFetchResponses(fetchMock, {
'https://plugins.example.test/index.json': jsonResponse({
plugins: [ plugins: [
{ {
author: 'Ada Example', author: 'Ada Example',
@@ -51,16 +56,17 @@ describe('PluginStoreService', () => {
} }
], ],
title: 'Example Plugins' title: 'Example Plugins'
})); })
});
const service = createService(registerLocalManifest, unregister); const service = createService(registerLocalManifest, unregister);
await service.addSourceUrl('https://plugins.example.test/index.json#latest'); await service.addSourceUrl('https://plugins.example.test/index.json#latest');
expect(service.sourceUrls()).toEqual(['https://plugins.example.test/index.json']); expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'https://plugins.example.test/index.json']);
expect(service.sources()[0]?.title).toBe('Example Plugins');
expect(service.availablePlugins()).toEqual([ expect(service.sources().some((source) => source.title === 'Example Plugins')).toBe(true);
expect.objectContaining({ expect(service.availablePlugins()).toContainEqual(expect.objectContaining({
author: 'Ada Example', author: 'Ada Example',
githubUrl: 'https://github.com/example/better-channels', githubUrl: 'https://github.com/example/better-channels',
id: 'example.better-channels', id: 'example.better-channels',
@@ -70,8 +76,43 @@ describe('PluginStoreService', () => {
sourceTitle: 'Example Plugins', sourceTitle: 'Example Plugins',
title: 'Better Channels', title: 'Better Channels',
version: '1.2.0' version: '1.2.0'
}));
});
it('seeds the official plugin repository for new users', () => {
const service = createService(registerLocalManifest, unregister);
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL]);
expect(fetchMock).toHaveBeenCalledWith(
OFFICIAL_PLUGIN_SOURCE_URL,
expect.objectContaining({
headers: { Accept: 'application/json' }
}) })
]); );
});
it('adds the official plugin repository when loading legacy source lists', () => {
storage.setItem('metoyou_plugin_store', JSON.stringify({
installedPlugins: [],
schemaVersion: 1,
sourceUrls: ['https://plugins.example.test/index.json']
}));
const service = createService(registerLocalManifest, unregister);
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'https://plugins.example.test/index.json']);
});
it('keeps user-removed default sources removed after schema migration', () => {
storage.setItem('metoyou_plugin_store', JSON.stringify({
installedPlugins: [],
schemaVersion: 2,
sourceUrls: ['https://plugins.example.test/index.json']
}));
const service = createService(registerLocalManifest, unregister);
expect(service.sourceUrls()).toEqual(['https://plugins.example.test/index.json']);
}); });
it('accepts local source manifest paths and resolves relative file links', async () => { it('accepts local source manifest paths and resolves relative file links', async () => {
@@ -94,9 +135,10 @@ describe('PluginStoreService', () => {
await service.addSourceUrl('/home/ludde/Desktop/TestPlugin/plugin-source.json'); await service.addSourceUrl('/home/ludde/Desktop/TestPlugin/plugin-source.json');
expect(fetchMock).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json', expect.anything());
expect(readFile).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json'); expect(readFile).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json');
expect(service.sourceUrls()).toEqual(['file:///home/ludde/Desktop/TestPlugin/plugin-source.json']); expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'file:///home/ludde/Desktop/TestPlugin/plugin-source.json']);
expect(service.availablePlugins()).toEqual([ expect(service.availablePlugins()).toEqual([
expect.objectContaining({ expect.objectContaining({
id: 'example.local-plugin', id: 'example.local-plugin',
@@ -112,7 +154,9 @@ describe('PluginStoreService', () => {
const manifest = createManifest({ version: '1.0.0' }); const manifest = createManifest({ version: '1.0.0' });
const plugin = createStoreEntry({ version: '1.0.0' }); const plugin = createStoreEntry({ version: '1.0.0' });
fetchMock.mockResolvedValueOnce(jsonResponse(manifest)); mockFetchResponses(fetchMock, {
[plugin.installUrl ?? '']: jsonResponse(manifest)
});
const service = createService(registerLocalManifest, unregister); const service = createService(registerLocalManifest, unregister);
@@ -141,9 +185,10 @@ describe('PluginStoreService', () => {
writeFile: vi.fn(async () => true) writeFile: vi.fn(async () => true)
}; };
fetchMock mockFetchResponses(fetchMock, {
.mockResolvedValueOnce(jsonResponse(manifest)) [plugin.bundleUrl ?? '']: textResponse('export function activate() {}'),
.mockResolvedValueOnce(textResponse('export function activate() {}')); [plugin.installUrl ?? '']: jsonResponse(manifest)
});
const service = createService(registerLocalManifest, unregister, electronApi); const service = createService(registerLocalManifest, unregister, electronApi);
@@ -171,7 +216,9 @@ describe('PluginStoreService', () => {
it('loads plugin readmes as markdown text', async () => { it('loads plugin readmes as markdown text', async () => {
const plugin = createStoreEntry({ readmeUrl: 'https://plugins.example.test/better/README.md' }); const plugin = createStoreEntry({ readmeUrl: 'https://plugins.example.test/better/README.md' });
fetchMock.mockResolvedValueOnce(textResponse('# Better Channels')); mockFetchResponses(fetchMock, {
[plugin.readmeUrl ?? '']: textResponse('# Better Channels')
});
const service = createService(registerLocalManifest, unregister); const service = createService(registerLocalManifest, unregister);
const readme = await service.loadReadme(plugin); const readme = await service.loadReadme(plugin);
@@ -185,6 +232,21 @@ describe('PluginStoreService', () => {
}); });
}); });
function mockFetchResponses(fetchMock: ReturnType<typeof vi.fn>, responses: Record<string, Response>): void {
fetchMock.mockImplementation(async (url: RequestInfo | URL) => {
const requestUrl = typeof url === 'string'
? url
: url instanceof URL
? url.toString()
: url.url;
return responses[requestUrl]
?? (requestUrl === OFFICIAL_PLUGIN_SOURCE_URL
? jsonResponse({ plugins: [], title: 'Official Toju Plugins' })
: textResponse(''));
});
}
function createService( function createService(
registerLocalManifest: ReturnType<typeof vi.fn>, registerLocalManifest: ReturnType<typeof vi.fn>,
unregister: ReturnType<typeof vi.fn>, unregister: ReturnType<typeof vi.fn>,
@@ -220,6 +282,12 @@ function createService(
writeJson: vi.fn(async () => undefined) writeJson: vi.fn(async () => undefined)
} }
}, },
{
provide: PluginCapabilityService,
useValue: {
grantAll: vi.fn()
}
},
{ {
provide: PluginRegistryService, provide: PluginRegistryService,
useValue: { unregister } useValue: { unregister }
@@ -10,6 +10,7 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { environment } from '../../../../../environments/environment';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage'; import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
@@ -44,14 +45,11 @@ import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginRequirementService } from './plugin-requirement.service'; import { PluginRequirementService } from './plugin-requirement.service';
import { PluginRegistryService } from './plugin-registry.service'; import { PluginRegistryService } from './plugin-registry.service';
const STORE_SCHEMA_VERSION = 1; const STORE_SCHEMA_VERSION = 2;
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store'; const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
const STORAGE_KEY_SERVER_PLUGIN_INSTALLS = 'metoyou_server_plugin_installs'; const STORAGE_KEY_SERVER_PLUGIN_INSTALLS = 'metoyou_server_plugin_installs';
const PLUGIN_CACHE_DIR = 'plugin-bundles'; const PLUGIN_CACHE_DIR = 'plugin-bundles';
const DEFAULT_STORE_STATE: PersistedPluginStoreState = { const DEFAULT_PLUGIN_SOURCE_URLS = [...environment.pluginStore.defaultSourceUrls];
installedPlugins: [],
sourceUrls: []
};
export interface PluginStoreInstallOptions { export interface PluginStoreInstallOptions {
activate?: boolean; activate?: boolean;
@@ -953,12 +951,12 @@ export class PluginStoreService {
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE)); const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE));
if (!raw) { if (!raw) {
return { ...DEFAULT_STORE_STATE }; return createDefaultStoreState();
} }
return normalizePersistedState(JSON.parse(raw) as unknown); return normalizePersistedState(JSON.parse(raw) as unknown);
} catch { } catch {
return { ...DEFAULT_STORE_STATE }; return createDefaultStoreState();
} }
} }
@@ -1132,22 +1130,63 @@ function readPluginInstallScope(record: Record<string, unknown>): TojuPluginInst
function normalizePersistedState(value: unknown): PersistedPluginStoreState { function normalizePersistedState(value: unknown): PersistedPluginStoreState {
if (!isRecord(value)) { if (!isRecord(value)) {
return { ...DEFAULT_STORE_STATE }; return createDefaultStoreState();
} }
const schemaVersion = typeof value['schemaVersion'] === 'number' ? value['schemaVersion'] : 0;
const sourceUrls = Array.isArray(value['sourceUrls'])
? normalizePluginSourceUrls(value['sourceUrls'])
: [];
return { return {
installedPlugins: Array.isArray(value['installedPlugins']) installedPlugins: Array.isArray(value['installedPlugins'])
? value['installedPlugins'].filter(isInstalledStorePlugin) ? value['installedPlugins'].filter(isInstalledStorePlugin)
: [], : [],
sourceUrls: Array.isArray(value['sourceUrls']) schemaVersion: STORE_SCHEMA_VERSION,
? value['sourceUrls'] sourceUrls: schemaVersion < STORE_SCHEMA_VERSION
.filter((entry): entry is string => typeof entry === 'string') ? mergePluginSourceUrls(DEFAULT_PLUGIN_SOURCE_URLS, sourceUrls)
.map((entry) => normalizeOptionalSourceUrl(entry)) : sourceUrls
.filter((entry): entry is string => !!entry)
: []
}; };
} }
function createDefaultStoreState(): PersistedPluginStoreState {
return {
installedPlugins: [],
schemaVersion: STORE_SCHEMA_VERSION,
sourceUrls: [...DEFAULT_PLUGIN_SOURCE_URLS]
};
}
function normalizePluginSourceUrls(sourceUrls: unknown[]): string[] {
const normalizedSourceUrls: string[] = [];
for (const entry of sourceUrls) {
if (typeof entry !== 'string') {
continue;
}
const sourceUrl = normalizeOptionalSourceUrl(entry);
if (sourceUrl && !normalizedSourceUrls.includes(sourceUrl)) {
normalizedSourceUrls.push(sourceUrl);
}
}
return normalizedSourceUrls;
}
function mergePluginSourceUrls(defaultSourceUrls: string[], sourceUrls: string[]): string[] {
const mergedSourceUrls: string[] = [];
for (const sourceUrl of defaultSourceUrls.concat(sourceUrls)) {
if (!mergedSourceUrls.includes(sourceUrl)) {
mergedSourceUrls.push(sourceUrl);
}
}
return mergedSourceUrls;
}
function normalizePersistedServerPluginInstallState(value: unknown): { servers: Record<string, InstalledStorePlugin[]> } { function normalizePersistedServerPluginInstallState(value: unknown): { servers: Record<string, InstalledStorePlugin[]> } {
if (!isRecord(value) || !isRecord(value['servers'])) { if (!isRecord(value) || !isRecord(value['servers'])) {
return { servers: {} }; return { servers: {} };
@@ -48,6 +48,7 @@ export interface PluginStoreReadme {
export interface PersistedPluginStoreState { export interface PersistedPluginStoreState {
installedPlugins: InstalledStorePlugin[]; installedPlugins: InstalledStorePlugin[];
schemaVersion?: number;
sourceUrls: string[]; sourceUrls: string[];
} }
@@ -15,6 +15,7 @@ import {
fromEvent fromEvent
} from 'rxjs'; } from 'rxjs';
import { PluginActionMenuComponent } from './plugin-action-menu.component'; import { PluginActionMenuComponent } from './plugin-action-menu.component';
import { ViewportService } from '../../../../core/platform';
const GAP = 10; const GAP = 10;
const VIEWPORT_MARGIN = 8; const VIEWPORT_MARGIN = 8;
@@ -28,6 +29,7 @@ const POSITIONS: ConnectedPosition[] = [
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PluginActionMenuService { export class PluginActionMenuService {
private readonly overlay = inject(Overlay); private readonly overlay = inject(Overlay);
private readonly viewport = inject(ViewportService);
private currentOrigin: HTMLElement | null = null; private currentOrigin: HTMLElement | null = null;
private overlayRef: OverlayRef | null = null; private overlayRef: OverlayRef | null = null;
private overlaySubscriptions: Subscription | null = null; private overlaySubscriptions: Subscription | null = null;
@@ -47,9 +49,26 @@ export class PluginActionMenuService {
} }
const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin); const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin);
const isMobile = this.viewport.isMobile();
this.currentOrigin = rawEl; this.currentOrigin = rawEl;
if (isMobile) {
const positionStrategy = this.overlay
.position()
.global()
.left('0')
.right('0')
.bottom('0');
this.overlayRef = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.block(),
hasBackdrop: true,
backdropClass: 'cdk-overlay-dark-backdrop',
panelClass: 'metoyou-bottom-sheet-panel'
});
} else {
const positionStrategy = this.overlay const positionStrategy = this.overlay
.position() .position()
.flexibleConnectedTo(elementRef) .flexibleConnectedTo(elementRef)
@@ -61,6 +80,7 @@ export class PluginActionMenuService {
positionStrategy, positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.noop() scrollStrategy: this.overlay.scrollStrategies.noop()
}); });
}
this.syncThemeVars(); this.syncThemeVars();
@@ -68,6 +88,14 @@ export class PluginActionMenuService {
const subscriptions = new Subscription(); const subscriptions = new Subscription();
subscriptions.add(componentRef.instance.closed.subscribe(() => this.close())); subscriptions.add(componentRef.instance.closed.subscribe(() => this.close()));
if (isMobile) {
subscriptions.add(this.overlayRef.backdropClick().subscribe(() => this.close()));
this.overlaySubscriptions = subscriptions;
return;
}
subscriptions.add(fromEvent<PointerEvent>(document, 'pointerdown') subscriptions.add(fromEvent<PointerEvent>(document, 'pointerdown')
.pipe( .pipe(
filter((event) => { filter((event) => {
@@ -1,6 +1,41 @@
<div class="flex h-full min-h-0 flex-col"> <div class="flex h-full min-h-0 flex-col">
<div class="border-b border-border px-3 py-3"> <div class="border-b border-border px-3 py-3">
<div class="flex flex-col gap-2 md:flex-row md:items-center"> <!--
Mobile-only header row:
[Back] ----- Search ----- [Settings]
Hidden on >=md where the original inline header (search bar + buttons) is used.
-->
<div class="mb-2 flex items-center gap-2 md:hidden">
<button
type="button"
aria-label="Back to server view"
class="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-border bg-secondary text-muted-foreground transition-colors hover:bg-secondary/80"
[class.invisible]="!canGoBack()"
[disabled]="!canGoBack()"
(click)="goBack()"
>
<ng-icon
name="lucideArrowLeft"
class="h-5 w-5"
/>
</button>
<h1 class="min-w-0 flex-1 truncate text-center text-base font-semibold text-foreground">Search</h1>
<button
type="button"
aria-label="Settings"
class="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-border bg-secondary text-muted-foreground transition-colors hover:bg-secondary/80"
(click)="openSettings()"
>
<ng-icon
name="lucideSettings"
class="h-5 w-5"
/>
</button>
</div>
<div class="flex flex-row items-center gap-2">
<div class="relative min-w-0 flex-1"> <div class="relative min-w-0 flex-1">
<ng-icon <ng-icon
name="lucideSearch" name="lucideSearch"
@@ -16,6 +51,7 @@
/> />
</div> </div>
<!-- Create button is shown inline next to the search input on all sizes; Settings is desktop-only here (mobile uses the top header row above). -->
<div class="flex shrink-0 items-center gap-2"> <div class="flex shrink-0 items-center gap-2">
<button <button
type="button" type="button"
@@ -27,12 +63,12 @@
name="lucidePlus" name="lucidePlus"
class="h-4 w-4" class="h-4 w-4"
/> />
Create <span>Create</span>
</button> </button>
<button <button
type="button" type="button"
class="grid h-10 w-10 place-items-center rounded-lg border border-border bg-secondary transition-colors hover:bg-secondary/80" class="hidden h-10 w-10 place-items-center rounded-lg border border-border bg-secondary transition-colors hover:bg-secondary/80 md:grid"
title="Settings" title="Settings"
(click)="openSettings()" (click)="openSettings()"
> >
@@ -60,13 +96,51 @@
} }
</div> </div>
<!-- Mobile tab strip: toggle between People and Servers panes (hidden on >=md) -->
<div
role="tablist"
aria-label="Search results"
class="flex border-b border-border md:hidden"
>
<button
type="button"
role="tab"
[attr.aria-selected]="mobileTab() === 'people'"
class="flex-1 px-3 py-2.5 text-sm font-medium transition-colors border-b-2"
[class.border-primary]="mobileTab() === 'people'"
[class.text-foreground]="mobileTab() === 'people'"
[class.border-transparent]="mobileTab() !== 'people'"
[class.text-muted-foreground]="mobileTab() !== 'people'"
(click)="mobileTab.set('people')"
>
People
</button>
<button
type="button"
role="tab"
[attr.aria-selected]="mobileTab() === 'servers'"
class="flex-1 px-3 py-2.5 text-sm font-medium transition-colors border-b-2"
[class.border-primary]="mobileTab() === 'servers'"
[class.text-foreground]="mobileTab() === 'servers'"
[class.border-transparent]="mobileTab() !== 'servers'"
[class.text-muted-foreground]="mobileTab() !== 'servers'"
(click)="mobileTab.set('servers')"
>
Servers ({{ searchResults().length }})
</button>
</div>
<div class="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[minmax(300px,380px)_1fr]"> <div class="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[minmax(300px,380px)_1fr]">
<app-user-search-list <app-user-search-list
class="min-h-0 overflow-y-auto border-b border-border lg:border-b-0 lg:border-r" class="min-h-0 overflow-y-auto border-b border-border lg:border-b-0 lg:border-r"
[class.hidden]="isMobile() && mobileTab() !== 'people'"
[searchQuery]="searchQuery" [searchQuery]="searchQuery"
/> />
<section class="min-h-0 overflow-y-auto"> <section
class="min-h-0 overflow-y-auto"
[class.hidden]="isMobile() && mobileTab() !== 'servers'"
>
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/95 px-3 py-2 backdrop-blur"> <div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/95 px-3 py-2 backdrop-blur">
<div> <div>
<h3 class="text-sm font-semibold text-foreground">Servers</h3> <h3 class="text-sm font-semibold text-foreground">Servers</h3>
@@ -215,7 +289,7 @@
} @else { } @else {
<button <button
type="button" type="button"
class="pointer-events-none scale-95 rounded-md bg-primary px-2.5 py-1.5 text-xs font-semibold text-primary-foreground opacity-0 transition-[opacity,transform] duration-75 ease-out hover:scale-100 hover:opacity-100 group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:scale-100 group-focus-within:opacity-100" class="rounded-md bg-primary px-2.5 py-1.5 text-xs font-semibold text-primary-foreground transition-[opacity,transform] duration-75 ease-out md:pointer-events-none md:scale-95 md:opacity-0 md:hover:scale-100 md:hover:opacity-100 md:group-hover:pointer-events-auto md:group-hover:scale-100 md:group-hover:opacity-100 md:group-focus-within:pointer-events-auto md:group-focus-within:scale-100 md:group-focus-within:opacity-100"
[attr.aria-label]="'Join ' + server.name" [attr.aria-label]="'Join ' + server.name"
(click)="joinServer(server)" (click)="joinServer(server)"
> >
@@ -18,6 +18,7 @@ import {
} from 'rxjs'; } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import {
lucideArrowLeft,
lucideExternalLink, lucideExternalLink,
lucideFileText, lucideFileText,
lucideSearch, lucideSearch,
@@ -34,14 +35,15 @@ import {
selectSearchResults, selectSearchResults,
selectIsSearching, selectIsSearching,
selectRoomsError, selectRoomsError,
selectSavedRooms selectSavedRooms,
selectCurrentRoom
} from '../../../../store/rooms/rooms.selectors'; } from '../../../../store/rooms/rooms.selectors';
import { import {
Room, Room,
User, User,
type PluginRequirementSummary type PluginRequirementSummary
} from '../../../../shared-kernel'; } from '../../../../shared-kernel';
import { ExternalLinkService } from '../../../../core/platform'; import { ExternalLinkService, ViewportService } from '../../../../core/platform';
import { SettingsModalService } from '../../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { DatabaseService } from '../../../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { type ServerInfo } from '../../domain/models/server-directory.model'; import { type ServerInfo } from '../../domain/models/server-directory.model';
@@ -83,6 +85,7 @@ interface JoinPluginConsentDialog {
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideArrowLeft,
lucideExternalLink, lucideExternalLink,
lucideFileText, lucideFileText,
lucideSearch, lucideSearch,
@@ -110,14 +113,22 @@ export class ServerSearchComponent implements OnInit {
private webrtc = inject(RealtimeSessionFacade); private webrtc = inject(RealtimeSessionFacade);
private pluginRequirements = inject(PluginRequirementService); private pluginRequirements = inject(PluginRequirementService);
private pluginStore = inject(PluginStoreService); private pluginStore = inject(PluginStoreService);
private viewport = inject(ViewportService);
private searchSubject = new Subject<string>(); private searchSubject = new Subject<string>();
private banLookupRequestVersion = 0; private banLookupRequestVersion = 0;
/** True on mobile breakpoints. Drives the tabbed mobile layout. */
readonly isMobile = this.viewport.isMobile;
/** Active mobile tab. Ignored on desktop where both panes are visible side-by-side. */
readonly mobileTab = signal<'people' | 'servers'>('servers');
searchQuery = ''; searchQuery = '';
searchResults = this.store.selectSignal(selectSearchResults); searchResults = this.store.selectSignal(selectSearchResults);
isSearching = this.store.selectSignal(selectIsSearching); isSearching = this.store.selectSignal(selectIsSearching);
error = this.store.selectSignal(selectRoomsError); error = this.store.selectSignal(selectRoomsError);
savedRooms = this.store.selectSignal(selectSavedRooms); savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
activeEndpoints = this.serverDirectory.activeServers; activeEndpoints = this.serverDirectory.activeServers;
bannedServerLookup = signal<Record<string, boolean>>({}); bannedServerLookup = signal<Record<string, boolean>>({});
@@ -235,6 +246,24 @@ export class ServerSearchComponent implements OnInit {
this.settingsModal.open('network'); this.settingsModal.open('network');
} }
/**
* Navigate back from the Search page to the chat-room view (server rail + current server).
* Prefers the current room; falls back to the first saved room. No-op when the user has not
* joined any servers.
*/
goBack(): void {
const target = this.currentRoom() ?? this.savedRooms()[0] ?? null;
if (target) {
this.store.dispatch(RoomsActions.viewServer({ room: target }));
}
}
/** True when the back button has a destination (user is in or has joined at least one server). */
canGoBack(): boolean {
return !!this.currentRoom() || this.savedRooms().length > 0;
}
/** Join a previously saved room by converting it to a ServerInfo payload. */ /** Join a previously saved room by converting it to a ServerInfo payload. */
joinSavedRoom(room: Room): void { joinSavedRoom(room: Room): void {
this.openJoinedRoom(room); this.openJoinedRoom(room);
@@ -63,6 +63,7 @@
/> />
</button> </button>
@if (!isMobile()) {
<button <button
(click)="toggleScreenShare()" (click)="toggleScreenShare()"
type="button" type="button"
@@ -74,6 +75,7 @@
class="w-4 h-4" class="w-4 h-4"
/> />
</button> </button>
}
<app-debug-console <app-debug-console
launcherVariant="compact" launcherVariant="compact"
@@ -24,6 +24,7 @@ import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../
import { VoiceConnectionFacade } from '../../../../domains/voice-connection'; import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { VoicePlaybackService } from '../../../../domains/voice-connection'; import { VoicePlaybackService } from '../../../../domains/voice-connection';
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share'; import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
import { ViewportService } from '../../../../core/platform';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared'; import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
@@ -59,6 +60,8 @@ import { ThemeNodeDirective } from '../../../../domains/theme';
export class FloatingVoiceControlsComponent implements OnInit { export class FloatingVoiceControlsComponent implements OnInit {
private readonly webrtcService = inject(VoiceConnectionFacade); private readonly webrtcService = inject(VoiceConnectionFacade);
private readonly screenShareService = inject(ScreenShareFacade); private readonly screenShareService = inject(ScreenShareFacade);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
private readonly voiceSessionService = inject(VoiceSessionFacade); private readonly voiceSessionService = inject(VoiceSessionFacade);
private readonly voicePlayback = inject(VoicePlaybackService); private readonly voicePlayback = inject(VoicePlaybackService);
private readonly store = inject(Store); private readonly store = inject(Store);
@@ -1,5 +1,121 @@
<div class="flex h-full flex-col bg-background"> <div class="flex h-full flex-col bg-background">
@if (currentRoom()) { @if (currentRoom()) {
@if (isMobile()) {
<!-- Mobile: Swiper-driven page stack (channels -> main -> members) -->
<swiper-container
#swiperEl
class="block min-h-0 w-full flex-1"
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 bg-card">
<app-rooms-side-panel
panelMode="channels"
class="block h-full w-full"
/>
</div>
</div>
</swiper-slide>
<swiper-slide class="block h-full w-full">
<div class="flex h-full w-full min-h-0 flex-col overflow-hidden bg-background">
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-card px-3 py-2">
<button
type="button"
(click)="setMobilePage('channels')"
class="grid h-11 w-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Back to channels"
>
<ng-icon
name="lucideChevronLeft"
class="h-5 w-5"
/>
</button>
<div class="min-w-0 flex-1">
@if (activeChannel(); as channel) {
<p class="flex min-w-0 items-center gap-1 truncate text-sm font-semibold text-foreground">
@if (channel.type === 'text') {
<ng-icon
name="lucideHash"
class="h-4 w-4 shrink-0 text-muted-foreground"
/>
}
<span class="truncate">{{ channel.name }}</span>
</p>
} @else {
<p class="truncate text-sm font-semibold text-foreground">{{ currentRoom()?.name }}</p>
}
</div>
<button
type="button"
(click)="setMobilePage('members')"
class="grid h-11 w-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Show members"
>
<ng-icon
name="lucideUsers"
class="h-5 w-5"
/>
</button>
</div>
<main class="relative min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
@if (!isVoiceWorkspaceExpanded()) {
@if (hasTextChannels()) {
<div class="h-full overflow-hidden">
<app-chat-messages />
</div>
} @else {
<div class="flex h-full items-center justify-center px-6">
<div class="max-w-md text-center text-muted-foreground">
<ng-icon
name="lucideHash"
class="mx-auto mb-4 h-16 w-16 opacity-30"
/>
<h2 class="mb-2 text-xl font-medium text-foreground">No text channels</h2>
<p class="text-sm">There are no existing text channels currently.</p>
</div>
</div>
}
}
<app-voice-workspace />
</main>
</div>
</swiper-slide>
<swiper-slide class="block h-full w-full">
<div class="flex h-full w-full min-h-0 flex-col overflow-hidden bg-card">
<div class="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
<button
type="button"
(click)="setMobilePage('main')"
class="grid h-11 w-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Back to chat"
>
<ng-icon
name="lucideChevronLeft"
class="h-5 w-5"
/>
</button>
<p class="truncate text-sm font-semibold text-foreground">Members</p>
</div>
<app-rooms-side-panel
panelMode="users"
[showVoiceControls]="false"
class="block h-full w-full"
/>
</div>
</swiper-slide>
</swiper-container>
} @else {
<!-- Desktop: theme-driven 3-pane grid layout -->
<div <div
class="grid min-h-0 flex-1 overflow-hidden" class="grid min-h-0 flex-1 overflow-hidden"
[ngStyle]="roomLayoutStyles()" [ngStyle]="roomLayoutStyles()"
@@ -66,6 +182,7 @@
/> />
</aside> </aside>
</div> </div>
}
} @else { } @else {
<div <div
appThemeNode="chatRoomEmptyState" appThemeNode="chatRoomEmptyState"
@@ -91,3 +208,4 @@
</div> </div>
} }
</div> </div>
@@ -1,9 +1,14 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { import {
CUSTOM_ELEMENTS_SCHEMA,
Component, Component,
ElementRef,
NgZone,
computed, computed,
effect,
inject, inject,
signal signal,
viewChild
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -21,13 +26,37 @@ import {
import { ChatMessagesComponent } from '../../../domains/chat/feature/chat-messages/chat-messages.component'; import { ChatMessagesComponent } from '../../../domains/chat/feature/chat-messages/chat-messages.component';
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component'; import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
import { VoiceWorkspaceComponent } from '../voice-workspace/voice-workspace.component'; import { VoiceWorkspaceComponent } from '../voice-workspace/voice-workspace.component';
import { ServersRailComponent } from '../../servers/servers-rail/servers-rail.component';
import { selectCurrentRoom, selectTextChannels } from '../../../store/rooms/rooms.selectors'; import {
selectActiveChannelId,
selectCurrentRoom,
selectTextChannels
} from '../../../store/rooms/rooms.selectors';
import { SettingsModalService } from '../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { ViewportService } from '../../../core/platform';
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { VoiceWorkspaceService } from '../../../domains/voice-session'; import { VoiceWorkspaceService } from '../../../domains/voice-session';
import { ThemeNodeDirective, ThemeService } from '../../../domains/theme'; import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
/** Mobile-only page identifier within the chat-room view. */
export type ChatRoomMobilePage = 'channels' | 'main' | 'members';
const PAGE_TO_INDEX: Record<ChatRoomMobilePage, number> = {
channels: 0,
main: 1,
members: 2
};
const INDEX_TO_PAGE: ChatRoomMobilePage[] = [
'channels',
'main',
'members'
];
interface SwiperElement extends HTMLElement {
swiper?: { activeIndex: number; slideTo: (index: number, speed?: number) => void };
}
@Component({ @Component({
selector: 'app-chat-room', selector: 'app-chat-room',
standalone: true, standalone: true,
@@ -37,6 +66,7 @@ import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
ChatMessagesComponent, ChatMessagesComponent,
VoiceWorkspaceComponent, VoiceWorkspaceComponent,
RoomsSidePanelComponent, RoomsSidePanelComponent,
ServersRailComponent,
ThemeNodeDirective ThemeNodeDirective
], ],
viewProviders: [ viewProviders: [
@@ -50,22 +80,52 @@ import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
lucideChevronLeft lucideChevronLeft
}) })
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './chat-room.component.html' templateUrl: './chat-room.component.html'
}) })
/** /**
* Main chat room view combining the messages panel, side panels, and admin controls. * Main chat room view combining the messages panel, side panels, and admin controls.
*
* On desktop the three panels (channels | main | members) are rendered side-by-side via the
* theme-driven grid layout. On mobile the same panels are rendered as Swiper slides
* (channels -> main -> members) so the user can swipe between them. `mobilePage`
* remains the source of truth and stays in sync with the active slide.
*/ */
export class ChatRoomComponent { export class ChatRoomComponent {
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly settingsModal = inject(SettingsModalService); private readonly settingsModal = inject(SettingsModalService);
private readonly theme = inject(ThemeService); private readonly theme = inject(ThemeService);
private readonly viewport = inject(ViewportService);
private readonly zone = inject(NgZone);
private voiceWorkspace = inject(VoiceWorkspaceService); private voiceWorkspace = inject(VoiceWorkspaceService);
private lastSeenChannelId: string | null = null;
private lastSeenRoomId: string | null = null;
private swiperListenerAttached: SwiperElement | null = null;
showMenu = signal(false); showMenu = signal(false);
showAdminPanel = signal(false); showAdminPanel = signal(false);
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
readonly mobilePage = signal<ChatRoomMobilePage>('channels');
readonly isMobile = this.viewport.isMobile;
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
textChannels = this.store.selectSignal(selectTextChannels); textChannels = this.store.selectSignal(selectTextChannels);
activeChannelId = this.store.selectSignal(selectActiveChannelId);
/**
* Resolved channel object for `activeChannelId`. Used on mobile to title the main pane
* with the selected channel name instead of the room name.
*/
activeChannel = computed(() => {
const id = this.activeChannelId();
if (!id) {
return null;
}
return this.currentRoom()?.channels?.find((channel) => channel.id === id) ?? null;
});
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded; isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
hasTextChannels = computed(() => this.textChannels().length > 0); hasTextChannels = computed(() => this.textChannels().length > 0);
roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout')); roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout'));
@@ -73,6 +133,82 @@ export class ChatRoomComponent {
mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel')); mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel'));
membersPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMembersPanel')); membersPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMembersPanel'));
constructor() {
// When entering a server, always land on the channels list ("first page") on mobile, even
// if a default channel is pre-selected. Once inside the server, *changing* channels
// (i.e. user taps a channel in the list) advances to the main pane so the user sees the chat.
effect(() => {
const channelId = this.activeChannelId();
const roomId = this.currentRoom()?.id ?? null;
const isRoomChange = roomId !== this.lastSeenRoomId;
this.lastSeenRoomId = roomId;
if (!this.isMobile()) {
this.lastSeenChannelId = channelId ?? null;
return;
}
if (isRoomChange) {
// New server: show the channels list and don't auto-advance.
this.lastSeenChannelId = channelId ?? null;
this.mobilePage.set('channels');
return;
}
if (channelId && channelId !== this.lastSeenChannelId) {
this.mobilePage.set('main');
}
this.lastSeenChannelId = channelId ?? null;
});
// Mirror `mobilePage` into the Swiper instance so back-button taps and the
// channel-selected auto-advance actually slide the carousel.
effect(() => {
const el = this.swiperRef()?.nativeElement;
const targetIndex = PAGE_TO_INDEX[this.mobilePage()];
if (el?.swiper && el.swiper.activeIndex !== targetIndex) {
el.swiper.slideTo(targetIndex);
}
});
// Bridge Swiper's slidechange event back into `mobilePage`.
effect((onCleanup) => {
const el = this.swiperRef()?.nativeElement;
if (!el || el === this.swiperListenerAttached) {
return;
}
const handler = (event: Event) => {
const detail = (event as CustomEvent).detail;
const swiper = Array.isArray(detail) ? detail[0] : detail;
const index = swiper?.activeIndex ?? 0;
const page = INDEX_TO_PAGE[index] ?? 'channels';
this.zone.run(() => this.mobilePage.set(page));
};
el.addEventListener('swiperslidechange', handler);
this.swiperListenerAttached = el;
onCleanup(() => {
el.removeEventListener('swiperslidechange', handler);
if (this.swiperListenerAttached === el) {
this.swiperListenerAttached = null;
}
});
});
}
/** Set the active mobile page. No-op on desktop. */
setMobilePage(page: ChatRoomMobilePage): void {
this.mobilePage.set(page);
}
/** Open the settings modal to the Server admin page for the current room. */ /** Open the settings modal to the Server admin page for the current room. */
toggleAdminPanel() { toggleAdminPanel() {
const room = this.currentRoom(); const room = this.currentRoom();
@@ -82,3 +218,4 @@ export class ChatRoomComponent {
} }
} }
} }
@@ -257,6 +257,7 @@
<button <button
type="button" type="button"
class="inline-flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 font-medium text-primary-foreground transition hover:bg-primary/90" class="inline-flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 font-medium text-primary-foreground transition hover:bg-primary/90"
[class.hidden]="isMobile()"
(click)="toggleScreenShare()" (click)="toggleScreenShare()"
> >
<ng-icon <ng-icon
@@ -43,6 +43,7 @@ import {
ScreenShareQuality, ScreenShareQuality,
ScreenShareStartOptions ScreenShareStartOptions
} from '../../../domains/screen-share'; } from '../../../domains/screen-share';
import { ViewportService } from '../../../core/platform';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors'; import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
@@ -90,6 +91,8 @@ export class VoiceWorkspaceComponent {
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly webrtc = inject(VoiceConnectionFacade); private readonly webrtc = inject(VoiceConnectionFacade);
private readonly screenShare = inject(ScreenShareFacade); private readonly screenShare = inject(ScreenShareFacade);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
private readonly voicePlayback = inject(VoicePlaybackService); private readonly voicePlayback = inject(VoicePlaybackService);
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService); private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
private readonly voiceSession = inject(VoiceSessionFacade); private readonly voiceSession = inject(VoiceSessionFacade);
@@ -1,8 +1,8 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity --> <!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
@if (isOpen() && !isThemeStudioFullscreen()) { @if (isOpen() && !isThemeStudioFullscreen()) {
<!-- Backdrop --> <!-- Backdrop (hidden on mobile where the modal is full-screen) -->
<div <div
class="fixed inset-0 z-[90] bg-black/80 backdrop-blur-sm transition-opacity duration-200" class="fixed inset-0 z-[90] hidden bg-black/80 backdrop-blur-sm transition-opacity duration-200 md:block"
[class.opacity-100]="animating()" [class.opacity-100]="animating()"
[class.opacity-0]="!animating()" [class.opacity-0]="!animating()"
(click)="onBackdropClick()" (click)="onBackdropClick()"
@@ -13,15 +13,14 @@
aria-label="Close settings" aria-label="Close settings"
></div> ></div>
<!-- Modal --> <!-- Modal: full-screen page on mobile, centered dialog on desktop -->
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none"> <div class="fixed inset-0 z-[91] flex pointer-events-none md:items-center md:justify-center md:p-4">
<div <div
appThemeNode="settingsModalSurface" appThemeNode="settingsModalSurface"
class="pointer-events-auto relative flex w-full max-w-5xl overflow-hidden rounded-lg border border-border bg-card shadow-lg transition-all duration-200" class="pointer-events-auto relative flex h-full w-full overflow-hidden bg-card transition-all duration-200 md:h-[min(720px,88vh)] md:max-w-5xl md:rounded-lg md:border md:border-border md:shadow-lg"
style="height: min(720px, 88vh)"
[class.scale-100]="animating()" [class.scale-100]="animating()"
[class.opacity-100]="animating()" [class.opacity-100]="animating()"
[class.scale-95]="!animating()" [class.md:scale-95]="!animating()"
[class.opacity-0]="!animating()" [class.opacity-0]="!animating()"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()" (keydown.enter)="$event.stopPropagation()"
@@ -31,18 +30,32 @@
aria-labelledby="settings-modal-title" aria-labelledby="settings-modal-title"
tabindex="-1" tabindex="-1"
> >
<!-- Side Navigation --> <!-- Side Navigation: persistent on desktop; full-width "menu" page on mobile -->
<nav <nav
appThemeNode="settingsModalNav" appThemeNode="settingsModalNav"
class="flex w-56 flex-shrink-0 flex-col border-r border-border bg-card" class="flex w-full flex-shrink-0 flex-col border-r border-border bg-card md:w-56"
[class.hidden]="isMobile() && mobilePage() !== 'menu'"
> >
<div class="border-b border-border px-3 py-3"> <div class="flex items-center justify-between border-b border-border px-3 py-3">
<h2 <h2
id="settings-modal-title" id="settings-modal-title"
class="text-lg font-semibold text-foreground" class="text-lg font-semibold text-foreground"
> >
Settings Settings
</h2> </h2>
@if (isMobile()) {
<button
(click)="close()"
type="button"
aria-label="Close settings"
class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground md:hidden"
>
<ng-icon
name="lucideX"
class="w-5 h-5"
/>
</button>
}
</div> </div>
<div class="flex-1 overflow-y-auto py-2"> <div class="flex-1 overflow-y-auto py-2">
@@ -52,8 +65,8 @@
<button <button
(click)="navigate(page.id)" (click)="navigate(page.id)"
type="button" type="button"
class="mx-2 flex w-[calc(100%-1rem)] items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors" class="mx-2 flex w-[calc(100%-1rem)] items-center gap-2.5 rounded-md px-2.5 py-2.5 text-sm transition-colors md:py-1.5"
[class.bg-secondary]="activePage() === page.id" [class.bg-secondary]="activePage() === page.id && !isMobile()"
[class.text-foreground]="activePage() === page.id" [class.text-foreground]="activePage() === page.id"
[class.font-medium]="activePage() === page.id" [class.font-medium]="activePage() === page.id"
[class.text-muted-foreground]="activePage() !== page.id" [class.text-muted-foreground]="activePage() !== page.id"
@@ -92,8 +105,8 @@
<button <button
(click)="navigate(page.id)" (click)="navigate(page.id)"
type="button" type="button"
class="mx-2 flex w-[calc(100%-1rem)] items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors" class="mx-2 flex w-[calc(100%-1rem)] items-center gap-2.5 rounded-md px-2.5 py-2.5 text-sm transition-colors md:py-1.5"
[class.bg-secondary]="activePage() === page.id" [class.bg-secondary]="activePage() === page.id && !isMobile()"
[class.text-foreground]="activePage() === page.id" [class.text-foreground]="activePage() === page.id"
[class.font-medium]="activePage() === page.id" [class.font-medium]="activePage() === page.id"
[class.text-muted-foreground]="activePage() !== page.id" [class.text-muted-foreground]="activePage() !== page.id"
@@ -123,14 +136,31 @@
</div> </div>
</nav> </nav>
<!-- Content --> <!-- Content: shown alongside nav on desktop; full-width "detail" page on mobile -->
<div class="flex-1 flex flex-col min-w-0"> <div
class="flex flex-1 flex-col min-w-0"
[class.hidden]="isMobile() && mobilePage() !== 'detail'"
>
<!-- Header --> <!-- Header -->
<div <div
appThemeNode="settingsModalHeader" appThemeNode="settingsModalHeader"
class="flex items-center justify-between border-b border-border px-5 py-3 flex-shrink-0" class="flex items-center justify-between border-b border-border px-3 py-3 flex-shrink-0 md:px-5"
> >
<h3 class="text-lg font-semibold text-foreground"> <div class="flex min-w-0 items-center gap-1">
@if (isMobile()) {
<button
(click)="backToMenu()"
type="button"
aria-label="Back to settings menu"
class="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground md:hidden"
>
<ng-icon
name="lucideChevronLeft"
class="w-5 h-5"
/>
</button>
}
<h3 class="truncate text-lg font-semibold text-foreground">
@switch (activePage()) { @switch (activePage()) {
@case ('general') { @case ('general') {
General General
@@ -179,10 +209,12 @@
} }
} }
</h3> </h3>
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
(click)="close()" (click)="close()"
type="button" type="button"
aria-label="Close settings"
class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground" class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
> >
<ng-icon <ng-icon
@@ -17,6 +17,7 @@ import {
lucideX, lucideX,
lucideBug, lucideBug,
lucideBell, lucideBell,
lucideChevronLeft,
lucideDownload, lucideDownload,
lucideGlobe, lucideGlobe,
lucideAudioLines, lucideAudioLines,
@@ -30,6 +31,7 @@ import {
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service'; import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
import { ViewportService } from '../../../core/platform';
import { RealtimeSessionFacade } from '../../../core/realtime'; import { RealtimeSessionFacade } from '../../../core/realtime';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../store/users/users.selectors';
@@ -83,6 +85,7 @@ import {
lucideX, lucideX,
lucideBug, lucideBug,
lucideBell, lucideBell,
lucideChevronLeft,
lucideDownload, lucideDownload,
lucideGlobe, lucideGlobe,
lucideAudioLines, lucideAudioLines,
@@ -103,9 +106,21 @@ export class SettingsModalComponent {
private webrtc = inject(RealtimeSessionFacade); private webrtc = inject(RealtimeSessionFacade);
private theme = inject(ThemeService); private theme = inject(ThemeService);
private themeLibrary = inject(ThemeLibraryService); private themeLibrary = inject(ThemeLibraryService);
private viewport = inject(ViewportService);
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES; readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
private lastRequestedServerId: string | null = null; private lastRequestedServerId: string | null = null;
/** True on mobile breakpoints. Drives the full-screen, page-stack layout. */
readonly isMobile = this.viewport.isMobile;
/**
* Active mobile sub-page within the settings flow.
* 'menu' -> the section list (nav)
* 'detail' -> the selected page content
* Ignored on desktop.
*/
readonly mobilePage = signal<'menu' | 'detail'>('menu');
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp'); private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
savedRooms = this.store.selectSignal(selectSavedRooms); savedRooms = this.store.selectSignal(selectSavedRooms);
@@ -299,6 +314,11 @@ export class SettingsModalComponent {
} }
this.animating.set(true); this.animating.set(true);
// On mobile, always start on the section list so the user picks the page first.
if (this.isMobile()) {
this.mobilePage.set('menu');
}
}); });
effect(() => { effect(() => {
@@ -360,6 +380,12 @@ export class SettingsModalComponent {
} }
if (this.isOpen()) { if (this.isOpen()) {
// On mobile, Escape on the detail page just navigates back to the menu.
if (this.isMobile() && this.mobilePage() === 'detail') {
this.backToMenu();
return;
}
this.close(); this.close();
} }
} }
@@ -386,6 +412,16 @@ export class SettingsModalComponent {
navigate(page: SettingsPage): void { navigate(page: SettingsPage): void {
this.modal.navigate(page); this.modal.navigate(page);
// On mobile, advance to the detail page so the user sees the selected pane.
if (this.isMobile()) {
this.mobilePage.set('detail');
}
}
/** Go back to the section list on mobile. No-op on desktop. */
backToMenu(): void {
this.mobilePage.set('menu');
} }
openThemeStudio(): void { openThemeStudio(): void {
@@ -8,6 +8,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../core/platform/viewport.service';
import { ContextMenuComponent } from '../../../shared'; import { ContextMenuComponent } from '../../../shared';
import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models'; import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models';
@@ -55,11 +56,19 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
private readonly viewport = inject(ViewportService);
private cleanup: (() => void) | null = null; private cleanup: (() => void) | null = null;
private selectionSnapshot: ContextMenuSelectionSnapshot | null = null; private selectionSnapshot: ContextMenuSelectionSnapshot | null = null;
@HostListener('document:contextmenu', ['$event']) @HostListener('document:contextmenu', ['$event'])
onDocumentContextMenu(event: MouseEvent): void { onDocumentContextMenu(event: MouseEvent): void {
// On mobile (non-Electron), let the OS-native context menu handle text inputs,
// selection, links, and images. Intercepting here suppresses the OS menu and
// leaves the user without copy/paste/select-all affordances.
if (this.viewport.isMobile() && !this.electronBridge.isAvailable) {
return;
}
this.captureSelectionSnapshot(event); this.captureSelectionSnapshot(event);
if (this.electronBridge.isAvailable) { if (this.electronBridge.isAvailable) {
@@ -4,8 +4,8 @@ import {
computed, computed,
type Signal type Signal
} from '@angular/core'; } from '@angular/core';
import { environment } from '../../../environments/environment';
import { STORAGE_KEY_ICE_SERVERS } from '../../core/constants'; import { STORAGE_KEY_ICE_SERVERS } from '../../core/constants';
import { ICE_SERVERS } from './realtime.constants';
export interface IceServerEntry { export interface IceServerEntry {
id: string; id: string;
@@ -15,7 +15,7 @@ export interface IceServerEntry {
credential?: string; credential?: string;
} }
const DEFAULT_ENTRIES: IceServerEntry[] = ICE_SERVERS.map((server, index) => ({ const DEFAULT_ENTRIES: IceServerEntry[] = environment.realtime.defaultIceServers.map((server, index) => ({
id: `default-stun-${index}`, id: `default-stun-${index}`,
type: 'stun' as const, type: 'stun' as const,
urls: Array.isArray(server.urls) ? server.urls[0] : server.urls urls: Array.isArray(server.urls) ? server.urls[0] : server.urls
@@ -5,14 +5,6 @@ import type { LatencyProfile } from '../../shared-kernel';
* Centralised here so nothing is hard-coded inline. * Centralised here so nothing is hard-coded inline.
*/ */
export const ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' },
{ urls: 'stun:stun3.l.google.com:19302' },
{ urls: 'stun:stun4.l.google.com:19302' }
];
/** Base delay (ms) for exponential backoff on signaling reconnect */ /** Base delay (ms) for exponential backoff on signaling reconnect */
export const SIGNALING_RECONNECT_BASE_DELAY_MS = 1_000; export const SIGNALING_RECONNECT_BASE_DELAY_MS = 1_000;
/** Maximum delay (ms) between signaling reconnect attempts */ /** Maximum delay (ms) between signaling reconnect attempts */
@@ -0,0 +1,46 @@
<!-- Dimmed backdrop. Tap to dismiss. -->
<div
class="fixed inset-0 z-[140] bg-black/40 backdrop-blur-sm"
(click)="onBackdropClick()"
(keydown.enter)="onBackdropClick()"
(keydown.space)="onBackdropClick()"
role="button"
tabindex="0"
aria-label="Close"
></div>
<!--
Bottom sheet panel. Slides up from the bottom of the viewport. Drag the top handle downward
beyond 80px to dismiss. Inner content is projected by the parent component.
-->
<div
appThemeNode="bottomSheetSurface"
class="bottom-sheet-panel fixed inset-x-0 bottom-0 z-[141] flex max-h-[85vh] flex-col rounded-t-2xl border-x border-t border-border bg-card text-foreground shadow-2xl"
[style.transform]="'translateY(' + translateY() + 'px)'"
[style.transition]="translateY() === 0 ? 'transform 200ms ease-out' : 'none'"
[attr.aria-label]="title() || ariaLabel()"
role="dialog"
aria-modal="true"
>
<!-- Drag handle + optional title -->
<div
class="flex shrink-0 cursor-grab touch-none flex-col items-center gap-2 px-4 pb-2 pt-3 active:cursor-grabbing"
(touchstart)="onHandleTouchStart($event)"
(touchmove)="onHandleTouchMove($event)"
(touchend)="onHandleTouchEnd()"
(touchcancel)="onHandleTouchEnd()"
>
<span
aria-hidden="true"
class="h-1.5 w-10 rounded-full bg-muted-foreground/40"
></span>
@if (title()) {
<h3 class="text-sm font-semibold text-foreground">{{ title() }}</h3>
}
</div>
<!-- Scrollable content area -->
<div class="min-h-0 flex-1 overflow-y-auto px-1 pb-[max(env(safe-area-inset-bottom),1rem)]">
<ng-content />
</div>
</div>
@@ -0,0 +1,27 @@
/*
* Bottom sheet slide-up animation. Applied on initial mount so the panel slides into view.
* Drag offsets are applied inline via [style.transform], which override this animation.
*/
:host {
display: contents;
}
.bottom-sheet-panel {
animation: bottom-sheet-slide-up 220ms ease-out;
}
@keyframes bottom-sheet-slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.bottom-sheet-panel {
animation: none;
}
}
@@ -0,0 +1,102 @@
import {
Component,
HostListener,
computed,
input,
output,
signal
} from '@angular/core';
import { ThemeNodeDirective } from '../../../domains/theme';
/**
* Mobile bottom-sheet container.
*
* Renders a backdrop + a panel anchored to the bottom of the viewport that slides up from below.
* Intended for use on phone-sized viewports where context menus, action sheets, and confirmation
* dialogs are better presented as bottom sheets than as floating popovers or centered modals.
*
* The component is layout-only: callers project their content via `<ng-content>` and listen for
* the `dismissed` output to close themselves. Drag-to-dismiss is supported via touch gestures.
*
* Desktop callers should not render this component; use the original popover/modal layout instead.
*
* @example
* ```html
* @if (isMobile()) {
* <app-bottom-sheet (dismissed)="close()">
* <my-menu-items />
* </app-bottom-sheet>
* }
* ```
*/
@Component({
selector: 'app-bottom-sheet',
standalone: true,
imports: [ThemeNodeDirective],
templateUrl: './bottom-sheet.component.html',
styleUrl: './bottom-sheet.component.scss'
})
export class BottomSheetComponent {
/** Optional title rendered at the top of the sheet. Omit for an unlabeled action sheet. */
readonly title = input<string | null>(null);
/** Optional ARIA label when no visible title is provided. */
readonly ariaLabel = input<string>('Menu');
/** Emits when the user dismisses the sheet (backdrop tap, swipe-down, or Escape). */
readonly dismissed = output<undefined>();
/** Pixels the sheet is currently dragged downward. Drives the translate transform. */
protected readonly dragOffset = signal(0);
/** Visible transform offset in CSS pixels (only positive values move the sheet down). */
protected readonly translateY = computed(() => Math.max(0, this.dragOffset()));
private touchStartY: number | null = null;
@HostListener('document:keydown.escape')
protected onEscape(): void {
this.dismissed.emit(undefined);
}
protected onBackdropClick(): void {
this.dismissed.emit(undefined);
}
protected onHandleTouchStart(event: TouchEvent): void {
const touch = event.touches[0];
if (!touch) {
return;
}
this.touchStartY = touch.clientY;
}
protected onHandleTouchMove(event: TouchEvent): void {
if (this.touchStartY === null) {
return;
}
const touch = event.touches[0];
if (!touch) {
return;
}
const delta = touch.clientY - this.touchStartY;
// Only allow dragging downward; ignore upward drags.
this.dragOffset.set(Math.max(0, delta));
}
protected onHandleTouchEnd(): void {
// Dismiss if the user dragged the sheet down by more than 80px; otherwise snap back.
if (this.dragOffset() > 80) {
this.dismissed.emit(undefined);
}
this.touchStartY = null;
this.dragOffset.set(0);
}
}
@@ -1,3 +1,41 @@
<!--
Two presentations:
- Mobile: rendered through `app-bottom-sheet` so confirmations slide up from the bottom.
- Desktop: original centered modal with backdrop.
-->
@if (isMobile()) {
<app-bottom-sheet
[title]="title()"
[ariaLabel]="title()"
(dismissed)="cancelled.emit(undefined)"
>
<div class="px-4 pb-3 pt-1 text-sm text-muted-foreground">
<ng-content />
</div>
<div class="flex gap-2 border-t border-border p-3">
<button
(click)="cancelled.emit(undefined)"
type="button"
class="min-h-11 flex-1 rounded-lg bg-secondary px-3 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
>
{{ cancelLabel() }}
</button>
<button
(click)="confirmed.emit(undefined)"
type="button"
class="min-h-11 flex-1 rounded-lg px-3 py-2 text-sm transition-colors"
[class.bg-primary]="variant() === 'primary'"
[class.text-primary-foreground]="variant() === 'primary'"
[class.hover:bg-primary/90]="variant() === 'primary'"
[class.bg-destructive]="variant() === 'danger'"
[class.text-destructive-foreground]="variant() === 'danger'"
[class.hover:bg-destructive/90]="variant() === 'danger'"
>
{{ confirmLabel() }}
</button>
</div>
</app-bottom-sheet>
} @else {
<!-- Backdrop --> <!-- Backdrop -->
<div <div
class="fixed inset-0 z-40 bg-black/30" class="fixed inset-0 z-40 bg-black/30"
@@ -44,3 +82,4 @@
</button> </button>
</div> </div>
</div> </div>
}
@@ -1,15 +1,18 @@
import { import {
Component, Component,
HostListener,
inject,
input, input,
output, output
HostListener
} from '@angular/core'; } from '@angular/core';
import { ThemeNodeDirective } from '../../../domains/theme'; import { ThemeNodeDirective } from '../../../domains/theme';
import { ViewportService } from '../../../core/platform';
import { BottomSheetComponent } from '../bottom-sheet/bottom-sheet.component';
@Component({ @Component({
selector: 'app-confirm-dialog', selector: 'app-confirm-dialog',
standalone: true, standalone: true,
imports: [ThemeNodeDirective], imports: [ThemeNodeDirective, BottomSheetComponent],
templateUrl: './confirm-dialog.component.html', templateUrl: './confirm-dialog.component.html',
host: { host: {
style: 'display: contents;' style: 'display: contents;'
@@ -24,6 +27,8 @@ export class ConfirmDialogComponent {
confirmed = output<undefined>(); confirmed = output<undefined>();
cancelled = output<undefined>(); cancelled = output<undefined>();
readonly isMobile = inject(ViewportService).isMobile;
@HostListener('document:keydown.escape') @HostListener('document:keydown.escape')
onEscape(): void { onEscape(): void {
this.cancelled.emit(undefined); this.cancelled.emit(undefined);
@@ -1,3 +1,19 @@
<!--
ContextMenu has two presentations:
- On phone-sized viewports the menu opens as a bottom sheet anchored to the bottom of the screen.
- On desktop it remains an absolutely-positioned popover at the requested (x, y) coordinates.
-->
@if (isMobile()) {
<app-bottom-sheet
[title]="sheetTitle()"
[ariaLabel]="sheetTitle() || 'Menu'"
(dismissed)="closed.emit(undefined)"
>
<div class="flex flex-col py-1">
<ng-content />
</div>
</app-bottom-sheet>
} @else {
<!-- Invisible backdrop that captures clicks outside --> <!-- Invisible backdrop that captures clicks outside -->
<div <div
class="fixed inset-0 z-40" class="fixed inset-0 z-40"
@@ -21,3 +37,4 @@
> >
<ng-content /> <ng-content />
</div> </div>
}
@@ -7,14 +7,17 @@ import {
ViewChild, ViewChild,
ElementRef, ElementRef,
AfterViewInit, AfterViewInit,
OnInit OnInit,
inject
} from '@angular/core'; } from '@angular/core';
import { ThemeNodeDirective } from '../../../domains/theme'; import { ThemeNodeDirective } from '../../../domains/theme';
import { ViewportService } from '../../../core/platform';
import { BottomSheetComponent } from '../bottom-sheet/bottom-sheet.component';
@Component({ @Component({
selector: 'app-context-menu', selector: 'app-context-menu',
standalone: true, standalone: true,
imports: [ThemeNodeDirective], imports: [ThemeNodeDirective, BottomSheetComponent],
templateUrl: './context-menu.component.html', templateUrl: './context-menu.component.html',
styleUrl: './context-menu.component.scss' styleUrl: './context-menu.component.scss'
}) })
@@ -24,19 +27,32 @@ export class ContextMenuComponent implements OnInit, AfterViewInit {
y = input.required<number>(); y = input.required<number>();
width = input<string>('w-48'); width = input<string>('w-48');
widthPx = input<number | null>(null); widthPx = input<number | null>(null);
/** Optional title shown when the menu is presented as a mobile bottom sheet. */
sheetTitle = input<string | null>(null);
closed = output<undefined>(); closed = output<undefined>();
@ViewChild('panel', { static: true }) panelRef!: ElementRef<HTMLDivElement>; @ViewChild('panel', { static: false }) panelRef?: ElementRef<HTMLDivElement>;
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
clampedX = signal(0); clampedX = signal(0);
clampedY = signal(0); clampedY = signal(0);
ngOnInit(): void { ngOnInit(): void {
if (this.isMobile()) {
return;
}
this.clampedX.set(this.clampX(this.x(), this.estimateWidth())); this.clampedX.set(this.clampX(this.x(), this.estimateWidth()));
this.clampedY.set(this.clampY(this.y(), 80)); this.clampedY.set(this.clampY(this.y(), 80));
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (this.isMobile() || !this.panelRef) {
return;
}
const rect = this.panelRef.nativeElement.getBoundingClientRect(); const rect = this.panelRef.nativeElement.getBoundingClientRect();
this.clampedX.set(this.clampX(this.x(), rect.width)); this.clampedX.set(this.clampX(this.x(), rect.width));
@@ -0,0 +1,253 @@
<div
appThemeNode="profileCardSurface"
class="flex w-full flex-col bg-card text-foreground"
>
@let profileUser = displayedUser();
@let statusColor = currentStatusColor();
@let statusLabel = currentStatusLabel();
@let self = isSelf();
@let friend = isFriend();
@let isEditable = editable();
@let activeField = editingField();
<div
appThemeNode="profileCardBanner"
class="h-24 bg-gradient-to-r from-primary/30 to-primary/10"
></div>
<div class="-mt-12 flex flex-col items-center px-6">
<div class="relative">
<button
type="button"
class="rounded-full"
[disabled]="!isEditable || avatarSaving()"
(click)="pickAvatar(avatarInput)"
>
<app-user-avatar
[name]="profileUser.displayName"
[avatarUrl]="profileUser.avatarUrl"
size="xl"
[status]="profileUser.status"
[showStatusBadge]="true"
ringClass="ring-4 ring-card"
/>
</button>
@if (isEditable) {
<span
class="pointer-events-none absolute bottom-1 right-1 flex h-7 w-7 items-center justify-center rounded-full border-2 border-card bg-primary text-primary-foreground shadow"
>
<ng-icon
name="lucideCamera"
class="h-3.5 w-3.5"
/>
</span>
}
<input
#avatarInput
type="file"
class="hidden"
[accept]="avatarAccept"
(change)="onAvatarSelected($event)"
/>
</div>
<div class="mt-3 w-full text-center">
@if (isEditable && activeField === 'displayName') {
<input
type="text"
class="w-full rounded-lg border border-border bg-background/70 px-3 py-2 text-center text-lg font-semibold text-foreground outline-none focus:border-primary/70"
[value]="displayNameDraft()"
(input)="onDisplayNameInput($event)"
(blur)="finishEdit('displayName')"
/>
} @else if (isEditable) {
<button
type="button"
class="w-full text-center text-xl font-semibold text-foreground hover:underline"
(click)="startEdit('displayName')"
>
{{ profileUser.displayName }}
</button>
} @else {
<h2 class="text-center text-xl font-semibold text-foreground">{{ profileUser.displayName }}</h2>
}
</div>
@if (profileUser.username && profileUser.username !== profileUser.displayName) {
<p class="mt-0.5 text-sm text-muted-foreground">{{ '@' + profileUser.username }}</p>
}
@if (isEditable) {
<div class="relative mt-3 w-full max-w-[14rem]">
<button
type="button"
class="flex w-full items-center gap-2 rounded-full border border-border bg-secondary/40 px-3 py-1.5 text-sm transition-colors hover:bg-secondary"
(click)="toggleStatusMenu()"
>
<span
class="h-2 w-2 rounded-full"
[class]="statusColor"
></span>
<span class="flex-1 text-left text-foreground">{{ statusLabel }}</span>
<ng-icon
name="lucideChevronDown"
class="h-3.5 w-3.5 text-muted-foreground"
/>
</button>
@if (showStatusMenu()) {
<div class="absolute left-0 right-0 top-full z-10 mt-1 rounded-lg border border-border bg-card py-1 shadow-lg">
@for (opt of statusOptions; track opt.label) {
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-secondary"
[class.bg-secondary]="isStatusOptionSelected(opt.value)"
[class.text-foreground]="isStatusOptionSelected(opt.value)"
[class.text-muted-foreground]="!isStatusOptionSelected(opt.value)"
(click)="setStatus(opt.value)"
>
<span
class="h-2 w-2 rounded-full"
[class]="opt.color"
></span>
<span class="flex-1">{{ opt.label }}</span>
@if (isStatusOptionSelected(opt.value)) {
<ng-icon
name="lucideCheck"
class="h-4 w-4 text-primary"
/>
}
</button>
}
</div>
}
</div>
} @else {
<div class="mt-2 inline-flex items-center gap-1.5 rounded-full bg-secondary/40 px-2.5 py-1 text-xs text-muted-foreground">
<span
class="h-2 w-2 rounded-full"
[class]="statusColor"
></span>
<span>{{ statusLabel }}</span>
</div>
}
</div>
<div class="mt-4 space-y-3 px-6 pb-2">
@if (isEditable) {
@if (activeField === 'description') {
<textarea
rows="3"
class="w-full resize-none rounded-lg border border-border bg-background/70 px-3 py-2 text-sm leading-5 text-foreground outline-none focus:border-primary/70"
[value]="descriptionDraft()"
placeholder="Add a description"
(input)="onDescriptionInput($event)"
(blur)="finishEdit('description')"
></textarea>
} @else {
<button
type="button"
class="block w-full rounded-lg border border-dashed border-border/70 bg-background/30 px-3 py-2 text-left text-sm leading-5"
(click)="startEdit('description')"
>
@if (profileUser.description) {
<span class="whitespace-pre-line text-muted-foreground">{{ profileUser.description }}</span>
} @else {
<span class="text-muted-foreground/70">Add a description</span>
}
</button>
}
} @else if (profileUser.description) {
<p class="whitespace-pre-line text-center text-sm leading-5 text-muted-foreground">
{{ profileUser.description }}
</p>
}
@if (avatarError()) {
<div class="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-200">
{{ avatarError() }}
</div>
}
@if (profileUser.gameActivity; as activity) {
<button
type="button"
class="flex w-full items-center gap-3 rounded-xl border border-border bg-background/40 px-3 py-2 text-left"
[disabled]="!activity.store?.url"
(click)="openGameStore($event)"
>
@if (activity.iconUrl) {
<img
class="h-10 w-10 shrink-0 rounded-md object-cover"
[src]="activity.iconUrl"
alt=""
/>
} @else {
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<ng-icon
name="lucideGamepad2"
class="h-5 w-5"
/>
</div>
}
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">Playing {{ activity.name }}</p>
<p class="truncate text-xs text-muted-foreground">{{ gameActivityElapsed() }}</p>
</div>
</button>
}
</div>
@if (!self) {
<div class="grid grid-cols-1 gap-2 px-6 pb-6 pt-4">
<button
type="button"
class="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
[disabled]="busy()"
(click)="startChat()"
>
<ng-icon
name="lucideMessageCircle"
class="h-5 w-5"
/>
<span>Start chat</span>
</button>
<button
type="button"
class="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl border border-border bg-secondary/40 px-4 text-sm font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
[disabled]="busy()"
(click)="startCall()"
>
<ng-icon
name="lucidePhone"
class="h-5 w-5"
/>
<span>Call</span>
</button>
<button
type="button"
class="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl border border-border bg-secondary/20 px-4 text-sm font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
[disabled]="busy()"
(click)="toggleFriend()"
>
@if (friend) {
<ng-icon
name="lucideUserMinus"
class="h-5 w-5"
/>
<span>Remove friend</span>
} @else {
<ng-icon
name="lucideUserPlus"
class="h-5 w-5"
/>
<span>Add friend</span>
}
</button>
</div>
} @else {
<div class="px-6 pb-6 pt-2"></div>
}
</div>
@@ -0,0 +1,378 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
effect,
inject,
OnDestroy,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideCamera,
lucideCheck,
lucideChevronDown,
lucideGamepad2,
lucideMessageCircle,
lucidePhone,
lucideUserMinus,
lucideUserPlus
} from '@ng-icons/lucide';
import { UserAvatarComponent } from '../user-avatar/user-avatar.component';
import { ThemeNodeDirective } from '../../../domains/theme';
import { User, UserStatus } from '../../../shared-kernel';
import { selectCurrentUser, selectUsersEntities } from '../../../store/users/users.selectors';
import { UsersActions } from '../../../store/users/users.actions';
import { DirectMessageService } from '../../../domains/direct-message/application/services/direct-message.service';
import { FriendService } from '../../../domains/direct-message/application/services/friend.service';
import { DirectCallService } from '../../../domains/direct-call/application/services/direct-call.service';
import { formatGameActivityElapsed } from '../../../domains/game-activity';
import { ExternalLinkService } from '../../../core/platform/external-link.service';
import { UserStatusService } from '../../../core/services/user-status.service';
import {
EditableProfileAvatarSource,
PROFILE_AVATAR_ACCEPT_ATTRIBUTE,
ProcessedProfileAvatar,
ProfileAvatarEditorService,
ProfileAvatarFacade
} from '../../../domains/profile-avatar';
@Component({
selector: 'app-profile-card-mobile',
standalone: true,
imports: [
CommonModule,
NgIcon,
UserAvatarComponent,
ThemeNodeDirective
],
viewProviders: [
provideIcons({
lucideCamera,
lucideCheck,
lucideChevronDown,
lucideGamepad2,
lucideMessageCircle,
lucidePhone,
lucideUserMinus,
lucideUserPlus
})
],
templateUrl: './profile-card-mobile.component.html'
})
export class ProfileCardMobileComponent implements OnDestroy {
readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
readonly editable = signal(false);
readonly closed = output<undefined>();
readonly avatarAccept = PROFILE_AVATAR_ACCEPT_ATTRIBUTE;
readonly avatarError = signal<string | null>(null);
readonly avatarSaving = signal(false);
readonly editingField = signal<'displayName' | 'description' | null>(null);
readonly displayNameDraft = signal('');
readonly descriptionDraft = signal('');
readonly showStatusMenu = signal(false);
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
{ value: null, label: 'Online', color: 'bg-green-500' },
{ value: 'away', label: 'Away', color: 'bg-yellow-500' },
{ value: 'busy', label: 'Do Not Disturb', color: 'bg-red-500' },
{ value: 'offline', label: 'Invisible', color: 'bg-gray-500' }
];
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly directMessages = inject(DirectMessageService);
private readonly directCalls = inject(DirectCallService);
private readonly friendsService = inject(FriendService);
private readonly externalLinks = inject(ExternalLinkService);
private readonly userStatus = inject(UserStatusService);
private readonly profileAvatar = inject(ProfileAvatarFacade);
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
private readonly users = this.store.selectSignal(selectUsersEntities);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly displayedUser = computed(() => {
const snapshot = this.user();
const entities = this.users();
const liveUser = entities[snapshot.id] ?? entities[snapshot.oderId];
return liveUser ? { ...snapshot, ...liveUser } : snapshot;
});
readonly isSelf = computed(() => {
const me = this.currentUser();
const them = this.displayedUser();
if (!me)
return false;
return me.id === them.id || me.oderId === them.oderId;
});
readonly isFriend = computed(() => this.friendsService.friendIds().has(this.displayedUser().id));
readonly activityNow = signal(Date.now());
readonly busy = signal(false);
private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000);
private readonly syncProfileDrafts = effect(
() => {
const user = this.displayedUser();
const editingField = this.editingField();
if (editingField !== 'displayName') {
this.displayNameDraft.set(user.displayName || '');
}
if (editingField !== 'description') {
this.descriptionDraft.set(user.description || '');
}
},
{ allowSignalWrites: true }
);
ngOnDestroy(): void {
clearInterval(this.activityTimer);
}
currentStatusColor(): string {
switch (this.displayedUser().status) {
case 'online':
return 'bg-green-500';
case 'away':
return 'bg-yellow-500';
case 'busy':
return 'bg-red-500';
default:
return 'bg-gray-500';
}
}
currentStatusLabel(): string {
switch (this.displayedUser().status) {
case 'online':
return 'Online';
case 'away':
return 'Away';
case 'busy':
return 'Do Not Disturb';
case 'offline':
return 'Invisible';
case 'disconnected':
return 'Offline';
default:
return 'Online';
}
}
gameActivityElapsed(): string {
const activity = this.displayedUser().gameActivity;
return activity ? formatGameActivityElapsed(activity.startedAt, this.activityNow()) : '';
}
openGameStore(event: Event): void {
event.stopPropagation();
const url = this.displayedUser().gameActivity?.store?.url;
if (url) {
this.externalLinks.open(url);
}
}
toggleStatusMenu(): void {
this.showStatusMenu.update((open) => !open);
}
setStatus(status: UserStatus | null): void {
this.userStatus.setManualStatus(status);
this.showStatusMenu.set(false);
}
isStatusOptionSelected(status: UserStatus | null): boolean {
const currentStatus = this.displayedUser().status;
return status === null ? currentStatus === 'online' : currentStatus === status;
}
onDisplayNameInput(event: Event): void {
this.displayNameDraft.set((event.target as HTMLInputElement).value);
}
onDescriptionInput(event: Event): void {
this.descriptionDraft.set((event.target as HTMLTextAreaElement).value);
}
startEdit(field: 'displayName' | 'description'): void {
if (!this.editable() || this.editingField() === field) {
return;
}
this.editingField.set(field);
}
finishEdit(field: 'displayName' | 'description'): void {
if (this.editingField() !== field) {
return;
}
this.commitProfileDrafts();
this.editingField.set(null);
}
pickAvatar(fileInput: HTMLInputElement): void {
if (!this.editable() || this.avatarSaving()) {
return;
}
this.avatarError.set(null);
fileInput.click();
}
async onAvatarSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
let source: EditableProfileAvatarSource | null = null;
input.value = '';
if (!file) {
return;
}
const validationError = this.profileAvatar.validateFile(file);
if (validationError) {
this.avatarError.set(validationError);
return;
}
try {
source = await this.profileAvatar.prepareEditableSource(file);
const avatar = await this.profileAvatarEditor.open(source);
if (!avatar) {
return;
}
await this.applyAvatar(avatar);
} catch {
this.avatarError.set('Failed to open selected image.');
} finally {
this.profileAvatar.releaseEditableSource(source);
}
}
async applyAvatar(avatar: ProcessedProfileAvatar): Promise<void> {
const currentUser = this.displayedUser();
this.avatarSaving.set(true);
this.avatarError.set(null);
try {
await this.profileAvatar.persistProcessedAvatar(currentUser, avatar);
const updates = this.profileAvatar.buildAvatarUpdates(avatar);
this.store.dispatch(UsersActions.updateCurrentUserAvatar({ avatar: updates }));
this.user.update((user) => ({
...user,
...updates
}));
} catch {
this.avatarError.set('Failed to save profile image.');
} finally {
this.avatarSaving.set(false);
}
}
async startChat(): Promise<void> {
if (this.busy() || this.isSelf())
return;
this.busy.set(true);
try {
const conversation = await this.directMessages.createConversation(this.displayedUser());
await this.router.navigate(['/dm', conversation.id]);
this.closed.emit(undefined);
} finally {
this.busy.set(false);
}
}
async startCall(): Promise<void> {
if (this.busy() || this.isSelf())
return;
this.busy.set(true);
try {
await this.directCalls.startCall(this.displayedUser());
this.closed.emit(undefined);
} finally {
this.busy.set(false);
}
}
async toggleFriend(): Promise<void> {
if (this.busy() || this.isSelf())
return;
this.busy.set(true);
try {
await this.friendsService.toggleFriend(this.displayedUser().id);
} finally {
this.busy.set(false);
}
}
private commitProfileDrafts(): void {
if (!this.editable()) {
return;
}
const displayName = this.normalizeDisplayName(this.displayNameDraft());
if (!displayName) {
this.displayNameDraft.set(this.user().displayName || '');
return;
}
const user = this.displayedUser();
const description = this.normalizeDescription(this.descriptionDraft());
if (displayName === this.normalizeDisplayName(user.displayName) && description === this.normalizeDescription(user.description)) {
return;
}
const profile = {
displayName,
description,
profileUpdatedAt: Date.now()
};
this.store.dispatch(UsersActions.updateCurrentUserProfile({ profile }));
this.user.update((user) => ({
...user,
...profile
}));
}
private normalizeDisplayName(value: string | undefined): string {
return value?.trim().replace(/\s+/g, ' ') || '';
}
private normalizeDescription(value: string | undefined): string | undefined {
const normalized = value?.trim();
return normalized || undefined;
}
}
@@ -15,7 +15,9 @@ import {
fromEvent fromEvent
} from 'rxjs'; } from 'rxjs';
import { ProfileCardComponent } from './profile-card.component'; import { ProfileCardComponent } from './profile-card.component';
import { ProfileCardMobileComponent } from './profile-card-mobile.component';
import { PROFILE_AVATAR_EDITOR_OVERLAY_CLASS } from '../../../domains/profile-avatar'; import { PROFILE_AVATAR_EDITOR_OVERLAY_CLASS } from '../../../domains/profile-avatar';
import { ViewportService } from '../../../core/platform';
import { User } from '../../../shared-kernel'; import { User } from '../../../shared-kernel';
export type ProfileCardPlacement = 'above' | 'left' | 'auto'; export type ProfileCardPlacement = 'above' | 'left' | 'auto';
@@ -57,6 +59,7 @@ function positionsFor(placement: ProfileCardPlacement): ConnectedPosition[] {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ProfileCardService { export class ProfileCardService {
private readonly overlay = inject(Overlay); private readonly overlay = inject(Overlay);
private readonly viewport = inject(ViewportService);
private overlayRef: OverlayRef | null = null; private overlayRef: OverlayRef | null = null;
private currentOrigin: HTMLElement | null = null; private currentOrigin: HTMLElement | null = null;
private outsideClickSub: Subscription | null = null; private outsideClickSub: Subscription | null = null;
@@ -76,9 +79,26 @@ export class ProfileCardService {
const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin); const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin);
const placement = options.placement ?? 'auto'; const placement = options.placement ?? 'auto';
const isMobile = this.viewport.isMobile();
this.currentOrigin = rawEl; this.currentOrigin = rawEl;
if (isMobile) {
const positionStrategy = this.overlay
.position()
.global()
.left('0')
.right('0')
.bottom('0');
this.overlayRef = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.block(),
hasBackdrop: true,
backdropClass: 'cdk-overlay-dark-backdrop',
panelClass: 'metoyou-bottom-sheet-panel'
});
} else {
const positionStrategy = this.overlay const positionStrategy = this.overlay
.position() .position()
.flexibleConnectedTo(elementRef) .flexibleConnectedTo(elementRef)
@@ -90,9 +110,26 @@ export class ProfileCardService {
positionStrategy, positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.noop() scrollStrategy: this.overlay.scrollStrategies.noop()
}); });
}
this.syncThemeVars(); this.syncThemeVars();
if (isMobile) {
const portal = new ComponentPortal(ProfileCardMobileComponent);
const ref = this.overlayRef.attach(portal);
ref.instance.user.set(user);
ref.instance.editable.set(options.editable ?? false);
const subscription = new Subscription();
subscription.add(ref.instance.closed.subscribe(() => this.close()));
subscription.add(this.overlayRef.backdropClick().subscribe(() => this.close()));
this.outsideClickSub = subscription;
return;
}
const portal = new ComponentPortal(ProfileCardComponent); const portal = new ComponentPortal(ProfileCardComponent);
const ref = this.overlayRef.attach(portal); const ref = this.overlayRef.attach(portal);
+1
View File
@@ -2,6 +2,7 @@
* Shared reusable UI components barrel. * Shared reusable UI components barrel.
*/ */
export { ContextMenuComponent } from './components/context-menu/context-menu.component'; export { ContextMenuComponent } from './components/context-menu/context-menu.component';
export { BottomSheetComponent } from './components/bottom-sheet/bottom-sheet.component';
export { UserAvatarComponent } from './components/user-avatar/user-avatar.component'; export { UserAvatarComponent } from './components/user-avatar/user-avatar.component';
export { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dialog.component'; export { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dialog.component';
export { LeaveServerDialogComponent } from './components/leave-server-dialog/leave-server-dialog.component'; export { LeaveServerDialogComponent } from './components/leave-server-dialog/leave-server-dialog.component';
+17 -1
View File
@@ -1,5 +1,6 @@
export const environment = { export const environment = {
production: true, production: true,
publicOrigin: 'https://toju.app',
defaultServers: [ defaultServers: [
{ {
key: 'toju-primary', key: 'toju-primary',
@@ -12,5 +13,20 @@ export const environment = {
url: 'https://signal-sweden.toju.app' url: 'https://signal-sweden.toju.app'
} }
], ],
defaultServerUrl: 'https://signal.toju.app' defaultServerUrl: 'https://signal.toju.app',
experimentalMedia: {
vlcRuntimeScriptUrl: '/vlcjs/metoyou-vlc-player.js'
},
pluginStore: {
defaultSourceUrls: ['https://raw.githubusercontent.com/Myxelium/official-toju-plugin-repository/refs/heads/master/plugin-source.json']
},
realtime: {
defaultIceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' },
{ urls: 'stun:stun3.l.google.com:19302' },
{ urls: 'stun:stun4.l.google.com:19302' }
]
}
}; };
+17 -1
View File
@@ -1,5 +1,6 @@
export const environment = { export const environment = {
production: false, production: false,
publicOrigin: 'https://toju.app',
defaultServers: [ defaultServers: [
{ {
key: 'default', key: 'default',
@@ -17,5 +18,20 @@ export const environment = {
url: 'https://signal-sweden.toju.app' url: 'https://signal-sweden.toju.app'
} }
], ],
defaultServerUrl: 'https://46.59.68.77:3001' defaultServerUrl: 'https://46.59.68.77:3001',
experimentalMedia: {
vlcRuntimeScriptUrl: '/vlcjs/metoyou-vlc-player.js'
},
pluginStore: {
defaultSourceUrls: ['https://raw.githubusercontent.com/Myxelium/official-toju-plugin-repository/refs/heads/master/plugin-source.json']
},
realtime: {
defaultIceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' },
{ urls: 'stun:stun3.l.google.com:19302' },
{ urls: 'stun:stun4.l.google.com:19302' }
]
}
}; };
+4
View File
@@ -1,4 +1,5 @@
import { bootstrapApplication } from '@angular/platform-browser'; import { bootstrapApplication } from '@angular/platform-browser';
import { register as registerSwiperElements } from 'swiper/element/bundle';
import { appConfig } from './app/app.config'; import { appConfig } from './app/app.config';
import { App } from './app/app'; import { App } from './app/app';
import mermaid from 'mermaid'; import mermaid from 'mermaid';
@@ -9,6 +10,9 @@ declare global {
} }
} }
// Register Swiper custom elements (<swiper-container>, <swiper-slide>) globally.
registerSwiperElements();
// Expose mermaid globally for ngx-remark's MermaidComponent // Expose mermaid globally for ngx-remark's MermaidComponent
window.mermaid = mermaid; window.mermaid = mermaid;
mermaid.initialize({ mermaid.initialize({
+72
View File
@@ -11,6 +11,78 @@
} }
} }
/*
* Global classes consumed by overlay-driven bottom sheets (profile card, plugin action menu).
* The CDK overlay container lives outside the Angular component tree, so styling must be global
* rather than component-scoped. Keep this in sync with `shared/components/bottom-sheet`.
*/
@keyframes metoyou-bottom-sheet-slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.metoyou-bottom-sheet-panel {
width: 100% !important;
max-width: 100vw;
max-height: 85vh;
border-top-left-radius: 1rem;
border-top-right-radius: 1rem;
border: 1px solid hsl(var(--border));
border-bottom: none;
background: hsl(var(--card));
color: hsl(var(--card-foreground));
box-shadow: 0 -20px 50px -10px rgb(0 0 0 / 50%);
overflow: hidden;
animation: metoyou-bottom-sheet-slide-up 220ms ease-out;
}
.metoyou-bottom-sheet-panel > * {
display: block;
width: 100%;
max-height: 85vh;
overflow-y: auto;
}
/*
* Sheet-mode overrides: when the profile card or plugin action menu render inside the bottom
* sheet panel they should fill the panel rather than keep their popover chrome (fixed width,
* shadow, double border).
*/
.metoyou-bottom-sheet-panel app-profile-card,
.metoyou-bottom-sheet-panel app-plugin-action-menu {
width: 100%;
}
.metoyou-bottom-sheet-panel app-plugin-action-menu > div {
width: 100% !important;
max-width: 100%;
border: none;
border-radius: 0;
box-shadow: none;
animation: none;
}
/*
* Flatten the GIF picker chrome when it renders inside the inline `app-bottom-sheet`.
* The picker brings its own rounded border + shadow that visually nest inside the sheet frame.
*/
.bottom-sheet-panel app-klipy-gif-picker > div {
border-radius: 0 !important;
border: none !important;
box-shadow: none !important;
--tw-ring-color: transparent !important;
}
@media (prefers-reduced-motion: reduce) {
.metoyou-bottom-sheet-panel {
animation: none;
}
}
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;