feat: Response mobile layout support v1
Some checks failed
Queue Release Build / prepare (push) Successful in 30s
Deploy Web Apps / deploy (push) Successful in 7m8s
Queue Release Build / build-windows (push) Successful in 28m11s
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-linux (push) Has started running
Some checks failed
Queue Release Build / prepare (push) Successful in 30s
Deploy Web Apps / deploy (push) Successful in 7m8s
Queue Release Build / build-windows (push) Successful in 28m11s
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-linux (push) Has started running
This commit is contained in:
20
package-lock.json
generated
20
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
69
toju-app/src/app/core/platform/viewport.service.ts
Normal file
69
toju-app/src/app/core/platform/viewport.service.ts
Normal file
@@ -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,
|
||||||
|
|||||||
@@ -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,7 +1,53 @@
|
|||||||
<div
|
@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
|
||||||
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()"
|
||||||
>
|
>
|
||||||
<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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,42 @@
|
|||||||
<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"
|
||||||
|
title="Settings"
|
||||||
|
(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 +52,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 +64,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 +97,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 +290,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) {
|
||||||
|
|||||||
@@ -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,5 +1,43 @@
|
|||||||
<!-- Backdrop -->
|
<!--
|
||||||
<div
|
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 -->
|
||||||
|
<div
|
||||||
class="fixed inset-0 z-40 bg-black/30"
|
class="fixed inset-0 z-40 bg-black/30"
|
||||||
(click)="cancelled.emit(undefined)"
|
(click)="cancelled.emit(undefined)"
|
||||||
(keydown.enter)="cancelled.emit(undefined)"
|
(keydown.enter)="cancelled.emit(undefined)"
|
||||||
@@ -7,14 +45,14 @@
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Close dialog"
|
aria-label="Close dialog"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Dialog -->
|
<!-- Dialog -->
|
||||||
<div
|
<div
|
||||||
appThemeNode="confirmDialogSurface"
|
appThemeNode="confirmDialogSurface"
|
||||||
class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg"
|
class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg"
|
||||||
[class]="widthClass()"
|
[class]="widthClass()"
|
||||||
>
|
>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<h4 class="font-semibold text-foreground mb-2">{{ title() }}</h4>
|
<h4 class="font-semibold text-foreground mb-2">{{ title() }}</h4>
|
||||||
<div class="text-sm text-muted-foreground">
|
<div class="text-sm text-muted-foreground">
|
||||||
@@ -43,4 +81,5 @@
|
|||||||
{{ confirmLabel() }}
|
{{ confirmLabel() }}
|
||||||
</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,5 +1,21 @@
|
|||||||
<!-- Invisible backdrop that captures clicks outside -->
|
<!--
|
||||||
<div
|
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 -->
|
||||||
|
<div
|
||||||
class="fixed inset-0 z-40"
|
class="fixed inset-0 z-40"
|
||||||
(click)="closed.emit(undefined)"
|
(click)="closed.emit(undefined)"
|
||||||
(contextmenu)="$event.preventDefault(); closed.emit(undefined)"
|
(contextmenu)="$event.preventDefault(); closed.emit(undefined)"
|
||||||
@@ -8,9 +24,9 @@
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Close menu"
|
aria-label="Close menu"
|
||||||
></div>
|
></div>
|
||||||
<!-- Positioned menu panel -->
|
<!-- Positioned menu panel -->
|
||||||
<div
|
<div
|
||||||
#panel
|
#panel
|
||||||
appThemeNode="contextMenuSurface"
|
appThemeNode="contextMenuSurface"
|
||||||
class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1"
|
class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1"
|
||||||
@@ -18,6 +34,7 @@
|
|||||||
[style.left.px]="clampedX()"
|
[style.left.px]="clampedX()"
|
||||||
[style.top.px]="clampedY()"
|
[style.top.px]="clampedY()"
|
||||||
[style.width.px]="widthPx() || null"
|
[style.width.px]="widthPx() || null"
|
||||||
>
|
>
|
||||||
<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);
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user