feat: Android APP V1 - Experimental Alpha

This commit is contained in:
2026-06-05 07:40:25 +02:00
parent bf4e6891d1
commit 9a1305f976
179 changed files with 8031 additions and 120 deletions

View File

@@ -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()) {

View File

@@ -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 = [
'🙂',

View File

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

View File

@@ -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';
}

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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[] {

View File

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

View File

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

View File

@@ -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