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

@@ -1,6 +1,6 @@
<div
appThemeNode="appRoot"
class="workspace-bright-theme relative h-screen overflow-hidden bg-background text-foreground"
class="workspace-bright-theme relative h-full overflow-hidden bg-background text-foreground"
>
<div
class="h-full min-h-0 min-w-0 overflow-hidden"

View File

@@ -37,6 +37,11 @@ import { UserStatusService } from './core/services/user-status.service';
import { GameActivityService } from './domains/game-activity';
import { PluginBootstrapService } from './domains/plugins';
import { DirectCallService } from './domains/direct-call';
import {
MobileAppLifecycleService,
MobileCallSessionService,
MobilePersistenceService
} from './infrastructure/mobile';
import { IncomingCallModalComponent } from './domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component';
import { PrivateCallComponent } from './features/direct-call/private-call.component';
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
@@ -171,6 +176,9 @@ export class App implements OnInit, OnDestroy {
};
});
private readonly mobilePersistence = inject(MobilePersistenceService);
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
private readonly mobileCallSession = inject(MobileCallSessionService);
private deepLinkCleanup: (() => void) | null = null;
private themeStudioControlsDragOffset: { x: number; y: number } | null = null;
private themeStudioControlsBounds: { width: number; height: number } | null = null;
@@ -331,6 +339,9 @@ export class App implements OnInit, OnDestroy {
}
void this.notifications.initialize().catch(() => {});
void this.mobilePersistence.initialize().catch(() => {});
void this.mobileLifecycle.initialize().catch(() => {});
this.mobileCallSession.initialize();
void this.setupDesktopDeepLinks().catch(() => {});
this.userStatus.start();

View File

@@ -1,15 +1,23 @@
import { Injectable, inject } from '@angular/core';
import { detectRuntimePlatform, isCapacitorNativeRuntime } from '../../infrastructure/mobile/logic/platform-detection.rules';
import { ElectronBridgeService } from './electron/electron-bridge.service';
@Injectable({ providedIn: 'root' })
export class PlatformService {
readonly isElectron: boolean;
readonly isCapacitor: boolean;
readonly isBrowser: boolean;
private readonly electronBridge = inject(ElectronBridgeService);
constructor() {
this.isElectron = this.electronBridge.isAvailable;
this.isBrowser = !this.isElectron;
const runtime = detectRuntimePlatform({
hasElectronApi: this.isElectron,
capacitorIsNative: isCapacitorNativeRuntime()
});
this.isCapacitor = runtime === 'capacitor';
this.isBrowser = runtime === 'browser';
}
}

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

View File

@@ -27,6 +27,24 @@
/>
</button>
@if (showSpeakerphoneButton()) {
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
[class.ring-2]="speakerphoneEnabled()"
[class.ring-primary]="speakerphoneEnabled()"
[disabled]="!connected()"
(click)="speakerphoneToggled.emit()"
[attr.aria-label]="speakerphoneEnabled() ? 'Use earpiece' : 'Use speakerphone'"
[title]="speakerphoneEnabled() ? 'Use earpiece' : 'Use speakerphone'"
>
<ng-icon
name="lucideVolume2"
class="h-5 w-5"
/>
</button>
}
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"

View File

@@ -12,7 +12,8 @@ import {
lucidePhone,
lucidePhoneOff,
lucideVideo,
lucideVideoOff
lucideVideoOff,
lucideVolume2
} from '@ng-icons/lucide';
@Component({
@@ -28,7 +29,8 @@ import {
lucidePhone,
lucidePhoneOff,
lucideVideo,
lucideVideoOff
lucideVideoOff,
lucideVolume2
})
],
templateUrl: './private-call-controls.component.html'
@@ -38,10 +40,13 @@ export class PrivateCallControlsComponent {
readonly muted = input.required<boolean>();
readonly cameraEnabled = input.required<boolean>();
readonly screenSharing = input.required<boolean>();
readonly showSpeakerphoneButton = input(false);
readonly speakerphoneEnabled = input(false);
readonly joinRequested = output<void>();
readonly muteToggled = output<void>();
readonly cameraToggled = output<void>();
readonly screenShareToggled = output<void>();
readonly leaveRequested = output<void>();
readonly joinRequested = output();
readonly muteToggled = output();
readonly cameraToggled = output();
readonly screenShareToggled = output();
readonly speakerphoneToggled = output();
readonly leaveRequested = output();
}

View File

@@ -198,10 +198,13 @@
[muted]="isMuted()"
[cameraEnabled]="isCameraEnabled()"
[screenSharing]="isScreenSharing()"
[showSpeakerphoneButton]="showSpeakerphoneButton()"
[speakerphoneEnabled]="speakerphoneEnabled()"
(joinRequested)="join()"
(muteToggled)="toggleMute()"
(cameraToggled)="toggleCamera()"
(screenShareToggled)="toggleScreenShare()"
(speakerphoneToggled)="toggleSpeakerphone()"
(leaveRequested)="leave()"
/>
</div>

View File

