feat: Add slashcommand api
This commit is contained in:
@@ -16,6 +16,7 @@ dependencies {
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capawesome-capacitor-app-update')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<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" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.metoyou.app;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Activity;
|
||||
import android.app.PictureInPictureParams;
|
||||
import android.content.Context;
|
||||
@@ -9,13 +10,57 @@ import android.os.Build;
|
||||
import android.util.Rational;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.PermissionState;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
import com.getcapacitor.annotation.Permission;
|
||||
import com.getcapacitor.annotation.PermissionCallback;
|
||||
|
||||
@CapacitorPlugin(name = "MetoyouMobile")
|
||||
@CapacitorPlugin(
|
||||
name = "MetoyouMobile",
|
||||
permissions = {
|
||||
@Permission(
|
||||
strings = { Manifest.permission.RECORD_AUDIO, Manifest.permission.MODIFY_AUDIO_SETTINGS },
|
||||
alias = MetoyouMobilePlugin.MICROPHONE
|
||||
),
|
||||
@Permission(strings = { Manifest.permission.CAMERA }, alias = MetoyouMobilePlugin.CAMERA)
|
||||
}
|
||||
)
|
||||
public class MetoyouMobilePlugin extends Plugin {
|
||||
static final String MICROPHONE = "microphone";
|
||||
static final String CAMERA = "camera";
|
||||
@PluginMethod
|
||||
public void requestVoiceCapturePermissions(PluginCall call) {
|
||||
if (getPermissionState(MICROPHONE) == PermissionState.GRANTED) {
|
||||
resolveCapturePermission(call, MICROPHONE);
|
||||
return;
|
||||
}
|
||||
|
||||
requestPermissionForAlias(MICROPHONE, call, "voiceCapturePermissionsCallback");
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void requestCameraCapturePermissions(PluginCall call) {
|
||||
if (getPermissionState(CAMERA) == PermissionState.GRANTED) {
|
||||
resolveCapturePermission(call, CAMERA);
|
||||
return;
|
||||
}
|
||||
|
||||
requestPermissionForAlias(CAMERA, call, "cameraCapturePermissionsCallback");
|
||||
}
|
||||
|
||||
@PermissionCallback
|
||||
private void voiceCapturePermissionsCallback(PluginCall call) {
|
||||
resolveCapturePermission(call, MICROPHONE);
|
||||
}
|
||||
|
||||
@PermissionCallback
|
||||
private void cameraCapturePermissionsCallback(PluginCall call) {
|
||||
resolveCapturePermission(call, CAMERA);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void setSpeakerphoneEnabled(PluginCall call) {
|
||||
Boolean enabled = call.getBoolean("enabled", false);
|
||||
@@ -112,4 +157,10 @@ public class MetoyouMobilePlugin extends Plugin {
|
||||
result.put("configured", configured);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
private void resolveCapturePermission(PluginCall call, String alias) {
|
||||
JSObject result = new JSObject();
|
||||
result.put(alias, getPermissionState(alias).toString());
|
||||
call.resolve(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,3 +22,6 @@ project(':capacitor-local-notifications').projectDir = new File('../../node_modu
|
||||
|
||||
include ':capacitor-push-notifications'
|
||||
project(':capacitor-push-notifications').projectDir = new File('../../node_modules/@capacitor/push-notifications/android')
|
||||
|
||||
include ':capawesome-capacitor-app-update'
|
||||
project(':capawesome-capacitor-app-update').projectDir = new File('../../node_modules/@capawesome/capacitor-app-update/android')
|
||||
|
||||
@@ -19,6 +19,7 @@ let package = Package(
|
||||
.package(name: "CapacitorFilesystem", path: "../../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorLocalNotifications", path: "../../../../node_modules/@capacitor/local-notifications"),
|
||||
.package(name: "CapacitorPushNotifications", path: "../../../../node_modules/@capacitor/push-notifications"),
|
||||
.package(name: "CapawesomeCapacitorAppUpdate", path: "../../../../node_modules/@capawesome/capacitor-app-update"),
|
||||
.package(name: "CapgoCapacitorAudioSession", path: "../../../../node_modules/@capgo/capacitor-audio-session")
|
||||
],
|
||||
targets: [
|
||||
@@ -34,6 +35,7 @@ let package = Package(
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
|
||||
.product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications"),
|
||||
.product(name: "CapawesomeCapacitorAppUpdate", package: "CapawesomeCapacitorAppUpdate"),
|
||||
.product(name: "CapgoCapacitorAudioSession", package: "CapgoCapacitorAudioSession")
|
||||
]
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@capacitor/ios": "^8.4.0",
|
||||
"@capacitor/local-notifications": "^8.2.0",
|
||||
"@capacitor/push-notifications": "^8.1.1",
|
||||
"@capawesome/capacitor-app-update": "^8.0.3",
|
||||
"@capgo/capacitor-audio-session": "^8.0.40"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# E2E All API Plugin
|
||||
|
||||
Fixture plugin for Playwright coverage. It calls every public Toju plugin API surface, registers UI contributions, writes storage, publishes events, creates plugin user data, and logs completion.
|
||||
Fixture plugin for Playwright coverage. It calls every public Toju plugin API surface, registers UI contributions, writes storage, publishes events, creates plugin user data, imports attachments/messages, exercises typing APIs, and logs completion.
|
||||
|
||||
It also registers two `/` slash commands: `/e2e-echo <message>` (server scope, echoes text back into the channel) and `/e2e-ping` (global scope, logs a pong), exercising `api.commands.register` / `api.commands.list`.
|
||||
|
||||
@@ -48,6 +48,29 @@ export async function activate(context) {
|
||||
embedType: 'e2e.coverage',
|
||||
render: (payload) => `E2E custom embed: ${payload?.title ?? 'missing title'}`
|
||||
}));
|
||||
context.subscriptions.push(api.commands.register('e2e-echo', {
|
||||
description: 'Echo the provided text back into the channel',
|
||||
icon: '📣',
|
||||
name: 'e2e-echo',
|
||||
options: [{ name: 'message', required: true, type: 'rest' }],
|
||||
run: (slashContext) => {
|
||||
api.messages.send(`E2E echo: ${slashContext.args.message || '(empty)'}`);
|
||||
},
|
||||
scope: 'server'
|
||||
}));
|
||||
context.subscriptions.push(api.commands.register('e2e-ping', {
|
||||
description: 'Log a pong from the plugin runtime',
|
||||
icon: '🏓',
|
||||
name: 'e2e-ping',
|
||||
run: (slashContext) => {
|
||||
api.logger.info(`E2E ping handled in ${slashContext.server?.name ?? 'no server'}`);
|
||||
},
|
||||
scope: 'global'
|
||||
}));
|
||||
api.commands.list();
|
||||
api.context.getCurrent();
|
||||
api.logger.debug('coverage debug');
|
||||
api.logger.error('coverage error');
|
||||
|
||||
const injectedBadge = document.createElement('div');
|
||||
|
||||
@@ -99,12 +122,15 @@ export async function activate(context) {
|
||||
api.roles.setAssignments([]);
|
||||
|
||||
api.channels.list();
|
||||
api.channels.addTextChannel({ id: 'e2e-text', name: 'E2E Text', position: 89 });
|
||||
api.channels.addAudioChannel({ id: 'e2e-audio', name: 'E2E Audio', position: 90 });
|
||||
api.channels.addVideoChannel({ id: 'e2e-video', name: 'E2E Video', position: 91 });
|
||||
api.channels.select('general');
|
||||
api.channels.rename('e2e-audio', 'E2E Audio Renamed');
|
||||
api.channels.remove('e2e-text');
|
||||
|
||||
api.server.getCurrent();
|
||||
await api.server.updateIcon('e2e-plugin-icon').catch((error) => api.logger.warn('server icon rejected', String(error)));
|
||||
api.server.updatePermissions({ allowVoice: true });
|
||||
api.server.updateSettings({
|
||||
name: api.server.getCurrent()?.name,
|
||||
@@ -129,6 +155,10 @@ export async function activate(context) {
|
||||
});
|
||||
api.messages.moderateDelete('missing-message-id');
|
||||
api.messages.sync(api.messages.readCurrent());
|
||||
await api.messages.import(api.messages.readCurrent()).catch((error) => api.logger.warn('message import rejected', String(error)));
|
||||
api.messages.setTyping(true);
|
||||
api.messages.setTyping(false);
|
||||
context.subscriptions.push(api.messages.subscribeTyping(() => {}));
|
||||
context.subscriptions.push(api.messageBus.subscribe({
|
||||
handler: () => {},
|
||||
latestMessageLimit: 5,
|
||||
@@ -147,6 +177,19 @@ export async function activate(context) {
|
||||
topic: 'e2e:latest'
|
||||
});
|
||||
|
||||
if (shouldMutateChat) {
|
||||
const sentMessage = api.messages.readCurrent().find((message) => message.content === editedMessage);
|
||||
|
||||
if (sentMessage) {
|
||||
const attachmentFile = new File(['plugin attachment'], 'e2e-plugin.txt', { type: 'text/plain' });
|
||||
|
||||
await api.attachments.import({
|
||||
files: [attachmentFile],
|
||||
messageId: sentMessage.id
|
||||
}).catch((error) => api.logger.warn('attachment import rejected', String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
api.p2p.connectedPeers();
|
||||
api.p2p.broadcastData('e2e:p2p', { ok: true });
|
||||
api.p2p.sendData('missing-peer', 'e2e:p2p', { ok: true });
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"ui.channelsSection",
|
||||
"ui.embeds",
|
||||
"ui.dom",
|
||||
"ui.commands",
|
||||
"storage.local",
|
||||
"storage.serverData.read",
|
||||
"storage.serverData.write",
|
||||
@@ -91,7 +92,8 @@
|
||||
"ui": {
|
||||
"settingsPages": ["coverage"],
|
||||
"sidePanels": ["coverage"],
|
||||
"channelSections": ["coverage"]
|
||||
"channelSections": ["coverage"],
|
||||
"slashCommands": ["e2e-echo", "e2e-ping"]
|
||||
},
|
||||
"pluginUser": {
|
||||
"displayName": "E2E Plugin Bot",
|
||||
|
||||
@@ -40,6 +40,7 @@ import { PluginBootstrapService } from './domains/plugins';
|
||||
import { DirectCallService } from './domains/direct-call';
|
||||
import {
|
||||
MobileAppLifecycleService,
|
||||
MobileAppUpdateService,
|
||||
MobileCallSessionService,
|
||||
MobilePersistenceService
|
||||
} from './infrastructure/mobile';
|
||||
@@ -190,6 +191,7 @@ export class App implements OnInit, OnDestroy {
|
||||
|
||||
private readonly mobilePersistence = inject(MobilePersistenceService);
|
||||
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
|
||||
private readonly mobileUpdates = inject(MobileAppUpdateService);
|
||||
private readonly mobileCallSession = inject(MobileCallSessionService);
|
||||
private deepLinkCleanup: (() => void) | null = null;
|
||||
private themeStudioControlsDragOffset: { x: number; y: number } | null = null;
|
||||
@@ -335,32 +337,6 @@ export class App implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs services that the user does not actively wait on. Scheduled
|
||||
* through `requestIdleCallback` so they yield to the renderer until
|
||||
* the browser is idle, eliminating bootstrap stutter on Electron.
|
||||
*/
|
||||
private kickOffBackgroundBootstrap(): void {
|
||||
runWhenIdle(() => {
|
||||
try {
|
||||
const apiBase = this.servers.getApiBaseUrl();
|
||||
|
||||
void this.timeSync.syncWithEndpoint(apiBase).catch(() => {});
|
||||
} catch {
|
||||
// getApiBaseUrl can throw before endpoints are hydrated; ignore.
|
||||
}
|
||||
|
||||
void this.notifications.initialize().catch(() => {});
|
||||
void this.mobilePersistence.initialize().catch(() => {});
|
||||
void this.mobileLifecycle.initialize().catch(() => {});
|
||||
this.mobileCallSession.initialize();
|
||||
void this.setupDesktopDeepLinks().catch(() => {});
|
||||
|
||||
this.userStatus.start();
|
||||
this.gameActivity.start();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.deepLinkCleanup?.();
|
||||
this.deepLinkCleanup = null;
|
||||
@@ -432,6 +408,33 @@ export class App implements OnInit, OnDestroy {
|
||||
await this.desktopUpdates.restartToApplyUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs services that the user does not actively wait on. Scheduled
|
||||
* through `requestIdleCallback` so they yield to the renderer until
|
||||
* the browser is idle, eliminating bootstrap stutter on Electron.
|
||||
*/
|
||||
private kickOffBackgroundBootstrap(): void {
|
||||
runWhenIdle(() => {
|
||||
try {
|
||||
const apiBase = this.servers.getApiBaseUrl();
|
||||
|
||||
void this.timeSync.syncWithEndpoint(apiBase).catch(() => {});
|
||||
} catch {
|
||||
// getApiBaseUrl can throw before endpoints are hydrated; ignore.
|
||||
}
|
||||
|
||||
void this.notifications.initialize().catch(() => {});
|
||||
void this.mobilePersistence.initialize().catch(() => {});
|
||||
void this.mobileLifecycle.initialize().catch(() => {});
|
||||
void this.mobileUpdates.initialize().catch(() => {});
|
||||
this.mobileCallSession.initialize();
|
||||
void this.setupDesktopDeepLinks().catch(() => {});
|
||||
|
||||
this.userStatus.start();
|
||||
this.gameActivity.start();
|
||||
});
|
||||
}
|
||||
|
||||
private clampThemeStudioControlsPosition(left: number, top: number, width: number, height: number): { x: number; y: number } {
|
||||
const minX = App.THEME_STUDIO_CONTROLS_MARGIN;
|
||||
const minY = App.TITLE_BAR_HEIGHT + App.THEME_STUDIO_CONTROLS_MARGIN;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { shouldShowMobileAppServersRail } from './mobile-shell-layout.rules';
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
const MOBILE_NO_APP_RAIL_PREFIXES = ['/login', '/register'] as const;
|
||||
|
||||
const MOBILE_EMBEDDED_RAIL_PREFIXES = ['/room/', '/dm', '/pm', '/call'] as const;
|
||||
const MOBILE_EMBEDDED_RAIL_PREFIXES = [
|
||||
'/room/',
|
||||
'/dm',
|
||||
'/pm',
|
||||
'/call'
|
||||
] as const;
|
||||
|
||||
/** Whether the mobile app shell should render the global servers rail for a route. */
|
||||
export function shouldShowMobileAppServersRail(routePath: string): boolean {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { decodeBase64ToUint8Array } from './attachment-blob.rules';
|
||||
|
||||
@@ -6,6 +10,10 @@ describe('attachment blob rules', () => {
|
||||
it('decodes base64 payloads into byte arrays', () => {
|
||||
const bytes = decodeBase64ToUint8Array('QUJD');
|
||||
|
||||
expect(Array.from(bytes)).toEqual([65, 66, 67]);
|
||||
expect(Array.from(bytes)).toEqual([
|
||||
65,
|
||||
66,
|
||||
67
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { annotateLocalFilePath, resolveLocalFilePath } from './local-file-path.r
|
||||
describe('local file path rules', () => {
|
||||
it('prefers an existing path property on the file', () => {
|
||||
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' });
|
||||
|
||||
Object.defineProperty(file, 'path', { value: '/tmp/clip.mp4' });
|
||||
|
||||
expect(resolveLocalFilePath(file)).toBe('/tmp/clip.mp4');
|
||||
|
||||
@@ -144,6 +144,10 @@ The chat domain consumes the custom emoji picker from `domains/custom-emoji`. Me
|
||||
|
||||
Custom image emoji are stored locally through `DatabaseService` and sync peer-to-peer with `custom-emoji-summary`, `custom-emoji-request`, `custom-emoji-full`, and `custom-emoji-chunk` data-channel events. Uploads use the same image types as profile avatars (`.webp`, `.gif`, `.jpg`, `.jpeg`) and are capped at 1 MB. The composer inserts saved custom emoji as readable inline aliases such as `:party:`, so they can sit in the middle of text like `This is :party: cool`; sending rewrites known aliases to stable `:emoji[id](name)` tokens and proactively pushes the referenced assets to connected peers alongside the outgoing message, edit, or reaction. Rendering resolves stable tokens against synced known assets and shows a sized placeholder image until the asset arrives; deferred markdown placeholders use readable `:name:` aliases instead of raw tokens. A repair request is still sent if a token is seen without a local asset. Seen remote emoji do not enter the picker automatically; right-click a custom emoji in chat or on a custom emoji reaction and choose **Add to emoji library** from the context menu. Right-click a saved custom emoji inside the picker to remove it from the local library. The full picker includes search that filters Unicode emoji by common terms and saved custom emoji by name.
|
||||
|
||||
### Slash commands
|
||||
|
||||
The composer renders a Discord-style autocomplete menu when the user types `/`. Results merge first-party built-in commands with plugin-registered commands (`PluginUiRegistryService.slashCommandRecords`), filtered by surface (`commandSurface` input: `server` exposes global + server commands, `direct` exposes only global) and by query. Built-in commands live in `chat-builtin-slash-commands.rules.ts`; each defines fixed `text` that is sent as a normal chat message through the composer's `messageSubmitted` output. The default built-in is `/lenny`, which posts `( ͡° ͜ʖ ͡°)`. Plugin commands run their own `run` callback instead. Picking a command with no options runs it immediately; a command with options pre-fills `/name ` for argument entry. Slash input is intercepted and never posted verbatim; `/text` that matches no command falls through as a normal message. See the plugins domain README for the `api.commands` registration contract.
|
||||
|
||||
## Domain rules
|
||||
|
||||
| Function | Purpose |
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import {
|
||||
BUILT_IN_SLASH_COMMANDS,
|
||||
BUILT_IN_SLASH_COMMAND_SOURCE,
|
||||
buildBuiltInSlashCommandEntries
|
||||
} from './chat-builtin-slash-commands.rules';
|
||||
|
||||
describe('built-in slash commands', () => {
|
||||
it('includes a global lenny command that sends the Lenny face', () => {
|
||||
const lenny = BUILT_IN_SLASH_COMMANDS.find((command) => command.name === 'lenny');
|
||||
|
||||
expect(lenny?.text).toBe('( ͡° ͜ʖ ͡°)');
|
||||
});
|
||||
|
||||
it('adapts definitions to global slash command entries tagged as built-in', () => {
|
||||
const entries = buildBuiltInSlashCommandEntries(() => {});
|
||||
const lenny = entries.find((entry) => entry.contribution.name === 'lenny');
|
||||
|
||||
expect(lenny?.pluginId).toBe(BUILT_IN_SLASH_COMMAND_SOURCE);
|
||||
expect(lenny?.id).toBe(`${BUILT_IN_SLASH_COMMAND_SOURCE}:lenny`);
|
||||
expect(lenny?.contribution.scope).toBe('global');
|
||||
});
|
||||
|
||||
it('runs the command by sending its text', () => {
|
||||
const sendText = vi.fn();
|
||||
const entries = buildBuiltInSlashCommandEntries(sendText);
|
||||
|
||||
entries.find((entry) => entry.contribution.name === 'lenny')?.contribution.run({
|
||||
args: {},
|
||||
command: 'lenny',
|
||||
rawArgs: '',
|
||||
server: null,
|
||||
source: 'slashCommand',
|
||||
textChannel: null,
|
||||
user: null,
|
||||
voiceChannel: null
|
||||
});
|
||||
|
||||
expect(sendText).toHaveBeenCalledWith('( ͡° ͜ʖ ͡°)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { SlashCommandEntry } from '../../../../../plugins';
|
||||
|
||||
/** Source label shown for built-in commands in the slash command menu. */
|
||||
export const BUILT_IN_SLASH_COMMAND_SOURCE = 'Built-in';
|
||||
|
||||
/** A first-party slash command that inserts fixed text into the chat as a message. */
|
||||
export interface BuiltInSlashCommand {
|
||||
description: string;
|
||||
icon?: string;
|
||||
name: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default commands available everywhere (chat servers and direct messages),
|
||||
* without requiring any plugin to be installed.
|
||||
*/
|
||||
export const BUILT_IN_SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
|
||||
{
|
||||
name: 'lenny',
|
||||
description: 'Send the Lenny face ( ͡° ͜ʖ ͡°)',
|
||||
text: '( ͡° ͜ʖ ͡°)'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Adapts the built-in command definitions to the `SlashCommandEntry` shape used
|
||||
* by the composer menu. Each entry's `run` sends the command's text through the
|
||||
* provided callback so it posts as a normal chat message.
|
||||
*/
|
||||
export function buildBuiltInSlashCommandEntries(sendText: (text: string) => void): SlashCommandEntry[] {
|
||||
return BUILT_IN_SLASH_COMMANDS.map((command) => ({
|
||||
contribution: {
|
||||
description: command.description,
|
||||
icon: command.icon,
|
||||
name: command.name,
|
||||
run: () => sendText(command.text),
|
||||
scope: 'global'
|
||||
},
|
||||
id: `${BUILT_IN_SLASH_COMMAND_SOURCE}:${command.name}`,
|
||||
pluginId: BUILT_IN_SLASH_COMMAND_SOURCE
|
||||
}));
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
||||
<div
|
||||
#composerRoot
|
||||
class="min-w-0 w-full"
|
||||
class="relative min-w-0 w-full"
|
||||
>
|
||||
@if (slashMenuOpen()) {
|
||||
<div class="pointer-events-auto absolute bottom-full left-0 right-0 z-30 mb-2 px-3 sm:px-4">
|
||||
<app-chat-slash-command-menu
|
||||
[commands]="slashCommandResults()"
|
||||
[activeIndex]="slashActiveIndex()"
|
||||
(commandPicked)="pickSlashCommand($event)"
|
||||
(activeIndexChanged)="onSlashActiveIndexChanged($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (replyTo()) {
|
||||
<div
|
||||
appThemeNode="chatComposerReplyBar"
|
||||
@@ -320,8 +331,9 @@
|
||||
[(ngModel)]="messageContent"
|
||||
(focus)="onInputFocus()"
|
||||
(blur)="onInputBlur()"
|
||||
(keydown)="onComposerKeydown($event)"
|
||||
(keydown.enter)="onEnter($event)"
|
||||
(input)="onInputChange(); autoResizeTextarea()"
|
||||
(input)="onInputChange(); autoResizeTextarea(); updateSlashCommandMenu()"
|
||||
(paste)="onPaste($event)"
|
||||
(dragenter)="onDragEnter($event)"
|
||||
(dragover)="onDragOver($event)"
|
||||
|
||||
@@ -30,7 +30,15 @@ import { Message } from '../../../../../../shared-kernel';
|
||||
import {
|
||||
PluginApiActionContribution,
|
||||
PluginClientApiService,
|
||||
PluginUiRegistryService
|
||||
PluginUiRegistryService,
|
||||
filterSlashCommands,
|
||||
findSlashCommand,
|
||||
parseSlashCommandArguments,
|
||||
parseSlashCommandInput,
|
||||
parseSlashCommandQuery,
|
||||
selectAvailableSlashCommands,
|
||||
type SlashCommandEntry,
|
||||
type SlashCommandSurface
|
||||
} from '../../../../../plugins';
|
||||
import { ThemeNodeDirective } from '../../../../../theme';
|
||||
import type { RoomSignalSourceInput } from '../../../../../server-directory';
|
||||
@@ -54,6 +62,8 @@ import {
|
||||
shouldMergeComposerMediaActions,
|
||||
type ComposerMediaMenuAction
|
||||
} from './composer-media-menu.rules';
|
||||
import { ChatSlashCommandMenuComponent } from './chat-slash-command-menu.component';
|
||||
import { buildBuiltInSlashCommandEntries } from './chat-builtin-slash-commands.rules';
|
||||
|
||||
type LocalFileWithPath = File & {
|
||||
path?: string;
|
||||
@@ -72,7 +82,8 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
|
||||
CustomEmojiPickerComponent,
|
||||
TypingIndicatorComponent,
|
||||
ThemeNodeDirective,
|
||||
BottomSheetComponent
|
||||
BottomSheetComponent,
|
||||
ChatSlashCommandMenuComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
@@ -104,6 +115,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
readonly klipyEnabled = input(false);
|
||||
readonly klipySignalSource = input<RoomSignalSourceInput | null>(null);
|
||||
readonly textareaTestId = input<string | null>(null);
|
||||
readonly commandSurface = input<SlashCommandSurface>('server');
|
||||
|
||||
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
|
||||
readonly typingStarted = output();
|
||||
@@ -138,6 +150,21 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
readonly showEmojiPicker = signal(false);
|
||||
readonly emojiButton = signal('🙂');
|
||||
readonly pluginComposerActions = this.pluginUi.composerActionRecords;
|
||||
readonly slashQuery = signal<string | null>(null);
|
||||
readonly slashActiveIndex = signal(0);
|
||||
private readonly builtInSlashEntries = buildBuiltInSlashCommandEntries((text) => this.sendBuiltInSlashText(text));
|
||||
readonly availableSlashCommands = computed(() =>
|
||||
selectAvailableSlashCommands(
|
||||
[...this.builtInSlashEntries, ...this.pluginUi.slashCommandRecords()],
|
||||
this.commandSurface()
|
||||
)
|
||||
);
|
||||
readonly slashCommandResults = computed(() => {
|
||||
const query = this.slashQuery();
|
||||
|
||||
return query === null ? [] : filterSlashCommands(this.availableSlashCommands(), query);
|
||||
});
|
||||
readonly slashMenuOpen = computed(() => this.slashCommandResults().length > 0);
|
||||
readonly toolbarVisible = signal(false);
|
||||
readonly dragActive = signal(false);
|
||||
readonly inputHovered = signal(false);
|
||||
@@ -166,6 +193,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
sendMessage(): void {
|
||||
const raw = this.messageContent.trim();
|
||||
|
||||
if (this.maybeRunSlashCommand(raw))
|
||||
return;
|
||||
|
||||
if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif())
|
||||
return;
|
||||
|
||||
@@ -206,6 +236,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
if (keyEvent.shiftKey)
|
||||
return;
|
||||
|
||||
if (this.slashMenuOpen())
|
||||
return;
|
||||
|
||||
keyEvent.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
@@ -348,6 +381,146 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
.then(() => action.run(this.pluginApi.createActionContext('composerAction')));
|
||||
}
|
||||
|
||||
updateSlashCommandMenu(): void {
|
||||
const query = parseSlashCommandQuery(this.messageContent);
|
||||
|
||||
this.slashQuery.set(query);
|
||||
this.slashActiveIndex.set(0);
|
||||
}
|
||||
|
||||
closeSlashCommandMenu(): void {
|
||||
this.slashQuery.set(null);
|
||||
}
|
||||
|
||||
onComposerKeydown(event: KeyboardEvent): void {
|
||||
if (!this.slashMenuOpen())
|
||||
return;
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.moveSlashActive(1);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.moveSlashActive(-1);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.closeSlashCommandMenu();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
const active = this.activeSlashCommand();
|
||||
|
||||
if (active) {
|
||||
event.preventDefault();
|
||||
this.pickSlashCommand(active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onSlashActiveIndexChanged(index: number): void {
|
||||
this.slashActiveIndex.set(index);
|
||||
}
|
||||
|
||||
pickSlashCommand(entry: SlashCommandEntry): void {
|
||||
const hasOptions = (entry.contribution.options?.length ?? 0) > 0;
|
||||
|
||||
if (hasOptions) {
|
||||
this.messageContent = `/${entry.contribution.name} `;
|
||||
this.closeSlashCommandMenu();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const element = this.messageInputRef?.nativeElement;
|
||||
|
||||
if (element) {
|
||||
const caret = this.messageContent.length;
|
||||
|
||||
element.focus();
|
||||
element.selectionStart = caret;
|
||||
element.selectionEnd = caret;
|
||||
}
|
||||
|
||||
this.autoResizeTextarea();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.executeSlashCommand(entry, '');
|
||||
this.resetComposerAfterCommand();
|
||||
}
|
||||
|
||||
private maybeRunSlashCommand(raw: string): boolean {
|
||||
const parsed = parseSlashCommandInput(raw);
|
||||
|
||||
if (!parsed)
|
||||
return false;
|
||||
|
||||
const entry = findSlashCommand(this.availableSlashCommands(), parsed.name);
|
||||
|
||||
if (!entry)
|
||||
return false;
|
||||
|
||||
this.executeSlashCommand(entry, parsed.rawArgs);
|
||||
this.resetComposerAfterCommand();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private sendBuiltInSlashText(text: string): void {
|
||||
this.messageSubmitted.emit({
|
||||
content: text,
|
||||
pendingFiles: []
|
||||
});
|
||||
|
||||
this.replyCleared.emit();
|
||||
}
|
||||
|
||||
private executeSlashCommand(entry: SlashCommandEntry, rawArgs: string): void {
|
||||
const args = parseSlashCommandArguments(rawArgs, entry.contribution.options ?? []);
|
||||
const context = this.pluginApi.createSlashCommandContext({
|
||||
args,
|
||||
command: entry.contribution.name,
|
||||
rawArgs
|
||||
});
|
||||
|
||||
void Promise.resolve().then(() => entry.contribution.run(context));
|
||||
}
|
||||
|
||||
private resetComposerAfterCommand(): void {
|
||||
this.messageContent = '';
|
||||
this.closeSlashCommandMenu();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.autoResizeTextarea();
|
||||
this.messageInputRef?.nativeElement.focus();
|
||||
});
|
||||
}
|
||||
|
||||
private moveSlashActive(delta: number): void {
|
||||
const total = this.slashCommandResults().length;
|
||||
|
||||
if (total === 0)
|
||||
return;
|
||||
|
||||
this.slashActiveIndex.update((current) => (current + delta + total) % total);
|
||||
}
|
||||
|
||||
private activeSlashCommand(): SlashCommandEntry | null {
|
||||
const results = this.slashCommandResults();
|
||||
|
||||
return results[this.slashActiveIndex()] ?? results[0] ?? null;
|
||||
}
|
||||
|
||||
getKlipyTriggerRect(): DOMRect | null {
|
||||
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
|
||||
}
|
||||
@@ -484,6 +657,8 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
if (!this.toolbarHovering) {
|
||||
this.toolbarVisible.set(false);
|
||||
}
|
||||
|
||||
this.closeSlashCommandMenu();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
@@ -702,10 +877,11 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
lastModified: payload.lastModified,
|
||||
type: payload.mime
|
||||
});
|
||||
const payloadPath = payload.path;
|
||||
|
||||
return annotateLocalFilePath(file, {
|
||||
getPathForFile: payload.path
|
||||
? () => payload.path!
|
||||
getPathForFile: payloadPath
|
||||
? () => payloadPath
|
||||
: this.electronBridge.getApi()?.getPathForFile
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
@if (commands().length > 0) {
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl border border-border bg-card/95 shadow-2xl shadow-black/30 backdrop-blur-xl"
|
||||
role="listbox"
|
||||
aria-label="Slash commands"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-border/60 px-4 py-2">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Commands</span>
|
||||
<span class="text-[11px] text-muted-foreground">{{ commands().length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="max-h-72 overflow-y-auto py-1">
|
||||
@for (entry of commands(); track entry.id; let index = $index) {
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
[attr.aria-selected]="index === activeIndex()"
|
||||
[attr.data-slash-active]="index === activeIndex()"
|
||||
class="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors"
|
||||
[class.bg-secondary]="index === activeIndex()"
|
||||
(mouseenter)="hover(index)"
|
||||
(mousedown)="$event.preventDefault(); pick(entry)"
|
||||
>
|
||||
<span
|
||||
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-border/70 bg-secondary/60 text-sm font-semibold text-foreground"
|
||||
>
|
||||
{{ badgeLabel(entry) }}
|
||||
</span>
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex items-baseline gap-2">
|
||||
<span class="truncate font-semibold text-foreground">/{{ entry.contribution.name }}</span>
|
||||
@if (usage(entry)) {
|
||||
<span class="truncate font-mono text-xs text-muted-foreground">{{ usage(entry) }}</span>
|
||||
}
|
||||
</span>
|
||||
@if (entry.contribution.description) {
|
||||
<span class="mt-0.5 block truncate text-xs text-muted-foreground">{{ entry.contribution.description }}</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="hidden shrink-0 rounded-full border border-border/60 px-2 py-0.5 text-[10px] uppercase tracking-[0.14em] text-muted-foreground sm:inline"
|
||||
>
|
||||
{{ entry.pluginId }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output
|
||||
} from '@angular/core';
|
||||
import type { PluginApiSlashCommandOption } from '../../../../../plugins';
|
||||
import type { SlashCommandEntry } from '../../../../../plugins';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-slash-command-menu',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './chat-slash-command-menu.component.html',
|
||||
host: {
|
||||
class: 'block'
|
||||
}
|
||||
})
|
||||
export class ChatSlashCommandMenuComponent {
|
||||
readonly commands = input<SlashCommandEntry[]>([]);
|
||||
readonly activeIndex = input<number>(0);
|
||||
|
||||
readonly commandPicked = output<SlashCommandEntry>();
|
||||
readonly activeIndexChanged = output<number>();
|
||||
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
// Re-run whenever the active row changes so it stays visible while
|
||||
// navigating the list with the keyboard.
|
||||
this.activeIndex();
|
||||
queueMicrotask(() => this.scrollActiveIntoView());
|
||||
});
|
||||
}
|
||||
|
||||
badgeLabel(entry: SlashCommandEntry): string {
|
||||
return entry.contribution.icon?.trim() || entry.contribution.name.charAt(0).toUpperCase() || '/';
|
||||
}
|
||||
|
||||
usage(entry: SlashCommandEntry): string {
|
||||
return (entry.contribution.options ?? [])
|
||||
.map((option) => this.formatOption(option))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
pick(entry: SlashCommandEntry): void {
|
||||
this.commandPicked.emit(entry);
|
||||
}
|
||||
|
||||
hover(index: number): void {
|
||||
this.activeIndexChanged.emit(index);
|
||||
}
|
||||
|
||||
private formatOption(option: PluginApiSlashCommandOption): string {
|
||||
return option.required ? `<${option.name}>` : `[${option.name}]`;
|
||||
}
|
||||
|
||||
private scrollActiveIntoView(): void {
|
||||
const active = this.host.nativeElement.querySelector<HTMLElement>('[data-slash-active="true"]');
|
||||
|
||||
active?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ChatMarkdownService } from './chat-markdown.service';
|
||||
|
||||
describe('ChatMarkdownService', () => {
|
||||
let service: ChatMarkdownService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new ChatMarkdownService();
|
||||
});
|
||||
|
||||
describe('applyInline', () => {
|
||||
it('wraps selected text with the token', () => {
|
||||
const result = service.applyInline('hello world', { start: 6, end: 11 }, '**');
|
||||
|
||||
expect(result.text).toBe('hello **world**');
|
||||
expect(result.selectionStart).toBe(result.selectionEnd);
|
||||
expect(result.selectionStart).toBe(result.text.length);
|
||||
});
|
||||
|
||||
it('inserts empty token pair with the cursor between markers when nothing is selected', () => {
|
||||
const result = service.applyInline('', { start: 0, end: 0 }, '**');
|
||||
|
||||
expect(result.text).toBe('****');
|
||||
expect(result.selectionStart).toBe(2);
|
||||
expect(result.selectionEnd).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPrefix', () => {
|
||||
it('prefixes each selected line', () => {
|
||||
const result = service.applyPrefix('line one\nline two', { start: 0, end: 17 }, '> ');
|
||||
|
||||
expect(result.text).toBe('> line one\n> line two');
|
||||
expect(result.selectionStart).toBe(result.text.length);
|
||||
});
|
||||
|
||||
it('inserts only the prefix with the cursor after it when nothing is selected', () => {
|
||||
const result = service.applyPrefix('', { start: 0, end: 0 }, '> ');
|
||||
|
||||
expect(result.text).toBe('> ');
|
||||
expect(result.selectionStart).toBe(2);
|
||||
expect(result.selectionEnd).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyHeading', () => {
|
||||
it('wraps selected text as a heading', () => {
|
||||
const result = service.applyHeading('intro\nTitle here\noutro', { start: 6, end: 16 }, 2);
|
||||
|
||||
expect(result.text).toBe('intro\n## Title here\noutro');
|
||||
});
|
||||
|
||||
it('inserts only the heading marker and space when nothing is selected', () => {
|
||||
const result = service.applyHeading('', { start: 0, end: 0 }, 1);
|
||||
|
||||
expect(result.text).toBe('# ');
|
||||
expect(result.selectionStart).toBe(2);
|
||||
expect(result.selectionEnd).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyOrderedList', () => {
|
||||
it('numbers each selected line', () => {
|
||||
const result = service.applyOrderedList('alpha\nbeta', { start: 0, end: 10 });
|
||||
|
||||
expect(result.text).toBe('1. alpha\n2. beta');
|
||||
});
|
||||
|
||||
it('inserts only the first list marker when nothing is selected', () => {
|
||||
const result = service.applyOrderedList('', { start: 0, end: 0 });
|
||||
|
||||
expect(result.text).toBe('1. ');
|
||||
expect(result.selectionStart).toBe(3);
|
||||
expect(result.selectionEnd).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyCodeBlock', () => {
|
||||
it('wraps selected text in a fenced code block', () => {
|
||||
const result = service.applyCodeBlock('before\nconst x = 1;\nafter', { start: 7, end: 19 });
|
||||
|
||||
expect(result.text).toBe('before\n```\nconst x = 1;\n```\n\n\nafter');
|
||||
});
|
||||
|
||||
it('inserts an empty fenced block with the cursor inside when nothing is selected', () => {
|
||||
const result = service.applyCodeBlock('', { start: 0, end: 0 });
|
||||
|
||||
expect(result.text).toBe('```\n\n```\n\n');
|
||||
expect(result.selectionStart).toBe(4);
|
||||
expect(result.selectionEnd).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyLink', () => {
|
||||
it('wraps selected text as link label and places the cursor in the url slot', () => {
|
||||
const result = service.applyLink('Visit docs', { start: 6, end: 10 });
|
||||
|
||||
expect(result.text).toBe('Visit [docs]()');
|
||||
expect(result.selectionStart).toBe(13);
|
||||
expect(result.selectionEnd).toBe(13);
|
||||
});
|
||||
|
||||
it('inserts empty link syntax with the cursor inside the label when nothing is selected', () => {
|
||||
const result = service.applyLink('', { start: 0, end: 0 });
|
||||
|
||||
expect(result.text).toBe('[]()');
|
||||
expect(result.selectionStart).toBe(1);
|
||||
expect(result.selectionEnd).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyImage', () => {
|
||||
it('wraps selected text as image alt text and places the cursor in the url slot', () => {
|
||||
const result = service.applyImage('logo', { start: 0, end: 4 });
|
||||
|
||||
expect(result.text).toBe('![logo]()');
|
||||
expect(result.selectionStart).toBe(8);
|
||||
expect(result.selectionEnd).toBe(8);
|
||||
});
|
||||
|
||||
it('inserts empty image syntax with the cursor inside the alt text when nothing is selected', () => {
|
||||
const result = service.applyImage('', { start: 0, end: 0 });
|
||||
|
||||
expect(result.text).toBe('![]()');
|
||||
expect(result.selectionStart).toBe(2);
|
||||
expect(result.selectionEnd).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyHorizontalRule', () => {
|
||||
it('inserts a horizontal rule without placeholder text', () => {
|
||||
const result = service.applyHorizontalRule('hello', { start: 5, end: 5 });
|
||||
|
||||
expect(result.text).toBe('hello\n\n---\n\n');
|
||||
expect(result.selectionStart).toBe(result.text.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,10 +16,12 @@ export class ChatMarkdownService {
|
||||
applyInline(content: string, selection: SelectionRange, token: string): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'text';
|
||||
const selected = content.slice(start, end);
|
||||
const after = content.slice(end);
|
||||
const newText = `${before}${token}${selected}${token}${after}`;
|
||||
const cursor = before.length + token.length + selected.length + token.length;
|
||||
const cursor = selected.length === 0
|
||||
? before.length + token.length
|
||||
: before.length + token.length + selected.length + token.length;
|
||||
|
||||
return { text: newText,
|
||||
selectionStart: cursor,
|
||||
@@ -29,7 +31,7 @@ export class ChatMarkdownService {
|
||||
applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'text';
|
||||
const selected = content.slice(start, end);
|
||||
const after = content.slice(end);
|
||||
const lines = selected.split('\n').map(line => `${prefix}${line}`);
|
||||
const newSelected = lines.join('\n');
|
||||
@@ -45,13 +47,13 @@ export class ChatMarkdownService {
|
||||
const hashes = '#'.repeat(Math.max(1, Math.min(6, level)));
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'Heading';
|
||||
const selected = content.slice(start, end);
|
||||
const after = content.slice(end);
|
||||
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
|
||||
const needsTrailingNewline = after.length > 0 && !after.startsWith('\n');
|
||||
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
|
||||
const text = `${before}${block}${after}`;
|
||||
const cursor = before.length + block.length;
|
||||
const cursor = before.length + block.length - (needsTrailingNewline ? 1 : 0);
|
||||
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
@@ -61,7 +63,7 @@ export class ChatMarkdownService {
|
||||
applyOrderedList(content: string, selection: SelectionRange): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'item\nitem';
|
||||
const selected = content.slice(start, end);
|
||||
const after = content.slice(end);
|
||||
const lines = selected.split('\n').map((line, index) => `${index + 1}. ${line}`);
|
||||
const newSelected = lines.join('\n');
|
||||
@@ -76,11 +78,15 @@ export class ChatMarkdownService {
|
||||
applyCodeBlock(content: string, selection: SelectionRange): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'code';
|
||||
const selected = content.slice(start, end);
|
||||
const after = content.slice(end);
|
||||
const fenced = `\`\`\`\n${selected}\n\`\`\`\n\n`;
|
||||
const fenced = selected.length === 0
|
||||
? '```\n\n```\n\n'
|
||||
: `\`\`\`\n${selected}\n\`\`\`\n\n`;
|
||||
const text = `${before}${fenced}${after}`;
|
||||
const cursor = before.length + fenced.length;
|
||||
const cursor = selected.length === 0
|
||||
? before.length + 4
|
||||
: before.length + fenced.length;
|
||||
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
@@ -90,30 +96,33 @@ export class ChatMarkdownService {
|
||||
applyLink(content: string, selection: SelectionRange): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'link';
|
||||
const selected = content.slice(start, end);
|
||||
const after = content.slice(end);
|
||||
const link = `[${selected}](https://)`;
|
||||
const link = `[${selected}]()`;
|
||||
const text = `${before}${link}${after}`;
|
||||
const cursorStart = before.length + link.length - 1;
|
||||
const cursor = selected.length === 0
|
||||
? before.length + 1
|
||||
: before.length + 1 + selected.length + 2;
|
||||
|
||||
// Position inside the URL placeholder
|
||||
return { text,
|
||||
selectionStart: cursorStart - 8,
|
||||
selectionEnd: cursorStart - 1 };
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyImage(content: string, selection: SelectionRange): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'alt';
|
||||
const selected = content.slice(start, end);
|
||||
const after = content.slice(end);
|
||||
const img = ``;
|
||||
const img = `![${selected}]()`;
|
||||
const text = `${before}${img}${after}`;
|
||||
const cursorStart = before.length + img.length - 1;
|
||||
const cursor = selected.length === 0
|
||||
? before.length + 2
|
||||
: before.length + 2 + selected.length + 2;
|
||||
|
||||
return { text,
|
||||
selectionStart: cursorStart - 8,
|
||||
selectionEnd: cursorStart - 1 };
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult {
|
||||
|
||||
@@ -81,12 +81,13 @@ export const UNICODE_EMOJI_PICKER_ENTRIES: readonly UnicodeEmojiPickerEntry[] =
|
||||
|
||||
export const DEFAULT_UNICODE_EMOJIS = UNICODE_EMOJI_PICKER_ENTRIES.map((entry) => entry.emoji);
|
||||
|
||||
export type ChatTextSegment = {
|
||||
export interface ChatTextSegment {
|
||||
kind: 'text' | 'emoji';
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
const UNICODE_EMOJI_IN_TEXT_PATTERN = /\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?)*|[#*0-9]\uFE0F?\u20E3/gu;
|
||||
const UNICODE_EMOJI_IN_TEXT_PATTERN =
|
||||
/\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?)*|[#*0-9]\uFE0F?\u20E3/gu;
|
||||
|
||||
export function splitTextIntoEmojiSegments(text: string): ChatTextSegment[] {
|
||||
if (!text) {
|
||||
@@ -94,6 +95,7 @@ export function splitTextIntoEmojiSegments(text: string): ChatTextSegment[] {
|
||||
}
|
||||
|
||||
const segments: ChatTextSegment[] = [];
|
||||
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of text.matchAll(UNICODE_EMOJI_IN_TEXT_PATTERN)) {
|
||||
@@ -106,6 +108,7 @@ export function splitTextIntoEmojiSegments(text: string): ChatTextSegment[] {
|
||||
|
||||
segments.push({ kind: 'emoji',
|
||||
value: match[0] });
|
||||
|
||||
lastIndex = index + match[0].length;
|
||||
}
|
||||
|
||||
@@ -114,8 +117,10 @@ export function splitTextIntoEmojiSegments(text: string): ChatTextSegment[] {
|
||||
value: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments : [{ kind: 'text',
|
||||
value: text }];
|
||||
return segments.length > 0 ? segments : [
|
||||
{ kind: 'text',
|
||||
value: text }
|
||||
];
|
||||
}
|
||||
|
||||
export interface CustomEmojiFileLike {
|
||||
|
||||
@@ -9,10 +9,7 @@ import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Subject } from 'rxjs';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import {
|
||||
MobileCallSessionService,
|
||||
MobileNotificationsService
|
||||
} from '../../../../infrastructure/mobile';
|
||||
import { MobileCallSessionService, MobileNotificationsService } from '../../../../infrastructure/mobile';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import {
|
||||
VoiceActivityService,
|
||||
|
||||
@@ -10,7 +10,11 @@ import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import { MobileCallSessionService, MobileNotificationsService } from '../../../../infrastructure/mobile';
|
||||
import {
|
||||
MobileCallSessionService,
|
||||
MobileMediaService,
|
||||
MobileNotificationsService
|
||||
} from '../../../../infrastructure/mobile';
|
||||
import {
|
||||
VoiceActivityService,
|
||||
VoiceConnectionFacade,
|
||||
@@ -43,6 +47,7 @@ export class DirectCallService {
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly mobileNotifications = inject(MobileNotificationsService);
|
||||
private readonly mobileCallSession = inject(MobileCallSessionService);
|
||||
private readonly mobileMedia = inject(MobileMediaService);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
||||
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
|
||||
@@ -324,6 +329,12 @@ export class DirectCallService {
|
||||
return;
|
||||
}
|
||||
|
||||
const voicePermissionsGranted = await this.mobileMedia.ensureVoiceCapturePermissions();
|
||||
|
||||
if (!voicePermissionsGranted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
[klipyEnabled]="klipyEnabled()"
|
||||
[klipySignalSource]="null"
|
||||
[textareaTestId]="'dm-input'"
|
||||
[commandSurface]="'direct'"
|
||||
(messageSubmitted)="handleMessageSubmitted($event)"
|
||||
(typingStarted)="handleTypingStarted()"
|
||||
(replyCleared)="clearReply()"
|
||||
|
||||
@@ -19,7 +19,7 @@ import { DmChatComponent } from '../dm-chat/dm-chat.component';
|
||||
templateUrl: './dm-chat-panel.component.html'
|
||||
})
|
||||
export class DmChatPanelComponent {
|
||||
private readonly theme = inject(ThemeService);
|
||||
|
||||
readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel'));
|
||||
|
||||
private readonly theme = inject(ThemeService);
|
||||
}
|
||||
|
||||
@@ -40,10 +40,7 @@ function createHarness(options: HarnessOptions = {}) {
|
||||
dispatch: vi.fn()
|
||||
} as unknown as Store;
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
FindPeopleComponent,
|
||||
{ provide: Store, useValue: store }
|
||||
]
|
||||
providers: [FindPeopleComponent, { provide: Store, useValue: store }]
|
||||
});
|
||||
const component = runInInjectionContext(injector, () => injector.get(FindPeopleComponent));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { jsonStorage } from '../../../infrastructure/persistence/json-storage.service';
|
||||
import type { Friend } from '../domain/models/direct-message.model';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { jsonStorage } from '../../../infrastructure/persistence/json-storage.service';
|
||||
|
||||
const STORAGE_PREFIX = 'metoyou_direct_message_queue';
|
||||
|
||||
@@ -17,13 +17,13 @@ const DEFAULT_EXPERIMENTAL_MEDIA_SETTINGS: ExperimentalMediaSettings = {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExperimentalMediaSettingsService {
|
||||
private readonly vlcRuntime = inject(ExperimentalVlcRuntimeService);
|
||||
private readonly storedSettings = loadExperimentalMediaSettings();
|
||||
|
||||
readonly vlcJsPlaybackEnabled = signal(false);
|
||||
readonly vlcJsRuntimeAvailable = signal(false);
|
||||
readonly vlcJsRuntimeStatus = signal<'checking' | 'available' | 'missing'>('checking');
|
||||
|
||||
private readonly vlcRuntime = inject(ExperimentalVlcRuntimeService);
|
||||
private readonly storedSettings = loadExperimentalMediaSettings();
|
||||
|
||||
constructor() {
|
||||
void this.refreshVlcRuntimeStatus();
|
||||
}
|
||||
|
||||
@@ -40,15 +40,15 @@ export class ExperimentalVlcPlayerComponent implements AfterViewInit, OnDestroy
|
||||
mime = input.required<string>();
|
||||
sizeLabel = input<string>('');
|
||||
|
||||
closed = output<void>();
|
||||
downloadRequested = output<void>();
|
||||
|
||||
private readonly runtime = inject(ExperimentalVlcRuntimeService);
|
||||
private playerHandle: ExperimentalVlcPlayerHandle | null = null;
|
||||
closed = output();
|
||||
downloadRequested = output();
|
||||
|
||||
readonly status = signal<'loading' | 'ready' | 'error'>('loading');
|
||||
readonly errorMessage = signal('');
|
||||
|
||||
private readonly runtime = inject(ExperimentalVlcRuntimeService);
|
||||
private playerHandle: ExperimentalVlcPlayerHandle | null = null;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
void this.loadPlayer();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { STORAGE_KEY_NOTIFICATION_SETTINGS } from '../../../../core/constants';
|
||||
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
|
||||
import { createDefaultNotificationSettings, type NotificationsSettings } from '../../domain/models/notification.model';
|
||||
|
||||
@@ -24,6 +24,8 @@ Plugins can inspect the current interaction context through `api.context.getCurr
|
||||
|
||||
Plugins can add quick actions to the server sidebar's View plugins menu with `api.ui.registerToolbarAction(id, { icon, label, run })`. The menu is rendered from the room side-panel plugin area as an overlay grid, and callbacks receive a `toolbarAction` interaction context.
|
||||
|
||||
Plugins can register `/` slash commands with `api.commands.register(id, { name, description, icon, options, scope, run })` (capability `ui.commands`). A command's `scope` is `global` (default — available in chat servers and direct messages) or `server` (only while a chat server is the active surface). The chat composer renders a Discord-style autocomplete menu when the user types `/`: results come from `PluginUiRegistryService.slashCommandRecords` filtered by surface via `selectAvailableSlashCommands` and by query via `filterSlashCommands` (both in `domain/logic/slash-command.rules.ts`). Picking a command (click, Enter, or Tab) either runs it immediately when it declares no options, or fills `/name ` so the user can type arguments before sending. On submit, `parseSlashCommandInput` + `findSlashCommand` resolve the command, `parseSlashCommandArguments` maps positional tokens (or a single `rest` option) to `args`, and `PluginClientApiService.createSlashCommandContext` builds a `slashCommand`-source context. Slash command input is intercepted in the composer and never sent as a chat message; unmatched `/text` falls through to a normal message. `api.commands.list()` returns every registered command across plugins.
|
||||
|
||||
Desktop plugin preferences that belong to the local user, including capability grants, disabled plugin ids, and previously activated plugin ids, are persisted through Electron's local database meta table with renderer localStorage as the browser fallback.
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
import { Injector, signal } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Subject } from 'rxjs';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { AttachmentFacade } from '../../../attachment';
|
||||
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
|
||||
import type {
|
||||
Channel,
|
||||
Message,
|
||||
Room,
|
||||
TojuPluginManifest,
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||
import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors';
|
||||
import {
|
||||
selectActiveChannelId,
|
||||
selectCurrentRoom,
|
||||
selectCurrentRoomChannels,
|
||||
selectCurrentRoomId
|
||||
} from '../../../../store/rooms/rooms.selectors';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import {
|
||||
PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS,
|
||||
assertPluginApiSurfaceImplemented,
|
||||
collectPluginApiMethodPaths,
|
||||
type PluginClientApiMethodPath
|
||||
} from '../../domain/logic/plugin-client-api-surface.rules';
|
||||
import { PluginCapabilityError, PluginCapabilityService } from './plugin-capability.service';
|
||||
import { PluginClientApiService } from './plugin-client-api.service';
|
||||
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
||||
import { PluginLoggerService } from './plugin-logger.service';
|
||||
import { PluginMessageBusService } from './plugin-message-bus.service';
|
||||
import { PluginStorageService } from './plugin-storage.service';
|
||||
import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
||||
|
||||
const TEST_MANIFEST = createTestManifest();
|
||||
|
||||
describe('PluginClientApiService', () => {
|
||||
let context: ServiceTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createServiceTestContext();
|
||||
context.capabilities.grantAll(TEST_MANIFEST);
|
||||
});
|
||||
|
||||
it('implements the full public plugin API surface', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
expect(() => assertPluginApiSurfaceImplemented(api as Record<string, Record<string, unknown>>)).not.toThrow();
|
||||
});
|
||||
|
||||
it('freezes the API object returned to plugins', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
expect(Object.isFrozen(api)).toBe(true);
|
||||
expect(Object.isFrozen(api.commands)).toBe(true);
|
||||
expect(Object.isFrozen(api.messages)).toBe(true);
|
||||
});
|
||||
|
||||
it('exposes current interaction context without capability checks', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
expect(api.context.getCurrent()).toEqual({
|
||||
server: context.room(),
|
||||
source: 'manual',
|
||||
textChannel: context.channels().find((channel) => channel.id === 'general') ?? null,
|
||||
user: context.currentUser(),
|
||||
voiceChannel: null
|
||||
});
|
||||
});
|
||||
|
||||
it('routes logger calls to the plugin logger service', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
api.logger.debug('debug');
|
||||
api.logger.error('error');
|
||||
api.logger.info('info');
|
||||
api.logger.warn('warn');
|
||||
|
||||
expect(context.logger.debug).toHaveBeenCalledWith(TEST_MANIFEST.id, 'debug', undefined);
|
||||
expect(context.logger.error).toHaveBeenCalledWith(TEST_MANIFEST.id, 'error', undefined);
|
||||
expect(context.logger.info).toHaveBeenCalledWith(TEST_MANIFEST.id, 'info', undefined);
|
||||
expect(context.logger.warn).toHaveBeenCalledWith(TEST_MANIFEST.id, 'warn', undefined);
|
||||
});
|
||||
|
||||
it('dispatches profile updates through the users store', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
api.profile.update({ displayName: 'Plugin User' });
|
||||
api.profile.updateAvatar({
|
||||
avatarHash: 'hash',
|
||||
avatarMime: 'image/png',
|
||||
avatarUrl: '/avatar.png'
|
||||
});
|
||||
|
||||
expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateCurrentUserProfile({
|
||||
profile: expect.objectContaining({ displayName: 'Plugin User' })
|
||||
}));
|
||||
|
||||
expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateCurrentUserAvatar({
|
||||
avatar: expect.objectContaining({ avatarUrl: '/avatar.png' })
|
||||
}));
|
||||
});
|
||||
|
||||
it('sends plugin messages and broadcasts them to peers', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
const message = api.messages.send('hello plugin');
|
||||
|
||||
expect(message.content).toBe('hello plugin');
|
||||
expect(message.roomId).toBe('room-1');
|
||||
expect(context.store.dispatch).toHaveBeenCalledWith(MessagesActions.sendMessageSuccess({ message }));
|
||||
expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'chat-message',
|
||||
message
|
||||
}));
|
||||
});
|
||||
|
||||
it('publishes typing state through the realtime facade', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
api.messages.setTyping(true, 'general');
|
||||
|
||||
expect(context.realtime.sendRawMessage).toHaveBeenCalledWith({
|
||||
type: 'typing',
|
||||
serverId: 'room-1',
|
||||
channelId: 'general',
|
||||
isTyping: true
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards slash command registration to the UI registry', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
const contribution = {
|
||||
name: 'echo',
|
||||
run: () => {}
|
||||
};
|
||||
|
||||
api.commands.register('echo', contribution);
|
||||
|
||||
expect(context.uiRegistry.registerSlashCommand).toHaveBeenCalledWith(
|
||||
TEST_MANIFEST.id,
|
||||
'echo',
|
||||
contribution
|
||||
);
|
||||
});
|
||||
|
||||
it('lists slash commands from the UI registry', () => {
|
||||
const commands = [{ name: 'echo', run: () => {} }];
|
||||
|
||||
context.uiRegistry.slashCommands.mockReturnValue(commands);
|
||||
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
expect(api.commands.list()).toBe(commands);
|
||||
});
|
||||
|
||||
it('routes storage APIs through the plugin storage service', async () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
api.storage.set('key', { ok: true });
|
||||
api.storage.get('key');
|
||||
api.storage.remove('key');
|
||||
await api.clientData.write('client-key', { ok: true });
|
||||
await api.clientData.read('client-key');
|
||||
await api.clientData.remove('client-key');
|
||||
await api.serverData.write('server-key', { ok: true });
|
||||
await api.serverData.read('server-key');
|
||||
await api.serverData.remove('server-key');
|
||||
|
||||
expect(context.storage.setLocal).toHaveBeenCalledWith(TEST_MANIFEST.id, 'key', { ok: true });
|
||||
expect(context.storage.getLocal).toHaveBeenCalledWith(TEST_MANIFEST.id, 'key');
|
||||
expect(context.storage.removeLocal).toHaveBeenCalledWith(TEST_MANIFEST.id, 'key');
|
||||
expect(context.storage.writeClientData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'client-key', { ok: true });
|
||||
expect(context.storage.readClientData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'client-key');
|
||||
expect(context.storage.removeClientData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'client-key');
|
||||
expect(context.storage.writeServerData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'server-key', { ok: true });
|
||||
expect(context.storage.readServerData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'server-key');
|
||||
expect(context.storage.removeServerData).toHaveBeenCalledWith(TEST_MANIFEST.id, 'server-key');
|
||||
});
|
||||
|
||||
it('publishes declared server events through the realtime facade', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
api.events.publishServer('e2e:server', { ok: true });
|
||||
|
||||
expect(context.realtime.sendRawMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'plugin_event',
|
||||
eventName: 'e2e:server',
|
||||
payload: { ok: true },
|
||||
pluginId: TEST_MANIFEST.id,
|
||||
serverId: 'room-1'
|
||||
}));
|
||||
});
|
||||
|
||||
it('rejects undeclared plugin events', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
expect(() => api.events.publishServer('missing:event', {})).toThrow(/did not declare event/);
|
||||
});
|
||||
|
||||
it('enforces capability grants for privileged API methods', async () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
context.capabilities.revokeAll(TEST_MANIFEST.id);
|
||||
|
||||
for (const path of collectPluginApiMethodPaths()) {
|
||||
const capability = PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS[path as PluginClientApiMethodPath];
|
||||
|
||||
if (!capability) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [namespace, method] = path.split('.') as [keyof typeof api, string];
|
||||
const target = api[namespace] as Record<string, (...args: unknown[]) => unknown>;
|
||||
|
||||
await expect(invokeApiMethod(target[method], path)).rejects.toThrow(PluginCapabilityError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface ServiceTestContext {
|
||||
capabilities: PluginCapabilityService;
|
||||
channels: ReturnType<typeof signal<Channel[]>>;
|
||||
currentUser: ReturnType<typeof signal<User | null>>;
|
||||
logger: {
|
||||
debug: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
info: ReturnType<typeof vi.fn>;
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
messages: ReturnType<typeof signal<Message[]>>;
|
||||
realtime: {
|
||||
onSignalingMessage: Subject<unknown>;
|
||||
sendRawMessage: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
room: ReturnType<typeof signal<Room | null>>;
|
||||
service: PluginClientApiService;
|
||||
storage: {
|
||||
getLocal: ReturnType<typeof vi.fn>;
|
||||
readClientData: ReturnType<typeof vi.fn>;
|
||||
readServerData: ReturnType<typeof vi.fn>;
|
||||
removeClientData: ReturnType<typeof vi.fn>;
|
||||
removeLocal: ReturnType<typeof vi.fn>;
|
||||
removeServerData: ReturnType<typeof vi.fn>;
|
||||
setLocal: ReturnType<typeof vi.fn>;
|
||||
writeClientData: ReturnType<typeof vi.fn>;
|
||||
writeServerData: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
store: {
|
||||
dispatch: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
uiRegistry: {
|
||||
registerSlashCommand: ReturnType<typeof vi.fn>;
|
||||
slashCommands: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
voice: {
|
||||
broadcastMessage: ReturnType<typeof vi.fn>;
|
||||
getConnectedPeers: ReturnType<typeof vi.fn>;
|
||||
setInputVolume: ReturnType<typeof vi.fn>;
|
||||
setLocalStream: ReturnType<typeof vi.fn>;
|
||||
setOutputVolume: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
function createServiceTestContext(): ServiceTestContext {
|
||||
installLocalStorageMock();
|
||||
installBrowserMediaMocks();
|
||||
|
||||
const currentUser = signal<User | null>(createUser());
|
||||
const users = signal<User[]>(currentUser() ? [currentUser() as User] : []);
|
||||
const room = signal<Room | null>(createRoom());
|
||||
const channels = signal<Channel[]>(room()?.channels ?? []);
|
||||
const messages = signal<Message[]>([]);
|
||||
const activeChannelId = signal('general');
|
||||
const roomId = signal('room-1');
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn()
|
||||
};
|
||||
const storage = {
|
||||
getLocal: vi.fn(),
|
||||
setLocal: vi.fn(),
|
||||
removeLocal: vi.fn(),
|
||||
readClientData: vi.fn(async () => null),
|
||||
writeClientData: vi.fn(async () => undefined),
|
||||
removeClientData: vi.fn(async () => undefined),
|
||||
readServerData: vi.fn(async () => null),
|
||||
writeServerData: vi.fn(async () => undefined),
|
||||
removeServerData: vi.fn(async () => undefined)
|
||||
};
|
||||
const uiRegistry = {
|
||||
registerSlashCommand: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
slashCommands: vi.fn(() => [])
|
||||
};
|
||||
const voice = {
|
||||
broadcastMessage: vi.fn(),
|
||||
getConnectedPeers: vi.fn(() => []),
|
||||
setInputVolume: vi.fn(),
|
||||
setLocalStream: vi.fn(async () => undefined),
|
||||
setOutputVolume: vi.fn()
|
||||
};
|
||||
const realtime = {
|
||||
onSignalingMessage: new Subject<unknown>(),
|
||||
sendRawMessage: vi.fn()
|
||||
};
|
||||
const store = {
|
||||
dispatch: vi.fn(),
|
||||
selectSignal: vi.fn((selector: unknown) => {
|
||||
if (selector === selectCurrentUser) {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
if (selector === selectAllUsers) {
|
||||
return users;
|
||||
}
|
||||
|
||||
if (selector === selectCurrentRoom) {
|
||||
return room;
|
||||
}
|
||||
|
||||
if (selector === selectCurrentRoomChannels) {
|
||||
return channels;
|
||||
}
|
||||
|
||||
if (selector === selectCurrentRoomMessages) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (selector === selectActiveChannelId) {
|
||||
return activeChannelId;
|
||||
}
|
||||
|
||||
if (selector === selectCurrentRoomId) {
|
||||
return roomId;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected selector in PluginClientApiService test: ${String(selector)}`);
|
||||
})
|
||||
};
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
PluginClientApiService,
|
||||
PluginCapabilityService,
|
||||
{
|
||||
provide: AttachmentFacade,
|
||||
useValue: {
|
||||
publishAttachments: vi.fn(async () => undefined),
|
||||
rememberMessageRoom: vi.fn()
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useValue: {
|
||||
saveMessage: vi.fn(async () => undefined),
|
||||
updateMessage: vi.fn(async () => undefined),
|
||||
updateRoom: vi.fn(async () => undefined)
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: PluginDesktopStateService,
|
||||
useValue: {
|
||||
readJson: vi.fn(async () => null),
|
||||
writeJson: vi.fn(async () => undefined)
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: PluginLoggerService,
|
||||
useValue: logger
|
||||
},
|
||||
{
|
||||
provide: PluginMessageBusService,
|
||||
useValue: {
|
||||
publish: vi.fn(() => ({ topic: 'test' })),
|
||||
sendLatestMessages: vi.fn(() => ({ topic: 'test' })),
|
||||
subscribe: vi.fn(() => ({ dispose: vi.fn() }))
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: PluginStorageService,
|
||||
useValue: storage
|
||||
},
|
||||
{
|
||||
provide: PluginUiRegistryService,
|
||||
useValue: uiRegistry
|
||||
},
|
||||
{
|
||||
provide: RealtimeSessionFacade,
|
||||
useValue: {
|
||||
broadcastMessage: vi.fn(),
|
||||
onSignalingMessage: realtime.onSignalingMessage.asObservable(),
|
||||
sendRawMessage: realtime.sendRawMessage
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: ServerDirectoryFacade,
|
||||
useValue: {
|
||||
updateServer: vi.fn(() => ({ subscribe: vi.fn() }))
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: store
|
||||
},
|
||||
{
|
||||
provide: VoiceConnectionFacade,
|
||||
useValue: voice
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
capabilities: injector.get(PluginCapabilityService),
|
||||
channels,
|
||||
currentUser,
|
||||
logger,
|
||||
messages,
|
||||
realtime,
|
||||
room,
|
||||
service: injector.get(PluginClientApiService),
|
||||
storage,
|
||||
store,
|
||||
uiRegistry,
|
||||
voice
|
||||
};
|
||||
}
|
||||
|
||||
function createTestManifest(): TojuPluginManifest {
|
||||
return {
|
||||
apiVersion: '1.0.0',
|
||||
capabilities: [
|
||||
'profile.read',
|
||||
'profile.write',
|
||||
'users.read',
|
||||
'users.manage',
|
||||
'roles.read',
|
||||
'roles.manage',
|
||||
'messages.read',
|
||||
'messages.send',
|
||||
'messages.editOwn',
|
||||
'messages.deleteOwn',
|
||||
'messages.moderate',
|
||||
'messages.sync',
|
||||
'channels.read',
|
||||
'channels.manage',
|
||||
'server.read',
|
||||
'server.manage',
|
||||
'p2p.data',
|
||||
'media.playAudio',
|
||||
'media.addAudioStream',
|
||||
'media.addVideoStream',
|
||||
'audio.volume',
|
||||
'ui.settings',
|
||||
'ui.pages',
|
||||
'ui.sidePanel',
|
||||
'ui.channelsSection',
|
||||
'ui.embeds',
|
||||
'ui.dom',
|
||||
'ui.commands',
|
||||
'storage.local',
|
||||
'storage.serverData.read',
|
||||
'storage.serverData.write',
|
||||
'events.server.publish',
|
||||
'events.server.subscribe',
|
||||
'events.p2p.publish',
|
||||
'events.p2p.subscribe'
|
||||
],
|
||||
compatibility: {
|
||||
minimumTojuVersion: '1.0.0'
|
||||
},
|
||||
description: 'Plugin API service test fixture',
|
||||
events: [
|
||||
{
|
||||
direction: 'serverRelay',
|
||||
eventName: 'e2e:server',
|
||||
scope: 'server'
|
||||
}
|
||||
],
|
||||
id: 'test.plugin-api',
|
||||
kind: 'client',
|
||||
schemaVersion: 1,
|
||||
title: 'Plugin API Service Fixture',
|
||||
version: '1.0.0'
|
||||
};
|
||||
}
|
||||
|
||||
function createUser(): User {
|
||||
return {
|
||||
displayName: 'Alice',
|
||||
id: 'user-1',
|
||||
isOnline: true,
|
||||
joinedAt: Date.now(),
|
||||
oderId: 'user-1',
|
||||
role: 'host',
|
||||
status: 'online',
|
||||
username: 'alice'
|
||||
};
|
||||
}
|
||||
|
||||
function createRoom(): Room {
|
||||
return {
|
||||
channels: [{ id: 'general', name: 'general', position: 0, type: 'text' }, { id: 'voice', name: 'voice', position: 1, type: 'voice' }],
|
||||
description: 'Plugin API room',
|
||||
hostId: 'user-1',
|
||||
id: 'room-1',
|
||||
isPrivate: false,
|
||||
members: [],
|
||||
name: 'Plugin API Room',
|
||||
roles: []
|
||||
};
|
||||
}
|
||||
|
||||
async function invokeApiMethod(method: (...args: unknown[]) => unknown, path: string): Promise<unknown> {
|
||||
switch (path) {
|
||||
case 'attachments.import':
|
||||
return method({ files: [], messageId: 'message-1' });
|
||||
case 'channels.addAudioChannel':
|
||||
return method({ name: 'Audio' });
|
||||
case 'channels.addTextChannel':
|
||||
return method({ name: 'Text' });
|
||||
case 'channels.addVideoChannel':
|
||||
return method({ name: 'Video' });
|
||||
case 'channels.remove':
|
||||
return method('general');
|
||||
case 'channels.rename':
|
||||
return method('general', 'renamed');
|
||||
case 'channels.select':
|
||||
return method('general');
|
||||
case 'clientData.read':
|
||||
case 'serverData.read':
|
||||
return method('key');
|
||||
case 'clientData.remove':
|
||||
case 'serverData.remove':
|
||||
return method('key');
|
||||
case 'clientData.write':
|
||||
case 'serverData.write':
|
||||
return method('key', { ok: true });
|
||||
case 'commands.register':
|
||||
return method('echo', { name: 'echo', run: () => {} });
|
||||
case 'events.publishP2p':
|
||||
case 'events.publishServer':
|
||||
return method('e2e:server', {});
|
||||
case 'events.subscribeP2p':
|
||||
case 'events.subscribeServer':
|
||||
return method({ eventName: 'e2e:server', handler: () => {} });
|
||||
case 'media.addCustomAudioStream':
|
||||
case 'media.addCustomVideoStream':
|
||||
return method({ stream: new MediaStream() });
|
||||
case 'media.playAudioClip':
|
||||
return method({ url: 'data:audio/wav;base64,' });
|
||||
case 'media.setInputVolume':
|
||||
case 'media.setOutputVolume':
|
||||
return method(0.5);
|
||||
case 'messageBus.publish':
|
||||
return method({ topic: 'test' });
|
||||
case 'messageBus.sendLatestMessages':
|
||||
return method({});
|
||||
case 'messageBus.subscribe':
|
||||
return method({ handler: () => {} });
|
||||
case 'messages.delete':
|
||||
return method('message-1');
|
||||
case 'messages.edit':
|
||||
return method('message-1', 'updated');
|
||||
case 'messages.moderateDelete':
|
||||
return method('message-1');
|
||||
case 'messages.import':
|
||||
return method([]);
|
||||
case 'messages.send':
|
||||
return method('hello');
|
||||
case 'messages.sendAsPluginUser':
|
||||
return method({ content: 'hello', pluginUserId: 'bot' });
|
||||
case 'messages.setTyping':
|
||||
return method(true);
|
||||
case 'messages.subscribeTyping':
|
||||
return method(() => {});
|
||||
case 'messages.sync':
|
||||
return method([]);
|
||||
case 'p2p.broadcastData':
|
||||
return method('e2e:server', {});
|
||||
case 'p2p.sendData':
|
||||
return method('peer-1', 'e2e:server', {});
|
||||
case 'profile.update':
|
||||
return method({ displayName: 'Updated' });
|
||||
case 'profile.updateAvatar':
|
||||
return method({ avatarHash: 'hash', avatarMime: 'image/png', avatarUrl: '/avatar.png' });
|
||||
case 'roles.setAssignments':
|
||||
return method([]);
|
||||
case 'server.registerPluginUser':
|
||||
return method({ displayName: 'Bot' });
|
||||
case 'server.updateIcon':
|
||||
return method('icon-hash');
|
||||
case 'server.updatePermissions':
|
||||
return method({ allowVoice: true });
|
||||
case 'server.updateSettings':
|
||||
return method({ name: 'Room' });
|
||||
case 'storage.get':
|
||||
return method('key');
|
||||
case 'storage.remove':
|
||||
return method('key');
|
||||
case 'storage.set':
|
||||
return method('key', { ok: true });
|
||||
case 'ui.mountElement':
|
||||
return method('mount', { element: { tagName: 'DIV' }, target: 'body' });
|
||||
case 'ui.registerAppPage':
|
||||
case 'ui.registerChannelSection':
|
||||
case 'ui.registerComposerAction':
|
||||
case 'ui.registerEmbedRenderer':
|
||||
case 'ui.registerProfileAction':
|
||||
case 'ui.registerSettingsPage':
|
||||
case 'ui.registerSidePanel':
|
||||
case 'ui.registerToolbarAction':
|
||||
return method('id', { label: 'Test', render: () => 'ok', run: () => {} });
|
||||
case 'users.ban':
|
||||
return method('user-2', 'reason');
|
||||
case 'users.kick':
|
||||
return method('user-2');
|
||||
case 'users.setRole':
|
||||
return method('user-2', 'member');
|
||||
default:
|
||||
return Promise.resolve(method());
|
||||
}
|
||||
}
|
||||
|
||||
function installBrowserMediaMocks(): void {
|
||||
vi.stubGlobal('MediaStream', class MediaStream {});
|
||||
vi.stubGlobal('Audio', class Audio {
|
||||
volume = 1;
|
||||
|
||||
async play(): Promise<void> {}
|
||||
});
|
||||
}
|
||||
|
||||
function installLocalStorageMock(): void {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
storage.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
PluginApiCustomStreamRequest,
|
||||
PluginApiMessageAsPluginUserRequest,
|
||||
PluginApiServerSettingsUpdate,
|
||||
PluginApiSlashCommandContext,
|
||||
PluginApiTypingEvent,
|
||||
TojuClientPluginApi,
|
||||
TojuPluginDisposable
|
||||
@@ -77,6 +78,16 @@ export class PluginClientApiService {
|
||||
const assertEvent = (eventName: string): void => this.assertDeclaredEvent(manifest, eventName);
|
||||
|
||||
return deepFreeze<TojuClientPluginApi>({
|
||||
commands: {
|
||||
list: () => {
|
||||
requireCapability('ui.commands');
|
||||
return this.uiRegistry.slashCommands();
|
||||
},
|
||||
register: (id, contribution) => {
|
||||
requireCapability('ui.commands');
|
||||
return this.uiRegistry.registerSlashCommand(pluginId, id, contribution);
|
||||
}
|
||||
},
|
||||
channels: {
|
||||
addAudioChannel: (request) => {
|
||||
requireCapability('channels.manage');
|
||||
@@ -513,6 +524,15 @@ export class PluginClientApiService {
|
||||
};
|
||||
}
|
||||
|
||||
createSlashCommandContext(request: { args: Record<string, string>; command: string; rawArgs: string }): PluginApiSlashCommandContext {
|
||||
return {
|
||||
...this.createActionContext('slashCommand'),
|
||||
args: request.args,
|
||||
command: request.command,
|
||||
rawArgs: request.rawArgs
|
||||
};
|
||||
}
|
||||
|
||||
private assertDeclaredEvent(manifest: TojuPluginManifest, eventName: string): void {
|
||||
const declared = manifest.events?.some((event) => event.eventName === eventName) ?? false;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
PluginApiPageContribution,
|
||||
PluginApiPanelContribution,
|
||||
PluginApiSettingsPageContribution,
|
||||
PluginApiSlashCommandContribution,
|
||||
PluginApiUiContributionMap,
|
||||
TojuPluginDisposable
|
||||
} from '../../domain/models/plugin-api.models';
|
||||
@@ -53,6 +54,8 @@ export class PluginUiRegistryService {
|
||||
readonly settingsPageRecords = this.createContributionRecordSignal('settingsPages');
|
||||
readonly sidePanels = this.createContributionSignal('sidePanels');
|
||||
readonly sidePanelRecords = this.createContributionRecordSignal('sidePanels');
|
||||
readonly slashCommands = this.createContributionSignal('slashCommands');
|
||||
readonly slashCommandRecords = this.createContributionRecordSignal('slashCommands');
|
||||
readonly toolbarActions = this.createContributionSignal('toolbarActions');
|
||||
readonly toolbarActionRecords = this.createContributionRecordSignal('toolbarActions');
|
||||
readonly conflicts = computed(() => this.collectConflicts());
|
||||
@@ -66,6 +69,7 @@ export class PluginUiRegistryService {
|
||||
profileActions: PluginUiContributionRecord<PluginApiActionContribution>[];
|
||||
settingsPages: PluginUiContributionRecord<PluginApiSettingsPageContribution>[];
|
||||
sidePanels: PluginUiContributionRecord<PluginApiPanelContribution>[];
|
||||
slashCommands: PluginUiContributionRecord<PluginApiSlashCommandContribution>[];
|
||||
toolbarActions: PluginUiContributionRecord<PluginApiActionContribution>[];
|
||||
}>({
|
||||
appPages: [],
|
||||
@@ -75,6 +79,7 @@ export class PluginUiRegistryService {
|
||||
profileActions: [],
|
||||
settingsPages: [],
|
||||
sidePanels: [],
|
||||
slashCommands: [],
|
||||
toolbarActions: []
|
||||
});
|
||||
|
||||
@@ -125,6 +130,10 @@ export class PluginUiRegistryService {
|
||||
return this.register('sidePanels', pluginId, id, contribution);
|
||||
}
|
||||
|
||||
registerSlashCommand(pluginId: string, id: string, contribution: PluginApiSlashCommandContribution): TojuPluginDisposable {
|
||||
return this.register('slashCommands', pluginId, id, contribution);
|
||||
}
|
||||
|
||||
registerToolbarAction(pluginId: string, id: string, contribution: PluginApiActionContribution): TojuPluginDisposable {
|
||||
return this.register('toolbarActions', pluginId, id, contribution);
|
||||
}
|
||||
@@ -144,6 +153,7 @@ export class PluginUiRegistryService {
|
||||
profileActions: current.profileActions.filter((entry) => entry.pluginId !== pluginId),
|
||||
settingsPages: current.settingsPages.filter((entry) => entry.pluginId !== pluginId),
|
||||
sidePanels: current.sidePanels.filter((entry) => entry.pluginId !== pluginId),
|
||||
slashCommands: current.slashCommands.filter((entry) => entry.pluginId !== pluginId),
|
||||
toolbarActions: current.toolbarActions.filter((entry) => entry.pluginId !== pluginId)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { TojuPluginManifest } from '../../../../shared-kernel';
|
||||
import { PLUGIN_CAPABILITIES } from '../../../../shared-kernel';
|
||||
import {
|
||||
PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS,
|
||||
PLUGIN_CLIENT_API_SURFACE,
|
||||
assertPluginApiSurfaceImplemented,
|
||||
collectPluginApiMethodPaths,
|
||||
collectRequiredPluginApiCapabilities
|
||||
} from './plugin-client-api-surface.rules';
|
||||
|
||||
const E2E_ALL_API_MANIFEST_PATH = resolve(
|
||||
process.cwd(),
|
||||
'public/plugins/e2e-all-api/toju.plugin.json'
|
||||
);
|
||||
|
||||
describe('plugin client API surface rules', () => {
|
||||
it('lists every documented namespace and method', () => {
|
||||
expect(collectPluginApiMethodPaths()).toEqual([
|
||||
'attachments.import',
|
||||
'channels.addAudioChannel',
|
||||
'channels.addTextChannel',
|
||||
'channels.addVideoChannel',
|
||||
'channels.list',
|
||||
'channels.remove',
|
||||
'channels.rename',
|
||||
'channels.select',
|
||||
'clientData.read',
|
||||
'clientData.remove',
|
||||
'clientData.write',
|
||||
'commands.list',
|
||||
'commands.register',
|
||||
'context.getCurrent',
|
||||
'events.publishP2p',
|
||||
'events.publishServer',
|
||||
'events.subscribeP2p',
|
||||
'events.subscribeServer',
|
||||
'logger.debug',
|
||||
'logger.error',
|
||||
'logger.info',
|
||||
'logger.warn',
|
||||
'media.addCustomAudioStream',
|
||||
'media.addCustomVideoStream',
|
||||
'media.playAudioClip',
|
||||
'media.setInputVolume',
|
||||
'media.setOutputVolume',
|
||||
'messageBus.publish',
|
||||
'messageBus.sendLatestMessages',
|
||||
'messageBus.subscribe',
|
||||
'messages.delete',
|
||||
'messages.edit',
|
||||
'messages.import',
|
||||
'messages.moderateDelete',
|
||||
'messages.readCurrent',
|
||||
'messages.send',
|
||||
'messages.sendAsPluginUser',
|
||||
'messages.setTyping',
|
||||
'messages.subscribeTyping',
|
||||
'messages.sync',
|
||||
'p2p.broadcastData',
|
||||
'p2p.connectedPeers',
|
||||
'p2p.sendData',
|
||||
'profile.getCurrent',
|
||||
'profile.update',
|
||||
'profile.updateAvatar',
|
||||
'roles.list',
|
||||
'roles.setAssignments',
|
||||
'server.getCurrent',
|
||||
'server.registerPluginUser',
|
||||
'server.updateIcon',
|
||||
'server.updatePermissions',
|
||||
'server.updateSettings',
|
||||
'serverData.read',
|
||||
'serverData.remove',
|
||||
'serverData.write',
|
||||
'storage.get',
|
||||
'storage.remove',
|
||||
'storage.set',
|
||||
'ui.mountElement',
|
||||
'ui.registerAppPage',
|
||||
'ui.registerChannelSection',
|
||||
'ui.registerComposerAction',
|
||||
'ui.registerEmbedRenderer',
|
||||
'ui.registerProfileAction',
|
||||
'ui.registerSettingsPage',
|
||||
'ui.registerSidePanel',
|
||||
'ui.registerToolbarAction',
|
||||
'users.ban',
|
||||
'users.getCurrent',
|
||||
'users.kick',
|
||||
'users.list',
|
||||
'users.readMembers',
|
||||
'users.setRole'
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps privileged methods to known plugin capabilities', () => {
|
||||
for (const capability of Object.values(PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS)) {
|
||||
expect(PLUGIN_CAPABILITIES).toContain(capability);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires a capability for every privileged namespace method', () => {
|
||||
const privilegedNamespaces = Object.entries(PLUGIN_CLIENT_API_SURFACE)
|
||||
.filter(([namespace]) => !['context', 'logger'].includes(namespace));
|
||||
|
||||
for (const [namespace, methods] of privilegedNamespaces) {
|
||||
for (const method of methods) {
|
||||
const path = `${namespace}.${method}`;
|
||||
|
||||
expect(PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS).toHaveProperty(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('detects missing API methods', () => {
|
||||
expect(() => assertPluginApiSurfaceImplemented({
|
||||
commands: { list: () => [] }
|
||||
})).toThrow(/Plugin API surface is incomplete/);
|
||||
});
|
||||
|
||||
it('accepts a fully implemented API object', () => {
|
||||
const api = Object.fromEntries(
|
||||
Object.entries(PLUGIN_CLIENT_API_SURFACE).map(([namespace, methods]) => {
|
||||
const namespaceApi = Object.fromEntries(methods.map((method) => [method, () => undefined]));
|
||||
|
||||
return [namespace, namespaceApi];
|
||||
})
|
||||
);
|
||||
|
||||
expect(() => assertPluginApiSurfaceImplemented(api)).not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps the E2E all-api fixture manifest granted for full API coverage', () => {
|
||||
const manifest = JSON.parse(readFileSync(E2E_ALL_API_MANIFEST_PATH, 'utf8')) as TojuPluginManifest;
|
||||
|
||||
expect(manifest.capabilities ?? []).toEqual(expect.arrayContaining(collectRequiredPluginApiCapabilities()));
|
||||
});
|
||||
|
||||
it('collects the full capability set needed for API coverage', () => {
|
||||
expect(collectRequiredPluginApiCapabilities()).toEqual([
|
||||
'audio.volume',
|
||||
'channels.manage',
|
||||
'channels.read',
|
||||
'events.p2p.publish',
|
||||
'events.p2p.subscribe',
|
||||
'events.server.publish',
|
||||
'events.server.subscribe',
|
||||
'media.addAudioStream',
|
||||
'media.addVideoStream',
|
||||
'media.playAudio',
|
||||
'messages.deleteOwn',
|
||||
'messages.editOwn',
|
||||
'messages.moderate',
|
||||
'messages.read',
|
||||
'messages.send',
|
||||
'messages.sync',
|
||||
'p2p.data',
|
||||
'profile.read',
|
||||
'profile.write',
|
||||
'roles.manage',
|
||||
'roles.read',
|
||||
'server.manage',
|
||||
'server.read',
|
||||
'storage.local',
|
||||
'storage.serverData.read',
|
||||
'storage.serverData.write',
|
||||
'ui.channelsSection',
|
||||
'ui.commands',
|
||||
'ui.dom',
|
||||
'ui.embeds',
|
||||
'ui.pages',
|
||||
'ui.settings',
|
||||
'ui.sidePanel',
|
||||
'users.manage',
|
||||
'users.read'
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { PluginCapabilityId } from '../../../../shared-kernel';
|
||||
|
||||
/**
|
||||
* Canonical registry of every public `TojuClientPluginApi` namespace and method.
|
||||
* Keep this list aligned with `plugin-api.models.ts` when the contract changes.
|
||||
*/
|
||||
export const PLUGIN_CLIENT_API_SURFACE = {
|
||||
attachments: ['import'],
|
||||
channels: [
|
||||
'addAudioChannel',
|
||||
'addTextChannel',
|
||||
'addVideoChannel',
|
||||
'list',
|
||||
'remove',
|
||||
'rename',
|
||||
'select'
|
||||
],
|
||||
clientData: [
|
||||
'read',
|
||||
'remove',
|
||||
'write'
|
||||
],
|
||||
commands: ['list', 'register'],
|
||||
context: ['getCurrent'],
|
||||
events: [
|
||||
'publishP2p',
|
||||
'publishServer',
|
||||
'subscribeP2p',
|
||||
'subscribeServer'
|
||||
],
|
||||
logger: [
|
||||
'debug',
|
||||
'error',
|
||||
'info',
|
||||
'warn'
|
||||
],
|
||||
media: [
|
||||
'addCustomAudioStream',
|
||||
'addCustomVideoStream',
|
||||
'playAudioClip',
|
||||
'setInputVolume',
|
||||
'setOutputVolume'
|
||||
],
|
||||
messageBus: [
|
||||
'publish',
|
||||
'sendLatestMessages',
|
||||
'subscribe'
|
||||
],
|
||||
messages: [
|
||||
'delete',
|
||||
'edit',
|
||||
'import',
|
||||
'moderateDelete',
|
||||
'readCurrent',
|
||||
'send',
|
||||
'sendAsPluginUser',
|
||||
'setTyping',
|
||||
'subscribeTyping',
|
||||
'sync'
|
||||
],
|
||||
p2p: [
|
||||
'broadcastData',
|
||||
'connectedPeers',
|
||||
'sendData'
|
||||
],
|
||||
profile: [
|
||||
'getCurrent',
|
||||
'update',
|
||||
'updateAvatar'
|
||||
],
|
||||
roles: ['list', 'setAssignments'],
|
||||
server: [
|
||||
'getCurrent',
|
||||
'registerPluginUser',
|
||||
'updateIcon',
|
||||
'updatePermissions',
|
||||
'updateSettings'
|
||||
],
|
||||
serverData: [
|
||||
'read',
|
||||
'remove',
|
||||
'write'
|
||||
],
|
||||
storage: [
|
||||
'get',
|
||||
'remove',
|
||||
'set'
|
||||
],
|
||||
ui: [
|
||||
'mountElement',
|
||||
'registerAppPage',
|
||||
'registerChannelSection',
|
||||
'registerComposerAction',
|
||||
'registerEmbedRenderer',
|
||||
'registerProfileAction',
|
||||
'registerSettingsPage',
|
||||
'registerSidePanel',
|
||||
'registerToolbarAction'
|
||||
],
|
||||
users: [
|
||||
'ban',
|
||||
'getCurrent',
|
||||
'kick',
|
||||
'list',
|
||||
'readMembers',
|
||||
'setRole'
|
||||
]
|
||||
} as const;
|
||||
|
||||
export type PluginClientApiNamespace = keyof typeof PLUGIN_CLIENT_API_SURFACE;
|
||||
export type PluginClientApiMethodPath = {
|
||||
[Namespace in PluginClientApiNamespace]: `${Namespace & string}.${typeof PLUGIN_CLIENT_API_SURFACE[Namespace][number]}`;
|
||||
}[PluginClientApiNamespace];
|
||||
|
||||
/**
|
||||
* Capability required before a method may run. Methods omitted here are always available.
|
||||
*/
|
||||
export const PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS: Partial<Record<PluginClientApiMethodPath, PluginCapabilityId>> = {
|
||||
'attachments.import': 'messages.sync',
|
||||
'channels.addAudioChannel': 'channels.manage',
|
||||
'channels.addTextChannel': 'channels.manage',
|
||||
'channels.addVideoChannel': 'channels.manage',
|
||||
'channels.list': 'channels.read',
|
||||
'channels.remove': 'channels.manage',
|
||||
'channels.rename': 'channels.manage',
|
||||
'channels.select': 'channels.read',
|
||||
'clientData.read': 'storage.local',
|
||||
'clientData.remove': 'storage.local',
|
||||
'clientData.write': 'storage.local',
|
||||
'commands.list': 'ui.commands',
|
||||
'commands.register': 'ui.commands',
|
||||
'events.publishP2p': 'events.p2p.publish',
|
||||
'events.publishServer': 'events.server.publish',
|
||||
'events.subscribeP2p': 'events.p2p.subscribe',
|
||||
'events.subscribeServer': 'events.server.subscribe',
|
||||
'media.addCustomAudioStream': 'media.addAudioStream',
|
||||
'media.addCustomVideoStream': 'media.addVideoStream',
|
||||
'media.playAudioClip': 'media.playAudio',
|
||||
'media.setInputVolume': 'audio.volume',
|
||||
'media.setOutputVolume': 'audio.volume',
|
||||
'messageBus.publish': 'events.p2p.publish',
|
||||
'messageBus.sendLatestMessages': 'events.p2p.publish',
|
||||
'messageBus.subscribe': 'events.p2p.subscribe',
|
||||
'messages.delete': 'messages.deleteOwn',
|
||||
'messages.edit': 'messages.editOwn',
|
||||
'messages.import': 'messages.sync',
|
||||
'messages.moderateDelete': 'messages.moderate',
|
||||
'messages.readCurrent': 'messages.read',
|
||||
'messages.send': 'messages.send',
|
||||
'messages.sendAsPluginUser': 'messages.send',
|
||||
'messages.setTyping': 'messages.send',
|
||||
'messages.subscribeTyping': 'messages.read',
|
||||
'messages.sync': 'messages.sync',
|
||||
'p2p.broadcastData': 'p2p.data',
|
||||
'p2p.connectedPeers': 'p2p.data',
|
||||
'p2p.sendData': 'p2p.data',
|
||||
'profile.getCurrent': 'profile.read',
|
||||
'profile.update': 'profile.write',
|
||||
'profile.updateAvatar': 'profile.write',
|
||||
'roles.list': 'roles.read',
|
||||
'roles.setAssignments': 'roles.manage',
|
||||
'server.getCurrent': 'server.read',
|
||||
'server.registerPluginUser': 'users.manage',
|
||||
'server.updateIcon': 'server.manage',
|
||||
'server.updatePermissions': 'server.manage',
|
||||
'server.updateSettings': 'server.manage',
|
||||
'serverData.read': 'storage.serverData.read',
|
||||
'serverData.remove': 'storage.serverData.write',
|
||||
'serverData.write': 'storage.serverData.write',
|
||||
'storage.get': 'storage.local',
|
||||
'storage.remove': 'storage.local',
|
||||
'storage.set': 'storage.local',
|
||||
'ui.mountElement': 'ui.dom',
|
||||
'ui.registerAppPage': 'ui.pages',
|
||||
'ui.registerChannelSection': 'ui.channelsSection',
|
||||
'ui.registerComposerAction': 'ui.pages',
|
||||
'ui.registerEmbedRenderer': 'ui.embeds',
|
||||
'ui.registerProfileAction': 'ui.pages',
|
||||
'ui.registerSettingsPage': 'ui.settings',
|
||||
'ui.registerSidePanel': 'ui.sidePanel',
|
||||
'ui.registerToolbarAction': 'ui.pages',
|
||||
'users.ban': 'users.manage',
|
||||
'users.getCurrent': 'users.read',
|
||||
'users.kick': 'users.manage',
|
||||
'users.list': 'users.read',
|
||||
'users.readMembers': 'users.read',
|
||||
'users.setRole': 'roles.manage'
|
||||
};
|
||||
|
||||
export function collectPluginApiMethodPaths(
|
||||
surface: typeof PLUGIN_CLIENT_API_SURFACE = PLUGIN_CLIENT_API_SURFACE
|
||||
): PluginClientApiMethodPath[] {
|
||||
return Object.entries(surface).flatMap(([namespace, methods]) =>
|
||||
methods.map((method) => `${namespace}.${method}` as PluginClientApiMethodPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function getPluginApiMethod(
|
||||
api: Record<string, Record<string, unknown>>,
|
||||
path: PluginClientApiMethodPath
|
||||
): unknown {
|
||||
const [namespace, method] = path.split('.') as [PluginClientApiNamespace, string];
|
||||
|
||||
return api[namespace]?.[method];
|
||||
}
|
||||
|
||||
export function assertPluginApiSurfaceImplemented(
|
||||
api: Record<string, Record<string, unknown>>,
|
||||
surface: typeof PLUGIN_CLIENT_API_SURFACE = PLUGIN_CLIENT_API_SURFACE
|
||||
): void {
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const path of collectPluginApiMethodPaths(surface)) {
|
||||
const method = getPluginApiMethod(api, path);
|
||||
|
||||
if (typeof method !== 'function') {
|
||||
missing.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Plugin API surface is incomplete: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function collectRequiredPluginApiCapabilities(): PluginCapabilityId[] {
|
||||
return Array.from(new Set(Object.values(PLUGIN_CLIENT_API_CAPABILITY_REQUIREMENTS))).sort();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import type { PluginApiSlashCommandContribution } from '../models/plugin-api.models';
|
||||
import {
|
||||
filterSlashCommands,
|
||||
findSlashCommand,
|
||||
parseSlashCommandArguments,
|
||||
parseSlashCommandInput,
|
||||
parseSlashCommandQuery,
|
||||
selectAvailableSlashCommands,
|
||||
type SlashCommandEntry
|
||||
} from './slash-command.rules';
|
||||
|
||||
function entry(name: string, overrides: Partial<PluginApiSlashCommandContribution> = {}, pluginId = 'plugin.test'): SlashCommandEntry {
|
||||
return {
|
||||
contribution: {
|
||||
name,
|
||||
run: () => {},
|
||||
...overrides
|
||||
},
|
||||
id: `${pluginId}:${name}`,
|
||||
pluginId
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseSlashCommandQuery', () => {
|
||||
it('returns the empty query for a lone slash', () => {
|
||||
expect(parseSlashCommandQuery('/')).toBe('');
|
||||
});
|
||||
|
||||
it('returns the partial name while typing', () => {
|
||||
expect(parseSlashCommandQuery('/gi')).toBe('gi');
|
||||
});
|
||||
|
||||
it('returns null once whitespace (arguments) are typed', () => {
|
||||
expect(parseSlashCommandQuery('/giphy cat')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-command text', () => {
|
||||
expect(parseSlashCommandQuery('hello')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSlashCommandInput', () => {
|
||||
it('parses a command without arguments', () => {
|
||||
expect(parseSlashCommandInput('/ping')).toEqual({ name: 'ping', rawArgs: '' });
|
||||
});
|
||||
|
||||
it('parses a command with arguments', () => {
|
||||
expect(parseSlashCommandInput('/giphy funny cat ')).toEqual({ name: 'giphy', rawArgs: 'funny cat' });
|
||||
});
|
||||
|
||||
it('returns null for a lone slash', () => {
|
||||
expect(parseSlashCommandInput('/')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-command text', () => {
|
||||
expect(parseSlashCommandInput('not a command')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectAvailableSlashCommands', () => {
|
||||
const entries = [
|
||||
entry('alpha', { scope: 'global' }),
|
||||
entry('zeta', { scope: 'server' }),
|
||||
entry('beta', { scope: 'global' })
|
||||
];
|
||||
|
||||
it('includes global and server commands on a server surface, sorted by name', () => {
|
||||
expect(selectAvailableSlashCommands(entries, 'server').map((item) => item.contribution.name)).toEqual([
|
||||
'alpha',
|
||||
'beta',
|
||||
'zeta'
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes server-scoped commands on a direct surface', () => {
|
||||
expect(selectAvailableSlashCommands(entries, 'direct').map((item) => item.contribution.name)).toEqual(['alpha', 'beta']);
|
||||
});
|
||||
|
||||
it('treats a missing scope as global', () => {
|
||||
expect(selectAvailableSlashCommands([entry('plain')], 'direct')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterSlashCommands', () => {
|
||||
const entries = [
|
||||
entry('giphy'),
|
||||
entry('gif-search'),
|
||||
entry('roll')
|
||||
];
|
||||
|
||||
it('returns all entries for an empty query', () => {
|
||||
expect(filterSlashCommands(entries, '')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('ranks prefix matches above contains matches', () => {
|
||||
const names = filterSlashCommands([entry('a-gif'), entry('gif')], 'gif').map((item) => item.contribution.name);
|
||||
|
||||
expect(names).toEqual(['gif', 'a-gif']);
|
||||
});
|
||||
|
||||
it('matches case-insensitively', () => {
|
||||
expect(filterSlashCommands(entries, 'GIF').map((item) => item.contribution.name)).toContain('gif-search');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSlashCommand', () => {
|
||||
const entries = [entry('Ping'), entry('roll')];
|
||||
|
||||
it('matches the command name case-insensitively', () => {
|
||||
expect(findSlashCommand(entries, 'ping')?.contribution.name).toBe('Ping');
|
||||
});
|
||||
|
||||
it('returns null when no command matches', () => {
|
||||
expect(findSlashCommand(entries, 'unknown')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSlashCommandArguments', () => {
|
||||
it('returns an empty record when the command has no options', () => {
|
||||
expect(parseSlashCommandArguments('anything here', [])).toEqual({});
|
||||
});
|
||||
|
||||
it('maps positional tokens to option names', () => {
|
||||
const args = parseSlashCommandArguments('6 advantage', [{ name: 'sides' }, { name: 'mode' }]);
|
||||
|
||||
expect(args).toEqual({ sides: '6', mode: 'advantage' });
|
||||
});
|
||||
|
||||
it('captures the remaining text for a rest option', () => {
|
||||
const args = parseSlashCommandArguments('happy birthday to you', [{ name: 'tone' }, { name: 'message', type: 'rest' }]);
|
||||
|
||||
expect(args).toEqual({ tone: 'happy', message: 'birthday to you' });
|
||||
});
|
||||
|
||||
it('fills missing positional options with empty strings', () => {
|
||||
expect(parseSlashCommandArguments('', [{ name: 'first' }])).toEqual({ first: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
PluginApiSlashCommandContribution,
|
||||
PluginApiSlashCommandOption,
|
||||
PluginApiSlashCommandScope
|
||||
} from '../models/plugin-api.models';
|
||||
|
||||
/**
|
||||
* The chat surface a composer is rendered on. `server` surfaces expose both
|
||||
* global and server-scoped commands; `direct` surfaces (DMs/group chats) only
|
||||
* expose global commands.
|
||||
*/
|
||||
export type SlashCommandSurface = 'server' | 'direct';
|
||||
|
||||
/** A registered slash command together with the owning plugin. */
|
||||
export interface SlashCommandEntry {
|
||||
contribution: PluginApiSlashCommandContribution;
|
||||
id: string;
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface ParsedSlashCommandInput {
|
||||
name: string;
|
||||
rawArgs: string;
|
||||
}
|
||||
|
||||
function resolveScope(contribution: PluginApiSlashCommandContribution): PluginApiSlashCommandScope {
|
||||
return contribution.scope === 'server' ? 'server' : 'global';
|
||||
}
|
||||
|
||||
function normalizeName(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the live query while the user is still typing a command name, e.g.
|
||||
* `/gi` -> `gi` and `/` -> ``. Returns `null` once the input no longer looks
|
||||
* like an in-progress command (contains whitespace or does not start with `/`).
|
||||
*/
|
||||
export function parseSlashCommandQuery(text: string): string | null {
|
||||
const match = /^\/(\S*)$/.exec(text);
|
||||
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a fully typed command for execution. Returns the command name and the
|
||||
* raw remaining argument string, or `null` when the text is not a command.
|
||||
*/
|
||||
export function parseSlashCommandInput(text: string): ParsedSlashCommandInput | null {
|
||||
if (!text.startsWith('/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = text.slice(1);
|
||||
const whitespaceIndex = body.search(/\s/);
|
||||
|
||||
if (whitespaceIndex === -1) {
|
||||
return body.length > 0 ? { name: body, rawArgs: '' } : null;
|
||||
}
|
||||
|
||||
const name = body.slice(0, whitespaceIndex);
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { name, rawArgs: body.slice(whitespaceIndex + 1).trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the registered commands down to the ones available on the given chat
|
||||
* surface, sorted alphabetically by name.
|
||||
*/
|
||||
export function selectAvailableSlashCommands(entries: readonly SlashCommandEntry[], surface: SlashCommandSurface): SlashCommandEntry[] {
|
||||
return entries
|
||||
.filter((entry) => resolveScope(entry.contribution) === 'global' || surface === 'server')
|
||||
.slice()
|
||||
.sort((left, right) => left.contribution.name.localeCompare(right.contribution.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows available commands by the typed query. Commands whose name starts
|
||||
* with the query rank above commands that merely contain it.
|
||||
*/
|
||||
export function filterSlashCommands(entries: readonly SlashCommandEntry[], query: string): SlashCommandEntry[] {
|
||||
const normalizedQuery = normalizeName(query);
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return entries.slice();
|
||||
}
|
||||
|
||||
const matches = entries.filter((entry) => normalizeName(entry.contribution.name).includes(normalizedQuery));
|
||||
|
||||
return matches.sort((left, right) => {
|
||||
const leftStarts = normalizeName(left.contribution.name).startsWith(normalizedQuery);
|
||||
const rightStarts = normalizeName(right.contribution.name).startsWith(normalizedQuery);
|
||||
|
||||
if (leftStarts !== rightStarts) {
|
||||
return leftStarts ? -1 : 1;
|
||||
}
|
||||
|
||||
return left.contribution.name.localeCompare(right.contribution.name);
|
||||
});
|
||||
}
|
||||
|
||||
/** Finds the command to execute for an exact (case-insensitive) name match. */
|
||||
export function findSlashCommand(entries: readonly SlashCommandEntry[], name: string): SlashCommandEntry | null {
|
||||
const normalizedName = normalizeName(name);
|
||||
|
||||
return entries.find((entry) => normalizeName(entry.contribution.name) === normalizedName) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a raw argument string into named values according to the command's
|
||||
* declared options. A `rest` option captures the remaining text verbatim;
|
||||
* commands without options receive an empty record.
|
||||
*/
|
||||
export function parseSlashCommandArguments(rawArgs: string, options: readonly PluginApiSlashCommandOption[] = []): Record<string, string> {
|
||||
const args: Record<string, string> = {};
|
||||
|
||||
if (options.length === 0) {
|
||||
return args;
|
||||
}
|
||||
|
||||
let remaining = rawArgs.trim();
|
||||
|
||||
for (const option of options) {
|
||||
if (option.type === 'rest') {
|
||||
args[option.name] = remaining;
|
||||
remaining = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = /^(\S+)\s*/.exec(remaining);
|
||||
|
||||
args[option.name] = match ? match[1] : '';
|
||||
remaining = match ? remaining.slice(match[0].length) : '';
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export interface PluginApiEventSubscription {
|
||||
handler: (event: PluginEventEnvelope) => void;
|
||||
}
|
||||
|
||||
export type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'manual';
|
||||
export type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'slashCommand' | 'manual';
|
||||
|
||||
export interface PluginApiActionContext {
|
||||
server: Room | null;
|
||||
@@ -99,6 +99,40 @@ export interface PluginApiActionContext {
|
||||
voiceChannel: Channel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a slash command is allowed to appear. `global` commands are available
|
||||
* everywhere (chat servers and direct messages); `server` commands only appear
|
||||
* while a chat server is the active surface.
|
||||
*/
|
||||
export type PluginApiSlashCommandScope = 'global' | 'server';
|
||||
|
||||
export type PluginApiSlashCommandOptionType = 'string' | 'number' | 'boolean' | 'rest';
|
||||
|
||||
export interface PluginApiSlashCommandOption {
|
||||
description?: string;
|
||||
name: string;
|
||||
required?: boolean;
|
||||
type?: PluginApiSlashCommandOptionType;
|
||||
}
|
||||
|
||||
export interface PluginApiSlashCommandContext extends PluginApiActionContext {
|
||||
/** Parsed positional/named argument values keyed by option name. */
|
||||
args: Record<string, string>;
|
||||
/** The invoked command name without the leading slash. */
|
||||
command: string;
|
||||
/** The raw, unparsed argument string typed after the command name. */
|
||||
rawArgs: string;
|
||||
}
|
||||
|
||||
export interface PluginApiSlashCommandContribution {
|
||||
description?: string;
|
||||
icon?: string;
|
||||
name: string;
|
||||
options?: PluginApiSlashCommandOption[];
|
||||
run: (context: PluginApiSlashCommandContext) => Promise<void> | void;
|
||||
scope?: PluginApiSlashCommandScope;
|
||||
}
|
||||
|
||||
export interface PluginApiTypingEvent extends Omit<PluginApiActionContext, 'source'> {
|
||||
channelId: string;
|
||||
displayName: string;
|
||||
@@ -194,10 +228,15 @@ export interface PluginApiUiContributionMap {
|
||||
profileActions: PluginApiActionContribution[];
|
||||
settingsPages: PluginApiSettingsPageContribution[];
|
||||
sidePanels: PluginApiPanelContribution[];
|
||||
slashCommands: PluginApiSlashCommandContribution[];
|
||||
toolbarActions: PluginApiActionContribution[];
|
||||
}
|
||||
|
||||
export interface TojuClientPluginApi {
|
||||
readonly commands: {
|
||||
list: () => PluginApiSlashCommandContribution[];
|
||||
register: (id: string, contribution: PluginApiSlashCommandContribution) => TojuPluginDisposable;
|
||||
};
|
||||
readonly channels: {
|
||||
addAudioChannel: (request: PluginApiChannelRequest) => void;
|
||||
addTextChannel: (request: PluginApiChannelRequest) => void;
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
{ label: 'Composer actions', value: extensionCounts().composerActions },
|
||||
{ label: 'Profile actions', value: extensionCounts().profileActions },
|
||||
{ label: 'Toolbar actions', value: extensionCounts().toolbarActions },
|
||||
{ label: 'Slash commands', value: extensionCounts().slashCommands },
|
||||
{ label: 'Embed renderers', value: extensionCounts().embeds }
|
||||
];
|
||||
track item.label
|
||||
|
||||
@@ -106,6 +106,7 @@ export class PluginManagerComponent {
|
||||
profileActions: this.uiRegistry.profileActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||
settingsPages: this.uiRegistry.settingsPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||
sidePanels: this.uiRegistry.sidePanelRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||
slashCommands: this.uiRegistry.slashCommandRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||
toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length
|
||||
}));
|
||||
readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []);
|
||||
|
||||
@@ -563,10 +563,6 @@ export class PluginStoreComponent implements OnInit {
|
||||
return this.brokenImageKeys().has(this.imageKey(plugin));
|
||||
}
|
||||
|
||||
private imageKey(plugin: PluginStoreEntry): string {
|
||||
return `${plugin.sourceUrl}:${plugin.id}:${plugin.imageUrl ?? ''}`;
|
||||
}
|
||||
|
||||
trackServer(index: number, server: Room): string {
|
||||
return server.id;
|
||||
}
|
||||
@@ -585,6 +581,10 @@ export class PluginStoreComponent implements OnInit {
|
||||
: this.getPrimaryActionLabel(plugin);
|
||||
}
|
||||
|
||||
private imageKey(plugin: PluginStoreEntry): string {
|
||||
return `${plugin.sourceUrl}:${plugin.id}:${plugin.imageUrl ?? ''}`;
|
||||
}
|
||||
|
||||
private matchesSearch(plugin: PluginStoreEntry, searchTerm: string): boolean {
|
||||
return [
|
||||
plugin.author,
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from './application/services/plugin-store.service';
|
||||
export * from './application/services/plugin-ui-registry.service';
|
||||
export * from './domain/logic/plugin-dependency-resolver.logic';
|
||||
export * from './domain/logic/plugin-manifest-validation.logic';
|
||||
export * from './domain/logic/slash-command.rules';
|
||||
export * from './domain/models/plugin-api.models';
|
||||
export * from './domain/models/plugin-runtime.models';
|
||||
export * from './domain/models/plugin-store.models';
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/** Hostnames known to run older signal servers without featured/trending discovery routes. */
|
||||
const DISCOVERY_UNSUPPORTED_HOSTS = new Set([
|
||||
'signal.toju.app',
|
||||
'signal-sweden.toju.app'
|
||||
]);
|
||||
const DISCOVERY_UNSUPPORTED_HOSTS = new Set(['signal.toju.app', 'signal-sweden.toju.app']);
|
||||
|
||||
/** Returns false when discovery endpoints are known to 404 on the active signal server. */
|
||||
export function endpointSupportsServerDiscovery(baseUrl: string): boolean {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import {
|
||||
isSignalServerTagUrl,
|
||||
presentSignalServerTag,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
|
||||
import {
|
||||
DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
|
||||
|
||||
@@ -122,6 +122,7 @@ describe('ThemeService theme application', () => {
|
||||
'Toju Website Dark',
|
||||
'Toju Default Dark'
|
||||
]);
|
||||
|
||||
expect(service.activeThemeName()).toBe('Toju Default Dark 11');
|
||||
|
||||
const applied = service.applyBuiltInPreset('Toju Default Dark');
|
||||
|
||||
@@ -33,6 +33,7 @@ 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 {
|
||||
DebugConsoleComponent,
|
||||
ScreenShareQualityDialogComponent,
|
||||
@@ -81,6 +82,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private readonly settingsModal = inject(SettingsModalService);
|
||||
private readonly hostEl = inject(ElementRef);
|
||||
private readonly profileCard = inject(ProfileCardService);
|
||||
private readonly mobileMedia = inject(MobileMediaService);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
@@ -169,6 +171,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const voicePermissionsGranted = await this.mobileMedia.ensureVoiceCapturePermissions();
|
||||
|
||||
if (!voicePermissionsGranted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
deviceId: this.selectedInputDevice() || undefined,
|
||||
|
||||
@@ -25,10 +25,7 @@ import {
|
||||
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import {
|
||||
MobileAppLifecycleService,
|
||||
MobilePictureInPictureService
|
||||
} from '../../../../infrastructure/mobile';
|
||||
import { MobileAppLifecycleService, MobilePictureInPictureService } from '../../../../infrastructure/mobile';
|
||||
import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service';
|
||||
import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
|
||||
|
||||
@@ -84,6 +81,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
this.mobileLifecycle.onAppStateChange((isActive) => {
|
||||
void this.handleAppStateChange(isActive);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const ref = this.videoRef();
|
||||
const item = this.item();
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
/>
|
||||
<span class="text-sm">Process RAM</span>
|
||||
</div>
|
||||
<span class="font-mono text-sm text-foreground">{{ ramLabel() ?? '—' }}</span>
|
||||
<span class="font-mono text-sm text-foreground">{{ ramLabel() ?? '-' }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Live total working set from Electron app metrics. Updates every 2 seconds.</p>
|
||||
</section>
|
||||
|
||||
@@ -14,11 +14,104 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!isElectron) {
|
||||
<section class="rounded-lg border border-border bg-secondary/30 p-5">
|
||||
<p class="text-sm text-muted-foreground">Automatic updates are only available in the packaged Electron desktop app.</p>
|
||||
@if (isCapacitor) {
|
||||
<section class="rounded-lg border border-border bg-card/60 p-5">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-foreground">Mobile app updates</h4>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Check the Play Store or App Store for newer native builds. Android can install in-app updates when Google Play allows it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span class="inline-flex items-center rounded-full border border-primary/30 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||
{{ mobileStatusLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
} @else {
|
||||
|
||||
@if (!mobileState().isSupported) {
|
||||
<section class="rounded-lg border border-border bg-secondary/30 p-5">
|
||||
<p class="text-sm text-muted-foreground">Store updates are only available in the packaged Android or iOS app.</p>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="rounded-lg border border-border bg-secondary/20 p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Installed</p>
|
||||
<p class="mt-2 text-lg font-semibold text-foreground">{{ mobileState().currentVersion }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border bg-secondary/20 p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Store version</p>
|
||||
<p class="mt-2 text-lg font-semibold text-foreground">{{ mobileState().availableVersion || 'Unknown' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border bg-secondary/20 p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Last checked</p>
|
||||
<p class="mt-2 text-sm font-medium text-foreground">
|
||||
{{ mobileState().lastCheckedAt ? (mobileState().lastCheckedAt | date: 'medium') : 'Not checked yet' }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
||||
<div class="rounded-lg border border-border bg-secondary/20 p-4">
|
||||
<p class="text-sm font-medium text-foreground">Status</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ mobileState().statusMessage || 'Waiting for the first store update check.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
(click)="refreshMobileReleaseInfo()"
|
||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Check for updates
|
||||
</button>
|
||||
|
||||
@if (mobileState().status === 'update-available') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="openMobileAppStore()"
|
||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Open app store
|
||||
</button>
|
||||
|
||||
@if (mobileState().immediateUpdateAllowed || mobileState().flexibleUpdateAllowed) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="installMobileUpdateNow()"
|
||||
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Install update
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (mobileState().flexibleUpdateAllowed && mobileState().status === 'downloading') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="completeMobileFlexibleUpdate()"
|
||||
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Restart to finish update
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!isElectron && !isCapacitor) {
|
||||
<section class="rounded-lg border border-border bg-secondary/30 p-5">
|
||||
<p class="text-sm text-muted-foreground">Automatic updates are only available in the packaged Electron desktop app or native mobile app.</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (isElectron) {
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-lg border border-border bg-secondary/20 p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Installed</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DesktopAppUpdateService } from '../../../../core/services/desktop-app-update.service';
|
||||
import { MobileAppUpdateService, getMobileUpdateStatusLabel } from '../../../../infrastructure/mobile';
|
||||
|
||||
type AutoUpdateMode = 'auto' | 'off' | 'version';
|
||||
type DesktopUpdateStatus =
|
||||
@@ -29,9 +30,15 @@ type DesktopUpdateStatus =
|
||||
templateUrl: './updates-settings.component.html'
|
||||
})
|
||||
export class UpdatesSettingsComponent {
|
||||
readonly updates = inject(DesktopAppUpdateService);
|
||||
readonly isElectron = this.updates.isElectron;
|
||||
readonly state = this.updates.state;
|
||||
readonly desktopUpdates = inject(DesktopAppUpdateService);
|
||||
readonly mobileUpdates = inject(MobileAppUpdateService);
|
||||
readonly isElectron = this.desktopUpdates.isElectron;
|
||||
readonly isCapacitor = this.mobileUpdates.isCapacitor;
|
||||
readonly state = this.desktopUpdates.state;
|
||||
readonly mobileState = this.mobileUpdates.state;
|
||||
readonly mobileStatusLabel = computed(() =>
|
||||
getMobileUpdateStatusLabel(this.mobileState().status)
|
||||
);
|
||||
readonly hasPendingManifestUrlChanges = signal(false);
|
||||
readonly manifestUrlsText = signal('');
|
||||
readonly statusLabel = computed(() => this.getStatusLabel(this.state().status));
|
||||
@@ -60,18 +67,18 @@ export class UpdatesSettingsComponent {
|
||||
? this.state().preferredVersion ?? this.state().availableVersions[0] ?? null
|
||||
: this.state().preferredVersion;
|
||||
|
||||
await this.updates.saveUpdatePreferences(mode, preferredVersion);
|
||||
await this.desktopUpdates.saveUpdatePreferences(mode, preferredVersion);
|
||||
}
|
||||
|
||||
async onVersionChange(event: Event): Promise<void> {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
await this.updates.saveUpdatePreferences('version', select.value || null);
|
||||
await this.desktopUpdates.saveUpdatePreferences('version', select.value || null);
|
||||
}
|
||||
|
||||
async refreshReleaseInfo(): Promise<void> {
|
||||
await this.updates.refreshServerContext();
|
||||
await this.updates.checkForUpdates();
|
||||
await this.desktopUpdates.refreshServerContext();
|
||||
await this.desktopUpdates.checkForUpdates();
|
||||
}
|
||||
|
||||
onManifestUrlsInput(event: Event): void {
|
||||
@@ -82,7 +89,7 @@ export class UpdatesSettingsComponent {
|
||||
}
|
||||
|
||||
async saveManifestUrls(): Promise<void> {
|
||||
await this.updates.saveManifestUrls(
|
||||
await this.desktopUpdates.saveManifestUrls(
|
||||
this.parseManifestUrls(this.manifestUrlsText())
|
||||
);
|
||||
|
||||
@@ -90,12 +97,37 @@ export class UpdatesSettingsComponent {
|
||||
}
|
||||
|
||||
async useConnectedServerDefaults(): Promise<void> {
|
||||
await this.updates.saveManifestUrls([]);
|
||||
await this.desktopUpdates.saveManifestUrls([]);
|
||||
this.hasPendingManifestUrlChanges.set(false);
|
||||
}
|
||||
|
||||
async restartNow(): Promise<void> {
|
||||
await this.updates.restartToApplyUpdate();
|
||||
await this.desktopUpdates.restartToApplyUpdate();
|
||||
}
|
||||
|
||||
async refreshMobileReleaseInfo(): Promise<void> {
|
||||
await this.mobileUpdates.checkForUpdates();
|
||||
}
|
||||
|
||||
async openMobileAppStore(): Promise<void> {
|
||||
await this.mobileUpdates.openAppStore();
|
||||
}
|
||||
|
||||
async installMobileUpdateNow(): Promise<void> {
|
||||
const mobileState = this.mobileState();
|
||||
|
||||
if (mobileState.immediateUpdateAllowed) {
|
||||
await this.mobileUpdates.performImmediateUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mobileState.flexibleUpdateAllowed) {
|
||||
await this.mobileUpdates.startFlexibleUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
async completeMobileFlexibleUpdate(): Promise<void> {
|
||||
await this.mobileUpdates.completeFlexibleUpdate();
|
||||
}
|
||||
|
||||
private parseManifestUrls(rawValue: string): string[] {
|
||||
|
||||
@@ -134,12 +134,6 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
this.cleanup = null;
|
||||
}
|
||||
|
||||
private readonly onDocumentContextMenuCapture = (event: MouseEvent): void => {
|
||||
if (resolveCustomEmojiContextMenuTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
close(): void {
|
||||
this.params.set(null);
|
||||
this.customEmojiMenu.set(null);
|
||||
@@ -186,6 +180,12 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
void this.runAction(action);
|
||||
}
|
||||
|
||||
private readonly onDocumentContextMenuCapture = (event: MouseEvent): void => {
|
||||
if (resolveCustomEmojiContextMenuTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
private openCustomEmojiMenu(event: MouseEvent, target: CustomEmojiContextMenuTarget): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -9,18 +9,19 @@ Loosely coupled Capacitor/native bridge for the Angular product client. Domains
|
||||
| `MobilePlatformService` | Runtime detection (`browser` / `capacitor` / `electron`) and mobile UX flags |
|
||||
| `MobileNotificationsService` | Local/push notifications for calls |
|
||||
| `MobileCallSessionService` | In-call notification actions, background audio session, stream video hand-off |
|
||||
| `MobileMediaService` | Attachment picker, speakerphone route, screen-share/PiP capability probes |
|
||||
| `MobileMediaService` | Attachment picker, speakerphone route, Android/iOS capture permission preflight, screen-share/PiP capability probes |
|
||||
| `MobilePictureInPictureService` | Stream pop-out while backgrounded |
|
||||
| `MobilePersistenceService` | Native SQLite schema init (`@capacitor-community/sqlite`) |
|
||||
| `MobileSqliteConnectionService` | Shared SQLite connection for persistence + `DatabaseService` |
|
||||
| `MobileCallKitService` | iOS CallKit active-call reporting for background voice |
|
||||
| `MobilePushRegistrationService` | FCM/APNs token registration with signaling server; skips `PushNotifications.register()` when Firebase/APNs is not configured |
|
||||
| `MobileAppLifecycleService` | Foreground/background lifecycle |
|
||||
| `MobileAppUpdateService` | Play Store / App Store update checks via `@capawesome/capacitor-app-update` |
|
||||
|
||||
## Adapters
|
||||
|
||||
- `adapters/web/*` — browser fallbacks (Notification API, hidden file input, Document PiP).
|
||||
- `adapters/capacitor/*` — lazy-loaded Capacitor plugins via `capacitor-plugin-loader.ts`.
|
||||
- `adapters/capacitor/*` — lazy-loaded Capacitor plugins via `capacitor-plugin-loader.ts` (including `AppUpdate` for store update checks).
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { AppUpdateAvailability } from '@capawesome/capacitor-app-update';
|
||||
|
||||
import type {
|
||||
MobileAppUpdateAdapter,
|
||||
MobileAppUpdateAvailability,
|
||||
MobileAppUpdateInfo
|
||||
} from '../../contracts/mobile.contracts';
|
||||
import { getCapacitorPlatform, loadCapacitorAppUpdatePlugin } from './capacitor-plugin-loader';
|
||||
|
||||
function mapAvailability(value: AppUpdateAvailability | undefined): MobileAppUpdateAvailability {
|
||||
switch (value) {
|
||||
case AppUpdateAvailability.UPDATE_AVAILABLE:
|
||||
case AppUpdateAvailability.UPDATE_IN_PROGRESS:
|
||||
return 'update-available';
|
||||
case AppUpdateAvailability.UPDATE_NOT_AVAILABLE:
|
||||
return 'update-not-available';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/** Capacitor App Update plugin bridge for Play Store / App Store checks. */
|
||||
export class CapacitorMobileAppUpdateAdapter implements MobileAppUpdateAdapter {
|
||||
readonly isSupported = true;
|
||||
|
||||
async getAppUpdateInfo(): Promise<MobileAppUpdateInfo> {
|
||||
const AppUpdate = await loadCapacitorAppUpdatePlugin();
|
||||
|
||||
if (!AppUpdate) {
|
||||
throw new Error('Capacitor AppUpdate plugin is not available on this platform.');
|
||||
}
|
||||
|
||||
const platform = await this.resolvePlatform();
|
||||
const result = await AppUpdate.getAppUpdateInfo(
|
||||
platform === 'ios'
|
||||
? { country: 'US' }
|
||||
: undefined
|
||||
);
|
||||
|
||||
return {
|
||||
availableVersion: result.availableVersionName
|
||||
?? result.availableVersionCode
|
||||
?? null,
|
||||
currentVersion: result.currentVersionName
|
||||
?? result.currentVersionCode
|
||||
?? '0.0.0',
|
||||
flexibleUpdateAllowed: result.flexibleUpdateAllowed ?? false,
|
||||
immediateUpdateAllowed: result.immediateUpdateAllowed ?? false,
|
||||
platform,
|
||||
updateAvailability: mapAvailability(result.updateAvailability)
|
||||
};
|
||||
}
|
||||
|
||||
async openAppStore(): Promise<void> {
|
||||
const AppUpdate = await loadCapacitorAppUpdatePlugin();
|
||||
|
||||
if (!AppUpdate) {
|
||||
throw new Error('Capacitor AppUpdate plugin is not available on this platform.');
|
||||
}
|
||||
|
||||
await AppUpdate.openAppStore();
|
||||
}
|
||||
|
||||
async performImmediateUpdate(): Promise<void> {
|
||||
const AppUpdate = await loadCapacitorAppUpdatePlugin();
|
||||
|
||||
if (!AppUpdate) {
|
||||
throw new Error('Capacitor AppUpdate plugin is not available on this platform.');
|
||||
}
|
||||
|
||||
await AppUpdate.performImmediateUpdate();
|
||||
}
|
||||
|
||||
async startFlexibleUpdate(): Promise<void> {
|
||||
const AppUpdate = await loadCapacitorAppUpdatePlugin();
|
||||
|
||||
if (!AppUpdate) {
|
||||
throw new Error('Capacitor AppUpdate plugin is not available on this platform.');
|
||||
}
|
||||
|
||||
await AppUpdate.startFlexibleUpdate();
|
||||
}
|
||||
|
||||
async completeFlexibleUpdate(): Promise<void> {
|
||||
const AppUpdate = await loadCapacitorAppUpdatePlugin();
|
||||
|
||||
if (!AppUpdate) {
|
||||
throw new Error('Capacitor AppUpdate plugin is not available on this platform.');
|
||||
}
|
||||
|
||||
await AppUpdate.completeFlexibleUpdate();
|
||||
}
|
||||
|
||||
private async resolvePlatform(): Promise<'android' | 'ios' | null> {
|
||||
const platform = await getCapacitorPlatform();
|
||||
|
||||
if (platform === 'android' || platform === 'ios') {
|
||||
return platform;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,13 @@ async function resolveCapacitorPlugin<T>(
|
||||
return pluginPromises.get(pluginName) as Promise<T>;
|
||||
}
|
||||
|
||||
/** Resolve the active Capacitor platform id on native shells. */
|
||||
export async function getCapacitorPlatform(): Promise<string | null> {
|
||||
const Capacitor = await loadCapacitorCore();
|
||||
|
||||
return Capacitor?.getPlatform() ?? null;
|
||||
}
|
||||
|
||||
/** Resolve the Capacitor App plugin on native shells; returns null on web/electron or when unavailable. */
|
||||
export async function loadCapacitorAppPlugin(): Promise<import('@capacitor/app').AppPlugin | null> {
|
||||
return resolveCapacitorPlugin('App', async () => {
|
||||
@@ -86,3 +93,12 @@ export async function loadCapacitorAudioSessionPlugin(): Promise<import('@capgo/
|
||||
return module.AudioSession;
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve the Capacitor App Update plugin on native shells. */
|
||||
export async function loadCapacitorAppUpdatePlugin(): Promise<import('@capawesome/capacitor-app-update').AppUpdatePlugin | null> {
|
||||
return resolveCapacitorPlugin('AppUpdate', async () => {
|
||||
const module = await import('@capawesome/capacitor-app-update');
|
||||
|
||||
return module.AppUpdate;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
|
||||
|
||||
import type { MobileCapturePermissionResult } from '../../logic/mobile-media-permission.rules';
|
||||
|
||||
export interface MetoyouMobilePlugin {
|
||||
requestVoiceCapturePermissions(): Promise<MobileCapturePermissionResult>;
|
||||
requestCameraCapturePermissions(): Promise<MobileCapturePermissionResult>;
|
||||
setSpeakerphoneEnabled(options: { enabled: boolean }): Promise<void>;
|
||||
startVoiceForegroundService(): Promise<void>;
|
||||
stopVoiceForegroundService(): Promise<void>;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MobileAppUpdateAdapter } from '../../contracts/mobile.contracts';
|
||||
|
||||
/** Browser/electron fallback - store updates are not available outside native shells. */
|
||||
export class WebMobileAppUpdateAdapter implements MobileAppUpdateAdapter {
|
||||
readonly isSupported = false;
|
||||
|
||||
async getAppUpdateInfo() {
|
||||
return {
|
||||
availableVersion: null,
|
||||
currentVersion: '0.0.0',
|
||||
flexibleUpdateAllowed: false,
|
||||
immediateUpdateAllowed: false,
|
||||
platform: null,
|
||||
updateAvailability: 'unknown' as const
|
||||
};
|
||||
}
|
||||
|
||||
async openAppStore(): Promise<void> {}
|
||||
|
||||
async performImmediateUpdate(): Promise<void> {}
|
||||
|
||||
async startFlexibleUpdate(): Promise<void> {}
|
||||
|
||||
async completeFlexibleUpdate(): Promise<void> {}
|
||||
}
|
||||
@@ -44,3 +44,23 @@ export interface MobilePlatformSnapshot {
|
||||
isNativeMobile: boolean;
|
||||
isCapacitor: boolean;
|
||||
}
|
||||
|
||||
export type MobileAppUpdateAvailability = 'unknown' | 'update-available' | 'update-not-available';
|
||||
|
||||
export interface MobileAppUpdateInfo {
|
||||
availableVersion: string | null;
|
||||
currentVersion: string;
|
||||
flexibleUpdateAllowed: boolean;
|
||||
immediateUpdateAllowed: boolean;
|
||||
platform: 'android' | 'ios' | null;
|
||||
updateAvailability: MobileAppUpdateAvailability;
|
||||
}
|
||||
|
||||
export interface MobileAppUpdateAdapter {
|
||||
readonly isSupported: boolean;
|
||||
getAppUpdateInfo(): Promise<MobileAppUpdateInfo>;
|
||||
openAppStore(): Promise<void>;
|
||||
performImmediateUpdate(): Promise<void>;
|
||||
startFlexibleUpdate(): Promise<void>;
|
||||
completeFlexibleUpdate(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -10,3 +10,5 @@ export * from './services/mobile-app-lifecycle.service';
|
||||
export * from './services/mobile-push-registration.service';
|
||||
export * from './services/mobile-callkit.service';
|
||||
export * from './services/mobile-sqlite-connection.service';
|
||||
export * from './services/mobile-app-update.service';
|
||||
export * from './logic/mobile-app-update.rules';
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin';
|
||||
import {
|
||||
isCameraCaptureAllowed,
|
||||
isVoiceCaptureAllowed,
|
||||
shouldPreflightMobileCapturePermissions
|
||||
} from './mobile-media-permission.rules';
|
||||
import { detectRuntimePlatform, isCapacitorNativeRuntime } from './platform-detection.rules';
|
||||
|
||||
function resolveRuntimePlatform(): ReturnType<typeof detectRuntimePlatform> {
|
||||
return detectRuntimePlatform({
|
||||
hasElectronApi: false,
|
||||
capacitorIsNative: isCapacitorNativeRuntime()
|
||||
});
|
||||
}
|
||||
|
||||
/** Request Android/iOS runtime microphone permissions before WebRTC voice capture. */
|
||||
export async function ensureMobileVoiceCapturePermissions(): Promise<boolean> {
|
||||
if (!shouldPreflightMobileCapturePermissions(resolveRuntimePlatform())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const plugin = await loadMetoyouMobilePlugin();
|
||||
|
||||
if (!plugin?.requestVoiceCapturePermissions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await plugin.requestVoiceCapturePermissions();
|
||||
|
||||
return isVoiceCaptureAllowed(result);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Request Android/iOS runtime camera permissions before WebRTC camera capture. */
|
||||
export async function ensureMobileCameraCapturePermissions(): Promise<boolean> {
|
||||
if (!shouldPreflightMobileCapturePermissions(resolveRuntimePlatform())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const plugin = await loadMetoyouMobilePlugin();
|
||||
|
||||
if (!plugin?.requestCameraCapturePermissions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await plugin.requestCameraCapturePermissions();
|
||||
|
||||
return isCameraCaptureAllowed(result);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { ANDROID_REQUIRED_MANIFEST_PERMISSIONS, findMissingAndroidManifestPermissions } from './mobile-android-manifest-permissions.rules';
|
||||
|
||||
describe('mobile-android-manifest-permissions.rules', () => {
|
||||
it('requires MODIFY_AUDIO_SETTINGS so Capacitor WebView audio grants succeed', () => {
|
||||
expect(ANDROID_REQUIRED_MANIFEST_PERMISSIONS).toContain('android.permission.MODIFY_AUDIO_SETTINGS');
|
||||
});
|
||||
|
||||
it('declares every required permission in the Android app manifest', () => {
|
||||
const manifestPath = resolve(process.cwd(), 'android/app/src/main/AndroidManifest.xml');
|
||||
const manifestXml = readFileSync(manifestPath, 'utf8');
|
||||
const missing = findMissingAndroidManifestPermissions(manifestXml);
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Android permissions the MetoYou Capacitor shell must declare for voice, camera, and notifications. */
|
||||
export const ANDROID_REQUIRED_MANIFEST_PERMISSIONS = [
|
||||
'android.permission.INTERNET',
|
||||
'android.permission.POST_NOTIFICATIONS',
|
||||
'android.permission.RECORD_AUDIO',
|
||||
'android.permission.MODIFY_AUDIO_SETTINGS',
|
||||
'android.permission.CAMERA',
|
||||
'android.permission.FOREGROUND_SERVICE',
|
||||
'android.permission.FOREGROUND_SERVICE_MICROPHONE',
|
||||
'android.permission.WAKE_LOCK',
|
||||
'android.permission.BLUETOOTH_CONNECT'
|
||||
] as const;
|
||||
|
||||
/** Return manifest permission names that are missing from the given AndroidManifest.xml source. */
|
||||
export function findMissingAndroidManifestPermissions(
|
||||
manifestXml: string,
|
||||
requiredPermissions: readonly string[] = ANDROID_REQUIRED_MANIFEST_PERMISSIONS
|
||||
): string[] {
|
||||
return requiredPermissions.filter((permission) => !manifestXml.includes(`android:name="${permission}"`));
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
buildMobileUpdateState,
|
||||
createInitialMobileUpdateState,
|
||||
getMobileUpdateStatusLabel,
|
||||
mapUpdateAvailabilityToStatus,
|
||||
resolveMobileUpdateStatusMessage
|
||||
} from './mobile-app-update.rules';
|
||||
import type { MobileAppUpdateInfoSnapshot } from './mobile-app-update.rules';
|
||||
|
||||
describe('mobile-app-update.rules', () => {
|
||||
it('creates an idle unsupported state for non-native shells', () => {
|
||||
const state = createInitialMobileUpdateState({ isSupported: false });
|
||||
|
||||
expect(state.status).toBe('unsupported');
|
||||
expect(state.isSupported).toBe(false);
|
||||
expect(state.currentVersion).toBe('0.0.0');
|
||||
});
|
||||
|
||||
it('maps update availability to mobile update statuses', () => {
|
||||
expect(mapUpdateAvailabilityToStatus('checking')).toBe('checking');
|
||||
expect(mapUpdateAvailabilityToStatus('update-available')).toBe('update-available');
|
||||
expect(mapUpdateAvailabilityToStatus('update-not-available')).toBe('up-to-date');
|
||||
expect(mapUpdateAvailabilityToStatus('unknown')).toBe('idle');
|
||||
});
|
||||
|
||||
it('builds an update-available state from store metadata', () => {
|
||||
const snapshot: MobileAppUpdateInfoSnapshot = {
|
||||
availableVersion: '1.2.0',
|
||||
currentVersion: '1.1.0',
|
||||
flexibleUpdateAllowed: true,
|
||||
immediateUpdateAllowed: false,
|
||||
platform: 'android',
|
||||
updateAvailability: 'update-available'
|
||||
};
|
||||
const state = buildMobileUpdateState(snapshot, {
|
||||
isSupported: true,
|
||||
lastCheckedAt: 1_700_000_000_000
|
||||
});
|
||||
|
||||
expect(state.status).toBe('update-available');
|
||||
expect(state.availableVersion).toBe('1.2.0');
|
||||
expect(state.currentVersion).toBe('1.1.0');
|
||||
expect(state.flexibleUpdateAllowed).toBe(true);
|
||||
expect(state.immediateUpdateAllowed).toBe(false);
|
||||
expect(state.lastCheckedAt).toBe(1_700_000_000_000);
|
||||
expect(state.statusMessage).toContain('1.2.0');
|
||||
});
|
||||
|
||||
it('builds an up-to-date state when the store reports no update', () => {
|
||||
const snapshot: MobileAppUpdateInfoSnapshot = {
|
||||
availableVersion: null,
|
||||
currentVersion: '1.1.0',
|
||||
flexibleUpdateAllowed: false,
|
||||
immediateUpdateAllowed: false,
|
||||
platform: 'ios',
|
||||
updateAvailability: 'update-not-available'
|
||||
};
|
||||
const state = buildMobileUpdateState(snapshot, { isSupported: true });
|
||||
|
||||
expect(state.status).toBe('up-to-date');
|
||||
expect(state.statusMessage).toContain('up to date');
|
||||
});
|
||||
|
||||
it('maps errors to a friendly status message', () => {
|
||||
expect(resolveMobileUpdateStatusMessage('error', new Error('Required app information could not be fetched')))
|
||||
.toContain('store');
|
||||
|
||||
expect(getMobileUpdateStatusLabel('update-available')).toBe('Update available');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
export type MobileUpdateAvailability = 'unknown' | 'update-available' | 'update-not-available' | 'checking';
|
||||
|
||||
export type MobileUpdateStatus =
|
||||
| 'idle'
|
||||
| 'checking'
|
||||
| 'downloading'
|
||||
| 'up-to-date'
|
||||
| 'update-available'
|
||||
| 'unsupported'
|
||||
| 'error';
|
||||
|
||||
export type MobileUpdatePlatform = 'android' | 'ios' | null;
|
||||
|
||||
export interface MobileAppUpdateInfoSnapshot {
|
||||
availableVersion: string | null;
|
||||
currentVersion: string;
|
||||
flexibleUpdateAllowed: boolean;
|
||||
immediateUpdateAllowed: boolean;
|
||||
platform: MobileUpdatePlatform;
|
||||
updateAvailability: MobileUpdateAvailability;
|
||||
}
|
||||
|
||||
export interface MobileUpdateState {
|
||||
availableVersion: string | null;
|
||||
currentVersion: string;
|
||||
flexibleUpdateAllowed: boolean;
|
||||
immediateUpdateAllowed: boolean;
|
||||
isSupported: boolean;
|
||||
lastCheckedAt: number | null;
|
||||
platform: MobileUpdatePlatform;
|
||||
status: MobileUpdateStatus;
|
||||
statusMessage: string | null;
|
||||
}
|
||||
|
||||
export function createInitialMobileUpdateState(
|
||||
options: { isSupported: boolean; currentVersion?: string } = { isSupported: false }
|
||||
): MobileUpdateState {
|
||||
return {
|
||||
availableVersion: null,
|
||||
currentVersion: options.currentVersion ?? '0.0.0',
|
||||
flexibleUpdateAllowed: false,
|
||||
immediateUpdateAllowed: false,
|
||||
isSupported: options.isSupported,
|
||||
lastCheckedAt: null,
|
||||
platform: null,
|
||||
status: options.isSupported ? 'idle' : 'unsupported',
|
||||
statusMessage: options.isSupported
|
||||
? 'Waiting for the first store update check.'
|
||||
: 'Store updates are only available in the packaged Android or iOS app.'
|
||||
};
|
||||
}
|
||||
|
||||
export function mapUpdateAvailabilityToStatus(
|
||||
availability: MobileUpdateAvailability
|
||||
): MobileUpdateStatus {
|
||||
switch (availability) {
|
||||
case 'checking':
|
||||
return 'checking';
|
||||
case 'update-available':
|
||||
return 'update-available';
|
||||
case 'update-not-available':
|
||||
return 'up-to-date';
|
||||
default:
|
||||
return 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMobileUpdateState(
|
||||
snapshot: MobileAppUpdateInfoSnapshot,
|
||||
options: {
|
||||
isSupported: boolean;
|
||||
lastCheckedAt?: number | null;
|
||||
statusOverride?: MobileUpdateStatus;
|
||||
statusMessage?: string | null;
|
||||
}
|
||||
): MobileUpdateState {
|
||||
const status = options.statusOverride ?? mapUpdateAvailabilityToStatus(snapshot.updateAvailability);
|
||||
|
||||
return {
|
||||
availableVersion: snapshot.availableVersion,
|
||||
currentVersion: snapshot.currentVersion,
|
||||
flexibleUpdateAllowed: snapshot.flexibleUpdateAllowed,
|
||||
immediateUpdateAllowed: snapshot.immediateUpdateAllowed,
|
||||
isSupported: options.isSupported,
|
||||
lastCheckedAt: options.lastCheckedAt ?? null,
|
||||
platform: snapshot.platform,
|
||||
status,
|
||||
statusMessage: options.statusMessage ?? resolveMobileUpdateStatusMessage(status, null, snapshot)
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMobileUpdateStatusMessage(
|
||||
status: MobileUpdateStatus,
|
||||
error: unknown = null,
|
||||
snapshot: MobileAppUpdateInfoSnapshot | null = null
|
||||
): string | null {
|
||||
if (status === 'error') {
|
||||
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||
|
||||
if (message.includes('Required app information could not be fetched')) {
|
||||
return 'The app store could not return release information. Confirm the app is published and try again.';
|
||||
}
|
||||
|
||||
return message.trim() || 'Unable to check for mobile app updates.';
|
||||
}
|
||||
|
||||
if (status === 'unsupported') {
|
||||
return 'Store updates are only available in the packaged Android or iOS app.';
|
||||
}
|
||||
|
||||
if (status === 'checking') {
|
||||
return 'Checking the app store for a newer release...';
|
||||
}
|
||||
|
||||
if (status === 'downloading') {
|
||||
return 'Downloading the update from the app store...';
|
||||
}
|
||||
|
||||
if (status === 'update-available' && snapshot?.availableVersion) {
|
||||
return `MetoYou ${snapshot.availableVersion} is available in the app store.`;
|
||||
}
|
||||
|
||||
if (status === 'up-to-date' && snapshot?.currentVersion) {
|
||||
return `MetoYou ${snapshot.currentVersion} is up to date.`;
|
||||
}
|
||||
|
||||
if (status === 'idle') {
|
||||
return 'Waiting for the first store update check.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getMobileUpdateStatusLabel(status: MobileUpdateStatus): string {
|
||||
switch (status) {
|
||||
case 'checking':
|
||||
return 'Checking';
|
||||
case 'downloading':
|
||||
return 'Downloading';
|
||||
case 'update-available':
|
||||
return 'Update available';
|
||||
case 'up-to-date':
|
||||
return 'Up to date';
|
||||
case 'unsupported':
|
||||
return 'Unsupported';
|
||||
case 'error':
|
||||
return 'Error';
|
||||
default:
|
||||
return 'Idle';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
isCameraCaptureAllowed,
|
||||
isMobileCapturePermissionGranted,
|
||||
isVoiceCaptureAllowed,
|
||||
shouldPreflightMobileCapturePermissions
|
||||
} from './mobile-media-permission.rules';
|
||||
|
||||
describe('mobile-media-permission.rules', () => {
|
||||
it('only preflights capture permissions on Capacitor shells', () => {
|
||||
expect(shouldPreflightMobileCapturePermissions('capacitor')).toBe(true);
|
||||
expect(shouldPreflightMobileCapturePermissions('browser')).toBe(false);
|
||||
expect(shouldPreflightMobileCapturePermissions('electron')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats granted as the only successful native permission state', () => {
|
||||
expect(isMobileCapturePermissionGranted('granted')).toBe(true);
|
||||
expect(isMobileCapturePermissionGranted('prompt')).toBe(false);
|
||||
expect(isMobileCapturePermissionGranted('denied')).toBe(false);
|
||||
});
|
||||
|
||||
it('requires microphone permission for voice capture', () => {
|
||||
expect(isVoiceCaptureAllowed({ microphone: 'granted' })).toBe(true);
|
||||
expect(isVoiceCaptureAllowed({ microphone: 'denied' })).toBe(false);
|
||||
});
|
||||
|
||||
it('requires camera permission for camera capture', () => {
|
||||
expect(isCameraCaptureAllowed({ camera: 'granted' })).toBe(true);
|
||||
expect(isCameraCaptureAllowed({ camera: 'prompt' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { RuntimePlatform } from './platform-detection.rules';
|
||||
|
||||
export type MobileMediaPermissionState = 'granted' | 'denied' | 'prompt' | string;
|
||||
|
||||
export interface MobileCapturePermissionResult {
|
||||
microphone?: MobileMediaPermissionState;
|
||||
camera?: MobileMediaPermissionState;
|
||||
}
|
||||
|
||||
/** Whether a Capacitor permission alias was granted by the native shell. */
|
||||
export function isMobileCapturePermissionGranted(state: MobileMediaPermissionState | undefined): boolean {
|
||||
return state === 'granted';
|
||||
}
|
||||
|
||||
/** Native Android/iOS shells need an explicit runtime grant before WebRTC capture works reliably. */
|
||||
export function shouldPreflightMobileCapturePermissions(runtime: RuntimePlatform): boolean {
|
||||
return runtime === 'capacitor';
|
||||
}
|
||||
|
||||
/** Resolve whether voice capture can proceed after a native permission request. */
|
||||
export function isVoiceCaptureAllowed(result: MobileCapturePermissionResult): boolean {
|
||||
return isMobileCapturePermissionGranted(result.microphone);
|
||||
}
|
||||
|
||||
/** Resolve whether camera capture can proceed after a native permission request. */
|
||||
export function isCameraCaptureAllowed(result: MobileCapturePermissionResult): boolean {
|
||||
return isMobileCapturePermissionGranted(result.camera);
|
||||
}
|
||||
@@ -28,7 +28,6 @@ describe('mobile-sqlite-row-mapper.rules', () => {
|
||||
kind: 'user' as const,
|
||||
reactions: []
|
||||
};
|
||||
|
||||
const row = messageToRow(message);
|
||||
const restored = rowToMessage(row, [{ id: 'rx1', messageId: 'm1', oderId: 'u1', userId: 'u1', emoji: '👍', timestamp: 102 }]);
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import '@angular/compiler';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||
import { ViewportService } from '../../../core/platform/viewport.service';
|
||||
import { MobileAppUpdateService } from './mobile-app-update.service';
|
||||
import { MobilePlatformService } from './mobile-platform.service';
|
||||
|
||||
function createService(isMobile: boolean): MobileAppUpdateService {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
MobileAppUpdateService,
|
||||
MobilePlatformService,
|
||||
{
|
||||
provide: ElectronBridgeService,
|
||||
useValue: { isAvailable: false }
|
||||
},
|
||||
{
|
||||
provide: ViewportService,
|
||||
useValue: {
|
||||
isMobile: () => isMobile
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return runInInjectionContext(injector, () => injector.get(MobileAppUpdateService));
|
||||
}
|
||||
|
||||
describe('MobileAppUpdateService', () => {
|
||||
it('reports unsupported state on browser shells', () => {
|
||||
const service = createService(false);
|
||||
|
||||
expect(service.isCapacitor).toBe(false);
|
||||
expect(service.state().status).toBe('unsupported');
|
||||
});
|
||||
|
||||
it('exposes capacitor support flag from the mobile platform service', () => {
|
||||
const service = createService(true);
|
||||
|
||||
expect(service.isCapacitor).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
Injectable,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
|
||||
import { WebMobileAppUpdateAdapter } from '../adapters/web/web-mobile-app-update.adapter';
|
||||
import type { MobileAppUpdateAdapter } from '../contracts/mobile.contracts';
|
||||
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
||||
import {
|
||||
buildMobileUpdateState,
|
||||
createInitialMobileUpdateState,
|
||||
resolveMobileUpdateStatusMessage,
|
||||
type MobileUpdateState
|
||||
} from '../logic/mobile-app-update.rules';
|
||||
import { MobilePlatformService } from './mobile-platform.service';
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 30 * 60_000;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MobileAppUpdateService {
|
||||
readonly state = signal<MobileUpdateState>(createInitialMobileUpdateState());
|
||||
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
private adapter: MobileAppUpdateAdapter = new WebMobileAppUpdateAdapter();
|
||||
private adapterReady: Promise<MobileAppUpdateAdapter> | null = null;
|
||||
private initialized = false;
|
||||
private pollTimerId: number | null = null;
|
||||
|
||||
get isCapacitor(): boolean {
|
||||
return this.mobilePlatform.isCapacitor();
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
const adapter = await this.ensureAdapter();
|
||||
|
||||
this.state.set(createInitialMobileUpdateState({
|
||||
isSupported: adapter.isSupported
|
||||
}));
|
||||
|
||||
if (!adapter.isSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.startPolling();
|
||||
await this.checkForUpdates();
|
||||
}
|
||||
|
||||
async checkForUpdates(): Promise<void> {
|
||||
const adapter = await this.ensureAdapter();
|
||||
|
||||
if (!adapter.isSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.update((current) => ({
|
||||
...current,
|
||||
status: 'checking',
|
||||
statusMessage: resolveMobileUpdateStatusMessage('checking')
|
||||
}));
|
||||
|
||||
try {
|
||||
const snapshot = await adapter.getAppUpdateInfo();
|
||||
|
||||
this.state.set(buildMobileUpdateState(snapshot, {
|
||||
isSupported: true,
|
||||
lastCheckedAt: Date.now()
|
||||
}));
|
||||
} catch (error) {
|
||||
this.state.set({
|
||||
...createInitialMobileUpdateState({ isSupported: true }),
|
||||
lastCheckedAt: Date.now(),
|
||||
status: 'error',
|
||||
statusMessage: resolveMobileUpdateStatusMessage('error', error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async openAppStore(): Promise<void> {
|
||||
const adapter = await this.ensureAdapter();
|
||||
|
||||
if (!adapter.isSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
await adapter.openAppStore();
|
||||
}
|
||||
|
||||
async performImmediateUpdate(): Promise<void> {
|
||||
const adapter = await this.ensureAdapter();
|
||||
|
||||
if (!adapter.isSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.update((current) => ({
|
||||
...current,
|
||||
status: 'downloading',
|
||||
statusMessage: resolveMobileUpdateStatusMessage('downloading')
|
||||
}));
|
||||
|
||||
await adapter.performImmediateUpdate();
|
||||
}
|
||||
|
||||
async startFlexibleUpdate(): Promise<void> {
|
||||
const adapter = await this.ensureAdapter();
|
||||
|
||||
if (!adapter.isSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.update((current) => ({
|
||||
...current,
|
||||
status: 'downloading',
|
||||
statusMessage: resolveMobileUpdateStatusMessage('downloading')
|
||||
}));
|
||||
|
||||
await adapter.startFlexibleUpdate();
|
||||
}
|
||||
|
||||
async completeFlexibleUpdate(): Promise<void> {
|
||||
const adapter = await this.ensureAdapter();
|
||||
|
||||
if (!adapter.isSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
await adapter.completeFlexibleUpdate();
|
||||
}
|
||||
|
||||
private ensureAdapter(): Promise<MobileAppUpdateAdapter> {
|
||||
if (!this.adapterReady) {
|
||||
this.adapterReady = resolveMobileAdapter(
|
||||
this.mobilePlatform.runtime(),
|
||||
this.adapter,
|
||||
async () => {
|
||||
const { CapacitorMobileAppUpdateAdapter } = await import('../adapters/capacitor/capacitor-mobile-app-update.adapter');
|
||||
|
||||
return new CapacitorMobileAppUpdateAdapter();
|
||||
}
|
||||
).then((adapter) => {
|
||||
this.adapter = adapter;
|
||||
this.mobilePlatform.refreshRuntimeDetection();
|
||||
return adapter;
|
||||
});
|
||||
}
|
||||
|
||||
return this.adapterReady;
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
if (this.pollTimerId !== null || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pollTimerId = window.setInterval(() => {
|
||||
void this.checkForUpdates();
|
||||
}, DEFAULT_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@angular/core';
|
||||
|
||||
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
||||
import { ensureMobileCameraCapturePermissions, ensureMobileVoiceCapturePermissions } from '../logic/ensure-mobile-capture-permissions';
|
||||
import type { MobileMediaAdapter } from '../contracts/mobile.contracts';
|
||||
import { WebMobileMediaAdapter } from '../adapters/web/web-mobile-media.adapter';
|
||||
import { MobilePlatformService } from './mobile-platform.service';
|
||||
@@ -35,6 +36,14 @@ export class MobileMediaService {
|
||||
return this.ensureAdapter().then((adapter) => adapter.stopBackgroundAudioSession());
|
||||
}
|
||||
|
||||
ensureVoiceCapturePermissions(): Promise<boolean> {
|
||||
return ensureMobileVoiceCapturePermissions();
|
||||
}
|
||||
|
||||
ensureCameraCapturePermissions(): Promise<boolean> {
|
||||
return ensureMobileCameraCapturePermissions();
|
||||
}
|
||||
|
||||
private ensureAdapter(): Promise<MobileMediaAdapter> {
|
||||
if (!this.adapterReady) {
|
||||
this.adapterReady = resolveMobileAdapter(
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
Injector,
|
||||
runInInjectionContext
|
||||
} from '@angular/core';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -35,10 +32,12 @@ describe('DatabaseService', () => {
|
||||
getBansForRoom: vi.fn(() => Promise.resolve([])),
|
||||
initialize: vi.fn(() => Promise.resolve())
|
||||
};
|
||||
|
||||
capacitorDatabase = {
|
||||
getBansForRoom: vi.fn(() => Promise.resolve([])),
|
||||
initialize: vi.fn(() => Promise.resolve())
|
||||
};
|
||||
|
||||
electronDatabase = {
|
||||
getBansForRoom: vi.fn(() => Promise.resolve([])),
|
||||
initialize: vi.fn(() => Promise.resolve())
|
||||
|
||||
@@ -135,7 +135,9 @@ export class DatabaseService {
|
||||
saveReaction(reaction: Reaction) { return this.withReady(() => this.backend.saveReaction(reaction)); }
|
||||
|
||||
/** Remove a specific reaction (user + emoji + message). */
|
||||
removeReaction(messageId: string, userId: string, emoji: string) { return this.withReady(() => this.backend.removeReaction(messageId, userId, emoji)); }
|
||||
removeReaction(messageId: string, userId: string, emoji: string) {
|
||||
return this.withReady(() => this.backend.removeReaction(messageId, userId, emoji));
|
||||
}
|
||||
|
||||
/** Return all reactions for a given message. */
|
||||
getReactionsForMessage(messageId: string) { return this.withReady(() => this.backend.getReactionsForMessage(messageId)); }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Injectable,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
type Signal
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* and optional RNNoise-based noise reduction.
|
||||
*/
|
||||
import { Subject } from 'rxjs';
|
||||
import { ensureMobileCameraCapturePermissions, ensureMobileVoiceCapturePermissions } from '../../mobile/logic/ensure-mobile-capture-permissions';
|
||||
import { ChatEvent } from '../../../shared-kernel';
|
||||
import { LatencyProfile } from '../realtime.constants';
|
||||
import { PeerData } from '../realtime.types';
|
||||
@@ -223,6 +224,12 @@ export class MediaManager {
|
||||
);
|
||||
}
|
||||
|
||||
const voicePermissionsGranted = await ensureMobileVoiceCapturePermissions();
|
||||
|
||||
if (!voicePermissionsGranted) {
|
||||
throw new Error('Microphone permission was not granted.');
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
|
||||
|
||||
this.rawMicStream = stream;
|
||||
@@ -337,6 +344,12 @@ export class MediaManager {
|
||||
);
|
||||
}
|
||||
|
||||
const cameraPermissionsGranted = await ensureMobileCameraCapturePermissions();
|
||||
|
||||
if (!cameraPermissionsGranted) {
|
||||
throw new Error('Camera permission was not granted.');
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
|
||||
const cameraTrack = stream.getVideoTracks()[0];
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from 'vitest';
|
||||
import {
|
||||
SIGNALING_CONNECT_TIMEOUT_MS,
|
||||
SIGNALING_HEALTH_PROBE_INTERVAL_MS,
|
||||
SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS,
|
||||
SIGNALING_KEEPALIVE_INTERVAL_MS,
|
||||
SIGNALING_RECONNECT_BASE_DELAY_MS,
|
||||
|
||||
@@ -551,6 +551,7 @@ export class SignalingManager {
|
||||
this.stateHeartbeatTimer = setInterval(() => {
|
||||
this.runHeartbeatChecks();
|
||||
}, STATE_HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
void this.runHeartbeatChecks();
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export const PLUGIN_CAPABILITIES = [
|
||||
'ui.channelsSection',
|
||||
'ui.embeds',
|
||||
'ui.dom',
|
||||
'ui.commands',
|
||||
'storage.local',
|
||||
'storage.serverData.read',
|
||||
'storage.serverData.write',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { portalHostElementToBody } from './portal-host-to-body.logic';
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ import { portalHostElementToBody } from '../portal-host-to-body.logic';
|
||||
}
|
||||
})
|
||||
export class ScreenShareQualityDialogComponent implements OnInit, AfterViewInit {
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
selectedQuality = input.required<ScreenShareQuality>();
|
||||
includeSystemAudio = input(false);
|
||||
|
||||
@@ -36,6 +34,9 @@ export class ScreenShareQualityDialogComponent implements OnInit, AfterViewInit
|
||||
readonly qualityOptions = SCREEN_SHARE_QUALITY_OPTIONS;
|
||||
readonly activeQuality = signal<ScreenShareQuality>('balanced');
|
||||
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.cancelled.emit(undefined);
|
||||
|
||||
Reference in New Issue
Block a user