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;