@@ -43,6 +43,7 @@ import {
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session';
import { ScreenShareQualityDialogComponent } from '../../shared';
import { ViewportService } from '../../core/platform';
import { MobileMediaService, MobilePlatformService } from '../../infrastructure/mobile';
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
import { UsersActions } from '../../store/users/users.actions';
import { User } from '../../shared-kernel';
@@ -87,11 +88,15 @@ export class PrivateCallComponent {
private readonly playback = inject(VoicePlaybackService);
private readonly screenShare = inject(ScreenShareFacade);
private readonly viewport = inject(ViewportService);
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly mobileMedia = inject(MobileMediaService);
private chatResizing = false;
readonly allUsers = this.store.selectSignal(selectAllUsers);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly isMobile = this.viewport.isMobile;
readonly showSpeakerphoneButton = computed(() => this.mobilePlatform.isNativeMobile());
readonly speakerphoneEnabled = signal(true);
readonly callIdInput = input<string | null>(null);
readonly overlayMode = input(false);
readonly routeCallId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), {
@@ -342,6 +347,13 @@ export class PrivateCallComponent {
this.broadcastLocalVoiceState();
}
async toggleSpeakerphone(): Promise<void> {
const nextEnabled = !this.speakerphoneEnabled();
this.speakerphoneEnabled.set(nextEnabled);
await this.mobileMedia.setSpeakerphoneEnabled(nextEnabled);
}
toggleDeafen(): void {
const nextDeafened = !this.isDeafened();

View File

@@ -25,6 +25,10 @@ import {
import { UserAvatarComponent } from '../../../../shared';
import { ViewportService } from '../../../../core/platform';
import {
MobileAppLifecycleService,
MobilePictureInPictureService
} from '../../../../infrastructure/mobile';
import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service';
import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
@@ -55,6 +59,8 @@ import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
private readonly viewport = inject(ViewportService);
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
private readonly mobilePictureInPicture = inject(MobilePictureInPictureService);
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
readonly item = input.required<VoiceWorkspaceStreamItem>();
@@ -74,6 +80,10 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
readonly muted = signal(false);
constructor() {
void this.mobileLifecycle.initialize();
this.mobileLifecycle.onAppStateChange((isActive) => {
void this.handleAppStateChange(isActive);
});
effect(() => {
const ref = this.videoRef();
const item = this.item();
@@ -150,6 +160,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
ngOnDestroy(): void {
this.clearFullscreenHeaderHideTimeout();
void this.mobilePictureInPicture.exit();
const tile = this.tileRef()?.nativeElement;
@@ -160,6 +171,24 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
this.unlockOrientation();
}
private async handleAppStateChange(isActive: boolean): Promise<void> {
if (isActive || !this.focused() || !this.mobilePictureInPicture.isSupported()) {
if (isActive) {
await this.mobilePictureInPicture.exit();
}
return;
}
const video = this.videoRef()?.nativeElement;
if (!video || !this.item().stream) {
return;
}
await this.mobilePictureInPicture.enter(video);
}
canToggleFullscreen(): boolean {
return !this.mini() && !this.compact();
}

View File

@@ -0,0 +1,27 @@
# Mobile infrastructure
Loosely coupled Capacitor/native bridge for the Angular product client. Domains depend on facades in this folder — never on `@capacitor/*` imports directly.
## Facades
| Service | Responsibility |
|---------|----------------|
| `MobilePlatformService` | Runtime detection (`browser` / `capacitor` / `electron`) and mobile UX flags |
| `MobileNotificationsService` | Local/push notifications for calls |
| `MobileCallSessionService` | In-call notification actions, background audio session, stream video hand-off |
| `MobileMediaService` | Attachment picker, speakerphone route, screen-share/PiP capability probes |
| `MobilePictureInPictureService` | Stream pop-out while backgrounded |
| `MobilePersistenceService` | Native SQLite schema init (`@capacitor-community/sqlite`) |
| `MobileSqliteConnectionService` | Shared SQLite connection for persistence + `DatabaseService` |
| `MobileCallKitService` | iOS CallKit active-call reporting for background voice |
| `MobilePushRegistrationService` | FCM/APNs token registration with signaling server; skips `PushNotifications.register()` when Firebase/APNs is not configured |
| `MobileAppLifecycleService` | Foreground/background lifecycle |
## Adapters
- `adapters/web/*` — browser fallbacks (Notification API, hidden file input, Document PiP).
- `adapters/capacitor/*` — lazy-loaded Capacitor plugins via `capacitor-plugin-loader.ts`.
## Rules
Pure platform/call-notification rules live in `logic/*.rules.ts` and are Vitest-tested without Angular.

View File

@@ -0,0 +1,23 @@
import type { MobileAppLifecycleAdapter } from '../../contracts/mobile.contracts';
import { loadCapacitorAppPlugin } from './capacitor-plugin-loader';
/** Capacitor App plugin lifecycle bridge. */
export class CapacitorMobileAppLifecycleAdapter implements MobileAppLifecycleAdapter {
private handler: ((isActive: boolean) => void) | null = null;
async initialize(): Promise<void> {
const App = loadCapacitorAppPlugin();
if (!App) {
return;
}
await App.addListener('appStateChange', (state) => {
this.handler?.(state.isActive);
});
}
onAppStateChange(handler: (isActive: boolean) => void): void {
this.handler = handler;
}
}

View File

@@ -0,0 +1,25 @@
import type { MobileCallKitAdapter } from '../../contracts/mobile.contracts';
import { MetoyouMobile } from './metoyou-mobile.plugin';
/** iOS CallKit bridge via the MetoyouMobile native plugin. */
export class CapacitorMobileCallKitAdapter implements MobileCallKitAdapter {
async startActiveCall(callId: string, displayName: string): Promise<void> {
try {
const result = await MetoyouMobile.startCallKitSession({ callId, displayName });
if (!result.supported) {
console.info('[mobile] CallKit is unavailable on this iOS build');
}
} catch (error) {
console.info('[mobile] CallKit start skipped', error);
}
}
async endActiveCall(callId: string): Promise<void> {
try {
await MetoyouMobile.endCallKitSession({ callId });
} catch (error) {
console.info('[mobile] CallKit end skipped', error);
}
}
}

View File

@@ -0,0 +1,68 @@
import type { MobileMediaAdapter } from '../../contracts/mobile.contracts';
import { loadCapacitorAudioSessionPlugin } from './capacitor-plugin-loader';
import { MetoyouMobile } from './metoyou-mobile.plugin';
import { WebMobileMediaAdapter } from '../web/web-mobile-media.adapter';
/** Capacitor media adapter with native speaker routing and background voice session hooks. */
export class CapacitorMobileMediaAdapter extends WebMobileMediaAdapter implements MobileMediaAdapter {
private backgroundSessionActive = false;
override async setSpeakerphoneEnabled(enabled: boolean): Promise<void> {
try {
await MetoyouMobile.setSpeakerphoneEnabled({ enabled });
return;
} catch {
// Android plugin unavailable in web builds; fall through to iOS audio session.
}
const AudioSession = loadCapacitorAudioSessionPlugin();
if (!AudioSession) {
return;
}
await AudioSession.overrideOutput(enabled ? 'speaker' : 'default');
}
override async startBackgroundAudioSession(): Promise<void> {
if (this.backgroundSessionActive) {
return;
}
this.backgroundSessionActive = true;
try {
await MetoyouMobile.startVoiceForegroundService();
} catch (error) {
console.info('[mobile] background voice foreground service unavailable', error);
}
}
override async stopBackgroundAudioSession(): Promise<void> {
if (!this.backgroundSessionActive) {
return;
}
this.backgroundSessionActive = false;
try {
await MetoyouMobile.stopVoiceForegroundService();
} catch (error) {
console.info('[mobile] failed to stop background voice session', error);
}
}
override isScreenShareSupported(): boolean {
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
if (/iPhone|iPad|iPod/i.test(userAgent)) {
return false;
}
return !!navigator.mediaDevices?.getDisplayMedia;
}
override isPictureInPictureSupported(): boolean {
return super.isPictureInPictureSupported();
}
}

View File

@@ -0,0 +1,151 @@
import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules';
import { resolveCallNotificationAction } from '../../logic/call-notification.rules';
import type { MobileNotificationAdapter } from '../../contracts/mobile.contracts';
import { loadCapacitorLocalNotificationsPlugin, loadCapacitorPushNotificationsPlugin } from './capacitor-plugin-loader';
const INCOMING_CALL_CHANNEL_ID = 'toju-incoming-call';
const ACTIVE_CALL_CHANNEL_ID = 'toju-active-call';
/** Capacitor local + push notification bridge with action buttons for in-call controls. */
export class CapacitorMobileNotificationsAdapter implements MobileNotificationAdapter {
private actionHandler: ((input: { callId: string; intent: CallNotificationActionIntent }) => void) | null = null;
private listenersRegistered = false;
async initialize(): Promise<void> {
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
const PushNotifications = loadCapacitorPushNotificationsPlugin();
if (!LocalNotifications) {
return;
}
await LocalNotifications.createChannel({
id: INCOMING_CALL_CHANNEL_ID,
name: 'Incoming calls',
importance: 5,
visibility: 1,
sound: 'call.wav'
});
await LocalNotifications.createChannel({
id: ACTIVE_CALL_CHANNEL_ID,
name: 'Active calls',
importance: 4,
visibility: 1
});
await LocalNotifications.registerActionTypes({
types: [
{
id: 'INCOMING_CALL_ACTIONS',
actions: [{ id: 'answer', title: 'Answer' }, { id: 'hangup', title: 'Decline' }]
},
{
id: 'ACTIVE_CALL_ACTIONS',
actions: [{ id: 'mute', title: 'Mute' }, { id: 'hangup', title: 'Hang up' }]
}
]
});
if (!this.listenersRegistered) {
await LocalNotifications.addListener('localNotificationActionPerformed', (event) => {
const callId = event.notification.extra?.callId as string | undefined;
const intent = resolveCallNotificationAction(event.actionId);
if (!callId || !intent || !this.actionHandler) {
return;
}
this.actionHandler({ callId, intent });
});
this.listenersRegistered = true;
}
if (PushNotifications) {
const permission = await PushNotifications.checkPermissions();
if (permission.receive === 'prompt') {
await PushNotifications.requestPermissions();
}
}
}
async requestPermission(): Promise<boolean> {
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
if (!LocalNotifications) {
return false;
}
const permission = await LocalNotifications.checkPermissions();
if (permission.display === 'granted') {
return true;
}
const requested = await LocalNotifications.requestPermissions();
return requested.display === 'granted';
}
async showCallNotification(payload: CallNotificationPayload): Promise<void> {
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
if (!LocalNotifications) {
return;
}
const granted = await this.requestPermission();
if (!granted) {
return;
}
await LocalNotifications.schedule({
notifications: [
{
id: payload.id,
title: payload.title,
body: payload.body,
channelId: payload.kind === 'incoming' ? INCOMING_CALL_CHANNEL_ID : ACTIVE_CALL_CHANNEL_ID,
ongoing: payload.kind === 'active',
autoCancel: payload.kind === 'incoming',
extra: {
callId: payload.callId,
kind: payload.kind
},
actionTypeId: payload.kind === 'active' ? 'ACTIVE_CALL_ACTIONS' : 'INCOMING_CALL_ACTIONS'
}
]
});
}
async dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void> {
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
if (!LocalNotifications) {
return;
}
const notifications = await LocalNotifications.getDeliveredNotifications();
const matching = notifications.notifications.filter((notification) => {
const extraCallId = notification.extra?.callId as string | undefined;
const extraKind = notification.extra?.kind as CallNotificationPayload['kind'] | undefined;
return extraCallId === callId && extraKind === kind;
});
if (matching.length === 0) {
return;
}
await LocalNotifications.removeDeliveredNotifications({
notifications: matching
});
}
onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void {
this.actionHandler = handler;
}
}

View File

@@ -0,0 +1,35 @@
import type { MobilePersistenceAdapter } from '../../contracts/mobile.contracts';
import { MobileSqliteConnectionService } from '../../services/mobile-sqlite-connection.service';
/**
* Capacitor SQLite persistence adapter.
*
* Initializes native SQLite with schema mirrored from Electron TypeORM entities.
* Domain persistence routes through {@link CapacitorDatabaseService} on Capacitor shells.
*/
export class CapacitorMobilePersistenceAdapter implements MobilePersistenceAdapter {
private initialized = false;
constructor(private readonly connection: MobileSqliteConnectionService) {}
get isNativeSqlite(): boolean {
return this.connection.isAvailable;
}
async initialize(): Promise<void> {
if (this.initialized) {
return;
}
const store = await this.connection.initialize();
if (!store?.isAvailable) {
console.warn('[mobile] SQLite plugin unavailable on this Capacitor shell');
this.initialized = true;
return;
}
this.initialized = true;
console.info('[mobile] native SQLite persistence initialized');
}
}

View File

@@ -0,0 +1,43 @@
import type { MobilePictureInPictureAdapter } from '../../contracts/mobile.contracts';
import { MetoyouMobile } from './metoyou-mobile.plugin';
import { WebMobilePictureInPictureAdapter } from '../web/web-mobile-picture-in-picture.adapter';
/** Capacitor PiP adapter with Document PiP first and native Android PiP fallback. */
export class CapacitorMobilePictureInPictureAdapter extends WebMobilePictureInPictureAdapter implements MobilePictureInPictureAdapter {
private nativeSupported: boolean | null = null;
override isSupported(): boolean {
if (super.isSupported()) {
return true;
}
return this.nativeSupported === true;
}
override async enter(videoElement: HTMLVideoElement): Promise<void> {
if (super.isSupported()) {
await super.enter(videoElement);
return;
}
const result = await MetoyouMobile.enterNativePictureInPicture();
this.nativeSupported = result.supported;
if (!result.supported) {
return;
}
if (videoElement.paused) {
await videoElement.play().catch(() => {});
}
}
override async exit(): Promise<void> {
if (document.pictureInPictureElement) {
await super.exit();
}
await MetoyouMobile.exitNativePictureInPicture().catch(() => {});
}
}

View File

@@ -0,0 +1,75 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
const capacitorState = vi.hoisted(() => ({
isNativePlatform: true,
isPluginAvailable: true,
platform: 'android'
}));
vi.mock('@capacitor/core', () => ({
Capacitor: {
isNativePlatform: () => capacitorState.isNativePlatform,
isPluginAvailable: (name: string) => capacitorState.isPluginAvailable && name.length > 0,
getPlatform: () => capacitorState.platform
}
}));
vi.mock('@capacitor/app', () => ({
App: {
addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() }))
}
}));
vi.mock('@capacitor/local-notifications', () => ({
LocalNotifications: {
checkPermissions: vi.fn(() => Promise.resolve({ display: 'granted' }))
}
}));
import { App } from '@capacitor/app';
import { LocalNotifications } from '@capacitor/local-notifications';
import { loadCapacitorAppPlugin, loadCapacitorLocalNotificationsPlugin } from './capacitor-plugin-loader';
describe('capacitor-plugin-loader', () => {
beforeEach(() => {
vi.stubGlobal('window', {});
});
afterEach(() => {
capacitorState.isNativePlatform = true;
capacitorState.isPluginAvailable = true;
capacitorState.platform = 'android';
vi.unstubAllGlobals();
});
it('returns registered plugin instances synchronously without wrapping them in a Promise', () => {
const appPlugin = loadCapacitorAppPlugin();
const notificationsPlugin = loadCapacitorLocalNotificationsPlugin();
expect(appPlugin).toBe(App);
expect(notificationsPlugin).toBe(LocalNotifications);
expect(appPlugin).not.toBeInstanceOf(Promise);
expect(notificationsPlugin).not.toBeInstanceOf(Promise);
});
it('returns null when the plugin is unavailable on the active native shell', () => {
capacitorState.isPluginAvailable = false;
expect(loadCapacitorAppPlugin()).toBeNull();
expect(loadCapacitorLocalNotificationsPlugin()).toBeNull();
});
it('returns null on non-native shells', () => {
capacitorState.isNativePlatform = false;
expect(loadCapacitorAppPlugin()).toBeNull();
expect(loadCapacitorLocalNotificationsPlugin()).toBeNull();
});
});

View File

@@ -0,0 +1,44 @@
import { App } from '@capacitor/app';
import { Capacitor } from '@capacitor/core';
import { Device } from '@capacitor/device';
import { LocalNotifications } from '@capacitor/local-notifications';
import { PushNotifications } from '@capacitor/push-notifications';
import { AudioSession } from '@capgo/capacitor-audio-session';
function resolveCapacitorPlugin<T>(pluginName: string, plugin: T): T | null {
if (typeof window === 'undefined' || !Capacitor.isNativePlatform()) {
return null;
}
if (!Capacitor.isPluginAvailable(pluginName)) {
console.warn(`[mobile] Capacitor plugin "${pluginName}" is not implemented on ${Capacitor.getPlatform()}`);
return null;
}
return plugin;
}
/** Resolve the Capacitor App plugin on native shells; returns null on web/electron or when unavailable. */
export function loadCapacitorAppPlugin(): typeof App | null {
return resolveCapacitorPlugin('App', App);
}
/** Resolve the Capacitor LocalNotifications plugin on native shells. */
export function loadCapacitorLocalNotificationsPlugin(): typeof LocalNotifications | null {
return resolveCapacitorPlugin('LocalNotifications', LocalNotifications);
}
/** Resolve the Capacitor PushNotifications plugin on native shells. */
export function loadCapacitorPushNotificationsPlugin(): typeof PushNotifications | null {
return resolveCapacitorPlugin('PushNotifications', PushNotifications);
}
/** Resolve the Capacitor Device plugin on native shells. */
export function loadCapacitorDevicePlugin(): typeof Device | null {
return resolveCapacitorPlugin('Device', Device);
}
/** Resolve the Capacitor AudioSession plugin on native shells. */
export function loadCapacitorAudioSessionPlugin(): typeof AudioSession | null {
return resolveCapacitorPlugin('AudioSession', AudioSession);
}

View File

@@ -0,0 +1,115 @@
import {
MOBILE_SQLITE_DATABASE_NAME,
MOBILE_SQLITE_SCHEMA_VERSION,
resolveMobileSqliteMigrationStatements
} from '../../logic/mobile-sqlite-schema.rules';
import { executeMobileSqliteStatements } from '../../logic/mobile-sqlite-execute.rules';
const META_SCHEMA_VERSION_KEY = 'mobile_sqlite_schema_version';
export interface MobileSqliteStore {
readonly isAvailable: boolean;
initialize(): Promise<void>;
run(statement: string, values?: unknown[]): Promise<void>;
query<T>(statement: string, values?: unknown[]): Promise<T[]>;
}
const schemaInitializationFailures = new Set<string>();
/** Lazy-loaded @capacitor-community/sqlite connection for native mobile shells. */
export async function createCapacitorSqliteStore(
databaseName: string = MOBILE_SQLITE_DATABASE_NAME
): Promise<MobileSqliteStore | null> {
if (typeof window === 'undefined') {
return null;
}
try {
const sqliteModule = await import('@capacitor-community/sqlite');
type SqliteDbConnection = import('@capacitor-community/sqlite').SQLiteDBConnection;
const sqliteConnection = new sqliteModule.SQLiteConnection(sqliteModule.CapacitorSQLite);
let database: SqliteDbConnection | null = null;
return {
get isAvailable() {
return database !== null && !schemaInitializationFailures.has(databaseName);
},
async initialize(): Promise<void> {
if (schemaInitializationFailures.has(databaseName)) {
throw new Error(`Mobile SQLite schema initialization failed for "${databaseName}".`);
}
await sqliteConnection.checkConnectionsConsistency();
const connectionState = await sqliteConnection.isConnection(databaseName, false);
database = connectionState.result
? await sqliteConnection.retrieveConnection(databaseName, false)
: await sqliteConnection.createConnection(
databaseName,
false,
'no-encryption',
MOBILE_SQLITE_SCHEMA_VERSION,
false
);
await database.open();
let storedVersion = 0;
try {
const versionRows = await database.query(`SELECT value FROM meta WHERE key = '${META_SCHEMA_VERSION_KEY}' LIMIT 1`);
storedVersion = Number(versionRows.values?.[0]?.value ?? 0);
} catch {
storedVersion = 0;
}
const statements = resolveMobileSqliteMigrationStatements(storedVersion);
if (statements.length === 0) {
return;
}
try {
const activeDatabase = database;
if (!activeDatabase) {
throw new Error('Mobile SQLite store is not initialized.');
}
await executeMobileSqliteStatements(
(statement) => activeDatabase.execute(statement),
statements
);
} catch (error) {
schemaInitializationFailures.add(databaseName);
throw error;
}
},
async run(statement: string, values: unknown[] = []): Promise<void> {
if (!database) {
throw new Error('Mobile SQLite store is not initialized.');
}
await database.run(statement, values);
},
async query<T>(statement: string, values: unknown[] = []): Promise<T[]> {
if (!database) {
throw new Error('Mobile SQLite store is not initialized.');
}
const result = await database.query(statement, values);
return (result.values ?? []) as T[];
}
};
} catch {
return null;
}
}

View File

@@ -0,0 +1,14 @@
import { registerPlugin } from '@capacitor/core';
export interface MetoyouMobilePlugin {
setSpeakerphoneEnabled(options: { enabled: boolean }): Promise<void>;
startVoiceForegroundService(): Promise<void>;
stopVoiceForegroundService(): Promise<void>;
enterNativePictureInPicture(): Promise<{ supported: boolean }>;
exitNativePictureInPicture(): Promise<void>;
startCallKitSession(options: { callId: string; displayName: string }): Promise<{ supported: boolean }>;
endCallKitSession(options: { callId: string }): Promise<void>;
isRemotePushConfigured(): Promise<{ configured: boolean }>;
}
export const MetoyouMobile = registerPlugin<MetoyouMobilePlugin>('MetoyouMobile');

View File

@@ -0,0 +1,20 @@
import type { MobileAppLifecycleAdapter } from '../../contracts/mobile.contracts';
/** Visibility API fallback for browser runtimes. */
export class WebMobileAppLifecycleAdapter implements MobileAppLifecycleAdapter {
private handler: ((isActive: boolean) => void) | null = null;
async initialize(): Promise<void> {
if (typeof document === 'undefined') {
return;
}
document.addEventListener('visibilitychange', () => {
this.handler?.(!document.hidden);
});
}
onAppStateChange(handler: (isActive: boolean) => void): void {
this.handler = handler;
}
}

View File

@@ -0,0 +1,8 @@
import type { MobileCallKitAdapter } from '../../contracts/mobile.contracts';
/** Web shells do not expose CallKit. */
export class WebMobileCallKitAdapter implements MobileCallKitAdapter {
async startActiveCall(): Promise<void> {}
async endActiveCall(): Promise<void> {}
}

View File

@@ -0,0 +1,49 @@
import type { MobileMediaAdapter } from '../../contracts/mobile.contracts';
/** Web fallback for mobile media affordances. */
export class WebMobileMediaAdapter implements MobileMediaAdapter {
async pickAttachments(): Promise<File[]> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = 'image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z,.doc,.docx,.xls,.xlsx,.ppt,.pptx';
input.style.display = 'none';
input.addEventListener('change', () => {
const files = input.files ? Array.from(input.files) : [];
input.remove();
resolve(files);
}, { once: true });
document.body.appendChild(input);
input.click();
});
}
async setSpeakerphoneEnabled(_enabled: boolean): Promise<void> {
return;
}
async startBackgroundAudioSession(): Promise<void> {
return;
}
async stopBackgroundAudioSession(): Promise<void> {
return;
}
isScreenShareSupported(): boolean {
return typeof navigator !== 'undefined'
&& !!navigator.mediaDevices?.getDisplayMedia
&& !/iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
}
isPictureInPictureSupported(): boolean {
return typeof document !== 'undefined'
&& 'pictureInPictureEnabled' in document
&& document.pictureInPictureEnabled === true;
}
}

View File

@@ -0,0 +1,60 @@
import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules';
import type { MobileNotificationAdapter } from '../../contracts/mobile.contracts';
type CallActionHandler = (input: { callId: string; intent: CallNotificationActionIntent }) => void;
/** Browser Notification API fallback for web and Capacitor dev shells. */
export class WebMobileNotificationsAdapter implements MobileNotificationAdapter {
private actionHandler: CallActionHandler | null = null;
async initialize(): Promise<void> {
return;
}
async requestPermission(): Promise<boolean> {
if (typeof Notification === 'undefined') {
return false;
}
if (Notification.permission === 'granted') {
return true;
}
if (Notification.permission === 'denied') {
return false;
}
const permission = await Notification.requestPermission();
return permission === 'granted';
}
async showCallNotification(payload: CallNotificationPayload): Promise<void> {
const granted = await this.requestPermission();
if (!granted) {
return;
}
const notification = new Notification(payload.title, {
body: payload.body,
tag: `toju-call-${payload.callId}-${payload.kind}`
});
notification.onclick = () => {
window.focus();
this.actionHandler?.({
callId: payload.callId,
intent: payload.kind === 'incoming' ? 'answer' : 'toggle-mute'
});
};
}
async dismissCallNotification(_callId: string, _kind: CallNotificationPayload['kind']): Promise<void> {
return;
}
onActionSelected(handler: CallActionHandler): void {
this.actionHandler = handler;
}
}

View File

@@ -0,0 +1,10 @@
import type { MobilePersistenceAdapter } from '../../contracts/mobile.contracts';
/** Web persistence marker - IndexedDB remains the active store via DatabaseService. */
export class WebMobilePersistenceAdapter implements MobilePersistenceAdapter {
readonly isNativeSqlite = false;
async initialize(): Promise<void> {
return;
}
}

View File

@@ -0,0 +1,26 @@
import type { MobilePictureInPictureAdapter } from '../../contracts/mobile.contracts';
/** Document Picture-in-Picture API adapter for supported browsers. */
export class WebMobilePictureInPictureAdapter implements MobilePictureInPictureAdapter {
isSupported(): boolean {
return typeof document !== 'undefined'
&& 'pictureInPictureEnabled' in document
&& document.pictureInPictureEnabled === true;
}
async enter(videoElement: HTMLVideoElement): Promise<void> {
if (!this.isSupported() || document.pictureInPictureElement) {
return;
}
await videoElement.requestPictureInPicture();
}
async exit(): Promise<void> {
if (!document.pictureInPictureElement) {
return;
}
await document.exitPictureInPicture();
}
}

View File

@@ -0,0 +1,46 @@
import type { CallNotificationActionIntent, CallNotificationPayload } from '../logic/call-notification.rules';
import type { RuntimePlatform } from '../logic/platform-detection.rules';
export interface MobileNotificationAdapter {
initialize(): Promise<void>;
requestPermission(): Promise<boolean>;
showCallNotification(payload: CallNotificationPayload): Promise<void>;
dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void>;
onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void;
}
export interface MobileMediaAdapter {
pickAttachments(): Promise<File[]>;
setSpeakerphoneEnabled(enabled: boolean): Promise<void>;
startBackgroundAudioSession(): Promise<void>;
stopBackgroundAudioSession(): Promise<void>;
isScreenShareSupported(): boolean;
isPictureInPictureSupported(): boolean;
}
export interface MobilePictureInPictureAdapter {
isSupported(): boolean;
enter(videoElement: HTMLVideoElement): Promise<void>;
exit(): Promise<void>;
}
export interface MobilePersistenceAdapter {
readonly isNativeSqlite: boolean;
initialize(): Promise<void>;
}
export interface MobileAppLifecycleAdapter {
initialize(): Promise<void>;
onAppStateChange(handler: (isActive: boolean) => void): void;
}
export interface MobileCallKitAdapter {
startActiveCall(callId: string, displayName: string): Promise<void>;
endActiveCall(callId: string): Promise<void>;
}
export interface MobilePlatformSnapshot {
runtime: RuntimePlatform;
isNativeMobile: boolean;
isCapacitor: boolean;
}

View File

@@ -0,0 +1,12 @@
export * from './logic/platform-detection.rules';
export * from './logic/call-notification.rules';
export * from './services/mobile-platform.service';
export * from './services/mobile-notifications.service';
export * from './services/mobile-media.service';
export * from './services/mobile-picture-in-picture.service';
export * from './services/mobile-persistence.service';
export * from './services/mobile-call-session.service';
export * from './services/mobile-app-lifecycle.service';
export * from './services/mobile-push-registration.service';
export * from './services/mobile-callkit.service';
export * from './services/mobile-sqlite-connection.service';

View File

@@ -0,0 +1,46 @@
import {
describe,
expect,
it
} from 'vitest';
import {
buildIncomingCallNotification,
buildInCallNotification,
resolveCallNotificationAction
} from './call-notification.rules';
describe('call-notification.rules', () => {
it('builds an incoming call notification payload', () => {
expect(buildIncomingCallNotification('Alex', 'call-1')).toMatchObject({
title: 'Incoming call',
body: 'Alex is calling you',
callId: 'call-1',
kind: 'incoming',
actions: [{ id: 'answer', title: 'Answer' }, { id: 'hangup', title: 'Decline' }]
});
});
it('builds a persistent in-call notification with action ids', () => {
const payload = buildInCallNotification({
callId: 'call-2',
displayName: 'Team call',
isMuted: false
});
expect(payload).toMatchObject({
title: 'Team call',
body: 'Call in progress',
callId: 'call-2',
kind: 'active',
actions: [{ id: 'mute', title: 'Mute' }, { id: 'hangup', title: 'Hang up' }]
});
});
it('maps mute action to toggle mute intent', () => {
expect(resolveCallNotificationAction('mute')).toBe('toggle-mute');
expect(resolveCallNotificationAction('hangup')).toBe('hang-up');
expect(resolveCallNotificationAction('answer')).toBe('answer');
expect(resolveCallNotificationAction('unknown')).toBeNull();
});
});

View File

@@ -0,0 +1,69 @@
export type CallNotificationKind = 'incoming' | 'active';
export type CallNotificationActionId = 'answer' | 'mute' | 'hangup';
export type CallNotificationActionIntent = 'answer' | 'toggle-mute' | 'hang-up';
export interface CallNotificationPayload {
id: number;
title: string;
body: string;
callId: string;
kind: CallNotificationKind;
actions?: { id: CallNotificationActionId; title: string }[];
}
const INCOMING_CALL_NOTIFICATION_BASE_ID = 1000;
const ACTIVE_CALL_NOTIFICATION_BASE_ID = 2000;
/** Build a local notification payload for an incoming direct call. */
export function buildIncomingCallNotification(displayName: string, callId: string): CallNotificationPayload {
return {
id: INCOMING_CALL_NOTIFICATION_BASE_ID + hashCallId(callId),
title: 'Incoming call',
body: `${displayName} is calling you`,
callId,
kind: 'incoming',
actions: [{ id: 'answer', title: 'Answer' }, { id: 'hangup', title: 'Decline' }]
};
}
/** Build a persistent in-call notification payload with quick actions. */
export function buildInCallNotification(input: {
callId: string;
displayName: string;
isMuted: boolean;
}): CallNotificationPayload {
return {
id: ACTIVE_CALL_NOTIFICATION_BASE_ID + hashCallId(input.callId),
title: input.displayName,
body: input.isMuted ? 'Call in progress · muted' : 'Call in progress',
callId: input.callId,
kind: 'active',
actions: [{ id: 'mute', title: input.isMuted ? 'Unmute' : 'Mute' }, { id: 'hangup', title: 'Hang up' }]
};
}
/** Map a notification action button id to a call-control intent. */
export function resolveCallNotificationAction(actionId: string): CallNotificationActionIntent | null {
switch (actionId) {
case 'answer':
return 'answer';
case 'mute':
return 'toggle-mute';
case 'hangup':
return 'hang-up';
default:
return null;
}
}
function hashCallId(callId: string): number {
let hash = 0;
for (let index = 0; index < callId.length; index += 1) {
hash = (hash * 31 + callId.charCodeAt(index)) % 997;
}
return hash;
}

View File

@@ -0,0 +1,64 @@
import {
describe,
expect,
it
} from 'vitest';
import {
buildRemotePushSkipMessage,
resolveRemotePushSkipReason,
shouldRegisterForRemotePush,
type RemotePushRegistrationGateInput
} from './mobile-push-registration.rules';
describe('mobile-push-registration.rules', () => {
const configuredInput: RemotePushRegistrationGateInput = {
hasPushPlugin: true,
hasDevicePlugin: true,
remotePushConfigured: true
};
it('skips registration when remote push is not configured', () => {
expect(
shouldRegisterForRemotePush({
...configuredInput,
remotePushConfigured: false
})
).toBe(false);
});
it('skips registration when the push plugin is unavailable', () => {
expect(
shouldRegisterForRemotePush({
...configuredInput,
hasPushPlugin: false
})
).toBe(false);
});
it('skips registration when the device plugin is unavailable', () => {
expect(
shouldRegisterForRemotePush({
...configuredInput,
hasDevicePlugin: false
})
).toBe(false);
});
it('allows registration when plugins and remote push are available', () => {
expect(shouldRegisterForRemotePush(configuredInput)).toBe(true);
});
it('reports why registration was skipped', () => {
expect(
resolveRemotePushSkipReason({
...configuredInput,
remotePushConfigured: false
})
).toBe('remote-push-not-configured');
});
it('builds a single actionable skip message for missing Firebase setup', () => {
expect(buildRemotePushSkipMessage('remote-push-not-configured')).toContain('google-services.json');
});
});

View File

@@ -0,0 +1,55 @@
export type RemotePushSkipReason =
| 'missing-push-plugin'
| 'missing-device-plugin'
| 'remote-push-not-configured'
| 'remote-push-disabled';
export interface RemotePushRegistrationGateInput {
hasPushPlugin: boolean;
hasDevicePlugin: boolean;
remotePushConfigured: boolean;
remotePushEnabled?: boolean;
}
/** Whether the client should call `PushNotifications.register()` on this shell. */
export function shouldRegisterForRemotePush(input: RemotePushRegistrationGateInput): boolean {
return resolveRemotePushSkipReason(input) === null;
}
/** Resolve the first reason remote push registration should be skipped. */
export function resolveRemotePushSkipReason(
input: RemotePushRegistrationGateInput
): RemotePushSkipReason | null {
if (!input.hasPushPlugin) {
return 'missing-push-plugin';
}
if (!input.hasDevicePlugin) {
return 'missing-device-plugin';
}
if (input.remotePushEnabled === false) {
return 'remote-push-disabled';
}
if (!input.remotePushConfigured) {
return 'remote-push-not-configured';
}
return null;
}
/** User-facing console message when remote push registration is skipped. */
export function buildRemotePushSkipMessage(reason: RemotePushSkipReason): string {
switch (reason) {
case 'missing-push-plugin':
return '[mobile] remote push registration skipped: PushNotifications plugin is unavailable on this shell.';
case 'missing-device-plugin':
return '[mobile] remote push registration skipped: Device plugin is unavailable on this shell.';
case 'remote-push-disabled':
return '[mobile] remote push registration skipped: disabled by environment configuration.';
case 'remote-push-not-configured':
return '[mobile] remote push registration skipped: Firebase/APNs is not configured. '
+ 'Add google-services.json (Android) and rebuild to enable push.';
}
}

View File

@@ -0,0 +1,27 @@
import {
describe,
expect,
it
} from 'vitest';
import { buildPushDeviceTokenRegistrationPayload, normalizePushPlatform } from './mobile-push-token.rules';
describe('mobile-push-token.rules', () => {
it('normalizes capacitor runtime platforms', () => {
expect(normalizePushPlatform('ios')).toBe('ios');
expect(normalizePushPlatform('android')).toBe('android');
expect(normalizePushPlatform('web')).toBeNull();
});
it('builds a registration payload for the signaling server', () => {
expect(buildPushDeviceTokenRegistrationPayload({
userId: 'user-1',
token: 'abc123',
platform: 'android'
})).toEqual({
userId: 'user-1',
token: 'abc123',
platform: 'android'
});
});
});

View File

@@ -0,0 +1,31 @@
export type MobilePushPlatform = 'ios' | 'android';
export interface PushDeviceTokenRegistrationInput {
userId: string;
token: string;
platform: MobilePushPlatform;
}
export interface PushDeviceTokenRegistrationPayload {
userId: string;
token: string;
platform: MobilePushPlatform;
}
export function normalizePushPlatform(platform: string): MobilePushPlatform | null {
if (platform === 'ios' || platform === 'android') {
return platform;
}
return null;
}
export function buildPushDeviceTokenRegistrationPayload(
input: PushDeviceTokenRegistrationInput
): PushDeviceTokenRegistrationPayload {
return {
userId: input.userId,
token: input.token,
platform: input.platform
};
}

View File

@@ -0,0 +1,37 @@
import {
describe,
expect,
it
} from 'vitest';
import { applyMobileSafeAreaDefaults, getSafeAreaInsetCSSValue } from './mobile-safe-area.rules';
describe('mobile-safe-area.rules', () => {
it('builds CSS values with Capacitor and env fallbacks', () => {
expect(getSafeAreaInsetCSSValue('top')).toBe('var(--safe-area-inset-top, env(safe-area-inset-top, 0px))');
expect(getSafeAreaInsetCSSValue('bottom')).toBe('var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))');
});
it('sets default safe-area variables on the document root', () => {
const properties = new Map<string, string>();
const root = {
style: {
getPropertyValue: (property: string) => properties.get(property) ?? '',
setProperty: (property: string, value: string) => {
properties.set(property, value);
}
}
} as unknown as HTMLElement;
applyMobileSafeAreaDefaults(root);
expect(properties.get('--safe-area-inset-top')).toBe('0px');
expect(properties.get('--safe-area-inset-right')).toBe('0px');
expect(properties.get('--safe-area-inset-bottom')).toBe('0px');
expect(properties.get('--safe-area-inset-left')).toBe('0px');
});
it('ignores null roots instead of throwing', () => {
expect(() => applyMobileSafeAreaDefaults(null)).not.toThrow();
});
});

View File

@@ -0,0 +1,30 @@
const SAFE_AREA_SIDES = [
'top',
'right',
'bottom',
'left'
] as const;
export type SafeAreaSide = (typeof SAFE_AREA_SIDES)[number];
/** CSS value chain for one safe-area inset (Capacitor vars with env() fallback). */
export function getSafeAreaInsetCSSValue(side: SafeAreaSide): string {
return `var(--safe-area-inset-${side}, env(safe-area-inset-${side}, 0px))`;
}
/** Apply default safe-area CSS variables when the document root is available. */
export function applyMobileSafeAreaDefaults(root: HTMLElement | null = typeof document === 'undefined'
? null
: document.documentElement): void {
if (!root?.style) {
return;
}
for (const side of SAFE_AREA_SIDES) {
const property = `--safe-area-inset-${side}`;
if (!root.style.getPropertyValue(property)) {
root.style.setProperty(property, '0px');
}
}
}

View File

@@ -0,0 +1,17 @@
import {
describe,
expect,
it
} from 'vitest';
import { resolveMobileSqliteDatabaseName } from './mobile-sqlite-database-name.rules';
describe('mobile-sqlite-database-name.rules', () => {
it('scopes sqlite files per authenticated user', () => {
expect(resolveMobileSqliteDatabaseName('user-123')).toBe('metoyou__user-123');
});
it('uses an anonymous scope before login', () => {
expect(resolveMobileSqliteDatabaseName(null)).toBe('metoyou__anonymous');
});
});

View File

@@ -0,0 +1,10 @@
import { MOBILE_SQLITE_DATABASE_NAME } from './mobile-sqlite-schema.rules';
const ANONYMOUS_DATABASE_SCOPE = 'anonymous';
/** Mirrors IndexedDB per-user database scoping for Capacitor SQLite files. */
export function resolveMobileSqliteDatabaseName(userId: string | null): string {
const scopedUserId = userId?.trim() || ANONYMOUS_DATABASE_SCOPE;
return `${MOBILE_SQLITE_DATABASE_NAME}__${encodeURIComponent(scopedUserId)}`;
}

View File

@@ -0,0 +1,15 @@
/** Runs SQLite DDL/DML statements one at a time - required by @capacitor-community/sqlite `execute()`. */
export async function executeMobileSqliteStatements(
execute: (statement: string) => Promise<unknown>,
statements: readonly string[]
): Promise<void> {
for (const statement of statements) {
const trimmed = statement.trim();
if (!trimmed) {
continue;
}
await execute(trimmed);
}
}

View File

@@ -0,0 +1,65 @@
import {
describe,
expect,
it
} from 'vitest';
import {
messageToRow,
rowToMessage,
rowToUser,
userToRow
} from './mobile-sqlite-row-mapper.rules';
describe('mobile-sqlite-row-mapper.rules', () => {
it('round-trips message fields including JSON metadata', () => {
const message = {
id: 'm1',
roomId: 'r1',
channelId: 'general',
senderId: 'u1',
senderName: 'Alice',
content: 'hello',
timestamp: 100,
editedAt: 101,
isDeleted: false,
replyToId: 'm0',
linkMetadata: [{ url: 'https://example.com', title: 'Example' }],
kind: 'user' as const,
reactions: []
};
const row = messageToRow(message);
const restored = rowToMessage(row, [{ id: 'rx1', messageId: 'm1', oderId: 'u1', userId: 'u1', emoji: '👍', timestamp: 102 }]);
expect(restored.id).toBe('m1');
expect(restored.linkMetadata?.[0]?.title).toBe('Example');
expect(restored.reactions).toHaveLength(1);
});
it('maps booleans to integers for SQLite storage', () => {
const user = userToRow({
id: 'u1',
oderId: 'u1',
username: 'alice',
displayName: 'Alice',
status: 'online',
role: 'member',
joinedAt: 1,
isOnline: true,
isAdmin: false,
isRoomOwner: true,
voiceState: { muted: false, deafened: false, speaking: false }
});
expect(user.isOnline).toBe(1);
expect(user.isAdmin).toBe(0);
expect(user.isRoomOwner).toBe(1);
expect(user.voiceState).toContain('muted');
const restored = rowToUser(user);
expect(restored.isOnline).toBe(true);
expect(restored.voiceState).toEqual({ muted: false, deafened: false, speaking: false });
});
});

View File

@@ -0,0 +1,320 @@
import {
DELETED_MESSAGE_CONTENT,
type BanEntry,
type Message,
type Reaction,
type Room,
type User
} from '../../../shared-kernel';
import type { ChatAttachmentMeta, CustomEmoji } from '../../../shared-kernel';
export interface MessageRow {
id: string;
roomId: string;
ownerUserId?: string | null;
channelId?: string | null;
senderId: string;
senderName: string;
content: string;
timestamp: number;
editedAt?: number | null;
isDeleted: number;
replyToId?: string | null;
linkMetadata?: string | null;
kind?: string | null;
systemEvent?: string | null;
}
export interface UserRow {
id: string;
oderId?: string | null;
username?: string | null;
displayName?: string | null;
description?: string | null;
profileUpdatedAt?: number | null;
avatarUrl?: string | null;
avatarHash?: string | null;
avatarMime?: string | null;
avatarUpdatedAt?: number | null;
status?: string | null;
role?: string | null;
joinedAt?: number | null;
peerId?: string | null;
isOnline: number;
isAdmin: number;
isRoomOwner: number;
voiceState?: string | null;
screenShareState?: string | null;
homeSignalServerUrl?: string | null;
}
export interface RoomRow {
id: string;
name: string;
description?: string | null;
topic?: string | null;
hostId: string;
password?: string | null;
hasPassword: number;
isPrivate: number;
createdAt: number;
userCount: number;
maxUsers?: number | null;
icon?: string | null;
iconUpdatedAt?: number | null;
slowModeInterval: number;
sourceId?: string | null;
sourceName?: string | null;
sourceUrl?: string | null;
}
function encodeJson(value: unknown): string | null {
if (value === undefined || value === null) {
return null;
}
return JSON.stringify(value);
}
function decodeJson<T>(value: string | null | undefined): T | undefined {
if (!value) {
return undefined;
}
try {
return JSON.parse(value) as T;
} catch {
return undefined;
}
}
export function messageToRow(message: Message): MessageRow {
return {
id: message.id,
roomId: message.roomId,
channelId: message.channelId ?? null,
senderId: message.senderId,
senderName: message.senderName,
content: message.content,
timestamp: message.timestamp,
editedAt: message.editedAt ?? null,
isDeleted: message.isDeleted ? 1 : 0,
replyToId: message.replyToId ?? null,
linkMetadata: encodeJson(message.linkMetadata),
kind: message.kind ?? null,
systemEvent: message.systemEvent ?? null
};
}
export function rowToMessage(row: MessageRow, reactions: Reaction[] = []): Message {
const message: Message = {
id: row.id,
roomId: row.roomId,
channelId: row.channelId ?? undefined,
senderId: row.senderId,
senderName: row.senderName,
content: row.content,
timestamp: row.timestamp,
editedAt: row.editedAt ?? undefined,
isDeleted: row.isDeleted === 1,
replyToId: row.replyToId ?? undefined,
linkMetadata: decodeJson(row.linkMetadata),
kind: (row.kind as Message['kind']) ?? undefined,
systemEvent: (row.systemEvent as Message['systemEvent']) ?? undefined,
reactions
};
if (message.content === DELETED_MESSAGE_CONTENT) {
return { ...message, reactions: [] };
}
return message;
}
export function userToRow(user: User): UserRow {
return {
id: user.id,
oderId: user.oderId,
username: user.username,
displayName: user.displayName,
description: user.description ?? null,
profileUpdatedAt: user.profileUpdatedAt ?? null,
avatarUrl: user.avatarUrl ?? null,
avatarHash: user.avatarHash ?? null,
avatarMime: user.avatarMime ?? null,
avatarUpdatedAt: user.avatarUpdatedAt ?? null,
status: user.status,
role: user.role,
joinedAt: user.joinedAt,
peerId: user.peerId ?? null,
isOnline: user.isOnline ? 1 : 0,
isAdmin: user.isAdmin ? 1 : 0,
isRoomOwner: user.isRoomOwner ? 1 : 0,
voiceState: encodeJson(user.voiceState),
screenShareState: encodeJson(user.screenShareState),
homeSignalServerUrl: user.homeSignalServerUrl ?? null
};
}
export function rowToUser(row: UserRow): User {
return {
id: row.id,
oderId: row.oderId ?? row.id,
username: row.username ?? '',
displayName: row.displayName ?? '',
description: row.description ?? undefined,
profileUpdatedAt: row.profileUpdatedAt ?? undefined,
avatarUrl: row.avatarUrl ?? undefined,
avatarHash: row.avatarHash ?? undefined,
avatarMime: row.avatarMime ?? undefined,
avatarUpdatedAt: row.avatarUpdatedAt ?? undefined,
status: (row.status as User['status']) ?? 'offline',
role: (row.role as User['role']) ?? 'member',
joinedAt: row.joinedAt ?? 0,
peerId: row.peerId ?? undefined,
isOnline: row.isOnline === 1,
isAdmin: row.isAdmin === 1,
isRoomOwner: row.isRoomOwner === 1,
voiceState: decodeJson(row.voiceState),
screenShareState: decodeJson(row.screenShareState),
homeSignalServerUrl: row.homeSignalServerUrl ?? undefined
};
}
export function roomToRow(room: Room): RoomRow {
return {
id: room.id,
name: room.name,
description: room.description ?? null,
topic: room.topic ?? null,
hostId: room.hostId,
password: room.password ?? null,
hasPassword: room.hasPassword ? 1 : 0,
isPrivate: room.isPrivate ? 1 : 0,
createdAt: room.createdAt,
userCount: room.userCount,
maxUsers: room.maxUsers ?? null,
icon: room.icon ?? null,
iconUpdatedAt: room.iconUpdatedAt ?? null,
slowModeInterval: room.slowModeInterval ?? 0,
sourceId: room.sourceId ?? null,
sourceName: room.sourceName ?? null,
sourceUrl: room.sourceUrl ?? null
};
}
export function rowToRoom(row: RoomRow): Room {
return {
id: row.id,
name: row.name,
description: row.description ?? undefined,
topic: row.topic ?? undefined,
hostId: row.hostId,
password: row.password ?? undefined,
hasPassword: row.hasPassword === 1,
isPrivate: row.isPrivate === 1,
createdAt: row.createdAt,
userCount: row.userCount,
maxUsers: row.maxUsers ?? undefined,
icon: row.icon ?? undefined,
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
slowModeInterval: row.slowModeInterval,
sourceId: row.sourceId ?? undefined,
sourceName: row.sourceName ?? undefined,
sourceUrl: row.sourceUrl ?? undefined
};
}
export function reactionToValues(reaction: Reaction): unknown[] {
return [
reaction.id,
reaction.messageId,
reaction.oderId,
reaction.userId,
reaction.emoji,
reaction.timestamp
];
}
export function rowToReaction(row: Reaction): Reaction {
return row;
}
export function banToValues(ban: BanEntry): unknown[] {
return [
ban.oderId,
ban.roomId,
ban.userId,
ban.bannedBy,
ban.displayName ?? null,
ban.reason ?? null,
ban.expiresAt ?? null,
ban.timestamp
];
}
export function attachmentToValues(attachment: ChatAttachmentMeta): unknown[] {
return [
attachment.id,
attachment.messageId,
attachment.filename,
attachment.size,
attachment.mime,
attachment.isImage ? 1 : 0,
attachment.uploaderPeerId ?? null,
attachment.filePath ?? null,
attachment.savedPath ?? null
];
}
export function rowToAttachment(row: {
id: string;
messageId: string;
filename: string;
size: number;
mime: string;
isImage: number;
uploaderPeerId?: string | null;
filePath?: string | null;
savedPath?: string | null;
}): ChatAttachmentMeta {
return {
id: row.id,
messageId: row.messageId,
filename: row.filename,
size: row.size,
mime: row.mime,
isImage: row.isImage === 1,
uploaderPeerId: row.uploaderPeerId ?? undefined,
filePath: row.filePath ?? undefined,
savedPath: row.savedPath ?? undefined
};
}
export function customEmojiToValues(emoji: CustomEmoji): unknown[] {
return [
emoji.id,
emoji.name,
emoji.creatorUserId,
emoji.dataUrl,
emoji.hash,
emoji.mime,
emoji.size,
emoji.createdAt,
emoji.updatedAt
];
}
export function rowToCustomEmoji(row: {
id: string;
name: string;
creatorUserId: string;
dataUrl: string;
hash: string;
mime: string;
size: number;
createdAt: number;
updatedAt: number;
}): CustomEmoji {
return row;
}

View File

@@ -0,0 +1,43 @@
import {
describe,
expect,
it,
vi
} from 'vitest';
import { executeMobileSqliteStatements } from './mobile-sqlite-execute.rules';
import {
MOBILE_SQLITE_SCHEMA_VERSION,
buildMobileSqliteSchemaStatements,
resolveMobileSqliteMigrationStatements
} from './mobile-sqlite-schema.rules';
describe('mobile-sqlite-schema.rules', () => {
it('returns one statement per DDL operation for a fresh database', () => {
const statements = resolveMobileSqliteMigrationStatements(0);
expect(statements.length).toBe(buildMobileSqliteSchemaStatements().length);
expect(statements.length).toBeGreaterThan(1);
for (const statement of statements) {
expect(statement.trim().length).toBeGreaterThan(0);
expect(statement).not.toMatch(/;\s*CREATE/i);
}
});
it('returns no statements when the stored schema version is current', () => {
expect(resolveMobileSqliteMigrationStatements(MOBILE_SQLITE_SCHEMA_VERSION)).toEqual([]);
});
it('executes each migration statement separately', async () => {
const execute = vi.fn(() => Promise.resolve());
const statements = resolveMobileSqliteMigrationStatements(0).slice(0, 3);
await executeMobileSqliteStatements(execute, statements);
expect(execute).toHaveBeenCalledTimes(3);
expect(execute.mock.calls.map(([statement]) => statement)).toEqual(statements);
expect(execute.mock.calls[0]?.[0]).toMatch(/^CREATE TABLE IF NOT EXISTS messages/);
expect(execute.mock.calls[1]?.[0]).toMatch(/^CREATE INDEX IF NOT EXISTS idx_messages_room_id/);
});
});

View File

@@ -0,0 +1,157 @@
/** Native SQLite database name for Capacitor mobile shells. */
export const MOBILE_SQLITE_DATABASE_NAME = 'metoyou';
/** Bump when adding DDL statements; stored in meta table. */
export const MOBILE_SQLITE_SCHEMA_VERSION = 2;
const META_SCHEMA_VERSION_KEY = 'mobile_sqlite_schema_version';
/** DDL mirrored from Electron TypeORM entities under `electron/entities/`. */
export function buildMobileSqliteSchemaStatements(): string[] {
return [
`CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY NOT NULL,
roomId TEXT NOT NULL,
ownerUserId TEXT,
channelId TEXT,
senderId TEXT NOT NULL,
senderName TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER NOT NULL,
editedAt INTEGER,
isDeleted INTEGER NOT NULL DEFAULT 0,
replyToId TEXT,
linkMetadata TEXT,
kind TEXT,
systemEvent TEXT
)`,
'CREATE INDEX IF NOT EXISTS idx_messages_room_id ON messages(roomId)',
'CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)',
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
oderId TEXT,
username TEXT,
displayName TEXT,
description TEXT,
profileUpdatedAt INTEGER,
avatarUrl TEXT,
avatarHash TEXT,
avatarMime TEXT,
avatarUpdatedAt INTEGER,
status TEXT,
role TEXT,
joinedAt INTEGER,
peerId TEXT,
isOnline INTEGER NOT NULL DEFAULT 0,
isAdmin INTEGER NOT NULL DEFAULT 0,
isRoomOwner INTEGER NOT NULL DEFAULT 0,
voiceState TEXT,
screenShareState TEXT,
homeSignalServerUrl TEXT
)`,
`CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
description TEXT,
topic TEXT,
hostId TEXT NOT NULL,
password TEXT,
hasPassword INTEGER NOT NULL DEFAULT 0,
isPrivate INTEGER NOT NULL DEFAULT 0,
createdAt INTEGER NOT NULL,
userCount INTEGER NOT NULL DEFAULT 0,
maxUsers INTEGER,
icon TEXT,
iconUpdatedAt INTEGER,
slowModeInterval INTEGER NOT NULL DEFAULT 0,
sourceId TEXT,
sourceName TEXT,
sourceUrl TEXT
)`,
'CREATE INDEX IF NOT EXISTS idx_rooms_created_at ON rooms(createdAt)',
`CREATE TABLE IF NOT EXISTS reactions (
id TEXT PRIMARY KEY NOT NULL,
messageId TEXT NOT NULL,
oderId TEXT,
userId TEXT,
emoji TEXT NOT NULL,
timestamp INTEGER NOT NULL
)`,
'CREATE INDEX IF NOT EXISTS idx_reactions_message_id ON reactions(messageId)',
`CREATE TABLE IF NOT EXISTS bans (
oderId TEXT NOT NULL,
roomId TEXT NOT NULL,
userId TEXT,
bannedBy TEXT NOT NULL,
displayName TEXT,
reason TEXT,
expiresAt INTEGER,
timestamp INTEGER NOT NULL,
PRIMARY KEY (oderId, roomId)
)`,
'CREATE INDEX IF NOT EXISTS idx_bans_room_id ON bans(roomId)',
`CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY NOT NULL,
messageId TEXT NOT NULL,
filename TEXT NOT NULL,
size INTEGER NOT NULL,
mime TEXT NOT NULL,
isImage INTEGER NOT NULL DEFAULT 0,
uploaderPeerId TEXT,
filePath TEXT,
savedPath TEXT
)`,
'CREATE INDEX IF NOT EXISTS idx_attachments_message_id ON attachments(messageId)',
`CREATE TABLE IF NOT EXISTS custom_emojis (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
creatorUserId TEXT NOT NULL,
dataUrl TEXT NOT NULL,
hash TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL
)`,
'CREATE INDEX IF NOT EXISTS idx_custom_emojis_updated_at ON custom_emojis(updatedAt)',
`CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY NOT NULL,
value TEXT
)`,
`CREATE TABLE IF NOT EXISTS push_device_tokens (
id TEXT PRIMARY KEY NOT NULL,
userId TEXT NOT NULL,
platform TEXT NOT NULL,
token TEXT NOT NULL,
updatedAt INTEGER NOT NULL
)`,
'CREATE INDEX IF NOT EXISTS idx_push_device_tokens_user_id ON push_device_tokens(userId)',
`INSERT OR REPLACE INTO meta (key, value) VALUES ('${META_SCHEMA_VERSION_KEY}', '${MOBILE_SQLITE_SCHEMA_VERSION}')`
];
}
const SCHEMA_V2_MESSAGE_COLUMNS = [
'ALTER TABLE messages ADD COLUMN linkMetadata TEXT',
'ALTER TABLE messages ADD COLUMN kind TEXT',
'ALTER TABLE messages ADD COLUMN systemEvent TEXT'
];
/** Returns DDL statements that still need to run for the stored schema version. */
export function resolveMobileSqliteMigrationStatements(storedVersion: number): string[] {
if (storedVersion >= MOBILE_SQLITE_SCHEMA_VERSION) {
return [];
}
if (storedVersion <= 0) {
return buildMobileSqliteSchemaStatements();
}
const statements: string[] = [];
if (storedVersion < 2) {
statements.push(...SCHEMA_V2_MESSAGE_COLUMNS);
statements.push(`INSERT OR REPLACE INTO meta (key, value) VALUES ('${META_SCHEMA_VERSION_KEY}', '2')`);
}
return statements;
}

View File

@@ -0,0 +1,61 @@
import {
describe,
expect,
it
} from 'vitest';
import {
detectRuntimePlatform,
isCapacitorNativeRuntime,
shouldUseMobileAttachmentPicker,
type RuntimePlatform
} from './platform-detection.rules';
describe('platform-detection.rules', () => {
describe('detectRuntimePlatform', () => {
it('prefers electron when the preload API is present', () => {
expect(
detectRuntimePlatform({
hasElectronApi: true,
capacitorIsNative: true
})
).toBe<RuntimePlatform>('electron');
});
it('detects capacitor when running in a native shell without electron', () => {
expect(
detectRuntimePlatform({
hasElectronApi: false,
capacitorIsNative: true
})
).toBe<RuntimePlatform>('capacitor');
});
it('falls back to browser for web runtimes', () => {
expect(
detectRuntimePlatform({
hasElectronApi: false,
capacitorIsNative: false
})
).toBe<RuntimePlatform>('browser');
});
});
describe('shouldUseMobileAttachmentPicker', () => {
it('enables the picker on capacitor native and mobile web viewports', () => {
expect(shouldUseMobileAttachmentPicker('capacitor', true)).toBe(true);
expect(shouldUseMobileAttachmentPicker('browser', true)).toBe(true);
});
it('keeps drag-and-drop on desktop browser and electron', () => {
expect(shouldUseMobileAttachmentPicker('browser', false)).toBe(false);
expect(shouldUseMobileAttachmentPicker('electron', true)).toBe(false);
});
});
describe('isCapacitorNativeRuntime', () => {
it('returns false when Capacitor is unavailable', () => {
expect(isCapacitorNativeRuntime()).toBe(false);
});
});
});

View File

@@ -0,0 +1,45 @@
export type RuntimePlatform = 'electron' | 'capacitor' | 'browser';
export interface PlatformDetectionInput {
hasElectronApi: boolean;
capacitorIsNative?: boolean;
}
type CapacitorWindow = Window & {
Capacitor?: {
isNativePlatform?: () => boolean;
};
};
/** Resolve the active runtime shell used by the product client. */
export function detectRuntimePlatform(input: PlatformDetectionInput): RuntimePlatform {
if (input.hasElectronApi) {
return 'electron';
}
if (input.capacitorIsNative) {
return 'capacitor';
}
return 'browser';
}
/** Best-effort detection of a Capacitor native shell without importing Capacitor modules. */
export function isCapacitorNativeRuntime(): boolean {
if (typeof window === 'undefined') {
return false;
}
const capacitor = (window as CapacitorWindow).Capacitor;
return capacitor?.isNativePlatform?.() === true;
}
/** Whether the chat composer should expose a tap-to-attach control instead of drag-and-drop only. */
export function shouldUseMobileAttachmentPicker(runtime: RuntimePlatform, isMobileViewport: boolean): boolean {
if (runtime === 'electron') {
return false;
}
return runtime === 'capacitor' || isMobileViewport;
}

View File

@@ -0,0 +1,34 @@
import { Injectable, inject } from '@angular/core';
import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobileAppLifecycleAdapter } from '../adapters/capacitor/capacitor-mobile-app-lifecycle.adapter';
import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter';
import { MobilePlatformService } from './mobile-platform.service';
/** Facade for foreground/background lifecycle events. */
@Injectable({ providedIn: 'root' })
export class MobileAppLifecycleService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly adapter: MobileAppLifecycleAdapter = this.createAdapter();
private initialized = false;
async initialize(): Promise<void> {
if (this.initialized) {
return;
}
await this.adapter.initialize();
this.mobilePlatform.refreshRuntimeDetection();
this.initialized = true;
}
onAppStateChange(handler: (isActive: boolean) => void): void {
this.adapter.onAppStateChange(handler);
}
private createAdapter(): MobileAppLifecycleAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobileAppLifecycleAdapter()
: new WebMobileAppLifecycleAdapter();
}
}

View File

@@ -0,0 +1,135 @@
import {
DestroyRef,
Injectable,
inject
} from '@angular/core';
import { Router } from '@angular/router';
import type { CallNotificationActionIntent } from '../logic/call-notification.rules';
import { MobileAppLifecycleService } from './mobile-app-lifecycle.service';
import { MobileCallKitService } from './mobile-callkit.service';
import { MobileMediaService } from './mobile-media.service';
import { MobileNotificationsService } from './mobile-notifications.service';
import { MobilePictureInPictureService } from './mobile-picture-in-picture.service';
import { MobilePlatformService } from './mobile-platform.service';
export interface ActiveCallSessionState {
callId: string;
displayName: string;
isMuted: boolean;
focusedStreamVideo?: HTMLVideoElement | null;
}
/** Coordinates in-call notifications, background audio, and stream pop-out for mobile shells. */
@Injectable({ providedIn: 'root' })
export class MobileCallSessionService {
private readonly destroyRef = inject(DestroyRef);
private readonly router = inject(Router);
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly notifications = inject(MobileNotificationsService);
private readonly media = inject(MobileMediaService);
private readonly pictureInPicture = inject(MobilePictureInPictureService);
private readonly lifecycle = inject(MobileAppLifecycleService);
private readonly callKit = inject(MobileCallKitService);
private activeSession: ActiveCallSessionState | null = null;
private actionHandler: ((intent: CallNotificationActionIntent, callId: string) => void) | null = null;
private wired = false;
initialize(): void {
if (this.wired) {
return;
}
this.wired = true;
void this.notifications.initialize();
void this.lifecycle.initialize();
this.notifications.onCallAction(({ callId, intent }) => {
void this.router.navigate(['/call', callId]);
this.actionHandler?.(intent, callId);
});
this.lifecycle.onAppStateChange((isActive) => {
void this.handleAppStateChange(isActive);
});
this.destroyRef.onDestroy(() => {
this.activeSession = null;
this.actionHandler = null;
});
}
onCallControlAction(handler: (intent: CallNotificationActionIntent, callId: string) => void): void {
this.actionHandler = handler;
}
async notifyIncomingCall(displayName: string, callId: string): Promise<void> {
if (!this.shouldHandleMobileCalls()) {
return;
}
await this.notifications.showIncomingCall(displayName, callId);
}
async startActiveCall(session: ActiveCallSessionState): Promise<void> {
if (!this.shouldHandleMobileCalls()) {
return;
}
this.activeSession = session;
await this.media.startBackgroundAudioSession();
await this.callKit.startActiveCall(session.callId, session.displayName);
await this.notifications.dismissIncomingCall(session.callId);
await this.notifications.showActiveCall(session);
}
async updateActiveCall(session: ActiveCallSessionState): Promise<void> {
if (!this.shouldHandleMobileCalls()) {
return;
}
this.activeSession = session;
await this.notifications.showActiveCall(session);
}
async endActiveCall(callId: string): Promise<void> {
await this.notifications.dismissIncomingCall(callId);
await this.notifications.dismissActiveCall(callId);
await this.callKit.endActiveCall(callId);
await this.media.stopBackgroundAudioSession();
await this.pictureInPicture.exit();
if (this.activeSession?.callId === callId) {
this.activeSession = null;
}
}
setFocusedStreamVideo(videoElement: HTMLVideoElement | null): void {
if (!this.activeSession) {
return;
}
this.activeSession = {
...this.activeSession,
focusedStreamVideo: videoElement
};
}
private shouldHandleMobileCalls(): boolean {
return this.mobilePlatform.isNativeMobile();
}
private async handleAppStateChange(isActive: boolean): Promise<void> {
if (!this.activeSession || isActive) {
return;
}
const video = this.activeSession.focusedStreamVideo;
if (video && this.pictureInPicture.isSupported()) {
await this.pictureInPicture.enter(video);
}
}
}

View File

@@ -0,0 +1,35 @@
import { Injectable, inject } from '@angular/core';
import type { MobileCallKitAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobileCallKitAdapter } from '../adapters/capacitor/capacitor-mobile-callkit.adapter';
import { WebMobileCallKitAdapter } from '../adapters/web/web-mobile-callkit.adapter';
import { MobilePlatformService } from './mobile-platform.service';
/** Facade for iOS CallKit active-call reporting. */
@Injectable({ providedIn: 'root' })
export class MobileCallKitService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly adapter: MobileCallKitAdapter = this.createAdapter();
startActiveCall(callId: string, displayName: string): Promise<void> {
if (!this.mobilePlatform.isCapacitor()) {
return Promise.resolve();
}
return this.adapter.startActiveCall(callId, displayName);
}
endActiveCall(callId: string): Promise<void> {
if (!this.mobilePlatform.isCapacitor()) {
return Promise.resolve();
}
return this.adapter.endActiveCall(callId);
}
private createAdapter(): MobileCallKitAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobileCallKitAdapter()
: new WebMobileCallKitAdapter();
}
}

View File

@@ -0,0 +1,42 @@
import {
Injectable,
computed,
inject
} from '@angular/core';
import type { MobileMediaAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobileMediaAdapter } from '../adapters/capacitor/capacitor-mobile-media.adapter';
import { WebMobileMediaAdapter } from '../adapters/web/web-mobile-media.adapter';
import { MobilePlatformService } from './mobile-platform.service';
/** Facade for mobile media affordances: attachments, speakerphone, background audio, capture limits. */
@Injectable({ providedIn: 'root' })
export class MobileMediaService {
readonly isScreenShareSupported = computed(() => this.adapter.isScreenShareSupported());
readonly isPictureInPictureSupported = computed(() => this.adapter.isPictureInPictureSupported());
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly adapter: MobileMediaAdapter = this.createAdapter();
pickAttachments(): Promise<File[]> {
return this.adapter.pickAttachments();
}
setSpeakerphoneEnabled(enabled: boolean): Promise<void> {
return this.adapter.setSpeakerphoneEnabled(enabled);
}
startBackgroundAudioSession(): Promise<void> {
return this.adapter.startBackgroundAudioSession();
}
stopBackgroundAudioSession(): Promise<void> {
return this.adapter.stopBackgroundAudioSession();
}
private createAdapter(): MobileMediaAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobileMediaAdapter()
: new WebMobileMediaAdapter();
}
}

View File

@@ -0,0 +1,56 @@
import { Injectable, inject } from '@angular/core';
import type { CallNotificationActionIntent } from '../logic/call-notification.rules';
import { buildIncomingCallNotification, buildInCallNotification } from '../logic/call-notification.rules';
import type { MobileNotificationAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobileNotificationsAdapter } from '../adapters/capacitor/capacitor-mobile-notifications.adapter';
import { WebMobileNotificationsAdapter } from '../adapters/web/web-mobile-notifications.adapter';
import { MobilePlatformService } from './mobile-platform.service';
import { MobilePushRegistrationService } from './mobile-push-registration.service';
/** Facade for push/local notifications with platform-specific adapters. */
@Injectable({ providedIn: 'root' })
export class MobileNotificationsService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly pushRegistration = inject(MobilePushRegistrationService);
private readonly adapter: MobileNotificationAdapter = this.createAdapter();
private initialized = false;
async initialize(): Promise<void> {
if (this.initialized) {
return;
}
await this.adapter.initialize();
this.pushRegistration.initialize();
this.initialized = true;
}
async showIncomingCall(displayName: string, callId: string): Promise<void> {
await this.initialize();
await this.adapter.showCallNotification(buildIncomingCallNotification(displayName, callId));
}
async showActiveCall(input: { callId: string; displayName: string; isMuted: boolean }): Promise<void> {
await this.initialize();
await this.adapter.showCallNotification(buildInCallNotification(input));
}
async dismissIncomingCall(callId: string): Promise<void> {
await this.adapter.dismissCallNotification(callId, 'incoming');
}
async dismissActiveCall(callId: string): Promise<void> {
await this.adapter.dismissCallNotification(callId, 'active');
}
onCallAction(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void {
this.adapter.onActionSelected(handler);
}
private createAdapter(): MobileNotificationAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobileNotificationsAdapter()
: new WebMobileNotificationsAdapter();
}
}

View File

@@ -0,0 +1,29 @@
import { Injectable, inject } from '@angular/core';
import type { MobilePersistenceAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobilePersistenceAdapter } from '../adapters/capacitor/capacitor-mobile-persistence.adapter';
import { WebMobilePersistenceAdapter } from '../adapters/web/web-mobile-persistence.adapter';
import { MobilePlatformService } from './mobile-platform.service';
import { MobileSqliteConnectionService } from './mobile-sqlite-connection.service';
/** Facade for native SQLite persistence on mobile shells. */
@Injectable({ providedIn: 'root' })
export class MobilePersistenceService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly sqliteConnection = inject(MobileSqliteConnectionService);
private readonly adapter: MobilePersistenceAdapter = this.createAdapter();
get isNativeSqlite(): boolean {
return this.adapter.isNativeSqlite;
}
initialize(): Promise<void> {
return this.adapter.initialize();
}
private createAdapter(): MobilePersistenceAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobilePersistenceAdapter(this.sqliteConnection)
: new WebMobilePersistenceAdapter();
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable, inject } from '@angular/core';
import type { MobilePictureInPictureAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobilePictureInPictureAdapter } from '../adapters/capacitor/capacitor-mobile-picture-in-picture.adapter';
import { WebMobilePictureInPictureAdapter } from '../adapters/web/web-mobile-picture-in-picture.adapter';
import { MobilePlatformService } from './mobile-platform.service';
/** Facade for stream pop-out while the app is backgrounded. */
@Injectable({ providedIn: 'root' })
export class MobilePictureInPictureService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly adapter: MobilePictureInPictureAdapter = this.createAdapter();
isSupported(): boolean {
return this.adapter.isSupported();
}
enter(videoElement: HTMLVideoElement): Promise<void> {
return this.adapter.enter(videoElement);
}
exit(): Promise<void> {
return this.adapter.exit();
}
private createAdapter(): MobilePictureInPictureAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobilePictureInPictureAdapter()
: new WebMobilePictureInPictureAdapter();
}
}

View File

@@ -0,0 +1,46 @@
import '@angular/compiler';
import { Injector, runInInjectionContext } from '@angular/core';
import {
describe,
expect,
it
} from 'vitest';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../core/platform/viewport.service';
import { MobilePlatformService } from './mobile-platform.service';
function createService(options: { isMobile: boolean }): MobilePlatformService {
const injector = Injector.create({
providers: [
MobilePlatformService,
{
provide: ElectronBridgeService,
useValue: { isAvailable: false }
},
{
provide: ViewportService,
useValue: {
isMobile: () => options.isMobile
}
}
]
});
return runInInjectionContext(injector, () => injector.get(MobilePlatformService));
}
describe('MobilePlatformService', () => {
it('reports browser runtime and hides attachment button on desktop viewport', () => {
const service = createService({ isMobile: false });
expect(service.runtime()).toBe('browser');
expect(service.shouldShowAttachmentButton()).toBe(false);
});
it('enables attachment button on mobile viewport in browser runtime', () => {
const service = createService({ isMobile: true });
expect(service.shouldShowAttachmentButton()).toBe(true);
});
});

View File

@@ -0,0 +1,51 @@
import {
Injectable,
computed,
inject,
signal
} from '@angular/core';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../core/platform/viewport.service';
import {
detectRuntimePlatform,
isCapacitorNativeRuntime,
shouldUseMobileAttachmentPicker,
type RuntimePlatform
} from '../logic/platform-detection.rules';
import type { MobilePlatformSnapshot } from '../contracts/mobile.contracts';
/** Detects runtime shell and exposes mobile capability flags to Angular domains. */
@Injectable({ providedIn: 'root' })
export class MobilePlatformService {
readonly runtime = computed(() => this.runtimeSignal());
readonly isCapacitor = computed(() => this.runtimeSignal() === 'capacitor');
readonly isNativeMobile = computed(() => this.isCapacitor());
readonly shouldShowAttachmentButton = computed(() =>
shouldUseMobileAttachmentPicker(this.runtimeSignal(), this.viewport.isMobile())
);
readonly snapshot = computed<MobilePlatformSnapshot>(() => ({
runtime: this.runtimeSignal(),
isNativeMobile: this.isCapacitor(),
isCapacitor: this.isCapacitor()
}));
private readonly electronBridge = inject(ElectronBridgeService);
private readonly viewport = inject(ViewportService);
private readonly runtimeSignal = signal<RuntimePlatform>(
detectRuntimePlatform({
hasElectronApi: this.electronBridge.isAvailable,
capacitorIsNative: isCapacitorNativeRuntime()
})
);
/** Re-evaluate runtime detection after Capacitor bootstraps on device. */
refreshRuntimeDetection(): void {
this.runtimeSignal.set(
detectRuntimePlatform({
hasElectronApi: this.electronBridge.isAvailable,
capacitorIsNative: isCapacitorNativeRuntime()
})
);
}
}

View File

@@ -0,0 +1,111 @@
import '@angular/compiler';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { Injector, runInInjectionContext } from '@angular/core';
const pushState = vi.hoisted(() => ({
register: vi.fn(() => Promise.resolve()),
checkPermissions: vi.fn(() => Promise.resolve({ receive: 'granted' })),
requestPermissions: vi.fn(() => Promise.resolve({ receive: 'granted' })),
addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() }))
}));
const deviceState = vi.hoisted(() => ({
getInfo: vi.fn(() => Promise.resolve({ platform: 'android' }))
}));
const mobilePlatformState = vi.hoisted(() => ({
isCapacitor: true
}));
const remotePushState = vi.hoisted(() => ({
configured: true
}));
vi.mock('../adapters/capacitor/capacitor-plugin-loader', () => ({
loadCapacitorPushNotificationsPlugin: () => pushState,
loadCapacitorDevicePlugin: () => deviceState
}));
vi.mock('../adapters/capacitor/metoyou-mobile.plugin', () => ({
MetoyouMobile: {
isRemotePushConfigured: vi.fn(() => Promise.resolve({ configured: remotePushState.configured }))
}
}));
import { MetoyouMobile } from '../adapters/capacitor/metoyou-mobile.plugin';
import { MobilePlatformService } from './mobile-platform.service';
import { MobilePushRegistrationService } from './mobile-push-registration.service';
function createService(): MobilePushRegistrationService {
const injector = Injector.create({
providers: [
MobilePushRegistrationService,
{
provide: MobilePlatformService,
useValue: {
isCapacitor: () => mobilePlatformState.isCapacitor
}
}
]
});
return runInInjectionContext(injector, () => injector.get(MobilePushRegistrationService));
}
describe('MobilePushRegistrationService', () => {
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
mobilePlatformState.isCapacitor = true;
remotePushState.configured = true;
pushState.register.mockClear();
pushState.addListener.mockClear();
vi.mocked(MetoyouMobile.isRemotePushConfigured).mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('skips PushNotifications.register when remote push is unavailable', async () => {
remotePushState.configured = false;
const service = createService();
service.initialize();
await vi.waitFor(() => {
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('google-services.json')
);
});
expect(pushState.register).not.toHaveBeenCalled();
});
it('registers for remote push when Firebase/APNs is configured', async () => {
const service = createService();
service.initialize();
await vi.waitFor(() => {
expect(pushState.register).toHaveBeenCalledTimes(1);
});
expect(MetoyouMobile.isRemotePushConfigured).toHaveBeenCalled();
});
it('does not wire listeners on non-capacitor shells', () => {
mobilePlatformState.isCapacitor = false;
const service = createService();
service.initialize();
expect(pushState.register).not.toHaveBeenCalled();
expect(MetoyouMobile.isRemotePushConfigured).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,139 @@
import { Injectable, inject } from '@angular/core';
import { getStoredCurrentUserId } from '../../../core/storage/current-user-storage';
import { buildPushDeviceTokenRegistrationPayload, normalizePushPlatform } from '../logic/mobile-push-token.rules';
import { buildRemotePushSkipMessage, resolveRemotePushSkipReason } from '../logic/mobile-push-registration.rules';
import { loadCapacitorDevicePlugin, loadCapacitorPushNotificationsPlugin } from '../adapters/capacitor/capacitor-plugin-loader';
import { MetoyouMobile } from '../adapters/capacitor/metoyou-mobile.plugin';
import { MobilePlatformService } from './mobile-platform.service';
/** Registers FCM/APNs device tokens with the signaling server on Capacitor shells. */
@Injectable({ providedIn: 'root' })
export class MobilePushRegistrationService {
private readonly mobilePlatform = inject(MobilePlatformService);
private wired = false;
private latestToken: string | null = null;
initialize(): void {
if (this.wired || !this.mobilePlatform.isCapacitor()) {
return;
}
this.wired = true;
void this.registerPushListeners();
}
async registerCurrentToken(): Promise<void> {
if (!this.latestToken) {
return;
}
await this.persistToken(this.latestToken);
}
private async registerPushListeners(): Promise<void> {
const PushNotifications = loadCapacitorPushNotificationsPlugin();
const Device = loadCapacitorDevicePlugin();
const remotePushConfigured = await this.isRemotePushConfigured();
const skipReason = resolveRemotePushSkipReason({
hasPushPlugin: !!PushNotifications,
hasDevicePlugin: !!Device,
remotePushConfigured
});
if (skipReason) {
console.warn(buildRemotePushSkipMessage(skipReason));
return;
}
const pushNotifications = PushNotifications;
if (!pushNotifications) {
return;
}
try {
const permission = await pushNotifications.checkPermissions();
if (permission.receive === 'prompt') {
await pushNotifications.requestPermissions();
}
await pushNotifications.addListener('registration', (event) => {
this.latestToken = event.value;
void this.persistToken(event.value);
});
await pushNotifications.addListener('registrationError', (error) => {
console.warn('[mobile] push registration failed', error);
});
await pushNotifications.register();
} catch (error) {
console.warn('[mobile] remote push registration skipped after failure', error);
}
}
private async isRemotePushConfigured(): Promise<boolean> {
try {
const result = await MetoyouMobile.isRemotePushConfigured();
return result.configured === true;
} catch {
return false;
}
}
private async persistToken(token: string): Promise<void> {
const userId = getStoredCurrentUserId();
if (!userId) {
return;
}
const Device = loadCapacitorDevicePlugin();
const deviceInfo = Device ? await Device.getInfo() : null;
const platform = normalizePushPlatform(deviceInfo?.platform ?? '');
if (!platform) {
return;
}
const payload = buildPushDeviceTokenRegistrationPayload({
userId,
token,
platform
});
const serverUrl = this.resolveSignalingServerUrl();
if (!serverUrl) {
return;
}
try {
await fetch(`${serverUrl}/api/users/device-tokens`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
} catch (error) {
console.warn('[mobile] failed to persist push device token', error);
}
}
private resolveSignalingServerUrl(): string | null {
if (typeof window === 'undefined') {
return null;
}
const configured = window.localStorage.getItem('metoyou.signalServerUrl');
if (configured) {
return configured.replace(/\/$/, '');
}
return `${window.location.origin}`;
}
}

View File

@@ -0,0 +1,69 @@
import { Injectable } from '@angular/core';
import { createCapacitorSqliteStore, type MobileSqliteStore } from '../adapters/capacitor/capacitor-sqlite.store';
import { resolveMobileSqliteDatabaseName } from '../logic/mobile-sqlite-database-name.rules';
import { getStoredCurrentUserId } from '../../../core/storage/current-user-storage';
/** Shared native SQLite connection used by mobile persistence and DatabaseService. */
@Injectable({ providedIn: 'root' })
export class MobileSqliteConnectionService {
private store: MobileSqliteStore | null = null;
private activeDatabaseName: string | null = null;
private initializationPromise: Promise<MobileSqliteStore | null> | null = null;
private initializationFailed = false;
get isAvailable(): boolean {
return this.store?.isAvailable === true;
}
async initialize(): Promise<MobileSqliteStore | null> {
if (this.initializationFailed) {
return null;
}
if (this.initializationPromise) {
return this.initializationPromise;
}
this.initializationPromise = this.openStore()
.catch((error) => {
this.initializationFailed = true;
console.error('[mobile] SQLite initialization failed', error);
return null;
})
.finally(() => {
this.initializationPromise = null;
});
return this.initializationPromise;
}
async getStore(): Promise<MobileSqliteStore> {
const store = await this.initialize();
if (!store?.isAvailable) {
throw new Error('Native SQLite store is unavailable on this shell.');
}
return store;
}
private async openStore(): Promise<MobileSqliteStore | null> {
const databaseName = resolveMobileSqliteDatabaseName(getStoredCurrentUserId());
if (this.store && this.activeDatabaseName === databaseName && this.store.isAvailable) {
return this.store;
}
this.store = await createCapacitorSqliteStore(databaseName);
if (!this.store) {
return null;
}
await this.store.initialize();
this.activeDatabaseName = databaseName;
return this.store;
}
}

View File

@@ -0,0 +1,503 @@
import { Injectable, inject } from '@angular/core';
import {
DELETED_MESSAGE_CONTENT,
type BanEntry,
type Message,
type Reaction,
type Room,
type User
} from '../../shared-kernel';
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
import {
attachmentToValues,
banToValues,
customEmojiToValues,
messageToRow,
reactionToValues,
roomToRow,
rowToAttachment,
rowToCustomEmoji,
rowToMessage,
rowToRoom,
rowToUser,
userToRow,
type MessageRow,
type RoomRow,
type UserRow
} from '../mobile/logic/mobile-sqlite-row-mapper.rules';
import { MobileSqliteConnectionService } from '../mobile/services/mobile-sqlite-connection.service';
import type { RoomMessageStats } from './database.service';
/**
* SQLite-backed database service for Capacitor native shells.
*
* Mirrors the {@link BrowserDatabaseService} API using `@capacitor-community/sqlite`.
*/
@Injectable({ providedIn: 'root' })
export class CapacitorDatabaseService {
private readonly connection = inject(MobileSqliteConnectionService);
async initialize(): Promise<void> {
await this.connection.initialize();
}
async saveMessage(message: Message): Promise<void> {
const store = await this.connection.getStore();
const row = messageToRow(message);
await store.run(
`INSERT OR REPLACE INTO messages (
id, roomId, ownerUserId, channelId, senderId, senderName, content,
timestamp, editedAt, isDeleted, replyToId, linkMetadata, kind, systemEvent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
row.id,
row.roomId,
row.ownerUserId ?? null,
row.channelId ?? null,
row.senderId,
row.senderName,
row.content,
row.timestamp,
row.editedAt ?? null,
row.isDeleted,
row.replyToId ?? null,
row.linkMetadata ?? null,
row.kind ?? null,
row.systemEvent ?? null
]
);
}
async getMessages(
roomId: string,
limit = 100,
offset = 0,
channelId?: string,
beforeTimestamp?: number
): Promise<Message[]> {
const store = await this.connection.getStore();
const rows = await store.query<MessageRow>(`
SELECT * FROM messages
WHERE roomId = ?
${beforeTimestamp !== undefined ? 'AND timestamp < ?' : ''}
ORDER BY timestamp ASC
`, beforeTimestamp !== undefined ? [roomId, beforeTimestamp] : [roomId]);
const scopedRows = channelId
? rows.filter((row) => (row.channelId || 'general') === channelId)
: rows;
const endIndex = Math.max(scopedRows.length - offset, 0);
const startIndex = Math.max(endIndex - limit, 0);
const slice = scopedRows.slice(startIndex, endIndex);
return this.hydrateMessages(slice.map((row) => rowToMessage(row)));
}
async getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> {
const store = await this.connection.getStore();
const rows = await store.query<MessageRow>(
'SELECT * FROM messages WHERE roomId = ? AND timestamp > ? ORDER BY timestamp ASC',
[roomId, sinceTimestamp]
);
return this.hydrateMessages(rows.map((row) => rowToMessage(row)));
}
async getRoomMessageStats(roomId: string): Promise<RoomMessageStats> {
const store = await this.connection.getStore();
const rows = await store.query<{ count: number; lastUpdated: number }>(
`SELECT COUNT(*) as count, MAX(COALESCE(editedAt, timestamp, 0)) as lastUpdated
FROM messages WHERE roomId = ?`,
[roomId]
);
return {
count: Number(rows[0]?.count ?? 0),
lastUpdated: Number(rows[0]?.lastUpdated ?? 0)
};
}
async deleteMessage(messageId: string): Promise<void> {
const store = await this.connection.getStore();
await store.run('DELETE FROM messages WHERE id = ?', [messageId]);
}
async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
const existing = await this.getMessageById(messageId);
if (existing) {
await this.saveMessage({ ...existing, ...updates });
}
}
async getMessageById(messageId: string): Promise<Message | null> {
const store = await this.connection.getStore();
const rows = await store.query<MessageRow>('SELECT * FROM messages WHERE id = ? LIMIT 1', [messageId]);
const row = rows[0];
if (!row) {
return null;
}
const messages = await this.hydrateMessages([rowToMessage(row)]);
return messages[0] ?? null;
}
async clearRoomMessages(roomId: string): Promise<void> {
const store = await this.connection.getStore();
await store.run('DELETE FROM messages WHERE roomId = ?', [roomId]);
}
async saveReaction(reaction: Reaction): Promise<void> {
const store = await this.connection.getStore();
const existing = await store.query<Reaction>(
'SELECT * FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ? LIMIT 1',
[
reaction.messageId,
reaction.userId,
reaction.emoji
]
);
if (existing.length === 0) {
await store.run(
'INSERT OR REPLACE INTO reactions (id, messageId, oderId, userId, emoji, timestamp) VALUES (?, ?, ?, ?, ?, ?)',
reactionToValues(reaction)
);
}
}
async removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
const store = await this.connection.getStore();
await store.run(
'DELETE FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ?',
[
messageId,
userId,
emoji
]
);
}
async getReactionsForMessage(messageId: string): Promise<Reaction[]> {
const store = await this.connection.getStore();
return store.query<Reaction>(
'SELECT * FROM reactions WHERE messageId = ? ORDER BY timestamp ASC',
[messageId]
);
}
async saveUser(user: User): Promise<void> {
const store = await this.connection.getStore();
const row = userToRow(user);
await store.run(
`INSERT OR REPLACE INTO users (
id, oderId, username, displayName, description, profileUpdatedAt,
avatarUrl, avatarHash, avatarMime, avatarUpdatedAt, status, role,
joinedAt, peerId, isOnline, isAdmin, isRoomOwner, voiceState,
screenShareState, homeSignalServerUrl
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
row.id,
row.oderId ?? null,
row.username ?? null,
row.displayName ?? null,
row.description ?? null,
row.profileUpdatedAt ?? null,
row.avatarUrl ?? null,
row.avatarHash ?? null,
row.avatarMime ?? null,
row.avatarUpdatedAt ?? null,
row.status ?? null,
row.role ?? null,
row.joinedAt ?? null,
row.peerId ?? null,
row.isOnline,
row.isAdmin,
row.isRoomOwner,
row.voiceState ?? null,
row.screenShareState ?? null,
row.homeSignalServerUrl ?? null
]
);
}
async getUser(userId: string): Promise<User | null> {
const store = await this.connection.getStore();
const rows = await store.query<UserRow>('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]);
return rows[0] ? rowToUser(rows[0]) : null;
}
async getCurrentUser(): Promise<User | null> {
const userId = await this.getCurrentUserId();
return userId ? this.getUser(userId) : null;
}
async getCurrentUserId(): Promise<string | null> {
const store = await this.connection.getStore();
const rows = await store.query<{ value: string }>(
"SELECT value FROM meta WHERE key = 'currentUserId' LIMIT 1"
);
return rows[0]?.value?.trim() || null;
}
async setCurrentUserId(userId: string): Promise<void> {
const store = await this.connection.getStore();
await store.run(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('currentUserId', ?)",
[userId]
);
if (getStoredCurrentUserId() !== userId) {
await this.connection.initialize();
}
}
async getUsersByRoom(_roomId: string): Promise<User[]> {
const store = await this.connection.getStore();
const rows = await store.query<UserRow>('SELECT * FROM users');
return rows.map(rowToUser);
}
async updateUser(userId: string, updates: Partial<User>): Promise<void> {
const existing = await this.getUser(userId);
if (existing) {
await this.saveUser({ ...existing, ...updates });
}
}
async saveRoom(room: Room): Promise<void> {
const store = await this.connection.getStore();
const row = roomToRow(room);
await store.run(
`INSERT OR REPLACE INTO rooms (
id, name, description, topic, hostId, password, hasPassword, isPrivate,
createdAt, userCount, maxUsers, icon, iconUpdatedAt, slowModeInterval,
sourceId, sourceName, sourceUrl
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
row.id,
row.name,
row.description ?? null,
row.topic ?? null,
row.hostId,
row.password ?? null,
row.hasPassword,
row.isPrivate,
row.createdAt,
row.userCount,
row.maxUsers ?? null,
row.icon ?? null,
row.iconUpdatedAt ?? null,
row.slowModeInterval,
row.sourceId ?? null,
row.sourceName ?? null,
row.sourceUrl ?? null
]
);
}
async getRoom(roomId: string): Promise<Room | null> {
const store = await this.connection.getStore();
const rows = await store.query<RoomRow>('SELECT * FROM rooms WHERE id = ? LIMIT 1', [roomId]);
return rows[0] ? rowToRoom(rows[0]) : null;
}
async getAllRooms(): Promise<Room[]> {
const store = await this.connection.getStore();
const rows = await store.query<RoomRow>('SELECT * FROM rooms ORDER BY createdAt ASC');
return rows.map(rowToRoom);
}
async deleteRoom(roomId: string): Promise<void> {
const store = await this.connection.getStore();
await store.run('DELETE FROM rooms WHERE id = ?', [roomId]);
await this.clearRoomMessages(roomId);
}
async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
const existing = await this.getRoom(roomId);
if (existing) {
await this.saveRoom({ ...existing, ...updates });
}
}
async saveBan(ban: BanEntry): Promise<void> {
const store = await this.connection.getStore();
await store.run(
`INSERT OR REPLACE INTO bans (
oderId, roomId, userId, bannedBy, displayName, reason, expiresAt, timestamp
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
banToValues(ban)
);
}
async removeBan(oderId: string): Promise<void> {
const store = await this.connection.getStore();
await store.run('DELETE FROM bans WHERE oderId = ?', [oderId]);
}
async getBansForRoom(roomId: string): Promise<BanEntry[]> {
const store = await this.connection.getStore();
const now = Date.now();
const rows = await store.query<BanEntry>(
'SELECT * FROM bans WHERE roomId = ?',
[roomId]
);
return rows.filter((ban) => !ban.expiresAt || ban.expiresAt > now);
}
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
const activeBans = await this.getBansForRoom(roomId);
return activeBans.some((ban) => ban.oderId === userId);
}
async saveAttachment(attachment: ChatAttachmentMeta): Promise<void> {
const store = await this.connection.getStore();
await store.run(
`INSERT OR REPLACE INTO attachments (
id, messageId, filename, size, mime, isImage, uploaderPeerId, filePath, savedPath
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
attachmentToValues(attachment)
);
}
async getAttachmentsForMessage(messageId: string): Promise<ChatAttachmentMeta[]> {
const store = await this.connection.getStore();
const rows = await store.query<Parameters<typeof rowToAttachment>[0]>(
'SELECT * FROM attachments WHERE messageId = ?',
[messageId]
);
return rows.map(rowToAttachment);
}
async getAllAttachments(): Promise<ChatAttachmentMeta[]> {
const store = await this.connection.getStore();
const rows = await store.query<Parameters<typeof rowToAttachment>[0]>('SELECT * FROM attachments');
return rows.map(rowToAttachment);
}
async saveCustomEmoji(emoji: CustomEmoji): Promise<void> {
const store = await this.connection.getStore();
await store.run(
`INSERT OR REPLACE INTO custom_emojis (
id, name, creatorUserId, dataUrl, hash, mime, size, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
customEmojiToValues(emoji)
);
}
async getCustomEmojis(): Promise<CustomEmoji[]> {
const store = await this.connection.getStore();
const rows = await store.query<Parameters<typeof rowToCustomEmoji>[0]>('SELECT * FROM custom_emojis');
return rows.map(rowToCustomEmoji);
}
async deleteCustomEmoji(emojiId: string): Promise<void> {
const store = await this.connection.getStore();
await store.run('DELETE FROM custom_emojis WHERE id = ?', [emojiId]);
}
async deleteAttachmentsForMessage(messageId: string): Promise<void> {
const store = await this.connection.getStore();
await store.run('DELETE FROM attachments WHERE messageId = ?', [messageId]);
}
async clearAllData(): Promise<void> {
const store = await this.connection.getStore();
const tables = [
'messages',
'users',
'rooms',
'reactions',
'bans',
'attachments',
'custom_emojis',
'meta',
'push_device_tokens'
];
for (const table of tables) {
await store.run(`DELETE FROM ${table}`);
}
}
private async hydrateMessages(messages: Message[]): Promise<Message[]> {
if (messages.length === 0) {
return [];
}
const reactionsByMessageId = await this.loadReactionsForMessages(messages.map((message) => message.id));
return messages.map((message) => this.normaliseMessage({
...message,
reactions: reactionsByMessageId.get(message.id) ?? message.reactions ?? []
}));
}
private async loadReactionsForMessages(messageIds: readonly string[]): Promise<Map<string, Reaction[]>> {
const messageIdSet = new Set(messageIds.filter((messageId) => messageId.trim().length > 0));
const reactionsByMessageId = new Map<string, Reaction[]>();
if (messageIdSet.size === 0) {
return reactionsByMessageId;
}
const store = await this.connection.getStore();
const allReactions = await store.query<Reaction>('SELECT * FROM reactions');
for (const reaction of allReactions) {
if (!messageIdSet.has(reaction.messageId)) {
continue;
}
const reactions = reactionsByMessageId.get(reaction.messageId) ?? [];
reactions.push(reaction);
reactionsByMessageId.set(reaction.messageId, reactions);
}
for (const reactions of reactionsByMessageId.values()) {
reactions.sort((first, second) => first.timestamp - second.timestamp);
}
return reactionsByMessageId;
}
private normaliseMessage(message: Message): Message {
if (message.content === DELETED_MESSAGE_CONTENT) {
return { ...message, reactions: [] };
}
return message;
}
}

View File

@@ -0,0 +1,25 @@
import {
describe,
expect,
it
} from 'vitest';
import { resolveDatabaseBackend } from './database-backend.rules';
describe('database-backend.rules', () => {
it('routes Electron to the IPC SQLite backend', () => {
expect(resolveDatabaseBackend({ isElectron: true, isCapacitor: false })).toBe('electron');
});
it('routes Capacitor native shells to SQLite instead of IndexedDB', () => {
expect(resolveDatabaseBackend({ isElectron: false, isCapacitor: true })).toBe('capacitor-sqlite');
});
it('routes plain browser shells to IndexedDB', () => {
expect(resolveDatabaseBackend({ isElectron: false, isCapacitor: false })).toBe('browser');
});
it('prefers Electron when both Electron and Capacitor flags are set', () => {
expect(resolveDatabaseBackend({ isElectron: true, isCapacitor: true })).toBe('electron');
});
});

View File

@@ -0,0 +1,17 @@
export type DatabaseBackendKind = 'electron' | 'capacitor-sqlite' | 'browser';
/** Selects the persistence backend for the current runtime shell. */
export function resolveDatabaseBackend(input: {
isElectron: boolean;
isCapacitor: boolean;
}): DatabaseBackendKind {
if (input.isElectron) {
return 'electron';
}
if (input.isCapacitor) {
return 'capacitor-sqlite';
}
return 'browser';
}

View File

@@ -12,6 +12,7 @@ import {
import { PlatformService } from '../../core/platform';
import { BrowserDatabaseService } from './browser-database.service';
import { CapacitorDatabaseService } from './capacitor-database.service';
import { DatabaseService } from './database.service';
import { ElectronDatabaseService } from './electron-database.service';
@@ -20,6 +21,10 @@ describe('DatabaseService', () => {
getBansForRoom: ReturnType<typeof vi.fn>;
initialize: ReturnType<typeof vi.fn>;
};
let capacitorDatabase: {
getBansForRoom: ReturnType<typeof vi.fn>;
initialize: ReturnType<typeof vi.fn>;
};
let electronDatabase: {
getBansForRoom: ReturnType<typeof vi.fn>;
initialize: ReturnType<typeof vi.fn>;
@@ -30,18 +35,23 @@ describe('DatabaseService', () => {
getBansForRoom: vi.fn(() => Promise.resolve([])),
initialize: vi.fn(() => Promise.resolve())
};
capacitorDatabase = {
getBansForRoom: vi.fn(() => Promise.resolve([])),
initialize: vi.fn(() => Promise.resolve())
};
electronDatabase = {
getBansForRoom: vi.fn(() => Promise.resolve([])),
initialize: vi.fn(() => Promise.resolve())
};
});
function createService(): DatabaseService {
function createService(platform: Pick<PlatformService, 'isBrowser' | 'isElectron' | 'isCapacitor'>): DatabaseService {
const injector = Injector.create({
providers: [
DatabaseService,
{ provide: PlatformService, useValue: { isBrowser: true, isElectron: false } },
{ provide: PlatformService, useValue: platform },
{ provide: BrowserDatabaseService, useValue: browserDatabase },
{ provide: CapacitorDatabaseService, useValue: capacitorDatabase },
{ provide: ElectronDatabaseService, useValue: electronDatabase }
]
});
@@ -49,13 +59,35 @@ describe('DatabaseService', () => {
return runInInjectionContext(injector, () => injector.get(DatabaseService));
}
it('initializes the selected backend before the first delegated read', async () => {
const service = createService();
it('initializes the browser backend before the first delegated read', async () => {
const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false });
await service.getBansForRoom('room-1');
expect(browserDatabase.initialize).toHaveBeenCalledTimes(1);
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-1');
expect(capacitorDatabase.initialize).not.toHaveBeenCalled();
expect(service.isReady()).toBe(true);
});
});
it('routes Capacitor shells to native SQLite instead of IndexedDB', async () => {
const service = createService({ isBrowser: false, isElectron: false, isCapacitor: true });
await service.getBansForRoom('room-1');
expect(capacitorDatabase.initialize).toHaveBeenCalledTimes(1);
expect(capacitorDatabase.getBansForRoom).toHaveBeenCalledWith('room-1');
expect(browserDatabase.initialize).not.toHaveBeenCalled();
});
it('routes Electron shells to the IPC SQLite backend', async () => {
const service = createService({ isBrowser: false, isElectron: true, isCapacitor: false });
await service.getBansForRoom('room-1');
expect(electronDatabase.initialize).toHaveBeenCalledTimes(1);
expect(electronDatabase.getBansForRoom).toHaveBeenCalledWith('room-1');
expect(browserDatabase.initialize).not.toHaveBeenCalled();
expect(capacitorDatabase.initialize).not.toHaveBeenCalled();
});
});

View File

@@ -14,6 +14,8 @@ import {
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
import { PlatformService } from '../../core/platform';
import { BrowserDatabaseService } from './browser-database.service';
import { CapacitorDatabaseService } from './capacitor-database.service';
import { resolveDatabaseBackend } from './database-backend.rules';
import { ElectronDatabaseService } from './electron-database.service';
export interface RoomMessageStats {
@@ -26,6 +28,7 @@ export interface RoomMessageStats {
* storage backend based on the runtime platform.
*
* - Electron -> SQLite via {@link ElectronDatabaseService} (IPC to main process).
* - Capacitor -> native SQLite via {@link CapacitorDatabaseService}.
* - Browser -> IndexedDB via {@link BrowserDatabaseService}.
*
* All consumers inject `DatabaseService`; the underlying storage engine
@@ -35,6 +38,7 @@ export interface RoomMessageStats {
export class DatabaseService {
private readonly platform = inject(PlatformService);
private readonly browserDb = inject(BrowserDatabaseService);
private readonly capacitorDb = inject(CapacitorDatabaseService);
private readonly electronDb = inject(ElectronDatabaseService);
private initializationPromise: Promise<void> | null = null;
@@ -43,7 +47,20 @@ export class DatabaseService {
/** The active storage backend for the current platform. */
private get backend() {
return this.platform.isBrowser ? this.browserDb : this.electronDb;
const backendKind = resolveDatabaseBackend({
isElectron: this.platform.isElectron,
isCapacitor: this.platform.isCapacitor
});
if (backendKind === 'electron') {
return this.electronDb;
}
if (backendKind === 'capacitor-sqlite') {
return this.capacitorDb;
}
return this.browserDb;
}
/** Initialise the platform-specific database. */

View File

@@ -36,7 +36,7 @@
</div>
<!-- Scrollable content area -->
<div class="min-h-0 flex-1 overflow-y-auto px-1 pb-[max(env(safe-area-inset-bottom),1rem)]">
<div class="min-h-0 flex-1 overflow-y-auto px-1 pb-[max(var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px)),1rem)]">
<ng-content />
</div>
</div>

View File

@@ -1,12 +1,15 @@
<!doctype html>
<html lang="en">
<html
lang="en"
style="--safe-area-inset-top: 0px; --safe-area-inset-right: 0px; --safe-area-inset-bottom: 0px; --safe-area-inset-left: 0px"
>
<head>
<meta charset="utf-8" />
<title>MeToYou</title>
<base href="/" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta
http-equiv="Content-Security-Policy"

View File

@@ -2,6 +2,7 @@ import { bootstrapApplication } from '@angular/platform-browser';
import { register as registerSwiperElements } from 'swiper/element/bundle';
import { appConfig } from './app/app.config';
import { App } from './app/app';
import { applyMobileSafeAreaDefaults } from './app/infrastructure/mobile/logic/mobile-safe-area.rules';
import mermaid from 'mermaid';
declare global {
@@ -13,6 +14,9 @@ declare global {
// Register Swiper custom elements (<swiper-container>, <swiper-slide>) globally.
registerSwiperElements();
// Ensure Capacitor SystemBars injection has a document root with safe-area defaults.
applyMobileSafeAreaDefaults();
// Expose mermaid globally for ngx-remark's MermaidComponent
window.mermaid = mermaid;
mermaid.initialize({

View File

@@ -124,12 +124,34 @@
@apply border-border;
}
html {
box-sizing: border-box;
height: 100%;
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
}
*,
*::before,
*::after {
box-sizing: inherit;
}
body {
@apply bg-background text-foreground;
height: 100%;
margin: 0;
font-feature-settings:
'rlig' 1,
'calt' 1;
}
app-root {
display: block;
height: 100%;
}
}
/* Scrollbar styling */