feat: Response mobile layout support v1
All checks were successful
Queue Release Build / prepare (push) Successful in 1m6s
Deploy Web Apps / deploy (push) Successful in 7m35s
Queue Release Build / build-windows (push) Successful in 29m57s
Queue Release Build / build-linux (push) Successful in 46m28s
Queue Release Build / finalize (push) Successful in 49s

This commit is contained in:
2026-05-18 02:25:16 +02:00
parent ecb1a4b3a0
commit dea114aed0
45 changed files with 2369 additions and 377 deletions

View File

@@ -40,31 +40,43 @@
</div>
@if (showKlipyGifPicker()) {
<div
class="fixed inset-0 z-[89]"
(click)="closeKlipyGifPicker()"
(keydown.enter)="closeKlipyGifPicker()"
(keydown.space)="closeKlipyGifPicker()"
tabindex="0"
role="button"
aria-label="Close GIF picker"
style="-webkit-app-region: no-drag"
></div>
<div class="pointer-events-none fixed inset-0 z-[90]">
@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
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="klipyGifPickerAnchorRight()"
>
<app-klipy-gif-picker
[signalSource]="currentRoom()"
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>
class="fixed inset-0 z-[89]"
(click)="closeKlipyGifPicker()"
(keydown.enter)="closeKlipyGifPicker()"
(keydown.space)="closeKlipyGifPicker()"
tabindex="0"
role="button"
aria-label="Close GIF picker"
style="-webkit-app-region: no-drag"
></div>
<div class="pointer-events-none fixed inset-0 z-[90]">
<div
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="klipyGifPickerAnchorRight()"
>
<app-klipy-gif-picker
[signalSource]="currentRoom()"
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>
</div>
</div>
</div>
}
}
<app-chat-message-overlays

View File

@@ -10,6 +10,8 @@ import {
} from '@angular/core';
import { Store } from '@ngrx/store';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform';
import { BottomSheetComponent } from '../../../../shared';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { Attachment, AttachmentFacade } from '../../../attachment';
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
@@ -45,6 +47,7 @@ import {
KlipyGifPickerComponent,
ChatMessageListComponent,
ChatMessageOverlaysComponent,
BottomSheetComponent,
ThemeNodeDirective
],
templateUrl: './chat-messages.component.html',
@@ -59,6 +62,9 @@ export class ChatMessagesComponent {
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
readonly allMessages = this.store.selectSignal(selectAllMessages);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);

View File

