From 8ecfc9a1fe66cdf73abf70689f0087c0aa21a7f3 Mon Sep 17 00:00:00 2001 From: Myx Date: Fri, 5 Jun 2026 17:12:26 +0200 Subject: [PATCH] feat: Add slashcommand api --- .gitea/workflows/build-android-apk.yml | 11 +- agents-docs/LESSONS.md | 7 + agents-docs/features/mobile-capacitor.md | 28 +- .../developer/llm-plugin-builder-guide.md | 39 +- .../docs/plugin-development/api-reference.md | 49 ++ .../docs/plugin-development/api/commands.md | 114 +++ docs-site/docs/plugin-development/api/ui.md | 2 + .../docs/plugin-development/capabilities.md | 1 + docs-site/docs/plugin-development/examples.md | 55 ++ docs-site/docs/plugin-development/manifest.md | 1 + docs-site/docs/user-guide/plugins.md | 4 +- .../user-guide/text-and-direct-messages.md | 12 + docs-site/sidebars.ts | 3 +- electron/preload.ts | 6 +- eslint.config.js | 9 +- package-lock.json | 20 + package.json | 1 + toju-app/android/app/capacitor.build.gradle | 1 + .../android/app/src/main/AndroidManifest.xml | 3 + .../com/metoyou/app/MetoyouMobilePlugin.java | 53 +- toju-app/android/capacitor.settings.gradle | 3 + toju-app/ios/App/CapApp-SPM/Package.swift | 2 + toju-app/package.json | 1 + toju-app/public/plugins/e2e-all-api/README.md | 4 +- toju-app/public/plugins/e2e-all-api/main.js | 43 ++ .../plugins/e2e-all-api/toju.plugin.json | 4 +- toju-app/src/app/app.ts | 55 +- .../mobile-shell-layout.rules.spec.ts | 6 +- .../platform/mobile-shell-layout.rules.ts | 8 +- .../logic/attachment-blob.rules.spec.ts | 12 +- .../logic/local-file-path.rules.spec.ts | 1 + toju-app/src/app/domains/chat/README.md | 4 + .../chat-builtin-slash-commands.rules.spec.ts | 46 ++ .../chat-builtin-slash-commands.rules.ts | 43 ++ .../chat-message-composer.component.html | 16 +- .../chat-message-composer.component.ts | 184 ++++- .../chat-slash-command-menu.component.html | 51 ++ .../chat-slash-command-menu.component.ts | 67 ++ .../services/chat-markdown.service.spec.ts | 137 ++++ .../services/chat-markdown.service.ts | 49 +- .../custom-emoji/domain/custom-emoji.rules.ts | 15 +- .../services/direct-call.service.spec.ts | 5 +- .../services/direct-call.service.ts | 13 +- .../feature/dm-chat/dm-chat.component.html | 1 + .../dm-workspace/dm-chat-panel.component.ts | 4 +- .../find-people/find-people.component.spec.ts | 5 +- .../infrastructure/friend.repository.ts | 2 +- .../offline-queue.repository.ts | 2 +- .../experimental-media-settings.service.ts | 6 +- .../experimental-vlc-player.component.ts | 10 +- .../notification-settings-storage.service.ts | 2 +- toju-app/src/app/domains/plugins/README.md | 2 + .../plugin-client-api.service.spec.ts | 653 ++++++++++++++++++ .../services/plugin-client-api.service.ts | 20 + .../services/plugin-ui-registry.service.ts | 10 + .../plugin-client-api-surface.rules.spec.ts | 180 +++++ .../logic/plugin-client-api-surface.rules.ts | 228 ++++++ .../domain/logic/slash-command.rules.spec.ts | 143 ++++ .../domain/logic/slash-command.rules.ts | 141 ++++ .../domain/models/plugin-api.models.ts | 41 +- .../plugin-manager.component.html | 1 + .../plugin-manager.component.ts | 1 + .../plugin-store/plugin-store.component.ts | 8 +- toju-app/src/app/domains/plugins/index.ts | 1 + .../domain/logic/server-discovery.rules.ts | 5 +- .../logic/signal-server-tag.rules.spec.ts | 6 +- .../server-endpoint-storage.service.ts | 2 +- .../services/theme.service.spec.ts | 1 + .../voice-controls.component.ts | 8 + .../voice-workspace-stream-tile.component.ts | 6 +- .../debugging-settings.component.html | 2 +- .../updates-settings.component.html | 101 ++- .../updates-settings.component.ts | 52 +- .../native-context-menu.component.ts | 12 +- .../src/app/infrastructure/mobile/README.md | 5 +- .../capacitor-mobile-app-update.adapter.ts | 103 +++ .../capacitor/capacitor-plugin-loader.ts | 16 + .../capacitor/metoyou-mobile.plugin.ts | 4 + .../web/web-mobile-app-update.adapter.ts | 25 + .../mobile/contracts/mobile.contracts.ts | 20 + .../src/app/infrastructure/mobile/index.ts | 2 + .../ensure-mobile-capture-permissions.ts | 56 ++ ...android-manifest-permissions.rules.spec.ts | 24 + ...bile-android-manifest-permissions.rules.ts | 20 + .../logic/mobile-app-update.rules.spec.ts | 76 ++ .../mobile/logic/mobile-app-update.rules.ts | 151 ++++ .../mobile-media-permission.rules.spec.ts | 36 + .../logic/mobile-media-permission.rules.ts | 28 + .../mobile-sqlite-row-mapper.rules.spec.ts | 1 - .../mobile-app-update.service.spec.ts | 48 ++ .../services/mobile-app-update.service.ts | 166 +++++ .../mobile/services/mobile-media.service.ts | 9 + .../persistence/database.service.spec.ts | 7 +- .../persistence/database.service.ts | 4 +- .../realtime/ice-server-settings.service.ts | 1 - .../realtime/media/media.manager.ts | 13 + .../signaling/signaling.manager.spec.ts | 1 - .../realtime/signaling/signaling.manager.ts | 1 + .../shared-kernel/plugin-system.contracts.ts | 1 + .../portal-host-to-body.logic.spec.ts | 6 +- .../screen-share-quality-dialog.component.ts | 5 +- 101 files changed, 3526 insertions(+), 147 deletions(-) create mode 100644 docs-site/docs/plugin-development/api/commands.md create mode 100644 toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-builtin-slash-commands.rules.spec.ts create mode 100644 toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-builtin-slash-commands.rules.ts create mode 100644 toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-slash-command-menu.component.html create mode 100644 toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-slash-command-menu.component.ts create mode 100644 toju-app/src/app/domains/chat/feature/chat-messages/services/chat-markdown.service.spec.ts create mode 100644 toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.spec.ts create mode 100644 toju-app/src/app/domains/plugins/domain/logic/plugin-client-api-surface.rules.spec.ts create mode 100644 toju-app/src/app/domains/plugins/domain/logic/plugin-client-api-surface.rules.ts create mode 100644 toju-app/src/app/domains/plugins/domain/logic/slash-command.rules.spec.ts create mode 100644 toju-app/src/app/domains/plugins/domain/logic/slash-command.rules.ts create mode 100644 toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-app-update.adapter.ts create mode 100644 toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-app-update.adapter.ts create mode 100644 toju-app/src/app/infrastructure/mobile/logic/ensure-mobile-capture-permissions.ts create mode 100644 toju-app/src/app/infrastructure/mobile/logic/mobile-android-manifest-permissions.rules.spec.ts create mode 100644 toju-app/src/app/infrastructure/mobile/logic/mobile-android-manifest-permissions.rules.ts create mode 100644 toju-app/src/app/infrastructure/mobile/logic/mobile-app-update.rules.spec.ts create mode 100644 toju-app/src/app/infrastructure/mobile/logic/mobile-app-update.rules.ts create mode 100644 toju-app/src/app/infrastructure/mobile/logic/mobile-media-permission.rules.spec.ts create mode 100644 toju-app/src/app/infrastructure/mobile/logic/mobile-media-permission.rules.ts create mode 100644 toju-app/src/app/infrastructure/mobile/services/mobile-app-update.service.spec.ts create mode 100644 toju-app/src/app/infrastructure/mobile/services/mobile-app-update.service.ts diff --git a/.gitea/workflows/build-android-apk.yml b/.gitea/workflows/build-android-apk.yml index 89ea573..5d76cd2 100644 --- a/.gitea/workflows/build-android-apk.yml +++ b/.gitea/workflows/build-android-apk.yml @@ -39,7 +39,14 @@ jobs: - name: Install Android build toolchain run: | apt-get update - apt-get install -y --no-install-recommends openjdk-21-jdk wget unzip + apt-get install -y --no-install-recommends wget unzip ca-certificates gnupg + + # node:22 is Debian Bookworm — openjdk-21-jdk is not in default repos. + install -d /etc/apt/keyrings + wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor -o /etc/apt/keyrings/adoptium.gpg + echo "deb [signed-by=/etc/apt/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list + apt-get update + apt-get install -y --no-install-recommends temurin-21-jdk export ANDROID_SDK_ROOT=/opt/android-sdk mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" @@ -54,7 +61,7 @@ jobs: echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV" echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV" - echo "JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64" >> "$GITHUB_ENV" + echo "JAVA_HOME=/usr/lib/jvm/temurin-21-jdk-amd64" >> "$GITHUB_ENV" echo "PATH=$PATH:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_ENV" - name: Install dependencies diff --git a/agents-docs/LESSONS.md b/agents-docs/LESSONS.md index 36e0d06..1ba7246 100644 --- a/agents-docs/LESSONS.md +++ b/agents-docs/LESSONS.md @@ -25,6 +25,13 @@ Durable rules for AI agents working on this project. Read this file at session s ## Lessons +### Declare MODIFY_AUDIO_SETTINGS for Android WebRTC mic capture [mobile] [android] + +- **Trigger:** Android users accept the microphone prompt but voice calls and channels still fail to join. +- **Rule:** include `android.permission.MODIFY_AUDIO_SETTINGS` in `toju-app/android/app/src/main/AndroidManifest.xml` and preflight Capacitor capture through `MobileMediaService.ensureVoiceCapturePermissions()` before `getUserMedia`. +- **Why:** Capacitor's `BridgeWebChromeClient.onPermissionRequest` requests `RECORD_AUDIO` and `MODIFY_AUDIO_SETTINGS` together; if the latter is undeclared, the combined grant is treated as denied even after the user taps Allow. +- **Example:** `ANDROID_REQUIRED_MANIFEST_PERMISSIONS` in `mobile-android-manifest-permissions.rules.ts`. + ### Do not override Tailwind with box-sizing inherit [mobile] [css] - **Trigger:** mobile pages still overflow horizontally until devtools disables `*, *::before, *::after { box-sizing: inherit }` in global styles. diff --git a/agents-docs/features/mobile-capacitor.md b/agents-docs/features/mobile-capacitor.md index 53086d8..992cd05 100644 --- a/agents-docs/features/mobile-capacitor.md +++ b/agents-docs/features/mobile-capacitor.md @@ -54,7 +54,7 @@ The job installs JDK 21 and Android SDK platform 36 inside the `node:22` contain Optional `google-services.json` is not injected in CI; push registration in artifact builds follows the same optional-Firebase behavior as local unsigned debug builds. -After dependency or plugin changes, run `npm run build:prod && npm run cap:sync` so native projects register `@capacitor/app`, `@capacitor-community/sqlite`, push plugins, and `MetoyouMobile`. +After dependency or plugin changes, run `npm run build:prod && npm run cap:sync` so native projects register `@capacitor/app`, `@capacitor-community/sqlite`, `@capawesome/capacitor-app-update`, push plugins, and `MetoyouMobile`. ## Feature status @@ -71,6 +71,7 @@ After dependency or plugin changes, run `npm run build:prod && npm run cap:sync` | Camera sharing | **Working** | Existing `getUserMedia` camera path in WebRTC stack | | Speakerphone | **Working (partial)** | Android `AudioManager` via `MetoyouMobile`; iOS `@capgo/capacitor-audio-session`; direct-call speaker toggle on native mobile | | Local DB (SQLite) | **Working** | `DatabaseService` routes Capacitor shells to `CapacitorDatabaseService` (native SQLite CRUD) | +| Store app updates | **Working (partial)** | `@capawesome/capacitor-app-update` via `MobileAppUpdateService`; Android in-app updates when Play allows, iOS opens App Store | ## Platform limitations @@ -91,9 +92,26 @@ The app starts without Firebase. `MobilePushRegistrationService` probes `Metoyou 2. Copy `toju-app/android/app/google-services.json.example` to `google-services.json` (gitignored) and fill in your Firebase values. 3. Run `npm run cap:sync` so the Google Services Gradle plugin applies when the file is present (`build.gradle` applies it only when the JSON exists). 4. Rebuild with `npm run cap:build:android`. -5. Ensure `POST_NOTIFICATIONS`, `RECORD_AUDIO`, and foreground-service permissions are granted on Android 13+. +5. Ensure `POST_NOTIFICATIONS`, `RECORD_AUDIO`, `MODIFY_AUDIO_SETTINGS`, `CAMERA`, and foreground-service permissions are granted on Android 13+. 6. Verify `MobilePushRegistrationService` logs a registration token after login. +### Android runtime permissions (voice / camera) + +Capacitor's WebView requests `RECORD_AUDIO` **and** `MODIFY_AUDIO_SETTINGS` together for microphone capture. If `MODIFY_AUDIO_SETTINGS` is missing from `AndroidManifest.xml`, users can accept the prompt and `getUserMedia` still fails. + +Declared in `toju-app/android/app/src/main/AndroidManifest.xml`: + +| Permission | Purpose | +|------------|---------| +| `RECORD_AUDIO` | Microphone capture for voice calls and channels | +| `MODIFY_AUDIO_SETTINGS` | Required by Capacitor WebChromeClient alongside `RECORD_AUDIO` | +| `CAMERA` | WebRTC camera sharing and WebView file capture | +| `BLUETOOTH_CONNECT` | Bluetooth headset routing during calls (Android 12+) | +| `POST_NOTIFICATIONS` | Incoming/active call notifications | +| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session | + +Before WebRTC capture, the client calls `MobileMediaService.ensureVoiceCapturePermissions()` / `ensureCameraCapturePermissions()`, which delegate to `MetoyouMobile.requestVoiceCapturePermissions()` / `requestCameraCapturePermissions()` on Capacitor shells. + ### iOS (APNs) 1. Enable Push Notifications capability in Xcode for the `App` target. @@ -135,6 +153,7 @@ POST /api/users/device-tokens/:userId/dispatch - `FOREGROUND_SERVICE` - `FOREGROUND_SERVICE_MICROPHONE` - `RECORD_AUDIO` +- `MODIFY_AUDIO_SETTINGS` - `POST_NOTIFICATIONS` The service shows a low-importance ongoing notification while a call is active. @@ -152,7 +171,7 @@ The service shows a low-importance ongoing notification while a call is active. ## Capacitor plugin loading - `infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.ts` uses **static** `@capacitor/*` imports and `Capacitor.isPluginAvailable()` before returning a plugin. Do not `import()` plugin modules dynamically or `await` plugin objects (Capacitor proxies expose a throwing `.then()` stub). -- After adding or upgrading Capacitor plugins, run `npm run build:prod && npm run cap:sync` so Android/iOS native projects register `App`, `LocalNotifications`, push, and SQLite. +- After adding or upgrading Capacitor plugins, run `npm run build:prod && npm run cap:sync` so Android/iOS native projects register `App`, `AppUpdate`, `LocalNotifications`, push, and SQLite. ## Safe area (Android) @@ -208,7 +227,8 @@ Network security configs: - `ChatMessageComposerComponent` — `shouldShowAttachmentButton` + `pickAttachmentsFromDevice()`. - `VoiceWorkspaceStreamTileComponent` — PiP when focused stream tile backgrounds. - `MobileCallSessionService` — CallKit + foreground service + in-call notifications. -- `App` bootstrap — initializes mobile persistence, lifecycle, call-session, and push registration wiring. +- `App` bootstrap — initializes mobile persistence, lifecycle, app-update polling, call-session, and push registration wiring. +- `MobileAppUpdateService` — periodic Play Store / App Store checks (30 min) and settings UI actions; mirrors Electron `DesktopAppUpdateService` polling but uses native store APIs instead of release manifests. ## Phase 3 completion notes diff --git a/docs-site/docs/developer/llm-plugin-builder-guide.md b/docs-site/docs/developer/llm-plugin-builder-guide.md index d39d2c8..f423721 100644 --- a/docs-site/docs/developer/llm-plugin-builder-guide.md +++ b/docs-site/docs/developer/llm-plugin-builder-guide.md @@ -621,7 +621,7 @@ interface PluginApiCustomStreamRequest { label?: string; } -type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'manual'; +type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'slashCommand' | 'manual'; interface PluginApiActionContext { source: PluginApiActionSource; user: User | null; @@ -821,6 +821,10 @@ interface TojuClientPluginApi { registerEmbedRenderer: (id: string, contribution: PluginApiEmbedRendererContribution) => TojuPluginDisposable; mountElement: (id: string, request: PluginApiDomMountRequest) => TojuPluginDisposable; }; + readonly commands: { + register: (id: string, contribution: PluginApiSlashCommandContribution) => TojuPluginDisposable; + list: () => PluginApiSlashCommandContribution[]; + }; } ``` @@ -1178,6 +1182,8 @@ Capabilities: | `registerEmbedRenderer` | `ui.embeds` | | `mountElement` | `ui.dom` | +For `/` slash commands, use `api.commands.register` (capability `ui.commands`). See the Slash Commands subsection below. + Register side panel: ```js @@ -1310,6 +1316,36 @@ context.subscriptions.push( `mountElement` tags the element with plugin ownership metadata, replaces duplicate mounts for the same plugin/id, and removes it on disposal/unload. +### Slash Commands + +Capability: `commands.register` and `commands.list` both require `ui.commands`. + +Register `/` slash commands that appear in the chat composer's autocomplete menu. Set `scope: 'global'` (default) for commands available in chat servers and direct messages, or `scope: 'server'` for commands only offered while a chat server is active. Declare `options` to parse arguments into `context.args` (use `type: 'rest'` to capture all trailing text). The `run` callback receives a context with `source: 'slashCommand'`, the parsed `args`, the invoked `command` name, the raw `rawArgs`, and the current user/server/channel. + +```js +context.subscriptions.push( + api.commands.register('announce', { + name: 'announce', + description: 'Post an announcement to the current channel', + icon: 'megaphone', + scope: 'server', + options: [{ name: 'message', type: 'rest', required: true }], + run: (slash) => api.messages.send(`Announcement: ${slash.args.message}`, slash.textChannel?.id) + }) +); + +context.subscriptions.push( + api.commands.register('shrug', { + name: 'shrug', + description: 'Append the shrug emoticon', + scope: 'global', + run: () => api.messages.send('shrug') + }) +); +``` + +A command with no `options` runs immediately when picked; a command with options fills `/name ` so the user can type arguments before sending. Slash input is never posted as a chat message; unmatched `/text` falls through as a normal message. + ## Capability Cheat Sheet | API call group | Capabilities | @@ -1351,6 +1387,7 @@ context.subscriptions.push( | `ui.registerChannelSection` | `ui.channelsSection` | | `ui.registerEmbedRenderer` | `ui.embeds` | | `ui.mountElement` | `ui.dom` | +| `commands.register`, `commands.list` | `ui.commands` | ## Complete Example Plugin diff --git a/docs-site/docs/plugin-development/api-reference.md b/docs-site/docs/plugin-development/api-reference.md index 5f8c99e..3af63a9 100644 --- a/docs-site/docs/plugin-development/api-reference.md +++ b/docs-site/docs/plugin-development/api-reference.md @@ -318,6 +318,55 @@ interface PluginApiDomMountRequest { | `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` | Adds an embed renderer. | | `ui.mountElement(id, request)` | `ui.dom` | Mounts plugin-owned DOM into a target element or selector. | +## Slash Commands + +Slash commands appear in a Discord-style autocomplete menu when a user types `/` in the chat composer. A command with `scope: 'global'` (the default) is offered in every chat surface, including direct messages; a command with `scope: 'server'` only appears while a chat server is active. The user picks a command from the menu (or types it and presses Enter) and the `run` callback executes with the parsed arguments and the current interaction context. + +```ts +type PluginApiSlashCommandScope = 'global' | 'server'; + +interface PluginApiSlashCommandOption { + description?: string; + name: string; + required?: boolean; + // 'rest' captures all remaining text; otherwise a single whitespace-delimited token + type?: 'string' | 'number' | 'boolean' | 'rest'; +} + +interface PluginApiSlashCommandContext extends PluginApiActionContext { + args: Record; // parsed values keyed by option name + command: string; // invoked name without the leading slash + rawArgs: string; // raw text typed after the command name +} + +interface PluginApiSlashCommandContribution { + description?: string; + icon?: string; + name: string; + options?: PluginApiSlashCommandOption[]; + run: (context: PluginApiSlashCommandContext) => Promise | void; + scope?: PluginApiSlashCommandScope; +} +``` + +| Method | Capability | Description | +| ----------------------------------- | -------------- | ------------------------------------------------------------------- | +| `commands.register(id, command)` | `ui.commands` | Registers a `/` slash command for the chat composer. | +| `commands.list()` | `ui.commands` | Lists every slash command currently registered across all plugins. | + +```ts +context.subscriptions.push( + api.commands.register('shout', { + description: 'Shout a message in uppercase', + icon: '📢', + name: 'shout', + options: [{ name: 'message', required: true, type: 'rest' }], + run: (slash) => api.messages.send(slash.args.message.toUpperCase()), + scope: 'server' + }) +); +``` + ## Context and Logger | Method | Capability | Description | diff --git a/docs-site/docs/plugin-development/api/commands.md b/docs-site/docs/plugin-development/api/commands.md new file mode 100644 index 0000000..9ae77f5 --- /dev/null +++ b/docs-site/docs/plugin-development/api/commands.md @@ -0,0 +1,114 @@ +--- +sidebar_position: 12 +--- + +# Slash Commands API + +The Commands API lets plugins register `/` slash commands. When a user types `/` in the chat composer, MetoYou shows a Discord-style autocomplete menu of available commands. Selecting a command (click, `Enter`, or `Tab`) runs it — either immediately when it declares no options, or after the user types the requested arguments. + +## Required Capabilities + +| Method | Capability | +| --------------------------------- | ------------- | +| `commands.register(id, command)` | `ui.commands` | +| `commands.list()` | `ui.commands` | + +Every registration returns a disposable. Push it into `context.subscriptions` so the command is removed when the plugin unloads. + +## Command Scope + +A command's `scope` controls where it appears: + +| Scope | Available in | +| ------------------- | --------------------------------------------- | +| `global` (default) | Chat servers **and** direct messages | +| `server` | Only while a chat server is the active surface | + +Use `global` for commands that work without a server context (e.g. `/help`, `/shrug`). Use `server` for commands that act on the current server, channel, or members. + +## Options and Argument Parsing + +Declare `options` to describe the arguments a command accepts. MetoYou parses what the user typed after the command name and passes the result to `run` as `context.args`, keyed by option name. + +```ts +interface PluginApiSlashCommandOption { + description?: string; + name: string; + required?: boolean; + // 'rest' captures all remaining text; otherwise a single whitespace-delimited token + type?: 'string' | 'number' | 'boolean' | 'rest'; +} +``` + +- Positional options are filled left-to-right from whitespace-delimited tokens. +- A `rest` option captures all remaining text verbatim (use it last, for free-form text). +- Missing positional values are passed as empty strings. +- The autocomplete menu shows required options as `` and optional ones as `[name]`. + +Values arrive as strings; convert `number`/`boolean` types yourself inside `run`. + +## Command Context + +`run` receives a context that extends the standard action context (`source: 'slashCommand'`) with the invocation details: + +```ts +interface PluginApiSlashCommandContext extends PluginApiActionContext { + args: Record; // parsed values keyed by option name + command: string; // invoked name without the leading slash + rawArgs: string; // raw text typed after the command name + // inherited: server, textChannel, voiceChannel, user, source +} +``` + +## Register a Command + +```js +export function activate(context) { + const api = context.api; + + // Server-scoped command with a free-form message argument. + context.subscriptions.push( + api.commands.register('announce', { + name: 'announce', + description: 'Post an announcement to the current channel', + icon: '📢', + scope: 'server', + options: [{ name: 'message', type: 'rest', required: true }], + run: (slash) => { + api.messages.send(`📢 ${slash.args.message}`, slash.textChannel?.id); + } + }) + ); + + // Global command that works in servers and DMs. + context.subscriptions.push( + api.commands.register('shrug', { + name: 'shrug', + description: 'Append the shrug emoticon', + scope: 'global', + run: () => api.messages.send('¯\\_(ツ)_/¯') + }) + ); +} +``` + +`api.messages.send` requires the `messages.send` capability, so the example above declares both `ui.commands` and `messages.send` in its manifest. + +## List Registered Commands + +```js +const allCommands = context.api.commands.list(); +``` + +Returns every slash command currently registered across all active plugins, including their scope and options. + +## Built-in Commands + +MetoYou ships first-party commands that are always available without any plugin, such as `/lenny` (posts `( ͡° ͜ʖ ͡°)`). They appear in the same autocomplete menu tagged as **Built-in**. Plugin commands are listed alongside them; if a plugin registers a command with the same name as a built-in, both appear and the user can pick either. + +## How Input Is Handled + +- Typing `/` opens the menu; typing more characters filters by command name (prefix matches rank first). +- Picking a command **without options** runs it immediately and clears the composer. +- Picking a command **with options** fills `/name ` so the user can type arguments, then `Enter` runs it. +- Slash input is intercepted and never posted as a chat message. Text that starts with `/` but matches no registered command falls through and is sent as a normal message. diff --git a/docs-site/docs/plugin-development/api/ui.md b/docs-site/docs/plugin-development/api/ui.md index ba1f52b..bf084e8 100644 --- a/docs-site/docs/plugin-development/api/ui.md +++ b/docs-site/docs/plugin-development/api/ui.md @@ -6,6 +6,8 @@ sidebar_position: 11 The UI API lets plugins add pages, settings pages, side panels, channel sections, actions, embed renderers, and controlled DOM mounts. +For `/` slash commands in the chat composer, see the [Slash Commands API](./commands.md) (`api.commands`). + Prefer registered UI contributions over direct DOM mounting. Contribution APIs let Angular render the plugin UI when the matching app surface exists. Direct DOM mounting runs immediately and throws if the target selector is not present. ## Required Capabilities diff --git a/docs-site/docs/plugin-development/capabilities.md b/docs-site/docs/plugin-development/capabilities.md index 65bf99c..b7c49ae 100644 --- a/docs-site/docs/plugin-development/capabilities.md +++ b/docs-site/docs/plugin-development/capabilities.md @@ -37,6 +37,7 @@ Capabilities protect privileged app surfaces. A plugin must declare a capability | `ui.channelsSection` | `ui.registerChannelSection()` | Adds channel sections. | | `ui.embeds` | `ui.registerEmbedRenderer()` | Renders custom embeds. | | `ui.dom` | `ui.mountElement()` | Mounts plugin-owned DOM into app targets. | +| `ui.commands` | `commands.register()`, `commands.list()` | Registers `/` slash commands (global or server scope) and lists registered commands. | | `storage.local` | `storage.*`, `clientData.*` | Reads and writes plugin-local data. | | `storage.serverData.read` | `serverData.read()` | Reads local per-user/per-server plugin data. | | `storage.serverData.write` | `serverData.write()`, `serverData.remove()` | Writes or removes local per-user/per-server plugin data. | diff --git a/docs-site/docs/plugin-development/examples.md b/docs-site/docs/plugin-development/examples.md index 78b6ac7..480aa03 100644 --- a/docs-site/docs/plugin-development/examples.md +++ b/docs-site/docs/plugin-development/examples.md @@ -45,6 +45,61 @@ export function activate(context) { The action appears as a tile in the server side panel's View plugins menu and runs with `source: 'toolbarAction'`. +## Slash Command Plugin + +`toju-plugin.json` + +```json +{ + "schemaVersion": 1, + "id": "example.slash-commands", + "title": "Slash Commands", + "description": "Registers / commands available from the chat composer.", + "version": "1.0.0", + "kind": "client", + "scope": "client", + "apiVersion": "1.0.0", + "compatibility": { + "minimumTojuVersion": "1.0.0", + "verifiedTojuVersion": "1.0.0" + }, + "entrypoint": "./main.js", + "capabilities": ["messages.send", "ui.commands"] +} +``` + +`main.js` + +```js +export function activate(context) { + const { api } = context; + + // Global: works in chat servers and direct messages. + context.subscriptions.push( + api.commands.register('shrug', { + name: 'shrug', + description: 'Append the shrug emoticon', + scope: 'global', + run: () => api.messages.send('¯\\_(ツ)_/¯') + }) + ); + + // Server-scoped: only offered while a chat server is active. + context.subscriptions.push( + api.commands.register('announce', { + name: 'announce', + description: 'Post an announcement to the current channel', + icon: '📢', + scope: 'server', + options: [{ name: 'message', type: 'rest', required: true }], + run: (slash) => api.messages.send(`📢 ${slash.args.message}`, slash.textChannel?.id) + }) + ); +} +``` + +Typing `/` in the composer opens the autocomplete menu. `/shrug` runs immediately; `/announce ` fills the composer so the user can type the announcement before sending. See the [Slash Commands API](./api/commands.md) for option parsing and the command context. + ## Settings Page Plugin ```json diff --git a/docs-site/docs/plugin-development/manifest.md b/docs-site/docs/plugin-development/manifest.md index 8686c62..b9bfe7d 100644 --- a/docs-site/docs/plugin-development/manifest.md +++ b/docs-site/docs/plugin-development/manifest.md @@ -41,6 +41,7 @@ type PluginCapabilityId = | 'ui.channelsSection' | 'ui.embeds' | 'ui.dom' + | 'ui.commands' | 'storage.local' | 'storage.serverData.read' | 'storage.serverData.write' diff --git a/docs-site/docs/user-guide/plugins.md b/docs-site/docs/user-guide/plugins.md index 66db568..36f29a2 100644 --- a/docs-site/docs/user-guide/plugins.md +++ b/docs-site/docs/user-guide/plugins.md @@ -30,6 +30,8 @@ Server-scoped plugins installed to the server you are currently viewing are enab When plugins add quick actions to a server, the server side panel shows a View plugins link in the plugin area. Open it to see a grid of plugin action tiles. Selecting a tile runs that plugin's action in the current server and channel context. +Plugins can also add `/` slash commands. Type `/` in the message box to open the command menu; plugin commands appear there tagged with the plugin name, alongside built-in commands like `/lenny`. See [Text and Direct Messages](./text-and-direct-messages.md#slash-commands) for how to use the menu. + ## Install a Local Plugin Desktop builds can discover local plugin folders from the app data plugins directory. @@ -65,7 +67,7 @@ Examples: | Messages | Send messages, read current messages, moderate messages, or render embeds. | | Users and roles | Read member lists, create plugin users, or manage users. | | Voice and media | Play audio, add an audio stream, add a video stream, or adjust volume. | -| UI | Add pages, settings pages, side panels, toolbar buttons, or DOM elements. | +| UI | Add pages, settings pages, side panels, toolbar buttons, slash commands, or DOM elements. | | Storage | Save plugin preferences locally or per server. | Only grant capabilities to plugins you trust. diff --git a/docs-site/docs/user-guide/text-and-direct-messages.md b/docs-site/docs/user-guide/text-and-direct-messages.md index 06c2054..cc74aa3 100644 --- a/docs-site/docs/user-guide/text-and-direct-messages.md +++ b/docs-site/docs/user-guide/text-and-direct-messages.md @@ -13,6 +13,7 @@ Text channels belong to a server. Everyone with access to that server and channe You can use text channels to: - send normal messages; +- run slash commands by typing `/`; - edit or delete your own messages when allowed; - react to messages; - send attachments; @@ -24,6 +25,17 @@ You can use text channels to: Direct messages are private conversations outside a server channel. Use them when a message is meant for one person instead of the server. +## Slash Commands + +Type `/` at the start of the message box to open the slash command menu. It lists the commands you can run, with a short description for each. + +- Keep typing to filter the list (for example `/le`). +- Use the up and down arrow keys to move through the list, then press `Enter` or `Tab` to pick a command. You can also click one. +- Press `Escape` to close the menu. +- A command that needs extra text fills the box with `/name ` so you can type the rest, then send it. + +MetoYou includes built-in commands such as `/lenny`, which posts `( ͡° ͜ʖ ͡°)`. Plugins can add their own commands, which appear in the same menu (tagged with the plugin name). Slash commands are available in both text channels and direct messages; some plugin commands only appear inside a server. Text that starts with `/` but matches no command is sent as a normal message. + ## Attachments and Media Attachments can appear as files, images, audio, or video depending on the file type and what the app can preview. If an image or link cannot load directly, the app can use fallback paths where available. diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index 14ed58d..cf95737 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -50,7 +50,8 @@ const sidebars: SidebarsConfig = { 'plugin-development/api/message-bus', 'plugin-development/api/p2p-and-media', 'plugin-development/api/storage', - 'plugin-development/api/ui' + 'plugin-development/api/ui', + 'plugin-development/api/commands' ] }, 'plugin-development/examples' diff --git a/electron/preload.ts b/electron/preload.ts index 137a225..4ca060c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,8 @@ -import { contextBridge, ipcRenderer, webUtils } from 'electron'; +import { + contextBridge, + ipcRenderer, + webUtils +} from 'electron'; import { Command, Query } from './cqrs/types'; const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk'; diff --git a/eslint.config.js b/eslint.config.js index 1beacde..1db5c22 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,7 +9,14 @@ const metoyouEslintRules = require('./tools/eslint-rules'); module.exports = tseslint.config( { - ignores: ['**/.angular/**', '**/generated/*', '**/dist/**', '**/migrations/**', 'release/**'] + ignores: [ + '**/.angular/**', + '**/android/**', + '**/generated/*', + '**/dist/**', + '**/migrations/**', + 'release/**' + ] }, { files: ['**/*.ts'], diff --git a/package-lock.json b/package-lock.json index 7b4a957..13b5199 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@capacitor/filesystem": "^8.1.2", "@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", "@codemirror/commands": "^6.10.3", "@codemirror/lang-css": "^6.3.1", @@ -3002,6 +3003,25 @@ "integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==", "license": "ISC" }, + "node_modules/@capawesome/capacitor-app-update": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@capawesome/capacitor-app-update/-/capacitor-app-update-8.0.3.tgz", + "integrity": "sha512-5pS9I+i2TuUl8b6L3h2WsWF/PE68exzoc9CciXK/I2SdB2fIkdxObrYBeYS2Z09ndCGqJgx4iwZJmhXwVcMh4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/capawesome-team/" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/capawesome" + } + ], + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capgo/capacitor-audio-session": { "version": "8.0.40", "resolved": "https://registry.npmjs.org/@capgo/capacitor-audio-session/-/capacitor-audio-session-8.0.40.tgz", diff --git a/package.json b/package.json index 5f401da..30bfa9f 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "@capacitor/filesystem": "^8.1.2", "@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", "@codemirror/commands": "^6.10.3", "@codemirror/lang-css": "^6.3.1", diff --git a/toju-app/android/app/capacitor.build.gradle b/toju-app/android/app/capacitor.build.gradle index 16cd606..8b7e59f 100644 --- a/toju-app/android/app/capacitor.build.gradle +++ b/toju-app/android/app/capacitor.build.gradle @@ -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') } diff --git a/toju-app/android/app/src/main/AndroidManifest.xml b/toju-app/android/app/src/main/AndroidManifest.xml index df3c780..b03d540 100644 --- a/toju-app/android/app/src/main/AndroidManifest.xml +++ b/toju-app/android/app/src/main/AndroidManifest.xml @@ -53,6 +53,9 @@ + + + diff --git a/toju-app/android/app/src/main/java/com/metoyou/app/MetoyouMobilePlugin.java b/toju-app/android/app/src/main/java/com/metoyou/app/MetoyouMobilePlugin.java index fb1fbdd..44a9d4c 100644 --- a/toju-app/android/app/src/main/java/com/metoyou/app/MetoyouMobilePlugin.java +++ b/toju-app/android/app/src/main/java/com/metoyou/app/MetoyouMobilePlugin.java @@ -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); + } } diff --git a/toju-app/android/capacitor.settings.gradle b/toju-app/android/capacitor.settings.gradle index 08c11ad..59d7cce 100644 --- a/toju-app/android/capacitor.settings.gradle +++ b/toju-app/android/capacitor.settings.gradle @@ -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') diff --git a/toju-app/ios/App/CapApp-SPM/Package.swift b/toju-app/ios/App/CapApp-SPM/Package.swift index dcbf975..09742b6 100644 --- a/toju-app/ios/App/CapApp-SPM/Package.swift +++ b/toju-app/ios/App/CapApp-SPM/Package.swift @@ -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") ] ) diff --git a/toju-app/package.json b/toju-app/package.json index 41c0d97..6b768d8 100644 --- a/toju-app/package.json +++ b/toju-app/package.json @@ -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" } } diff --git a/toju-app/public/plugins/e2e-all-api/README.md b/toju-app/public/plugins/e2e-all-api/README.md index 58a8f81..68bec47 100644 --- a/toju-app/public/plugins/e2e-all-api/README.md +++ b/toju-app/public/plugins/e2e-all-api/README.md @@ -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 ` (server scope, echoes text back into the channel) and `/e2e-ping` (global scope, logs a pong), exercising `api.commands.register` / `api.commands.list`. diff --git a/toju-app/public/plugins/e2e-all-api/main.js b/toju-app/public/plugins/e2e-all-api/main.js index 1169914..074f7ad 100644 --- a/toju-app/public/plugins/e2e-all-api/main.js +++ b/toju-app/public/plugins/e2e-all-api/main.js @@ -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 }); diff --git a/toju-app/public/plugins/e2e-all-api/toju.plugin.json b/toju-app/public/plugins/e2e-all-api/toju.plugin.json index e4556d2..ab39287 100644 --- a/toju-app/public/plugins/e2e-all-api/toju.plugin.json +++ b/toju-app/public/plugins/e2e-all-api/toju.plugin.json @@ -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", diff --git a/toju-app/src/app/app.ts b/toju-app/src/app/app.ts index 5be0a48..5222daa 100644 --- a/toju-app/src/app/app.ts +++ b/toju-app/src/app/app.ts @@ -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; diff --git a/toju-app/src/app/core/platform/mobile-shell-layout.rules.spec.ts b/toju-app/src/app/core/platform/mobile-shell-layout.rules.spec.ts index bf302a8..e73bd23 100644 --- a/toju-app/src/app/core/platform/mobile-shell-layout.rules.spec.ts +++ b/toju-app/src/app/core/platform/mobile-shell-layout.rules.spec.ts @@ -1,4 +1,8 @@ -import { describe, expect, it } from 'vitest'; +import { + describe, + expect, + it +} from 'vitest'; import { shouldShowMobileAppServersRail } from './mobile-shell-layout.rules'; diff --git a/toju-app/src/app/core/platform/mobile-shell-layout.rules.ts b/toju-app/src/app/core/platform/mobile-shell-layout.rules.ts index 1458092..955c1b0 100644 --- a/toju-app/src/app/core/platform/mobile-shell-layout.rules.ts +++ b/toju-app/src/app/core/platform/mobile-shell-layout.rules.ts @@ -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 { diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.spec.ts index 1aa065c..ffaa95f 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.spec.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.spec.ts @@ -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 + ]); }); }); diff --git a/toju-app/src/app/domains/attachment/domain/logic/local-file-path.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/local-file-path.rules.spec.ts index b53b991..147da6c 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/local-file-path.rules.spec.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/local-file-path.rules.spec.ts @@ -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'); diff --git a/toju-app/src/app/domains/chat/README.md b/toju-app/src/app/domains/chat/README.md index d188b18..1668caf 100644 --- a/toju-app/src/app/domains/chat/README.md +++ b/toju-app/src/app/domains/chat/README.md @@ -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 | diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-builtin-slash-commands.rules.spec.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-builtin-slash-commands.rules.spec.ts new file mode 100644 index 0000000..1b25976 --- /dev/null +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-builtin-slash-commands.rules.spec.ts @@ -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('( ͡° ͜ʖ ͡°)'); + }); +}); diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-builtin-slash-commands.rules.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-builtin-slash-commands.rules.ts new file mode 100644 index 0000000..e1b0c7d --- /dev/null +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-builtin-slash-commands.rules.ts @@ -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 + })); +} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html index 12fb11a..d2d3fbf 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html @@ -1,8 +1,19 @@
+ @if (slashMenuOpen()) { +
+ +
+ } + @if (replyTo()) {
(null); readonly textareaTestId = input(null); + readonly commandSurface = input('server'); readonly messageSubmitted = output(); 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(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 }); } diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-slash-command-menu.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-slash-command-menu.component.html new file mode 100644 index 0000000..0ba5010 --- /dev/null +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-slash-command-menu.component.html @@ -0,0 +1,51 @@ +@if (commands().length > 0) { +
+
+ Commands + {{ commands().length }} +
+ +
+ @for (entry of commands(); track entry.id; let index = $index) { + + } +
+
+} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-slash-command-menu.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-slash-command-menu.component.ts new file mode 100644 index 0000000..8efedde --- /dev/null +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-slash-command-menu.component.ts @@ -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([]); + readonly activeIndex = input(0); + + readonly commandPicked = output(); + readonly activeIndexChanged = output(); + + private readonly host = inject>(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('[data-slash-active="true"]'); + + active?.scrollIntoView({ block: 'nearest' }); + } +} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/services/chat-markdown.service.spec.ts b/toju-app/src/app/domains/chat/feature/chat-messages/services/chat-markdown.service.spec.ts new file mode 100644 index 0000000..482b2a2 --- /dev/null +++ b/toju-app/src/app/domains/chat/feature/chat-messages/services/chat-markdown.service.spec.ts @@ -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); + }); + }); +}); diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/services/chat-markdown.service.ts b/toju-app/src/app/domains/chat/feature/chat-messages/services/chat-markdown.service.ts index a1040af..3595553 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/services/chat-markdown.service.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/services/chat-markdown.service.ts @@ -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 = `![${selected}](https://)`; + 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 { diff --git a/toju-app/src/app/domains/custom-emoji/domain/custom-emoji.rules.ts b/toju-app/src/app/domains/custom-emoji/domain/custom-emoji.rules.ts index 13433c0..9a230a8 100644 --- a/toju-app/src/app/domains/custom-emoji/domain/custom-emoji.rules.ts +++ b/toju-app/src/app/domains/custom-emoji/domain/custom-emoji.rules.ts @@ -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 { diff --git a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts index 6114e04..9d1fa17 100644 --- a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts +++ b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts @@ -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, diff --git a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts index 0bbf47b..e6b5be1 100644 --- a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts +++ b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts @@ -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([]); @@ -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, diff --git a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html index 3b72c81..a8104b3 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html +++ b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html @@ -111,6 +111,7 @@ [klipyEnabled]="klipyEnabled()" [klipySignalSource]="null" [textareaTestId]="'dm-input'" + [commandSurface]="'direct'" (messageSubmitted)="handleMessageSubmitted($event)" (typingStarted)="handleTypingStarted()" (replyCleared)="clearReply()" diff --git a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-chat-panel.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-chat-panel.component.ts index 9ee55e5..f18a00c 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-chat-panel.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-chat-panel.component.ts @@ -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); } diff --git a/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.spec.ts b/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.spec.ts index 43b1483..e547ab3 100644 --- a/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.spec.ts +++ b/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.spec.ts @@ -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)); diff --git a/toju-app/src/app/domains/direct-message/infrastructure/friend.repository.ts b/toju-app/src/app/domains/direct-message/infrastructure/friend.repository.ts index 17a4ebb..064d0a2 100644 --- a/toju-app/src/app/domains/direct-message/infrastructure/friend.repository.ts +++ b/toju-app/src/app/domains/direct-message/infrastructure/friend.repository.ts @@ -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'; diff --git a/toju-app/src/app/domains/direct-message/infrastructure/offline-queue.repository.ts b/toju-app/src/app/domains/direct-message/infrastructure/offline-queue.repository.ts index 01aaaae..082b9a3 100644 --- a/toju-app/src/app/domains/direct-message/infrastructure/offline-queue.repository.ts +++ b/toju-app/src/app/domains/direct-message/infrastructure/offline-queue.repository.ts @@ -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'; diff --git a/toju-app/src/app/domains/experimental-media/application/services/experimental-media-settings.service.ts b/toju-app/src/app/domains/experimental-media/application/services/experimental-media-settings.service.ts index 9d5a57f..104ae2d 100644 --- a/toju-app/src/app/domains/experimental-media/application/services/experimental-media-settings.service.ts +++ b/toju-app/src/app/domains/experimental-media/application/services/experimental-media-settings.service.ts @@ -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(); } diff --git a/toju-app/src/app/domains/experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component.ts b/toju-app/src/app/domains/experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component.ts index 5eaa3b9..c748c6b 100644 --- a/toju-app/src/app/domains/experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component.ts +++ b/toju-app/src/app/domains/experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component.ts @@ -40,15 +40,15 @@ export class ExperimentalVlcPlayerComponent implements AfterViewInit, OnDestroy mime = input.required(); sizeLabel = input(''); - closed = output(); - downloadRequested = output(); - - 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(); } diff --git a/toju-app/src/app/domains/notifications/infrastructure/services/notification-settings-storage.service.ts b/toju-app/src/app/domains/notifications/infrastructure/services/notification-settings-storage.service.ts index 56472e8..10bc3e0 100644 --- a/toju-app/src/app/domains/notifications/infrastructure/services/notification-settings-storage.service.ts +++ b/toju-app/src/app/domains/notifications/infrastructure/services/notification-settings-storage.service.ts @@ -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'; diff --git a/toju-app/src/app/domains/plugins/README.md b/toju-app/src/app/domains/plugins/README.md index c65ef0d..6aa200d 100644 --- a/toju-app/src/app/domains/plugins/README.md +++ b/toju-app/src/app/domains/plugins/README.md @@ -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. diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.spec.ts b/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.spec.ts new file mode 100644 index 0000000..2e7741a --- /dev/null +++ b/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.spec.ts @@ -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>)).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 unknown>; + + await expect(invokeApiMethod(target[method], path)).rejects.toThrow(PluginCapabilityError); + } + }); +}); + +interface ServiceTestContext { + capabilities: PluginCapabilityService; + channels: ReturnType>; + currentUser: ReturnType>; + logger: { + debug: ReturnType; + error: ReturnType; + info: ReturnType; + warn: ReturnType; + }; + messages: ReturnType>; + realtime: { + onSignalingMessage: Subject; + sendRawMessage: ReturnType; + }; + room: ReturnType>; + service: PluginClientApiService; + storage: { + getLocal: ReturnType; + readClientData: ReturnType; + readServerData: ReturnType; + removeClientData: ReturnType; + removeLocal: ReturnType; + removeServerData: ReturnType; + setLocal: ReturnType; + writeClientData: ReturnType; + writeServerData: ReturnType; + }; + store: { + dispatch: ReturnType; + }; + uiRegistry: { + registerSlashCommand: ReturnType; + slashCommands: ReturnType; + }; + voice: { + broadcastMessage: ReturnType; + getConnectedPeers: ReturnType; + setInputVolume: ReturnType; + setLocalStream: ReturnType; + setOutputVolume: ReturnType; + }; +} + +function createServiceTestContext(): ServiceTestContext { + installLocalStorageMock(); + installBrowserMediaMocks(); + + const currentUser = signal(createUser()); + const users = signal(currentUser() ? [currentUser() as User] : []); + const room = signal(createRoom()); + const channels = signal(room()?.channels ?? []); + const messages = signal([]); + 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(), + 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 { + 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 {} + }); +} + +function installLocalStorageMock(): void { + const storage = new Map(); + + 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(); + } + }); +} diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts index 26f8284..c63848b 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts @@ -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({ + 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; 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; diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-ui-registry.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-ui-registry.service.ts index 9d678d1..699e273 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-ui-registry.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-ui-registry.service.ts @@ -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[]; settingsPages: PluginUiContributionRecord[]; sidePanels: PluginUiContributionRecord[]; + slashCommands: PluginUiContributionRecord[]; toolbarActions: PluginUiContributionRecord[]; }>({ 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) })); } diff --git a/toju-app/src/app/domains/plugins/domain/logic/plugin-client-api-surface.rules.spec.ts b/toju-app/src/app/domains/plugins/domain/logic/plugin-client-api-surface.rules.spec.ts new file mode 100644 index 0000000..bb816c6 --- /dev/null +++ b/toju-app/src/app/domains/plugins/domain/logic/plugin-client-api-surface.rules.spec.ts @@ -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' + ]); + }); +}); diff --git a/toju-app/src/app/domains/plugins/domain/logic/plugin-client-api-surface.rules.ts b/toju-app/src/app/domains/plugins/domain/logic/plugin-client-api-surface.rules.ts new file mode 100644 index 0000000..5e2a366 --- /dev/null +++ b/toju-app/src/app/domains/plugins/domain/logic/plugin-client-api-surface.rules.ts @@ -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> = { + '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>, + path: PluginClientApiMethodPath +): unknown { + const [namespace, method] = path.split('.') as [PluginClientApiNamespace, string]; + + return api[namespace]?.[method]; +} + +export function assertPluginApiSurfaceImplemented( + api: Record>, + 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(); +} diff --git a/toju-app/src/app/domains/plugins/domain/logic/slash-command.rules.spec.ts b/toju-app/src/app/domains/plugins/domain/logic/slash-command.rules.spec.ts new file mode 100644 index 0000000..6c44d0d --- /dev/null +++ b/toju-app/src/app/domains/plugins/domain/logic/slash-command.rules.spec.ts @@ -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 = {}, 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: '' }); + }); +}); diff --git a/toju-app/src/app/domains/plugins/domain/logic/slash-command.rules.ts b/toju-app/src/app/domains/plugins/domain/logic/slash-command.rules.ts new file mode 100644 index 0000000..4a54eff --- /dev/null +++ b/toju-app/src/app/domains/plugins/domain/logic/slash-command.rules.ts @@ -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 { + const args: Record = {}; + + 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; +} diff --git a/toju-app/src/app/domains/plugins/domain/models/plugin-api.models.ts b/toju-app/src/app/domains/plugins/domain/models/plugin-api.models.ts index 1f8d8de..aea79e9 100644 --- a/toju-app/src/app/domains/plugins/domain/models/plugin-api.models.ts +++ b/toju-app/src/app/domains/plugins/domain/models/plugin-api.models.ts @@ -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; + /** 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; + scope?: PluginApiSlashCommandScope; +} + export interface PluginApiTypingEvent extends Omit { 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; diff --git a/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html index 193d701..fb31853 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html +++ b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html @@ -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 diff --git a/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts index 721c0ea..6f8ea37 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts +++ b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts @@ -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() : []); diff --git a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts index 2ab2a5f..4b22763 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts +++ b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts @@ -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, diff --git a/toju-app/src/app/domains/plugins/index.ts b/toju-app/src/app/domains/plugins/index.ts index db923d9..0842b76 100644 --- a/toju-app/src/app/domains/plugins/index.ts +++ b/toju-app/src/app/domains/plugins/index.ts @@ -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'; diff --git a/toju-app/src/app/domains/server-directory/domain/logic/server-discovery.rules.ts b/toju-app/src/app/domains/server-directory/domain/logic/server-discovery.rules.ts index 6cad163..95f3e3b 100644 --- a/toju-app/src/app/domains/server-directory/domain/logic/server-discovery.rules.ts +++ b/toju-app/src/app/domains/server-directory/domain/logic/server-discovery.rules.ts @@ -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 { diff --git a/toju-app/src/app/domains/server-directory/domain/logic/signal-server-tag.rules.spec.ts b/toju-app/src/app/domains/server-directory/domain/logic/signal-server-tag.rules.spec.ts index d2eb4c3..84d8631 100644 --- a/toju-app/src/app/domains/server-directory/domain/logic/signal-server-tag.rules.spec.ts +++ b/toju-app/src/app/domains/server-directory/domain/logic/signal-server-tag.rules.spec.ts @@ -1,4 +1,8 @@ -import { describe, expect, it } from 'vitest'; +import { + describe, + expect, + it +} from 'vitest'; import { isSignalServerTagUrl, presentSignalServerTag, diff --git a/toju-app/src/app/domains/server-directory/infrastructure/services/server-endpoint-storage.service.ts b/toju-app/src/app/domains/server-directory/infrastructure/services/server-endpoint-storage.service.ts index a540c40..c94a31e 100644 --- a/toju-app/src/app/domains/server-directory/infrastructure/services/server-endpoint-storage.service.ts +++ b/toju-app/src/app/domains/server-directory/infrastructure/services/server-endpoint-storage.service.ts @@ -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, diff --git a/toju-app/src/app/domains/theme/application/services/theme.service.spec.ts b/toju-app/src/app/domains/theme/application/services/theme.service.spec.ts index 07bce2f..1b8cb74 100644 --- a/toju-app/src/app/domains/theme/application/services/theme.service.spec.ts +++ b/toju-app/src/app/domains/theme/application/services/theme.service.spec.ts @@ -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'); diff --git a/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts b/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts index 4a6bf7d..b19428a 100644 --- a/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts +++ b/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts @@ -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, diff --git a/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.ts b/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.ts index e500053..87572fc 100644 --- a/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.ts +++ b/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.ts @@ -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(); diff --git a/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.html b/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.html index 6e9b63d..9602415 100644 --- a/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.html +++ b/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.html @@ -41,7 +41,7 @@ /> Process RAM
- {{ ramLabel() ?? '—' }} + {{ ramLabel() ?? '-' }}

Live total working set from Electron app metrics. Updates every 2 seconds.

diff --git a/toju-app/src/app/features/settings/settings-modal/updates-settings/updates-settings.component.html b/toju-app/src/app/features/settings/settings-modal/updates-settings/updates-settings.component.html index d91e482..1df0a43 100644 --- a/toju-app/src/app/features/settings/settings-modal/updates-settings/updates-settings.component.html +++ b/toju-app/src/app/features/settings/settings-modal/updates-settings/updates-settings.component.html @@ -14,11 +14,104 @@ - @if (!isElectron) { -
-

Automatic updates are only available in the packaged Electron desktop app.

+ @if (isCapacitor) { +
+
+
+

Mobile app updates

+

+ Check the Play Store or App Store for newer native builds. Android can install in-app updates when Google Play allows it. +

+
+ + + {{ mobileStatusLabel() }} + +
- } @else { + + @if (!mobileState().isSupported) { +
+

Store updates are only available in the packaged Android or iOS app.

+
+ } @else { +
+
+

Installed

+

{{ mobileState().currentVersion }}

+
+ +
+

Store version

+

{{ mobileState().availableVersion || 'Unknown' }}

+
+ +
+

Last checked

+

+ {{ mobileState().lastCheckedAt ? (mobileState().lastCheckedAt | date: 'medium') : 'Not checked yet' }} +

+
+
+ +
+
+

Status

+

+ {{ mobileState().statusMessage || 'Waiting for the first store update check.' }} +

+
+ +
+ + + @if (mobileState().status === 'update-available') { + + + @if (mobileState().immediateUpdateAllowed || mobileState().flexibleUpdateAllowed) { + + } + + @if (mobileState().flexibleUpdateAllowed && mobileState().status === 'downloading') { + + } + } +
+
+ } + } + + @if (!isElectron && !isCapacitor) { +
+

Automatic updates are only available in the packaged Electron desktop app or native mobile app.

+
+ } + + @if (isElectron) {

Installed

diff --git a/toju-app/src/app/features/settings/settings-modal/updates-settings/updates-settings.component.ts b/toju-app/src/app/features/settings/settings-modal/updates-settings/updates-settings.component.ts index 42a3253..892a7c0 100644 --- a/toju-app/src/app/features/settings/settings-modal/updates-settings/updates-settings.component.ts +++ b/toju-app/src/app/features/settings/settings-modal/updates-settings/updates-settings.component.ts @@ -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 { 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 { - 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 { - await this.updates.saveManifestUrls( + await this.desktopUpdates.saveManifestUrls( this.parseManifestUrls(this.manifestUrlsText()) ); @@ -90,12 +97,37 @@ export class UpdatesSettingsComponent { } async useConnectedServerDefaults(): Promise { - await this.updates.saveManifestUrls([]); + await this.desktopUpdates.saveManifestUrls([]); this.hasPendingManifestUrlChanges.set(false); } async restartNow(): Promise { - await this.updates.restartToApplyUpdate(); + await this.desktopUpdates.restartToApplyUpdate(); + } + + async refreshMobileReleaseInfo(): Promise { + await this.mobileUpdates.checkForUpdates(); + } + + async openMobileAppStore(): Promise { + await this.mobileUpdates.openAppStore(); + } + + async installMobileUpdateNow(): Promise { + const mobileState = this.mobileState(); + + if (mobileState.immediateUpdateAllowed) { + await this.mobileUpdates.performImmediateUpdate(); + return; + } + + if (mobileState.flexibleUpdateAllowed) { + await this.mobileUpdates.startFlexibleUpdate(); + } + } + + async completeMobileFlexibleUpdate(): Promise { + await this.mobileUpdates.completeFlexibleUpdate(); } private parseManifestUrls(rawValue: string): string[] { diff --git a/toju-app/src/app/features/shell/native-context-menu/native-context-menu.component.ts b/toju-app/src/app/features/shell/native-context-menu/native-context-menu.component.ts index 4c6742b..a26a7bd 100644 --- a/toju-app/src/app/features/shell/native-context-menu/native-context-menu.component.ts +++ b/toju-app/src/app/features/shell/native-context-menu/native-context-menu.component.ts @@ -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(); diff --git a/toju-app/src/app/infrastructure/mobile/README.md b/toju-app/src/app/infrastructure/mobile/README.md index ec30a23..529d2b1 100644 --- a/toju-app/src/app/infrastructure/mobile/README.md +++ b/toju-app/src/app/infrastructure/mobile/README.md @@ -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 diff --git a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-app-update.adapter.ts b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-app-update.adapter.ts new file mode 100644 index 0000000..8a4ed55 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-app-update.adapter.ts @@ -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 { + 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 { + const AppUpdate = await loadCapacitorAppUpdatePlugin(); + + if (!AppUpdate) { + throw new Error('Capacitor AppUpdate plugin is not available on this platform.'); + } + + await AppUpdate.openAppStore(); + } + + async performImmediateUpdate(): Promise { + const AppUpdate = await loadCapacitorAppUpdatePlugin(); + + if (!AppUpdate) { + throw new Error('Capacitor AppUpdate plugin is not available on this platform.'); + } + + await AppUpdate.performImmediateUpdate(); + } + + async startFlexibleUpdate(): Promise { + const AppUpdate = await loadCapacitorAppUpdatePlugin(); + + if (!AppUpdate) { + throw new Error('Capacitor AppUpdate plugin is not available on this platform.'); + } + + await AppUpdate.startFlexibleUpdate(); + } + + async completeFlexibleUpdate(): Promise { + 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; + } +} diff --git a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.ts b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.ts index 7a8cc8d..c3b2caa 100644 --- a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.ts +++ b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.ts @@ -42,6 +42,13 @@ async function resolveCapacitorPlugin( return pluginPromises.get(pluginName) as Promise; } +/** Resolve the active Capacitor platform id on native shells. */ +export async function getCapacitorPlatform(): Promise { + 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 { return resolveCapacitorPlugin('App', async () => { @@ -86,3 +93,12 @@ export async function loadCapacitorAudioSessionPlugin(): Promise { + return resolveCapacitorPlugin('AppUpdate', async () => { + const module = await import('@capawesome/capacitor-app-update'); + + return module.AppUpdate; + }); +} diff --git a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/metoyou-mobile.plugin.ts b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/metoyou-mobile.plugin.ts index b3e73f6..7154d1f 100644 --- a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/metoyou-mobile.plugin.ts +++ b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/metoyou-mobile.plugin.ts @@ -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; + requestCameraCapturePermissions(): Promise; setSpeakerphoneEnabled(options: { enabled: boolean }): Promise; startVoiceForegroundService(): Promise; stopVoiceForegroundService(): Promise; diff --git a/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-app-update.adapter.ts b/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-app-update.adapter.ts new file mode 100644 index 0000000..25318cd --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/adapters/web/web-mobile-app-update.adapter.ts @@ -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 {} + + async performImmediateUpdate(): Promise {} + + async startFlexibleUpdate(): Promise {} + + async completeFlexibleUpdate(): Promise {} +} diff --git a/toju-app/src/app/infrastructure/mobile/contracts/mobile.contracts.ts b/toju-app/src/app/infrastructure/mobile/contracts/mobile.contracts.ts index 9900ee2..f257f5c 100644 --- a/toju-app/src/app/infrastructure/mobile/contracts/mobile.contracts.ts +++ b/toju-app/src/app/infrastructure/mobile/contracts/mobile.contracts.ts @@ -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; + openAppStore(): Promise; + performImmediateUpdate(): Promise; + startFlexibleUpdate(): Promise; + completeFlexibleUpdate(): Promise; +} diff --git a/toju-app/src/app/infrastructure/mobile/index.ts b/toju-app/src/app/infrastructure/mobile/index.ts index a7c106e..e1e8472 100644 --- a/toju-app/src/app/infrastructure/mobile/index.ts +++ b/toju-app/src/app/infrastructure/mobile/index.ts @@ -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'; diff --git a/toju-app/src/app/infrastructure/mobile/logic/ensure-mobile-capture-permissions.ts b/toju-app/src/app/infrastructure/mobile/logic/ensure-mobile-capture-permissions.ts new file mode 100644 index 0000000..ad16193 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/ensure-mobile-capture-permissions.ts @@ -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 { + return detectRuntimePlatform({ + hasElectronApi: false, + capacitorIsNative: isCapacitorNativeRuntime() + }); +} + +/** Request Android/iOS runtime microphone permissions before WebRTC voice capture. */ +export async function ensureMobileVoiceCapturePermissions(): Promise { + 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 { + 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; + } +} diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-android-manifest-permissions.rules.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-android-manifest-permissions.rules.spec.ts new file mode 100644 index 0000000..67fa5af --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-android-manifest-permissions.rules.spec.ts @@ -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([]); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-android-manifest-permissions.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-android-manifest-permissions.rules.ts new file mode 100644 index 0000000..caca31d --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-android-manifest-permissions.rules.ts @@ -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}"`)); +} diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-app-update.rules.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-app-update.rules.spec.ts new file mode 100644 index 0000000..4b3a944 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-app-update.rules.spec.ts @@ -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'); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-app-update.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-app-update.rules.ts new file mode 100644 index 0000000..0402c4c --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-app-update.rules.ts @@ -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'; + } +} diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-media-permission.rules.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-media-permission.rules.spec.ts new file mode 100644 index 0000000..c7c031f --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-media-permission.rules.spec.ts @@ -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); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-media-permission.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-media-permission.rules.ts new file mode 100644 index 0000000..c6326f3 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-media-permission.rules.ts @@ -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); +} diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-row-mapper.rules.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-row-mapper.rules.spec.ts index 90c48ff..99bf642 100644 --- a/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-row-mapper.rules.spec.ts +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-row-mapper.rules.spec.ts @@ -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 }]); diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-app-update.service.spec.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-app-update.service.spec.ts new file mode 100644 index 0000000..5420bf6 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-app-update.service.spec.ts @@ -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); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-app-update.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-app-update.service.ts new file mode 100644 index 0000000..2386e81 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-app-update.service.ts @@ -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(createInitialMobileUpdateState()); + + private readonly mobilePlatform = inject(MobilePlatformService); + private adapter: MobileAppUpdateAdapter = new WebMobileAppUpdateAdapter(); + private adapterReady: Promise | null = null; + private initialized = false; + private pollTimerId: number | null = null; + + get isCapacitor(): boolean { + return this.mobilePlatform.isCapacitor(); + } + + async initialize(): Promise { + 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 { + 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 { + const adapter = await this.ensureAdapter(); + + if (!adapter.isSupported) { + return; + } + + await adapter.openAppStore(); + } + + async performImmediateUpdate(): Promise { + 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 { + 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 { + const adapter = await this.ensureAdapter(); + + if (!adapter.isSupported) { + return; + } + + await adapter.completeFlexibleUpdate(); + } + + private ensureAdapter(): Promise { + 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); + } +} diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-media.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-media.service.ts index 43e832a..6deb05e 100644 --- a/toju-app/src/app/infrastructure/mobile/services/mobile-media.service.ts +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-media.service.ts @@ -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 { + return ensureMobileVoiceCapturePermissions(); + } + + ensureCameraCapturePermissions(): Promise { + return ensureMobileCameraCapturePermissions(); + } + private ensureAdapter(): Promise { if (!this.adapterReady) { this.adapterReady = resolveMobileAdapter( diff --git a/toju-app/src/app/infrastructure/persistence/database.service.spec.ts b/toju-app/src/app/infrastructure/persistence/database.service.spec.ts index 6bd41e1..199621a 100644 --- a/toju-app/src/app/infrastructure/persistence/database.service.spec.ts +++ b/toju-app/src/app/infrastructure/persistence/database.service.spec.ts @@ -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()) diff --git a/toju-app/src/app/infrastructure/persistence/database.service.ts b/toju-app/src/app/infrastructure/persistence/database.service.ts index 7978f80..b5aca54 100644 --- a/toju-app/src/app/infrastructure/persistence/database.service.ts +++ b/toju-app/src/app/infrastructure/persistence/database.service.ts @@ -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)); } diff --git a/toju-app/src/app/infrastructure/realtime/ice-server-settings.service.ts b/toju-app/src/app/infrastructure/realtime/ice-server-settings.service.ts index 89d10ca..056eff6 100644 --- a/toju-app/src/app/infrastructure/realtime/ice-server-settings.service.ts +++ b/toju-app/src/app/infrastructure/realtime/ice-server-settings.service.ts @@ -1,6 +1,5 @@ import { Injectable, - inject, signal, computed, type Signal diff --git a/toju-app/src/app/infrastructure/realtime/media/media.manager.ts b/toju-app/src/app/infrastructure/realtime/media/media.manager.ts index 30086b4..518ca04 100644 --- a/toju-app/src/app/infrastructure/realtime/media/media.manager.ts +++ b/toju-app/src/app/infrastructure/realtime/media/media.manager.ts @@ -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]; diff --git a/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.spec.ts b/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.spec.ts index 9d92cbc..85a4109 100644 --- a/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.spec.ts +++ b/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.spec.ts @@ -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, diff --git a/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.ts b/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.ts index 39ce002..0a4594e 100644 --- a/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.ts +++ b/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.ts @@ -551,6 +551,7 @@ export class SignalingManager { this.stateHeartbeatTimer = setInterval(() => { this.runHeartbeatChecks(); }, STATE_HEARTBEAT_INTERVAL_MS); + void this.runHeartbeatChecks(); } diff --git a/toju-app/src/app/shared-kernel/plugin-system.contracts.ts b/toju-app/src/app/shared-kernel/plugin-system.contracts.ts index 8d35289..9686e65 100644 --- a/toju-app/src/app/shared-kernel/plugin-system.contracts.ts +++ b/toju-app/src/app/shared-kernel/plugin-system.contracts.ts @@ -56,6 +56,7 @@ export const PLUGIN_CAPABILITIES = [ 'ui.channelsSection', 'ui.embeds', 'ui.dom', + 'ui.commands', 'storage.local', 'storage.serverData.read', 'storage.serverData.write', diff --git a/toju-app/src/app/shared/components/portal-host-to-body.logic.spec.ts b/toju-app/src/app/shared/components/portal-host-to-body.logic.spec.ts index eec6919..2064f2f 100644 --- a/toju-app/src/app/shared/components/portal-host-to-body.logic.spec.ts +++ b/toju-app/src/app/shared/components/portal-host-to-body.logic.spec.ts @@ -1,4 +1,8 @@ -import { describe, expect, it } from 'vitest'; +import { + describe, + expect, + it +} from 'vitest'; import { portalHostElementToBody } from './portal-host-to-body.logic'; diff --git a/toju-app/src/app/shared/components/screen-share-quality-dialog/screen-share-quality-dialog.component.ts b/toju-app/src/app/shared/components/screen-share-quality-dialog/screen-share-quality-dialog.component.ts index 75d026c..2bda288 100644 --- a/toju-app/src/app/shared/components/screen-share-quality-dialog/screen-share-quality-dialog.component.ts +++ b/toju-app/src/app/shared/components/screen-share-quality-dialog/screen-share-quality-dialog.component.ts @@ -25,8 +25,6 @@ import { portalHostElementToBody } from '../portal-host-to-body.logic'; } }) export class ScreenShareQualityDialogComponent implements OnInit, AfterViewInit { - private readonly host = inject>(ElementRef); - private readonly document = inject(DOCUMENT); selectedQuality = input.required(); includeSystemAudio = input(false); @@ -36,6 +34,9 @@ export class ScreenShareQualityDialogComponent implements OnInit, AfterViewInit readonly qualityOptions = SCREEN_SHARE_QUALITY_OPTIONS; readonly activeQuality = signal('balanced'); + private readonly host = inject>(ElementRef); + private readonly document = inject(DOCUMENT); + @HostListener('document:keydown.escape') onEscape(): void { this.cancelled.emit(undefined);