chore: Fix app
This commit is contained in:
@@ -59,4 +59,11 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<!-- Attachment export to public Documents needs legacy storage permissions on Android 10 and below. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
</manifest>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 241 B |
Binary file not shown.
|
After Width: | Height: | Size: 180 B |
Binary file not shown.
|
After Width: | Height: | Size: 319 B |
Binary file not shown.
|
After Width: | Height: | Size: 476 B |
Binary file not shown.
|
After Width: | Height: | Size: 633 B |
@@ -13,8 +13,8 @@ const config: CapacitorConfig = {
|
||||
style: 'DARK'
|
||||
},
|
||||
LocalNotifications: {
|
||||
smallIcon: 'ic_stat_icon_config_sample',
|
||||
iconColor: '#488AFF',
|
||||
smallIcon: 'ic_stat_metoyou',
|
||||
iconColor: '#4A217A',
|
||||
sound: 'call.wav'
|
||||
},
|
||||
PushNotifications: {
|
||||
|
||||
@@ -43,7 +43,13 @@
|
||||
},
|
||||
"errors": {
|
||||
"noRecipient": "Direct message conversation has no recipient to call.",
|
||||
"noCurrentUser": "Cannot use calls without a current user."
|
||||
"noCurrentUser": "Cannot use calls without a current user.",
|
||||
"signalingUnavailable": "Not connected to the call server. Check your connection and try again.",
|
||||
"captureUnsupported": "Voice capture is not available on this device.",
|
||||
"microphonePermissionDenied": "Microphone access is blocked. Allow the microphone permission in system settings to join calls.",
|
||||
"microphoneUnavailable": "Could not start the microphone. Close other apps that use it and try again.",
|
||||
"cameraPermissionDenied": "Camera access is blocked. Allow the camera permission in system settings to share video.",
|
||||
"cameraUnavailable": "Could not start the camera. Close other apps that use it and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"notifications": {
|
||||
"incomingCallsChannel": "Incoming calls",
|
||||
"activeCallsChannel": "Active calls",
|
||||
"messagesChannel": "Messages",
|
||||
"answer": "Answer",
|
||||
"decline": "Decline",
|
||||
"mute": "Mute",
|
||||
|
||||
@@ -127,7 +127,13 @@
|
||||
},
|
||||
"errors": {
|
||||
"noRecipient": "Direct message conversation has no recipient to call.",
|
||||
"noCurrentUser": "Cannot use calls without a current user."
|
||||
"noCurrentUser": "Cannot use calls without a current user.",
|
||||
"signalingUnavailable": "Not connected to the call server. Check your connection and try again.",
|
||||
"captureUnsupported": "Voice capture is not available on this device.",
|
||||
"microphonePermissionDenied": "Microphone access is blocked. Allow the microphone permission in system settings to join calls.",
|
||||
"microphoneUnavailable": "Could not start the microphone. Close other apps that use it and try again.",
|
||||
"cameraPermissionDenied": "Camera access is blocked. Allow the camera permission in system settings to share video.",
|
||||
"cameraUnavailable": "Could not start the camera. Close other apps that use it and try again."
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@@ -517,6 +523,7 @@
|
||||
"notifications": {
|
||||
"incomingCallsChannel": "Incoming calls",
|
||||
"activeCallsChannel": "Active calls",
|
||||
"messagesChannel": "Messages",
|
||||
"answer": "Answer",
|
||||
"decline": "Decline",
|
||||
"mute": "Mute",
|
||||
|
||||
@@ -29,21 +29,26 @@ infrastructure adapters and UI.
|
||||
|
||||
The larger domains also keep longer design notes in their own folders:
|
||||
|
||||
- [attachment/README.md](attachment/README.md)
|
||||
- [access-control/README.md](access-control/README.md)
|
||||
- [attachment/README.md](attachment/README.md)
|
||||
- [authentication/README.md](authentication/README.md)
|
||||
- [chat/README.md](chat/README.md)
|
||||
- [custom-emoji/README.md](custom-emoji/README.md)
|
||||
- [direct-message/README.md](direct-message/README.md)
|
||||
- [direct-call/README.md](direct-call/README.md)
|
||||
- [experimental-media/README.md](experimental-media/README.md)
|
||||
- [game-activity/README.md](game-activity/README.md)
|
||||
- [notifications/README.md](notifications/README.md)
|
||||
- [plugins/README.md](plugins/README.md)
|
||||
- [profile-avatar/README.md](profile-avatar/README.md)
|
||||
- [screen-share/README.md](screen-share/README.md)
|
||||
- [server-directory/README.md](server-directory/README.md)
|
||||
- [theme/README.md](theme/README.md)
|
||||
- [voice-connection/README.md](voice-connection/README.md)
|
||||
- [voice-session/README.md](voice-session/README.md)
|
||||
|
||||
Cross-context wire contracts live in [`agents-docs/features/`](../../agents-docs/features/) — see [`agents-docs/FEATURES.md`](../../agents-docs/FEATURES.md).
|
||||
|
||||
## Folder convention
|
||||
|
||||
Every domain follows the same internal layout:
|
||||
|
||||
@@ -28,7 +28,8 @@ attachment/
|
||||
│
|
||||
├── infrastructure/
|
||||
│ ├── services/
|
||||
│ │ └── attachment-storage.service.ts Electron filesystem access (save / read / delete)
|
||||
│ │ ├── attachment-storage.service.ts Electron filesystem access (save / read / delete)
|
||||
│ │ └── capacitor-attachment-export.service.ts Capacitor "download": copy/write bytes into public Documents
|
||||
│ └── util/
|
||||
│ └── attachment-storage.util.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket
|
||||
│
|
||||
|
||||
+33
@@ -9,8 +9,15 @@ import {
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
|
||||
const isCapacitorNativeRuntimeMock = vi.fn(() => false);
|
||||
|
||||
vi.mock('../../../../infrastructure/mobile/logic/platform-detection.rules', () => ({
|
||||
isCapacitorNativeRuntime: () => isCapacitorNativeRuntimeMock()
|
||||
}));
|
||||
|
||||
import { AttachmentDownloadService } from './attachment-download.service';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { CapacitorAttachmentExportService } from '../../infrastructure/services/capacitor-attachment-export.service';
|
||||
import type { Attachment } from '../../domain/models/attachment.model';
|
||||
|
||||
describe('AttachmentDownloadService', () => {
|
||||
@@ -21,8 +28,11 @@ describe('AttachmentDownloadService', () => {
|
||||
let documentStub: Document;
|
||||
let saveExistingFileAs: ReturnType<typeof vi.fn>;
|
||||
let saveFileAs: ReturnType<typeof vi.fn>;
|
||||
let exportToDevice: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
isCapacitorNativeRuntimeMock.mockReturnValue(false);
|
||||
exportToDevice = vi.fn(async () => true);
|
||||
saveExistingFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
|
||||
saveFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
|
||||
|
||||
@@ -53,6 +63,7 @@ describe('AttachmentDownloadService', () => {
|
||||
providers: [
|
||||
AttachmentDownloadService,
|
||||
{ provide: ElectronBridgeService, useValue: electronBridge },
|
||||
{ provide: CapacitorAttachmentExportService, useValue: { exportToDevice } },
|
||||
{ provide: DOCUMENT, useValue: documentStub }
|
||||
]
|
||||
});
|
||||
@@ -78,6 +89,28 @@ describe('AttachmentDownloadService', () => {
|
||||
expect(saveFileAs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates to the Capacitor export service on a native mobile shell', async () => {
|
||||
isCapacitorNativeRuntimeMock.mockReturnValue(true);
|
||||
electronBridge.getApi = vi.fn(() => null);
|
||||
|
||||
const service = createService();
|
||||
const attachment: Attachment = {
|
||||
id: 'file-3',
|
||||
messageId: 'message-3',
|
||||
filename: 'photo.png',
|
||||
mime: 'image/png',
|
||||
size: 2048,
|
||||
available: true,
|
||||
savedPath: 'metoyou/server/room/files/photo.png'
|
||||
};
|
||||
|
||||
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(true);
|
||||
|
||||
expect(exportToDevice).toHaveBeenCalledWith(attachment);
|
||||
expect(saveExistingFileAs).not.toHaveBeenCalled();
|
||||
expect(saveFileAs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the attachment is not downloadable yet', async () => {
|
||||
const service = createService();
|
||||
const attachment: Attachment = {
|
||||
|
||||
+7
@@ -2,12 +2,15 @@ import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { isCapacitorNativeRuntime } from '../../../../infrastructure/mobile/logic/platform-detection.rules';
|
||||
import { canDownloadAttachment, resolveAttachmentDiskPath } from '../../domain/logic/attachment-download.rules';
|
||||
import type { Attachment } from '../../domain/models/attachment.model';
|
||||
import { CapacitorAttachmentExportService } from '../../infrastructure/services/capacitor-attachment-export.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentDownloadService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly capacitorExport = inject(CapacitorAttachmentExportService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
async downloadToUserLocation(attachment: Attachment): Promise<boolean> {
|
||||
@@ -15,6 +18,10 @@ export class AttachmentDownloadService {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isCapacitorNativeRuntime()) {
|
||||
return this.capacitorExport.exportToDevice(attachment);
|
||||
}
|
||||
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
const diskPath = resolveAttachmentDiskPath(attachment);
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { buildAttachmentExportFileName } from './attachment-export.rules';
|
||||
|
||||
describe('buildAttachmentExportFileName', () => {
|
||||
it('appends the timestamp before the extension so exports never collide', () => {
|
||||
expect(buildAttachmentExportFileName('report.pdf', 1_720_900_000_000)).toBe('report-1720900000000.pdf');
|
||||
});
|
||||
|
||||
it('appends the timestamp at the end when there is no extension', () => {
|
||||
expect(buildAttachmentExportFileName('README', 42)).toBe('README-42');
|
||||
});
|
||||
|
||||
it('keeps dotfiles intact instead of treating the leading dot as an extension', () => {
|
||||
expect(buildAttachmentExportFileName('.env', 42)).toBe('.env-42');
|
||||
});
|
||||
|
||||
it('strips directory components from the filename', () => {
|
||||
expect(buildAttachmentExportFileName('../secret/../../etc/passwd.txt', 7)).toBe('passwd-7.txt');
|
||||
expect(buildAttachmentExportFileName('folder\\file.bin', 7)).toBe('file-7.bin');
|
||||
});
|
||||
|
||||
it('falls back to a generic name when the filename is empty after sanitising', () => {
|
||||
expect(buildAttachmentExportFileName(' ', 7)).toBe('attachment-7');
|
||||
expect(buildAttachmentExportFileName('a/b/', 7)).toBe('attachment-7');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const FALLBACK_EXPORT_BASE_NAME = 'attachment';
|
||||
|
||||
/**
|
||||
* Build the file name used when exporting an attachment to a user-visible
|
||||
* directory (e.g. Android `Documents`). The timestamp is appended before the
|
||||
* extension so repeated exports of the same file never collide - public
|
||||
* directories on Android 11+ reject overwrites of files the app did not create.
|
||||
*/
|
||||
export function buildAttachmentExportFileName(filename: string, timestamp: number): string {
|
||||
const baseName = stripDirectoryComponents(filename);
|
||||
const dotIndex = baseName.lastIndexOf('.');
|
||||
const hasExtension = dotIndex > 0;
|
||||
const stem = hasExtension ? baseName.slice(0, dotIndex) : baseName;
|
||||
const extension = hasExtension ? baseName.slice(dotIndex) : '';
|
||||
|
||||
return `${stem || FALLBACK_EXPORT_BASE_NAME}-${timestamp}${extension}`;
|
||||
}
|
||||
|
||||
function stripDirectoryComponents(filename: string): string {
|
||||
const segments = filename.split(/[/\\]/);
|
||||
|
||||
return segments[segments.length - 1]?.trim() ?? '';
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
const isCapacitorNativeRuntimeMock = vi.fn(() => true);
|
||||
const loadFilesystemMock = vi.fn();
|
||||
|
||||
vi.mock('../../../../infrastructure/mobile/logic/platform-detection.rules', () => ({
|
||||
isCapacitorNativeRuntime: () => isCapacitorNativeRuntimeMock()
|
||||
}));
|
||||
|
||||
vi.mock('./capacitor-attachment-filesystem.adapter', () => ({
|
||||
loadCapacitorAttachmentFilesystem: () => loadFilesystemMock()
|
||||
}));
|
||||
|
||||
import { CapacitorAttachmentExportService } from './capacitor-attachment-export.service';
|
||||
import type { Attachment } from '../../domain/models/attachment.model';
|
||||
|
||||
function createFakeAdapter() {
|
||||
return {
|
||||
filesystem: {
|
||||
copy: vi.fn(async () => undefined),
|
||||
writeFile: vi.fn(async () => ({ uri: 'file:///docs/out' }))
|
||||
},
|
||||
directory: 'DATA',
|
||||
exportDirectory: 'DOCUMENTS',
|
||||
convertFileSrc: (url: string) => url
|
||||
};
|
||||
}
|
||||
|
||||
function makeAttachment(overrides: Partial<Attachment>): Attachment {
|
||||
return {
|
||||
id: 'file-1',
|
||||
messageId: 'message-1',
|
||||
filename: 'photo.png',
|
||||
mime: 'image/png',
|
||||
size: 1024,
|
||||
available: true,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('CapacitorAttachmentExportService', () => {
|
||||
let service: CapacitorAttachmentExportService;
|
||||
let fakeAdapter: ReturnType<typeof createFakeAdapter>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_720_900_000_000);
|
||||
isCapacitorNativeRuntimeMock.mockReturnValue(true);
|
||||
fakeAdapter = createFakeAdapter();
|
||||
loadFilesystemMock.mockResolvedValue(fakeAdapter);
|
||||
service = new CapacitorAttachmentExportService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('copies a disk-backed attachment from app data into the export directory', async () => {
|
||||
const attachment = makeAttachment({ savedPath: 'metoyou/server/room/files/photo.png' });
|
||||
|
||||
await expect(service.exportToDevice(attachment)).resolves.toBe(true);
|
||||
|
||||
expect(fakeAdapter.filesystem.copy).toHaveBeenCalledWith({
|
||||
from: 'metoyou/server/room/files/photo.png',
|
||||
directory: 'DATA',
|
||||
to: 'photo-1720900000000.png',
|
||||
toDirectory: 'DOCUMENTS'
|
||||
});
|
||||
|
||||
expect(fakeAdapter.filesystem.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes an in-memory attachment fetched from its object URL into the export directory', async () => {
|
||||
const bytes = new TextEncoder().encode('hello');
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(bytes)));
|
||||
|
||||
const attachment = makeAttachment({ objectUrl: 'blob:https://app/abc' });
|
||||
|
||||
await expect(service.exportToDevice(attachment)).resolves.toBe(true);
|
||||
|
||||
expect(fakeAdapter.filesystem.writeFile).toHaveBeenCalledWith({
|
||||
path: 'photo-1720900000000.png',
|
||||
data: btoa('hello'),
|
||||
directory: 'DOCUMENTS',
|
||||
recursive: true
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the object URL when copying from disk fails', async () => {
|
||||
fakeAdapter.filesystem.copy.mockRejectedValue(new Error('copy failed'));
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(new TextEncoder().encode('x'))));
|
||||
|
||||
const attachment = makeAttachment({
|
||||
savedPath: 'metoyou/server/room/files/photo.png',
|
||||
objectUrl: 'capacitor://localhost/_capacitor_file_/photo.png'
|
||||
});
|
||||
|
||||
await expect(service.exportToDevice(attachment)).resolves.toBe(true);
|
||||
|
||||
expect(fakeAdapter.filesystem.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false off a native shell', async () => {
|
||||
isCapacitorNativeRuntimeMock.mockReturnValue(false);
|
||||
|
||||
await expect(service.exportToDevice(makeAttachment({ savedPath: 'x' }))).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there is neither a disk path nor an object URL', async () => {
|
||||
await expect(service.exportToDevice(makeAttachment({}))).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { isCapacitorNativeRuntime } from '../../../../infrastructure/mobile/logic/platform-detection.rules';
|
||||
import { encodeUint8ArrayToBase64 } from '../../domain/logic/attachment-blob.rules';
|
||||
import { resolveAttachmentDiskPath } from '../../domain/logic/attachment-download.rules';
|
||||
import { buildAttachmentExportFileName } from '../../domain/logic/attachment-export.rules';
|
||||
import type { Attachment } from '../../domain/models/attachment.model';
|
||||
import { loadCapacitorAttachmentFilesystem } from './capacitor-attachment-filesystem.adapter';
|
||||
|
||||
/**
|
||||
* Exports attachments out of the app-private data directory into the device's
|
||||
* user-visible `Documents` directory on Capacitor. Anchor-based `download`
|
||||
* links do nothing in the Android WebView, so "download" on mobile means
|
||||
* copying the bytes somewhere the user can reach through the Files app.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CapacitorAttachmentExportService {
|
||||
async exportToDevice(attachment: Attachment): Promise<boolean> {
|
||||
if (!isCapacitorNativeRuntime()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const filesystem = await loadCapacitorAttachmentFilesystem();
|
||||
|
||||
if (!filesystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const exportPath = buildAttachmentExportFileName(attachment.filename, Date.now());
|
||||
const diskPath = resolveAttachmentDiskPath(attachment);
|
||||
|
||||
if (diskPath) {
|
||||
try {
|
||||
await filesystem.filesystem.copy({
|
||||
from: diskPath,
|
||||
directory: filesystem.directory,
|
||||
to: exportPath,
|
||||
toDirectory: filesystem.exportDirectory
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
/* fall back to the object URL below */
|
||||
}
|
||||
}
|
||||
|
||||
if (!attachment.objectUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const base64 = await this.fetchAsBase64(attachment.objectUrl);
|
||||
|
||||
if (base64 === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await filesystem.filesystem.writeFile({
|
||||
path: exportPath,
|
||||
data: base64,
|
||||
directory: filesystem.exportDirectory,
|
||||
recursive: true
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAsBase64(objectUrl: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(objectUrl);
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
return encodeUint8ArrayToBase64(new Uint8Array(buffer));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -82,6 +82,7 @@ function createFakeFilesystem() {
|
||||
adapter: {
|
||||
filesystem,
|
||||
directory: 'DATA',
|
||||
exportDirectory: 'DOCUMENTS',
|
||||
convertFileSrc: (url: string) => url.replace('file://', 'capacitor://localhost/_capacitor_file_')
|
||||
}
|
||||
};
|
||||
|
||||
+3
@@ -10,6 +10,8 @@ type CapacitorCoreModule = typeof import('@capacitor/core');
|
||||
export interface CapacitorAttachmentFilesystem {
|
||||
filesystem: CapacitorFilesystemModule['Filesystem'];
|
||||
directory: CapacitorFilesystemModule['Directory'][keyof CapacitorFilesystemModule['Directory']];
|
||||
/** User-visible directory (`Documents`) used when exporting attachments out of app storage. */
|
||||
exportDirectory: CapacitorFilesystemModule['Directory'][keyof CapacitorFilesystemModule['Directory']];
|
||||
convertFileSrc: (url: string) => string;
|
||||
}
|
||||
|
||||
@@ -42,6 +44,7 @@ async function resolveCapacitorAttachmentFilesystem(): Promise<CapacitorAttachme
|
||||
return {
|
||||
filesystem: filesystemModule.Filesystem,
|
||||
directory: filesystemModule.Directory.Data,
|
||||
exportDirectory: filesystemModule.Directory.Documents,
|
||||
convertFileSrc: (url: string) => coreModule.Capacitor.convertFileSrc(url)
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="h-full grid place-items-center bg-background">
|
||||
<div class="w-[360px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
||||
<div class="h-full grid place-items-center overflow-y-auto bg-background p-4">
|
||||
<div class="w-full max-w-[360px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<ng-icon
|
||||
name="lucideLogIn"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="h-full grid place-items-center bg-background">
|
||||
<div class="w-[380px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
||||
<div class="h-full grid place-items-center overflow-y-auto bg-background p-4">
|
||||
<div class="w-full max-w-[380px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<ng-icon
|
||||
name="lucideUserPlus"
|
||||
|
||||
@@ -178,3 +178,11 @@ Opening a conversation must land on the newest message even though images, link/
|
||||
## Typing indicator
|
||||
|
||||
`TypingIndicatorComponent` listens for typing events from peers scoped to the current server and active text channel. Each positive event resets a 3-second TTL timer for that channel; an explicit `isTyping: false` event clears that user immediately. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".
|
||||
|
||||
## Cross-context feature docs
|
||||
|
||||
- [`agents-docs/features/messaging.md`](../../../../../agents-docs/features/messaging.md) — server chat + DM transports, sync, delivery states
|
||||
- [`agents-docs/features/message-integrity.md`](../../../../../agents-docs/features/message-integrity.md) — signed revisions
|
||||
- [`agents-docs/features/custom-emoji.md`](../../../../../agents-docs/features/custom-emoji.md)
|
||||
- [`agents-docs/features/klipy-gifs.md`](../../../../../agents-docs/features/klipy-gifs.md)
|
||||
- [`agents-docs/features/link-preview-media-proxy.md`](../../../../../agents-docs/features/link-preview-media-proxy.md)
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
style="-webkit-app-region: no-drag"
|
||||
></div>
|
||||
|
||||
<div class="pointer-events-none fixed inset-0 z-[90]">
|
||||
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[90]">
|
||||
<div
|
||||
appThemeNode="chatGifPickerSurface"
|
||||
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
[ariaLabel]="'chat.overlays.closeGalleryAria' | translate"
|
||||
(dismissed)="closeGallery()"
|
||||
/>
|
||||
<div class="pointer-events-none fixed inset-0 z-[101] flex items-center justify-center p-4">
|
||||
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[101] flex items-center justify-center p-4">
|
||||
<div
|
||||
class="pointer-events-auto relative flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
|
||||
(click)="$event.stopPropagation()"
|
||||
@@ -111,7 +111,7 @@
|
||||
[ariaLabel]="'chat.overlays.closePreviewAria' | translate"
|
||||
(dismissed)="closeLightbox()"
|
||||
/>
|
||||
<div class="pointer-events-none fixed inset-0 z-[110] flex items-center justify-center p-4">
|
||||
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[110] flex items-center justify-center p-4">
|
||||
<div
|
||||
class="lightbox-stage pointer-events-auto relative max-h-[90vh] max-w-[90vw]"
|
||||
[class.lightbox-chrome-hidden]="!lightboxControlsVisible()"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Custom Emoji Domain
|
||||
|
||||
User-created image emoji: validation, local asset storage, saved-library membership, P2P sync, and the shared picker consumed by chat reactions and the composer.
|
||||
|
||||
**Wire contract (P2P envelopes, `account_sync` relay):** [`agents-docs/features/custom-emoji.md`](../../../../../agents-docs/features/custom-emoji.md)
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
custom-emoji/
|
||||
├── domain/
|
||||
│ ├── custom-emoji.rules.ts Validation, tokens, chunk limits, shortcut selection
|
||||
│ └── custom-emoji.rules.spec.ts
|
||||
├── application/
|
||||
│ ├── custom-emoji.service.ts Upload, library, known-asset cache, proactive push
|
||||
│ ├── custom-emoji.service.spec.ts
|
||||
│ └── custom-emoji-sync.effects.ts NgRx effects: data-channel + account_sync handling
|
||||
├── feature/
|
||||
│ └── custom-emoji-picker/ Picker UI, search, shortcut row
|
||||
└── index.ts Barrel — `CustomEmojiService`, picker component, effects
|
||||
```
|
||||
|
||||
## Public entry points
|
||||
|
||||
| Export | Role |
|
||||
|--------|------|
|
||||
| `CustomEmojiService` | Upload, save/remove library, resolve tokens, peer summaries |
|
||||
| `CustomEmojiPickerComponent` | Emoji selector for composer and reactions |
|
||||
| `CustomEmojiSyncEffects` | Registers with NgRx for inbound sync events |
|
||||
|
||||
## NgRx / realtime touchpoints
|
||||
|
||||
- `CustomEmojiSyncEffects` listens for P2P `ChatEvent` types and `account_sync` payloads relayed from [`signaling`](../../../../../agents-docs/features/signaling.md).
|
||||
- Chat domain calls `CustomEmojiService` when sending messages/reactions to push referenced assets to peers.
|
||||
|
||||
## UI integration
|
||||
|
||||
- **Chat composer** — `:name:` aliases rewrite to `:emoji[id](name)` on send; shortcut row shows top seven saved emoji.
|
||||
- **Message reactions** — custom emoji reactions use the same token format.
|
||||
- **Context menu** — add/remove from library on rendered custom emoji (`data-custom-emoji` attributes).
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Does not own chat message persistence or signaling server storage.
|
||||
- Usage ranking is local per user id; not synced across devices.
|
||||
- Import from `domains/custom-emoji` barrel only; chat imports the service, not internal paths.
|
||||
+92
-4
@@ -477,6 +477,56 @@ describe('DirectCallService', () => {
|
||||
expect(context.voiceSession.endSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a join error when the microphone permission is denied instead of failing silently', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
||||
const session = createSession('connected', false);
|
||||
|
||||
session.participants.bob.joined = true;
|
||||
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
||||
|
||||
context.mobileMedia.ensureVoiceCapturePermissions.mockResolvedValue(false);
|
||||
await withStubbedGetUserMedia(vi.fn(async () => new FakeMediaStream()), async () => {
|
||||
await context.service.joinCall(session.callId);
|
||||
});
|
||||
|
||||
expect(context.service.joinError()).not.toBeNull();
|
||||
expect(context.voice.setLocalStream).not.toHaveBeenCalled();
|
||||
expect(context.service.sessionById(session.callId)?.participants.alice.joined).not.toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces a join error when getUserMedia rejects', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
||||
const session = createSession('connected', false);
|
||||
|
||||
session.participants.bob.joined = true;
|
||||
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
||||
|
||||
await withStubbedGetUserMedia(vi.fn(async () => {
|
||||
throw new Error('NotReadableError');
|
||||
}), async () => {
|
||||
await context.service.joinCall(session.callId);
|
||||
});
|
||||
|
||||
expect(context.service.joinError()).not.toBeNull();
|
||||
expect(context.voice.setLocalStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the join error on a successful join', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
||||
const session = createSession('connected', false);
|
||||
|
||||
session.participants.bob.joined = true;
|
||||
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
||||
|
||||
await withStubbedGetUserMedia(vi.fn(async () => new FakeMediaStream()), async () => {
|
||||
await context.service.joinCall(session.callId);
|
||||
});
|
||||
|
||||
expect(context.service.joinError()).toBeNull();
|
||||
expect(context.voice.setLocalStream).toHaveBeenCalled();
|
||||
expect(context.service.sessionById(session.callId)?.participants.alice.joined).toBe(true);
|
||||
});
|
||||
|
||||
it('starts group calls by keeping the rail-visible call session and ringing every other participant', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [
|
||||
alice,
|
||||
@@ -643,6 +693,10 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
||||
const voiceSession = {
|
||||
endSession: vi.fn()
|
||||
};
|
||||
const mobileMedia = {
|
||||
ensureVoiceCapturePermissions: vi.fn(async () => true),
|
||||
setSpeakerphoneEnabled: vi.fn(async () => undefined)
|
||||
};
|
||||
const credentialStore = {
|
||||
listValidCredentials: vi.fn(() => (options.selfActorIds ?? []).map((userId) => ({
|
||||
serverUrl: `https://signal.example/${userId}`,
|
||||
@@ -732,10 +786,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
||||
},
|
||||
{
|
||||
provide: MobileMediaService,
|
||||
useValue: {
|
||||
ensureVoiceCapturePermissions: vi.fn(async () => true),
|
||||
setSpeakerphoneEnabled: vi.fn(async () => undefined)
|
||||
}
|
||||
useValue: mobileMedia
|
||||
},
|
||||
{
|
||||
provide: RealtimeSessionFacade,
|
||||
@@ -761,6 +812,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
||||
directCallEvents,
|
||||
directMessages,
|
||||
effectScheduler,
|
||||
mobileMedia,
|
||||
router,
|
||||
service: runInInjectionContext(injector, () => new DirectCallService()),
|
||||
voice,
|
||||
@@ -768,6 +820,42 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
||||
};
|
||||
}
|
||||
|
||||
class FakeMediaStream {
|
||||
getTracks(): unknown[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Temporarily provide `navigator.mediaDevices.getUserMedia` for the join path under Node. */
|
||||
async function withStubbedGetUserMedia(
|
||||
getUserMedia: () => Promise<unknown>,
|
||||
run: () => Promise<void>
|
||||
): Promise<void> {
|
||||
const globalWithNavigator = globalThis as { navigator?: { mediaDevices?: unknown } };
|
||||
const originalNavigator = globalWithNavigator.navigator;
|
||||
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: {
|
||||
...(originalNavigator ?? {}),
|
||||
mediaDevices: { getUserMedia }
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
if (originalNavigator === undefined) {
|
||||
delete globalWithNavigator.navigator;
|
||||
} else {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: originalNavigator
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createCallEvent(action: 'leave' | 'ring', sender: User, participantIds: string[]): ChatEvent {
|
||||
return {
|
||||
type: 'direct-call',
|
||||
|
||||
@@ -91,6 +91,8 @@ export class DirectCallService {
|
||||
&& this.hasConnectedParticipant(session)) ?? null;
|
||||
});
|
||||
readonly currentSession = signal<DirectCallSession | null>(null);
|
||||
/** User-facing reason the last joinCall attempt failed; null after a successful join. */
|
||||
readonly joinError = signal<string | null>(null);
|
||||
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
|
||||
readonly mobileOverlaySession = computed(() => {
|
||||
const callId = this.mobileOverlayCallId();
|
||||
@@ -333,6 +335,7 @@ export class DirectCallService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.joinError.set(null);
|
||||
this.leaveOtherJoinedCalls(callId);
|
||||
this.leaveCurrentVoiceTargetForCall(callId);
|
||||
this.audio.stop(AppSound.Call);
|
||||
@@ -344,22 +347,36 @@ export class DirectCallService {
|
||||
|
||||
const ok = await this.voice.ensureSignalingConnected();
|
||||
|
||||
if (!ok || !navigator.mediaDevices?.getUserMedia) {
|
||||
if (!ok) {
|
||||
this.joinError.set(this.i18n.instant('call.errors.signalingUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
this.joinError.set(this.i18n.instant('call.errors.captureUnsupported'));
|
||||
return;
|
||||
}
|
||||
|
||||
const voicePermissionsGranted = await this.mobileMedia.ensureVoiceCapturePermissions();
|
||||
|
||||
if (!voicePermissionsGranted) {
|
||||
this.joinError.set(this.i18n.instant('call.errors.microphonePermissionDenied'));
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: false
|
||||
}
|
||||
});
|
||||
let stream: MediaStream;
|
||||
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: false
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
this.joinError.set(this.i18n.instant('call.errors.microphoneUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.voice.setLocalStream(stream);
|
||||
this.voiceActivity.trackLocalMic(meId, stream);
|
||||
|
||||
+2
-2
@@ -4,9 +4,9 @@
|
||||
[dismissable]="false"
|
||||
/>
|
||||
|
||||
<div class="pointer-events-none fixed inset-0 z-[121] flex items-center justify-center p-4">
|
||||
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[121] flex items-center justify-center p-4">
|
||||
<section
|
||||
class="pointer-events-auto w-full max-w-sm rounded-lg border border-border bg-card shadow-2xl"
|
||||
class="pointer-events-auto max-h-full w-full max-w-sm overflow-y-auto rounded-lg border border-border bg-card shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="incoming-call-title"
|
||||
|
||||
@@ -61,3 +61,9 @@ Conversation participants keep avatar/profile metadata captured from user cards
|
||||
## Persistence
|
||||
|
||||
Repositories are user-scoped and stored locally under `metoyou_direct_message_*` keys. The storage is intentionally domain-owned so browser and Electron runtimes share the same renderer API without changing the existing chat-message database tables.
|
||||
|
||||
## Cross-context feature docs
|
||||
|
||||
- [`agents-docs/features/messaging.md`](../../../../../agents-docs/features/messaging.md) — DM delivery states, sync, transports
|
||||
- [`agents-docs/features/signaling.md`](../../../../../agents-docs/features/signaling.md) — DM WebSocket relay
|
||||
- [`agents-docs/features/voice-webrtc.md`](../../../../../agents-docs/features/voice-webrtc.md) — private calls
|
||||
|
||||
+11
@@ -14,6 +14,7 @@ import { OfflineMessageQueueService } from './offline-message-queue.service';
|
||||
import { PeerDeliveryService } from './peer-delivery.service';
|
||||
import { AttachmentFacade } from '../../../attachment';
|
||||
import { CustomEmojiService } from '../../../custom-emoji';
|
||||
import { NotificationsFacade } from '../../../notifications';
|
||||
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
|
||||
import {
|
||||
advanceDirectMessageStatus,
|
||||
@@ -72,6 +73,7 @@ export class DirectMessageService {
|
||||
private readonly credentialStore = inject(SignalServerCredentialStoreService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly router = inject(Router);
|
||||
private readonly notifications = inject(NotificationsFacade);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
|
||||
private readonly selectedConversationIdSignal = signal<string | null>(null);
|
||||
@@ -544,6 +546,15 @@ export class DirectMessageService {
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
if (incomingMessage.kind !== 'system' && !incomingMessage.isDeleted) {
|
||||
void this.notifications.handleIncomingDirectMessage({
|
||||
id: incomingMessage.id,
|
||||
senderName: sender.displayName || sender.username || sender.userId,
|
||||
content: incomingMessage.content,
|
||||
conversationVisible: !shouldIncrementUnread
|
||||
});
|
||||
}
|
||||
|
||||
if (!shouldIncrementUnread) {
|
||||
await this.markRead(conversationId);
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
(keydown.space)="closeGifPicker()"
|
||||
></div>
|
||||
|
||||
<div class="pointer-events-none fixed inset-0 z-[90]">
|
||||
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[90]">
|
||||
<div
|
||||
appThemeNode="chatGifPickerSurface"
|
||||
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Game Activity Domain
|
||||
|
||||
Foreground-window-first game detection, RAWG matching, and P2P now-playing sync.
|
||||
|
||||
**Cross-context contract:** [`agents-docs/features/game-activity.md`](../../../../../agents-docs/features/game-activity.md)
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
game-activity/
|
||||
├── domain/
|
||||
│ ├── game-activity.models.ts Shared-kernel types (re-exported)
|
||||
│ └── game-activity-time.ts formatGameActivityElapsed()
|
||||
├── application/
|
||||
│ ├── game-activity.service.ts Scan loop, match cache, P2P broadcast
|
||||
│ └── game-activity.service.spec.ts
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
## Public entry points
|
||||
|
||||
| Export | Role |
|
||||
|--------|------|
|
||||
| `GameActivityService` | Started from `App` bootstrap; updates user store + broadcasts |
|
||||
| `formatGameActivityElapsed()` | Profile card / sidebar elapsed time label |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `ElectronBridgeService` — process names and foreground candidate (desktop only)
|
||||
- `ServerDirectoryFacade` — `POST /api/games/match` base URL
|
||||
- `RealtimeSessionFacade` — P2P `game-activity` send/receive
|
||||
|
||||
## UI consumers
|
||||
|
||||
- Profile card components (`formatGameActivityElapsed`, `user.gameActivity`)
|
||||
- Room side panel member list
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Does not own RAWG API keys (server configuration).
|
||||
- Browser/mobile shells do not scan processes locally.
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
Actions,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { NotificationsService } from '../services/notifications.service';
|
||||
|
||||
@@ -39,6 +39,12 @@ export class NotificationsFacade {
|
||||
return this.service.handleIncomingMessage(...args);
|
||||
}
|
||||
|
||||
handleIncomingDirectMessage(
|
||||
...args: Parameters<NotificationsService['handleIncomingDirectMessage']>
|
||||
): ReturnType<NotificationsService['handleIncomingDirectMessage']> {
|
||||
return this.service.handleIncomingDirectMessage(...args);
|
||||
}
|
||||
|
||||
markCurrentChannelReadIfActive(
|
||||
...args: Parameters<NotificationsService['markCurrentChannelReadIfActive']>
|
||||
): ReturnType<NotificationsService['markCurrentChannelReadIfActive']> {
|
||||
|
||||
+17
@@ -10,7 +10,9 @@ import type {
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
import { NotificationAudioService } from '../../../../core/services/notification-audio.service';
|
||||
import { PlatformService } from '../../../../core/platform';
|
||||
import { TimeSyncService } from '../../../../core/services/time-sync.service';
|
||||
import { MobileAppLifecycleService } from '../../../../infrastructure/mobile/services/mobile-app-lifecycle.service';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import {
|
||||
selectActiveChannelId,
|
||||
@@ -188,6 +190,21 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
||||
play: vi.fn()
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: PlatformService,
|
||||
useValue: {
|
||||
isBrowser: true,
|
||||
isCapacitor: false,
|
||||
isElectron: false
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: MobileAppLifecycleService,
|
||||
useValue: {
|
||||
initialize: vi.fn(async () => undefined),
|
||||
onAppStateChange: vi.fn()
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: TimeSyncService,
|
||||
useValue: {
|
||||
|
||||
+68
-1
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
@@ -9,7 +9,9 @@ import { Store } from '@ngrx/store';
|
||||
import type { Message, Room } from '../../../../shared-kernel';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import { AppI18nService } from '../../../../core/i18n';
|
||||
import { PlatformService } from '../../../../core/platform';
|
||||
import { TimeSyncService } from '../../../../core/services/time-sync.service';
|
||||
import { MobileAppLifecycleService } from '../../../../infrastructure/mobile/services/mobile-app-lifecycle.service';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import {
|
||||
selectActiveChannelId,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
} from '../../../../store/rooms/rooms.selectors';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import {
|
||||
buildDirectMessageNotificationPayload,
|
||||
buildNotificationDisplayPayload,
|
||||
calculateUnreadForRoom,
|
||||
DEFAULT_TEXT_CHANNEL_ID,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
isRoomMuted,
|
||||
isMessageVisibleInActiveView,
|
||||
resolveMessageChannelId,
|
||||
shouldDeliverDirectMessageNotification,
|
||||
shouldDeliverNotification
|
||||
} from '../../domain/logic/notification.logic';
|
||||
import {
|
||||
@@ -53,6 +57,8 @@ export class NotificationsService {
|
||||
private readonly timeSync = inject(TimeSyncService);
|
||||
private readonly desktopNotifications = inject(DesktopNotificationService);
|
||||
private readonly storage = inject(NotificationSettingsStorageService);
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
|
||||
|
||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
@@ -83,6 +89,7 @@ export class NotificationsService {
|
||||
this.initialised = true;
|
||||
this.registerWindowListeners();
|
||||
this.registerWindowStateListener();
|
||||
this.registerMobileLifecycleListener();
|
||||
this.syncRoomCatalog(this.savedRooms());
|
||||
await this.hydrateUnreadCounts(this.savedRooms());
|
||||
this.markCurrentChannelReadIfActive();
|
||||
@@ -234,6 +241,44 @@ export class NotificationsService {
|
||||
await this.desktopNotifications.showNotification(payload);
|
||||
}
|
||||
|
||||
/** System notification for an incoming direct message; sender/self filtering happens in the DM domain. */
|
||||
async handleIncomingDirectMessage(input: {
|
||||
id: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
conversationVisible: boolean;
|
||||
}): Promise<void> {
|
||||
if (!this.initialised || this.isDuplicateMessage(input.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rememberMessageId(input.id);
|
||||
|
||||
const isWindowActive = this.isWindowActive();
|
||||
const shouldDeliver = shouldDeliverDirectMessageNotification(this._settings(), {
|
||||
conversationVisible: input.conversationVisible,
|
||||
currentUser: this.currentUser() ?? null,
|
||||
isWindowActive
|
||||
});
|
||||
|
||||
if (!shouldDeliver) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildDirectMessageNotificationPayload(
|
||||
input,
|
||||
this._settings(),
|
||||
!isWindowActive,
|
||||
(key, params) => this.appI18n.instant(key, params)
|
||||
);
|
||||
|
||||
if (this.shouldPlayNotificationSound()) {
|
||||
this.audio.play(AppSound.Notification);
|
||||
}
|
||||
|
||||
await this.desktopNotifications.showNotification(payload);
|
||||
}
|
||||
|
||||
markCurrentChannelReadIfActive(): void {
|
||||
if (!this.initialised || !this._windowFocused() || !this._documentVisible()) {
|
||||
return;
|
||||
@@ -321,6 +366,28 @@ export class NotificationsService {
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
||||
}
|
||||
|
||||
/** Android WebView focus/visibility events are unreliable when the app backgrounds; use Capacitor appStateChange instead. */
|
||||
private registerMobileLifecycleListener(): void {
|
||||
if (!this.platform.isCapacitor) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.mobileLifecycle.initialize().then(() => {
|
||||
this.mobileLifecycle.onAppStateChange((isActive) => {
|
||||
this._windowFocused.set(isActive);
|
||||
this._documentVisible.set(isActive);
|
||||
this._windowMinimized.set(!isActive);
|
||||
|
||||
if (isActive) {
|
||||
this.markCurrentChannelReadIfActive();
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncWindowAttention();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private registerWindowStateListener(): void {
|
||||
this.windowStateCleanup = this.desktopNotifications.onWindowStateChanged((state) => {
|
||||
this._windowFocused.set(state.isFocused);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { Message, Room } from '../../../../shared-kernel';
|
||||
import type {
|
||||
Message,
|
||||
Room,
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
import { createDefaultNotificationSettings } from '../models/notification.model';
|
||||
import { calculateUnreadForRoom } from './notification.logic';
|
||||
import {
|
||||
buildDirectMessageNotificationPayload,
|
||||
calculateUnreadForRoom,
|
||||
shouldDeliverDirectMessageNotification
|
||||
} from './notification.logic';
|
||||
|
||||
function createRoom(overrides: Partial<Room> = {}): Room {
|
||||
return {
|
||||
@@ -29,6 +37,80 @@ function createMessage(overrides: Partial<Message> = {}): Message {
|
||||
};
|
||||
}
|
||||
|
||||
describe('shouldDeliverDirectMessageNotification', () => {
|
||||
const settings = createDefaultNotificationSettings();
|
||||
const onlineUser = { status: 'online' } as User;
|
||||
|
||||
it('delivers when the conversation is not on screen', () => {
|
||||
expect(shouldDeliverDirectMessageNotification(settings, {
|
||||
conversationVisible: false,
|
||||
currentUser: onlineUser,
|
||||
isWindowActive: true
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('delivers when the conversation is on screen but the window is inactive', () => {
|
||||
expect(shouldDeliverDirectMessageNotification(settings, {
|
||||
conversationVisible: true,
|
||||
currentUser: onlineUser,
|
||||
isWindowActive: false
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses when the conversation is on screen in the active window', () => {
|
||||
expect(shouldDeliverDirectMessageNotification(settings, {
|
||||
conversationVisible: true,
|
||||
currentUser: onlineUser,
|
||||
isWindowActive: true
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('suppresses when notifications are disabled', () => {
|
||||
expect(shouldDeliverDirectMessageNotification({ ...settings, enabled: false }, {
|
||||
conversationVisible: false,
|
||||
currentUser: onlineUser,
|
||||
isWindowActive: false
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('suppresses when the user is busy', () => {
|
||||
expect(shouldDeliverDirectMessageNotification(settings, {
|
||||
conversationVisible: false,
|
||||
currentUser: { status: 'busy' } as User,
|
||||
isWindowActive: false
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDirectMessageNotificationPayload', () => {
|
||||
const translate = (key: string, params?: Record<string, string | number>) =>
|
||||
`${key}:${JSON.stringify(params ?? {})}`;
|
||||
|
||||
it('uses the sender name as title and the message preview as body', () => {
|
||||
const payload = buildDirectMessageNotificationPayload(
|
||||
{ senderName: 'Bob', content: 'hello there' },
|
||||
createDefaultNotificationSettings(),
|
||||
true,
|
||||
translate
|
||||
);
|
||||
|
||||
expect(payload.title).toBe('Bob');
|
||||
expect(payload.body).toBe('notifications.display.preview:{"sender":"Bob","content":"hello there"}');
|
||||
expect(payload.requestAttention).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the content when previews are disabled', () => {
|
||||
const payload = buildDirectMessageNotificationPayload(
|
||||
{ senderName: 'Bob', content: 'secret' },
|
||||
{ ...createDefaultNotificationSettings(), showPreview: false },
|
||||
false,
|
||||
translate
|
||||
);
|
||||
|
||||
expect(payload.body).toBe('notifications.display.newMessageHidden:{"sender":"Bob"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateUnreadForRoom', () => {
|
||||
it('ignores messages whose channel is not part of the room catalog', () => {
|
||||
const room = createRoom();
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Message, Room } from '../../../../shared-kernel';
|
||||
import type {
|
||||
Message,
|
||||
Room,
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
import type {
|
||||
NotificationDeliveryContext,
|
||||
NotificationDisplayPayload,
|
||||
@@ -116,6 +120,47 @@ export function buildNotificationDisplayPayload(
|
||||
};
|
||||
}
|
||||
|
||||
export interface DirectMessageNotificationContext {
|
||||
conversationVisible: boolean;
|
||||
currentUser: Pick<User, 'status'> | null;
|
||||
isWindowActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DMs have no room/channel mute concept; deliver unless the conversation is
|
||||
* actually on screen in an active window, notifications are off, or the user
|
||||
* is busy.
|
||||
*/
|
||||
export function shouldDeliverDirectMessageNotification(
|
||||
settings: Pick<NotificationsSettings, 'enabled'>,
|
||||
context: DirectMessageNotificationContext
|
||||
): boolean {
|
||||
if (!settings.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.currentUser?.status === 'busy') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !(context.conversationVisible && context.isWindowActive);
|
||||
}
|
||||
|
||||
export function buildDirectMessageNotificationPayload(
|
||||
message: { senderName: string; content: string },
|
||||
settings: Pick<NotificationsSettings, 'showPreview'>,
|
||||
requestAttention: boolean,
|
||||
translate: AppTranslateFn
|
||||
): NotificationDisplayPayload {
|
||||
return {
|
||||
title: message.senderName,
|
||||
body: settings.showPreview
|
||||
? formatMessagePreview(message.senderName, message.content, translate)
|
||||
: translate('notifications.display.newMessageHidden', { sender: message.senderName }),
|
||||
requestAttention
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateUnreadForRoom(
|
||||
room: Room,
|
||||
messages: Message[],
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { PlatformService } from '../../../../core/platform';
|
||||
import { MobileNotificationsService } from '../../../../infrastructure/mobile/services/mobile-notifications.service';
|
||||
import { DesktopNotificationService } from './desktop-notification.service';
|
||||
|
||||
interface ServiceContextOptions {
|
||||
electronApi?: { showDesktopNotification?: ReturnType<typeof vi.fn> } | null;
|
||||
isBrowser?: boolean;
|
||||
isCapacitor?: boolean;
|
||||
}
|
||||
|
||||
function createService(options: ServiceContextOptions = {}) {
|
||||
const showMessage = vi.fn(async () => undefined);
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: ElectronBridgeService,
|
||||
useValue: {
|
||||
getApi: vi.fn(() => options.electronApi ?? null)
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: PlatformService,
|
||||
useValue: {
|
||||
isBrowser: options.isBrowser ?? false,
|
||||
isCapacitor: options.isCapacitor ?? false,
|
||||
isElectron: false
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: MobileNotificationsService,
|
||||
useValue: { showMessage }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
service: runInInjectionContext(injector, () => new DesktopNotificationService()),
|
||||
showMessage
|
||||
};
|
||||
}
|
||||
|
||||
describe('DesktopNotificationService', () => {
|
||||
it('routes message notifications to the mobile notifications facade on Capacitor', async () => {
|
||||
const { service, showMessage } = createService({ isCapacitor: true });
|
||||
|
||||
await service.showNotification({ title: 'general - Toju HQ', body: 'Alice: hello' });
|
||||
|
||||
expect(showMessage).toHaveBeenCalledWith({ title: 'general - Toju HQ', body: 'Alice: hello' });
|
||||
});
|
||||
|
||||
it('prefers the Electron bridge when available', async () => {
|
||||
const showDesktopNotification = vi.fn(async () => undefined);
|
||||
const { service, showMessage } = createService({ electronApi: { showDesktopNotification } });
|
||||
|
||||
await service.showNotification({ title: 't', body: 'b' });
|
||||
|
||||
expect(showDesktopNotification).toHaveBeenCalled();
|
||||
expect(showMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing on non-browser, non-capacitor shells without Electron', async () => {
|
||||
const { service, showMessage } = createService({ isBrowser: false, isCapacitor: false });
|
||||
|
||||
await service.showNotification({ title: 't', body: 'b' });
|
||||
|
||||
expect(showMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+7
@@ -2,12 +2,14 @@ import { Injectable, inject } from '@angular/core';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import type { WindowStateSnapshot } from '../../../../core/platform/electron/electron-api.models';
|
||||
import { PlatformService } from '../../../../core/platform';
|
||||
import { MobileNotificationsService } from '../../../../infrastructure/mobile/services/mobile-notifications.service';
|
||||
import type { NotificationDisplayPayload } from '../../domain/models/notification.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DesktopNotificationService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly mobileNotifications = inject(MobileNotificationsService);
|
||||
|
||||
async showNotification(payload: NotificationDisplayPayload): Promise<void> {
|
||||
const api = this.electronBridge.getApi();
|
||||
@@ -17,6 +19,11 @@ export class DesktopNotificationService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.platform.isCapacitor) {
|
||||
await this.mobileNotifications.showMessage({ title: payload.title, body: payload.body });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.platform.isBrowser || typeof Notification === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,3 +44,8 @@ Desktop plugin preferences that belong to the local user, including capability g
|
||||
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. HTTP(S) entrypoints are imported directly when the host serves module-compatible JavaScript; if a source host serves JavaScript with a non-module MIME type, the runtime fetches the source and imports it through a blob URL. Successfully activated plugin ids are remembered locally, and store-installed plugins are reactivated for the active server when their persisted manifests load again. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id.
|
||||
|
||||
Plugins that need fully custom UI can call `api.ui.mountElement(id, { target, element, position })` with the `ui.dom` capability. The runtime tags mounted elements with plugin ownership metadata, replaces duplicate mounts for the same plugin/id pair, and removes remaining mounted elements when the plugin is unloaded.
|
||||
|
||||
## Cross-context feature docs
|
||||
|
||||
- [`agents-docs/features/plugins.md`](../../../../../agents-docs/features/plugins.md)
|
||||
- [`agents-docs/features/signaling.md`](../../../../../agents-docs/features/signaling.md) — `plugin_event`, `plugin_requirements`
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
(dismissed)="cancelled.emit(undefined)"
|
||||
/>
|
||||
|
||||
<div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
|
||||
<div class="fixed metoyou-fixed-safe-viewport z-[113] flex items-center justify-center p-4 pointer-events-none">
|
||||
<div
|
||||
class="pointer-events-auto flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
|
||||
role="dialog"
|
||||
|
||||
@@ -227,3 +227,12 @@ All endpoint state is persisted to localStorage under two keys:
|
||||
| `metoyou_removed_default_server_keys` | Set of default endpoint keys the user explicitly removed |
|
||||
|
||||
The storage service handles JSON serialisation and defensive parsing. Invalid data falls back to empty state rather than throwing.
|
||||
|
||||
## Cross-context feature docs
|
||||
|
||||
Wire contracts spanning client + server (REST routes, discovery fan-out, invites) are documented in:
|
||||
|
||||
- [`agents-docs/features/server-directory.md`](../../../../../agents-docs/features/server-directory.md)
|
||||
- [`agents-docs/features/server-discovery.md`](../../../../../agents-docs/features/server-discovery.md)
|
||||
- [`agents-docs/features/invites-join-requests.md`](../../../../../agents-docs/features/invites-join-requests.md)
|
||||
- [`agents-docs/features/signaling.md`](../../../../../agents-docs/features/signaling.md) — WebSocket `join_server` after REST join
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@
|
||||
/>
|
||||
</button>
|
||||
|
||||
@if (!isMobile()) {
|
||||
@if (showScreenShareButton()) {
|
||||
<button
|
||||
(click)="toggleScreenShare()"
|
||||
type="button"
|
||||
|
||||
+4
@@ -25,6 +25,7 @@ import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
||||
import { VoicePlaybackService } from '../../../../domains/voice-connection';
|
||||
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import { MobilePlatformService } from '../../../../infrastructure/mobile';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
|
||||
@@ -63,7 +64,10 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
||||
private readonly webrtcService = inject(VoiceConnectionFacade);
|
||||
private readonly screenShareService = inject(ScreenShareFacade);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
/** Screen share is not supported in mobile WebViews; hide the control there. */
|
||||
readonly showScreenShareButton = computed(() => !this.viewport.isMobile() && !this.mobilePlatform.isNativeMobile());
|
||||
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly store = inject(Store);
|
||||
|
||||
+27
-17
@@ -79,6 +79,14 @@
|
||||
[attr.aria-hidden]="isConnected() ? null : 'true'"
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
@if (mediaError(); as mediaErrorMessage) {
|
||||
<p
|
||||
class="mb-2 text-center text-xs text-destructive"
|
||||
data-testid="voice-controls-media-error"
|
||||
>
|
||||
{{ mediaErrorMessage }}
|
||||
</p>
|
||||
}
|
||||
<div
|
||||
appThemeNode="voiceControlsButtons"
|
||||
class="flex items-center justify-center gap-2"
|
||||
@@ -134,23 +142,25 @@
|
||||
</button>
|
||||
|
||||
<!-- Screen Share Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleScreenShare()"
|
||||
[class]="getScreenShareButtonClass()"
|
||||
>
|
||||
@if (isScreenSharing()) {
|
||||
<ng-icon
|
||||
name="lucideMonitorOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
@if (showScreenShareButton()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleScreenShare()"
|
||||
[class]="getScreenShareButtonClass()"
|
||||
>
|
||||
@if (isScreenSharing()) {
|
||||
<ng-icon
|
||||
name="lucideMonitorOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Disconnect -->
|
||||
<button
|
||||
|
||||
+19
-2
@@ -33,7 +33,8 @@ import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { MobileMediaService } from '../../../../infrastructure/mobile';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import { MobileMediaService, MobilePlatformService } from '../../../../infrastructure/mobile';
|
||||
import {
|
||||
DebugConsoleComponent,
|
||||
ScreenShareQualityDialogComponent,
|
||||
@@ -85,6 +86,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private readonly hostEl = inject(ElementRef);
|
||||
private readonly profileCard = inject(ProfileCardService);
|
||||
private readonly mobileMedia = inject(MobileMediaService);
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
@@ -106,6 +109,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
isCameraEnabled = computed(() => this.webrtcService.isCameraEnabled());
|
||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||
showSettings = signal(false);
|
||||
/** Camera/screen-share capture problems surfaced to the user instead of being swallowed. */
|
||||
mediaError = signal<string | null>(null);
|
||||
/** Screen share is not supported in mobile WebViews; hide the control there. */
|
||||
showScreenShareButton = computed(() => !this.viewport.isMobile() && !this.mobilePlatform.isNativeMobile());
|
||||
|
||||
toggleProfileCard(): void {
|
||||
const user = this.currentUser();
|
||||
@@ -412,6 +419,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
const user = this.currentUser();
|
||||
|
||||
this.mediaError.set(null);
|
||||
|
||||
if (this.isCameraEnabled()) {
|
||||
this.webrtcService.disableCamera();
|
||||
|
||||
@@ -438,7 +447,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (_error) {}
|
||||
} catch (error) {
|
||||
const errorName = error instanceof Error ? error.name : '';
|
||||
const isPermissionError = errorName === 'NotAllowedError'
|
||||
|| (error instanceof Error && error.message.includes('permission'));
|
||||
|
||||
this.mediaError.set(this.appI18n.instant(
|
||||
isPermissionError ? 'call.errors.cameraPermissionDenied' : 'call.errors.cameraUnavailable'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
|
||||
@@ -85,19 +85,21 @@
|
||||
/>
|
||||
</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"
|
||||
[disabled]="!connected()"
|
||||
(click)="screenShareToggled.emit()"
|
||||
[attr.aria-label]="(screenSharing() ? 'call.stopSharingScreen' : 'call.shareScreen') | translate"
|
||||
[title]="(screenSharing() ? 'call.stopSharingScreen' : 'call.shareScreen') | translate"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="screenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
@if (showScreenShareButton()) {
|
||||
<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"
|
||||
[disabled]="!connected()"
|
||||
(click)="screenShareToggled.emit()"
|
||||
[attr.aria-label]="(screenSharing() ? 'call.stopSharingScreen' : 'call.shareScreen') | translate"
|
||||
[title]="(screenSharing() ? 'call.stopSharingScreen' : 'call.shareScreen') | translate"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="screenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -46,6 +46,7 @@ export class PrivateCallControlsComponent {
|
||||
readonly cameraEnabled = input.required<boolean>();
|
||||
readonly screenSharing = input.required<boolean>();
|
||||
readonly showSpeakerphoneButton = input(false);
|
||||
readonly showScreenShareButton = input(true);
|
||||
readonly speakerphoneEnabled = input(false);
|
||||
|
||||
readonly joinRequested = output();
|
||||
|
||||
@@ -194,6 +194,14 @@
|
||||
}
|
||||
|
||||
<div class="shrink-0 pt-3">
|
||||
@if (callErrorMessage(); as callError) {
|
||||
<p
|
||||
class="mx-auto mb-2 w-full max-w-5xl px-3 text-center text-xs text-destructive"
|
||||
data-testid="private-call-error"
|
||||
>
|
||||
{{ callError }}
|
||||
</p>
|
||||
}
|
||||
<app-private-call-controls
|
||||
class="mx-auto block w-full max-w-5xl"
|
||||
[connected]="isConnected()"
|
||||
@@ -201,6 +209,7 @@
|
||||
[deafened]="isDeafened()"
|
||||
[cameraEnabled]="isCameraEnabled()"
|
||||
[screenSharing]="isScreenSharing()"
|
||||
[showScreenShareButton]="showScreenShareButton()"
|
||||
[showSpeakerphoneButton]="showSpeakerphoneButton()"
|
||||
[speakerphoneEnabled]="speakerphoneEnabled()"
|
||||
(joinRequested)="join()"
|
||||
|
||||
@@ -132,6 +132,10 @@ export class PrivateCallComponent {
|
||||
readonly isDeafened = this.voice.isDeafened;
|
||||
readonly isCameraEnabled = this.voice.isCameraEnabled;
|
||||
readonly isScreenSharing = this.screenShare.isScreenSharing;
|
||||
readonly joinError = this.calls.joinError;
|
||||
readonly cameraError = signal<string | null>(null);
|
||||
readonly callErrorMessage = computed(() => this.joinError() ?? this.cameraError());
|
||||
readonly showScreenShareButton = computed(() => !this.isMobile() && !this.mobilePlatform.isNativeMobile());
|
||||
readonly remoteStreamRevision = signal(0);
|
||||
readonly includeSystemAudio = signal(false);
|
||||
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
@@ -381,6 +385,8 @@ export class PrivateCallComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cameraError.set(null);
|
||||
|
||||
if (this.isCameraEnabled()) {
|
||||
this.voice.disableCamera();
|
||||
this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: false } }));
|
||||
@@ -388,11 +394,25 @@ export class PrivateCallComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.voice.enableCamera();
|
||||
try {
|
||||
await this.voice.enableCamera();
|
||||
} catch (error) {
|
||||
this.cameraError.set(this.resolveCameraErrorMessage(error));
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: true } }));
|
||||
this.bumpRemoteStreamRevision();
|
||||
}
|
||||
|
||||
private resolveCameraErrorMessage(error: unknown): string {
|
||||
const errorName = error instanceof Error ? error.name : '';
|
||||
const isPermissionError = errorName === 'NotAllowedError'
|
||||
|| (error instanceof Error && error.message.includes('permission'));
|
||||
|
||||
return this.i18n.instant(isPermissionError ? 'call.errors.cameraPermissionDenied' : 'call.errors.cameraUnavailable');
|
||||
}
|
||||
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
if (this.isScreenSharing()) {
|
||||
this.screenShare.stopScreenShare();
|
||||
|
||||
@@ -261,7 +261,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 font-medium text-primary-foreground transition hover:bg-primary/90"
|
||||
[class.hidden]="isMobile()"
|
||||
[class.hidden]="!showScreenShareButton()"
|
||||
(click)="toggleScreenShare()"
|
||||
>
|
||||
<ng-icon
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
ScreenShareStartOptions
|
||||
} from '../../../domains/screen-share';
|
||||
import { ViewportService } from '../../../core/platform';
|
||||
import { MobilePlatformService } from '../../../infrastructure/mobile';
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
|
||||
@@ -94,7 +95,10 @@ export class VoiceWorkspaceComponent {
|
||||
private readonly webrtc = inject(VoiceConnectionFacade);
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
/** Screen share is not supported in mobile WebViews; hide the control there. */
|
||||
readonly showScreenShareButton = computed(() => !this.viewport.isMobile() && !this.mobilePlatform.isNativeMobile());
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
/>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-[121] flex items-center justify-center px-4 pointer-events-none"
|
||||
class="fixed metoyou-fixed-safe-viewport z-[121] flex items-center justify-center px-4 pointer-events-none"
|
||||
>
|
||||
<div
|
||||
appThemeNode="highMemoryAlertDialog"
|
||||
|
||||
+40
@@ -1,11 +1,13 @@
|
||||
import translationsEn from '../../../../../../public/i18n/en.json';
|
||||
import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules';
|
||||
import { resolveCallNotificationAction } from '../../logic/call-notification.rules';
|
||||
import type { MessageNotificationPayload } from '../../logic/message-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';
|
||||
const MESSAGE_CHANNEL_ID = 'toju-messages';
|
||||
|
||||
function mobileLabel(key: string): string {
|
||||
const value = key.split('.').reduce<unknown>((current, part) => {
|
||||
@@ -47,6 +49,13 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
|
||||
visibility: 1
|
||||
});
|
||||
|
||||
await LocalNotifications.createChannel({
|
||||
id: MESSAGE_CHANNEL_ID,
|
||||
name: mobileLabel('mobile.notifications.messagesChannel'),
|
||||
importance: 4,
|
||||
visibility: 1
|
||||
});
|
||||
|
||||
await LocalNotifications.registerActionTypes({
|
||||
types: [
|
||||
{
|
||||
@@ -136,6 +145,37 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
|
||||
});
|
||||
}
|
||||
|
||||
async showMessageNotification(payload: MessageNotificationPayload): Promise<void> {
|
||||
const LocalNotifications = await 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: MESSAGE_CHANNEL_ID,
|
||||
autoCancel: true,
|
||||
group: payload.tag,
|
||||
extra: {
|
||||
kind: 'message',
|
||||
tag: payload.tag
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
async dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void> {
|
||||
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
|
||||
|
||||
|
||||
+16
@@ -1,4 +1,5 @@
|
||||
import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules';
|
||||
import type { MessageNotificationPayload } from '../../logic/message-notification.rules';
|
||||
import type { MobileNotificationAdapter } from '../../contracts/mobile.contracts';
|
||||
|
||||
type CallActionHandler = (input: { callId: string; intent: CallNotificationActionIntent }) => void;
|
||||
@@ -50,6 +51,21 @@ export class WebMobileNotificationsAdapter implements MobileNotificationAdapter
|
||||
};
|
||||
}
|
||||
|
||||
async showMessageNotification(payload: MessageNotificationPayload): Promise<void> {
|
||||
const granted = await this.requestPermission();
|
||||
|
||||
if (!granted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = new Notification(payload.title, {
|
||||
body: payload.body,
|
||||
tag: payload.tag
|
||||
});
|
||||
|
||||
notification.onclick = () => window.focus();
|
||||
}
|
||||
|
||||
async dismissCallNotification(_callId: string, _kind: CallNotificationPayload['kind']): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { CallNotificationActionIntent, CallNotificationPayload } from '../logic/call-notification.rules';
|
||||
import type { MessageNotificationPayload } from '../logic/message-notification.rules';
|
||||
import type { RuntimePlatform } from '../logic/platform-detection.rules';
|
||||
|
||||
export interface MobileNotificationAdapter {
|
||||
initialize(): Promise<void>;
|
||||
requestPermission(): Promise<boolean>;
|
||||
showCallNotification(payload: CallNotificationPayload): Promise<void>;
|
||||
showMessageNotification(payload: MessageNotificationPayload): Promise<void>;
|
||||
dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void>;
|
||||
onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void;
|
||||
}
|
||||
|
||||
+10
@@ -62,6 +62,16 @@ describe('ensure-mobile-capture-permissions', () => {
|
||||
await expect(ensureMobileCameraCapturePermissions()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('defers to WebView capture when the native prompt was dismissed', async () => {
|
||||
pluginState.plugin = {
|
||||
requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'prompt' })),
|
||||
requestCameraCapturePermissions: vi.fn(() => Promise.resolve({ camera: 'prompt' }))
|
||||
};
|
||||
|
||||
await expect(ensureMobileVoiceCapturePermissions()).resolves.toBe(true);
|
||||
await expect(ensureMobileCameraCapturePermissions()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('blocks capture when the native shell explicitly denies microphone access', async () => {
|
||||
pluginState.plugin = {
|
||||
requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'denied' })),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { MESSAGE_NOTIFICATION_BASE_ID, buildMessageNotification } from './message-notification.rules';
|
||||
|
||||
describe('buildMessageNotification', () => {
|
||||
it('builds a payload with title, body and a collapse tag', () => {
|
||||
const payload = buildMessageNotification({ title: 'general - Toju HQ', body: 'Alice: hello' });
|
||||
|
||||
expect(payload.title).toBe('general - Toju HQ');
|
||||
expect(payload.body).toBe('Alice: hello');
|
||||
expect(payload.tag).toBe('toju-message-general - Toju HQ');
|
||||
});
|
||||
|
||||
it('derives a stable numeric id from the tag so newer messages replace the same notification', () => {
|
||||
const first = buildMessageNotification({ title: 'general - Toju HQ', body: 'first' });
|
||||
const second = buildMessageNotification({ title: 'general - Toju HQ', body: 'second' });
|
||||
|
||||
expect(first.id).toBe(second.id);
|
||||
expect(Number.isInteger(first.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses distinct ids for distinct tags', () => {
|
||||
const general = buildMessageNotification({ title: 'general - Toju HQ', body: 'x' });
|
||||
const random = buildMessageNotification({ title: 'random - Toju HQ', body: 'x' });
|
||||
|
||||
expect(general.id).not.toBe(random.id);
|
||||
});
|
||||
|
||||
it('keeps ids outside the call-notification id ranges', () => {
|
||||
const payload = buildMessageNotification({ title: 't', body: 'b' });
|
||||
|
||||
expect(payload.id).toBeGreaterThanOrEqual(MESSAGE_NOTIFICATION_BASE_ID);
|
||||
});
|
||||
|
||||
it('honors an explicit tag override', () => {
|
||||
const payload = buildMessageNotification({ title: 't', body: 'b', tag: 'room-42' });
|
||||
|
||||
expect(payload.tag).toBe('toju-message-room-42');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface MessageNotificationPayload {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
/** Base id above the incoming (1000+) and active (2000+) call notification ranges. */
|
||||
export const MESSAGE_NOTIFICATION_BASE_ID = 3000;
|
||||
|
||||
const MESSAGE_NOTIFICATION_ID_SPAN = 9973;
|
||||
|
||||
/** Build a local notification payload for an incoming chat message; the tag collapses per-channel. */
|
||||
export function buildMessageNotification(input: { title: string; body: string; tag?: string }): MessageNotificationPayload {
|
||||
const tag = `toju-message-${input.tag ?? input.title}`;
|
||||
|
||||
return {
|
||||
id: MESSAGE_NOTIFICATION_BASE_ID + hashTag(tag),
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
tag
|
||||
};
|
||||
}
|
||||
|
||||
function hashTag(tag: string): number {
|
||||
let hash = 0;
|
||||
|
||||
for (let index = 0; index < tag.length; index += 1) {
|
||||
hash = (hash * 31 + tag.charCodeAt(index)) % MESSAGE_NOTIFICATION_ID_SPAN;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
+43
-1
@@ -15,8 +15,10 @@ import {
|
||||
findMissingLauncherResources,
|
||||
findStockCapacitorResources,
|
||||
isBrandLauncherBackgroundColor,
|
||||
NOTIFICATION_STATUS_ICON_NAME,
|
||||
readAdaptiveIconBackgroundColor,
|
||||
REQUIRED_LAUNCHER_ICON_FILES,
|
||||
REQUIRED_NOTIFICATION_ICON_FILES,
|
||||
REQUIRED_SPLASH_FILES,
|
||||
resolveIconPixelSize,
|
||||
SPLASH_ICON_RATIO
|
||||
@@ -32,7 +34,11 @@ function sha256OfResource(resRelativePath: string): string {
|
||||
}
|
||||
|
||||
describe('mobile-android-launcher-icon.rules', () => {
|
||||
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
|
||||
const allRequired = [
|
||||
...REQUIRED_LAUNCHER_ICON_FILES,
|
||||
...REQUIRED_SPLASH_FILES,
|
||||
...REQUIRED_NOTIFICATION_ICON_FILES
|
||||
];
|
||||
const presentFiles = allRequired.filter((file) => existsSync(resolve(RES_DIR, file)));
|
||||
|
||||
it('keeps the brand mark inside the adaptive-icon safe zone', () => {
|
||||
@@ -45,6 +51,42 @@ describe('mobile-android-launcher-icon.rules', () => {
|
||||
expect(findMissingLauncherResources(presentFiles)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ships a notification status-bar icon for every density', () => {
|
||||
expect(findMissingLauncherResources(presentFiles, REQUIRED_NOTIFICATION_ICON_FILES)).toEqual([]);
|
||||
});
|
||||
|
||||
it('references the notification status icon from the Capacitor config', () => {
|
||||
const capacitorConfig = readFileSync(resolve(process.cwd(), 'capacitor.config.ts'), 'utf8');
|
||||
|
||||
expect(capacitorConfig).toContain(`smallIcon: '${NOTIFICATION_STATUS_ICON_NAME}'`);
|
||||
});
|
||||
|
||||
it('renders the notification status icon as an alpha-only white glyph', async () => {
|
||||
const iconPath = resolve(RES_DIR, 'drawable-xxxhdpi/ic_stat_metoyou.png');
|
||||
const { data, info } = await sharp(iconPath).ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
let opaquePixels = 0;
|
||||
let transparentPixels = 0;
|
||||
|
||||
for (let offset = 0; offset < data.length; offset += info.channels) {
|
||||
const alpha = data[offset + 3];
|
||||
|
||||
if (alpha > 224) {
|
||||
opaquePixels += 1;
|
||||
expect(data[offset]).toBeGreaterThan(224);
|
||||
expect(data[offset + 1]).toBeGreaterThan(224);
|
||||
expect(data[offset + 2]).toBeGreaterThan(224);
|
||||
} else if (alpha < 32) {
|
||||
transparentPixels += 1;
|
||||
}
|
||||
}
|
||||
|
||||
expect(opaquePixels).toBeGreaterThan(0);
|
||||
expect(transparentPixels).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('replaces every stock Capacitor placeholder with the brand asset', () => {
|
||||
const hashByFile = Object.fromEntries(presentFiles.map((file) => [file, sha256OfResource(file)]));
|
||||
|
||||
|
||||
@@ -49,6 +49,14 @@ export const REQUIRED_LAUNCHER_ICON_FILES: readonly string[] = ANDROID_ICON_DENS
|
||||
LAUNCHER_ICON_BASENAMES.map((basename) => `mipmap-${density}/${basename}`)
|
||||
);
|
||||
|
||||
/** Resource name (no extension) the Capacitor LocalNotifications config must reference as `smallIcon`. */
|
||||
export const NOTIFICATION_STATUS_ICON_NAME = 'ic_stat_metoyou';
|
||||
|
||||
/** res-relative notification status-bar icon files (alpha-only white glyph, one per density). */
|
||||
export const REQUIRED_NOTIFICATION_ICON_FILES: readonly string[] = ANDROID_ICON_DENSITIES.map(
|
||||
(density) => `drawable-${density}/${NOTIFICATION_STATUS_ICON_NAME}.png`
|
||||
);
|
||||
|
||||
/** res-relative splash files the brand build must contain (portrait + landscape per density, plus the base). */
|
||||
export const REQUIRED_SPLASH_FILES: readonly string[] = [
|
||||
'drawable/splash.png',
|
||||
|
||||
@@ -24,13 +24,19 @@ describe('mobile-media-permission.rules', () => {
|
||||
expect(isMobileCapturePermissionGranted('denied')).toBe(false);
|
||||
});
|
||||
|
||||
it('requires microphone permission for voice capture', () => {
|
||||
it('only blocks voice capture on an explicit native denial', () => {
|
||||
expect(isVoiceCaptureAllowed({ microphone: 'granted' })).toBe(true);
|
||||
expect(isVoiceCaptureAllowed({ microphone: 'denied' })).toBe(false);
|
||||
// Dismissed dialogs ('prompt') defer to the WebView getUserMedia permission flow.
|
||||
expect(isVoiceCaptureAllowed({ microphone: 'prompt' })).toBe(true);
|
||||
expect(isVoiceCaptureAllowed({ microphone: 'prompt-with-rationale' })).toBe(true);
|
||||
expect(isVoiceCaptureAllowed({})).toBe(true);
|
||||
});
|
||||
|
||||
it('requires camera permission for camera capture', () => {
|
||||
it('only blocks camera capture on an explicit native denial', () => {
|
||||
expect(isCameraCaptureAllowed({ camera: 'granted' })).toBe(true);
|
||||
expect(isCameraCaptureAllowed({ camera: 'prompt' })).toBe(false);
|
||||
expect(isCameraCaptureAllowed({ camera: 'denied' })).toBe(false);
|
||||
expect(isCameraCaptureAllowed({ camera: 'prompt' })).toBe(true);
|
||||
expect(isCameraCaptureAllowed({})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,12 +17,21 @@ export function shouldPreflightMobileCapturePermissions(runtime: RuntimePlatform
|
||||
return runtime === 'capacitor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Only an explicit native denial blocks capture. Any other state (granted, a
|
||||
* dismissed prompt, or an unknown value) defers to the WebView getUserMedia
|
||||
* permission flow, which re-prompts through Capacitor's WebChromeClient.
|
||||
*/
|
||||
function isCaptureBlockedByNativeDenial(state: MobileMediaPermissionState | undefined): boolean {
|
||||
return state === 'denied';
|
||||
}
|
||||
|
||||
/** Resolve whether voice capture can proceed after a native permission request. */
|
||||
export function isVoiceCaptureAllowed(result: MobileCapturePermissionResult): boolean {
|
||||
return isMobileCapturePermissionGranted(result.microphone);
|
||||
return !isCaptureBlockedByNativeDenial(result.microphone);
|
||||
}
|
||||
|
||||
/** Resolve whether camera capture can proceed after a native permission request. */
|
||||
export function isCameraCaptureAllowed(result: MobileCapturePermissionResult): boolean {
|
||||
return isMobileCapturePermissionGranted(result.camera);
|
||||
return !isCaptureBlockedByNativeDenial(result.camera);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
const pluginState = vi.hoisted(() => ({
|
||||
plugin: null as null | {
|
||||
startVoiceForegroundService: () => Promise<void>;
|
||||
stopVoiceForegroundService: () => Promise<void>;
|
||||
},
|
||||
isNative: true
|
||||
}));
|
||||
|
||||
vi.mock('../adapters/capacitor/metoyou-mobile.plugin', () => ({
|
||||
loadMetoyouMobilePlugin: vi.fn(() => Promise.resolve(pluginState.plugin))
|
||||
}));
|
||||
|
||||
vi.mock('./platform-detection.rules', () => ({
|
||||
isCapacitorNativeRuntime: vi.fn(() => pluginState.isNative)
|
||||
}));
|
||||
|
||||
import { startMobileVoiceForegroundSession, stopMobileVoiceForegroundSession } from './mobile-voice-foreground-session';
|
||||
|
||||
describe('mobile-voice-foreground-session', () => {
|
||||
beforeEach(async () => {
|
||||
pluginState.isNative = true;
|
||||
pluginState.plugin = {
|
||||
startVoiceForegroundService: vi.fn(async () => undefined),
|
||||
stopVoiceForegroundService: vi.fn(async () => undefined)
|
||||
};
|
||||
|
||||
// Reset internal session flag between tests.
|
||||
await stopMobileVoiceForegroundSession();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('starts the native foreground service on Capacitor shells', async () => {
|
||||
await startMobileVoiceForegroundSession();
|
||||
|
||||
expect(pluginState.plugin?.startVoiceForegroundService).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing off Capacitor shells', async () => {
|
||||
pluginState.isNative = false;
|
||||
|
||||
await startMobileVoiceForegroundSession();
|
||||
|
||||
expect(pluginState.plugin?.startVoiceForegroundService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the native foreground service after a start', async () => {
|
||||
await startMobileVoiceForegroundSession();
|
||||
await stopMobileVoiceForegroundSession();
|
||||
|
||||
expect(pluginState.plugin?.stopVoiceForegroundService).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('swallows native bridge failures', async () => {
|
||||
pluginState.plugin = {
|
||||
startVoiceForegroundService: vi.fn(() => Promise.reject(new Error('UNIMPLEMENTED'))),
|
||||
stopVoiceForegroundService: vi.fn(async () => undefined)
|
||||
};
|
||||
|
||||
await expect(startMobileVoiceForegroundSession()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles a missing plugin gracefully', async () => {
|
||||
pluginState.plugin = null;
|
||||
|
||||
await expect(startMobileVoiceForegroundSession()).resolves.toBeUndefined();
|
||||
await expect(stopMobileVoiceForegroundSession()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin';
|
||||
import { isCapacitorNativeRuntime } from './platform-detection.rules';
|
||||
|
||||
let sessionActive = false;
|
||||
|
||||
/**
|
||||
* Keep Android microphone capture alive while any voice session (voice channel
|
||||
* or direct call) is active by running the native foreground service. Without
|
||||
* it Android kills WebRTC capture shortly after the app backgrounds.
|
||||
*/
|
||||
export async function startMobileVoiceForegroundSession(): Promise<void> {
|
||||
if (!isCapacitorNativeRuntime() || sessionActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = await loadMetoyouMobilePlugin();
|
||||
|
||||
if (!plugin?.startVoiceForegroundService) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await plugin.startVoiceForegroundService();
|
||||
sessionActive = true;
|
||||
} catch {
|
||||
// Native bridge unavailable; capture continues while the app stays foregrounded.
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the Android voice foreground service once no voice session remains. */
|
||||
export async function stopMobileVoiceForegroundSession(): Promise<void> {
|
||||
if (!isCapacitorNativeRuntime() || !sessionActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionActive = false;
|
||||
|
||||
const plugin = await loadMetoyouMobilePlugin();
|
||||
|
||||
if (!plugin?.stopVoiceForegroundService) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await plugin.stopVoiceForegroundService();
|
||||
} catch {
|
||||
// Service already gone.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
|
||||
import { MobileAppLifecycleService } from './mobile-app-lifecycle.service';
|
||||
import { MobilePlatformService } from './mobile-platform.service';
|
||||
import { MobileRuntimePermissionsService } from './mobile-runtime-permissions.service';
|
||||
|
||||
type VisibilityListener = () => void;
|
||||
|
||||
interface DocumentStub {
|
||||
hidden: boolean;
|
||||
listeners: VisibilityListener[];
|
||||
addEventListener: (type: string, listener: VisibilityListener) => void;
|
||||
}
|
||||
|
||||
function installDocumentStub(): DocumentStub {
|
||||
const stub: DocumentStub = {
|
||||
hidden: false,
|
||||
listeners: [],
|
||||
addEventListener(type: string, listener: VisibilityListener) {
|
||||
if (type === 'visibilitychange') {
|
||||
stub.listeners.push(listener);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
(globalThis as { document?: unknown }).document = stub;
|
||||
|
||||
return stub;
|
||||
}
|
||||
|
||||
function createService() {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: MobilePlatformService,
|
||||
useValue: {
|
||||
refreshRuntimeDetection: vi.fn(),
|
||||
runtime: vi.fn(() => 'browser')
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: MobileRuntimePermissionsService,
|
||||
useValue: {
|
||||
initialize: vi.fn(async () => undefined)
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return runInInjectionContext(injector, () => new MobileAppLifecycleService());
|
||||
}
|
||||
|
||||
describe('MobileAppLifecycleService', () => {
|
||||
let documentStub: DocumentStub;
|
||||
|
||||
beforeEach(() => {
|
||||
documentStub = installDocumentStub();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
});
|
||||
|
||||
it('fans app-state changes out to every registered handler', async () => {
|
||||
const service = createService();
|
||||
|
||||
await service.initialize();
|
||||
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
|
||||
service.onAppStateChange(first);
|
||||
service.onAppStateChange(second);
|
||||
|
||||
documentStub.hidden = true;
|
||||
documentStub.listeners.forEach((listener) => listener());
|
||||
|
||||
expect(first).toHaveBeenCalledWith(false);
|
||||
expect(second).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('keeps handlers registered before initialize', async () => {
|
||||
const service = createService();
|
||||
const handler = vi.fn();
|
||||
|
||||
service.onAppStateChange(handler);
|
||||
await service.initialize();
|
||||
|
||||
documentStub.hidden = false;
|
||||
documentStub.listeners.forEach((listener) => listener());
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -12,10 +12,15 @@ import { MobileRuntimePermissionsService } from './mobile-runtime-permissions.se
|
||||
export class MobileAppLifecycleService {
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
private readonly runtimePermissions = inject(MobileRuntimePermissionsService);
|
||||
private readonly appStateHandlers = new Set<(isActive: boolean) => void>();
|
||||
private adapter: MobileAppLifecycleAdapter = new WebMobileAppLifecycleAdapter();
|
||||
private adapterReady: Promise<MobileAppLifecycleAdapter> | null = null;
|
||||
private initialized = false;
|
||||
|
||||
constructor() {
|
||||
this.adapter.onAppStateChange((isActive) => this.dispatchAppStateChange(isActive));
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
@@ -30,8 +35,15 @@ export class MobileAppLifecycleService {
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/** Register an app foreground/background listener; every registered handler is invoked (fan-out). */
|
||||
onAppStateChange(handler: (isActive: boolean) => void): void {
|
||||
this.adapter.onAppStateChange(handler);
|
||||
this.appStateHandlers.add(handler);
|
||||
}
|
||||
|
||||
private dispatchAppStateChange(isActive: boolean): void {
|
||||
for (const handler of this.appStateHandlers) {
|
||||
handler(isActive);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAdapter(): Promise<MobileAppLifecycleAdapter> {
|
||||
@@ -46,6 +58,7 @@ export class MobileAppLifecycleService {
|
||||
}
|
||||
).then((adapter) => {
|
||||
this.adapter = adapter;
|
||||
this.adapter.onAppStateChange((isActive) => this.dispatchAppStateChange(isActive));
|
||||
return adapter;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import type { CallNotificationActionIntent } from '../logic/call-notification.rules';
|
||||
import { buildIncomingCallNotification, buildInCallNotification } from '../logic/call-notification.rules';
|
||||
import { buildMessageNotification } from '../logic/message-notification.rules';
|
||||
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
||||
import type { MobileNotificationAdapter } from '../contracts/mobile.contracts';
|
||||
import { WebMobileNotificationsAdapter } from '../adapters/web/web-mobile-notifications.adapter';
|
||||
@@ -44,6 +45,13 @@ export class MobileNotificationsService {
|
||||
await adapter.showCallNotification(buildInCallNotification(input));
|
||||
}
|
||||
|
||||
async showMessage(input: { title: string; body: string; tag?: string }): Promise<void> {
|
||||
await this.initialize();
|
||||
const adapter = await this.ensureAdapter();
|
||||
|
||||
await adapter.showMessageNotification(buildMessageNotification(input));
|
||||
}
|
||||
|
||||
async dismissIncomingCall(callId: string): Promise<void> {
|
||||
const adapter = await this.ensureAdapter();
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { Subject } from 'rxjs';
|
||||
import { ensureMobileCameraCapturePermissions, ensureMobileVoiceCapturePermissions } from '../../mobile/logic/ensure-mobile-capture-permissions';
|
||||
import { startMobileVoiceForegroundSession, stopMobileVoiceForegroundSession } from '../../mobile/logic/mobile-voice-foreground-session';
|
||||
import { ChatEvent } from '../../../shared-kernel';
|
||||
import { LatencyProfile } from '../realtime.constants';
|
||||
import { PeerData } from '../realtime.types';
|
||||
@@ -248,6 +249,7 @@ export class MediaManager {
|
||||
|
||||
this.isVoiceActive = true;
|
||||
this.voiceConnected$.next();
|
||||
void startMobileVoiceForegroundSession();
|
||||
return this.localMediaStream;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to getUserMedia', error);
|
||||
@@ -288,6 +290,7 @@ export class MediaManager {
|
||||
this.currentVoiceRoomId = undefined;
|
||||
this.currentVoiceServerId = undefined;
|
||||
this.allowedVoicePeerIds.clear();
|
||||
void stopMobileVoiceForegroundSession();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -315,6 +318,7 @@ export class MediaManager {
|
||||
this.bindLocalTracksToAllPeers();
|
||||
this.isVoiceActive = true;
|
||||
this.voiceConnected$.next();
|
||||
void startMobileVoiceForegroundSession();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
}
|
||||
|
||||
@if (showPanel() && isOpen()) {
|
||||
<div class="pointer-events-none fixed inset-0 z-[79]">
|
||||
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[79]">
|
||||
<section
|
||||
class="pointer-events-auto absolute flex min-h-0 flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
|
||||
[class.bottom-20]="!detached()"
|
||||
|
||||
+2
-2
@@ -4,9 +4,9 @@
|
||||
(dismissed)="cancelled.emit(undefined)"
|
||||
/>
|
||||
|
||||
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
|
||||
<div class="fixed metoyou-fixed-safe-viewport z-[111] flex items-center justify-center p-4 pointer-events-none">
|
||||
<div
|
||||
class="pointer-events-auto w-full max-w-2xl rounded-2xl border border-border bg-card shadow-2xl"
|
||||
class="pointer-events-auto max-h-full w-full max-w-2xl overflow-y-auto rounded-2xl border border-border bg-card shadow-2xl"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown.enter)="$event.stopPropagation()"
|
||||
(keydown.space)="$event.stopPropagation()"
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
(dismissed)="cancel()"
|
||||
/>
|
||||
|
||||
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
|
||||
<div class="fixed metoyou-fixed-safe-viewport z-[111] flex items-center justify-center p-4 pointer-events-none">
|
||||
<section
|
||||
appThemeNode="screenShareSourcePicker"
|
||||
class="pointer-events-auto w-full max-w-6xl rounded-2xl border border-border bg-card shadow-2xl"
|
||||
|
||||
Reference in New Issue
Block a user