@@ -6,6 +6,10 @@
[attr.data-message-id]="msg.id"
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
[class.opacity-50]="msg.isDeleted"
(touchstart)="onMessageTouchStart($event)"
(touchend)="onMessageTouchEnd()"
(touchmove)="onMessageTouchEnd()"
(touchcancel)="onMessageTouchEnd()"
>
<div
appThemeNode="chatMessageAvatar"
@@ -469,7 +473,7 @@
}
</div>
@if (!msg.isDeleted) {
@if (!msg.isDeleted && !isMobile()) {
<div
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"
@@ -534,4 +538,83 @@
}
</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>

View File

@@ -8,15 +8,21 @@ import {
effect,
inject,
input,
OnDestroy,
output,
signal,
ViewChild
TemplateRef,
ViewChild,
ViewContainerRef
} from '@angular/core';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideCheck,
lucideCopy,
lucideDownload,
lucideEdit,
lucideExpand,
@@ -34,7 +40,7 @@ import {
MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES,
MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../../../attachment';
import { PlatformService } from '../../../../../../core/platform';
import { PlatformService, ViewportService } from '../../../../../../core/platform';
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import {
ExperimentalMediaSettingsService
@@ -52,6 +58,7 @@ import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin
import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins';
import {
BottomSheetComponent,
ChatAudioPlayerComponent,
ChatVideoPlayerComponent,
ProfileCardService,
@@ -125,11 +132,13 @@ interface MissingPluginEmbedFallback {
UserAvatarComponent,
PluginRenderHostComponent,
ExperimentalVlcPlayerComponent,
ThemeNodeDirective
ThemeNodeDirective,
BottomSheetComponent
],
viewProviders: [
provideIcons({
lucideCheck,
lucideCopy,
lucideDownload,
lucideEdit,
lucideExpand,
@@ -148,8 +157,9 @@ interface MissingPluginEmbedFallback {
style: 'display: contents;'
}
})
export class ChatMessageItemComponent {
export class ChatMessageItemComponent implements OnDestroy {
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
@ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>;
private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
@@ -160,6 +170,13 @@ export class ChatMessageItemComponent {
private readonly experimentalMedia = inject(ExperimentalMediaSettingsService);
private readonly profileCard = inject(ProfileCardService);
private readonly router = inject(Router);
private readonly viewport = inject(ViewportService);
private readonly overlay = inject(Overlay);
private readonly viewContainerRef = inject(ViewContainerRef);
private mobileSheetOverlayRef: OverlayRef | null = null;
private longPressTimer: number | null = null;
readonly isMobile = this.viewport.isMobile;
readonly mobileSheetOpen = signal(false);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
private readonly experimentalPlayerAttachmentId = signal<string | null>(null);
private readonly mediaSupportCache = new Map<string, boolean>();
@@ -360,6 +377,116 @@ export class ChatMessageItemComponent {
this.deleteRequested.emit(this.message());
}
onMessageTouchStart(event: TouchEvent): void {
if (!this.isMobile() || this.message().isDeleted) {
return;
}
if (event.touches.length !== 1) {
this.clearLongPressTimer();
return;
}
if (this.isEditableTarget(event.target)) {
this.clearLongPressTimer();
return;
}
this.clearLongPressTimer();
this.longPressTimer = window.setTimeout(() => {
this.longPressTimer = null;
this.openMobileSheet();
}, 500);
}
onMessageTouchEnd(): void {
this.clearLongPressTimer();
}
private clearLongPressTimer(): void {
if (this.longPressTimer !== null) {
window.clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
}
private isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) {
return false;
}
return target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !== null;
}
closeMobileActions(): void {
this.detachMobileSheet();
}
private openMobileSheet(): void {
if (this.mobileSheetOverlayRef || !this.mobileSheetTpl) {
this.mobileSheetOpen.set(true);
return;
}
const overlayRef = this.overlay.create({
positionStrategy: this.overlay.position().global(),
scrollStrategy: this.overlay.scrollStrategies.block(),
hasBackdrop: false,
panelClass: 'metoyou-chat-actions-sheet-pane'
});
const portal = new TemplatePortal(this.mobileSheetTpl, this.viewContainerRef);
overlayRef.attach(portal);
this.mobileSheetOverlayRef = overlayRef;
this.mobileSheetOpen.set(true);
}
private detachMobileSheet(): void {
this.mobileSheetOpen.set(false);
if (this.mobileSheetOverlayRef) {
this.mobileSheetOverlayRef.dispose();
this.mobileSheetOverlayRef = null;
}
}
ngOnDestroy(): void {
this.clearLongPressTimer();
this.detachMobileSheet();
}
onMobileReact(emoji: string): void {
this.addReaction(emoji);
this.closeMobileActions();
}
onMobileReply(): void {
this.requestReply();
this.closeMobileActions();
}
onMobileEdit(): void {
this.startEdit();
this.closeMobileActions();
}
onMobileDelete(): void {
this.requestDelete();
this.closeMobileActions();
}
async onMobileCopy(): Promise<void> {
const text = this.message().content;
try {
await navigator.clipboard.writeText(text);
} catch {
// Clipboard API unavailable; silently ignore.
}
this.closeMobileActions();
}
removeEmbed(url: string): void {
this.embedRemoved.emit({
messageId: this.message().id,

View File

@@ -4,27 +4,29 @@
aria-label="KLIPY GIF picker"
style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)"
>
<div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
</p>
</div>
@if (!isMobile()) {
<div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
</p>
</div>
<button
type="button"
(click)="close()"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary/80 hover:text-foreground"
aria-label="Close GIF picker"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
<button
type="button"
(click)="close()"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary/80 hover:text-foreground"
aria-label="Close GIF picker"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
}
<div class="border-b border-border/70 bg-secondary/10 px-5 py-4">
<label class="relative block">
@@ -37,7 +39,7 @@
type="text"
[ngModel]="searchQuery"
(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"
/>
</label>
@@ -80,12 +82,14 @@
</div>
</div>
} @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) {
<button
type="button"
(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
class="relative flex items-center justify-center overflow-hidden bg-secondary/30"
@@ -104,30 +108,55 @@
KLIPY
</span>
</div>
<div class="px-3 py-2">
<p class="truncate text-xs font-medium text-foreground">
{{ gif.title || 'KLIPY GIF' }}
</p>
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
</div>
@if (!isMobile()) {
<div class="px-3 py-2">
<p class="truncate text-xs font-medium text-foreground">
{{ gif.title || 'KLIPY GIF' }}
</p>
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
</div>
}
</button>
}
</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 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>
@if (!isMobile()) {
<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>
@if (hasNext()) {
<button
type="button"
(click)="loadMore()"
[disabled]="loading()"
class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{{ loading() ? 'Loading...' : 'Load more' }}
</button>
}
</div>
@if (hasNext()) {
<button
type="button"
(click)="loadMore()"
[disabled]="loading()"
class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{{ loading() ? 'Loading...' : 'Load more' }}
</button>
}
</div>
}
</div>

View File

@@ -17,6 +17,7 @@ import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideChevronDown,
lucideImage,
lucideSearch,
lucideX
@@ -24,6 +25,7 @@ import {
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import type { RoomSignalSourceInput } from '../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
import { ViewportService } from '../../../../core/platform';
const KLIPY_CARD_MIN_WIDTH = 140;
const KLIPY_CARD_MAX_WIDTH = 248;
@@ -42,6 +44,7 @@ const KLIPY_CARD_FALLBACK_SIZE = 160;
],
viewProviders: [
provideIcons({
lucideChevronDown,
lucideImage,
lucideSearch,
lucideX
@@ -58,6 +61,8 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
private readonly klipy = inject(KlipyService);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
private currentPage = 1;
private searchTimer: ReturnType<typeof setTimeout> | null = null;
private requestId = 0;

View File

@@ -97,29 +97,40 @@
</div>
@if (showGifPicker()) {
<div
class="fixed inset-0 z-[89]"
tabindex="0"
role="button"
aria-label="Close GIF picker"
(click)="closeGifPicker()"
(keydown.enter)="closeGifPicker()"
(keydown.space)="closeGifPicker()"
></div>
<div class="pointer-events-none fixed inset-0 z-[90]">
@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
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="gifPickerAnchorRight()"
>
<app-klipy-gif-picker
(gifSelected)="handleGifSelected($event)"
(closed)="closeGifPicker()"
/>
class="fixed inset-0 z-[89]"
tabindex="0"
role="button"
aria-label="Close GIF picker"
(click)="closeGifPicker()"
(keydown.enter)="closeGifPicker()"
(keydown.space)="closeGifPicker()"
></div>
<div class="pointer-events-none fixed inset-0 z-[90]">
<div
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="gifPickerAnchorRight()"
>
<app-klipy-gif-picker
(gifSelected)="handleGifSelected($event)"
(closed)="closeGifPicker()"
/>
</div>
</div>
</div>
}
}
<app-chat-message-overlays

View File

@@ -15,7 +15,8 @@ import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
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 { Attachment, AttachmentFacade } from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme';
@@ -61,6 +62,7 @@ interface DmStatusLabel {
ChatMessageListComponent,
ChatMessageOverlaysComponent,
KlipyGifPickerComponent,
BottomSheetComponent,
NgIcon,
ThemeNodeDirective,
UserAvatarComponent
@@ -80,8 +82,10 @@ export class DmChatComponent {
private readonly attachments = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly linkMetadata = inject(LinkMetadataService);
private readonly viewport = inject(ViewportService);
private readonly metadataRequestKeys = new Set<string>();
private openedConversationId: string | null = null;
readonly isMobile = this.viewport.isMobile;
readonly directCalls = inject(DirectCallService);
readonly directMessages = inject(DirectMessageService);
readonly currentUser = this.store.selectSignal(selectCurrentUser);

View File

@@ -1,6 +1,6 @@
<main
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()"
>
<app-dm-chat />

View File

@@ -1,7 +1,53 @@
<div
class="grid h-full min-h-0 overflow-hidden bg-background"
[ngStyle]="layoutStyles()"
>
<app-dm-conversations-panel />
<app-dm-chat-panel />
</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"
[ngStyle]="layoutStyles()"
>
<app-dm-conversations-panel />
<app-dm-chat-panel />
</div>
}

View File

@@ -1,46 +1,99 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
CUSTOM_ELEMENTS_SCHEMA,
Component,
ElementRef,
NgZone,
OnDestroy,
computed,
effect,
inject,
OnDestroy
signal,
viewChild
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronLeft } from '@ng-icons/lucide';
import { ServersRailComponent } from '../../../../features/servers/servers-rail/servers-rail.component';
import { ViewportService } from '../../../../core/platform';
import { ThemeService } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { DmChatPanelComponent } from './dm-chat-panel.component';
import { DmConversationsPanelComponent } from './dm-conversations-panel.component';
/** Mobile-only page identifier within the DM workspace flow. */
export type DmWorkspaceMobilePage = 'conversations' | 'chat';
const PAGE_TO_INDEX: Record<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({
selector: 'app-dm-workspace',
standalone: true,
imports: [
CommonModule,
NgIcon,
DmChatPanelComponent,
DmConversationsPanelComponent
DmConversationsPanelComponent,
ServersRailComponent
],
viewProviders: [provideIcons({ lucideChevronLeft })],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './dm-workspace.component.html'
})
export class DmWorkspaceComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly theme = inject(ThemeService);
private readonly viewport = inject(ViewportService);
private readonly zone = inject(NgZone);
private lastSeenConversationId: string | null = null;
private swiperListenerAttached: SwiperElement | null = null;
readonly directMessages = inject(DirectMessageService);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId')
});
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
readonly isMobile = this.viewport.isMobile;
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
readonly mobilePage = signal<DmWorkspaceMobilePage>('conversations');
constructor() {
effect(() => {
const conversationId = this.routeConversationId();
const isMobile = this.isMobile();
if (conversationId) {
void this.directMessages.openConversation(conversationId);
// Only auto-advance to the chat page when the conversation actually changes.
// Without this, pressing Back to the conversations list immediately bounces
// us forward again because the conversation id is still the same.
if (isMobile && conversationId !== this.lastSeenConversationId) {
this.mobilePage.set('chat');
}
this.lastSeenConversationId = conversationId;
return;
}
this.lastSeenConversationId = null;
// On mobile, stay on the conversations list and let the user pick one explicitly.
if (isMobile) {
this.mobilePage.set('conversations');
return;
}
@@ -50,9 +103,55 @@ export class DmWorkspaceComponent implements OnDestroy {
void this.router.navigate(['/dm', firstConversation.id], { replaceUrl: true });
}
});
// Mirror `mobilePage` into the Swiper instance so route-driven page changes and the
// header back button actually slide the carousel.
effect(() => {
const el = this.swiperRef()?.nativeElement;
const targetIndex = PAGE_TO_INDEX[this.mobilePage()];
if (el?.swiper && el.swiper.activeIndex !== targetIndex) {
el.swiper.slideTo(targetIndex);
}
});
// Bridge Swiper's slidechange event back into `mobilePage`.
effect((onCleanup) => {
const el = this.swiperRef()?.nativeElement;
if (!el || el === this.swiperListenerAttached) {
return;
}
const handler = (event: Event) => {
const detail = (event as CustomEvent).detail;
const swiper = Array.isArray(detail) ? detail[0] : detail;
const index = swiper?.activeIndex ?? 0;
const page = INDEX_TO_PAGE[index] ?? 'conversations';
this.zone.run(() => this.mobilePage.set(page));
};
el.addEventListener('swiperslidechange', handler);
this.swiperListenerAttached = el;
onCleanup(() => {
el.removeEventListener('swiperslidechange', handler);
if (this.swiperListenerAttached === el) {
this.swiperListenerAttached = null;
}
});
});
}
/** Set the active mobile page. No-op on desktop. */
setMobilePage(page: DmWorkspaceMobilePage): void {
this.mobilePage.set(page);
}
ngOnDestroy(): void {
this.directMessages.closeConversationView(this.routeConversationId());
}
}

View File

@@ -15,6 +15,7 @@ import {
fromEvent
} from 'rxjs';
import { PluginActionMenuComponent } from './plugin-action-menu.component';
import { ViewportService } from '../../../../core/platform';
const GAP = 10;
const VIEWPORT_MARGIN = 8;
@@ -28,6 +29,7 @@ const POSITIONS: ConnectedPosition[] = [
@Injectable({ providedIn: 'root' })
export class PluginActionMenuService {
private readonly overlay = inject(Overlay);
private readonly viewport = inject(ViewportService);
private currentOrigin: HTMLElement | null = null;
private overlayRef: OverlayRef | null = null;
private overlaySubscriptions: Subscription | null = null;
@@ -47,20 +49,38 @@ export class PluginActionMenuService {
}
const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin);
const isMobile = this.viewport.isMobile();
this.currentOrigin = rawEl;
const positionStrategy = this.overlay
.position()
.flexibleConnectedTo(elementRef)
.withPositions(POSITIONS)
.withViewportMargin(VIEWPORT_MARGIN)
.withPush(true);
if (isMobile) {
const positionStrategy = this.overlay
.position()
.global()
.left('0')
.right('0')
.bottom('0');
this.overlayRef = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.noop()
});
this.overlayRef = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.block(),
hasBackdrop: true,
backdropClass: 'cdk-overlay-dark-backdrop',
panelClass: 'metoyou-bottom-sheet-panel'
});
} else {
const positionStrategy = this.overlay
.position()
.flexibleConnectedTo(elementRef)
.withPositions(POSITIONS)
.withViewportMargin(VIEWPORT_MARGIN)
.withPush(true);
this.overlayRef = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.noop()
});
}
this.syncThemeVars();
@@ -68,6 +88,14 @@ export class PluginActionMenuService {
const subscriptions = new Subscription();
subscriptions.add(componentRef.instance.closed.subscribe(() => this.close()));
if (isMobile) {
subscriptions.add(this.overlayRef.backdropClick().subscribe(() => this.close()));
this.overlaySubscriptions = subscriptions;
return;
}
subscriptions.add(fromEvent<PointerEvent>(document, 'pointerdown')
.pipe(
filter((event) => {

View File

@@ -1,6 +1,41 @@
<div class="flex h-full min-h-0 flex-col">
<div class="border-b border-border px-3 py-3">
<div class="flex flex-col gap-2 md:flex-row md:items-center">
<!--
Mobile-only header row:
[Back] ----- Search ----- [Settings]
Hidden on >=md where the original inline header (search bar + buttons) is used.
-->
<div class="mb-2 flex items-center gap-2 md:hidden">
<button
type="button"
aria-label="Back to server view"
class="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-border bg-secondary text-muted-foreground transition-colors hover:bg-secondary/80"
[class.invisible]="!canGoBack()"
[disabled]="!canGoBack()"
(click)="goBack()"
>
<ng-icon
name="lucideArrowLeft"
class="h-5 w-5"
/>
</button>
<h1 class="min-w-0 flex-1 truncate text-center text-base font-semibold text-foreground">Search</h1>
<button
type="button"
aria-label="Settings"
class="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-border bg-secondary text-muted-foreground transition-colors hover:bg-secondary/80"
(click)="openSettings()"
>
<ng-icon
name="lucideSettings"
class="h-5 w-5"
/>
</button>
</div>
<div class="flex flex-row items-center gap-2">
<div class="relative min-w-0 flex-1">
<ng-icon
name="lucideSearch"
@@ -16,6 +51,7 @@
/>
</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">
<button
type="button"
@@ -27,12 +63,12 @@
name="lucidePlus"
class="h-4 w-4"
/>
Create
<span>Create</span>
</button>
<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"
(click)="openSettings()"
>
@@ -60,13 +96,51 @@
}
</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]">
<app-user-search-list
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"
/>
<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>
<h3 class="text-sm font-semibold text-foreground">Servers</h3>
@@ -215,7 +289,7 @@
} @else {
<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"
(click)="joinServer(server)"
>

View File

@@ -18,6 +18,7 @@ import {
} from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideArrowLeft,
lucideExternalLink,
lucideFileText,
lucideSearch,
@@ -34,14 +35,15 @@ import {
selectSearchResults,
selectIsSearching,
selectRoomsError,
selectSavedRooms
selectSavedRooms,
selectCurrentRoom
} from '../../../../store/rooms/rooms.selectors';
import {
Room,
User,
type PluginRequirementSummary
} from '../../../../shared-kernel';
import { ExternalLinkService } from '../../../../core/platform';
import { ExternalLinkService, ViewportService } from '../../../../core/platform';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { type ServerInfo } from '../../domain/models/server-directory.model';
@@ -83,6 +85,7 @@ interface JoinPluginConsentDialog {
],
viewProviders: [
provideIcons({
lucideArrowLeft,
lucideExternalLink,
lucideFileText,
lucideSearch,
@@ -110,14 +113,22 @@ export class ServerSearchComponent implements OnInit {
private webrtc = inject(RealtimeSessionFacade);
private pluginRequirements = inject(PluginRequirementService);
private pluginStore = inject(PluginStoreService);
private viewport = inject(ViewportService);
private searchSubject = new Subject<string>();
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 = '';
searchResults = this.store.selectSignal(selectSearchResults);
isSearching = this.store.selectSignal(selectIsSearching);
error = this.store.selectSignal(selectRoomsError);
savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom);
currentUser = this.store.selectSignal(selectCurrentUser);
activeEndpoints = this.serverDirectory.activeServers;
bannedServerLookup = signal<Record<string, boolean>>({});
@@ -235,6 +246,24 @@ export class ServerSearchComponent implements OnInit {
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. */
joinSavedRoom(room: Room): void {
this.openJoinedRoom(room);

View File

@@ -63,17 +63,19 @@
/>
</button>
<button
(click)="toggleScreenShare()"
type="button"
[class]="getCompactScreenShareClass()"
title="Toggle Screen Share"
>
<ng-icon
[name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
class="w-4 h-4"
/>
</button>
@if (!isMobile()) {
<button
(click)="toggleScreenShare()"
type="button"
[class]="getCompactScreenShareClass()"
title="Toggle Screen Share"
>
<ng-icon
[name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
class="w-4 h-4"
/>
</button>
}
<app-debug-console
launcherVariant="compact"

View File

@@ -24,6 +24,7 @@ import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { VoicePlaybackService } from '../../../../domains/voice-connection';
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
import { ViewportService } from '../../../../core/platform';
import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
@@ -59,6 +60,8 @@ import { ThemeNodeDirective } from '../../../../domains/theme';
export class FloatingVoiceControlsComponent implements OnInit {
private readonly webrtcService = inject(VoiceConnectionFacade);
private readonly screenShareService = inject(ScreenShareFacade);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
private readonly voiceSessionService = inject(VoiceSessionFacade);
private readonly voicePlayback = inject(VoicePlaybackService);
private readonly store = inject(Store);