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

@@ -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());
}
}