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