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
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user