From dea114aed06947d46da13ee426b6d497363c759e Mon Sep 17 00:00:00 2001 From: Myx Date: Mon, 18 May 2026 02:25:16 +0200 Subject: [PATCH] feat: Response mobile layout support v1 --- .../plugins/plugin-api-two-users.spec.ts | 3 +- e2e/tests/plugins/plugin-manager-ui.spec.ts | 6 +- e2e/tests/voice/direct-call.spec.ts | 62 +-- package-lock.json | 20 + package.json | 1 + toju-app/src/app/app.html | 17 +- toju-app/src/app/app.ts | 4 +- toju-app/src/app/core/platform/index.ts | 1 + .../src/app/core/platform/viewport.service.ts | 69 ++++ .../chat-messages.component.html | 58 +-- .../chat-messages/chat-messages.component.ts | 6 + .../chat-message-item.component.html | 85 +++- .../chat-message-item.component.ts | 135 ++++++- .../klipy-gif-picker.component.html | 113 ++++-- .../klipy-gif-picker.component.ts | 5 + .../feature/dm-chat/dm-chat.component.html | 53 ++- .../feature/dm-chat/dm-chat.component.ts | 6 +- .../dm-workspace/dm-chat-panel.component.html | 2 +- .../dm-workspace/dm-workspace.component.html | 60 ++- .../dm-workspace/dm-workspace.component.ts | 103 ++++- .../plugin-action-menu.service.ts | 48 ++- .../server-search.component.html | 84 +++- .../server-search/server-search.component.ts | 33 +- .../floating-voice-controls.component.html | 24 +- .../floating-voice-controls.component.ts | 3 + .../room/chat-room/chat-room.component.html | 232 ++++++++--- .../room/chat-room/chat-room.component.ts | 141 ++++++- .../voice-workspace.component.html | 1 + .../voice-workspace.component.ts | 3 + .../settings-modal.component.html | 162 +++++--- .../settings-modal.component.ts | 36 ++ .../native-context-menu.component.ts | 9 + .../bottom-sheet/bottom-sheet.component.html | 46 +++ .../bottom-sheet/bottom-sheet.component.scss | 27 ++ .../bottom-sheet/bottom-sheet.component.ts | 102 +++++ .../confirm-dialog.component.html | 125 ++++-- .../confirm-dialog.component.ts | 11 +- .../context-menu/context-menu.component.html | 63 +-- .../context-menu/context-menu.component.ts | 22 +- .../profile-card-mobile.component.html | 253 ++++++++++++ .../profile-card-mobile.component.ts | 378 ++++++++++++++++++ .../profile-card/profile-card.service.ts | 57 ++- toju-app/src/app/shared/index.ts | 1 + toju-app/src/main.ts | 4 + toju-app/src/styles.scss | 72 ++++ 45 files changed, 2369 insertions(+), 377 deletions(-) create mode 100644 toju-app/src/app/core/platform/viewport.service.ts create mode 100644 toju-app/src/app/shared/components/bottom-sheet/bottom-sheet.component.html create mode 100644 toju-app/src/app/shared/components/bottom-sheet/bottom-sheet.component.scss create mode 100644 toju-app/src/app/shared/components/bottom-sheet/bottom-sheet.component.ts create mode 100644 toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.html create mode 100644 toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.ts diff --git a/e2e/tests/plugins/plugin-api-two-users.spec.ts b/e2e/tests/plugins/plugin-api-two-users.spec.ts index 6a19c96..e6e3a8f 100644 --- a/e2e/tests/plugins/plugin-api-two-users.spec.ts +++ b/e2e/tests/plugins/plugin-api-two-users.spec.ts @@ -39,7 +39,6 @@ test.describe('Plugin API multi-user runtime', () => { await closeSettingsModal(scenario.bob.page); 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.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 () => { @@ -150,7 +149,7 @@ async function installGrantAndActivatePlugin(page: Page, installFromStore: boole await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL); await page.getByRole('button', { name: 'Add Source' }).click(); 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 page.getByRole('button', { name: 'Install and Activate' }).click(); await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('Installed')).toBeVisible({ timeout: 20_000 }); diff --git a/e2e/tests/plugins/plugin-manager-ui.spec.ts b/e2e/tests/plugins/plugin-manager-ui.spec.ts index 5910fd8..6e69e8b 100644 --- a/e2e/tests/plugins/plugin-manager-ui.spec.ts +++ b/e2e/tests/plugins/plugin-manager-ui.spec.ts @@ -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.getByRole('button', { name: 'Add Source' }).click(); 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 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' }); await expect(installDialog).toBeVisible({ timeout: 10_000 }); diff --git a/e2e/tests/voice/direct-call.spec.ts b/e2e/tests/voice/direct-call.spec.ts index e981c3d..761e4f8 100644 --- a/e2e/tests/voice/direct-call.spec.ts +++ b/e2e/tests/voice/direct-call.spec.ts @@ -90,10 +90,7 @@ test.describe('Direct private calls', () => { }) .toBeGreaterThan(0); - await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click(); - 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 answerIncomingCall(scenario.bob.page); await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 }); await expect @@ -176,10 +173,7 @@ test.describe('Direct private calls', () => { }) .toBeGreaterThan(0); - await charlie.page.getByRole('button', { name: 'Open private call' }).click(); - 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 answerIncomingCall(charlie.page); await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0); await waitForConnectedPeerCount(scenario.alice.page, 2, 45_000); @@ -345,12 +339,7 @@ test.describe('Direct private calls', () => { }) .toBeGreaterThan(charliePlayCountBeforeGroupCall); - await scenario.bob.page - .getByRole('button', { name: 'Open private call' }) - .last() - .click(); - - await scenario.bob.page.getByRole('button', { name: 'Join call' }).click(); + await answerIncomingCall(scenario.bob.page); await expect .poll(async () => await getActiveCallAudioLoops(scenario.bob.page), { timeout: 10_000, @@ -358,12 +347,7 @@ test.describe('Direct private calls', () => { }) .toBe(0); - await scenario.charlie.page - .getByRole('button', { name: 'Open private call' }) - .last() - .click(); - - await scenario.charlie.page.getByRole('button', { name: 'Join call' }).click(); + await answerIncomingCall(scenario.charlie.page); await expect .poll(async () => await getActiveCallAudioLoops(scenario.charlie.page), { 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 startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob'); - await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click(); - 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 answerIncomingCall(scenario.bob.page); await waitForConnectedPeerCount(scenario.alice.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 startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob'); - await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click(); - await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 }); + await expect(incomingCallDialog(scenario.bob.page)).toBeVisible({ timeout: 20_000 }); await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click(); 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.locator('[data-testid^="server-rail-call-"]')).toHaveCount(0); 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 startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob'); - await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click(); - 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 answerIncomingCall(scenario.bob.page); await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click(); 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 }); } +function incomingCallDialog(page: Page) { + return page.getByRole('dialog', { name: /is calling/ }); +} + +async function answerIncomingCall(page: Page): Promise { + 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 { return await page.evaluate(() => localStorage.getItem('metoyou_currentUserId') ?? ''); } diff --git a/package-lock.json b/package-lock.json index 7d621fe..e0a2ca2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "rxjs": "~7.8.0", "simple-peer": "^9.11.1", "sql.js": "^1.13.0", + "swiper": "^12.1.4", "tslib": "^2.3.0", "typeorm": "^0.3.28", "uuid": "^13.0.0", @@ -31843,6 +31844,25 @@ "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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/swrv/-/swrv-1.2.0.tgz", diff --git a/package.json b/package.json index 9033c91..0d04621 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "rxjs": "~7.8.0", "simple-peer": "^9.11.1", "sql.js": "^1.13.0", + "swiper": "^12.1.4", "tslib": "^2.3.0", "typeorm": "^0.3.28", "uuid": "^13.0.0", diff --git a/toju-app/src/app/app.html b/toju-app/src/app/app.html index 31fbbc4..8ab4d68 100644 --- a/toju-app/src/app/app.html +++ b/toju-app/src/app/app.html @@ -3,14 +3,16 @@ class="workspace-bright-theme relative h-screen overflow-hidden bg-background text-foreground" >
@@ -18,9 +20,12 @@
- + @if (!isMobile()) { + + }
@if (isThemeStudioFullscreen()) { diff --git a/toju-app/src/app/app.ts b/toju-app/src/app/app.ts index ce00862..ecd925c 100644 --- a/toju-app/src/app/app.ts +++ b/toju-app/src/app/app.ts @@ -30,7 +30,7 @@ import { ServerDirectoryFacade } from './domains/server-directory'; import { NotificationsFacade } from './domains/notifications'; import { TimeSyncService } from './core/services/time-sync.service'; 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 { ElectronBridgeService } from './core/platform/electron/electron-bridge.service'; import { UserStatusService } from './core/services/user-status.service'; @@ -99,6 +99,8 @@ export class App implements OnInit, OnDestroy { readonly theme = inject(ThemeService); readonly voiceSession = inject(VoiceSessionFacade); readonly externalLinks = inject(ExternalLinkService); + readonly viewport = inject(ViewportService); + readonly isMobile = this.viewport.isMobile; readonly electronBridge = inject(ElectronBridgeService); readonly userStatus = inject(UserStatusService); readonly gameActivity = inject(GameActivityService); diff --git a/toju-app/src/app/core/platform/index.ts b/toju-app/src/app/core/platform/index.ts index 0165723..72e022d 100644 --- a/toju-app/src/app/core/platform/index.ts +++ b/toju-app/src/app/core/platform/index.ts @@ -1,2 +1,3 @@ export * from './platform.service'; export * from './external-link.service'; +export * from './viewport.service'; diff --git a/toju-app/src/app/core/platform/viewport.service.ts b/toju-app/src/app/core/platform/viewport.service.ts new file mode 100644 index 0000000..0b111c8 --- /dev/null +++ b/toju-app/src/app/core/platform/viewport.service.ts @@ -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); + }); + } +} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html index eff42d5..e1a5ac3 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html @@ -40,31 +40,43 @@
@if (showKlipyGifPicker()) { -
- -
+ @if (isMobile()) { + +
+ +
+
+ } @else {
- + class="fixed inset-0 z-[89]" + (click)="closeKlipyGifPicker()" + (keydown.enter)="closeKlipyGifPicker()" + (keydown.space)="closeKlipyGifPicker()" + tabindex="0" + role="button" + aria-label="Close GIF picker" + style="-webkit-app-region: no-drag" + >
+ +
+
+ +
-
+ } }
- @if (!msg.isDeleted) { + @if (!msg.isDeleted && !isMobile()) {
} + + + +
+
+

React

+
+ @for (emoji of commonEmojis; track emoji) { + + } +
+
+ +
+ + + + + + @if (isOwnMessage()) { + + } + + @if (isOwnMessage() || isAdmin()) { + + } +
+
+
diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts index 526252e..2efdcfc 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts @@ -8,15 +8,21 @@ import { effect, inject, input, + OnDestroy, output, signal, - ViewChild + TemplateRef, + ViewChild, + ViewContainerRef } from '@angular/core'; +import { Overlay, OverlayRef } from '@angular/cdk/overlay'; +import { TemplatePortal } from '@angular/cdk/portal'; import { Router } from '@angular/router'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideCheck, + lucideCopy, lucideDownload, lucideEdit, lucideExpand, @@ -34,7 +40,7 @@ import { MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES, MAX_AUTO_SAVE_SIZE_BYTES } from '../../../../../attachment'; -import { PlatformService } from '../../../../../../core/platform'; +import { PlatformService, ViewportService } from '../../../../../../core/platform'; import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service'; import { ExperimentalMediaSettingsService @@ -52,6 +58,7 @@ import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins'; import { + BottomSheetComponent, ChatAudioPlayerComponent, ChatVideoPlayerComponent, ProfileCardService, @@ -125,11 +132,13 @@ interface MissingPluginEmbedFallback { UserAvatarComponent, PluginRenderHostComponent, ExperimentalVlcPlayerComponent, - ThemeNodeDirective + ThemeNodeDirective, + BottomSheetComponent ], viewProviders: [ provideIcons({ lucideCheck, + lucideCopy, lucideDownload, lucideEdit, lucideExpand, @@ -148,8 +157,9 @@ interface MissingPluginEmbedFallback { style: 'display: contents;' } }) -export class ChatMessageItemComponent { +export class ChatMessageItemComponent implements OnDestroy { @ViewChild('editTextareaRef') editTextareaRef?: ElementRef; + @ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef; private readonly attachmentsSvc = inject(AttachmentFacade); private readonly klipy = inject(KlipyService); @@ -160,6 +170,13 @@ export class ChatMessageItemComponent { private readonly experimentalMedia = inject(ExperimentalMediaSettingsService); private readonly profileCard = inject(ProfileCardService); 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 experimentalPlayerAttachmentId = signal(null); private readonly mediaSupportCache = new Map(); @@ -360,6 +377,116 @@ export class ChatMessageItemComponent { 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 { + const text = this.message().content; + + try { + await navigator.clipboard.writeText(text); + } catch { + // Clipboard API unavailable; silently ignore. + } + + this.closeMobileActions(); + } + removeEmbed(url: string): void { this.embedRemoved.emit({ messageId: this.message().id, diff --git a/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.html b/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.html index aafd437..ea999b4 100644 --- a/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.html +++ b/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.html @@ -4,27 +4,29 @@ aria-label="KLIPY GIF picker" style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)" > -
-
-
KLIPY
-

Choose a GIF

-

- {{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }} -

-
+ @if (!isMobile()) { +
+
+
KLIPY
+

Choose a GIF

+

+ {{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }} +

+
- -
+ +
+ }
@@ -80,12 +82,14 @@
} @else { -
+
@for (gif of results(); track gif.id) { }
+ + @if (isMobile() && hasNext()) { +
+ +
+ } }
-
-

Click a GIF to select it. Powered by KLIPY.

+ @if (!isMobile()) { +
+

Click a GIF to select it. Powered by KLIPY.

- @if (hasNext()) { - - } -
+ @if (hasNext()) { + + } +
+ }
diff --git a/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.ts b/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.ts index 34c7af0..d1f9528 100644 --- a/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.ts +++ b/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.ts @@ -17,6 +17,7 @@ import { FormsModule } from '@angular/forms'; import { firstValueFrom } from 'rxjs'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { + lucideChevronDown, lucideImage, lucideSearch, lucideX @@ -24,6 +25,7 @@ import { import { KlipyGif, KlipyService } from '../../application/services/klipy.service'; import type { RoomSignalSourceInput } from '../../../server-directory'; import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive'; +import { ViewportService } from '../../../../core/platform'; const KLIPY_CARD_MIN_WIDTH = 140; const KLIPY_CARD_MAX_WIDTH = 248; @@ -42,6 +44,7 @@ const KLIPY_CARD_FALLBACK_SIZE = 160; ], viewProviders: [ provideIcons({ + lucideChevronDown, lucideImage, lucideSearch, lucideX @@ -58,6 +61,8 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy @ViewChild('searchInput') searchInput?: ElementRef; private readonly klipy = inject(KlipyService); + private readonly viewport = inject(ViewportService); + readonly isMobile = this.viewport.isMobile; private currentPage = 1; private searchTimer: ReturnType | null = null; private requestId = 0; diff --git a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html index 7abfe64..3fbfd17 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html +++ b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html @@ -97,29 +97,40 @@ @if (showGifPicker()) { -
- -
+ @if (isMobile()) { + +
+ +
+
+ } @else {
- + class="fixed inset-0 z-[89]" + tabindex="0" + role="button" + aria-label="Close GIF picker" + (click)="closeGifPicker()" + (keydown.enter)="closeGifPicker()" + (keydown.space)="closeGifPicker()" + >
+ +
+
+ +
-
+ } } (); private openedConversationId: string | null = null; + readonly isMobile = this.viewport.isMobile; readonly directCalls = inject(DirectCallService); readonly directMessages = inject(DirectMessageService); readonly currentUser = this.store.selectSignal(selectCurrentUser); diff --git a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-chat-panel.component.html b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-chat-panel.component.html index a7e336d..9408552 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-chat-panel.component.html +++ b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-chat-panel.component.html @@ -1,6 +1,6 @@
diff --git a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.html b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.html index 1821d8f..6521cf0 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.html +++ b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.html @@ -1,7 +1,53 @@ -
- - -
+@if (isMobile()) { + + + +
+ +
+ +
+
+
+ + +
+
+ +

Direct messages

+
+
+ +
+
+
+
+} @else { + +
+ + +
+} + diff --git a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts index 46807ee..b982aa6 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts @@ -1,46 +1,99 @@ /* eslint-disable @typescript-eslint/member-ordering */ import { + CUSTOM_ELEMENTS_SCHEMA, Component, + ElementRef, + NgZone, + OnDestroy, computed, effect, inject, - OnDestroy + signal, + viewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop'; 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 { DirectMessageService } from '../../application/services/direct-message.service'; import { DmChatPanelComponent } from './dm-chat-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 = { + 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({ selector: 'app-dm-workspace', standalone: true, imports: [ CommonModule, + NgIcon, DmChatPanelComponent, - DmConversationsPanelComponent + DmConversationsPanelComponent, + ServersRailComponent ], + viewProviders: [provideIcons({ lucideChevronLeft })], + schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './dm-workspace.component.html' }) export class DmWorkspaceComponent implements OnDestroy { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); 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 routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), { initialValue: this.route.snapshot.paramMap.get('conversationId') }); readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout')); + readonly isMobile = this.viewport.isMobile; + readonly swiperRef = viewChild>('swiperEl'); + + /** Active page within the mobile single-pane navigation flow. Ignored on desktop. */ + readonly mobilePage = signal('conversations'); constructor() { effect(() => { const conversationId = this.routeConversationId(); + const isMobile = this.isMobile(); if (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; } @@ -50,9 +103,55 @@ export class DmWorkspaceComponent implements OnDestroy { 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 { this.directMessages.closeConversationView(this.routeConversationId()); } } + diff --git a/toju-app/src/app/domains/plugins/feature/plugin-action-menu/plugin-action-menu.service.ts b/toju-app/src/app/domains/plugins/feature/plugin-action-menu/plugin-action-menu.service.ts index 5851d15..89b058f 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-action-menu/plugin-action-menu.service.ts +++ b/toju-app/src/app/domains/plugins/feature/plugin-action-menu/plugin-action-menu.service.ts @@ -15,6 +15,7 @@ import { fromEvent } from 'rxjs'; import { PluginActionMenuComponent } from './plugin-action-menu.component'; +import { ViewportService } from '../../../../core/platform'; const GAP = 10; const VIEWPORT_MARGIN = 8; @@ -28,6 +29,7 @@ const POSITIONS: ConnectedPosition[] = [ @Injectable({ providedIn: 'root' }) export class PluginActionMenuService { private readonly overlay = inject(Overlay); + private readonly viewport = inject(ViewportService); private currentOrigin: HTMLElement | null = null; private overlayRef: OverlayRef | null = null; private overlaySubscriptions: Subscription | null = null; @@ -47,20 +49,38 @@ export class PluginActionMenuService { } const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin); + const isMobile = this.viewport.isMobile(); this.currentOrigin = rawEl; - const positionStrategy = this.overlay - .position() - .flexibleConnectedTo(elementRef) - .withPositions(POSITIONS) - .withViewportMargin(VIEWPORT_MARGIN) - .withPush(true); + if (isMobile) { + const positionStrategy = this.overlay + .position() + .global() + .left('0') + .right('0') + .bottom('0'); - this.overlayRef = this.overlay.create({ - positionStrategy, - scrollStrategy: this.overlay.scrollStrategies.noop() - }); + 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 + .position() + .flexibleConnectedTo(elementRef) + .withPositions(POSITIONS) + .withViewportMargin(VIEWPORT_MARGIN) + .withPush(true); + + this.overlayRef = this.overlay.create({ + positionStrategy, + scrollStrategy: this.overlay.scrollStrategies.noop() + }); + } this.syncThemeVars(); @@ -68,6 +88,14 @@ export class PluginActionMenuService { const subscriptions = new Subscription(); 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(document, 'pointerdown') .pipe( filter((event) => { diff --git a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html index 8b9177e..a033599 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html +++ b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html @@ -1,6 +1,41 @@
-
+ +
+ + +

Search

+ + +
+ +
+
+ +
+ + +
+
-
+

Servers

@@ -215,7 +289,7 @@ } @else { - + @if (!isMobile()) { + + } @if (currentRoom()) { -
- - -
- @if (!isVoiceWorkspaceExpanded()) { - @if (hasTextChannels()) { -
- + +
+ +
+
- } @else { -
-
-
+
+ + + +
+
+ +
+ @if (activeChannel(); as channel) { +

+ @if (channel.type === 'text') { + + } + {{ channel.name }} +

+ } @else { +

{{ currentRoom()?.name }}

+ }
+
- } - } - -
+
+ @if (!isVoiceWorkspaceExpanded()) { + @if (hasTextChannels()) { +
+ +
+ } @else { +
+
+ +

No text channels

+

There are no existing text channels currently.

+
+
+ } + } -
+
+ + + +
+
+ +

Members

+
+ +
+
+ + } @else { + +
- - -
+ + +
+ @if (!isVoiceWorkspaceExpanded()) { + @if (hasTextChannels()) { +
+ +
+ } @else { +
+
+
+ +

+ No text channels +

+

There are no existing text channels currently.

+
+
+ } + } + + +
+ + +
+ } } @else {
}
+ diff --git a/toju-app/src/app/features/room/chat-room/chat-room.component.ts b/toju-app/src/app/features/room/chat-room/chat-room.component.ts index 7305e16..a48f86b 100644 --- a/toju-app/src/app/features/room/chat-room/chat-room.component.ts +++ b/toju-app/src/app/features/room/chat-room/chat-room.component.ts @@ -1,9 +1,14 @@ /* eslint-disable @typescript-eslint/member-ordering */ import { + CUSTOM_ELEMENTS_SCHEMA, Component, + ElementRef, + NgZone, computed, + effect, inject, - signal + signal, + viewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Store } from '@ngrx/store'; @@ -21,13 +26,37 @@ import { import { ChatMessagesComponent } from '../../../domains/chat/feature/chat-messages/chat-messages.component'; import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.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 { ViewportService } from '../../../core/platform'; import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; import { VoiceWorkspaceService } from '../../../domains/voice-session'; 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 = { + 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({ selector: 'app-chat-room', standalone: true, @@ -37,6 +66,7 @@ import { ThemeNodeDirective, ThemeService } from '../../../domains/theme'; ChatMessagesComponent, VoiceWorkspaceComponent, RoomsSidePanelComponent, + ServersRailComponent, ThemeNodeDirective ], viewProviders: [ @@ -50,22 +80,52 @@ import { ThemeNodeDirective, ThemeService } from '../../../domains/theme'; lucideChevronLeft }) ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], templateUrl: './chat-room.component.html' }) /** * 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 { private readonly store = inject(Store); private readonly settingsModal = inject(SettingsModalService); private readonly theme = inject(ThemeService); + private readonly viewport = inject(ViewportService); + private readonly zone = inject(NgZone); private voiceWorkspace = inject(VoiceWorkspaceService); + private lastSeenChannelId: string | null = null; + private lastSeenRoomId: string | null = null; + private swiperListenerAttached: SwiperElement | null = null; showMenu = signal(false); showAdminPanel = signal(false); + /** Active page within the mobile single-pane navigation flow. Ignored on desktop. */ + readonly mobilePage = signal('channels'); + readonly isMobile = this.viewport.isMobile; + readonly swiperRef = viewChild>('swiperEl'); + currentRoom = this.store.selectSignal(selectCurrentRoom); isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); 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; hasTextChannels = computed(() => this.textChannels().length > 0); roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout')); @@ -73,6 +133,82 @@ export class ChatRoomComponent { mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel')); 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. */ toggleAdminPanel() { const room = this.currentRoom(); @@ -82,3 +218,4 @@ export class ChatRoomComponent { } } } + diff --git a/toju-app/src/app/features/room/voice-workspace/voice-workspace.component.html b/toju-app/src/app/features/room/voice-workspace/voice-workspace.component.html index 3858b2f..82141b3 100644 --- a/toju-app/src/app/features/room/voice-workspace/voice-workspace.component.html +++ b/toju-app/src/app/features/room/voice-workspace/voice-workspace.component.html @@ -257,6 +257,7 @@ + }
@@ -52,8 +65,8 @@ } - +

+ @switch (activePage()) { + @case ('general') { + General + } + @case ('plugins') { + Client Plugins + } + @case ('network') { + Network + } + @case ('theme') { + Theme Studio + } + @case ('notifications') { + Notifications + } + @case ('voice') { + Voice & Audio + } + @case ('updates') { + Updates + } + @case ('localApi') { + Local API + } + @case ('data') { + Data + } + @case ('debugging') { + Debugging + } + @case ('server') { + Server Settings + } + @case ('serverPlugins') { + Server Plugins + } + @case ('members') { + Members + } + @case ('bans') { + Bans + } + @case ('permissions') { + Permissions + } + } +

+
+ +
+ +} @else { + +
+ + +
+
+

{{ title() }}

+
+ +
+
+
+ + +
-
- - -
-
+} diff --git a/toju-app/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts b/toju-app/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts index 2dd4c8b..7620e1c 100644 --- a/toju-app/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts +++ b/toju-app/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts @@ -1,15 +1,18 @@ import { Component, + HostListener, + inject, input, - output, - HostListener + output } from '@angular/core'; import { ThemeNodeDirective } from '../../../domains/theme'; +import { ViewportService } from '../../../core/platform'; +import { BottomSheetComponent } from '../bottom-sheet/bottom-sheet.component'; @Component({ selector: 'app-confirm-dialog', standalone: true, - imports: [ThemeNodeDirective], + imports: [ThemeNodeDirective, BottomSheetComponent], templateUrl: './confirm-dialog.component.html', host: { style: 'display: contents;' @@ -24,6 +27,8 @@ export class ConfirmDialogComponent { confirmed = output(); cancelled = output(); + readonly isMobile = inject(ViewportService).isMobile; + @HostListener('document:keydown.escape') onEscape(): void { this.cancelled.emit(undefined); diff --git a/toju-app/src/app/shared/components/context-menu/context-menu.component.html b/toju-app/src/app/shared/components/context-menu/context-menu.component.html index 35db0ab..4a66a70 100644 --- a/toju-app/src/app/shared/components/context-menu/context-menu.component.html +++ b/toju-app/src/app/shared/components/context-menu/context-menu.component.html @@ -1,23 +1,40 @@ - -
- -
- -
+ +@if (isMobile()) { + +
+ +
+
+} @else { + +
+ +
+ +
+} diff --git a/toju-app/src/app/shared/components/context-menu/context-menu.component.ts b/toju-app/src/app/shared/components/context-menu/context-menu.component.ts index f9a3647..9671d72 100644 --- a/toju-app/src/app/shared/components/context-menu/context-menu.component.ts +++ b/toju-app/src/app/shared/components/context-menu/context-menu.component.ts @@ -7,14 +7,17 @@ import { ViewChild, ElementRef, AfterViewInit, - OnInit + OnInit, + inject } from '@angular/core'; import { ThemeNodeDirective } from '../../../domains/theme'; +import { ViewportService } from '../../../core/platform'; +import { BottomSheetComponent } from '../bottom-sheet/bottom-sheet.component'; @Component({ selector: 'app-context-menu', standalone: true, - imports: [ThemeNodeDirective], + imports: [ThemeNodeDirective, BottomSheetComponent], templateUrl: './context-menu.component.html', styleUrl: './context-menu.component.scss' }) @@ -24,19 +27,32 @@ export class ContextMenuComponent implements OnInit, AfterViewInit { y = input.required(); width = input('w-48'); widthPx = input(null); + /** Optional title shown when the menu is presented as a mobile bottom sheet. */ + sheetTitle = input(null); closed = output(); - @ViewChild('panel', { static: true }) panelRef!: ElementRef; + @ViewChild('panel', { static: false }) panelRef?: ElementRef; + + private readonly viewport = inject(ViewportService); + readonly isMobile = this.viewport.isMobile; clampedX = signal(0); clampedY = signal(0); ngOnInit(): void { + if (this.isMobile()) { + return; + } + this.clampedX.set(this.clampX(this.x(), this.estimateWidth())); this.clampedY.set(this.clampY(this.y(), 80)); } ngAfterViewInit(): void { + if (this.isMobile() || !this.panelRef) { + return; + } + const rect = this.panelRef.nativeElement.getBoundingClientRect(); this.clampedX.set(this.clampX(this.x(), rect.width)); diff --git a/toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.html b/toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.html new file mode 100644 index 0000000..d6c53d7 --- /dev/null +++ b/toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.html @@ -0,0 +1,253 @@ +
+ @let profileUser = displayedUser(); + @let statusColor = currentStatusColor(); + @let statusLabel = currentStatusLabel(); + @let self = isSelf(); + @let friend = isFriend(); + @let isEditable = editable(); + @let activeField = editingField(); + +
+ +
+
+ + @if (isEditable) { + + + + } + +
+ +
+ @if (isEditable && activeField === 'displayName') { + + } @else if (isEditable) { + + } @else { +

{{ profileUser.displayName }}

+ } +
+ + @if (profileUser.username && profileUser.username !== profileUser.displayName) { +

{{ '@' + profileUser.username }}

+ } + + @if (isEditable) { +
+ + + @if (showStatusMenu()) { +
+ @for (opt of statusOptions; track opt.label) { + + } +
+ } +
+ } @else { +
+ + {{ statusLabel }} +
+ } +
+ +
+ @if (isEditable) { + @if (activeField === 'description') { + + } @else { + + } + } @else if (profileUser.description) { +

+ {{ profileUser.description }} +

+ } + + @if (avatarError()) { +
+ {{ avatarError() }} +
+ } + + @if (profileUser.gameActivity; as activity) { + + } +
+ + @if (!self) { +
+ + + + + +
+ } @else { +
+ } +
diff --git a/toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.ts b/toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.ts new file mode 100644 index 0000000..54ed0fd --- /dev/null +++ b/toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.ts @@ -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({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 }); + readonly editable = signal(false); + readonly closed = output(); + + readonly avatarAccept = PROFILE_AVATAR_ACCEPT_ATTRIBUTE; + readonly avatarError = signal(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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/toju-app/src/app/shared/components/profile-card/profile-card.service.ts b/toju-app/src/app/shared/components/profile-card/profile-card.service.ts index 475a3af..00481c7 100644 --- a/toju-app/src/app/shared/components/profile-card/profile-card.service.ts +++ b/toju-app/src/app/shared/components/profile-card/profile-card.service.ts @@ -15,7 +15,9 @@ import { fromEvent } from 'rxjs'; import { ProfileCardComponent } from './profile-card.component'; +import { ProfileCardMobileComponent } from './profile-card-mobile.component'; import { PROFILE_AVATAR_EDITOR_OVERLAY_CLASS } from '../../../domains/profile-avatar'; +import { ViewportService } from '../../../core/platform'; import { User } from '../../../shared-kernel'; export type ProfileCardPlacement = 'above' | 'left' | 'auto'; @@ -57,6 +59,7 @@ function positionsFor(placement: ProfileCardPlacement): ConnectedPosition[] { @Injectable({ providedIn: 'root' }) export class ProfileCardService { private readonly overlay = inject(Overlay); + private readonly viewport = inject(ViewportService); private overlayRef: OverlayRef | null = null; private currentOrigin: HTMLElement | null = null; private outsideClickSub: Subscription | null = null; @@ -76,23 +79,57 @@ export class ProfileCardService { const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin); const placement = options.placement ?? 'auto'; + const isMobile = this.viewport.isMobile(); this.currentOrigin = rawEl; - const positionStrategy = this.overlay - .position() - .flexibleConnectedTo(elementRef) - .withPositions(positionsFor(placement)) - .withViewportMargin(VIEWPORT_MARGIN) - .withPush(true); + if (isMobile) { + const positionStrategy = this.overlay + .position() + .global() + .left('0') + .right('0') + .bottom('0'); - this.overlayRef = this.overlay.create({ - positionStrategy, - scrollStrategy: this.overlay.scrollStrategies.noop() - }); + 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 + .position() + .flexibleConnectedTo(elementRef) + .withPositions(positionsFor(placement)) + .withViewportMargin(VIEWPORT_MARGIN) + .withPush(true); + + this.overlayRef = this.overlay.create({ + positionStrategy, + scrollStrategy: this.overlay.scrollStrategies.noop() + }); + } 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 ref = this.overlayRef.attach(portal); diff --git a/toju-app/src/app/shared/index.ts b/toju-app/src/app/shared/index.ts index b76629e..08cac13 100644 --- a/toju-app/src/app/shared/index.ts +++ b/toju-app/src/app/shared/index.ts @@ -2,6 +2,7 @@ * Shared reusable UI components barrel. */ 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 { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dialog.component'; export { LeaveServerDialogComponent } from './components/leave-server-dialog/leave-server-dialog.component'; diff --git a/toju-app/src/main.ts b/toju-app/src/main.ts index 47278f3..9f2b308 100644 --- a/toju-app/src/main.ts +++ b/toju-app/src/main.ts @@ -1,4 +1,5 @@ import { bootstrapApplication } from '@angular/platform-browser'; +import { register as registerSwiperElements } from 'swiper/element/bundle'; import { appConfig } from './app/app.config'; import { App } from './app/app'; import mermaid from 'mermaid'; @@ -9,6 +10,9 @@ declare global { } } +// Register Swiper custom elements (, ) globally. +registerSwiperElements(); + // Expose mermaid globally for ngx-remark's MermaidComponent window.mermaid = mermaid; mermaid.initialize({ diff --git a/toju-app/src/styles.scss b/toju-app/src/styles.scss index 516ea61..9718ed8 100644 --- a/toju-app/src/styles.scss +++ b/toju-app/src/styles.scss @@ -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 components; @tailwind utilities;