feat: Android APP V1 - Experimental Alpha
This commit is contained in:
@@ -155,53 +155,147 @@
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (klipyEnabled()) {
|
||||
<button
|
||||
#klipyTrigger
|
||||
type="button"
|
||||
(click)="toggleKlipyGifPicker()"
|
||||
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
|
||||
[class.border-primary]="showKlipyGifPicker()"
|
||||
[class.opacity-100]="inputHovered() || showKlipyGifPicker()"
|
||||
[class.opacity-70]="!inputHovered() && !showKlipyGifPicker()"
|
||||
[class.shadow-none]="!inputHovered() && !showKlipyGifPicker()"
|
||||
[class.text-primary]="showKlipyGifPicker()"
|
||||
aria-label="Search KLIPY GIFs"
|
||||
title="Search KLIPY GIFs"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideImage"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span class="hidden sm:inline">GIF</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
(click)="$event.stopPropagation(); toggleEmojiPicker()"
|
||||
(mouseenter)="randomizeEmojiButton()"
|
||||
class="inline-flex h-10 min-w-10 items-center justify-center rounded-2xl border border-border/70 bg-secondary/35 px-3 text-lg grayscale transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:grayscale-0"
|
||||
[class.opacity-100]="inputHovered() || showEmojiPicker()"
|
||||
[class.opacity-60]="!inputHovered() && !showEmojiPicker()"
|
||||
[class.grayscale-0]="showEmojiPicker()"
|
||||
aria-label="Open emoji selector"
|
||||
title="Open emoji selector"
|
||||
>
|
||||
{{ emojiButton() }}
|
||||
</button>
|
||||
|
||||
@if (showEmojiPicker()) {
|
||||
<div class="absolute bottom-full right-0 z-20 mb-2">
|
||||
<app-custom-emoji-picker
|
||||
[currentUserId]="currentUserId()"
|
||||
(emojiSelected)="insertEmoji($event)"
|
||||
(dismissed)="closeEmojiPicker()"
|
||||
@if (mergeComposerMediaActions()) {
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
(click)="$event.stopPropagation(); toggleComposerMediaMenu()"
|
||||
class="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-border/70 bg-secondary/55 text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
|
||||
[class.border-primary]="showComposerMediaMenu() || showEmojiPicker() || showKlipyGifPicker()"
|
||||
[class.opacity-100]="inputHovered() || showComposerMediaMenu() || showEmojiPicker() || showKlipyGifPicker()"
|
||||
[class.opacity-70]="!inputHovered() && !showComposerMediaMenu() && !showEmojiPicker() && !showKlipyGifPicker()"
|
||||
[class.text-primary]="showComposerMediaMenu() || showEmojiPicker() || showKlipyGifPicker()"
|
||||
aria-label="Add attachment, GIF, or emoji"
|
||||
title="Add attachment, GIF, or emoji"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@if (showComposerMediaMenu()) {
|
||||
<app-bottom-sheet
|
||||
title="Add to message"
|
||||
ariaLabel="Add to message"
|
||||
(dismissed)="closeComposerMediaMenu()"
|
||||
>
|
||||
<div class="flex flex-col py-1">
|
||||
@for (option of composerMediaMenuOptions(); track option.action) {
|
||||
<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)="handleComposerMediaMenuAction(option.action)"
|
||||
>
|
||||
@switch (option.action) {
|
||||
@case ('attachment') {
|
||||
<ng-icon
|
||||
name="lucidePaperclip"
|
||||
class="h-5 w-5 text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
@case ('gif') {
|
||||
<ng-icon
|
||||
name="lucideImage"
|
||||
class="h-5 w-5 text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
@case ('emoji') {
|
||||
<ng-icon
|
||||
name="lucideSmile"
|
||||
class="h-5 w-5 text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
}
|
||||
<span>{{ option.label }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</app-bottom-sheet>
|
||||
}
|
||||
|
||||
@if (showEmojiPicker()) {
|
||||
<app-bottom-sheet
|
||||
title="Emoji"
|
||||
ariaLabel="Emoji picker"
|
||||
(dismissed)="closeEmojiPicker()"
|
||||
>
|
||||
<app-custom-emoji-picker
|
||||
[compact]="false"
|
||||
[inline]="true"
|
||||
[currentUserId]="currentUserId()"
|
||||
(emojiSelected)="insertEmoji($event)"
|
||||
(dismissed)="closeEmojiPicker()"
|
||||
/>
|
||||
</app-bottom-sheet>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@if (shouldShowAttachmentButton()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="pickAttachmentsFromDevice()"
|
||||
class="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-border/70 bg-secondary/55 text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground md:h-10 md:w-10"
|
||||
[class.opacity-100]="inputHovered()"
|
||||
[class.opacity-70]="!inputHovered()"
|
||||
aria-label="Attach files"
|
||||
title="Attach files"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePaperclip"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (klipyEnabled()) {
|
||||
<button
|
||||
#klipyTrigger
|
||||
type="button"
|
||||
(click)="toggleKlipyGifPicker()"
|
||||
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
|
||||
[class.border-primary]="showKlipyGifPicker()"
|
||||
[class.opacity-100]="inputHovered() || showKlipyGifPicker()"
|
||||
[class.opacity-70]="!inputHovered() && !showKlipyGifPicker()"
|
||||
[class.shadow-none]="!inputHovered() && !showKlipyGifPicker()"
|
||||
[class.text-primary]="showKlipyGifPicker()"
|
||||
aria-label="Search KLIPY GIFs"
|
||||
title="Search KLIPY GIFs"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideImage"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span class="hidden sm:inline">GIF</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
(click)="$event.stopPropagation(); toggleEmojiPicker()"
|
||||
(mouseenter)="randomizeEmojiButton()"
|
||||
class="inline-flex h-10 min-w-10 items-center justify-center rounded-2xl border border-border/70 bg-secondary/35 px-3 text-lg grayscale transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:grayscale-0"
|
||||
[class.opacity-100]="inputHovered() || showEmojiPicker()"
|
||||
[class.opacity-60]="!inputHovered() && !showEmojiPicker()"
|
||||
[class.grayscale-0]="showEmojiPicker()"
|
||||
aria-label="Open emoji selector"
|
||||
title="Open emoji selector"
|
||||
>
|
||||
{{ emojiButton() }}
|
||||
</button>
|
||||
|
||||
@if (showEmojiPicker()) {
|
||||
<div class="absolute bottom-full right-0 z-20 mb-2">
|
||||
<app-custom-emoji-picker
|
||||
[currentUserId]="currentUserId()"
|
||||
(emojiSelected)="insertEmoji($event)"
|
||||
(dismissed)="closeEmojiPicker()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<button
|
||||
appThemeNode="chatComposerSendButton"
|
||||
@@ -239,8 +333,7 @@
|
||||
[class.border-primary]="dragActive()"
|
||||
[class.chat-textarea-expanded]="textareaExpanded()"
|
||||
[class.ctrl-resize]="ctrlHeld()"
|
||||
[class.pr-28]="!klipyEnabled()"
|
||||
[class.pr-52]="klipyEnabled()"
|
||||
[ngClass]="composerTextareaPaddingClass()"
|
||||
></textarea>
|
||||
|
||||
@if (dragActive()) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
@@ -15,8 +16,11 @@ import {
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideImage,
|
||||
lucidePaperclip,
|
||||
lucidePlus,
|
||||
lucideReply,
|
||||
lucideSend,
|
||||
lucideSmile,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import type { ClipboardFilePayload } from '../../../../../../core/platform/electron/electron-api.models';
|
||||
@@ -41,6 +45,15 @@ import {
|
||||
replaceCustomEmojiTextAliases
|
||||
} from '../../../../../custom-emoji';
|
||||
import { annotateLocalFilePath } from '../../../../../attachment';
|
||||
import { BottomSheetComponent } from '../../../../../../shared';
|
||||
import { ViewportService } from '../../../../../../core/platform/viewport.service';
|
||||
import { MobileMediaService, MobilePlatformService } from '../../../../../../infrastructure/mobile';
|
||||
import {
|
||||
buildComposerMediaMenuOptions,
|
||||
resolveComposerTextareaPaddingClass,
|
||||
shouldMergeComposerMediaActions,
|
||||
type ComposerMediaMenuAction
|
||||
} from './composer-media-menu.rules';
|
||||
|
||||
type LocalFileWithPath = File & {
|
||||
path?: string;
|
||||
@@ -58,13 +71,17 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
|
||||
ChatImageProxyFallbackDirective,
|
||||
CustomEmojiPickerComponent,
|
||||
TypingIndicatorComponent,
|
||||
ThemeNodeDirective
|
||||
ThemeNodeDirective,
|
||||
BottomSheetComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideImage,
|
||||
lucidePaperclip,
|
||||
lucidePlus,
|
||||
lucideReply,
|
||||
lucideSend,
|
||||
lucideSmile,
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
@@ -99,8 +116,24 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly pluginApi = inject(PluginClientApiService);
|
||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||
private readonly customEmoji = inject(CustomEmojiService);
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
private readonly mobileMedia = inject(MobileMediaService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
|
||||
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
||||
readonly shouldShowAttachmentButton = this.mobilePlatform.shouldShowAttachmentButton;
|
||||
readonly mergeComposerMediaActions = computed(() => shouldMergeComposerMediaActions(this.viewport.isMobile()));
|
||||
readonly composerMediaMenuOptions = computed(() =>
|
||||
buildComposerMediaMenuOptions(this.shouldShowAttachmentButton(), this.klipyEnabled())
|
||||
);
|
||||
readonly composerTextareaPaddingClass = computed(() =>
|
||||
resolveComposerTextareaPaddingClass({
|
||||
isMobileViewport: this.viewport.isMobile(),
|
||||
showAttachment: this.shouldShowAttachmentButton(),
|
||||
klipyEnabled: this.klipyEnabled()
|
||||
})
|
||||
);
|
||||
readonly showComposerMediaMenu = signal(false);
|
||||
readonly showEmojiPicker = signal(false);
|
||||
readonly emojiButton = signal('🙂');
|
||||
readonly pluginComposerActions = this.pluginUi.composerActionRecords;
|
||||
@@ -239,6 +272,30 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
this.klipyGifPickerToggleRequested.emit();
|
||||
}
|
||||
|
||||
toggleComposerMediaMenu(): void {
|
||||
this.showComposerMediaMenu.update((open) => !open);
|
||||
}
|
||||
|
||||
closeComposerMediaMenu(): void {
|
||||
this.showComposerMediaMenu.set(false);
|
||||
}
|
||||
|
||||
handleComposerMediaMenuAction(action: ComposerMediaMenuAction): void {
|
||||
this.closeComposerMediaMenu();
|
||||
|
||||
switch (action) {
|
||||
case 'attachment':
|
||||
void this.pickAttachmentsFromDevice();
|
||||
break;
|
||||
case 'gif':
|
||||
this.toggleKlipyGifPicker();
|
||||
break;
|
||||
case 'emoji':
|
||||
this.showEmojiPicker.set(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
toggleEmojiPicker(): void {
|
||||
this.showEmojiPicker.update((open) => !open);
|
||||
}
|
||||
@@ -247,6 +304,12 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
this.showEmojiPicker.set(false);
|
||||
}
|
||||
|
||||
async pickAttachmentsFromDevice(): Promise<void> {
|
||||
const files = await this.mobileMedia.pickAttachments();
|
||||
|
||||
this.addPendingFiles(files);
|
||||
}
|
||||
|
||||
randomizeEmojiButton(): void {
|
||||
const emojis = [
|
||||
'🙂',
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
buildComposerMediaMenuOptions,
|
||||
resolveComposerTextareaPaddingClass,
|
||||
shouldMergeComposerMediaActions
|
||||
} from './composer-media-menu.rules';
|
||||
|
||||
describe('composer-media-menu.rules', () => {
|
||||
describe('shouldMergeComposerMediaActions', () => {
|
||||
it('merges attachment, gif, and emoji triggers on mobile viewports', () => {
|
||||
expect(shouldMergeComposerMediaActions(true)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps separate triggers on desktop viewports', () => {
|
||||
expect(shouldMergeComposerMediaActions(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildComposerMediaMenuOptions', () => {
|
||||
it('includes attachment, gif, and emoji when all are available', () => {
|
||||
expect(buildComposerMediaMenuOptions(true, true)).toEqual([
|
||||
{ action: 'attachment', label: 'Attach files' },
|
||||
{ action: 'gif', label: 'GIF' },
|
||||
{ action: 'emoji', label: 'Emoji' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits attachment when the picker is unavailable', () => {
|
||||
expect(buildComposerMediaMenuOptions(false, true)).toEqual([{ action: 'gif', label: 'GIF' }, { action: 'emoji', label: 'Emoji' }]);
|
||||
});
|
||||
|
||||
it('omits gif when klipy is disabled', () => {
|
||||
const options = buildComposerMediaMenuOptions(true, false);
|
||||
|
||||
expect(options.map((option) => option.action)).toEqual(['attachment', 'emoji']);
|
||||
});
|
||||
|
||||
it('always includes emoji even when attachment and gif are unavailable', () => {
|
||||
expect(buildComposerMediaMenuOptions(false, false)).toEqual([{ action: 'emoji', label: 'Emoji' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveComposerTextareaPaddingClass', () => {
|
||||
it('uses compact padding when mobile actions are merged', () => {
|
||||
expect(
|
||||
resolveComposerTextareaPaddingClass({
|
||||
isMobileViewport: true,
|
||||
showAttachment: true,
|
||||
klipyEnabled: true
|
||||
})
|
||||
).toBe('pr-28');
|
||||
});
|
||||
|
||||
it('preserves desktop padding combinations', () => {
|
||||
expect(
|
||||
resolveComposerTextareaPaddingClass({
|
||||
isMobileViewport: false,
|
||||
showAttachment: true,
|
||||
klipyEnabled: false
|
||||
})
|
||||
).toBe('pr-36');
|
||||
|
||||
expect(
|
||||
resolveComposerTextareaPaddingClass({
|
||||
isMobileViewport: false,
|
||||
showAttachment: true,
|
||||
klipyEnabled: true
|
||||
})
|
||||
).toBe('pr-52');
|
||||
|
||||
expect(
|
||||
resolveComposerTextareaPaddingClass({
|
||||
isMobileViewport: false,
|
||||
showAttachment: false,
|
||||
klipyEnabled: false
|
||||
})
|
||||
).toBe('pr-28');
|
||||
|
||||
expect(
|
||||
resolveComposerTextareaPaddingClass({
|
||||
isMobileViewport: false,
|
||||
showAttachment: false,
|
||||
klipyEnabled: true
|
||||
})
|
||||
).toBe('pr-44');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
export type ComposerMediaMenuAction = 'attachment' | 'gif' | 'emoji';
|
||||
|
||||
export interface ComposerMediaMenuOption {
|
||||
action: ComposerMediaMenuAction;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ComposerTextareaPaddingInput {
|
||||
isMobileViewport: boolean;
|
||||
showAttachment: boolean;
|
||||
klipyEnabled: boolean;
|
||||
}
|
||||
|
||||
/** Whether the composer should expose one media menu trigger instead of separate buttons. */
|
||||
export function shouldMergeComposerMediaActions(isMobileViewport: boolean): boolean {
|
||||
return isMobileViewport;
|
||||
}
|
||||
|
||||
/** Build the actions shown in the merged mobile composer media menu. */
|
||||
export function buildComposerMediaMenuOptions(
|
||||
showAttachment: boolean,
|
||||
klipyEnabled: boolean
|
||||
): ComposerMediaMenuOption[] {
|
||||
const options: ComposerMediaMenuOption[] = [];
|
||||
|
||||
if (showAttachment) {
|
||||
options.push({ action: 'attachment', label: 'Attach files' });
|
||||
}
|
||||
|
||||
if (klipyEnabled) {
|
||||
options.push({ action: 'gif', label: 'GIF' });
|
||||
}
|
||||
|
||||
options.push({ action: 'emoji', label: 'Emoji' });
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Resolve textarea right padding based on how many composer action buttons are visible. */
|
||||
export function resolveComposerTextareaPaddingClass(input: ComposerTextareaPaddingInput): string {
|
||||
if (input.isMobileViewport) {
|
||||
return 'pr-28';
|
||||
}
|
||||
|
||||
if (input.showAttachment && !input.klipyEnabled) {
|
||||
return 'pr-36';
|
||||
}
|
||||
|
||||
if (input.showAttachment && input.klipyEnabled) {
|
||||
return 'pr-52';
|
||||
}
|
||||
|
||||
if (!input.showAttachment && !input.klipyEnabled) {
|
||||
return 'pr-28';
|
||||
}
|
||||
|
||||
return 'pr-44';
|
||||
}
|
||||
@@ -36,23 +36,34 @@
|
||||
}
|
||||
|
||||
@if (!compact() || modalOpen()) {
|
||||
<div class="absolute bottom-full right-0 z-20 mb-2 w-72 rounded-lg border border-border bg-card p-3 shadow-xl">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-semibold text-foreground">Emoji</p>
|
||||
@if (compact()) {
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-7 w-7 place-items-center rounded hover:bg-secondary"
|
||||
aria-label="Close emoji selector"
|
||||
(click)="closeModal()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border border-border bg-card p-3 shadow-xl"
|
||||
[class.absolute]="!inline()"
|
||||
[class.bottom-full]="!inline()"
|
||||
[class.right-0]="!inline()"
|
||||
[class.z-20]="!inline()"
|
||||
[class.mb-2]="!inline()"
|
||||
[class.w-72]="!inline()"
|
||||
[class.w-full]="inline()"
|
||||
>
|
||||
@if (!inline()) {
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-semibold text-foreground">Emoji</p>
|
||||
@if (compact()) {
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-7 w-7 place-items-center rounded hover:bg-secondary"
|
||||
aria-label="Close emoji selector"
|
||||
(click)="closeModal()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<label class="relative mb-3 block">
|
||||
<ng-icon
|
||||
|
||||
@@ -148,4 +148,10 @@ describe('CustomEmojiPickerComponent', () => {
|
||||
expect(component.searchQuery()).toBe('');
|
||||
});
|
||||
|
||||
it('defaults inline mode to false for floating popover embedding', () => {
|
||||
const component = createComponent(true);
|
||||
|
||||
expect(component.inline()).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -43,6 +43,8 @@ export class CustomEmojiPickerComponent {
|
||||
|
||||
readonly currentUserId = input<string | null>(null);
|
||||
readonly compact = input(true);
|
||||
/** Render the picker panel in normal document flow for bottom-sheet embedding. */
|
||||
readonly inline = input(false);
|
||||
|
||||
readonly emojiSelected = output<string>();
|
||||
readonly dismissed = output();
|
||||
|
||||
@@ -9,6 +9,10 @@ import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Subject } from 'rxjs';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import {
|
||||
MobileCallSessionService,
|
||||
MobileNotificationsService
|
||||
} from '../../../../infrastructure/mobile';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import {
|
||||
VoiceActivityService,
|
||||
@@ -547,6 +551,22 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
||||
useValue: {
|
||||
isMobile: vi.fn(() => false)
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: MobileNotificationsService,
|
||||
useValue: {
|
||||
initialize: vi.fn(async () => undefined),
|
||||
showIncomingCall: vi.fn(async () => undefined)
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: MobileCallSessionService,
|
||||
useValue: {
|
||||
initialize: vi.fn(),
|
||||
onCallControlAction: vi.fn(),
|
||||
startActiveCall: vi.fn(async () => undefined),
|
||||
endActiveCall: vi.fn(async () => undefined)
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import { MobileCallSessionService, MobileNotificationsService } from '../../../../infrastructure/mobile';
|
||||
import {
|
||||
VoiceActivityService,
|
||||
VoiceConnectionFacade,
|
||||
@@ -40,10 +41,14 @@ export class DirectCallService {
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly playback = inject(VoicePlaybackService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly mobileNotifications = inject(MobileNotificationsService);
|
||||
private readonly mobileCallSession = inject(MobileCallSessionService);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
||||
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
|
||||
private readonly mobileOverlayCallId = signal<string | null>(null);
|
||||
private readonly pendingIncomingCallPayloads: DirectCallEventPayload[] = [];
|
||||
private readonly declinedCallIds = new Set<string>();
|
||||
|
||||
readonly sessions = computed(() => this.sessionsSignal());
|
||||
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
|
||||
@@ -79,6 +84,24 @@ export class DirectCallService {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.mobileCallSession.initialize();
|
||||
this.mobileCallSession.onCallControlAction((intent, callId) => {
|
||||
if (intent === 'answer') {
|
||||
void this.answerIncomingCall(callId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent === 'toggle-mute') {
|
||||
this.voice.toggleMute(!this.voice.isMuted());
|
||||
void this.syncActiveCallNotification(callId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent === 'hang-up') {
|
||||
this.leaveCall(callId);
|
||||
}
|
||||
});
|
||||
|
||||
this.delivery.directCallEvents$.subscribe((event) => {
|
||||
if (event.directCall) {
|
||||
void this.handleIncomingCallEvent(event.directCall);
|
||||
@@ -110,6 +133,14 @@ export class DirectCallService {
|
||||
this.mobileOverlayCallId.set(null);
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (!this.currentUserId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.drainPendingIncomingCallPayloads();
|
||||
});
|
||||
}
|
||||
|
||||
sessionById(callId: string | null | undefined): DirectCallSession | null {
|
||||
@@ -171,6 +202,13 @@ export class DirectCallService {
|
||||
|
||||
this.upsertSession(session);
|
||||
this.currentSession.set(session);
|
||||
await this.directMessages.recordCallStarted(
|
||||
conversation.id,
|
||||
meParticipant,
|
||||
[meParticipant, peerParticipant],
|
||||
session.createdAt
|
||||
);
|
||||
|
||||
await this.joinCall(session.callId, false);
|
||||
this.sendCallEvent(peerParticipant.userId, 'ring', session);
|
||||
await this.openCallView(session.callId);
|
||||
@@ -242,6 +280,7 @@ export class DirectCallService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.declinedCallIds.add(callId);
|
||||
const meId = this.currentUserId();
|
||||
const nextSession = meId
|
||||
? {
|
||||
@@ -306,6 +345,7 @@ export class DirectCallService {
|
||||
|
||||
this.upsertSession(nextSession);
|
||||
this.currentSession.set(nextSession);
|
||||
void this.syncActiveCallNotification(callId);
|
||||
|
||||
if (notifyPeers) {
|
||||
this.broadcastCallEvent('join', nextSession);
|
||||
@@ -342,6 +382,7 @@ export class DirectCallService {
|
||||
this.upsertSession(nextSession);
|
||||
|
||||
this.currentSession.set(null);
|
||||
void this.mobileCallSession.endActiveCall(session.callId);
|
||||
}
|
||||
|
||||
async inviteUser(callId: string, user: User): Promise<void> {
|
||||
@@ -384,10 +425,32 @@ export class DirectCallService {
|
||||
return participant ? participantToUser(participant) : null;
|
||||
}
|
||||
|
||||
private async drainPendingIncomingCallPayloads(): Promise<void> {
|
||||
if (this.pendingIncomingCallPayloads.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = [...this.pendingIncomingCallPayloads];
|
||||
|
||||
this.pendingIncomingCallPayloads.length = 0;
|
||||
|
||||
for (const payload of pending) {
|
||||
await this.handleIncomingCallEvent(payload);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIncomingCallEvent(payload: DirectCallEventPayload): Promise<void> {
|
||||
const meId = this.currentUserId();
|
||||
|
||||
if (!meId || payload.sender.userId === meId) {
|
||||
if (!meId) {
|
||||
if (payload.action === 'ring') {
|
||||
this.pendingIncomingCallPayloads.push(payload);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.sender.userId === meId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -395,6 +458,10 @@ export class DirectCallService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.action === 'ring' && this.declinedCallIds.has(payload.callId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const participants = this.callParticipantsFromPayload(payload);
|
||||
const existing = this.sessionById(payload.callId);
|
||||
const incomingSession = this.createSession({
|
||||
@@ -423,18 +490,7 @@ export class DirectCallService {
|
||||
}
|
||||
|
||||
if (payload.action === 'ring') {
|
||||
await this.ensureCallConversation(session);
|
||||
|
||||
if (this.shouldAlertIncomingCall(session)) {
|
||||
this.audio.playLoop(AppSound.Call);
|
||||
} else {
|
||||
this.audio.stop(AppSound.Call);
|
||||
}
|
||||
|
||||
if (this.shouldAlertIncomingCall(session)) {
|
||||
await this.showIncomingNotification(payload.sender.displayName, payload.callId);
|
||||
}
|
||||
|
||||
await this.handleIncomingRingEvent(payload, session);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -445,9 +501,36 @@ export class DirectCallService {
|
||||
this.stopLocalMedia(session);
|
||||
this.currentSession.set(null);
|
||||
}
|
||||
|
||||
void this.mobileCallSession.endActiveCall(payload.callId);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIncomingRingEvent(payload: DirectCallEventPayload, session: DirectCallSession): Promise<void> {
|
||||
await this.ensureCallConversation(session);
|
||||
await this.directMessages.recordCallStarted(
|
||||
session.conversationId,
|
||||
payload.sender,
|
||||
Object.values(session.participants).map((participant) => participant.profile),
|
||||
payload.createdAt
|
||||
);
|
||||
|
||||
const latestSession = this.sessionById(payload.callId) ?? session;
|
||||
|
||||
if (this.declinedCallIds.has(payload.callId) || latestSession.status === 'ended') {
|
||||
this.audio.stop(AppSound.Call);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shouldAlertIncomingCall(latestSession)) {
|
||||
this.audio.playLoop(AppSound.Call);
|
||||
await this.showIncomingNotification(payload.sender.displayName, payload.callId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.audio.stop(AppSound.Call);
|
||||
}
|
||||
|
||||
private async startGroupCall(conversation: DirectMessageConversation): Promise<DirectCallSession> {
|
||||
const me = this.requireCurrentUser();
|
||||
const meParticipant = toDirectMessageParticipant(me);
|
||||
@@ -872,28 +955,33 @@ export class DirectCallService {
|
||||
}
|
||||
|
||||
private async showIncomingNotification(displayName: string, callId: string): Promise<void> {
|
||||
if (typeof Notification === 'undefined') {
|
||||
await this.mobileNotifications.showIncomingCall(displayName, callId);
|
||||
}
|
||||
|
||||
private async syncActiveCallNotification(callId: string): Promise<void> {
|
||||
const session = this.sessionById(callId);
|
||||
|
||||
if (!session || !this.isCurrentUserJoined(session)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let permission = Notification.permission;
|
||||
|
||||
if (permission === 'default') {
|
||||
permission = await Notification.requestPermission();
|
||||
}
|
||||
|
||||
if (permission !== 'granted') {
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = new Notification('Incoming call', {
|
||||
body: `${displayName} is calling you`
|
||||
await this.mobileCallSession.startActiveCall({
|
||||
callId,
|
||||
displayName: this.activeCallDisplayName(session),
|
||||
isMuted: this.voice.isMuted()
|
||||
});
|
||||
}
|
||||
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
void this.router.navigate(['/call', callId]);
|
||||
};
|
||||
private activeCallDisplayName(session: DirectCallSession): string {
|
||||
const remoteNames = this.remoteParticipantIds(session)
|
||||
.map((participantId) => session.participants[participantId]?.profile.displayName)
|
||||
.filter((name): name is string => !!name);
|
||||
|
||||
if (remoteNames.length > 0) {
|
||||
return remoteNames.join(', ');
|
||||
}
|
||||
|
||||
return 'Call in progress';
|
||||
}
|
||||
|
||||
private uniqueParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { endpointSupportsServerDiscovery } from './server-discovery.rules';
|
||||
|
||||
describe('server-discovery.rules', () => {
|
||||
it('skips discovery calls for production signal hosts without featured/trending routes', () => {
|
||||
expect(endpointSupportsServerDiscovery('https://signal.toju.app')).toBe(false);
|
||||
expect(endpointSupportsServerDiscovery('https://signal-sweden.toju.app')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows discovery on local and custom signal servers', () => {
|
||||
expect(endpointSupportsServerDiscovery('http://localhost:3001')).toBe(true);
|
||||
expect(endpointSupportsServerDiscovery('https://signal.example.com')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Hostnames known to run older signal servers without featured/trending discovery routes. */
|
||||
const DISCOVERY_UNSUPPORTED_HOSTS = new Set([
|
||||
'signal.toju.app',
|
||||
'signal-sweden.toju.app'
|
||||
]);
|
||||
|
||||
/** Returns false when discovery endpoints are known to 404 on the active signal server. */
|
||||
export function endpointSupportsServerDiscovery(baseUrl: string): boolean {
|
||||
try {
|
||||
const hostname = new URL(baseUrl).hostname;
|
||||
|
||||
return !DISCOVERY_UNSUPPORTED_HOSTS.has(hostname);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
UnbanServerMemberRequest
|
||||
} from '../../domain/models/server-directory.model';
|
||||
import type { RoomSignalSourceInput } from '../../domain/logic/room-signal-source.logic';
|
||||
import { endpointSupportsServerDiscovery } from '../../domain/logic/server-discovery.rules';
|
||||
|
||||
interface ServerLookupError {
|
||||
status?: number;
|
||||
@@ -297,6 +298,12 @@ export class ServerDirectoryApiService {
|
||||
}
|
||||
|
||||
private getDiscoveryServers(kind: 'featured' | 'trending', limit?: number): Observable<ServerInfo[]> {
|
||||
const baseUrl = this.resolveBaseServerUrl();
|
||||
|
||||
if (!endpointSupportsServerDiscovery(baseUrl)) {
|
||||
return of([]);
|
||||
}
|
||||
|
||||
const params = typeof limit === 'number' ? new HttpParams().set('limit', String(limit)) : undefined;
|
||||
|
||||
return this.http
|
||||
|
||||
Reference in New Issue
Block a user