Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecb1a4b3a0 | |||
| a173299ad3 | |||
| 8631290c01 | |||
| 8e3ccf4157 | |||
| 9d0a4478b2 | |||
| e769a6ee4a | |||
| 0f6cb3ee77 |
@@ -8,18 +8,18 @@ This page maps the app routes and important DOM areas. It is useful for plugin a
|
||||
|
||||
## Angular Routes
|
||||
|
||||
| Route | Component | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `/` | Redirect | Redirects to `/search`. |
|
||||
| `/login` | `LoginComponent` | User login. |
|
||||
| `/register` | `RegisterComponent` | User registration. |
|
||||
| `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. |
|
||||
| `/search` | `ServerSearchComponent` | Search and join servers. |
|
||||
| `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. |
|
||||
| `/dm` | `DmWorkspaceComponent` | Direct-message workspace. |
|
||||
| `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. |
|
||||
| `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. |
|
||||
| `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. |
|
||||
| Route | Component | Purpose |
|
||||
| ---------------------------- | ------------------------- | --------------------------------------------------------------------- |
|
||||
| `/` | Redirect | Redirects to `/search`. |
|
||||
| `/login` | `LoginComponent` | User login. |
|
||||
| `/register` | `RegisterComponent` | User registration. |
|
||||
| `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. |
|
||||
| `/search` | `ServerSearchComponent` | Search and join servers. |
|
||||
| `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. |
|
||||
| `/dm` | `DmWorkspaceComponent` | Direct-message workspace. |
|
||||
| `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. |
|
||||
| `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. |
|
||||
| `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. |
|
||||
| `/plugins/:pluginId/:pageId` | `PluginPageHostComponent` | Host for plugin app pages registered with `api.ui.registerAppPage()`. |
|
||||
|
||||
## Page Shell
|
||||
@@ -46,6 +46,7 @@ The server page is the most important page for plugins.
|
||||
<section>Text Channels</section>
|
||||
<section>Voice Channels</section>
|
||||
<section data-testid="plugin-room-side-panel">
|
||||
<button>View plugins</button>
|
||||
<app-plugin-render-host></app-plugin-render-host>
|
||||
</section>
|
||||
<section>Members</section>
|
||||
@@ -135,11 +136,11 @@ Prefer plugin APIs over DOM selectors. When direct DOM mounting is necessary, us
|
||||
|
||||
Common targets:
|
||||
|
||||
| Selector | Area |
|
||||
| --- | --- |
|
||||
| `body` | Global overlays or modals. |
|
||||
| `app-chat-messages` | Main text channel surface. |
|
||||
| `app-rooms-side-panel` | Server side panel. |
|
||||
| `[data-testid="plugin-room-side-panel"]` | Plugin side-panel area in the server sidebar. |
|
||||
| Selector | Area |
|
||||
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `body` | Global overlays or modals. |
|
||||
| `app-chat-messages` | Main text channel surface. |
|
||||
| `app-rooms-side-panel` | Server side panel. |
|
||||
| `[data-testid="plugin-room-side-panel"]` | Plugin side-panel area in the server sidebar, including the View plugins trigger for `registerToolbarAction()` tiles. |
|
||||
|
||||
Avoid depending on Tailwind utility classes; they are layout details and may change.
|
||||
Avoid depending on Tailwind utility classes; they are layout details and may change.
|
||||
|
||||
@@ -54,12 +54,12 @@ There are three communication boundaries a plugin author must understand:
|
||||
1. Signaling plane
|
||||
Angular renderer <-> WebSocket signaling server
|
||||
Used for identity, joining servers, presence, typing, plugin requirements,
|
||||
server-relayed plugin events, WebRTC offers, answers, and ICE candidates.
|
||||
server-relayed plugin events, WebRTC offers, answers, and ICE candidates.
|
||||
|
||||
2. Peer plane
|
||||
Angular renderer <-> WebRTC peer connections <-> other clients
|
||||
Used for media and data-channel events: chat messages, message sync,
|
||||
attachments, voice state, screen/camera state, and plugin message bus data.
|
||||
Used for media and data-channel events: chat messages, message sync,
|
||||
attachments, voice state, screen/camera state, and plugin message bus data.
|
||||
|
||||
3. Desktop/local plane
|
||||
Angular renderer <-> Electron preload bridge <-> Electron main process
|
||||
@@ -71,18 +71,18 @@ Plugins run only in the renderer. They do not run in Electron main and do not ru
|
||||
|
||||
Choose communication APIs like this:
|
||||
|
||||
| Need | Use | Notes |
|
||||
| --- | --- | --- |
|
||||
| Visible normal chat message | `api.messages.send` | Persists locally, updates chat UI, broadcasts peer chat event. |
|
||||
| Visible bot-style message | `api.server.registerPluginUser` plus `api.messages.sendAsPluginUser` | Requires `users.manage` and `messages.send`. |
|
||||
| Plugin state sync between connected clients | `api.messageBus.publish` and `api.messageBus.subscribe` | P2P data-channel envelope, not a visible chat message. |
|
||||
| Plugin state sync plus recent chat snapshot | `api.messageBus.publish` with `includeLatestMessages` | Also needs `messages.read`. |
|
||||
| Metadata through signaling server | `api.events.publishServer` and `api.events.subscribeServer` | Event must be declared in manifest. |
|
||||
| Low-level peer data | `api.p2p.broadcastData` or `api.p2p.sendData` | Prefer message bus for structured topics/subscriptions. |
|
||||
| Local user preferences | `api.clientData` | User-scoped local storage/database. |
|
||||
| Local per-server plugin data | `api.serverData` | User-scoped and current-server-scoped local storage/database. |
|
||||
| App UI extension | `api.ui.*` | Prefer registered contributions over DOM mounting. |
|
||||
| Audio/video/voice effects | `api.media.*` | Browser media APIs and voice facade. |
|
||||
| Need | Use | Notes |
|
||||
| ------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| Visible normal chat message | `api.messages.send` | Persists locally, updates chat UI, broadcasts peer chat event. |
|
||||
| Visible bot-style message | `api.server.registerPluginUser` plus `api.messages.sendAsPluginUser` | Requires `users.manage` and `messages.send`. |
|
||||
| Plugin state sync between connected clients | `api.messageBus.publish` and `api.messageBus.subscribe` | P2P data-channel envelope, not a visible chat message. |
|
||||
| Plugin state sync plus recent chat snapshot | `api.messageBus.publish` with `includeLatestMessages` | Also needs `messages.read`. |
|
||||
| Metadata through signaling server | `api.events.publishServer` and `api.events.subscribeServer` | Event must be declared in manifest. |
|
||||
| Low-level peer data | `api.p2p.broadcastData` or `api.p2p.sendData` | Prefer message bus for structured topics/subscriptions. |
|
||||
| Local user preferences | `api.clientData` | User-scoped local storage/database. |
|
||||
| Local per-server plugin data | `api.serverData` | User-scoped and current-server-scoped local storage/database. |
|
||||
| App UI extension | `api.ui.*` | Prefer registered contributions over DOM mounting. |
|
||||
| Audio/video/voice effects | `api.media.*` | Browser media APIs and voice facade. |
|
||||
|
||||
## How The App Looks
|
||||
|
||||
@@ -122,47 +122,51 @@ Main server page shape:
|
||||
|
||||
Important routes:
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `/search` | Search and join servers. |
|
||||
| `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. |
|
||||
| `/dm` and `/dm/:conversationId` | Direct-message workspace. |
|
||||
| `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. |
|
||||
| `/plugin-store` | Browse and install plugins. |
|
||||
| `/plugins/:pluginId/:pageId` | Host for pages registered with `api.ui.registerAppPage`. |
|
||||
| Route | Purpose |
|
||||
| ------------------------------- | ------------------------------------------------------------------- |
|
||||
| `/search` | Search and join servers. |
|
||||
| `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. |
|
||||
| `/dm` and `/dm/:conversationId` | Direct-message workspace. |
|
||||
| `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. |
|
||||
| `/plugin-store` | Browse and install plugins. |
|
||||
| `/plugins/:pluginId/:pageId` | Host for pages registered with `api.ui.registerAppPage`. |
|
||||
|
||||
Direct DOM mounting is a last resort. Route-specific targets may not exist when `activate` runs. If `api.ui.mountElement` cannot find the target, it throws `Plugin mount target not found: <selector>` and plugin activation fails.
|
||||
|
||||
Stable direct-mount targets when necessary:
|
||||
|
||||
| Selector | Area |
|
||||
| --- | --- |
|
||||
| `body` | Safest global target for overlays, badges, and modals. It exists during activation. |
|
||||
| `app-chat-messages` | Main text channel surface. Use only after checking the element exists. |
|
||||
| Selector | Area |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `body` | Safest global target for overlays, badges, and modals. It exists during activation. |
|
||||
| `app-chat-messages` | Main text channel surface. Use only after checking the element exists. |
|
||||
| `app-rooms-side-panel` | Server side panel. Use only after checking the element exists. Prefer `registerSidePanel` for plugin sidebar content. |
|
||||
|
||||
Do not mount directly into `[data-testid="plugin-room-side-panel"]`. That area is owned by the plugin side-panel registry and is rendered only on the server page. For server sidebar UI, use:
|
||||
|
||||
```js
|
||||
context.subscriptions.push(api.ui.registerSidePanel('control-panel', {
|
||||
label: 'Control Panel',
|
||||
order: 20,
|
||||
render: () => {
|
||||
const root = document.createElement('section');
|
||||
const button = document.createElement('button');
|
||||
context.subscriptions.push(
|
||||
api.ui.registerSidePanel('control-panel', {
|
||||
label: 'Control Panel',
|
||||
order: 20,
|
||||
render: () => {
|
||||
const root = document.createElement('section');
|
||||
const button = document.createElement('button');
|
||||
|
||||
button.type = 'button';
|
||||
button.textContent = 'Run Action';
|
||||
button.addEventListener('click', () => {
|
||||
api.logger.info('Side-panel action clicked');
|
||||
});
|
||||
button.type = 'button';
|
||||
button.textContent = 'Run Action';
|
||||
button.addEventListener('click', () => {
|
||||
api.logger.info('Side-panel action clicked');
|
||||
});
|
||||
|
||||
root.append(button);
|
||||
return root;
|
||||
}
|
||||
}));
|
||||
root.append(button);
|
||||
return root;
|
||||
}
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
For small command-style plugin entries, use `api.ui.registerToolbarAction()`. Those actions appear as icon tiles in the server side panel's View plugins menu and receive `source: 'toolbarAction'` in their action context.
|
||||
|
||||
Do not depend on Tailwind classes or internal styling classes.
|
||||
|
||||
## Manifest
|
||||
@@ -300,10 +304,10 @@ Validation rules:
|
||||
|
||||
Scope meanings:
|
||||
|
||||
| Scope | Meaning |
|
||||
| --- | --- |
|
||||
| `client` or omitted | Installed globally for this local user/client. |
|
||||
| `server` | Installed for a specific chat server as local client plugin plus server requirement metadata. |
|
||||
| Scope | Meaning |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `client` or omitted | Installed globally for this local user/client. |
|
||||
| `server` | Installed for a specific chat server as local client plugin plus server requirement metadata. |
|
||||
|
||||
Most generated plugins should use `kind: "client"`. Use `kind: "library"` only for dependency metadata with no executable entrypoint.
|
||||
|
||||
@@ -326,10 +330,7 @@ interface TojuClientPluginModule {
|
||||
ready?: (context: TojuPluginActivationContext) => Promise<void> | void;
|
||||
deactivate?: (context: TojuPluginActivationContext) => Promise<void> | void;
|
||||
onPluginDataChanged?: (context: TojuPluginActivationContext, event: unknown) => Promise<void> | void;
|
||||
onServerRequirementsChanged?: (
|
||||
context: TojuPluginActivationContext,
|
||||
snapshot: PluginRequirementsSnapshot
|
||||
) => Promise<void> | void;
|
||||
onServerRequirementsChanged?: (context: TojuPluginActivationContext, snapshot: PluginRequirementsSnapshot) => Promise<void> | void;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -579,9 +580,20 @@ interface ChannelPermissionOverride {
|
||||
## Full Plugin API Types
|
||||
|
||||
```ts
|
||||
interface PluginApiProfileUpdate { displayName: string; description?: string }
|
||||
interface PluginApiAvatarUpdate { avatarUrl: string; avatarMime: string; avatarHash: string }
|
||||
interface PluginApiChannelRequest { name: string; id?: string; position?: number }
|
||||
interface PluginApiProfileUpdate {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
}
|
||||
interface PluginApiAvatarUpdate {
|
||||
avatarUrl: string;
|
||||
avatarMime: string;
|
||||
avatarHash: string;
|
||||
}
|
||||
interface PluginApiChannelRequest {
|
||||
name: string;
|
||||
id?: string;
|
||||
position?: number;
|
||||
}
|
||||
interface PluginApiServerSettingsUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
@@ -590,10 +602,24 @@ interface PluginApiServerSettingsUpdate {
|
||||
password?: string;
|
||||
maxUsers?: number;
|
||||
}
|
||||
interface PluginApiPluginUserRequest { displayName: string; id?: string; avatarUrl?: string }
|
||||
interface PluginApiMessageAsPluginUserRequest { pluginUserId: string; content: string; channelId?: string }
|
||||
interface PluginApiAudioClipRequest { url: string; volume?: number }
|
||||
interface PluginApiCustomStreamRequest { stream: MediaStream; label?: string }
|
||||
interface PluginApiPluginUserRequest {
|
||||
displayName: string;
|
||||
id?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
interface PluginApiMessageAsPluginUserRequest {
|
||||
pluginUserId: string;
|
||||
content: string;
|
||||
channelId?: string;
|
||||
}
|
||||
interface PluginApiAudioClipRequest {
|
||||
url: string;
|
||||
volume?: number;
|
||||
}
|
||||
interface PluginApiCustomStreamRequest {
|
||||
stream: MediaStream;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'manual';
|
||||
interface PluginApiActionContext {
|
||||
@@ -660,13 +686,41 @@ interface PluginApiMessageBusSubscription {
|
||||
handler: (event: PluginApiMessageBusEnvelope) => void;
|
||||
}
|
||||
|
||||
interface PluginApiPageContribution { label: string; path: string; render: () => HTMLElement | string }
|
||||
interface PluginApiSettingsPageContribution { label: string; settingsKey?: string; order?: number; render: () => HTMLElement | string }
|
||||
interface PluginApiPanelContribution { label: string; order?: number; render: () => HTMLElement | string }
|
||||
interface PluginApiChannelSectionContribution { label: string; type?: 'audio' | 'video' | 'custom'; order?: number }
|
||||
interface PluginApiActionContribution { label: string; icon?: string; run: (context: PluginApiActionContext) => Promise<void> | void }
|
||||
interface PluginApiEmbedRendererContribution { embedType: string; render: (payload: unknown) => HTMLElement | string }
|
||||
interface PluginApiDomMountRequest { target: Element | string; element: HTMLElement; position?: InsertPosition }
|
||||
interface PluginApiPageContribution {
|
||||
label: string;
|
||||
path: string;
|
||||
render: () => HTMLElement | string;
|
||||
}
|
||||
interface PluginApiSettingsPageContribution {
|
||||
label: string;
|
||||
settingsKey?: string;
|
||||
order?: number;
|
||||
render: () => HTMLElement | string;
|
||||
}
|
||||
interface PluginApiPanelContribution {
|
||||
label: string;
|
||||
order?: number;
|
||||
render: () => HTMLElement | string;
|
||||
}
|
||||
interface PluginApiChannelSectionContribution {
|
||||
label: string;
|
||||
type?: 'audio' | 'video' | 'custom';
|
||||
order?: number;
|
||||
}
|
||||
interface PluginApiActionContribution {
|
||||
label: string;
|
||||
icon?: string;
|
||||
run: (context: PluginApiActionContext) => Promise<void> | void;
|
||||
}
|
||||
interface PluginApiEmbedRendererContribution {
|
||||
embedType: string;
|
||||
render: (payload: unknown) => HTMLElement | string;
|
||||
}
|
||||
interface PluginApiDomMountRequest {
|
||||
target: Element | string;
|
||||
element: HTMLElement;
|
||||
position?: InsertPosition;
|
||||
}
|
||||
|
||||
interface TojuClientPluginApi {
|
||||
readonly context: { getCurrent: () => PluginApiActionContext };
|
||||
@@ -890,10 +944,7 @@ Capabilities: `messages.read`, `messages.send`, `messages.editOwn`, `messages.de
|
||||
```js
|
||||
const visibleMessages = api.messages.readCurrent();
|
||||
|
||||
const sent = api.messages.send(
|
||||
'Build completed successfully. Docs are ready for review.',
|
||||
'general'
|
||||
);
|
||||
const sent = api.messages.send('Build completed successfully. Docs are ready for review.', 'general');
|
||||
|
||||
api.messages.edit(sent.id, 'Build completed successfully. Docs and plugin examples are ready.');
|
||||
api.messages.delete(sent.id);
|
||||
@@ -1115,88 +1166,110 @@ Desktop uses Electron's local database when available, with renderer localStorag
|
||||
|
||||
Capabilities:
|
||||
|
||||
| Method | Required capability |
|
||||
| --- | --- |
|
||||
| `registerAppPage` | `ui.pages` |
|
||||
| `registerSettingsPage` | `ui.settings` |
|
||||
| `registerSidePanel` | `ui.sidePanel` |
|
||||
| Method | Required capability |
|
||||
| ------------------------ | -------------------- |
|
||||
| `registerAppPage` | `ui.pages` |
|
||||
| `registerSettingsPage` | `ui.settings` |
|
||||
| `registerSidePanel` | `ui.sidePanel` |
|
||||
| `registerChannelSection` | `ui.channelsSection` |
|
||||
| `registerComposerAction` | `ui.pages` |
|
||||
| `registerProfileAction` | `ui.pages` |
|
||||
| `registerToolbarAction` | `ui.pages` |
|
||||
| `registerEmbedRenderer` | `ui.embeds` |
|
||||
| `mountElement` | `ui.dom` |
|
||||
| `registerComposerAction` | `ui.pages` |
|
||||
| `registerProfileAction` | `ui.pages` |
|
||||
| `registerToolbarAction` | `ui.pages` |
|
||||
| `registerEmbedRenderer` | `ui.embeds` |
|
||||
| `mountElement` | `ui.dom` |
|
||||
|
||||
Register side panel:
|
||||
|
||||
```js
|
||||
context.subscriptions.push(api.ui.registerSidePanel('summary', {
|
||||
label: 'Plugin Summary',
|
||||
order: 10,
|
||||
render: () => {
|
||||
const root = document.createElement('aside');
|
||||
const heading = document.createElement('h2');
|
||||
const text = document.createElement('p');
|
||||
context.subscriptions.push(
|
||||
api.ui.registerSidePanel('summary', {
|
||||
label: 'Plugin Summary',
|
||||
order: 10,
|
||||
render: () => {
|
||||
const root = document.createElement('aside');
|
||||
const heading = document.createElement('h2');
|
||||
const text = document.createElement('p');
|
||||
|
||||
heading.textContent = 'Plugin Summary';
|
||||
text.textContent = 'No active tasks.';
|
||||
root.append(heading, text);
|
||||
return root;
|
||||
}
|
||||
}));
|
||||
heading.textContent = 'Plugin Summary';
|
||||
text.textContent = 'No active tasks.';
|
||||
root.append(heading, text);
|
||||
return root;
|
||||
}
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
Use `registerSidePanel` for content that belongs in the server sidebar plugin area. Do not query `[data-testid="plugin-room-side-panel"]` and pass it to `mountElement`; that route-specific host may not exist while the plugin activates.
|
||||
|
||||
Register toolbar action for the View plugins menu:
|
||||
|
||||
```js
|
||||
context.subscriptions.push(
|
||||
api.ui.registerToolbarAction('quick-status', {
|
||||
icon: 'QS',
|
||||
label: 'Quick Status',
|
||||
run: (actionContext) => {
|
||||
api.logger.info('Quick Status clicked', {
|
||||
serverId: actionContext.server?.id,
|
||||
source: actionContext.source
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
Register app page:
|
||||
|
||||
```js
|
||||
context.subscriptions.push(api.ui.registerAppPage('dashboard', {
|
||||
label: 'Build Dashboard',
|
||||
path: '/plugins/example.build-dashboard/dashboard',
|
||||
render: () => {
|
||||
const root = document.createElement('section');
|
||||
const title = document.createElement('h1');
|
||||
const button = document.createElement('button');
|
||||
const output = document.createElement('p');
|
||||
context.subscriptions.push(
|
||||
api.ui.registerAppPage('dashboard', {
|
||||
label: 'Build Dashboard',
|
||||
path: '/plugins/example.build-dashboard/dashboard',
|
||||
render: () => {
|
||||
const root = document.createElement('section');
|
||||
const title = document.createElement('h1');
|
||||
const button = document.createElement('button');
|
||||
const output = document.createElement('p');
|
||||
|
||||
title.textContent = 'Build Dashboard';
|
||||
button.type = 'button';
|
||||
button.textContent = 'Send status';
|
||||
output.textContent = 'Idle.';
|
||||
title.textContent = 'Build Dashboard';
|
||||
button.type = 'button';
|
||||
button.textContent = 'Send status';
|
||||
output.textContent = 'Idle.';
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const message = api.messages.send('Build dashboard status: ready.');
|
||||
output.textContent = `Sent message ${message.id}`;
|
||||
});
|
||||
button.addEventListener('click', () => {
|
||||
const message = api.messages.send('Build dashboard status: ready.');
|
||||
output.textContent = `Sent message ${message.id}`;
|
||||
});
|
||||
|
||||
root.append(title, button, output);
|
||||
return root;
|
||||
}
|
||||
}));
|
||||
root.append(title, button, output);
|
||||
return root;
|
||||
}
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
Register actions:
|
||||
|
||||
```js
|
||||
context.subscriptions.push(api.ui.registerComposerAction('insert-template', {
|
||||
label: 'Insert Template',
|
||||
icon: 'file-text',
|
||||
run: (actionContext) => {
|
||||
api.messages.send(
|
||||
'Template: Please review the latest build notes.',
|
||||
actionContext.textChannel?.id
|
||||
);
|
||||
}
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
api.ui.registerComposerAction('insert-template', {
|
||||
label: 'Insert Template',
|
||||
icon: 'file-text',
|
||||
run: (actionContext) => {
|
||||
api.messages.send('Template: Please review the latest build notes.', actionContext.textChannel?.id);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(api.ui.registerToolbarAction('post-standup', {
|
||||
label: 'Post Standup',
|
||||
icon: 'megaphone',
|
||||
run: () => {
|
||||
api.messages.send('Standup starts now. Join the voice channel when ready.');
|
||||
}
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
api.ui.registerToolbarAction('post-standup', {
|
||||
label: 'Post Standup',
|
||||
icon: 'megaphone',
|
||||
run: () => {
|
||||
api.messages.send('Standup starts now. Join the voice channel when ready.');
|
||||
}
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
Mount DOM directly:
|
||||
@@ -1210,11 +1283,13 @@ banner.textContent = 'Plugin banner mounted in chat messages.';
|
||||
const target = document.querySelector('app-chat-messages');
|
||||
|
||||
if (target) {
|
||||
context.subscriptions.push(api.ui.mountElement('chat-banner', {
|
||||
target,
|
||||
element: banner,
|
||||
position: 'afterbegin'
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
api.ui.mountElement('chat-banner', {
|
||||
target,
|
||||
element: banner,
|
||||
position: 'afterbegin'
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1224,56 +1299,58 @@ Global overlay example:
|
||||
const badge = document.createElement('div');
|
||||
badge.textContent = 'Plugin active';
|
||||
|
||||
context.subscriptions.push(api.ui.mountElement('global-badge', {
|
||||
target: 'body',
|
||||
element: badge,
|
||||
position: 'beforeend'
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
api.ui.mountElement('global-badge', {
|
||||
target: 'body',
|
||||
element: badge,
|
||||
position: 'beforeend'
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
`mountElement` tags the element with plugin ownership metadata, replaces duplicate mounts for the same plugin/id, and removes it on disposal/unload.
|
||||
|
||||
## Capability Cheat Sheet
|
||||
|
||||
| API call group | Capabilities |
|
||||
| --- | --- |
|
||||
| `profile.getCurrent` | `profile.read` |
|
||||
| `profile.update`, `profile.updateAvatar` | `profile.write` |
|
||||
| `users.getCurrent`, `users.list`, `users.readMembers` | `users.read` |
|
||||
| `users.kick`, `users.ban`, `server.registerPluginUser` | `users.manage` |
|
||||
| `roles.list` | `roles.read` |
|
||||
| `users.setRole`, `roles.setAssignments` | `roles.manage` |
|
||||
| `server.getCurrent` | `server.read` |
|
||||
| `server.updatePermissions`, `server.updateSettings` | `server.manage` |
|
||||
| `channels.list`, `channels.select` | `channels.read` |
|
||||
| `channels.addAudioChannel`, `channels.addVideoChannel`, `channels.rename`, `channels.remove` | `channels.manage` |
|
||||
| `messages.readCurrent`, `messages.subscribeTyping` | `messages.read` |
|
||||
| `messages.send`, `messages.sendAsPluginUser`, `messages.setTyping` | `messages.send` |
|
||||
| `messages.edit` | `messages.editOwn` |
|
||||
| `messages.delete` | `messages.deleteOwn` |
|
||||
| `messages.moderateDelete` | `messages.moderate` |
|
||||
| `messages.sync` | `messages.sync` |
|
||||
| `events.publishServer` | `events.server.publish` |
|
||||
| `events.subscribeServer` | `events.server.subscribe` |
|
||||
| `events.publishP2p` | `events.p2p.publish` |
|
||||
| `events.subscribeP2p` | `events.p2p.subscribe` |
|
||||
| `messageBus.publish` | `events.p2p.publish`, plus `messages.read` when `includeLatestMessages` is true |
|
||||
| `messageBus.sendLatestMessages` | `events.p2p.publish`, `messages.read` |
|
||||
| `messageBus.subscribe` | `events.p2p.subscribe`, plus `messages.read` when `replayLatest` is true |
|
||||
| `p2p.*` | `p2p.data` |
|
||||
| `media.playAudioClip` | `media.playAudio` |
|
||||
| `media.addCustomAudioStream` | `media.addAudioStream` |
|
||||
| `media.addCustomVideoStream` | `media.addVideoStream` |
|
||||
| `media.setInputVolume`, `media.setOutputVolume` | `audio.volume` |
|
||||
| `clientData.*`, `storage.*` | `storage.local` |
|
||||
| `serverData.read` | `storage.serverData.read` |
|
||||
| `serverData.write`, `serverData.remove` | `storage.serverData.write` |
|
||||
| `ui.registerAppPage`, composer/profile/toolbar actions | `ui.pages` |
|
||||
| `ui.registerSettingsPage` | `ui.settings` |
|
||||
| `ui.registerSidePanel` | `ui.sidePanel` |
|
||||
| `ui.registerChannelSection` | `ui.channelsSection` |
|
||||
| `ui.registerEmbedRenderer` | `ui.embeds` |
|
||||
| `ui.mountElement` | `ui.dom` |
|
||||
| API call group | Capabilities |
|
||||
| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| `profile.getCurrent` | `profile.read` |
|
||||
| `profile.update`, `profile.updateAvatar` | `profile.write` |
|
||||
| `users.getCurrent`, `users.list`, `users.readMembers` | `users.read` |
|
||||
| `users.kick`, `users.ban`, `server.registerPluginUser` | `users.manage` |
|
||||
| `roles.list` | `roles.read` |
|
||||
| `users.setRole`, `roles.setAssignments` | `roles.manage` |
|
||||
| `server.getCurrent` | `server.read` |
|
||||
| `server.updatePermissions`, `server.updateSettings` | `server.manage` |
|
||||
| `channels.list`, `channels.select` | `channels.read` |
|
||||
| `channels.addAudioChannel`, `channels.addVideoChannel`, `channels.rename`, `channels.remove` | `channels.manage` |
|
||||
| `messages.readCurrent`, `messages.subscribeTyping` | `messages.read` |
|
||||
| `messages.send`, `messages.sendAsPluginUser`, `messages.setTyping` | `messages.send` |
|
||||
| `messages.edit` | `messages.editOwn` |
|
||||
| `messages.delete` | `messages.deleteOwn` |
|
||||
| `messages.moderateDelete` | `messages.moderate` |
|
||||
| `messages.sync` | `messages.sync` |
|
||||
| `events.publishServer` | `events.server.publish` |
|
||||
| `events.subscribeServer` | `events.server.subscribe` |
|
||||
| `events.publishP2p` | `events.p2p.publish` |
|
||||
| `events.subscribeP2p` | `events.p2p.subscribe` |
|
||||
| `messageBus.publish` | `events.p2p.publish`, plus `messages.read` when `includeLatestMessages` is true |
|
||||
| `messageBus.sendLatestMessages` | `events.p2p.publish`, `messages.read` |
|
||||
| `messageBus.subscribe` | `events.p2p.subscribe`, plus `messages.read` when `replayLatest` is true |
|
||||
| `p2p.*` | `p2p.data` |
|
||||
| `media.playAudioClip` | `media.playAudio` |
|
||||
| `media.addCustomAudioStream` | `media.addAudioStream` |
|
||||
| `media.addCustomVideoStream` | `media.addVideoStream` |
|
||||
| `media.setInputVolume`, `media.setOutputVolume` | `audio.volume` |
|
||||
| `clientData.*`, `storage.*` | `storage.local` |
|
||||
| `serverData.read` | `storage.serverData.read` |
|
||||
| `serverData.write`, `serverData.remove` | `storage.serverData.write` |
|
||||
| `ui.registerAppPage`, composer/profile/toolbar actions | `ui.pages` |
|
||||
| `ui.registerSettingsPage` | `ui.settings` |
|
||||
| `ui.registerSidePanel` | `ui.sidePanel` |
|
||||
| `ui.registerChannelSection` | `ui.channelsSection` |
|
||||
| `ui.registerEmbedRenderer` | `ui.embeds` |
|
||||
| `ui.mountElement` | `ui.dom` |
|
||||
|
||||
## Complete Example Plugin
|
||||
|
||||
@@ -1319,25 +1396,31 @@ export function activate(context) {
|
||||
|
||||
api.logger.info('Voice Notes activated');
|
||||
|
||||
context.subscriptions.push(api.messageBus.subscribe({
|
||||
topic: BUS_TOPIC,
|
||||
replayLatest: false,
|
||||
handler: (event) => {
|
||||
api.logger.debug('Received voice notes draft update', event.payload);
|
||||
}
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
api.messageBus.subscribe({
|
||||
topic: BUS_TOPIC,
|
||||
replayLatest: false,
|
||||
handler: (event) => {
|
||||
api.logger.debug('Received voice notes draft update', event.payload);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(api.ui.registerSidePanel('voice-notes-panel', {
|
||||
label: 'Voice Notes',
|
||||
order: 20,
|
||||
render: () => renderPanel(context)
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
api.ui.registerSidePanel('voice-notes-panel', {
|
||||
label: 'Voice Notes',
|
||||
order: 20,
|
||||
render: () => renderPanel(context)
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(api.ui.registerAppPage('voice-notes', {
|
||||
label: 'Voice Notes',
|
||||
path: '/plugins/example.voice-notes/voice-notes',
|
||||
render: () => renderPanel(context)
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
api.ui.registerAppPage('voice-notes', {
|
||||
label: 'Voice Notes',
|
||||
path: '/plugins/example.voice-notes/voice-notes',
|
||||
render: () => renderPanel(context)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function renderPanel(context) {
|
||||
@@ -1352,9 +1435,7 @@ function renderPanel(context) {
|
||||
|
||||
const current = api.context.getCurrent();
|
||||
heading.textContent = 'Voice Notes';
|
||||
meta.textContent = current.voiceChannel
|
||||
? `Connected to ${current.voiceChannel.name}`
|
||||
: 'Not connected to a voice channel.';
|
||||
meta.textContent = current.voiceChannel ? `Connected to ${current.voiceChannel.name}` : 'Not connected to a voice channel.';
|
||||
textarea.rows = 6;
|
||||
textarea.placeholder = 'Write notes from the current voice session.';
|
||||
save.type = 'button';
|
||||
@@ -1363,16 +1444,19 @@ function renderPanel(context) {
|
||||
post.textContent = 'Post Notes';
|
||||
status.textContent = 'Loading draft...';
|
||||
|
||||
void api.serverData.read(DRAFT_KEY).then((value) => {
|
||||
if (value && typeof value === 'object' && typeof value.text === 'string') {
|
||||
textarea.value = value.text;
|
||||
}
|
||||
void api.serverData
|
||||
.read(DRAFT_KEY)
|
||||
.then((value) => {
|
||||
if (value && typeof value === 'object' && typeof value.text === 'string') {
|
||||
textarea.value = value.text;
|
||||
}
|
||||
|
||||
status.textContent = 'Draft loaded.';
|
||||
}).catch((error) => {
|
||||
api.logger.warn('Could not load voice notes draft', error);
|
||||
status.textContent = 'Could not load draft.';
|
||||
});
|
||||
status.textContent = 'Draft loaded.';
|
||||
})
|
||||
.catch((error) => {
|
||||
api.logger.warn('Could not load voice notes draft', error);
|
||||
status.textContent = 'Could not load draft.';
|
||||
});
|
||||
|
||||
save.addEventListener('click', async () => {
|
||||
const draft = {
|
||||
@@ -1429,4 +1513,4 @@ export function deactivate(context) {
|
||||
- Local REST API: Developer Guide -> Local REST API.
|
||||
- Plugin manifest: Plugin Development -> Manifest Model.
|
||||
- Capabilities: Plugin Development -> Capabilities.
|
||||
- Focused plugin API examples: Plugin Development -> API Reference and its API subpages.
|
||||
- Focused plugin API examples: Plugin Development -> API Reference and its API subpages.
|
||||
|
||||
@@ -60,24 +60,24 @@ interface PluginApiAvatarUpdate {
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Capability | Description |
|
||||
| --- | --- | --- |
|
||||
| `profile.getCurrent()` | `profile.read` | Returns the current `User` or `null`. |
|
||||
| `profile.update(profile)` | `profile.write` | Updates display name and optional description. |
|
||||
| Method | Capability | Description |
|
||||
| ------------------------------ | --------------- | ------------------------------------------------- |
|
||||
| `profile.getCurrent()` | `profile.read` | Returns the current `User` or `null`. |
|
||||
| `profile.update(profile)` | `profile.write` | Updates display name and optional description. |
|
||||
| `profile.updateAvatar(avatar)` | `profile.write` | Updates avatar URL, MIME type, and hash metadata. |
|
||||
|
||||
## Users and Roles
|
||||
|
||||
| Method | Capability | Description |
|
||||
| --- | --- | --- |
|
||||
| `users.getCurrent()` | `users.read` | Returns current `User` or `null`. |
|
||||
| `users.list()` | `users.read` | Returns known users. |
|
||||
| `users.readMembers()` | `users.read` | Returns active room members. |
|
||||
| `users.setRole(userId, role)` | `roles.manage` | Updates a user's role. |
|
||||
| `users.kick(userId)` | `users.manage` | Kicks a user. |
|
||||
| `users.ban(userId, reason?)` | `users.manage` | Bans a user with optional reason. |
|
||||
| `roles.list()` | `roles.read` | Returns room roles. |
|
||||
| `roles.setAssignments(assignments)` | `roles.manage` | Replaces role assignments. |
|
||||
| Method | Capability | Description |
|
||||
| ----------------------------------- | -------------- | --------------------------------- |
|
||||
| `users.getCurrent()` | `users.read` | Returns current `User` or `null`. |
|
||||
| `users.list()` | `users.read` | Returns known users. |
|
||||
| `users.readMembers()` | `users.read` | Returns active room members. |
|
||||
| `users.setRole(userId, role)` | `roles.manage` | Updates a user's role. |
|
||||
| `users.kick(userId)` | `users.manage` | Kicks a user. |
|
||||
| `users.ban(userId, reason?)` | `users.manage` | Bans a user with optional reason. |
|
||||
| `roles.list()` | `roles.read` | Returns room roles. |
|
||||
| `roles.setAssignments(assignments)` | `roles.manage` | Replaces role assignments. |
|
||||
|
||||
## Server
|
||||
|
||||
@@ -98,12 +98,12 @@ interface PluginApiPluginUserRequest {
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Capability | Description |
|
||||
| --- | --- | --- |
|
||||
| `server.getCurrent()` | `server.read` | Returns the current `Room` or `null`. |
|
||||
| `server.registerPluginUser(request)` | `users.manage` | Adds a plugin-owned user and returns its id. |
|
||||
| `server.updatePermissions(permissions)` | `server.manage` | Updates partial room permissions. |
|
||||
| `server.updateSettings(settings)` | `server.manage` | Updates room settings. |
|
||||
| Method | Capability | Description |
|
||||
| --------------------------------------- | --------------- | -------------------------------------------- |
|
||||
| `server.getCurrent()` | `server.read` | Returns the current `Room` or `null`. |
|
||||
| `server.registerPluginUser(request)` | `users.manage` | Adds a plugin-owned user and returns its id. |
|
||||
| `server.updatePermissions(permissions)` | `server.manage` | Updates partial room permissions. |
|
||||
| `server.updateSettings(settings)` | `server.manage` | Updates room settings. |
|
||||
|
||||
## Channels
|
||||
|
||||
@@ -115,14 +115,14 @@ interface PluginApiChannelRequest {
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Capability | Description |
|
||||
| --- | --- | --- |
|
||||
| `channels.list()` | `channels.read` | Returns current room channels. |
|
||||
| `channels.select(channelId)` | `channels.read` | Selects a channel. |
|
||||
| `channels.addAudioChannel(request)` | `channels.manage` | Adds a voice channel. |
|
||||
| Method | Capability | Description |
|
||||
| ----------------------------------- | ----------------- | ---------------------------------- |
|
||||
| `channels.list()` | `channels.read` | Returns current room channels. |
|
||||
| `channels.select(channelId)` | `channels.read` | Selects a channel. |
|
||||
| `channels.addAudioChannel(request)` | `channels.manage` | Adds a voice channel. |
|
||||
| `channels.addVideoChannel(request)` | `channels.manage` | Registers a video channel section. |
|
||||
| `channels.rename(channelId, name)` | `channels.manage` | Renames a channel. |
|
||||
| `channels.remove(channelId)` | `channels.manage` | Removes a channel. |
|
||||
| `channels.rename(channelId, name)` | `channels.manage` | Renames a channel. |
|
||||
| `channels.remove(channelId)` | `channels.manage` | Removes a channel. |
|
||||
|
||||
## Messages
|
||||
|
||||
@@ -134,17 +134,17 @@ interface PluginApiMessageAsPluginUserRequest {
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Capability | Description |
|
||||
| --- | --- | --- |
|
||||
| `messages.readCurrent()` | `messages.read` | Returns current visible messages. |
|
||||
| `messages.send(content, channelId?)` | `messages.send` | Sends a message and returns the created `Message`. |
|
||||
| `messages.sendAsPluginUser(request)` | `messages.send` | Emits a message from a registered plugin user. |
|
||||
| `messages.setTyping(isTyping, channelId?)` | `messages.send` | Broadcasts current typing state for a channel. |
|
||||
| `messages.subscribeTyping(handler)` | `messages.read` | Subscribes to peer typing state. |
|
||||
| `messages.edit(messageId, content)` | `messages.editOwn` | Edits a plugin message. |
|
||||
| `messages.delete(messageId)` | `messages.deleteOwn` | Deletes a plugin message. |
|
||||
| `messages.moderateDelete(messageId)` | `messages.moderate` | Performs a moderation delete. |
|
||||
| `messages.sync(messages)` | `messages.sync` | Syncs an array of messages into state. |
|
||||
| Method | Capability | Description |
|
||||
| ------------------------------------------ | -------------------- | -------------------------------------------------- |
|
||||
| `messages.readCurrent()` | `messages.read` | Returns current visible messages. |
|
||||
| `messages.send(content, channelId?)` | `messages.send` | Sends a message and returns the created `Message`. |
|
||||
| `messages.sendAsPluginUser(request)` | `messages.send` | Emits a message from a registered plugin user. |
|
||||
| `messages.setTyping(isTyping, channelId?)` | `messages.send` | Broadcasts current typing state for a channel. |
|
||||
| `messages.subscribeTyping(handler)` | `messages.read` | Subscribes to peer typing state. |
|
||||
| `messages.edit(messageId, content)` | `messages.editOwn` | Edits a plugin message. |
|
||||
| `messages.delete(messageId)` | `messages.deleteOwn` | Deletes a plugin message. |
|
||||
| `messages.moderateDelete(messageId)` | `messages.moderate` | Performs a moderation delete. |
|
||||
| `messages.sync(messages)` | `messages.sync` | Syncs an array of messages into state. |
|
||||
|
||||
## Events
|
||||
|
||||
@@ -167,12 +167,12 @@ interface PluginEventEnvelope<TPayload = unknown> {
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Capability | Description |
|
||||
| --- | --- | --- |
|
||||
| `events.publishServer(eventName, payload)` | `events.server.publish` | Sends a declared plugin event through the signaling server. |
|
||||
| `events.subscribeServer(subscription)` | `events.server.subscribe` | Subscribes to a declared server plugin event. |
|
||||
| `events.publishP2p(eventName, payload)` | `events.p2p.publish` | Sends a declared plugin event over peer paths. |
|
||||
| `events.subscribeP2p(subscription)` | `events.p2p.subscribe` | Registers a P2P event subscription. |
|
||||
| Method | Capability | Description |
|
||||
| ------------------------------------------ | ------------------------- | ----------------------------------------------------------- |
|
||||
| `events.publishServer(eventName, payload)` | `events.server.publish` | Sends a declared plugin event through the signaling server. |
|
||||
| `events.subscribeServer(subscription)` | `events.server.subscribe` | Subscribes to a declared server plugin event. |
|
||||
| `events.publishP2p(eventName, payload)` | `events.p2p.publish` | Sends a declared plugin event over peer paths. |
|
||||
| `events.subscribeP2p(subscription)` | `events.p2p.subscribe` | Registers a P2P event subscription. |
|
||||
|
||||
## Message Bus
|
||||
|
||||
@@ -215,11 +215,11 @@ interface PluginApiMessageBusSubscription {
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Capability | Description |
|
||||
| --- | --- | --- |
|
||||
| `messageBus.publish(request)` | `events.p2p.publish`, optionally `messages.read` | Publishes a plugin-bus event, optionally including latest messages. |
|
||||
| `messageBus.sendLatestMessages(request?)` | `events.p2p.publish` and `messages.read` | Sends a latest-message snapshot. |
|
||||
| `messageBus.subscribe(subscription)` | `events.p2p.subscribe`, optionally `messages.read` | Subscribes to plugin-bus events, optionally replaying latest messages. |
|
||||
| Method | Capability | Description |
|
||||
| ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `messageBus.publish(request)` | `events.p2p.publish`, optionally `messages.read` | Publishes a plugin-bus event, optionally including latest messages. |
|
||||
| `messageBus.sendLatestMessages(request?)` | `events.p2p.publish` and `messages.read` | Sends a latest-message snapshot. |
|
||||
| `messageBus.subscribe(subscription)` | `events.p2p.subscribe`, optionally `messages.read` | Subscribes to plugin-bus events, optionally replaying latest messages. |
|
||||
|
||||
## P2P and Media
|
||||
|
||||
@@ -235,30 +235,30 @@ interface PluginApiCustomStreamRequest {
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Capability | Description |
|
||||
| --- | --- | --- |
|
||||
| `p2p.connectedPeers()` | `p2p.data` | Returns connected peer ids. |
|
||||
| `p2p.broadcastData(eventName, payload)` | `p2p.data` | Broadcasts plugin data. |
|
||||
| `p2p.sendData(peerId, eventName, payload)` | `p2p.data` | Sends plugin data targeted to a peer. |
|
||||
| `media.playAudioClip(request)` | `media.playAudio` | Plays an audio URL at optional volume. |
|
||||
| `media.addCustomAudioStream(request)` | `media.addAudioStream` | Contributes an audio `MediaStream`. |
|
||||
| `media.addCustomVideoStream(request)` | `media.addVideoStream` | Registers a video `MediaStream` contribution. |
|
||||
| `media.setInputVolume(volume)` | `audio.volume` | Sets local input volume. |
|
||||
| `media.setOutputVolume(volume)` | `audio.volume` | Sets local output volume. |
|
||||
| Method | Capability | Description |
|
||||
| ------------------------------------------ | ---------------------- | --------------------------------------------- |
|
||||
| `p2p.connectedPeers()` | `p2p.data` | Returns connected peer ids. |
|
||||
| `p2p.broadcastData(eventName, payload)` | `p2p.data` | Broadcasts plugin data. |
|
||||
| `p2p.sendData(peerId, eventName, payload)` | `p2p.data` | Sends plugin data targeted to a peer. |
|
||||
| `media.playAudioClip(request)` | `media.playAudio` | Plays an audio URL at optional volume. |
|
||||
| `media.addCustomAudioStream(request)` | `media.addAudioStream` | Contributes an audio `MediaStream`. |
|
||||
| `media.addCustomVideoStream(request)` | `media.addVideoStream` | Registers a video `MediaStream` contribution. |
|
||||
| `media.setInputVolume(volume)` | `audio.volume` | Sets local input volume. |
|
||||
| `media.setOutputVolume(volume)` | `audio.volume` | Sets local output volume. |
|
||||
|
||||
## Storage
|
||||
|
||||
| Method | Capability | Description |
|
||||
| --- | --- | --- |
|
||||
| `clientData.read(key)` | `storage.local` | Reads async plugin-local data. |
|
||||
| `clientData.write(key, value)` | `storage.local` | Writes async plugin-local data. |
|
||||
| `clientData.remove(key)` | `storage.local` | Removes async plugin-local data. |
|
||||
| `serverData.read(key)` | `storage.serverData.read` | Reads local per-user/per-server data. |
|
||||
| `serverData.write(key, value)` | `storage.serverData.write` | Writes local per-user/per-server data. |
|
||||
| `serverData.remove(key)` | `storage.serverData.write` | Removes local per-user/per-server data. |
|
||||
| `storage.get(key)` | `storage.local` | Legacy synchronous local read. |
|
||||
| `storage.set(key, value)` | `storage.local` | Legacy synchronous local write. |
|
||||
| `storage.remove(key)` | `storage.local` | Legacy synchronous local remove. |
|
||||
| Method | Capability | Description |
|
||||
| ------------------------------ | -------------------------- | --------------------------------------- |
|
||||
| `clientData.read(key)` | `storage.local` | Reads async plugin-local data. |
|
||||
| `clientData.write(key, value)` | `storage.local` | Writes async plugin-local data. |
|
||||
| `clientData.remove(key)` | `storage.local` | Removes async plugin-local data. |
|
||||
| `serverData.read(key)` | `storage.serverData.read` | Reads local per-user/per-server data. |
|
||||
| `serverData.write(key, value)` | `storage.serverData.write` | Writes local per-user/per-server data. |
|
||||
| `serverData.remove(key)` | `storage.serverData.write` | Removes local per-user/per-server data. |
|
||||
| `storage.get(key)` | `storage.local` | Legacy synchronous local read. |
|
||||
| `storage.set(key, value)` | `storage.local` | Legacy synchronous local write. |
|
||||
| `storage.remove(key)` | `storage.local` | Legacy synchronous local remove. |
|
||||
|
||||
## UI Contributions
|
||||
|
||||
@@ -306,24 +306,24 @@ interface PluginApiDomMountRequest {
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Capability | Description |
|
||||
| --- | --- | --- |
|
||||
| `ui.registerAppPage(id, contribution)` | `ui.pages` | Adds a plugin app page. |
|
||||
| `ui.registerSettingsPage(id, contribution)` | `ui.settings` | Adds a plugin settings page. |
|
||||
| `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` | Adds a side panel. |
|
||||
| `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` | Adds a channel section. |
|
||||
| `ui.registerComposerAction(id, contribution)` | `ui.pages` | Adds a composer action. |
|
||||
| `ui.registerProfileAction(id, contribution)` | `ui.pages` | Adds a profile action. |
|
||||
| `ui.registerToolbarAction(id, contribution)` | `ui.pages` | Adds a toolbar action. |
|
||||
| `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. |
|
||||
| Method | Capability | Description |
|
||||
| --------------------------------------------- | -------------------- | --------------------------------------------------------------- |
|
||||
| `ui.registerAppPage(id, contribution)` | `ui.pages` | Adds a plugin app page. |
|
||||
| `ui.registerSettingsPage(id, contribution)` | `ui.settings` | Adds a plugin settings page. |
|
||||
| `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` | Adds a side panel. |
|
||||
| `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` | Adds a channel section. |
|
||||
| `ui.registerComposerAction(id, contribution)` | `ui.pages` | Adds a composer action. |
|
||||
| `ui.registerProfileAction(id, contribution)` | `ui.pages` | Adds a profile action. |
|
||||
| `ui.registerToolbarAction(id, contribution)` | `ui.pages` | Adds an action tile to the server side panel View plugins menu. |
|
||||
| `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. |
|
||||
|
||||
## Context and Logger
|
||||
|
||||
| Method | Capability | Description |
|
||||
| --- | --- | --- |
|
||||
| `context.getCurrent()` | None | Reads current user, server, active text channel, and active voice channel. |
|
||||
| `logger.debug(message, data?)` | None | Writes a debug plugin log entry. |
|
||||
| `logger.info(message, data?)` | None | Writes an info plugin log entry. |
|
||||
| `logger.warn(message, data?)` | None | Writes a warning plugin log entry. |
|
||||
| `logger.error(message, data?)` | None | Writes an error plugin log entry. |
|
||||
| Method | Capability | Description |
|
||||
| ------------------------------ | ---------- | -------------------------------------------------------------------------- |
|
||||
| `context.getCurrent()` | None | Reads current user, server, active text channel, and active voice channel. |
|
||||
| `logger.debug(message, data?)` | None | Writes a debug plugin log entry. |
|
||||
| `logger.info(message, data?)` | None | Writes an info plugin log entry. |
|
||||
| `logger.warn(message, data?)` | None | Writes a warning plugin log entry. |
|
||||
| `logger.error(message, data?)` | None | Writes an error plugin log entry. |
|
||||
|
||||
@@ -37,21 +37,23 @@ Example context shape:
|
||||
|
||||
## Action Context
|
||||
|
||||
Composer, toolbar, and profile actions receive context directly.
|
||||
Composer, toolbar, and profile actions receive context directly. Toolbar actions are launched from the server side panel's View plugins menu and report `source: 'toolbarAction'`.
|
||||
|
||||
```js
|
||||
export function activate(context) {
|
||||
context.subscriptions.push(context.api.ui.registerToolbarAction('where-am-i', {
|
||||
label: 'Where am I?',
|
||||
run: (actionContext) => {
|
||||
context.api.logger.info('Toolbar action context', {
|
||||
source: actionContext.source,
|
||||
serverId: actionContext.server?.id,
|
||||
textChannelId: actionContext.textChannel?.id,
|
||||
voiceChannelId: actionContext.voiceChannel?.id
|
||||
});
|
||||
}
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
context.api.ui.registerToolbarAction('where-am-i', {
|
||||
label: 'Where am I?',
|
||||
run: (actionContext) => {
|
||||
context.api.logger.info('Toolbar action context', {
|
||||
source: actionContext.source,
|
||||
serverId: actionContext.server?.id,
|
||||
textChannelId: actionContext.textChannel?.id,
|
||||
voiceChannelId: actionContext.voiceChannel?.id
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -70,4 +72,4 @@ export function activate(context) {
|
||||
}
|
||||
```
|
||||
|
||||
Logs are visible in the Plugin Manager. Avoid logging passwords, bearer tokens, or private message contents.
|
||||
Logs are visible in the Plugin Manager. Avoid logging passwords, bearer tokens, or private message contents.
|
||||
|
||||
@@ -10,17 +10,17 @@ Prefer registered UI contributions over direct DOM mounting. Contribution APIs l
|
||||
|
||||
## Required Capabilities
|
||||
|
||||
| Method | Capability |
|
||||
| --- | --- |
|
||||
| `ui.registerAppPage(id, contribution)` | `ui.pages` |
|
||||
| `ui.registerSettingsPage(id, contribution)` | `ui.settings` |
|
||||
| `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` |
|
||||
| Method | Capability |
|
||||
| --------------------------------------------- | -------------------- |
|
||||
| `ui.registerAppPage(id, contribution)` | `ui.pages` |
|
||||
| `ui.registerSettingsPage(id, contribution)` | `ui.settings` |
|
||||
| `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` |
|
||||
| `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` |
|
||||
| `ui.registerComposerAction(id, contribution)` | `ui.pages` |
|
||||
| `ui.registerProfileAction(id, contribution)` | `ui.pages` |
|
||||
| `ui.registerToolbarAction(id, contribution)` | `ui.pages` |
|
||||
| `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` |
|
||||
| `ui.mountElement(id, request)` | `ui.dom` |
|
||||
| `ui.registerComposerAction(id, contribution)` | `ui.pages` |
|
||||
| `ui.registerProfileAction(id, contribution)` | `ui.pages` |
|
||||
| `ui.registerToolbarAction(id, contribution)` | `ui.pages` |
|
||||
| `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` |
|
||||
| `ui.mountElement(id, request)` | `ui.dom` |
|
||||
|
||||
Every registration returns a disposable. Push it into `context.subscriptions`.
|
||||
|
||||
@@ -28,15 +28,17 @@ Every registration returns a disposable. Push it into `context.subscriptions`.
|
||||
|
||||
```js
|
||||
export function activate(context) {
|
||||
context.subscriptions.push(context.api.ui.registerAppPage('dashboard', {
|
||||
label: 'Raid Dashboard',
|
||||
path: '/plugins/example.raid-helper/dashboard',
|
||||
render: () => {
|
||||
const root = document.createElement('section');
|
||||
root.innerHTML = '<h1>Raid Dashboard</h1><p>Tonight: dungeon practice.</p>';
|
||||
return root;
|
||||
}
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
context.api.ui.registerAppPage('dashboard', {
|
||||
label: 'Raid Dashboard',
|
||||
path: '/plugins/example.raid-helper/dashboard',
|
||||
render: () => {
|
||||
const root = document.createElement('section');
|
||||
root.innerHTML = '<h1>Raid Dashboard</h1><p>Tonight: dungeon practice.</p>';
|
||||
return root;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -46,22 +48,24 @@ The page is hosted by `/plugins/:pluginId/:pageId`.
|
||||
|
||||
```js
|
||||
export function activate(context) {
|
||||
context.subscriptions.push(context.api.ui.registerSettingsPage('preferences', {
|
||||
label: 'Raid Helper',
|
||||
settingsKey: 'raid-helper',
|
||||
order: 20,
|
||||
render: () => {
|
||||
const wrapper = document.createElement('section');
|
||||
const label = document.createElement('label');
|
||||
const checkbox = document.createElement('input');
|
||||
context.subscriptions.push(
|
||||
context.api.ui.registerSettingsPage('preferences', {
|
||||
label: 'Raid Helper',
|
||||
settingsKey: 'raid-helper',
|
||||
order: 20,
|
||||
render: () => {
|
||||
const wrapper = document.createElement('section');
|
||||
const label = document.createElement('label');
|
||||
const checkbox = document.createElement('input');
|
||||
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = true;
|
||||
label.append(checkbox, ' Enable ready-check reminders');
|
||||
wrapper.append(label);
|
||||
return wrapper;
|
||||
}
|
||||
}));
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = true;
|
||||
label.append(checkbox, ' Enable ready-check reminders');
|
||||
wrapper.append(label);
|
||||
return wrapper;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -71,23 +75,26 @@ Use `ui.registerSidePanel` for content that belongs in the server sidebar plugin
|
||||
|
||||
```js
|
||||
export function activate(context) {
|
||||
context.subscriptions.push(context.api.ui.registerSidePanel('soundboard', {
|
||||
label: 'Soundboard',
|
||||
order: 10,
|
||||
render: () => {
|
||||
const panel = document.createElement('div');
|
||||
const button = document.createElement('button');
|
||||
context.subscriptions.push(
|
||||
context.api.ui.registerSidePanel('soundboard', {
|
||||
label: 'Soundboard',
|
||||
order: 10,
|
||||
render: () => {
|
||||
const panel = document.createElement('div');
|
||||
const button = document.createElement('button');
|
||||
|
||||
button.type = 'button';
|
||||
button.textContent = 'Play chime';
|
||||
button.onclick = () => context.api.media.playAudioClip({
|
||||
url: 'https://cdn.example.com/chime.wav',
|
||||
volume: 0.6
|
||||
});
|
||||
panel.append(button);
|
||||
return panel;
|
||||
}
|
||||
}));
|
||||
button.type = 'button';
|
||||
button.textContent = 'Play chime';
|
||||
button.onclick = () =>
|
||||
context.api.media.playAudioClip({
|
||||
url: 'https://cdn.example.com/chime.wav',
|
||||
volume: 0.6
|
||||
});
|
||||
panel.append(button);
|
||||
return panel;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -97,11 +104,13 @@ Capabilities required: `ui.sidePanel` and `media.playAudio`.
|
||||
|
||||
```js
|
||||
export function activate(context) {
|
||||
context.subscriptions.push(context.api.ui.registerChannelSection('events', {
|
||||
label: 'Event Rooms',
|
||||
type: 'custom',
|
||||
order: 50
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
context.api.ui.registerChannelSection('events', {
|
||||
label: 'Event Rooms',
|
||||
type: 'custom',
|
||||
order: 50
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -109,16 +118,15 @@ export function activate(context) {
|
||||
|
||||
```js
|
||||
export function activate(context) {
|
||||
context.subscriptions.push(context.api.ui.registerComposerAction('insert-standup', {
|
||||
icon: 'ST',
|
||||
label: 'Insert standup prompt',
|
||||
run: (actionContext) => {
|
||||
context.api.messages.send(
|
||||
'Standup: yesterday I..., today I..., blocked by...',
|
||||
actionContext.textChannel?.id
|
||||
);
|
||||
}
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
context.api.ui.registerComposerAction('insert-standup', {
|
||||
icon: 'ST',
|
||||
label: 'Insert standup prompt',
|
||||
run: (actionContext) => {
|
||||
context.api.messages.send('Standup: yesterday I..., today I..., blocked by...', actionContext.textChannel?.id);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -128,45 +136,65 @@ Capabilities required: `ui.pages` and `messages.send`.
|
||||
|
||||
```js
|
||||
export function activate(context) {
|
||||
context.subscriptions.push(context.api.ui.registerProfileAction('wave', {
|
||||
label: 'Wave',
|
||||
run: (actionContext) => {
|
||||
context.api.messages.send(`Waving at ${actionContext.user?.displayName ?? 'someone'}!`);
|
||||
}
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
context.api.ui.registerProfileAction('wave', {
|
||||
label: 'Wave',
|
||||
run: (actionContext) => {
|
||||
context.api.messages.send(`Waving at ${actionContext.user?.displayName ?? 'someone'}!`);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Toolbar Action
|
||||
|
||||
Toolbar actions are command-style plugin entries shown in the server side panel's View plugins menu. Use them for small actions that should be easy to launch from a server, such as opening a plugin page, sending a status message, starting a timer, or toggling a plugin feature.
|
||||
|
||||
The View plugins link appears in `[data-testid="plugin-room-side-panel"]` when the plugin side-panel area is rendered. Opening it shows an overlay menu, positioned like profile-card overlays, with registered actions laid out as plugin icon tiles. The `icon` field can be short text such as `RH`, an emoji, or an image URL; when omitted, MetoYou falls back to initials from the plugin/action labels.
|
||||
|
||||
Toolbar action callbacks receive an action context with `source: 'toolbarAction'`, the current user, current server, active text channel, and current voice channel when available.
|
||||
|
||||
```js
|
||||
export function activate(context) {
|
||||
context.subscriptions.push(context.api.ui.registerToolbarAction('open-dashboard', {
|
||||
label: 'Raid Helper',
|
||||
run: () => {
|
||||
context.api.logger.info('Open the Raid Helper plugin page from /plugins/example.raid-helper/dashboard');
|
||||
}
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
context.api.ui.registerToolbarAction('open-dashboard', {
|
||||
icon: 'RH',
|
||||
label: 'Raid Helper',
|
||||
run: (actionContext) => {
|
||||
context.api.logger.info('Raid Helper opened', {
|
||||
channelId: actionContext.textChannel?.id,
|
||||
serverId: actionContext.server?.id
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Capabilities required: `ui.pages`. Add any capability your action uses, such as `messages.send` or `server.read`.
|
||||
|
||||
Use `registerSidePanel` instead when the plugin needs persistent sidebar content, and use `registerAppPage` when the plugin needs a full-page workflow.
|
||||
|
||||
## Embed Renderer
|
||||
|
||||
```js
|
||||
export function activate(context) {
|
||||
context.subscriptions.push(context.api.ui.registerEmbedRenderer('raid-card', {
|
||||
embedType: 'raid.card',
|
||||
render: (payload) => {
|
||||
const card = document.createElement('article');
|
||||
const title = document.createElement('h3');
|
||||
const body = document.createElement('p');
|
||||
context.subscriptions.push(
|
||||
context.api.ui.registerEmbedRenderer('raid-card', {
|
||||
embedType: 'raid.card',
|
||||
render: (payload) => {
|
||||
const card = document.createElement('article');
|
||||
const title = document.createElement('h3');
|
||||
const body = document.createElement('p');
|
||||
|
||||
title.textContent = payload?.title ?? 'Raid';
|
||||
body.textContent = payload?.description ?? 'No description provided.';
|
||||
card.append(title, body);
|
||||
return card;
|
||||
}
|
||||
}));
|
||||
title.textContent = payload?.title ?? 'Raid';
|
||||
body.textContent = payload?.description ?? 'No description provided.';
|
||||
card.append(title, body);
|
||||
return card;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -202,11 +230,13 @@ export function activate(context) {
|
||||
badge.style.color = 'white';
|
||||
badge.style.borderRadius = '6px';
|
||||
|
||||
context.subscriptions.push(context.api.ui.mountElement('active-badge', {
|
||||
target: 'body',
|
||||
position: 'beforeend',
|
||||
element: badge
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
context.api.ui.mountElement('active-badge', {
|
||||
target: 'body',
|
||||
position: 'beforeend',
|
||||
element: badge
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -224,12 +254,14 @@ export function activate(context) {
|
||||
const banner = document.createElement('div');
|
||||
banner.textContent = 'Raid helper active in this chat.';
|
||||
|
||||
context.subscriptions.push(context.api.ui.mountElement('chat-banner', {
|
||||
target,
|
||||
position: 'afterbegin',
|
||||
element: banner
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
context.api.ui.mountElement('chat-banner', {
|
||||
target,
|
||||
position: 'afterbegin',
|
||||
element: banner
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The runtime tags plugin-owned DOM and removes it on unload, but plugins should still keep mounts minimal and accessible.
|
||||
The runtime tags plugin-owned DOM and removes it on unload, but plugins should still keep mounts minimal and accessible.
|
||||
|
||||
@@ -6,44 +6,44 @@ sidebar_position: 3
|
||||
|
||||
Capabilities protect privileged app surfaces. A plugin must declare a capability in its manifest and the user must grant it before the runtime allows the corresponding API call.
|
||||
|
||||
| Capability | API areas | Notes |
|
||||
| --- | --- | --- |
|
||||
| `profile.read` | `profile.getCurrent()` | Reads the current user. |
|
||||
| `profile.write` | `profile.update()`, `profile.updateAvatar()` | Updates local profile fields and avatar metadata. |
|
||||
| `users.read` | `users.getCurrent()`, `users.list()`, `users.readMembers()` | Reads users and server members. |
|
||||
| `users.manage` | `users.kick()`, `users.ban()`, `server.registerPluginUser()` | Can create plugin users and moderate members. |
|
||||
| `roles.read` | `roles.list()` | Reads server roles. |
|
||||
| `roles.manage` | `roles.setAssignments()`, `users.setRole()` | Changes role assignments or user roles. |
|
||||
| `messages.read` | `messages.readCurrent()`, message bus latest snapshots | Reads current channel messages. |
|
||||
| `messages.send` | `messages.send()`, `messages.sendAsPluginUser()` | Sends messages as the current user or registered plugin user. |
|
||||
| `messages.editOwn` | `messages.edit()` | Edits plugin-owned messages. |
|
||||
| `messages.deleteOwn` | `messages.delete()` | Deletes plugin-owned messages. |
|
||||
| `messages.moderate` | `messages.moderateDelete()` | Moderation delete path. |
|
||||
| `messages.sync` | `messages.sync()` | Syncs message arrays into client state. |
|
||||
| `channels.read` | `channels.list()`, `channels.select()` | Reads and selects channels. |
|
||||
| `channels.manage` | `channels.addAudioChannel()`, `channels.addVideoChannel()`, `channels.remove()`, `channels.rename()` | Mutates channel or channel-section state. |
|
||||
| `server.read` | `server.getCurrent()` | Reads active server. |
|
||||
| `server.manage` | `server.updatePermissions()`, `server.updateSettings()` | Updates server permissions or settings. |
|
||||
| `p2p.data` | `p2p.connectedPeers()`, `p2p.broadcastData()`, `p2p.sendData()` | Uses plugin peer data paths. |
|
||||
| `p2p.media` | Reserved peer media features. | Included for media-facing plugins. |
|
||||
| `media.playAudio` | `media.playAudioClip()` | Plays an audio URL locally. |
|
||||
| `media.addAudioStream` | `media.addCustomAudioStream()` | Adds a custom stream to voice handling. |
|
||||
| `media.addVideoStream` | `media.addCustomVideoStream()` | Registers custom video stream contribution. |
|
||||
| `audio.volume` | `media.setInputVolume()`, `media.setOutputVolume()` | Adjusts local voice volume. |
|
||||
| `audio.effects` | Reserved audio effect features. | Included for audio processing plugins. |
|
||||
| `ui.settings` | `ui.registerSettingsPage()` | Adds settings pages. |
|
||||
| `ui.pages` | `ui.registerAppPage()`, `ui.registerComposerAction()`, `ui.registerProfileAction()`, `ui.registerToolbarAction()` | Adds app pages and actions. |
|
||||
| `ui.sidePanel` | `ui.registerSidePanel()` | Adds side panels. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `events.server.publish` | `events.publishServer()` | Publishes declared server plugin events. |
|
||||
| `events.server.subscribe` | `events.subscribeServer()` | Subscribes to declared server plugin events. |
|
||||
| `events.p2p.publish` | `events.publishP2p()`, `messageBus.publish()`, `messageBus.sendLatestMessages()` | Publishes declared P2P/plugin bus events. |
|
||||
| `events.p2p.subscribe` | `events.subscribeP2p()`, `messageBus.subscribe()` | Subscribes to declared P2P/plugin bus events. |
|
||||
| Capability | API areas | Notes |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `profile.read` | `profile.getCurrent()` | Reads the current user. |
|
||||
| `profile.write` | `profile.update()`, `profile.updateAvatar()` | Updates local profile fields and avatar metadata. |
|
||||
| `users.read` | `users.getCurrent()`, `users.list()`, `users.readMembers()` | Reads users and server members. |
|
||||
| `users.manage` | `users.kick()`, `users.ban()`, `server.registerPluginUser()` | Can create plugin users and moderate members. |
|
||||
| `roles.read` | `roles.list()` | Reads server roles. |
|
||||
| `roles.manage` | `roles.setAssignments()`, `users.setRole()` | Changes role assignments or user roles. |
|
||||
| `messages.read` | `messages.readCurrent()`, message bus latest snapshots | Reads current channel messages. |
|
||||
| `messages.send` | `messages.send()`, `messages.sendAsPluginUser()` | Sends messages as the current user or registered plugin user. |
|
||||
| `messages.editOwn` | `messages.edit()` | Edits plugin-owned messages. |
|
||||
| `messages.deleteOwn` | `messages.delete()` | Deletes plugin-owned messages. |
|
||||
| `messages.moderate` | `messages.moderateDelete()` | Moderation delete path. |
|
||||
| `messages.sync` | `messages.sync()` | Syncs message arrays into client state. |
|
||||
| `channels.read` | `channels.list()`, `channels.select()` | Reads and selects channels. |
|
||||
| `channels.manage` | `channels.addAudioChannel()`, `channels.addVideoChannel()`, `channels.remove()`, `channels.rename()` | Mutates channel or channel-section state. |
|
||||
| `server.read` | `server.getCurrent()` | Reads active server. |
|
||||
| `server.manage` | `server.updatePermissions()`, `server.updateSettings()` | Updates server permissions or settings. |
|
||||
| `p2p.data` | `p2p.connectedPeers()`, `p2p.broadcastData()`, `p2p.sendData()` | Uses plugin peer data paths. |
|
||||
| `p2p.media` | Reserved peer media features. | Included for media-facing plugins. |
|
||||
| `media.playAudio` | `media.playAudioClip()` | Plays an audio URL locally. |
|
||||
| `media.addAudioStream` | `media.addCustomAudioStream()` | Adds a custom stream to voice handling. |
|
||||
| `media.addVideoStream` | `media.addCustomVideoStream()` | Registers custom video stream contribution. |
|
||||
| `audio.volume` | `media.setInputVolume()`, `media.setOutputVolume()` | Adjusts local voice volume. |
|
||||
| `audio.effects` | Reserved audio effect features. | Included for audio processing plugins. |
|
||||
| `ui.settings` | `ui.registerSettingsPage()` | Adds settings pages. |
|
||||
| `ui.pages` | `ui.registerAppPage()`, `ui.registerComposerAction()`, `ui.registerProfileAction()`, `ui.registerToolbarAction()` | Adds app pages and action entry points, including View plugins menu actions. |
|
||||
| `ui.sidePanel` | `ui.registerSidePanel()` | Adds side panels. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `events.server.publish` | `events.publishServer()` | Publishes declared server plugin events. |
|
||||
| `events.server.subscribe` | `events.subscribeServer()` | Subscribes to declared server plugin events. |
|
||||
| `events.p2p.publish` | `events.publishP2p()`, `messageBus.publish()`, `messageBus.sendLatestMessages()` | Publishes declared P2P/plugin bus events. |
|
||||
| `events.p2p.subscribe` | `events.subscribeP2p()`, `messageBus.subscribe()` | Subscribes to declared P2P/plugin bus events. |
|
||||
|
||||
## Recommended Practice
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ The manifest file can be named `toju-plugin.json` or `plugin.json`. Entrypoints
|
||||
"schemaVersion": 1,
|
||||
"id": "example.hello-world",
|
||||
"title": "Hello World",
|
||||
"description": "Adds a toolbar action that sends a message.",
|
||||
"description": "Adds a View plugins menu action that sends a message.",
|
||||
"version": "1.0.0",
|
||||
"kind": "client",
|
||||
"scope": "client",
|
||||
@@ -49,6 +49,7 @@ export function activate(context) {
|
||||
api.logger.info('Hello World activated');
|
||||
|
||||
const disposable = api.ui.registerToolbarAction('hello', {
|
||||
icon: 'HI',
|
||||
label: 'Hello',
|
||||
run: () => api.messages.send('Hello from my plugin')
|
||||
});
|
||||
@@ -65,15 +66,17 @@ export function deactivate(context) {
|
||||
}
|
||||
```
|
||||
|
||||
`registerToolbarAction()` adds an action tile to the server side panel's View plugins menu. Use `icon` for the tile badge and keep the `label` short enough to scan in a grid.
|
||||
|
||||
## Lifecycle Hooks
|
||||
|
||||
| Hook | When it runs | Use it for |
|
||||
| --- | --- | --- |
|
||||
| `activate(context)` | During explicit plugin activation. | Register UI, subscribe to events, initialize state. |
|
||||
| `ready(context)` | After the load-order pass has activated ready plugins. | Cross-plugin coordination that needs other plugins loaded. |
|
||||
| `deactivate(context)` | During unload or reload. | Flush state and log shutdown. Disposables are also cleaned up by the host. |
|
||||
| `onPluginDataChanged(context, event)` | When plugin data changes are observed. | React to plugin-scoped persistence changes. |
|
||||
| `onServerRequirementsChanged(context, snapshot)` | When server plugin requirements change. | Adapt to required, optional, blocked, or incompatible server plugins. |
|
||||
| Hook | When it runs | Use it for |
|
||||
| ------------------------------------------------ | ------------------------------------------------------ | -------------------------------------------------------------------------- |
|
||||
| `activate(context)` | During explicit plugin activation. | Register UI, subscribe to events, initialize state. |
|
||||
| `ready(context)` | After the load-order pass has activated ready plugins. | Cross-plugin coordination that needs other plugins loaded. |
|
||||
| `deactivate(context)` | During unload or reload. | Flush state and log shutdown. Disposables are also cleaned up by the host. |
|
||||
| `onPluginDataChanged(context, event)` | When plugin data changes are observed. | React to plugin-scoped persistence changes. |
|
||||
| `onServerRequirementsChanged(context, snapshot)` | When server plugin requirements change. | Adapt to required, optional, blocked, or incompatible server plugins. |
|
||||
|
||||
## Cleanup
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ sidebar_position: 5
|
||||
"schemaVersion": 1,
|
||||
"id": "example.toolbar-message",
|
||||
"title": "Toolbar Message",
|
||||
"description": "Adds a toolbar action that sends a reusable message.",
|
||||
"description": "Adds a View plugins menu action that sends a reusable message.",
|
||||
"version": "1.0.0",
|
||||
"kind": "client",
|
||||
"scope": "client",
|
||||
@@ -33,13 +33,18 @@ sidebar_position: 5
|
||||
export function activate(context) {
|
||||
const { api } = context;
|
||||
|
||||
context.subscriptions.push(api.ui.registerToolbarAction('standup-message', {
|
||||
label: 'Standup',
|
||||
run: () => api.messages.send('Standup: yesterday, today, blocked')
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
api.ui.registerToolbarAction('standup-message', {
|
||||
icon: 'ST',
|
||||
label: 'Standup',
|
||||
run: (actionContext) => api.messages.send('Standup: yesterday, today, blocked', actionContext.textChannel?.id)
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The action appears as a tile in the server side panel's View plugins menu and runs with `source: 'toolbarAction'`.
|
||||
|
||||
## Settings Page Plugin
|
||||
|
||||
```json
|
||||
@@ -67,19 +72,21 @@ export function activate(context) {
|
||||
export function activate(context) {
|
||||
const { api } = context;
|
||||
|
||||
context.subscriptions.push(api.ui.registerSettingsPage('preferences', {
|
||||
label: 'Example Preferences',
|
||||
render: () => {
|
||||
const root = document.createElement('section');
|
||||
const button = document.createElement('button');
|
||||
context.subscriptions.push(
|
||||
api.ui.registerSettingsPage('preferences', {
|
||||
label: 'Example Preferences',
|
||||
render: () => {
|
||||
const root = document.createElement('section');
|
||||
const button = document.createElement('button');
|
||||
|
||||
button.type = 'button';
|
||||
button.textContent = 'Remember preference';
|
||||
button.onclick = () => api.storage.set('enabled', true);
|
||||
root.append(button);
|
||||
return root;
|
||||
}
|
||||
}));
|
||||
button.type = 'button';
|
||||
button.textContent = 'Remember preference';
|
||||
button.onclick = () => api.storage.set('enabled', true);
|
||||
root.append(button);
|
||||
return root;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -99,13 +106,7 @@ A server-scoped plugin can be installed as a server requirement and auto-install
|
||||
"apiVersion": "1.0.0",
|
||||
"compatibility": { "minimumTojuVersion": "1.0.0" },
|
||||
"entrypoint": "./main.js",
|
||||
"capabilities": [
|
||||
"server.read",
|
||||
"users.manage",
|
||||
"ui.sidePanel",
|
||||
"media.playAudio",
|
||||
"messages.send"
|
||||
],
|
||||
"capabilities": ["server.read", "users.manage", "ui.sidePanel", "media.playAudio", "messages.send"],
|
||||
"pluginUser": {
|
||||
"displayName": "Soundboard",
|
||||
"label": "Audio helper"
|
||||
@@ -121,23 +122,25 @@ export function activate(context) {
|
||||
displayName: 'Soundboard'
|
||||
});
|
||||
|
||||
context.subscriptions.push(api.ui.registerSidePanel('sounds', {
|
||||
label: 'Soundboard',
|
||||
render: () => {
|
||||
const panel = document.createElement('div');
|
||||
const button = document.createElement('button');
|
||||
context.subscriptions.push(
|
||||
api.ui.registerSidePanel('sounds', {
|
||||
label: 'Soundboard',
|
||||
render: () => {
|
||||
const panel = document.createElement('div');
|
||||
const button = document.createElement('button');
|
||||
|
||||
button.type = 'button';
|
||||
button.textContent = 'Play chime';
|
||||
button.onclick = async () => {
|
||||
await api.media.playAudioClip({ url: './chime.wav', volume: 0.7 });
|
||||
api.messages.sendAsPluginUser({ pluginUserId: botId, content: 'Played chime' });
|
||||
};
|
||||
button.type = 'button';
|
||||
button.textContent = 'Play chime';
|
||||
button.onclick = async () => {
|
||||
await api.media.playAudioClip({ url: './chime.wav', volume: 0.7 });
|
||||
api.messages.sendAsPluginUser({ pluginUserId: botId, content: 'Played chime' });
|
||||
};
|
||||
|
||||
panel.append(button);
|
||||
return panel;
|
||||
}
|
||||
}));
|
||||
panel.append(button);
|
||||
return panel;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -162,12 +165,14 @@ export function activate(context) {
|
||||
export function activate(context) {
|
||||
const { api } = context;
|
||||
|
||||
context.subscriptions.push(api.messageBus.subscribe({
|
||||
topic: 'poll:votes',
|
||||
replayLatest: true,
|
||||
latestMessageLimit: 20,
|
||||
handler: (event) => api.logger.info('Vote received', event.payload)
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
api.messageBus.subscribe({
|
||||
topic: 'poll:votes',
|
||||
replayLatest: true,
|
||||
latestMessageLimit: 20,
|
||||
handler: (event) => api.logger.info('Vote received', event.payload)
|
||||
})
|
||||
);
|
||||
|
||||
api.messageBus.publish({
|
||||
topic: 'poll:votes',
|
||||
@@ -192,10 +197,12 @@ export function activate(context) {
|
||||
badge.style.right = '1rem';
|
||||
badge.style.bottom = '1rem';
|
||||
|
||||
context.subscriptions.push(context.api.ui.mountElement('active-badge', {
|
||||
target: 'body',
|
||||
element: badge
|
||||
}));
|
||||
context.subscriptions.push(
|
||||
context.api.ui.mountElement('active-badge', {
|
||||
target: 'body',
|
||||
element: badge
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ Plugins add features to MetoYou. They can add pages, buttons, panels, settings,
|
||||
|
||||
## Types of Plugins
|
||||
|
||||
| Type | What it means |
|
||||
| --- | --- |
|
||||
| Client plugin | Installed for your app. It follows you across servers when active. |
|
||||
| Server plugin | Installed for a specific server. It may be required, recommended, optional, blocked, or incompatible. |
|
||||
| Library plugin | Shared plugin code used by other plugins. It is not normally something users interact with directly. |
|
||||
| Type | What it means |
|
||||
| -------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| Client plugin | Installed for your app. It follows you across servers when active. |
|
||||
| Server plugin | Installed for a specific server. It may be required, recommended, optional, blocked, or incompatible. |
|
||||
| Library plugin | Shared plugin code used by other plugins. It is not normally something users interact with directly. |
|
||||
|
||||
## Install from the Plugin Store
|
||||
|
||||
@@ -26,6 +26,10 @@ Plugins add features to MetoYou. They can add pages, buttons, panels, settings,
|
||||
|
||||
Server-scoped plugins installed to the server you are currently viewing are enabled and activated automatically after install, so their panels, actions, or embeds can appear immediately.
|
||||
|
||||
## Use Plugin Actions
|
||||
|
||||
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.
|
||||
|
||||
## Install a Local Plugin
|
||||
|
||||
Desktop builds can discover local plugin folders from the app data plugins directory.
|
||||
@@ -40,12 +44,12 @@ Desktop builds can discover local plugin folders from the app data plugins direc
|
||||
|
||||
When a server uses plugins, MetoYou may show a prompt.
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| Required | You must install the plugin to join or continue using that server. |
|
||||
| Recommended | The server suggests the plugin, but you can choose. |
|
||||
| Optional | The plugin is available for the server, but not required. |
|
||||
| Blocked | The server marks the plugin as not allowed. |
|
||||
| Status | Meaning |
|
||||
| ------------ | --------------------------------------------------------------------------------- |
|
||||
| Required | You must install the plugin to join or continue using that server. |
|
||||
| Recommended | The server suggests the plugin, but you can choose. |
|
||||
| Optional | The plugin is available for the server, but not required. |
|
||||
| Blocked | The server marks the plugin as not allowed. |
|
||||
| Incompatible | The plugin version does not work with your app version or the server requirement. |
|
||||
|
||||
Required plugins are still installed locally on your device. The signaling server stores requirement metadata only; it does not run plugin code.
|
||||
@@ -56,13 +60,13 @@ Plugins must ask for capabilities before using sensitive features.
|
||||
|
||||
Examples:
|
||||
|
||||
| Capability area | Why a plugin might ask |
|
||||
| --- | --- |
|
||||
| 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. |
|
||||
| Storage | Save plugin preferences locally or per server. |
|
||||
| Capability area | Why a plugin might ask |
|
||||
| --------------- | -------------------------------------------------------------------------- |
|
||||
| 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. |
|
||||
| Storage | Save plugin preferences locally or per server. |
|
||||
|
||||
Only grant capabilities to plugins you trust.
|
||||
|
||||
@@ -79,4 +83,4 @@ The Plugin Manager lets you:
|
||||
|
||||
## Plugin Safety Notes
|
||||
|
||||
Plugins are browser-safe JavaScript modules loaded by the client. They do not run on the signaling server. A plugin can only call privileged MetoYou APIs when its manifest declares the capability and you grant it.
|
||||
Plugins are browser-safe JavaScript modules loaded by the client. They do not run on the signaling server. A plugin can only call privileged MetoYou APIs when its manifest declares the capability and you grant it.
|
||||
|
||||
@@ -11,6 +11,7 @@ import { type Page } from '@playwright/test';
|
||||
export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const connections: RTCPeerConnection[] = [];
|
||||
const dataChannels: RTCDataChannel[] = [];
|
||||
const syntheticMediaResources: {
|
||||
audioCtx: AudioContext;
|
||||
source?: AudioScheduledSourceNode;
|
||||
@@ -18,20 +19,40 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||
}[] = [];
|
||||
|
||||
(window as any).__rtcConnections = connections;
|
||||
(window as any).__rtcDataChannels = dataChannels;
|
||||
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
||||
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
|
||||
|
||||
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
||||
const trackDataChannel = (channel: RTCDataChannel) => {
|
||||
if (dataChannels.includes(channel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dataChannels.push(channel);
|
||||
};
|
||||
|
||||
(window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) {
|
||||
const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
|
||||
const originalCreateDataChannel = pc.createDataChannel.bind(pc);
|
||||
|
||||
connections.push(pc);
|
||||
|
||||
pc.createDataChannel = ((label: string, options?: RTCDataChannelInit) => {
|
||||
const channel = originalCreateDataChannel(label, options);
|
||||
|
||||
trackDataChannel(channel);
|
||||
return channel;
|
||||
}) as RTCPeerConnection['createDataChannel'];
|
||||
|
||||
pc.addEventListener('connectionstatechange', () => {
|
||||
(window as any).__lastRtcState = pc.connectionState;
|
||||
});
|
||||
|
||||
pc.addEventListener('datachannel', (event: RTCDataChannelEvent) => {
|
||||
trackDataChannel(event.channel);
|
||||
});
|
||||
|
||||
pc.addEventListener('track', (event: RTCTrackEvent) => {
|
||||
(window as any).__rtcRemoteTracks.push({
|
||||
kind: event.track.kind,
|
||||
@@ -211,6 +232,66 @@ export async function waitForConnectedPeerCount(page: Page, expectedCount: numbe
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the number of tracked RTCDataChannels in the open state. */
|
||||
export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
||||
return page.evaluate(
|
||||
() => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||
(channel) => channel.readyState === 'open'
|
||||
).length ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Wait until the expected number of tracked RTCDataChannels are open. */
|
||||
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(count) => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||
(channel) => channel.readyState === 'open'
|
||||
).length === count,
|
||||
expectedCount,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
/** Close every currently-open RTCDataChannel and return how many were closed. */
|
||||
export async function closeOpenDataChannels(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const channels = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||
|
||||
let closed = 0;
|
||||
|
||||
for (const channel of channels) {
|
||||
if (channel.readyState !== 'open') {
|
||||
continue;
|
||||
}
|
||||
|
||||
channel.close();
|
||||
closed++;
|
||||
}
|
||||
|
||||
return closed;
|
||||
});
|
||||
}
|
||||
|
||||
/** Dispatch a synthetic data-channel error event on each open channel. */
|
||||
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const channels = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||
|
||||
let dispatched = 0;
|
||||
|
||||
for (const channel of channels) {
|
||||
if (channel.readyState !== 'open') {
|
||||
continue;
|
||||
}
|
||||
|
||||
channel.dispatchEvent(new Event('error'));
|
||||
dispatched++;
|
||||
}
|
||||
|
||||
return dispatched;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume all suspended AudioContext instances created by the synthetic
|
||||
* media patch. Uses CDP `Runtime.evaluate` with `userGesture: true` so
|
||||
|
||||
181
e2e/tests/voice/data-channel-recovery.spec.ts
Normal file
181
e2e/tests/voice/data-channel-recovery.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { test, type Client } from '../../fixtures/multi-client';
|
||||
import {
|
||||
closeOpenDataChannels,
|
||||
dispatchDataChannelErrors,
|
||||
dumpRtcDiagnostics,
|
||||
getOpenDataChannelCount,
|
||||
installAutoResumeAudioContext,
|
||||
installWebRTCTracking,
|
||||
waitForAllPeerAudioFlow,
|
||||
waitForAudioStatsPresent,
|
||||
waitForConnectedPeerCount,
|
||||
waitForOpenDataChannelCount
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
|
||||
interface VoiceClient extends Client {
|
||||
displayName: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
const VOICE_CHANNEL = 'General';
|
||||
|
||||
test.describe('Voice data-channel recovery', () => {
|
||||
test('keeps two users hearing each other after a data-channel error and close', async ({ createClient }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const clients = await createVoiceScenario(createClient, 2, `DC Recovery Duo ${Date.now()}`);
|
||||
const [alice, bob] = clients;
|
||||
|
||||
await assertMeshAudio(clients, 1, 'initial two-user voice');
|
||||
|
||||
await test.step('A non-fatal data-channel error does not interrupt audio', async () => {
|
||||
const dispatched = await dispatchDataChannelErrors(alice.page);
|
||||
|
||||
expect(dispatched).toBeGreaterThan(0);
|
||||
await waitForOpenDataChannelCount(alice.page, 1, 15_000);
|
||||
await waitForOpenDataChannelCount(bob.page, 1, 15_000);
|
||||
await assertMeshAudio(clients, 1, 'after synthetic data-channel error');
|
||||
});
|
||||
|
||||
await test.step('A closed data channel is rebuilt and audio resumes both ways', async () => {
|
||||
const closed = await closeOpenDataChannels(alice.page);
|
||||
|
||||
expect(closed).toBeGreaterThan(0);
|
||||
await waitForConnectedPeerCount(alice.page, 1, 60_000);
|
||||
await waitForConnectedPeerCount(bob.page, 1, 60_000);
|
||||
await waitForOpenDataChannelCount(alice.page, 1, 60_000);
|
||||
await waitForOpenDataChannelCount(bob.page, 1, 60_000);
|
||||
await assertMeshAudio(clients, 1, 'after data-channel close recovery');
|
||||
});
|
||||
});
|
||||
|
||||
test('heals a three-user voice mesh when one client loses every data channel', async ({ createClient }) => {
|
||||
test.setTimeout(300_000);
|
||||
|
||||
const clients = await createVoiceScenario(createClient, 3, `DC Recovery Trio ${Date.now()}`);
|
||||
const bob = clients[1];
|
||||
|
||||
await assertMeshAudio(clients, 2, 'initial three-user mesh');
|
||||
|
||||
await test.step('Bob loses all control channels and the full mesh recovers', async () => {
|
||||
const closed = await closeOpenDataChannels(bob.page);
|
||||
|
||||
expect(closed).toBe(2);
|
||||
|
||||
for (const client of clients) {
|
||||
await waitForConnectedPeerCount(client.page, 2, 90_000);
|
||||
await waitForOpenDataChannelCount(client.page, 2, 90_000);
|
||||
}
|
||||
|
||||
await assertMeshAudio(clients, 2, 'after full control-channel recovery');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createVoiceScenario(
|
||||
createClient: () => Promise<Client>,
|
||||
userCount: number,
|
||||
serverName: string
|
||||
): Promise<VoiceClient[]> {
|
||||
const clients: VoiceClient[] = [];
|
||||
|
||||
for (let index = 0; index < userCount; index++) {
|
||||
const client = await createClient();
|
||||
const displayName = `DC Voice ${index + 1}`;
|
||||
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
clients.push({
|
||||
...client,
|
||||
displayName,
|
||||
username: `dc_voice_${Date.now()}_${index + 1}`
|
||||
});
|
||||
}
|
||||
|
||||
await test.step('Register clients', async () => {
|
||||
for (const client of clients) {
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(client.username, client.displayName, USER_PASSWORD);
|
||||
await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 });
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Create and join server', async () => {
|
||||
const hostSearch = new ServerSearchPage(clients[0].page);
|
||||
|
||||
await hostSearch.createServer(serverName, { description: 'Data-channel recovery voice test' });
|
||||
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
for (const client of clients.slice(1)) {
|
||||
const searchPage = new ServerSearchPage(client.page);
|
||||
|
||||
await searchPage.joinServerFromSearch(serverName);
|
||||
await expect(client.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Join everyone to voice', async () => {
|
||||
const hostRoom = new ChatRoomPage(clients[0].page);
|
||||
|
||||
await hostRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||
|
||||
for (const client of clients) {
|
||||
const room = new ChatRoomPage(client.page);
|
||||
|
||||
await room.joinVoiceChannel(VOICE_CHANNEL);
|
||||
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
const expectedRemotePeers = clients.length - 1;
|
||||
|
||||
for (const client of clients) {
|
||||
await waitForConnectedPeerCount(client.page, expectedRemotePeers, 90_000);
|
||||
await waitForOpenDataChannelCount(client.page, expectedRemotePeers, 90_000);
|
||||
await waitForAudioStatsPresent(client.page, 30_000);
|
||||
}
|
||||
});
|
||||
|
||||
return clients;
|
||||
}
|
||||
|
||||
async function installDeterministicVoiceSettings(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('metoyou_voice_settings', JSON.stringify({
|
||||
inputVolume: 100,
|
||||
outputVolume: 100,
|
||||
audioBitrate: 96,
|
||||
latencyProfile: 'balanced',
|
||||
includeSystemAudio: false,
|
||||
noiseReduction: false,
|
||||
screenShareQuality: 'balanced',
|
||||
askScreenShareQuality: false
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function assertMeshAudio(
|
||||
clients: readonly VoiceClient[],
|
||||
expectedRemotePeers: number,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
for (const client of clients) {
|
||||
try {
|
||||
await waitForAllPeerAudioFlow(client.page, expectedRemotePeers, 60_000);
|
||||
} catch (error) {
|
||||
const dataChannelCount = await getOpenDataChannelCount(client.page);
|
||||
|
||||
console.log(`[${client.displayName} ${label} data channels] ${dataChannelCount}`);
|
||||
console.log(`[${client.displayName} ${label} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
743
e2e/tests/voice/direct-call.spec.ts
Normal file
743
e2e/tests/voice/direct-call.spec.ts
Normal file
@@ -0,0 +1,743 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { test, type Client } from '../../fixtures/multi-client';
|
||||
import {
|
||||
closeOpenDataChannels,
|
||||
dumpRtcDiagnostics,
|
||||
installAutoResumeAudioContext,
|
||||
installWebRTCTracking,
|
||||
waitForAllPeerAudioFlow,
|
||||
waitForAudioFlow,
|
||||
waitForAudioStatsPresent,
|
||||
waitForConnectedPeerCount,
|
||||
waitForOpenDataChannelCount,
|
||||
waitForInboundVideoFlow,
|
||||
waitForOutboundVideoFlow,
|
||||
waitForPeerConnected
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||
import { disableLastViewedChatResume } from '../../helpers/seed-test-endpoint';
|
||||
|
||||
interface DirectCallScenario {
|
||||
alice: Client;
|
||||
bob: Client;
|
||||
charlie?: Client;
|
||||
aliceUserId: string;
|
||||
bobUserId: string;
|
||||
charlieUserId?: string;
|
||||
}
|
||||
|
||||
interface AudioFlowDelta {
|
||||
outboundBytesDelta: number;
|
||||
inboundBytesDelta: number;
|
||||
outboundPacketsDelta: number;
|
||||
inboundPacketsDelta: number;
|
||||
}
|
||||
|
||||
interface VideoFlowDelta {
|
||||
outboundBytesDelta: number;
|
||||
inboundBytesDelta: number;
|
||||
outboundPacketsDelta: number;
|
||||
inboundPacketsDelta: number;
|
||||
}
|
||||
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
|
||||
test.describe('Direct private calls', () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test('two users can ring, answer, chat, see self voice indicators, and exchange audio', async ({ createClient }) => {
|
||||
const scenario = await createDirectCallScenario(createClient, { includeCharlie: true });
|
||||
const callMessage = `Call chat ${uniqueName('msg')}`;
|
||||
const privateOnlyMessage = `Private before group ${uniqueName('msg')}`;
|
||||
const groupMessage = `Group call chat ${uniqueName('msg')}`;
|
||||
|
||||
await test.step('Alice starts a call from the search people card', async () => {
|
||||
await disableLastViewedChatResume(scenario.alice.page);
|
||||
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
|
||||
await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const bobPeopleCard = scenario.alice.page.locator(`[data-testid="user-card-${scenario.bobUserId}"]`, { hasText: 'Bob' }).first();
|
||||
|
||||
await expect(bobPeopleCard).toBeVisible({ timeout: 20_000 });
|
||||
await bobPeopleCard.hover();
|
||||
await bobPeopleCard.getByRole('button', { name: 'Call Bob' }).click();
|
||||
await expect(scenario.alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||
await expect(scenario.alice.page.locator('app-private-call')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.alice.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Alice starts sharing before Bob joins', async () => {
|
||||
await scenario.alice.page.getByRole('button', { name: 'Share screen' }).click();
|
||||
await expect(scenario.alice.page.getByRole('button', { name: 'Stop sharing screen' })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.alice.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Bob receives a ringing call and the ring stops when he answers', async () => {
|
||||
await expect
|
||||
.poll(async () => await getCallAudioPlayCount(scenario.bob.page), {
|
||||
timeout: 20_000,
|
||||
intervals: [500, 1_000]
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
|
||||
await expect
|
||||
.poll(async () => await getCallNotificationCount(scenario.bob.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [250, 500]
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click();
|
||||
await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
|
||||
await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
|
||||
|
||||
await expect
|
||||
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [250, 500]
|
||||
})
|
||||
.toBe(0);
|
||||
|
||||
await expect
|
||||
.poll(async () => await getCallAudioPauseCount(scenario.bob.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [250, 500]
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await test.step('WebRTC connects and late-join screen share is visible', async () => {
|
||||
await waitForPeerConnected(scenario.alice.page, 45_000);
|
||||
await waitForPeerConnected(scenario.bob.page, 45_000);
|
||||
await expectParticipantConnected(scenario.alice.page, scenario.aliceUserId);
|
||||
await expectParticipantConnected(scenario.alice.page, scenario.bobUserId);
|
||||
await expectParticipantConnected(scenario.bob.page, scenario.aliceUserId);
|
||||
await expectParticipantConnected(scenario.bob.page, scenario.bobUserId);
|
||||
|
||||
const aliceVideo = await waitForOutboundVideoFlow(scenario.alice.page, 30_000);
|
||||
const bobVideo = await waitForInboundVideoFlow(scenario.bob.page, 30_000);
|
||||
|
||||
if (!isOutboundVideoFlowing(aliceVideo) || !isInboundVideoFlowing(bobVideo)) {
|
||||
console.log('[Alice direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.alice.page)));
|
||||
console.log('[Bob direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.bob.page)));
|
||||
}
|
||||
|
||||
expectOutboundVideoFlow(aliceVideo, 'Alice late-join direct call screen share');
|
||||
expectInboundVideoFlow(bobVideo, 'Bob late-join direct call screen share');
|
||||
await expect(scenario.bob.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Audio flows in both directions', async () => {
|
||||
await waitForAudioStatsPresent(scenario.alice.page, 30_000);
|
||||
await waitForAudioStatsPresent(scenario.bob.page, 30_000);
|
||||
|
||||
const aliceDelta = await waitForAudioFlow(scenario.alice.page, 45_000);
|
||||
const bobDelta = await waitForAudioFlow(scenario.bob.page, 45_000);
|
||||
|
||||
if (!isAudioFlowing(aliceDelta) || !isAudioFlowing(bobDelta)) {
|
||||
console.log('[Alice direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.alice.page)));
|
||||
console.log('[Bob direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.bob.page)));
|
||||
}
|
||||
|
||||
expectAudioFlow(aliceDelta, 'Alice direct call');
|
||||
expectAudioFlow(bobDelta, 'Bob direct call');
|
||||
});
|
||||
|
||||
await test.step('Adding a third participant converts the call chat to an empty group chat', async () => {
|
||||
if (!scenario.charlie || !scenario.charlieUserId) {
|
||||
throw new Error('Expected direct-call scenario to include Charlie.');
|
||||
}
|
||||
|
||||
const charlie = scenario.charlie;
|
||||
|
||||
await scenario.alice.page.getByTestId('dm-input').fill(privateOnlyMessage);
|
||||
await scenario.alice.page.getByTestId('dm-input').press('Enter');
|
||||
await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await scenario.alice.page.getByLabel('Add user to call').selectOption(scenario.charlieUserId);
|
||||
await scenario.alice.page.getByRole('button', { name: 'Add user' }).click();
|
||||
|
||||
await expect(scenario.alice.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0, {
|
||||
timeout: 20_000
|
||||
});
|
||||
|
||||
await expect(scenario.alice.page.locator('[data-testid^="dm-rail-item-dm-group-"]')).toHaveCount(0, { timeout: 20_000 });
|
||||
await expect(scenario.alice.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
|
||||
|
||||
await expect
|
||||
.poll(async () => await getCallAudioPlayCount(charlie.page), {
|
||||
timeout: 20_000,
|
||||
intervals: [500, 1_000]
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await charlie.page.getByRole('button', { name: 'Open private call' }).click();
|
||||
await expect(charlie.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||
await charlie.page.getByRole('button', { name: 'Join call' }).click();
|
||||
await expect(charlie.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0);
|
||||
|
||||
await waitForConnectedPeerCount(scenario.alice.page, 2, 45_000);
|
||||
await waitForConnectedPeerCount(scenario.bob.page, 2, 45_000);
|
||||
await waitForConnectedPeerCount(charlie.page, 2, 45_000);
|
||||
await Promise.all([
|
||||
waitForAllPeerAudioFlow(scenario.alice.page, 2, 45_000),
|
||||
waitForAllPeerAudioFlow(scenario.bob.page, 2, 45_000),
|
||||
waitForAllPeerAudioFlow(charlie.page, 2, 45_000)
|
||||
]);
|
||||
|
||||
await expectParticipantConnected(scenario.alice.page, scenario.charlieUserId);
|
||||
await expectParticipantConnected(scenario.bob.page, scenario.charlieUserId);
|
||||
await expectParticipantConnected(charlie.page, scenario.aliceUserId);
|
||||
await expectParticipantConnected(charlie.page, scenario.bobUserId);
|
||||
await expectParticipantConnected(charlie.page, scenario.charlieUserId);
|
||||
|
||||
await scenario.alice.page.getByTestId('dm-input').fill(groupMessage);
|
||||
await scenario.alice.page.getByTestId('dm-input').press('Enter');
|
||||
await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(groupMessage)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(groupMessage)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Private call streams can switch between all-stream and focused viewing', async () => {
|
||||
await scenario.bob.page.getByRole('button', { name: 'Turn camera on' }).click();
|
||||
await expect(scenario.bob.page.getByRole('button', { name: 'Turn camera off' })).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expect
|
||||
.poll(async () => await privateCallGridStreamCount(scenario.bob.page), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1_000]
|
||||
})
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
|
||||
await scenario.bob.page
|
||||
.getByTestId('private-call-stream-grid')
|
||||
.locator('app-voice-workspace-stream-tile')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(scenario.bob.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.bob.page.getByTestId('private-call-show-all-streams')).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await assertSustainedMediaFlow(scenario.alice.page, scenario.bob.page, 'direct call screen share and camera');
|
||||
|
||||
await scenario.bob.page.getByTestId('private-call-focused-stream').dblclick();
|
||||
await expect
|
||||
.poll(async () => await hasFullscreenElement(scenario.bob.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [250, 500]
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await exitFullscreen(scenario.bob.page);
|
||||
|
||||
await expect
|
||||
.poll(async () => await hasFullscreenElement(scenario.bob.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [250, 500]
|
||||
})
|
||||
.toBe(false);
|
||||
|
||||
await scenario.bob.page.getByTestId('private-call-show-all-streams').click();
|
||||
await expect(scenario.bob.page.getByTestId('private-call-stream-grid')).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Both clients show their own speaking indicator', async () => {
|
||||
await expect(scenario.alice.page.getByTestId(`call-participant-${scenario.aliceUserId}`)).toHaveClass(/ring-emerald-400/, {
|
||||
timeout: 20_000
|
||||
});
|
||||
|
||||
await expect(scenario.bob.page.getByTestId(`call-participant-${scenario.bobUserId}`)).toHaveClass(/ring-emerald-400/, {
|
||||
timeout: 20_000
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Private call layout does not require vertical scrolling', async () => {
|
||||
await expect
|
||||
.poll(async () => await privateCallMainHasNoVerticalOverflow(scenario.alice.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [250, 500]
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(async () => await privateCallMainHasNoVerticalOverflow(scenario.bob.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [250, 500]
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await expect(scenario.alice.page.locator('app-private-call article').filter({ hasText: /Connected|Speaking/ })).toHaveCount(0);
|
||||
await expect(scenario.bob.page.locator('app-private-call article').filter({ hasText: /Connected|Speaking/ })).toHaveCount(0);
|
||||
await expect(scenario.alice.page.getByText('No live streams yet')).toHaveCount(0);
|
||||
await expect(scenario.bob.page.getByText('No live streams yet')).toHaveCount(0);
|
||||
|
||||
const originalWidth = await privateCallChatWidth(scenario.alice.page);
|
||||
const resizer = scenario.alice.page.getByTestId('private-call-chat-resizer');
|
||||
const box = await resizer.boundingBox();
|
||||
|
||||
expect(box, 'private call chat resizer should be measurable').not.toBeNull();
|
||||
|
||||
if (box) {
|
||||
await scenario.alice.page.mouse.move(box.x + box.width / 2, box.y + 20);
|
||||
await scenario.alice.page.mouse.down();
|
||||
await scenario.alice.page.mouse.move(box.x - 96, box.y + 20);
|
||||
await scenario.alice.page.mouse.up();
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => await privateCallChatWidth(scenario.alice.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [250, 500]
|
||||
})
|
||||
.toBeGreaterThan(originalWidth + 40);
|
||||
});
|
||||
|
||||
await test.step('Embedded call chat syncs and does not expose another call button', async () => {
|
||||
await expect(scenario.alice.page.locator('app-private-call aside').getByRole('button', { name: /Call/i })).toHaveCount(0);
|
||||
await expect(scenario.bob.page.locator('app-private-call aside').getByRole('button', { name: /Call/i })).toHaveCount(0);
|
||||
|
||||
await scenario.bob.page.getByTestId('dm-input').fill('typing from Bob');
|
||||
await expect(scenario.alice.page.getByTestId('dm-typing-indicator')).toContainText('Bob is typing', { timeout: 20_000 });
|
||||
|
||||
await scenario.alice.page.getByTestId('dm-input').fill(callMessage);
|
||||
await scenario.alice.page.getByTestId('dm-input').press('Enter');
|
||||
await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(callMessage)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Group chat call button rings every other participant', async () => {
|
||||
if (!scenario.charlie) {
|
||||
throw new Error('Expected direct-call scenario to include Charlie.');
|
||||
}
|
||||
|
||||
await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click();
|
||||
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
|
||||
await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click();
|
||||
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
|
||||
await scenario.charlie.page.getByRole('button', { name: 'Leave call' }).click();
|
||||
await expect(scenario.charlie.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
|
||||
|
||||
const bobPlayCountBeforeGroupCall = await getCallAudioPlayCount(scenario.bob.page);
|
||||
const charliePlayCountBeforeGroupCall = await getCallAudioPlayCount(scenario.charlie.page);
|
||||
|
||||
await scenario.alice.page
|
||||
.locator('app-dm-chat header')
|
||||
.getByRole('button', { name: /Call/i })
|
||||
.click();
|
||||
|
||||
await expect(scenario.alice.page).toHaveURL(/\/call\/dm-group-/, { timeout: 20_000 });
|
||||
|
||||
await expect
|
||||
.poll(async () => await getCallAudioPlayCount(scenario.bob.page), {
|
||||
timeout: 20_000,
|
||||
intervals: [500, 1_000]
|
||||
})
|
||||
.toBeGreaterThan(bobPlayCountBeforeGroupCall);
|
||||
|
||||
await expect
|
||||
.poll(async () => await getCallAudioPlayCount(scenario.charlie.page), {
|
||||
timeout: 20_000,
|
||||
intervals: [500, 1_000]
|
||||
})
|
||||
.toBeGreaterThan(charliePlayCountBeforeGroupCall);
|
||||
|
||||
await scenario.bob.page
|
||||
.getByRole('button', { name: 'Open private call' })
|
||||
.last()
|
||||
.click();
|
||||
|
||||
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
|
||||
await expect
|
||||
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [250, 500]
|
||||
})
|
||||
.toBe(0);
|
||||
|
||||
await scenario.charlie.page
|
||||
.getByRole('button', { name: 'Open private call' })
|
||||
.last()
|
||||
.click();
|
||||
|
||||
await scenario.charlie.page.getByRole('button', { name: 'Join call' }).click();
|
||||
await expect
|
||||
.poll(async () => await getActiveCallAudioLoops(scenario.charlie.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [250, 500]
|
||||
})
|
||||
.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps private-call audio flowing after the data channel closes', async ({ createClient }) => {
|
||||
const scenario = await createDirectCallScenario(createClient);
|
||||
|
||||
await test.step('Alice starts a private call and Bob joins', async () => {
|
||||
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
|
||||
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click();
|
||||
await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
|
||||
await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await waitForConnectedPeerCount(scenario.alice.page, 1, 45_000);
|
||||
await waitForConnectedPeerCount(scenario.bob.page, 1, 45_000);
|
||||
await waitForOpenDataChannelCount(scenario.alice.page, 1, 45_000);
|
||||
await waitForOpenDataChannelCount(scenario.bob.page, 1, 45_000);
|
||||
await waitForAllPeerAudioFlow(scenario.alice.page, 1, 45_000);
|
||||
await waitForAllPeerAudioFlow(scenario.bob.page, 1, 45_000);
|
||||
});
|
||||
|
||||
await test.step('Data-channel recovery keeps the call audible', async () => {
|
||||
const closed = await closeOpenDataChannels(scenario.alice.page);
|
||||
|
||||
expect(closed).toBeGreaterThan(0);
|
||||
await waitForOpenDataChannelCount(scenario.alice.page, 1, 60_000);
|
||||
await waitForOpenDataChannelCount(scenario.bob.page, 1, 60_000);
|
||||
await waitForAllPeerAudioFlow(scenario.alice.page, 1, 60_000);
|
||||
await waitForAllPeerAudioFlow(scenario.bob.page, 1, 60_000);
|
||||
});
|
||||
});
|
||||
|
||||
test('missing and ended private calls do not leave stale call controls behind', async ({ createClient }) => {
|
||||
const scenario = await createDirectCallScenario(createClient);
|
||||
|
||||
await test.step('Unknown call routes render an inert empty state', async () => {
|
||||
await scenario.alice.page.goto('/call/not-a-real-call', { waitUntil: 'domcontentloaded' });
|
||||
await expect(scenario.alice.page.getByText('No active call for this route.')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.alice.page.getByRole('button', { name: 'Join call' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step('Caller leaving before answer clears recipient call route and rail icon', async () => {
|
||||
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
|
||||
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click();
|
||||
await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||
|
||||
await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click();
|
||||
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
|
||||
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
|
||||
await expect(scenario.bob.page.getByRole('button', { name: 'Open private call' })).toHaveCount(0);
|
||||
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [250, 500]
|
||||
})
|
||||
.toBe(0);
|
||||
});
|
||||
|
||||
await test.step('Leaving an answered call clears local ringing and returns to DM', async () => {
|
||||
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
|
||||
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click();
|
||||
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
|
||||
await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click();
|
||||
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
|
||||
await expect
|
||||
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
|
||||
timeout: 10_000,
|
||||
intervals: [250, 500]
|
||||
})
|
||||
.toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createDirectCallScenario(
|
||||
createClient: () => Promise<Client>,
|
||||
options: { includeCharlie?: boolean } = {}
|
||||
): Promise<DirectCallScenario> {
|
||||
const suffix = uniqueName('direct-call');
|
||||
const serverName = `Direct Call Server ${suffix}`;
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
const charlie = options.includeCharlie ? await createClient() : undefined;
|
||||
|
||||
await installDirectCallInstrumentation(alice.page);
|
||||
await installDirectCallInstrumentation(bob.page);
|
||||
|
||||
if (charlie) {
|
||||
await installDirectCallInstrumentation(charlie.page);
|
||||
}
|
||||
|
||||
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
|
||||
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
|
||||
|
||||
if (charlie) {
|
||||
await registerUser(charlie.page, `charlie_${suffix}`, 'Charlie');
|
||||
}
|
||||
|
||||
const aliceUserId = await getCurrentUserId(alice.page);
|
||||
const aliceSearch = new ServerSearchPage(alice.page);
|
||||
|
||||
await aliceSearch.createServer(serverName, { description: 'E2E direct call discovery server' });
|
||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await new ChatMessagesPage(alice.page).waitForReady();
|
||||
|
||||
const bobSearch = new ServerSearchPage(bob.page);
|
||||
|
||||
await bobSearch.joinServerFromSearch(serverName);
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await new ChatMessagesPage(bob.page).waitForReady();
|
||||
|
||||
if (charlie) {
|
||||
const charlieSearch = new ServerSearchPage(charlie.page);
|
||||
|
||||
await charlieSearch.joinServerFromSearch(serverName);
|
||||
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await new ChatMessagesPage(charlie.page).waitForReady();
|
||||
}
|
||||
|
||||
const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
|
||||
const charlieRoomCard = charlie ? alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Charlie' }).first() : null;
|
||||
|
||||
await expect(bobRoomCard).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
if (charlieRoomCard) {
|
||||
await expect(charlieRoomCard).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
const bobUserCardTestId = await bobRoomCard.getAttribute('data-testid');
|
||||
const bobUserId = bobUserCardTestId?.replace('room-user-card-', '');
|
||||
const charlieUserCardTestId = charlieRoomCard ? await charlieRoomCard.getAttribute('data-testid') : null;
|
||||
const charlieUserId = charlieUserCardTestId?.replace('room-user-card-', '');
|
||||
|
||||
if (!aliceUserId || !bobUserId || (charlie && !charlieUserId)) {
|
||||
throw new Error('Expected direct-call scenario users to expose stable ids.');
|
||||
}
|
||||
|
||||
return {
|
||||
alice,
|
||||
bob,
|
||||
charlie,
|
||||
aliceUserId,
|
||||
bobUserId,
|
||||
charlieUserId
|
||||
};
|
||||
}
|
||||
|
||||
async function installDirectCallInstrumentation(page: Page): Promise<void> {
|
||||
await installWebRTCTracking(page);
|
||||
await installAutoResumeAudioContext(page);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
'metoyou_voice_settings',
|
||||
JSON.stringify({
|
||||
inputVolume: 100,
|
||||
outputVolume: 100,
|
||||
audioBitrate: 96,
|
||||
latencyProfile: 'balanced',
|
||||
includeSystemAudio: false,
|
||||
noiseReduction: false,
|
||||
screenShareQuality: 'balanced',
|
||||
askScreenShareQuality: false
|
||||
})
|
||||
);
|
||||
|
||||
const OriginalAudio = window.Audio;
|
||||
const callAudioState = {
|
||||
activeLoops: 0,
|
||||
pauseCount: 0,
|
||||
playCount: 0
|
||||
};
|
||||
const callNotificationState = {
|
||||
count: 0,
|
||||
bodies: [] as string[],
|
||||
titles: [] as string[]
|
||||
};
|
||||
|
||||
(window as Window & { __callAudioState?: typeof callAudioState }).__callAudioState = callAudioState;
|
||||
(window as Window & { __callNotificationState?: typeof callNotificationState }).__callNotificationState = callNotificationState;
|
||||
|
||||
function isCallAudio(audio: HTMLAudioElement): boolean {
|
||||
return audio.src.includes('/assets/audio/call.wav') || audio.src.endsWith('assets/audio/call.wav');
|
||||
}
|
||||
|
||||
(window as unknown as { Audio: typeof Audio }).Audio = function(this: HTMLAudioElement, src?: string) {
|
||||
const audio = new OriginalAudio(src);
|
||||
const originalPlay = audio.play.bind(audio);
|
||||
const originalPause = audio.pause.bind(audio);
|
||||
|
||||
audio.play = () => {
|
||||
if (isCallAudio(audio)) {
|
||||
callAudioState.playCount += 1;
|
||||
|
||||
if (audio.loop) {
|
||||
callAudioState.activeLoops += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return originalPlay();
|
||||
};
|
||||
|
||||
audio.pause = () => {
|
||||
if (isCallAudio(audio)) {
|
||||
callAudioState.pauseCount += 1;
|
||||
|
||||
if (audio.loop && callAudioState.activeLoops > 0) {
|
||||
callAudioState.activeLoops -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return originalPause();
|
||||
};
|
||||
|
||||
return audio;
|
||||
} as typeof Audio;
|
||||
|
||||
window.Audio.prototype = OriginalAudio.prototype;
|
||||
Object.setPrototypeOf(window.Audio, OriginalAudio);
|
||||
|
||||
class MockNotification {
|
||||
static permission: NotificationPermission = 'granted';
|
||||
|
||||
onclick: ((this: Notification, ev: Event) => unknown) | null = null;
|
||||
|
||||
constructor(title: string, options?: NotificationOptions) {
|
||||
callNotificationState.count += 1;
|
||||
callNotificationState.titles.push(title);
|
||||
callNotificationState.bodies.push(options?.body ?? '');
|
||||
}
|
||||
|
||||
static async requestPermission(): Promise<NotificationPermission> {
|
||||
return 'granted';
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
(window as unknown as { Notification: typeof Notification }).Notification = MockNotification as unknown as typeof Notification;
|
||||
});
|
||||
}
|
||||
|
||||
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(username, displayName, USER_PASSWORD);
|
||||
await expect(page).toHaveURL(/\/search/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function startCallFromSearch(page: Page, userId: string, displayName: string): Promise<void> {
|
||||
await disableLastViewedChatResume(page);
|
||||
await page.goto('/search', { waitUntil: 'domcontentloaded' });
|
||||
const peopleCard = page.locator(`[data-testid="user-card-${userId}"]`, { hasText: displayName }).first();
|
||||
|
||||
await expect(peopleCard).toBeVisible({ timeout: 20_000 });
|
||||
await peopleCard.hover();
|
||||
await peopleCard.getByRole('button', { name: `Call ${displayName}` }).click();
|
||||
await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function getCurrentUserId(page: Page): Promise<string> {
|
||||
return await page.evaluate(() => localStorage.getItem('metoyou_currentUserId') ?? '');
|
||||
}
|
||||
|
||||
async function getCallAudioPlayCount(page: Page): Promise<number> {
|
||||
return await page.evaluate(() => (window as Window & { __callAudioState?: { playCount: number } }).__callAudioState?.playCount ?? 0);
|
||||
}
|
||||
|
||||
async function getCallAudioPauseCount(page: Page): Promise<number> {
|
||||
return await page.evaluate(() => (window as Window & { __callAudioState?: { pauseCount: number } }).__callAudioState?.pauseCount ?? 0);
|
||||
}
|
||||
|
||||
async function getCallNotificationCount(page: Page): Promise<number> {
|
||||
return await page.evaluate(() => (
|
||||
window as Window & { __callNotificationState?: { count: number } }
|
||||
).__callNotificationState?.count ?? 0);
|
||||
}
|
||||
|
||||
async function getActiveCallAudioLoops(page: Page): Promise<number> {
|
||||
return await page.evaluate(() => (window as Window & { __callAudioState?: { activeLoops: number } }).__callAudioState?.activeLoops ?? 0);
|
||||
}
|
||||
|
||||
async function expectParticipantConnected(page: Page, userId: string | undefined): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error('Expected a stable participant id.');
|
||||
}
|
||||
|
||||
await expect(page.getByTestId(`call-participant-${userId}`)).not.toHaveClass(/opacity-55/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function assertSustainedMediaFlow(senderPage: Page, receiverPage: Page, label: string): Promise<void> {
|
||||
for (let sample = 0; sample < 3; sample++) {
|
||||
const [
|
||||
senderAudio,
|
||||
receiverAudio,
|
||||
outboundVideo,
|
||||
inboundVideo
|
||||
] = await Promise.all([
|
||||
waitForAudioFlow(senderPage, 30_000),
|
||||
waitForAudioFlow(receiverPage, 30_000),
|
||||
waitForOutboundVideoFlow(senderPage, 30_000),
|
||||
waitForInboundVideoFlow(receiverPage, 30_000)
|
||||
]);
|
||||
|
||||
expectAudioFlow(senderAudio, `${label} sender sample ${sample + 1}`);
|
||||
expectAudioFlow(receiverAudio, `${label} receiver sample ${sample + 1}`);
|
||||
expectOutboundVideoFlow(outboundVideo, `${label} outbound sample ${sample + 1}`);
|
||||
expectInboundVideoFlow(inboundVideo, `${label} inbound sample ${sample + 1}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function privateCallMainHasNoVerticalOverflow(page: Page): Promise<boolean> {
|
||||
return await page.locator('app-private-call > section > main').evaluate((main) => main.scrollHeight <= main.clientHeight + 1);
|
||||
}
|
||||
|
||||
async function privateCallGridStreamCount(page: Page): Promise<number> {
|
||||
return await page
|
||||
.getByTestId('private-call-stream-grid')
|
||||
.locator('app-voice-workspace-stream-tile')
|
||||
.count();
|
||||
}
|
||||
|
||||
async function privateCallChatWidth(page: Page): Promise<number> {
|
||||
return await page.locator('app-private-call aside').evaluate((aside) => aside.getBoundingClientRect().width);
|
||||
}
|
||||
|
||||
async function hasFullscreenElement(page: Page): Promise<boolean> {
|
||||
return await page.evaluate(() => document.fullscreenElement !== null);
|
||||
}
|
||||
|
||||
async function exitFullscreen(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function expectAudioFlow(delta: AudioFlowDelta, label: string): void {
|
||||
expect(delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0, `${label} should send audio`).toBe(true);
|
||||
expect(delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0, `${label} should receive audio`).toBe(true);
|
||||
}
|
||||
|
||||
function expectOutboundVideoFlow(delta: VideoFlowDelta, label: string): void {
|
||||
expect(isOutboundVideoFlowing(delta), `${label} should send video`).toBe(true);
|
||||
}
|
||||
|
||||
function expectInboundVideoFlow(delta: VideoFlowDelta, label: string): void {
|
||||
expect(isInboundVideoFlowing(delta), `${label} should receive video`).toBe(true);
|
||||
}
|
||||
|
||||
function isAudioFlowing(delta: AudioFlowDelta): boolean {
|
||||
return (delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0)
|
||||
&& (delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0);
|
||||
}
|
||||
|
||||
function isOutboundVideoFlowing(delta: VideoFlowDelta): boolean {
|
||||
return delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0;
|
||||
}
|
||||
|
||||
function isInboundVideoFlowing(delta: VideoFlowDelta): boolean {
|
||||
return delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0;
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
@@ -16,7 +16,8 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
|
||||
| --- | --- |
|
||||
| `main.ts` | Electron app bootstrap and process entry point |
|
||||
| `preload.ts` | Typed renderer-facing preload bridge |
|
||||
| `process-list.ts` | Linux/Windows process-name scan used by now-playing game detection |
|
||||
| `process-list.ts` | Linux/Windows process-name scan used as a fallback when foreground detection is unavailable |
|
||||
| `game-detection/` | Foreground-window detection (`get-windows` + Hyprland/Sway fallbacks) plus pure heuristics scoring and ignore-list filtering |
|
||||
| `app/` | App lifecycle and startup composition |
|
||||
| `ipc/` | Renderer-invoked IPC handlers |
|
||||
| `cqrs/` | Local database command/query handlers and mappings |
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface DesktopSettings {
|
||||
autoStart: boolean;
|
||||
closeToTray: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
ignoredGameProcesses: string[];
|
||||
localApi: LocalApiSettings;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
@@ -42,6 +43,7 @@ const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||
autoStart: true,
|
||||
closeToTray: true,
|
||||
hardwareAcceleration: true,
|
||||
ignoredGameProcesses: [],
|
||||
localApi: { ...DEFAULT_LOCAL_API_SETTINGS },
|
||||
manifestUrls: [],
|
||||
preferredVersion: null,
|
||||
@@ -80,6 +82,31 @@ function normalizeManifestUrls(value: unknown): string[] {
|
||||
return manifestUrls;
|
||||
}
|
||||
|
||||
function normalizeIgnoredGameProcesses(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ignored: string[] = [];
|
||||
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = entry.trim().toLowerCase()
|
||||
.replace(/\.(exe|bin|app|out)$/iu, '');
|
||||
|
||||
if (!trimmed || trimmed.length > 96 || ignored.includes(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ignored.push(trimmed);
|
||||
}
|
||||
|
||||
return ignored.sort();
|
||||
}
|
||||
|
||||
function normalizePort(value: unknown, fallback: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
@@ -171,6 +198,7 @@ export function readDesktopSettings(): DesktopSettings {
|
||||
hardwareAcceleration: typeof parsed.hardwareAcceleration === 'boolean'
|
||||
? parsed.hardwareAcceleration
|
||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||
ignoredGameProcesses: normalizeIgnoredGameProcesses(parsed.ignoredGameProcesses),
|
||||
localApi: normalizeLocalApiSettings(parsed.localApi),
|
||||
manifestUrls: normalizeManifestUrls(parsed.manifestUrls),
|
||||
preferredVersion: normalizePreferredVersion(parsed.preferredVersion)
|
||||
@@ -200,6 +228,7 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
|
||||
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
||||
? mergedSettings.hardwareAcceleration
|
||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||
ignoredGameProcesses: normalizeIgnoredGameProcesses(mergedSettings.ignoredGameProcesses),
|
||||
localApi: normalizeLocalApiSettings(mergedSettings.localApi),
|
||||
manifestUrls: normalizeManifestUrls(mergedSettings.manifestUrls),
|
||||
preferredVersion: normalizePreferredVersion(mergedSettings.preferredVersion),
|
||||
|
||||
268
electron/game-detection/active-window.ts
Normal file
268
electron/game-detection/active-window.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { execFile } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Structured snapshot of the currently focused window. Returned by
|
||||
* detectActiveWindow() and consumed by the game-detection orchestrator.
|
||||
*
|
||||
* Field availability varies by platform/compositor; consumers must treat all
|
||||
* optional fields as best-effort. `processName` is required because the
|
||||
* heuristic engine refuses to score a candidate without it.
|
||||
*/
|
||||
export interface ActiveWindowSnapshot {
|
||||
processName: string;
|
||||
executablePath?: string;
|
||||
windowTitle?: string;
|
||||
pid?: number;
|
||||
bounds?: { width: number; height: number };
|
||||
isFullscreen?: boolean;
|
||||
/** Where the snapshot came from, for diagnostics. */
|
||||
source: 'get-windows' | 'hyprctl' | 'swaymsg' | 'xprop';
|
||||
}
|
||||
|
||||
let cachedDynamicImport: ((specifier: string) => Promise<unknown>) | null = null;
|
||||
|
||||
function importEsm<T>(specifier: string): Promise<T> {
|
||||
if (!cachedDynamicImport) {
|
||||
// Built via the Function constructor so the TypeScript compiler does not
|
||||
// down-level the `import()` call to `require()` under module: commonjs.
|
||||
cachedDynamicImport = new Function('s', 'return import(s)') as (specifier: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
return cachedDynamicImport(specifier) as Promise<T>;
|
||||
}
|
||||
|
||||
interface GetWindowsModule {
|
||||
activeWindow: (options?: { accessibilityPermission?: boolean; screenRecordingPermission?: boolean }) => Promise<GetWindowsResult | undefined>;
|
||||
}
|
||||
|
||||
interface GetWindowsResult {
|
||||
platform: 'macos' | 'linux' | 'windows';
|
||||
title: string;
|
||||
id: number;
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
owner: { name: string; processId: number; path: string };
|
||||
}
|
||||
|
||||
export async function detectActiveWindow(): Promise<ActiveWindowSnapshot | null> {
|
||||
const getWindowsResult = await tryGetWindows();
|
||||
|
||||
if (getWindowsResult) {
|
||||
return getWindowsResult;
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
return await detectLinuxActiveWindowViaCompositor();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function tryGetWindows(): Promise<ActiveWindowSnapshot | null> {
|
||||
try {
|
||||
const mod = await importEsm<GetWindowsModule>('get-windows');
|
||||
const result = await mod.activeWindow({
|
||||
accessibilityPermission: false,
|
||||
screenRecordingPermission: false
|
||||
});
|
||||
|
||||
if (!result || !result.owner?.name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
processName: result.owner.name,
|
||||
executablePath: result.owner.path || undefined,
|
||||
windowTitle: result.title || undefined,
|
||||
pid: result.owner.processId,
|
||||
bounds: result.bounds
|
||||
? { width: result.bounds.width, height: result.bounds.height }
|
||||
: undefined,
|
||||
isFullscreen: isFullscreenFromBounds(result.bounds),
|
||||
source: 'get-windows'
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isFullscreenFromBounds(bounds: { x?: number; y?: number; width?: number; height?: number } | undefined): boolean {
|
||||
if (!bounds || typeof bounds.width !== 'number' || typeof bounds.height !== 'number') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cheap proxy: anything ≥1920x1080 is treated as fullscreen-ish. This is
|
||||
// intentionally loose because we already gate on focus and exe path.
|
||||
return bounds.width >= 1920 && bounds.height >= 1080;
|
||||
}
|
||||
|
||||
async function detectLinuxActiveWindowViaCompositor(): Promise<ActiveWindowSnapshot | null> {
|
||||
const hypr = await tryHyprctl();
|
||||
|
||||
if (hypr) {
|
||||
return hypr;
|
||||
}
|
||||
|
||||
const sway = await trySwaymsg();
|
||||
|
||||
if (sway) {
|
||||
return sway;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface HyprlandActiveWindow {
|
||||
address?: string;
|
||||
pid?: number;
|
||||
title?: string;
|
||||
class?: string;
|
||||
initialClass?: string;
|
||||
fullscreen?: number | boolean;
|
||||
fullscreenClient?: number | boolean;
|
||||
size?: [number, number];
|
||||
}
|
||||
|
||||
async function tryHyprctl(): Promise<ActiveWindowSnapshot | null> {
|
||||
if (!process.env.HYPRLAND_INSTANCE_SIGNATURE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hyprctl', ['activewindow', '-j'], {
|
||||
timeout: 2_000,
|
||||
maxBuffer: 256 * 1024
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as HyprlandActiveWindow;
|
||||
|
||||
if (!parsed?.pid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await snapshotFromPid(parsed.pid, {
|
||||
windowTitle: parsed.title,
|
||||
processNameHint: parsed.class || parsed.initialClass,
|
||||
bounds: parsed.size ? { width: parsed.size[0], height: parsed.size[1] } : undefined,
|
||||
isFullscreen: !!parsed.fullscreen || !!parsed.fullscreenClient,
|
||||
source: 'hyprctl'
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface SwayTreeNode {
|
||||
focused?: boolean;
|
||||
pid?: number;
|
||||
name?: string;
|
||||
app_id?: string;
|
||||
window_properties?: { class?: string };
|
||||
fullscreen_mode?: number;
|
||||
rect?: { width?: number; height?: number };
|
||||
nodes?: SwayTreeNode[];
|
||||
floating_nodes?: SwayTreeNode[];
|
||||
}
|
||||
|
||||
async function trySwaymsg(): Promise<ActiveWindowSnapshot | null> {
|
||||
if (!process.env.SWAYSOCK && !process.env.I3SOCK) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('swaymsg', ['-t', 'get_tree'], {
|
||||
timeout: 2_000,
|
||||
maxBuffer: 2 * 1024 * 1024
|
||||
});
|
||||
const tree = JSON.parse(stdout) as SwayTreeNode;
|
||||
const focused = findFocusedSwayNode(tree);
|
||||
|
||||
if (!focused?.pid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await snapshotFromPid(focused.pid, {
|
||||
windowTitle: focused.name,
|
||||
processNameHint: focused.app_id || focused.window_properties?.class,
|
||||
bounds: focused.rect
|
||||
? { width: focused.rect.width ?? 0, height: focused.rect.height ?? 0 }
|
||||
: undefined,
|
||||
isFullscreen: (focused.fullscreen_mode ?? 0) > 0,
|
||||
source: 'swaymsg'
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findFocusedSwayNode(node: SwayTreeNode): SwayTreeNode | null {
|
||||
if (node.focused && node.pid) {
|
||||
return node;
|
||||
}
|
||||
|
||||
for (const child of node.nodes ?? []) {
|
||||
const found = findFocusedSwayNode(child);
|
||||
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of node.floating_nodes ?? []) {
|
||||
const found = findFocusedSwayNode(child);
|
||||
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface SnapshotFromPidOptions {
|
||||
windowTitle?: string;
|
||||
processNameHint?: string;
|
||||
bounds?: { width: number; height: number };
|
||||
isFullscreen?: boolean;
|
||||
source: ActiveWindowSnapshot['source'];
|
||||
}
|
||||
|
||||
async function snapshotFromPid(pid: number, options: SnapshotFromPidOptions): Promise<ActiveWindowSnapshot | null> {
|
||||
let executablePath: string | undefined;
|
||||
let processName = options.processNameHint?.trim() || '';
|
||||
|
||||
try {
|
||||
executablePath = await fs.promises.readlink(`/proc/${pid}/exe`);
|
||||
|
||||
if (!processName) {
|
||||
processName = path.basename(executablePath);
|
||||
}
|
||||
} catch {
|
||||
/* /proc/<pid>/exe is restricted for foreign-uid processes; that's fine. */
|
||||
}
|
||||
|
||||
if (!processName) {
|
||||
try {
|
||||
processName = (await fs.promises.readFile(`/proc/${pid}/comm`, 'utf8')).trim();
|
||||
} catch {
|
||||
/* ignored */
|
||||
}
|
||||
}
|
||||
|
||||
if (!processName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
processName,
|
||||
executablePath,
|
||||
windowTitle: options.windowTitle?.trim() || undefined,
|
||||
pid,
|
||||
bounds: options.bounds,
|
||||
isFullscreen: options.isFullscreen,
|
||||
source: options.source
|
||||
};
|
||||
}
|
||||
402
electron/game-detection/heuristics.ts
Normal file
402
electron/game-detection/heuristics.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Pure scoring/filtering helpers for game detection. Lives in the main process
|
||||
* and is exercised by both the foreground-window detector and the legacy
|
||||
* process-name scanner.
|
||||
*
|
||||
* The goal is to dramatically reduce the false-positive rate compared to the
|
||||
* previous "send every running process name to RAWG" approach by combining
|
||||
* multiple signals (window focus, executable path, engine markers, blacklist,
|
||||
* user-managed ignore list) into a confidence score.
|
||||
*/
|
||||
|
||||
export interface GameCandidateInput {
|
||||
/** Lower-cased base name without extension (e.g. "stardewvalley"). */
|
||||
processName: string;
|
||||
/** Original process name as reported by the OS (for display). */
|
||||
rawProcessName?: string;
|
||||
executablePath?: string;
|
||||
windowTitle?: string;
|
||||
pid?: number;
|
||||
bounds?: { width: number; height: number } | undefined;
|
||||
isFullscreen?: boolean;
|
||||
source: 'foreground' | 'process-scan';
|
||||
/** User-managed ignore list, already lower-cased. */
|
||||
ignoredProcessNames: ReadonlySet<string>;
|
||||
/** True when an engine signature file was found beside the executable. */
|
||||
hasEngineSignature?: boolean;
|
||||
}
|
||||
|
||||
export interface ScoredGameCandidate {
|
||||
processName: string;
|
||||
rawProcessName: string;
|
||||
executablePath?: string;
|
||||
windowTitle?: string;
|
||||
pid?: number;
|
||||
isFullscreen: boolean;
|
||||
bounds?: { width: number; height: number };
|
||||
confidence: number;
|
||||
source: 'foreground' | 'process-scan';
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export const MIN_GAME_CONFIDENCE = 55;
|
||||
|
||||
/**
|
||||
* Processes that are commonly misclassified as games. Lower-cased base names.
|
||||
* Note: we deliberately blacklist Electron/Chromium/IDE/launcher/comm apps.
|
||||
*/
|
||||
export const HARDCODED_IGNORED_PROCESSES: ReadonlySet<string> = new Set([
|
||||
'1password',
|
||||
'7zfm',
|
||||
'agent',
|
||||
'audiodg',
|
||||
'bash',
|
||||
'baloo',
|
||||
'baloo_file',
|
||||
'baloorunner',
|
||||
'bluetoothuiservice',
|
||||
'brave',
|
||||
'brave-browser',
|
||||
'chrome',
|
||||
'cmd',
|
||||
'code',
|
||||
'code-insiders',
|
||||
'conhost',
|
||||
'cursor',
|
||||
'csrss',
|
||||
'ctfmon',
|
||||
'dbus',
|
||||
'dbus-daemon',
|
||||
'discord',
|
||||
'discordcanary',
|
||||
'discordptb',
|
||||
'dolphin',
|
||||
'dwm',
|
||||
'electron',
|
||||
'epicgameslauncher',
|
||||
'epicgames',
|
||||
'explorer',
|
||||
'fcitx5',
|
||||
'firefox',
|
||||
'fontdrvhost',
|
||||
'gameoverlayui',
|
||||
'gamemoded',
|
||||
'gamemode-launcher',
|
||||
'gamescopereaper',
|
||||
'gnome-shell',
|
||||
'gnome-software',
|
||||
'gnome-terminal',
|
||||
'init',
|
||||
'java',
|
||||
'javaw',
|
||||
'kdeconnect',
|
||||
'kdeconnectd',
|
||||
'kded5',
|
||||
'kded6',
|
||||
'keepass',
|
||||
'keepassxc',
|
||||
'kernel_task',
|
||||
'krunner',
|
||||
'ksmserver',
|
||||
'lockapp',
|
||||
'logioptionsplus',
|
||||
'logitechg',
|
||||
'login',
|
||||
'metoyou',
|
||||
'msedge',
|
||||
'msedgewebview2',
|
||||
'msteams',
|
||||
'node',
|
||||
'npm',
|
||||
'nvcontainer',
|
||||
'nvidia-broadcast',
|
||||
'nvidia-share',
|
||||
'nvidia-smi',
|
||||
'obs',
|
||||
'obs64',
|
||||
'obs-studio',
|
||||
'pipewire',
|
||||
'plasmashell',
|
||||
'pluma',
|
||||
'powershell',
|
||||
'pwsh',
|
||||
'pulseaudio',
|
||||
'remoteapps-service',
|
||||
'rundll32',
|
||||
'runtimebroker',
|
||||
'screen',
|
||||
'searchapp',
|
||||
'searchhost',
|
||||
'shellexperiencehost',
|
||||
'signal',
|
||||
'slack',
|
||||
'spotify',
|
||||
'spotifywebhelper',
|
||||
'sshd',
|
||||
'startmenuexperiencehost',
|
||||
'steam',
|
||||
'steamservice',
|
||||
'steamwebhelper',
|
||||
'svchost',
|
||||
'system',
|
||||
'systemd',
|
||||
'systemsettings',
|
||||
'systemsoundsservice',
|
||||
'taskhost',
|
||||
'taskhostw',
|
||||
'taskmgr',
|
||||
'teams',
|
||||
'telegram',
|
||||
'telegramdesktop',
|
||||
'textinputhost',
|
||||
'thunderbird',
|
||||
'tracker-miner-fs',
|
||||
'tray',
|
||||
'utilman',
|
||||
'vivaldi',
|
||||
'whatsapp',
|
||||
'wininit',
|
||||
'winlogon',
|
||||
'xdg-desktop-portal',
|
||||
'xorg',
|
||||
'xwayland',
|
||||
'yakuake',
|
||||
'zoom'
|
||||
]);
|
||||
|
||||
const GENERIC_SUFFIX_NAMES = [
|
||||
'agent',
|
||||
'browser',
|
||||
'daemon',
|
||||
'helper',
|
||||
'indexer',
|
||||
'launcher',
|
||||
'monitor',
|
||||
'renderer',
|
||||
'runner',
|
||||
'service',
|
||||
'tray',
|
||||
'updater',
|
||||
'watcher',
|
||||
'worker',
|
||||
'portal',
|
||||
'sync',
|
||||
'broker',
|
||||
'host'
|
||||
].join('|');
|
||||
const IGNORE_NAME_PATTERNS: readonly RegExp[] = [
|
||||
new RegExp(`(^|[-_\\s.])(${GENERIC_SUFFIX_NAMES})([-_\\s.]|$)`, 'iu'),
|
||||
/^kworker/i,
|
||||
/^kthread/i,
|
||||
/^kpipefs/i,
|
||||
/^(at-spi|gvfs|ibus|kded|kglobalaccel|knotify|polkit|pulse|systemd)/i
|
||||
];
|
||||
/** Known game install root markers, case-insensitive substrings of the exe path. */
|
||||
const KNOWN_GAME_PATH_MARKERS: readonly RegExp[] = [
|
||||
/[\\/]steamapps[\\/]common[\\/]/i,
|
||||
/[\\/]steamlibrary[\\/]/i,
|
||||
/[\\/]epic games[\\/]/i,
|
||||
/[\\/]epicgameslauncher[\\/]/i,
|
||||
/[\\/]gog galaxy[\\/]games[\\/]/i,
|
||||
/[\\/]gog\.com[\\/]games[\\/]/i,
|
||||
/[\\/]gog games[\\/]/i,
|
||||
/[\\/]ea games[\\/]/i,
|
||||
/[\\/]origin games[\\/]/i,
|
||||
/[\\/]battle\.net[\\/]/i,
|
||||
/[\\/]ubisoft[\\/]/i,
|
||||
/[\\/]riot games[\\/]/i,
|
||||
/[\\/]itch[\\/]apps[\\/]/i,
|
||||
/[\\/]\.itch[\\/]apps[\\/]/i,
|
||||
/[\\/]heroic[\\/]games[\\/]/i,
|
||||
/[\\/]lutris[\\/]/i,
|
||||
/[\\/]games[\\/]/i,
|
||||
// Proton / Wine prefixes used by Steam/Lutris/Heroic
|
||||
/[\\/]proton[\\/]/i,
|
||||
/[\\/]pfx[\\/]drive_c[\\/]/i,
|
||||
/[\\/]\.wine[\\/]drive_c[\\/]program files[\\/]/i
|
||||
];
|
||||
/** Path segments that strongly indicate the process is NOT a game. */
|
||||
const NON_GAME_PATH_MARKERS: readonly RegExp[] = [
|
||||
/[\\/]appdata[\\/]local[\\/]temp[\\/]/i,
|
||||
/[\\/]temp[\\/]/i,
|
||||
/[\\/]node_modules[\\/]/i,
|
||||
/[\\/]chromium[\\/]/i,
|
||||
/[\\/]appdata[\\/]roaming[\\/]discord[\\/]/i,
|
||||
/[\\/]appdata[\\/]roaming[\\/]spotify[\\/]/i,
|
||||
/[\\/]windows[\\/]system32[\\/]/i,
|
||||
/[\\/]windows[\\/]syswow64[\\/]/i,
|
||||
/[\\/]\.cache[\\/]/i,
|
||||
/[\\/]snap[\\/]firefox[\\/]/i,
|
||||
/[\\/]snap[\\/]spotify[\\/]/i
|
||||
];
|
||||
|
||||
/** File names placed beside a game's executable that reveal its engine. */
|
||||
export const ENGINE_SIGNATURE_FILES: readonly string[] = [
|
||||
'UnityPlayer.dll',
|
||||
'libUnityPlayer.so',
|
||||
'UnityCrashHandler64.exe',
|
||||
'UnityCrashHandler32.exe',
|
||||
// Unreal Engine: foo-Win64-Shipping.exe sits in <Game>/Binaries/Win64/
|
||||
'UnrealEditor.exe',
|
||||
'UE4PrereqSetup_x64.exe',
|
||||
'UE4Game.dll',
|
||||
'UE5Game.dll',
|
||||
// Godot
|
||||
'Godot.exe',
|
||||
'libgodot.so',
|
||||
// Source engine
|
||||
'tier0.dll',
|
||||
'engine.dll',
|
||||
'hl2.exe',
|
||||
// RPG Maker
|
||||
'nw.dll',
|
||||
// CryEngine
|
||||
'CryGameSDK.dll'
|
||||
];
|
||||
|
||||
export function normalizeProcessKey(value: string): string {
|
||||
return path.basename(value.trim())
|
||||
.replace(/\.(exe|bin|app|out)$/iu, '')
|
||||
.replace(/[_-]+/gu, ' ')
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function shouldIgnoreProcess(
|
||||
processName: string,
|
||||
userIgnored: ReadonlySet<string>
|
||||
): boolean {
|
||||
const key = normalizeProcessKey(processName);
|
||||
|
||||
if (!key) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (userIgnored.has(key) || HARDCODED_IGNORED_PROCESSES.has(key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key.length < 4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return IGNORE_NAME_PATTERNS.some((pattern) => pattern.test(key));
|
||||
}
|
||||
|
||||
export function pathMatchesKnownGameRoot(executablePath: string | undefined): boolean {
|
||||
if (!executablePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return KNOWN_GAME_PATH_MARKERS.some((pattern) => pattern.test(executablePath));
|
||||
}
|
||||
|
||||
export function pathMatchesNonGameRoot(executablePath: string | undefined): boolean {
|
||||
if (!executablePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return NON_GAME_PATH_MARKERS.some((pattern) => pattern.test(executablePath));
|
||||
}
|
||||
|
||||
interface ConfidenceScore {
|
||||
confidence: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
function computeConfidence(input: GameCandidateInput, rawProcessName: string): ConfidenceScore {
|
||||
let confidence = 0;
|
||||
|
||||
const reasons: string[] = [];
|
||||
const add = (points: number, reason: string): void => {
|
||||
confidence += points;
|
||||
reasons.push(reason);
|
||||
};
|
||||
|
||||
if (input.source === 'foreground') {
|
||||
add(35, 'foreground-window');
|
||||
}
|
||||
|
||||
if (pathMatchesKnownGameRoot(input.executablePath)) {
|
||||
add(30, 'known-game-folder');
|
||||
}
|
||||
|
||||
if (input.hasEngineSignature) {
|
||||
add(25, 'engine-signature');
|
||||
}
|
||||
|
||||
if (input.isFullscreen) {
|
||||
add(15, 'fullscreen');
|
||||
}
|
||||
|
||||
const width = input.bounds?.width ?? 0;
|
||||
const height = input.bounds?.height ?? 0;
|
||||
|
||||
if (width >= 800 && height >= 600) {
|
||||
add(5, 'large-window');
|
||||
}
|
||||
|
||||
const title = input.windowTitle?.trim() ?? '';
|
||||
|
||||
if (title.length >= 3 && /[A-Za-z]/u.test(title)) {
|
||||
add(10, 'window-title');
|
||||
}
|
||||
|
||||
if (/[A-Z]/u.test(rawProcessName) && /[a-z]/u.test(rawProcessName)) {
|
||||
add(3, 'mixed-case-name');
|
||||
}
|
||||
|
||||
if (input.executablePath && /\.exe$/iu.test(input.executablePath)) {
|
||||
confidence += 2;
|
||||
}
|
||||
|
||||
return { confidence: Math.min(100, confidence), reasons };
|
||||
}
|
||||
|
||||
export function scoreCandidate(input: GameCandidateInput): ScoredGameCandidate | null {
|
||||
const rawProcessName = input.rawProcessName ?? input.processName;
|
||||
const normalizedKey = normalizeProcessKey(input.processName);
|
||||
|
||||
if (!normalizedKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shouldIgnoreProcess(normalizedKey, input.ignoredProcessNames)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pathMatchesNonGameRoot(input.executablePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { confidence, reasons } = computeConfidence(input, rawProcessName);
|
||||
const title = input.windowTitle?.trim() ?? '';
|
||||
|
||||
// Process-scan candidates must clear a higher bar: without a foreground or
|
||||
// path signal the confidence will stay below the threshold, which is the
|
||||
// whole point - no more silent RAWG lookups for arbitrary processes.
|
||||
return {
|
||||
processName: normalizedKey,
|
||||
rawProcessName,
|
||||
executablePath: input.executablePath,
|
||||
windowTitle: title || undefined,
|
||||
pid: input.pid,
|
||||
isFullscreen: !!input.isFullscreen,
|
||||
bounds: input.bounds,
|
||||
confidence,
|
||||
source: input.source,
|
||||
reasons
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns whether a confidence score clears the "report to peers" threshold. */
|
||||
export function meetsGameConfidence(candidate: ScoredGameCandidate | null): boolean {
|
||||
return !!candidate && candidate.confidence >= MIN_GAME_CONFIDENCE;
|
||||
}
|
||||
119
electron/game-detection/index.ts
Normal file
119
electron/game-detection/index.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { detectActiveWindow } from './active-window';
|
||||
import {
|
||||
ENGINE_SIGNATURE_FILES,
|
||||
GameCandidateInput,
|
||||
MIN_GAME_CONFIDENCE,
|
||||
ScoredGameCandidate,
|
||||
scoreCandidate,
|
||||
shouldIgnoreProcess
|
||||
} from './heuristics';
|
||||
import { listRunningProcessNames } from '../process-list';
|
||||
import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
/**
|
||||
* Public result of a detection scan. The renderer prefers `candidate` and only
|
||||
* falls back to `fallbackProcessNames` when no focused candidate clears the
|
||||
* minimum confidence threshold. The fallback list is intentionally trimmed and
|
||||
* pre-filtered so the renderer never sees obvious non-games like Spotify.
|
||||
*/
|
||||
export interface GameDetectionResult {
|
||||
candidate: ScoredGameCandidate | null;
|
||||
/**
|
||||
* Filtered list of plausible game process names. Empty when the focused
|
||||
* candidate already crossed the threshold (so the renderer skips fallback
|
||||
* matching). Capped to keep RAWG quota usage predictable.
|
||||
*/
|
||||
fallbackProcessNames: string[];
|
||||
}
|
||||
|
||||
const MAX_FALLBACK_PROCESSES = 8;
|
||||
|
||||
export async function detectActiveGame(): Promise<GameDetectionResult> {
|
||||
const ignoredProcessNames = getUserIgnoredProcesses();
|
||||
const active = await detectActiveWindow();
|
||||
|
||||
let candidate: ScoredGameCandidate | null = null;
|
||||
|
||||
if (active) {
|
||||
const hasEngineSignature = await detectEngineSignature(active.executablePath);
|
||||
const input: GameCandidateInput = {
|
||||
processName: active.processName,
|
||||
rawProcessName: active.processName,
|
||||
executablePath: active.executablePath,
|
||||
windowTitle: active.windowTitle,
|
||||
pid: active.pid,
|
||||
bounds: active.bounds,
|
||||
isFullscreen: active.isFullscreen,
|
||||
source: 'foreground',
|
||||
ignoredProcessNames,
|
||||
hasEngineSignature
|
||||
};
|
||||
|
||||
candidate = scoreCandidate(input);
|
||||
}
|
||||
|
||||
if (candidate && candidate.confidence >= MIN_GAME_CONFIDENCE) {
|
||||
return { candidate, fallbackProcessNames: [] };
|
||||
}
|
||||
|
||||
const fallbackProcessNames = await collectFallbackProcessNames(ignoredProcessNames);
|
||||
|
||||
return { candidate, fallbackProcessNames };
|
||||
}
|
||||
|
||||
async function collectFallbackProcessNames(ignoredProcessNames: ReadonlySet<string>): Promise<string[]> {
|
||||
try {
|
||||
const names = await listRunningProcessNames();
|
||||
const filtered: string[] = [];
|
||||
|
||||
for (const name of names) {
|
||||
if (filtered.length >= MAX_FALLBACK_PROCESSES) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!shouldIgnoreProcess(name, ignoredProcessNames)) {
|
||||
filtered.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function detectEngineSignature(executablePath: string | undefined): Promise<boolean> {
|
||||
if (!executablePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const directory = path.dirname(executablePath);
|
||||
const entries = await fs.promises.readdir(directory).catch(() => []);
|
||||
const lowerEntries = new Set(entries.map((entry) => entry.toLowerCase()));
|
||||
|
||||
if (ENGINE_SIGNATURE_FILES.some((file) => lowerEntries.has(file.toLowerCase()))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unreal Engine ships executables ending in "-Win64-Shipping.exe" or
|
||||
// "-Linux-Shipping" inside <Game>/Binaries/<Platform>/.
|
||||
return entries.some((entry) => /-(win64|win32|linux)-shipping(\.exe)?$/i.test(entry));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getUserIgnoredProcesses(): ReadonlySet<string> {
|
||||
try {
|
||||
const stored = readDesktopSettings().ignoredGameProcesses ?? [];
|
||||
|
||||
return new Set(stored.map((entry) => entry.trim().toLowerCase()).filter(Boolean));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export type { ScoredGameCandidate } from './heuristics';
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
import * as fs from 'fs';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
import {
|
||||
getDesktopSettingsSnapshot,
|
||||
readDesktopSettings,
|
||||
updateDesktopSettings,
|
||||
type DesktopSettings
|
||||
} from '../desktop-settings';
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
openCurrentDataFolder
|
||||
} from '../data-management';
|
||||
import { listRunningProcessNames } from '../process-list';
|
||||
import { detectActiveGame } from '../game-detection';
|
||||
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
const FILE_CLIPBOARD_FORMATS = [
|
||||
@@ -325,6 +327,18 @@ export function setupSystemHandlers(): void {
|
||||
|
||||
ipcMain.handle('get-running-process-names', async () => await listRunningProcessNames());
|
||||
|
||||
ipcMain.handle('get-active-game-candidate', async () => await detectActiveGame());
|
||||
|
||||
ipcMain.handle('get-ignored-game-processes', () => {
|
||||
return readDesktopSettings().ignoredGameProcesses;
|
||||
});
|
||||
|
||||
ipcMain.handle('set-ignored-game-processes', (_event, list: unknown) => {
|
||||
const snapshot = updateDesktopSettings({ ignoredGameProcesses: Array.isArray(list) ? list : [] });
|
||||
|
||||
return snapshot.ignoredGameProcesses;
|
||||
});
|
||||
|
||||
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
||||
return await prepareLinuxScreenShareAudioRouting();
|
||||
});
|
||||
@@ -519,12 +533,46 @@ export function setupSystemHandlers(): void {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-file-url', async (_event, filePath: string) => {
|
||||
if (typeof filePath !== 'string' || !filePath.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsp.access(filePath, fs.constants.F_OK);
|
||||
return pathToFileURL(filePath).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('read-file', async (_event, filePath: string) => {
|
||||
const data = await fsp.readFile(filePath);
|
||||
|
||||
return data.toString('base64');
|
||||
});
|
||||
|
||||
ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => {
|
||||
const fileHandle = await fsp.open(filePath, 'r');
|
||||
|
||||
try {
|
||||
const safeStart = Math.max(0, Math.trunc(start));
|
||||
const safeEnd = Math.max(safeStart, Math.trunc(end));
|
||||
const buffer = Buffer.alloc(safeEnd - safeStart);
|
||||
const result = await fileHandle.read(buffer, 0, buffer.length, safeStart);
|
||||
|
||||
return buffer.subarray(0, result.bytesRead).toString('base64');
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-file-size', async (_event, filePath: string) => {
|
||||
const stats = await fsp.stat(filePath);
|
||||
|
||||
return stats.size;
|
||||
});
|
||||
|
||||
ipcMain.handle('read-clipboard-files', async () => {
|
||||
return await readClipboardFiles();
|
||||
});
|
||||
@@ -536,6 +584,13 @@ export function setupSystemHandlers(): void {
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('append-file', async (_event, filePath: string, base64Data: string) => {
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
await fsp.appendFile(filePath, buffer);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('delete-file', async (_event, filePath: string) => {
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
@@ -567,6 +622,60 @@ export function setupSystemHandlers(): void {
|
||||
cancelled: false };
|
||||
});
|
||||
|
||||
ipcMain.handle('save-existing-file-as', async (_event, sourceFilePath: string, defaultFileName: string) => {
|
||||
if (typeof sourceFilePath !== 'string' || !sourceFilePath.trim()) {
|
||||
return { saved: false,
|
||||
cancelled: false };
|
||||
}
|
||||
|
||||
const stats = await fsp.stat(sourceFilePath);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
return { saved: false,
|
||||
cancelled: false };
|
||||
}
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
defaultPath: defaultFileName || path.basename(sourceFilePath)
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { saved: false,
|
||||
cancelled: true };
|
||||
}
|
||||
|
||||
await fsp.copyFile(sourceFilePath, result.filePath);
|
||||
|
||||
return { saved: true,
|
||||
cancelled: false };
|
||||
});
|
||||
|
||||
ipcMain.handle('open-file-path', async (_event, filePath: string) => {
|
||||
if (typeof filePath !== 'string' || !filePath.trim()) {
|
||||
return { opened: false,
|
||||
reason: 'missing-path' };
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fsp.stat(filePath);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
return { opened: false,
|
||||
reason: 'not-a-file' };
|
||||
}
|
||||
|
||||
const error = await shell.openPath(filePath);
|
||||
|
||||
return error
|
||||
? { opened: false,
|
||||
reason: error }
|
||||
: { opened: true };
|
||||
} catch (error) {
|
||||
return { opened: false,
|
||||
reason: error instanceof Error ? error.message : 'open-failed' };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('ensure-dir', async (_event, dirPath: string) => {
|
||||
await fsp.mkdir(dirPath, { recursive: true });
|
||||
return true;
|
||||
|
||||
@@ -203,6 +203,24 @@ export interface ContextMenuParams {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ActiveGameCandidate {
|
||||
processName: string;
|
||||
rawProcessName: string;
|
||||
executablePath?: string;
|
||||
windowTitle?: string;
|
||||
pid?: number;
|
||||
isFullscreen: boolean;
|
||||
bounds?: { width: number; height: number };
|
||||
confidence: number;
|
||||
source: 'foreground' | 'process-scan';
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface ActiveGameCandidateResult {
|
||||
candidate: ActiveGameCandidate | null;
|
||||
fallbackProcessNames: string[];
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
linuxDisplayServer: string;
|
||||
minimizeWindow: () => void;
|
||||
@@ -212,6 +230,9 @@ export interface ElectronAPI {
|
||||
openExternal: (url: string) => Promise<boolean>;
|
||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||
getRunningProcessNames: () => Promise<string[]>;
|
||||
getActiveGameCandidate: () => Promise<ActiveGameCandidateResult>;
|
||||
getIgnoredGameProcesses: () => Promise<string[]>;
|
||||
setIgnoredGameProcesses: (list: string[]) => Promise<string[]>;
|
||||
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||
@@ -282,9 +303,15 @@ export interface ElectronAPI {
|
||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
readFileChunk: (filePath: string, start: number, end: number) => Promise<string>;
|
||||
getFileSize: (filePath: string) => Promise<number>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
saveExistingFileAs: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
openFilePath: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;
|
||||
fileExists: (filePath: string) => Promise<boolean>;
|
||||
getFileUrl: (filePath: string) => Promise<string | null>;
|
||||
deleteFile: (filePath: string) => Promise<boolean>;
|
||||
ensureDir: (dirPath: string) => Promise<boolean>;
|
||||
|
||||
@@ -308,6 +335,9 @@ const electronAPI: ElectronAPI = {
|
||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||
getSources: () => ipcRenderer.invoke('get-sources'),
|
||||
getRunningProcessNames: () => ipcRenderer.invoke('get-running-process-names'),
|
||||
getActiveGameCandidate: () => ipcRenderer.invoke('get-active-game-candidate'),
|
||||
getIgnoredGameProcesses: () => ipcRenderer.invoke('get-ignored-game-processes'),
|
||||
setIgnoredGameProcesses: (list) => ipcRenderer.invoke('set-ignored-game-processes', list),
|
||||
prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'),
|
||||
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
|
||||
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),
|
||||
@@ -404,9 +434,15 @@ const electronAPI: ElectronAPI = {
|
||||
},
|
||||
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
|
||||
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||
readFileChunk: (filePath, start, end) => ipcRenderer.invoke('read-file-chunk', filePath, start, end),
|
||||
getFileSize: (filePath) => ipcRenderer.invoke('get-file-size', filePath),
|
||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||
appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data),
|
||||
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
|
||||
saveExistingFileAs: (sourceFilePath, defaultFileName) => ipcRenderer.invoke('save-existing-file-as', sourceFilePath, defaultFileName),
|
||||
openFilePath: (filePath) => ipcRenderer.invoke('open-file-path', filePath),
|
||||
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
|
||||
getFileUrl: (filePath) => ipcRenderer.invoke('get-file-url', filePath),
|
||||
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
||||
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
|
||||
|
||||
|
||||
937
package-lock.json
generated
937
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -90,6 +90,7 @@
|
||||
"codemirror": "^6.0.2",
|
||||
"cytoscape": "^3.33.1",
|
||||
"electron-updater": "^6.6.2",
|
||||
"get-windows": "^9.3.0",
|
||||
"mermaid": "^11.12.3",
|
||||
"ngx-remark": "^0.2.2",
|
||||
"prismjs": "^1.30.0",
|
||||
@@ -155,8 +156,13 @@
|
||||
"!node_modules",
|
||||
"dist/client/**/*",
|
||||
"dist/electron/**/*",
|
||||
"node_modules/{ansi-regex,ansi-styles,ansis,app-root-path,applescript,argparse,auto-launch,available-typed-arrays,balanced-match,base64-js,brace-expansion,buffer,builder-util-runtime,call-bind,call-bind-apply-helpers,call-bound,cliui,concat-map,cross-spawn,dayjs,debug,dedent,define-data-property,dotenv,dunder-proto,electron-updater,emoji-regex,es-define-property,es-errors,es-object-atoms,escalade,for-each,foreground-child,fs-extra,function-bind,get-caller-file,get-east-asian-width,get-intrinsic,get-proto,glob,gopd,graceful-fs,has-property-descriptors,has-symbols,has-tostringtag,hasown,ieee754,inherits,is-callable,is-fullwidth-code-point,is-typed-array,isarray,isexe,jackspeak,js-yaml,jsonfile,lazy-val,lodash.escaperegexp,lodash.isequal,lru-cache,math-intrinsics,minimatch,minimist,minipass,mkdirp,ms,package-json-from-dist,path-is-absolute,path-key,path-scurry,possible-typed-array-names,reflect-metadata,safe-buffer,sax,semver,set-function-length,sha.js,shebang-command,shebang-regex,signal-exit,sql-highlight,sql.js,string-width,string-width-cjs,strip-ansi,strip-ansi-cjs,tiny-typed-emitter,to-buffer,tslib,typed-array-buffer,typeorm,universalify,untildify,uuid,which,which-typed-array,winreg,wrap-ansi,wrap-ansi-cjs,y18n,yallist,yargs,yargs-parser}/**/*",
|
||||
"node_modules/{abbrev,agent-base,ansi-regex,ansi-styles,ansis,app-root-path,applescript,aproba,are-we-there-yet,argparse,auto-launch,available-typed-arrays,balanced-match,base64-js,brace-expansion,buffer,builder-util-runtime,cacache,call-bind,call-bind-apply-helpers,call-bound,chownr,cliui,color-support,concat-map,console-control-strings,cross-spawn,dayjs,debug,dedent,define-data-property,delegates,detect-libc,dotenv,dunder-proto,electron-updater,emoji-regex,env-paths,es-define-property,es-errors,es-object-atoms,escalade,exponential-backoff,fdir,for-each,foreground-child,fs-extra,fs-minipass,function-bind,gauge,get-caller-file,get-east-asian-width,get-intrinsic,get-proto,get-windows,glob,gopd,graceful-fs,has-property-descriptors,has-symbols,has-tostringtag,has-unicode,hasown,http-cache-semantics,http-proxy-agent,https-proxy-agent,iconv-lite,ieee754,imurmurhash,inherits,ip-address,is-callable,is-fullwidth-code-point,is-typed-array,isarray,isexe,jackspeak,js-yaml,jsonfile,lazy-val,lodash.escaperegexp,lodash.isequal,lru-cache,make-dir,make-fetch-happen,math-intrinsics,minimatch,minimist,minipass,minipass-collect,minipass-fetch,minipass-flush,minipass-pipeline,minipass-sized,minizlib,mkdirp,ms,negotiator,node-addon-api,node-fetch,node-gyp,nopt,npmlog,object-assign,p-map,package-json-from-dist,path-is-absolute,path-key,path-scurry,picomatch,pify,possible-typed-array-names,proc-log,readable-stream,reflect-metadata,retry,rimraf,safe-buffer,safer-buffer,sax,semver,set-blocking,set-function-length,sha.js,shebang-command,shebang-regex,signal-exit,smart-buffer,socks,socks-proxy-agent,sql-highlight,sql.js,ssri,string-width,string-width-cjs,string_decoder,strip-ansi,strip-ansi-cjs,tar,tiny-typed-emitter,tinyglobby,to-buffer,tr46,tslib,typed-array-buffer,typeorm,unique-filename,unique-slug,universalify,untildify,util-deprecate,uuid,webidl-conversions,whatwg-url,which,which-typed-array,wide-align,winreg,wrap-ansi,wrap-ansi-cjs,y18n,yallist,yargs,yargs-parser}/**/*",
|
||||
"node_modules/@gar/promise-retry/**/*",
|
||||
"node_modules/@isaacs/cliui/**/*",
|
||||
"node_modules/@isaacs/fs-minipass/**/*",
|
||||
"node_modules/@mapbox/node-pre-gyp/**/*",
|
||||
"node_modules/@npmcli/agent/**/*",
|
||||
"node_modules/@npmcli/fs/**/*",
|
||||
"node_modules/@pkgjs/parseargs/**/*",
|
||||
"node_modules/@sqltools/formatter/**/*",
|
||||
"!node_modules/**/test/**/*",
|
||||
|
||||
Binary file not shown.
@@ -196,9 +196,8 @@ router.get('/link-metadata', async (req, res) => {
|
||||
const cached = metadataCache.get(url);
|
||||
|
||||
if (cached) {
|
||||
const { cachedAt, ...metadata } = cached;
|
||||
const { cachedAt: _cachedAt, ...metadata } = cached;
|
||||
|
||||
console.log(`[Link Metadata] Cache hit for ${url} (cached at ${new Date(cachedAt).toISOString()})`);
|
||||
return res.json(metadata);
|
||||
}
|
||||
|
||||
|
||||
@@ -484,6 +484,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
case 'direct-message':
|
||||
case 'direct-message-status':
|
||||
case 'direct-message-mutation':
|
||||
case 'direct-message-typing':
|
||||
case 'direct-message-sync-request':
|
||||
case 'direct-message-sync':
|
||||
case 'direct-call':
|
||||
case 'server_icon_peer_request':
|
||||
case 'server_icon_peer_data':
|
||||
forwardRtcMessage(user, message);
|
||||
|
||||
8
toju-app/public/vlcjs/metoyou-vlc-player.js
Normal file
8
toju-app/public/vlcjs/metoyou-vlc-player.js
Normal file
@@ -0,0 +1,8 @@
|
||||
(function registerMetoYouVlcPlaceholder(globalScope) {
|
||||
globalScope.MetoYouVlcJs = {
|
||||
isPlaceholder: true,
|
||||
createPlayer() {
|
||||
throw new Error('Experimental VLC.js playback is enabled, but no VLC.js runtime is bundled. Replace /vlcjs/metoyou-vlc-player.js with a runtime adapter to enable playback.');
|
||||
}
|
||||
};
|
||||
})(window);
|
||||
@@ -149,6 +149,7 @@
|
||||
<app-floating-voice-controls />
|
||||
}
|
||||
<app-settings-modal />
|
||||
<app-incoming-call-modal />
|
||||
<app-screen-share-source-picker />
|
||||
<app-native-context-menu />
|
||||
<app-debug-console [showLauncher]="false" />
|
||||
|
||||
@@ -44,6 +44,21 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
|
||||
},
|
||||
{
|
||||
path: 'pm',
|
||||
loadComponent: () =>
|
||||
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
|
||||
},
|
||||
{
|
||||
path: 'pm/:conversationId',
|
||||
loadComponent: () =>
|
||||
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
|
||||
},
|
||||
{
|
||||
path: 'call/:callId',
|
||||
loadComponent: () =>
|
||||
import('./features/direct-call/private-call.component').then((module) => module.PrivateCallComponent)
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -36,6 +36,8 @@ import { ElectronBridgeService } from './core/platform/electron/electron-bridge.
|
||||
import { UserStatusService } from './core/services/user-status.service';
|
||||
import { GameActivityService } from './domains/game-activity';
|
||||
import { PluginBootstrapService } from './domains/plugins';
|
||||
import { DirectCallService } from './domains/direct-call';
|
||||
import { IncomingCallModalComponent } from './domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component';
|
||||
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
|
||||
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
|
||||
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
ServersRailComponent,
|
||||
TitleBarComponent,
|
||||
FloatingVoiceControlsComponent,
|
||||
IncomingCallModalComponent,
|
||||
SettingsModalComponent,
|
||||
DebugConsoleComponent,
|
||||
ScreenShareSourcePickerComponent,
|
||||
@@ -99,6 +102,7 @@ export class App implements OnInit, OnDestroy {
|
||||
readonly electronBridge = inject(ElectronBridgeService);
|
||||
readonly userStatus = inject(UserStatusService);
|
||||
readonly gameActivity = inject(GameActivityService);
|
||||
readonly directCalls = inject(DirectCallService);
|
||||
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
|
||||
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
|
||||
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
|
||||
@@ -117,7 +121,11 @@ export class App implements OnInit, OnDestroy {
|
||||
return this.settingsModal.activePage() === 'theme'
|
||||
&& this.settingsModal.themeStudioMinimized();
|
||||
});
|
||||
readonly isDirectMessageRoute = computed(() => this.getRoutePath(this.currentRouteUrl()).startsWith('/dm'));
|
||||
readonly isDirectMessageRoute = computed(() => {
|
||||
const routePath = this.getRoutePath(this.currentRouteUrl());
|
||||
|
||||
return routePath.startsWith('/dm') || routePath.startsWith('/pm') || routePath.startsWith('/call');
|
||||
});
|
||||
readonly desktopUpdateNoticeKey = computed(() => {
|
||||
const updateState = this.desktopUpdateState();
|
||||
|
||||
|
||||
@@ -215,6 +215,24 @@ export interface ContextMenuParams {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ActiveGameCandidate {
|
||||
processName: string;
|
||||
rawProcessName: string;
|
||||
executablePath?: string;
|
||||
windowTitle?: string;
|
||||
pid?: number;
|
||||
isFullscreen: boolean;
|
||||
bounds?: { width: number; height: number };
|
||||
confidence: number;
|
||||
source: 'foreground' | 'process-scan';
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface ActiveGameCandidateResult {
|
||||
candidate: ActiveGameCandidate | null;
|
||||
fallbackProcessNames: string[];
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
linuxDisplayServer: string;
|
||||
minimizeWindow: () => void;
|
||||
@@ -223,6 +241,9 @@ export interface ElectronApi {
|
||||
openExternal: (url: string) => Promise<boolean>;
|
||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||
getRunningProcessNames: () => Promise<string[]>;
|
||||
getActiveGameCandidate?: () => Promise<ActiveGameCandidateResult>;
|
||||
getIgnoredGameProcesses?: () => Promise<string[]>;
|
||||
setIgnoredGameProcesses?: (list: string[]) => Promise<string[]>;
|
||||
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||
@@ -262,9 +283,15 @@ export interface ElectronApi {
|
||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
readFileChunk: (filePath: string, start: number, end: number) => Promise<string>;
|
||||
getFileSize: (filePath: string) => Promise<number>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
saveExistingFileAs?: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
openFilePath?: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;
|
||||
fileExists: (filePath: string) => Promise<boolean>;
|
||||
getFileUrl: (filePath: string) => Promise<string | null>;
|
||||
deleteFile: (filePath: string) => Promise<boolean>;
|
||||
ensureDir: (dirPath: string) => Promise<boolean>;
|
||||
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Injectable, signal } from '@angular/core';
|
||||
* Each key maps to a file in `src/assets/audio/`.
|
||||
*/
|
||||
export enum AppSound {
|
||||
Call = 'call',
|
||||
Joining = 'joining',
|
||||
Leave = 'leave',
|
||||
Notification = 'notification'
|
||||
@@ -38,6 +39,8 @@ export class NotificationAudioService {
|
||||
|
||||
private readonly sources = new Map<AppSound, string>();
|
||||
|
||||
private readonly activeLoops = new Map<AppSound, HTMLAudioElement>();
|
||||
|
||||
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
||||
readonly notificationVolume = signal(this.loadVolume());
|
||||
|
||||
@@ -142,4 +145,37 @@ export class NotificationAudioService {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
playLoop(sound: AppSound, volumeOverride?: number): void {
|
||||
if (this.dndMuted() || this.activeLoops.has(sound))
|
||||
return;
|
||||
|
||||
const src = this.sources.get(sound) ?? this.resolveAudioUrl(sound);
|
||||
const vol = volumeOverride ?? this.notificationVolume();
|
||||
|
||||
if (vol === 0)
|
||||
return;
|
||||
|
||||
const audio = new Audio(src);
|
||||
|
||||
audio.loop = true;
|
||||
audio.preload = 'auto';
|
||||
audio.volume = Math.max(0, Math.min(1, vol));
|
||||
this.activeLoops.set(sound, audio);
|
||||
audio.play().catch(() => {
|
||||
this.activeLoops.delete(sound);
|
||||
});
|
||||
}
|
||||
|
||||
stop(sound: AppSound): void {
|
||||
const audio = this.activeLoops.get(sound);
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
audio.remove();
|
||||
this.activeLoops.delete(sound);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ infrastructure adapters and UI.
|
||||
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
|
||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
|
||||
| **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
|
||||
| **direct-call** | Direct and small-group private calls initiated from people cards and direct messages | `DirectCallService` |
|
||||
| **experimental-media** | Optional media playback experiments kept isolated from the default attachment path | `ExperimentalMediaSettingsService` |
|
||||
| **game-activity** | Foreground-window-first game detection with confidence scoring (`MIN_GAME_CONFIDENCE`), server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
|
||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||
| **plugins** | Client-only plugin manifests, load ordering, registry state, and signal-server support metadata | `PluginHostService`, `PluginRegistryService` |
|
||||
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
|
||||
@@ -32,6 +34,8 @@ The larger domains also keep longer design notes in their own folders:
|
||||
- [authentication/README.md](authentication/README.md)
|
||||
- [chat/README.md](chat/README.md)
|
||||
- [direct-message/README.md](direct-message/README.md)
|
||||
- [direct-call/README.md](direct-call/README.md)
|
||||
- [experimental-media/README.md](experimental-media/README.md)
|
||||
- [notifications/README.md](notifications/README.md)
|
||||
- [plugins/README.md](plugins/README.md)
|
||||
- [profile-avatar/README.md](profile-avatar/README.md)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Room } from '../../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
|
||||
import { normalizeRoomAccessControl } from './room.rules';
|
||||
|
||||
function buildRoom(overrides: Partial<Room> = {}): Room {
|
||||
return {
|
||||
id: 'room-1',
|
||||
name: 'Room',
|
||||
hostId: 'host-1',
|
||||
isPrivate: false,
|
||||
createdAt: 1,
|
||||
userCount: 1,
|
||||
members: [
|
||||
{
|
||||
id: 'user-1',
|
||||
oderId: 'oder-1',
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
role: 'admin',
|
||||
joinedAt: 1,
|
||||
lastSeenAt: 1
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('normalizeRoomRoleAssignments', () => {
|
||||
it('uses legacy member roles when assignments are missing', () => {
|
||||
const room = normalizeRoomAccessControl(buildRoom());
|
||||
|
||||
expect(room.roleAssignments).toEqual([
|
||||
{
|
||||
userId: 'user-1',
|
||||
oderId: 'oder-1',
|
||||
roleIds: [SYSTEM_ROLE_IDS.admin]
|
||||
}
|
||||
]);
|
||||
|
||||
expect(room.members?.[0]?.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('honors an explicit empty assignment list', () => {
|
||||
const room = normalizeRoomAccessControl(buildRoom({ roleAssignments: [] }));
|
||||
|
||||
expect(room.roleAssignments).toEqual([]);
|
||||
expect(room.members?.[0]?.role).toBe('member');
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ export function normalizeRoomRoleAssignments(
|
||||
): RoomRoleAssignment[] {
|
||||
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
|
||||
const normalizedByUserKey = new Map<string, RoomRoleAssignment>();
|
||||
const hasExplicitAssignments = Array.isArray(assignments);
|
||||
|
||||
for (const assignment of assignments ?? []) {
|
||||
if (!assignment || typeof assignment !== 'object') {
|
||||
@@ -72,7 +73,7 @@ export function normalizeRoomRoleAssignments(
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedByUserKey.size > 0) {
|
||||
if (hasExplicitAssignments) {
|
||||
return sortAssignments(Array.from(normalizedByUserKey.values()));
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ graph TD
|
||||
|
||||
Files move between peers using a request/response pattern over the WebRTC data channel. The sender announces a file, the receiver requests it, and chunks flow back one by one.
|
||||
|
||||
When Electron serves a file from disk, the sender reads one chunk at a time and uses the buffered data-channel send path so large saved media does not get loaded into renderer memory or flood the receiver.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as Sender
|
||||
@@ -90,12 +92,12 @@ sequenceDiagram
|
||||
|
||||
loop Every 64 KB chunk
|
||||
S->>R: file-chunk (attachmentId, index, data, progress, speed)
|
||||
Note over R: Append to chunk buffer
|
||||
Note over R: Append to chunk buffer, or append media directly to disk on Electron
|
||||
Note over R: Update progress + EWMA speed
|
||||
end
|
||||
|
||||
Note over R: All chunks received
|
||||
Note over R: Reassemble blob
|
||||
Note over R: Reassemble blob, or open completed Electron media from disk
|
||||
Note over R: shouldPersistDownloadedAttachment? Save to disk
|
||||
```
|
||||
|
||||
@@ -131,17 +133,27 @@ When the user navigates to a room, the manager watches the route and decides whi
|
||||
|
||||
The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`.
|
||||
|
||||
Browser chat views render audio/video larger than 50 MB with the same generic file interface as other downloads, even after the bytes are available. Attachments with audio/video MIME types that Chromium reports as unsupported also use the generic file interface instead of a broken native player.
|
||||
|
||||
An optional experimental VLC.js adapter can be enabled from General settings. When enabled, unsupported downloaded audio/video files show a manual Play action that lazy-loads `/vlcjs/metoyou-vlc-player.js`. The runtime is intentionally isolated in the experimental media domain and is not part of the default attachment path.
|
||||
|
||||
## Persistence
|
||||
|
||||
On Electron, completed downloads are written to the app-data directory. The storage path is resolved per room and bucket:
|
||||
On Electron, local audio/video uploads are played through the original filesystem path when Electron exposes one, and received audio/video downloads are appended to an app-data file as chunks arrive. Completed audio/video downloads are then played through a file-backed media URL instead of being reloaded into a renderer `Blob`, which avoids full-file renderer memory pressure during download, startup restore, and playback. The storage path for downloaded server-room files is resolved per room and bucket:
|
||||
|
||||
```
|
||||
{appDataPath}/{serverId}/{roomName}/{bucket}/{attachmentId}.{ext?}
|
||||
{appDataPath}/server/{roomName}/{bucket}/{attachmentId}.{ext?}
|
||||
```
|
||||
|
||||
Room names are sanitised to remove filesystem-unsafe characters. The bucket is either `attachments` or `media` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other.
|
||||
Direct-message attachments use the conversation id instead of the server-room path:
|
||||
|
||||
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On browser builds, files stay in memory only.
|
||||
```
|
||||
{appDataPath}/direct-messages/{conversationId}/{bucket}/{attachmentId}.{ext?}
|
||||
```
|
||||
|
||||
Room and conversation names are sanitised to remove filesystem-unsafe characters. The bucket is `video`, `audio`, `image`, or `files` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other.
|
||||
|
||||
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On Electron, saved audio/video records are restored as file-backed URLs; other restored files still need their bytes loaded when a `Blob` URL is required. On browser builds, files stay in memory only.
|
||||
|
||||
## Runtime store
|
||||
|
||||
|
||||
@@ -70,17 +70,20 @@ export class AttachmentPersistenceService {
|
||||
} catch { /* persistence is best-effort */ }
|
||||
}
|
||||
|
||||
async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<void> {
|
||||
async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<string | null> {
|
||||
try {
|
||||
const roomName = await this.resolveCurrentRoomName();
|
||||
const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, roomName);
|
||||
const storageContainer = await this.resolveStorageContainerName(attachment);
|
||||
const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, storageContainer);
|
||||
|
||||
if (!diskPath)
|
||||
return;
|
||||
return null;
|
||||
|
||||
attachment.savedPath = diskPath;
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
return diskPath;
|
||||
} catch { /* disk save is best-effort */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async initFromDatabase(): Promise<void> {
|
||||
@@ -120,6 +123,10 @@ export class AttachmentPersistenceService {
|
||||
});
|
||||
}
|
||||
|
||||
async resolveStorageContainerName(attachment: Pick<Attachment, 'messageId'>): Promise<string> {
|
||||
return this.runtimeStore.getMessageRoomId(attachment.messageId) ?? await this.resolveCurrentRoomName();
|
||||
}
|
||||
|
||||
private async loadFromDatabase(): Promise<void> {
|
||||
try {
|
||||
const allRecords: AttachmentMeta[] = await this.database.getAllAttachments();
|
||||
@@ -176,6 +183,11 @@ export class AttachmentPersistenceService {
|
||||
continue;
|
||||
|
||||
if (attachment.savedPath) {
|
||||
if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.savedPath)) {
|
||||
hasChanges = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath);
|
||||
|
||||
if (savedBase64) {
|
||||
@@ -186,6 +198,11 @@ export class AttachmentPersistenceService {
|
||||
}
|
||||
|
||||
if (attachment.filePath) {
|
||||
if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.filePath)) {
|
||||
hasChanges = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalBase64 = await this.attachmentStorage.readFile(attachment.filePath);
|
||||
|
||||
if (originalBase64) {
|
||||
@@ -222,6 +239,26 @@ export class AttachmentPersistenceService {
|
||||
);
|
||||
}
|
||||
|
||||
private async restoreMediaAttachmentFromFileUrl(attachment: Attachment, filePath: string): Promise<boolean> {
|
||||
if (!this.isPlayableMedia(attachment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileUrl = await this.attachmentStorage.getFileUrl(filePath);
|
||||
|
||||
if (!fileUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
attachment.objectUrl = fileUrl;
|
||||
attachment.available = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private isPlayableMedia(attachment: Pick<Attachment, 'mime'>): boolean {
|
||||
return attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/');
|
||||
}
|
||||
|
||||
private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise<Set<string>> {
|
||||
const retainedSavedPaths = new Set<string>();
|
||||
|
||||
|
||||
@@ -49,6 +49,11 @@ export class AttachmentTransferTransportService {
|
||||
diskPath: string,
|
||||
isCancelled: () => boolean
|
||||
): Promise<void> {
|
||||
if (this.attachmentStorage.canReadFileChunks()) {
|
||||
await this.streamFileFromDiskChunksToPeer(targetPeerId, messageId, fileId, diskPath, isCancelled);
|
||||
return;
|
||||
}
|
||||
|
||||
const base64Full = await this.attachmentStorage.readFile(diskPath);
|
||||
|
||||
if (!base64Full)
|
||||
@@ -78,7 +83,45 @@ export class AttachmentTransferTransportService {
|
||||
data: base64Chunk
|
||||
};
|
||||
|
||||
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
|
||||
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
||||
}
|
||||
}
|
||||
|
||||
private async streamFileFromDiskChunksToPeer(
|
||||
targetPeerId: string,
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
diskPath: string,
|
||||
isCancelled: () => boolean
|
||||
): Promise<void> {
|
||||
const fileSize = await this.attachmentStorage.getFileSize(diskPath);
|
||||
|
||||
if (fileSize === null)
|
||||
return;
|
||||
|
||||
const totalChunks = Math.ceil(fileSize / FILE_CHUNK_SIZE_BYTES);
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
if (isCancelled())
|
||||
break;
|
||||
|
||||
const start = chunkIndex * FILE_CHUNK_SIZE_BYTES;
|
||||
const end = Math.min(fileSize, start + FILE_CHUNK_SIZE_BYTES);
|
||||
const base64Chunk = await this.attachmentStorage.readFileChunk(diskPath, start, end);
|
||||
|
||||
if (base64Chunk === null)
|
||||
return;
|
||||
|
||||
const fileChunkEvent: FileChunkEvent = {
|
||||
type: 'file-chunk',
|
||||
messageId,
|
||||
fileId,
|
||||
index: chunkIndex,
|
||||
total: totalChunks,
|
||||
data: base64Chunk
|
||||
};
|
||||
|
||||
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,22 @@ import { AttachmentPersistenceService } from './attachment-persistence.service';
|
||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
|
||||
|
||||
interface DiskReceiveAssembly {
|
||||
path: string;
|
||||
receivedCount: number;
|
||||
receivedIndexes: Set<number>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ValidFileChunkPayload {
|
||||
data: string;
|
||||
fileId: string;
|
||||
fromPeerId?: string;
|
||||
index: number;
|
||||
messageId: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentTransferService {
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
@@ -36,6 +52,9 @@ export class AttachmentTransferService {
|
||||
private readonly persistence = inject(AttachmentPersistenceService);
|
||||
private readonly transport = inject(AttachmentTransferTransportService);
|
||||
|
||||
private readonly diskReceiveAssemblies = new Map<string, DiskReceiveAssembly>();
|
||||
private readonly diskReceiveChains = new Map<string, Promise<void>>();
|
||||
|
||||
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
|
||||
const result: Record<string, AttachmentMeta[]> = {};
|
||||
|
||||
@@ -174,10 +193,19 @@ export class AttachmentTransferService {
|
||||
attachments.push(attachment);
|
||||
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
|
||||
|
||||
try {
|
||||
attachment.objectUrl = URL.createObjectURL(file);
|
||||
const fileUrl = attachment.filePath && this.isPlayableMedia(attachment)
|
||||
? await this.attachmentStorage.getFileUrl(attachment.filePath)
|
||||
: null;
|
||||
|
||||
if (fileUrl) {
|
||||
attachment.objectUrl = fileUrl;
|
||||
attachment.available = true;
|
||||
} catch { /* non-critical */ }
|
||||
} else {
|
||||
try {
|
||||
attachment.objectUrl = URL.createObjectURL(file);
|
||||
attachment.available = true;
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
void this.persistence.saveFileToDisk(attachment, file);
|
||||
@@ -257,6 +285,19 @@ export class AttachmentTransferService {
|
||||
if (!attachment)
|
||||
return;
|
||||
|
||||
if (this.shouldReceiveToDisk(attachment)) {
|
||||
this.enqueueDiskFileChunk(attachment, {
|
||||
data,
|
||||
fileId,
|
||||
fromPeerId,
|
||||
index,
|
||||
messageId,
|
||||
total
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const decodedBytes = this.transport.decodeBase64(data);
|
||||
const assemblyKey = `${messageId}:${fileId}`;
|
||||
const requestKey = this.buildRequestKey(messageId, fileId);
|
||||
@@ -274,7 +315,7 @@ export class AttachmentTransferService {
|
||||
this.updateTransferProgress(attachment, decodedBytes, fromPeerId);
|
||||
|
||||
this.runtimeStore.touch();
|
||||
this.finalizeTransferIfComplete(attachment, assemblyKey, total);
|
||||
void this.finalizeTransferIfComplete(attachment, assemblyKey, total);
|
||||
}
|
||||
|
||||
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
|
||||
@@ -375,6 +416,7 @@ export class AttachmentTransferService {
|
||||
|
||||
this.runtimeStore.deleteChunkBuffer(assemblyKey);
|
||||
this.runtimeStore.deleteChunkCount(assemblyKey);
|
||||
void this.deleteDiskReceiveAssembly(assemblyKey);
|
||||
|
||||
attachment.receivedBytes = 0;
|
||||
attachment.speedBps = 0;
|
||||
@@ -533,11 +575,11 @@ export class AttachmentTransferService {
|
||||
attachment.lastUpdateMs = now;
|
||||
}
|
||||
|
||||
private finalizeTransferIfComplete(
|
||||
private async finalizeTransferIfComplete(
|
||||
attachment: Attachment,
|
||||
assemblyKey: string,
|
||||
total: number
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const receivedChunkCount = this.runtimeStore.getChunkCount(assemblyKey) ?? 0;
|
||||
const completeBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
|
||||
|
||||
@@ -551,16 +593,167 @@ export class AttachmentTransferService {
|
||||
|
||||
const blob = new Blob(completeBuffer, { type: attachment.mime });
|
||||
|
||||
attachment.available = true;
|
||||
attachment.objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
if (shouldPersistDownloadedAttachment(attachment)) {
|
||||
void this.persistence.saveFileToDisk(attachment, blob);
|
||||
}
|
||||
|
||||
this.runtimeStore.deleteChunkBuffer(assemblyKey);
|
||||
this.runtimeStore.deleteChunkCount(assemblyKey);
|
||||
|
||||
if (shouldPersistDownloadedAttachment(attachment)) {
|
||||
const diskPath = await this.persistence.saveFileToDisk(attachment, blob);
|
||||
const fileUrl = diskPath && this.isPlayableMedia(attachment)
|
||||
? await this.attachmentStorage.getFileUrl(diskPath)
|
||||
: null;
|
||||
|
||||
if (fileUrl) {
|
||||
attachment.objectUrl = fileUrl;
|
||||
} else {
|
||||
attachment.objectUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
} else {
|
||||
attachment.objectUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
attachment.available = true;
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
}
|
||||
|
||||
private isPlayableMedia(attachment: Pick<Attachment, 'mime'>): boolean {
|
||||
return attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/');
|
||||
}
|
||||
|
||||
private shouldReceiveToDisk(attachment: Attachment): boolean {
|
||||
return this.isPlayableMedia(attachment) && !attachment.filePath && this.attachmentStorage.canWriteFiles();
|
||||
}
|
||||
|
||||
private enqueueDiskFileChunk(
|
||||
attachment: Attachment,
|
||||
payload: ValidFileChunkPayload
|
||||
): void {
|
||||
const assemblyKey = `${payload.messageId}:${payload.fileId}`;
|
||||
const previous = this.diskReceiveChains.get(assemblyKey) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => this.handleDiskFileChunk(attachment, assemblyKey, payload))
|
||||
.catch((error: unknown) => this.handleDiskReceiveFailure(attachment, assemblyKey, error));
|
||||
|
||||
this.diskReceiveChains.set(assemblyKey, next);
|
||||
void next.finally(() => {
|
||||
if (this.diskReceiveChains.get(assemblyKey) === next) {
|
||||
this.diskReceiveChains.delete(assemblyKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async handleDiskFileChunk(
|
||||
attachment: Attachment,
|
||||
assemblyKey: string,
|
||||
payload: ValidFileChunkPayload
|
||||
): Promise<void> {
|
||||
const decodedBytes = this.transport.decodeBase64(payload.data);
|
||||
const requestKey = this.buildRequestKey(payload.messageId, payload.fileId);
|
||||
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
this.clearAttachmentRequestError(attachment);
|
||||
|
||||
const assembly = await this.getOrCreateDiskReceiveAssembly(attachment, assemblyKey, payload.total);
|
||||
|
||||
if (!assembly) {
|
||||
throw new Error('Could not prepare media download on disk.');
|
||||
}
|
||||
|
||||
if (assembly.receivedIndexes.has(payload.index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.index !== assembly.receivedCount) {
|
||||
throw new Error('Received media chunks out of order. Retry the download.');
|
||||
}
|
||||
|
||||
const didAppend = await this.attachmentStorage.appendBase64(assembly.path, payload.data);
|
||||
|
||||
if (!didAppend) {
|
||||
throw new Error('Could not write media download to disk.');
|
||||
}
|
||||
|
||||
assembly.receivedIndexes.add(payload.index);
|
||||
assembly.receivedCount += 1;
|
||||
this.updateTransferProgress(attachment, decodedBytes, payload.fromPeerId);
|
||||
this.runtimeStore.touch();
|
||||
|
||||
if (assembly.receivedCount < assembly.total && (attachment.receivedBytes ?? 0) < attachment.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileUrl = await this.attachmentStorage.getFileUrl(assembly.path);
|
||||
|
||||
if (!fileUrl) {
|
||||
throw new Error('Could not open completed media download from disk.');
|
||||
}
|
||||
|
||||
attachment.savedPath = assembly.path;
|
||||
attachment.objectUrl = fileUrl;
|
||||
attachment.available = true;
|
||||
this.diskReceiveAssemblies.delete(assemblyKey);
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
}
|
||||
|
||||
private async getOrCreateDiskReceiveAssembly(
|
||||
attachment: Attachment,
|
||||
assemblyKey: string,
|
||||
total: number
|
||||
): Promise<DiskReceiveAssembly | null> {
|
||||
const existing = this.diskReceiveAssemblies.get(assemblyKey);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const storageContainer = await this.persistence.resolveStorageContainerName(attachment);
|
||||
const path = await this.attachmentStorage.createWritableFile(attachment, storageContainer);
|
||||
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assembly: DiskReceiveAssembly = {
|
||||
path,
|
||||
receivedCount: 0,
|
||||
receivedIndexes: new Set<number>(),
|
||||
total
|
||||
};
|
||||
|
||||
this.diskReceiveAssemblies.set(assemblyKey, assembly);
|
||||
|
||||
return assembly;
|
||||
}
|
||||
|
||||
private async handleDiskReceiveFailure(
|
||||
attachment: Attachment,
|
||||
assemblyKey: string,
|
||||
error: unknown
|
||||
): Promise<void> {
|
||||
await this.deleteDiskReceiveAssembly(assemblyKey);
|
||||
|
||||
attachment.available = false;
|
||||
attachment.objectUrl = undefined;
|
||||
attachment.receivedBytes = 0;
|
||||
attachment.speedBps = 0;
|
||||
attachment.startedAtMs = undefined;
|
||||
attachment.lastUpdateMs = undefined;
|
||||
attachment.requestError = error instanceof Error && error.message
|
||||
? error.message
|
||||
: 'Media download failed. Retry the download.';
|
||||
|
||||
this.runtimeStore.touch();
|
||||
}
|
||||
|
||||
private async deleteDiskReceiveAssembly(assemblyKey: string): Promise<void> {
|
||||
const assembly = this.diskReceiveAssemblies.get(assemblyKey);
|
||||
|
||||
this.diskReceiveAssemblies.delete(assemblyKey);
|
||||
|
||||
if (assembly?.path) {
|
||||
await this.attachmentStorage.deleteFile(assembly.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
/** Maximum file size (bytes) that is automatically saved or pushed for inline previews. */
|
||||
export const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/** Maximum browser-only audio/video size that renders with an inline media player. */
|
||||
export const MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
@@ -7,10 +7,24 @@ import {
|
||||
sanitizeAttachmentRoomName
|
||||
} from '../util/attachment-storage.util';
|
||||
|
||||
const DIRECT_MESSAGE_STORAGE_PREFIX = 'direct-message:';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentStorageService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
canWriteFiles(): boolean {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
return !!electronApi?.appendFile && !!electronApi.writeFile && !!electronApi.getAppDataPath;
|
||||
}
|
||||
|
||||
canReadFileChunks(): boolean {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
return !!electronApi?.readFileChunk && !!electronApi.getFileSize;
|
||||
}
|
||||
|
||||
async resolveExistingPath(
|
||||
attachment: Pick<Attachment, 'filePath' | 'savedPath'>
|
||||
): Promise<string | null> {
|
||||
@@ -41,10 +55,73 @@ export class AttachmentStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
async getFileSize(filePath: string): Promise<number | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi?.getFileSize || !filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await electronApi.getFileSize(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async readFileChunk(filePath: string, start: number, end: number): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi?.readFileChunk || !filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await electronApi.readFileChunk(filePath, start, end);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getFileUrl(filePath: string): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi?.getFileUrl || !filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await electronApi.getFileUrl(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async saveBlob(
|
||||
attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>,
|
||||
blob: Blob,
|
||||
roomName: string
|
||||
): Promise<string | null> {
|
||||
const diskPath = await this.createWritableFile(attachment, roomName);
|
||||
|
||||
if (!diskPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
|
||||
await this.writeBase64(diskPath, this.arrayBufferToBase64(arrayBuffer));
|
||||
|
||||
return diskPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createWritableFile(
|
||||
attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>,
|
||||
roomName: string
|
||||
): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
const appDataPath = await this.resolveAppDataPath();
|
||||
@@ -54,14 +131,12 @@ export class AttachmentStorageService {
|
||||
}
|
||||
|
||||
try {
|
||||
const directoryPath = `${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/${resolveAttachmentStorageBucket(attachment.mime)}`;
|
||||
const directoryPath = this.resolveStorageDirectoryPath(appDataPath, roomName, attachment.mime);
|
||||
|
||||
await electronApi.ensureDir(directoryPath);
|
||||
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const diskPath = `${directoryPath}/${resolveAttachmentStoredFilename(attachment.id, attachment.filename)}`;
|
||||
|
||||
await electronApi.writeFile(diskPath, this.arrayBufferToBase64(arrayBuffer));
|
||||
await this.writeBase64(diskPath, '');
|
||||
|
||||
return diskPath;
|
||||
} catch {
|
||||
@@ -69,6 +144,20 @@ export class AttachmentStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
async appendBase64(filePath: string, base64Data: string): Promise<boolean> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi?.appendFile || !filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await electronApi.appendFile(filePath, base64Data);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(filePath: string): Promise<void> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
@@ -95,6 +184,18 @@ export class AttachmentStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveStorageDirectoryPath(appDataPath: string, containerName: string, mime: string): string {
|
||||
const bucket = resolveAttachmentStorageBucket(mime);
|
||||
|
||||
if (containerName.startsWith(DIRECT_MESSAGE_STORAGE_PREFIX)) {
|
||||
const conversationId = containerName.slice(DIRECT_MESSAGE_STORAGE_PREFIX.length);
|
||||
|
||||
return `${appDataPath}/direct-messages/${sanitizeAttachmentRoomName(conversationId)}/${bucket}`;
|
||||
}
|
||||
|
||||
return `${appDataPath}/server/${sanitizeAttachmentRoomName(containerName)}/${bucket}`;
|
||||
}
|
||||
|
||||
private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
@@ -117,6 +218,16 @@ export class AttachmentStorageService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async writeBase64(filePath: string, base64Data: string): Promise<boolean> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi || !filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await electronApi.writeFile(filePath, base64Data);
|
||||
}
|
||||
|
||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
let binary = '';
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
})
|
||||
export class ChatMessagesComponent {
|
||||
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
|
||||
@ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent;
|
||||
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly store = inject(Store);
|
||||
@@ -98,6 +99,8 @@ export class ChatMessagesComponent {
|
||||
}
|
||||
|
||||
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
|
||||
this.messageList?.scrollToBottomAfterLocalSend();
|
||||
|
||||
this.store.dispatch(
|
||||
MessagesActions.sendMessage({
|
||||
content: event.content,
|
||||
@@ -278,6 +281,19 @@ export class ChatMessagesComponent {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (electronApi) {
|
||||
const diskPath = this.getAttachmentDiskPath(attachment);
|
||||
|
||||
if (diskPath && electronApi.saveExistingFileAs) {
|
||||
try {
|
||||
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
|
||||
|
||||
if (result.saved || result.cancelled)
|
||||
return;
|
||||
} catch {
|
||||
/* fall back to blob/browser download */
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await this.getAttachmentBlob(attachment);
|
||||
|
||||
if (blob) {
|
||||
@@ -326,6 +342,9 @@ export class ChatMessagesComponent {
|
||||
if (!attachment.objectUrl)
|
||||
return null;
|
||||
|
||||
if (attachment.objectUrl.startsWith('file:'))
|
||||
return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(attachment.objectUrl);
|
||||
|
||||
@@ -335,6 +354,10 @@ export class ChatMessagesComponent {
|
||||
}
|
||||
}
|
||||
|
||||
private getAttachmentDiskPath(attachment: Attachment): string | null {
|
||||
return attachment.savedPath || attachment.filePath || null;
|
||||
}
|
||||
|
||||
private blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
@@ -112,7 +112,8 @@
|
||||
type="button"
|
||||
class="font-semibold text-primary underline-offset-4 hover:underline"
|
||||
(click)="openMissingPluginStore(missingEmbed)"
|
||||
>store</button
|
||||
>
|
||||
store</button
|
||||
>.
|
||||
</article>
|
||||
}
|
||||
@@ -359,6 +360,30 @@
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
@if (att.canOpenExternally) {
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
|
||||
(click)="openAttachmentExternally(att)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideExternalLink"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
Open
|
||||
</button>
|
||||
}
|
||||
@if (att.canUseExperimentalPlayer) {
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
|
||||
(click)="openExperimentalPlayer(att)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlay"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
Play
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
class="rounded bg-primary px-2 py-1 text-xs text-primary-foreground"
|
||||
(click)="downloadAttachment(att)"
|
||||
@@ -368,6 +393,30 @@
|
||||
}
|
||||
} @else {
|
||||
<div class="text-xs text-muted-foreground">Shared from your device</div>
|
||||
@if (att.canOpenExternally) {
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
|
||||
(click)="openAttachmentExternally(att)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideExternalLink"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
Open
|
||||
</button>
|
||||
}
|
||||
@if (att.canUseExperimentalPlayer) {
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
|
||||
(click)="openExperimentalPlayer(att)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlay"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
Play
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -379,6 +428,22 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (att.experimentalPlayerActive && att.objectUrl) {
|
||||
@defer {
|
||||
<app-experimental-vlc-player
|
||||
[src]="att.objectUrl"
|
||||
[filename]="att.filename"
|
||||
[mime]="att.mime"
|
||||
[sizeLabel]="formatBytes(att.size)"
|
||||
(closed)="closeExperimentalPlayer()"
|
||||
(downloadRequested)="downloadAttachment(att)"
|
||||
/>
|
||||
} @loading {
|
||||
<div class="mt-2 max-w-xl rounded-md border border-border bg-secondary/20 p-3 text-xs text-muted-foreground">
|
||||
Loading experimental player...
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
lucideDownload,
|
||||
lucideEdit,
|
||||
lucideExpand,
|
||||
lucideExternalLink,
|
||||
lucideImage,
|
||||
lucidePlay,
|
||||
lucideReply,
|
||||
lucideSmile,
|
||||
lucideTrash2,
|
||||
@@ -29,8 +31,15 @@ import {
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentFacade,
|
||||
MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES,
|
||||
MAX_AUTO_SAVE_SIZE_BYTES
|
||||
} from '../../../../../attachment';
|
||||
import { PlatformService } from '../../../../../../core/platform';
|
||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||
import {
|
||||
ExperimentalMediaSettingsService
|
||||
} from '../../../../../experimental-media';
|
||||
import { ExperimentalVlcPlayerComponent } from '../../../../../experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component';
|
||||
import { KlipyService } from '../../../../application/services/klipy.service';
|
||||
import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules';
|
||||
import {
|
||||
@@ -81,6 +90,9 @@ const RICH_MARKDOWN_PATTERNS = [
|
||||
];
|
||||
|
||||
interface ChatMessageAttachmentViewModel extends Attachment {
|
||||
canOpenExternally: boolean;
|
||||
canUseExperimentalPlayer: boolean;
|
||||
experimentalPlayerActive: boolean;
|
||||
isAudio: boolean;
|
||||
isUploader: boolean;
|
||||
isVideo: boolean;
|
||||
@@ -112,6 +124,7 @@ interface MissingPluginEmbedFallback {
|
||||
ChatLinkEmbedComponent,
|
||||
UserAvatarComponent,
|
||||
PluginRenderHostComponent,
|
||||
ExperimentalVlcPlayerComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -120,7 +133,9 @@ interface MissingPluginEmbedFallback {
|
||||
lucideDownload,
|
||||
lucideEdit,
|
||||
lucideExpand,
|
||||
lucideExternalLink,
|
||||
lucideImage,
|
||||
lucidePlay,
|
||||
lucideReply,
|
||||
lucideSmile,
|
||||
lucideTrash2,
|
||||
@@ -140,9 +155,14 @@ export class ChatMessageItemComponent {
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly pluginRequirements = inject(PluginRequirementStateService);
|
||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly experimentalMedia = inject(ExperimentalMediaSettingsService);
|
||||
private readonly profileCard = inject(ProfileCardService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
||||
private readonly experimentalPlayerAttachmentId = signal<string | null>(null);
|
||||
private readonly mediaSupportCache = new Map<string, boolean>();
|
||||
|
||||
readonly message = input.required<Message>();
|
||||
readonly repliedMessage = input<Message | undefined>();
|
||||
@@ -539,13 +559,51 @@ export class ChatMessageItemComponent {
|
||||
this.downloadRequested.emit(attachment);
|
||||
}
|
||||
|
||||
openExperimentalPlayer(attachment: Attachment): void {
|
||||
if (!attachment.available || !attachment.objectUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.experimentalPlayerAttachmentId.set(attachment.id);
|
||||
}
|
||||
|
||||
async openAttachmentExternally(attachment: Attachment): Promise<void> {
|
||||
const diskPath = this.getAttachmentDiskPath(attachment);
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!diskPath || !electronApi?.openFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
await electronApi.openFilePath(diskPath);
|
||||
}
|
||||
|
||||
closeExperimentalPlayer(): void {
|
||||
this.experimentalPlayerAttachmentId.set(null);
|
||||
}
|
||||
|
||||
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
|
||||
const isVideo = this.isVideoAttachment(attachment);
|
||||
const isAudio = this.isAudioAttachment(attachment);
|
||||
const isRawVideo = this.isVideoAttachment(attachment);
|
||||
const isRawAudio = this.isAudioAttachment(attachment);
|
||||
const isRawPlayableMedia = isRawVideo || isRawAudio;
|
||||
const isNativePlayableMedia = this.canPlayMediaType(attachment.mime);
|
||||
const shouldUseDefaultFileInterface = isRawPlayableMedia &&
|
||||
(!isNativePlayableMedia ||
|
||||
(this.platform.isBrowser && attachment.size > MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES));
|
||||
const isVideo = isRawVideo && !shouldUseDefaultFileInterface;
|
||||
const isAudio = isRawAudio && !shouldUseDefaultFileInterface;
|
||||
const requiresMediaDownloadAcceptance = (isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
||||
const canUseExperimentalPlayer = this.experimentalMedia.vlcJsPlaybackEnabled() &&
|
||||
shouldUseDefaultFileInterface &&
|
||||
isRawPlayableMedia &&
|
||||
attachment.available &&
|
||||
!!attachment.objectUrl;
|
||||
|
||||
return {
|
||||
...attachment,
|
||||
canOpenExternally: this.platform.isElectron && attachment.available && !!this.getAttachmentDiskPath(attachment),
|
||||
canUseExperimentalPlayer,
|
||||
experimentalPlayerActive: canUseExperimentalPlayer && this.experimentalPlayerAttachmentId() === attachment.id,
|
||||
isAudio,
|
||||
isUploader: this.isUploader(attachment),
|
||||
isVideo,
|
||||
@@ -572,6 +630,30 @@ export class ChatMessageItemComponent {
|
||||
private getLiveAttachment(attachmentId: string): Attachment | undefined {
|
||||
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
|
||||
}
|
||||
|
||||
private getAttachmentDiskPath(attachment: Attachment): string | null {
|
||||
return attachment.savedPath || attachment.filePath || null;
|
||||
}
|
||||
|
||||
private canPlayMediaType(mime: string): boolean {
|
||||
if (!mime.startsWith('video/') && !mime.startsWith('audio/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cached = this.mediaSupportCache.get(mime);
|
||||
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const element = document.createElement(mime.startsWith('video/') ? 'video' : 'audio');
|
||||
|
||||
const canPlay = element.canPlayType(mime) !== '';
|
||||
|
||||
this.mediaSupportCache.set(mime, canPlay);
|
||||
|
||||
return canPlay;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePluginEmbedToken(content: string): PluginEmbedToken | null {
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
input
|
||||
} from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { environment } from '../../../../../../../../environments/environment';
|
||||
import { extractYoutubeVideoId } from '../../../../../domain/rules/link-embed.rules';
|
||||
|
||||
const YOUTUBE_EMBED_FALLBACK_ORIGIN = 'https://toju.app';
|
||||
const YOUTUBE_EMBED_FALLBACK_ORIGIN = environment.publicOrigin;
|
||||
|
||||
function resolveYoutubeClientOrigin(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
@@ -141,9 +141,11 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
return lookup;
|
||||
});
|
||||
|
||||
private initialScrollObserver: MutationObserver | null = null;
|
||||
private initialScrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private bottomScrollObserver: MutationObserver | null = null;
|
||||
private bottomScrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private boundOnImageLoad: (() => void) | null = null;
|
||||
private localSendScrollPending = false;
|
||||
private localSendScrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private isAutoScrolling = false;
|
||||
private lastMessageCount = 0;
|
||||
private initialScrollPending = true;
|
||||
@@ -170,10 +172,17 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
|
||||
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
const newMessages = currentCount > this.lastMessageCount;
|
||||
const forceLocalSendScroll = this.shouldForceLocalSendScroll();
|
||||
|
||||
if (newMessages) {
|
||||
if (distanceFromBottom <= 300) {
|
||||
this.scheduleScrollToBottomSmooth();
|
||||
if (forceLocalSendScroll || distanceFromBottom <= 300) {
|
||||
if (forceLocalSendScroll) {
|
||||
this.clearLocalSendScrollPending();
|
||||
this.scheduleScrollToBottomAfterRender(true);
|
||||
} else {
|
||||
this.scheduleScrollToBottomSmooth();
|
||||
}
|
||||
|
||||
this.showNewMessagesBar.set(false);
|
||||
} else {
|
||||
queueMicrotask(() => this.showNewMessagesBar.set(true));
|
||||
@@ -198,7 +207,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
this.isAutoScrolling = false;
|
||||
});
|
||||
|
||||
this.startInitialScrollWatch();
|
||||
this.clearLocalSendScrollPending();
|
||||
this.startBottomScrollWatch();
|
||||
this.showNewMessagesBar.set(false);
|
||||
this.lastMessageCount = this.messages().length;
|
||||
this.scheduleCodeHighlight();
|
||||
@@ -214,7 +224,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopInitialScrollWatch();
|
||||
this.stopBottomScrollWatch();
|
||||
this.clearLocalSendScrollPending();
|
||||
}
|
||||
|
||||
findRepliedMessage(messageId?: string | null): Message | undefined {
|
||||
@@ -237,8 +248,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
this.showNewMessagesBar.set(false);
|
||||
}
|
||||
|
||||
if (this.initialScrollObserver) {
|
||||
this.stopInitialScrollWatch();
|
||||
if (this.bottomScrollObserver) {
|
||||
this.stopBottomScrollWatch();
|
||||
}
|
||||
|
||||
if (element.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
|
||||
@@ -275,6 +286,13 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
this.showNewMessagesBar.set(false);
|
||||
}
|
||||
|
||||
scrollToBottomAfterLocalSend(): void {
|
||||
this.localSendScrollPending = true;
|
||||
this.showNewMessagesBar.set(false);
|
||||
this.scheduleScrollToBottomAfterRender(true);
|
||||
this.armLocalSendScrollTimeout();
|
||||
}
|
||||
|
||||
scrollToMessage(messageId: string): void {
|
||||
const container = this.messagesContainer?.nativeElement;
|
||||
|
||||
@@ -336,54 +354,42 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
|
||||
private resetScrollingState(): void {
|
||||
this.initialScrollPending = true;
|
||||
this.stopInitialScrollWatch();
|
||||
this.stopBottomScrollWatch();
|
||||
this.clearLocalSendScrollPending();
|
||||
this.showNewMessagesBar.set(false);
|
||||
this.lastMessageCount = 0;
|
||||
this.displayLimit.set(this.PAGE_SIZE);
|
||||
}
|
||||
|
||||
private startInitialScrollWatch(): void {
|
||||
this.stopInitialScrollWatch();
|
||||
private startBottomScrollWatch(): void {
|
||||
this.stopBottomScrollWatch();
|
||||
|
||||
const element = this.messagesContainer?.nativeElement;
|
||||
|
||||
if (!element)
|
||||
return;
|
||||
|
||||
const snapToBottom = () => {
|
||||
const container = this.messagesContainer?.nativeElement;
|
||||
|
||||
if (!container)
|
||||
return;
|
||||
|
||||
this.isAutoScrolling = true;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
this.isAutoScrolling = false;
|
||||
});
|
||||
};
|
||||
|
||||
this.initialScrollObserver = new MutationObserver(() => {
|
||||
requestAnimationFrame(snapToBottom);
|
||||
this.bottomScrollObserver = new MutationObserver(() => {
|
||||
requestAnimationFrame(() => this.scrollToBottomInstant());
|
||||
});
|
||||
|
||||
this.initialScrollObserver.observe(element, {
|
||||
this.bottomScrollObserver.observe(element, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src']
|
||||
});
|
||||
|
||||
this.boundOnImageLoad = () => requestAnimationFrame(snapToBottom);
|
||||
this.boundOnImageLoad = () => requestAnimationFrame(() => this.scrollToBottomInstant());
|
||||
element.addEventListener('load', this.boundOnImageLoad, true);
|
||||
|
||||
this.initialScrollTimer = setTimeout(() => this.stopInitialScrollWatch(), 5000);
|
||||
this.bottomScrollTimer = setTimeout(() => this.stopBottomScrollWatch(), 5000);
|
||||
}
|
||||
|
||||
private stopInitialScrollWatch(): void {
|
||||
if (this.initialScrollObserver) {
|
||||
this.initialScrollObserver.disconnect();
|
||||
this.initialScrollObserver = null;
|
||||
private stopBottomScrollWatch(): void {
|
||||
if (this.bottomScrollObserver) {
|
||||
this.bottomScrollObserver.disconnect();
|
||||
this.bottomScrollObserver = null;
|
||||
}
|
||||
|
||||
if (this.boundOnImageLoad && this.messagesContainer) {
|
||||
@@ -392,12 +398,41 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
this.boundOnImageLoad = null;
|
||||
}
|
||||
|
||||
if (this.initialScrollTimer) {
|
||||
clearTimeout(this.initialScrollTimer);
|
||||
this.initialScrollTimer = null;
|
||||
if (this.bottomScrollTimer) {
|
||||
clearTimeout(this.bottomScrollTimer);
|
||||
this.bottomScrollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private armLocalSendScrollTimeout(): void {
|
||||
if (this.localSendScrollTimer) {
|
||||
clearTimeout(this.localSendScrollTimer);
|
||||
}
|
||||
|
||||
this.localSendScrollTimer = setTimeout(() => {
|
||||
this.localSendScrollPending = false;
|
||||
this.localSendScrollTimer = null;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private clearLocalSendScrollPending(): void {
|
||||
this.localSendScrollPending = false;
|
||||
|
||||
if (this.localSendScrollTimer) {
|
||||
clearTimeout(this.localSendScrollTimer);
|
||||
this.localSendScrollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private shouldForceLocalSendScroll(): boolean {
|
||||
if (!this.localSendScrollPending)
|
||||
return false;
|
||||
|
||||
const latestMessage = this.channelMessages().at(-1);
|
||||
|
||||
return !!latestMessage && latestMessage.senderId === this.currentUserId();
|
||||
}
|
||||
|
||||
private getMessageDateTimestamp(message: Message): number {
|
||||
return message.timestamp || getMessageTimestamp(message);
|
||||
}
|
||||
@@ -424,6 +459,31 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private scrollToBottomInstant(): void {
|
||||
const element = this.messagesContainer?.nativeElement;
|
||||
|
||||
if (!element)
|
||||
return;
|
||||
|
||||
this.isAutoScrolling = true;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
this.isAutoScrolling = false;
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleScrollToBottomAfterRender(watchForLayoutChanges = false): void {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollToBottomInstant();
|
||||
|
||||
if (watchForLayoutChanges) {
|
||||
this.startBottomScrollWatch();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleScrollToBottomSmooth(): void {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => this.scrollToBottomSmooth());
|
||||
|
||||
18
toju-app/src/app/domains/direct-call/README.md
Normal file
18
toju-app/src/app/domains/direct-call/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Direct Call Domain
|
||||
|
||||
Direct calls coordinate private voice sessions started from people cards, direct-message headers, or active-call rail icons. The domain owns call session state and call-control events; media capture, camera, screen sharing, playback, and voice activity stay in the existing voice and screen-share domains.
|
||||
|
||||
## Flow
|
||||
|
||||
1. `DirectCallService.startCall()` creates or reuses the direct-message conversation for a peer, while `startConversationCall()` starts from an existing one-to-one or group conversation. Both paths reuse a live call for the same peer or group before creating a new session.
|
||||
2. The caller joins a call-scoped voice session and sends a `direct-call` ring event through `PeerDeliveryService`. Joining a direct call first leaves any other joined call or server voice channel.
|
||||
3. The recipient stores the incoming session, loops `assets/audio/call.wav`, shows an in-app answer/decline modal, and shows a desktop notification when permission allows. If the recipient is set to Do Not Disturb (`status: "busy"`), the session is stored silently without call audio, the in-app modal, or a desktop notification. The ring stops when the recipient joins, declines, leaves, or the call ends.
|
||||
4. Opening `/call/:callId` shows the private call surface with portraits, voice indicators, media controls, screen/camera tiles, add-user control, and a narrow DM chat panel.
|
||||
5. If a third participant is invited, the call creates a fresh empty group conversation and switches the call chat panel to it. Existing one-to-one messages stay in the original PM and are not copied into the group chat.
|
||||
6. Starting a call from a group chat uses the group conversation id as the call id and rings every other participant.
|
||||
7. Joining, leaving, ending, participant additions, and call chat conversion updates are mirrored as `direct-call` events over the same P2P/signaling fallback path used by direct messages.
|
||||
8. The server rail shows call icons only while at least one participant is joined. If a user is viewing a private call after the session ends, the route returns to the call's chat view.
|
||||
|
||||
Incoming `direct-call` events are ignored unless the current user is declared in the event's `participantIds` or participant profiles, so only invited PM/group-call participants can receive call audio, the in-app incoming-call modal, or a desktop ring notification.
|
||||
|
||||
Two-person calls use the one-to-one direct-message conversation id as their call id. Converted group calls keep the original call id for media routing but point `conversationId` at the new group chat so active streams stay connected while the chat history boundary changes.
|
||||
@@ -0,0 +1,598 @@
|
||||
import {
|
||||
Injector,
|
||||
runInInjectionContext,
|
||||
signal,
|
||||
ɵChangeDetectionScheduler as ChangeDetectionScheduler,
|
||||
ɵEffectScheduler as EffectScheduler
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Subject } from 'rxjs';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import {
|
||||
VoiceActivityService,
|
||||
VoiceConnectionFacade,
|
||||
VoicePlaybackService
|
||||
} from '../../../voice-connection';
|
||||
import { VoiceSessionFacade } from '../../../voice-session';
|
||||
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import type {
|
||||
ChatEvent,
|
||||
DirectMessageParticipant,
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
import type { DirectMessageConversation } from '../../../direct-message';
|
||||
import type { DirectCallSession } from '../../domain/models/direct-call.model';
|
||||
import { DirectCallService } from './direct-call.service';
|
||||
|
||||
const alice = createUser('alice', 'Alice');
|
||||
const bob = createUser('bob', 'Bob');
|
||||
const charlie = createUser('charlie', 'Charlie');
|
||||
|
||||
describe('DirectCallService', () => {
|
||||
it('only keeps sessions visible while a participant is joined', () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
||||
|
||||
expect(context.service.hasOngoingActivity(createSession('calling', false))).toBe(false);
|
||||
expect(context.service.hasOngoingActivity(createSession('ringing', false))).toBe(false);
|
||||
expect(context.service.hasOngoingActivity(createSession('connected', true))).toBe(true);
|
||||
expect(context.service.hasOngoingActivity(createSession('connected', false))).toBe(false);
|
||||
expect(context.service.hasOngoingActivity(createSession('ended', true))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a locally left call visible only until the last peer leaves', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
||||
const session = createSession('connected', true);
|
||||
|
||||
session.participants.bob.joined = true;
|
||||
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
||||
|
||||
expect(context.service.visibleActiveSessions()).toHaveLength(1);
|
||||
|
||||
context.service.leaveCall(session.callId);
|
||||
|
||||
expect(context.service.visibleActiveSessions()).toHaveLength(1);
|
||||
|
||||
context.directCallEvents.next(createCallEvent('leave', bob, ['alice', 'bob']));
|
||||
|
||||
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(0));
|
||||
});
|
||||
|
||||
it('hides an incoming call after the last joined participant leaves before answer', async () => {
|
||||
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
|
||||
|
||||
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
||||
|
||||
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(1));
|
||||
await vi.waitFor(() => expect(context.audio.playLoop).toHaveBeenCalledWith(AppSound.Call));
|
||||
expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob');
|
||||
|
||||
context.directCallEvents.next(createCallEvent('leave', alice, ['alice', 'bob']));
|
||||
|
||||
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(0));
|
||||
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
|
||||
expect(context.service.incomingCall()).toBeNull();
|
||||
});
|
||||
|
||||
it('suppresses incoming call audio and modal state while do not disturb is active', async () => {
|
||||
const busyBob = { ...bob, status: 'busy' as const };
|
||||
const context = createServiceContext({ currentUser: busyBob, allUsers: [alice, busyBob] });
|
||||
|
||||
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
||||
|
||||
await vi.waitFor(() => expect(context.service.sessionById('dm-alice-bob')).not.toBeNull());
|
||||
expect(context.audio.playLoop).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call));
|
||||
expect(context.service.incomingCall()).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores incoming call events when the current user is not a participant', async () => {
|
||||
const context = createServiceContext({ currentUser: charlie, allUsers: [alice, bob, charlie] });
|
||||
|
||||
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
||||
|
||||
await vi.waitFor(() => expect(context.service.sessionById('dm-alice-bob')).toBeNull());
|
||||
expect(context.audio.playLoop).not.toHaveBeenCalled();
|
||||
expect(context.directMessages.createConversation).not.toHaveBeenCalled();
|
||||
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers an incoming call from the modal action', async () => {
|
||||
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
|
||||
|
||||
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
||||
|
||||
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
|
||||
await context.service.answerIncomingCall('dm-alice-bob');
|
||||
|
||||
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
|
||||
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-alice-bob']);
|
||||
expect(context.service.incomingCall()).toBeNull();
|
||||
});
|
||||
|
||||
it('declines an incoming call from the modal action', async () => {
|
||||
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
|
||||
|
||||
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
||||
|
||||
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
|
||||
context.service.declineIncomingCall('dm-alice-bob');
|
||||
|
||||
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
|
||||
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('alice', expect.objectContaining({
|
||||
directCall: expect.objectContaining({
|
||||
action: 'leave',
|
||||
callId: 'dm-alice-bob'
|
||||
}),
|
||||
type: 'direct-call'
|
||||
}));
|
||||
|
||||
expect(context.service.sessionById('dm-alice-bob')?.status).toBe('ended');
|
||||
expect(context.service.incomingCall()).toBeNull();
|
||||
});
|
||||
|
||||
it('rejoins an existing direct call instead of ringing a duplicate after leaving locally', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
||||
const session = createSession('connected', true);
|
||||
|
||||
session.participants.alice.joined = false;
|
||||
session.participants.bob.joined = true;
|
||||
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
||||
context.service.joinCall = vi.fn(async () => undefined);
|
||||
|
||||
await context.service.startCall(bob);
|
||||
|
||||
expect(context.service.visibleActiveSessions()).toHaveLength(1);
|
||||
expect(context.service.joinCall).toHaveBeenCalledWith('dm-alice-bob');
|
||||
expect(context.delivery.sendCallEvent).not.toHaveBeenCalled();
|
||||
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-alice-bob']);
|
||||
});
|
||||
|
||||
it('reuses an existing group call by conversation id instead of creating a duplicate call', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
] });
|
||||
const session = createGroupSession('dm-original-call', 'dm-group-live', [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
]);
|
||||
const conversation = createGroupConversation('dm-group-live', [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
]);
|
||||
|
||||
session.participants.alice.joined = false;
|
||||
session.participants.bob.joined = true;
|
||||
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
||||
context.service.joinCall = vi.fn(async () => undefined);
|
||||
|
||||
await context.service.startConversationCall(conversation);
|
||||
|
||||
expect(context.service.visibleActiveSessions()).toHaveLength(1);
|
||||
expect(context.service.joinCall).toHaveBeenCalledWith('dm-original-call');
|
||||
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
|
||||
expect(context.delivery.sendCallEvent).not.toHaveBeenCalled();
|
||||
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-original-call']);
|
||||
});
|
||||
|
||||
it('leaves a joined call before joining a different call', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
] });
|
||||
const firstSession = createSession('connected', true);
|
||||
const nextSession = createDirectSession('dm-alice-charlie', alice, charlie, 'connected', false);
|
||||
|
||||
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(firstSession);
|
||||
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(nextSession);
|
||||
|
||||
await context.service.joinCall(nextSession.callId);
|
||||
|
||||
expect(context.service.sessionById(firstSession.callId)?.participants.alice.joined).toBe(false);
|
||||
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('bob', expect.objectContaining({
|
||||
directCall: expect.objectContaining({
|
||||
action: 'leave',
|
||||
callId: firstSession.callId
|
||||
}),
|
||||
type: 'direct-call'
|
||||
}));
|
||||
});
|
||||
|
||||
it('disconnects the current voice channel before joining a call', async () => {
|
||||
const voiceConnectedAlice: User = {
|
||||
...alice,
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: 'voice-room-1',
|
||||
serverId: 'server-1'
|
||||
}
|
||||
};
|
||||
const context = createServiceContext({ currentUser: voiceConnectedAlice, allUsers: [voiceConnectedAlice, bob] });
|
||||
const session = createSession('connected', false);
|
||||
|
||||
session.participants.bob.joined = true;
|
||||
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
||||
|
||||
await context.service.joinCall(session.callId);
|
||||
|
||||
expect(context.voice.stopVoiceHeartbeat).toHaveBeenCalled();
|
||||
expect(context.voice.disableVoice).toHaveBeenCalled();
|
||||
expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'voice-state',
|
||||
voiceState: expect.objectContaining({
|
||||
isConnected: false,
|
||||
roomId: 'voice-room-1',
|
||||
serverId: 'server-1'
|
||||
})
|
||||
}));
|
||||
|
||||
expect(context.voiceSession.endSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts group calls by keeping the rail-visible call session and ringing every other participant', async () => {
|
||||
const context = createServiceContext({ currentUser: alice, allUsers: [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
] });
|
||||
const conversation = createGroupConversation('dm-group-test', [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
]);
|
||||
|
||||
context.service.joinCall = vi.fn(async (callId: string) => {
|
||||
const session = context.service.sessionById(callId);
|
||||
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession({
|
||||
...session,
|
||||
status: 'connected',
|
||||
participants: {
|
||||
...session.participants,
|
||||
alice: {
|
||||
...session.participants.alice,
|
||||
joined: true
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await context.service.startConversationCall(conversation);
|
||||
|
||||
expect(context.service.visibleActiveSessions()).toHaveLength(1);
|
||||
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('bob', expect.objectContaining({
|
||||
directCall: expect.objectContaining({
|
||||
action: 'ring',
|
||||
callId: 'dm-group-test'
|
||||
}),
|
||||
type: 'direct-call'
|
||||
}));
|
||||
|
||||
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('charlie', expect.objectContaining({
|
||||
directCall: expect.objectContaining({
|
||||
action: 'ring',
|
||||
callId: 'dm-group-test'
|
||||
}),
|
||||
type: 'direct-call'
|
||||
}));
|
||||
|
||||
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-group-test']);
|
||||
});
|
||||
});
|
||||
|
||||
interface ServiceContextOptions {
|
||||
allUsers: User[];
|
||||
currentUser: User;
|
||||
}
|
||||
|
||||
interface ServiceContext {
|
||||
audio: {
|
||||
playLoop: ReturnType<typeof vi.fn>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
delivery: {
|
||||
sendCallEvent: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
directCallEvents: Subject<ChatEvent>;
|
||||
directMessages: {
|
||||
createConversation: ReturnType<typeof vi.fn>;
|
||||
createGroupConversation: ReturnType<typeof vi.fn>;
|
||||
openConversation: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
router: {
|
||||
navigate: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
service: DirectCallService;
|
||||
voice: {
|
||||
broadcastMessage: ReturnType<typeof vi.fn>;
|
||||
disableVoice: ReturnType<typeof vi.fn>;
|
||||
stopVoiceHeartbeat: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
voiceSession: {
|
||||
endSession: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
||||
const currentUser = signal<User | null>(options.currentUser);
|
||||
const allUsers = signal<User[]>(options.allUsers);
|
||||
const directCallEvents = new Subject<ChatEvent>();
|
||||
const router = {
|
||||
navigate: vi.fn(async () => true)
|
||||
};
|
||||
const store = {
|
||||
dispatch: vi.fn(),
|
||||
selectSignal: vi.fn((selector: unknown) => {
|
||||
if (selector === selectCurrentUser) {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
if (selector === selectAllUsers) {
|
||||
return allUsers;
|
||||
}
|
||||
|
||||
throw new Error('Unexpected selector requested by DirectCallService test.');
|
||||
})
|
||||
};
|
||||
const directMessages = {
|
||||
createConversation: vi.fn(async (user: User) => createDirectConversation(options.currentUser, user)),
|
||||
createGroupConversation: vi.fn(async (participants: DirectMessageParticipant[], title?: string, conversationId = 'dm-group-test') => ({
|
||||
...createGroupConversation(conversationId, participants.map(participantToUser)),
|
||||
title
|
||||
})),
|
||||
openConversation: vi.fn(async () => undefined)
|
||||
};
|
||||
const delivery = {
|
||||
directCallEvents$: directCallEvents.asObservable(),
|
||||
sendCallEvent: vi.fn(() => true)
|
||||
};
|
||||
const audio = {
|
||||
playLoop: vi.fn(),
|
||||
stop: vi.fn()
|
||||
};
|
||||
const voice = {
|
||||
broadcastMessage: vi.fn(),
|
||||
disableVoice: vi.fn(),
|
||||
ensureSignalingConnected: vi.fn(async () => true),
|
||||
isDeafened: vi.fn(() => false),
|
||||
isMuted: vi.fn(() => false),
|
||||
setLocalStream: vi.fn(async () => undefined),
|
||||
startVoiceHeartbeat: vi.fn(),
|
||||
stopVoiceHeartbeat: vi.fn(),
|
||||
syncOutgoingVoiceRouting: vi.fn(),
|
||||
toggleMute: vi.fn()
|
||||
};
|
||||
const voiceSession = {
|
||||
endSession: vi.fn()
|
||||
};
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: ChangeDetectionScheduler,
|
||||
useValue: {
|
||||
notify: vi.fn()
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: EffectScheduler,
|
||||
useValue: {
|
||||
add: vi.fn(),
|
||||
flush: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
schedule: vi.fn()
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: DirectMessageService,
|
||||
useValue: directMessages
|
||||
},
|
||||
{
|
||||
provide: NotificationAudioService,
|
||||
useValue: audio
|
||||
},
|
||||
{
|
||||
provide: PeerDeliveryService,
|
||||
useValue: delivery
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: router
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: store
|
||||
},
|
||||
{
|
||||
provide: VoiceActivityService,
|
||||
useValue: {
|
||||
trackLocalMic: vi.fn(),
|
||||
untrackLocalMic: vi.fn()
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: VoiceConnectionFacade,
|
||||
useValue: voice
|
||||
},
|
||||
{
|
||||
provide: VoiceSessionFacade,
|
||||
useValue: voiceSession
|
||||
},
|
||||
{
|
||||
provide: VoicePlaybackService,
|
||||
useValue: {
|
||||
playPendingStreams: vi.fn(),
|
||||
teardownAll: vi.fn()
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
audio,
|
||||
delivery,
|
||||
directCallEvents,
|
||||
directMessages,
|
||||
router,
|
||||
service: runInInjectionContext(injector, () => new DirectCallService()),
|
||||
voice,
|
||||
voiceSession
|
||||
};
|
||||
}
|
||||
|
||||
function createCallEvent(action: 'leave' | 'ring', sender: User, participantIds: string[]): ChatEvent {
|
||||
return {
|
||||
type: 'direct-call',
|
||||
directCall: {
|
||||
action,
|
||||
callId: 'dm-alice-bob',
|
||||
conversationId: 'dm-alice-bob',
|
||||
createdAt: 10,
|
||||
sender: toParticipant(sender),
|
||||
participantIds,
|
||||
participants: [alice, bob].map(toParticipant)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createSession(status: DirectCallSession['status'], joined: boolean): DirectCallSession {
|
||||
return {
|
||||
callId: 'dm-alice-bob',
|
||||
conversationId: 'dm-alice-bob',
|
||||
createdAt: 10,
|
||||
initiatorId: 'alice',
|
||||
participantIds: ['alice', 'bob'],
|
||||
participants: {
|
||||
alice: {
|
||||
userId: 'alice',
|
||||
profile: toParticipant(alice),
|
||||
joined
|
||||
},
|
||||
bob: {
|
||||
userId: 'bob',
|
||||
profile: toParticipant(bob),
|
||||
joined: false
|
||||
}
|
||||
},
|
||||
status
|
||||
};
|
||||
}
|
||||
|
||||
function createDirectSession(
|
||||
callId: string,
|
||||
currentUser: User,
|
||||
peer: User,
|
||||
status: DirectCallSession['status'],
|
||||
joined: boolean
|
||||
): DirectCallSession {
|
||||
const currentParticipant = toParticipant(currentUser);
|
||||
const peerParticipant = toParticipant(peer);
|
||||
|
||||
return {
|
||||
callId,
|
||||
conversationId: callId,
|
||||
createdAt: 10,
|
||||
initiatorId: currentParticipant.userId,
|
||||
participantIds: [currentParticipant.userId, peerParticipant.userId],
|
||||
participants: {
|
||||
[currentParticipant.userId]: {
|
||||
userId: currentParticipant.userId,
|
||||
profile: currentParticipant,
|
||||
joined
|
||||
},
|
||||
[peerParticipant.userId]: {
|
||||
userId: peerParticipant.userId,
|
||||
profile: peerParticipant,
|
||||
joined: false
|
||||
}
|
||||
},
|
||||
status
|
||||
};
|
||||
}
|
||||
|
||||
function createGroupSession(callId: string, conversationId: string, users: User[]): DirectCallSession {
|
||||
const participants = users.map(toParticipant);
|
||||
|
||||
return {
|
||||
callId,
|
||||
conversationId,
|
||||
createdAt: 10,
|
||||
initiatorId: participants[0].userId,
|
||||
participantIds: participants.map((participant) => participant.userId),
|
||||
participants: Object.fromEntries(participants.map((participant) => [
|
||||
participant.userId,
|
||||
{
|
||||
userId: participant.userId,
|
||||
profile: participant,
|
||||
joined: false
|
||||
}
|
||||
])),
|
||||
status: 'connected'
|
||||
};
|
||||
}
|
||||
|
||||
function createDirectConversation(currentUser: User, peer: User): DirectMessageConversation {
|
||||
const participants = [toParticipant(currentUser), toParticipant(peer)];
|
||||
const participantIds = participants.map((participant) => participant.userId).sort();
|
||||
|
||||
return {
|
||||
id: `dm-${participantIds.join('-')}`,
|
||||
kind: 'direct',
|
||||
lastMessageAt: 10,
|
||||
messages: [],
|
||||
participantProfiles: Object.fromEntries(participants.map((participant) => [participant.userId, participant])),
|
||||
participants: participantIds,
|
||||
unreadCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
function createGroupConversation(conversationId: string, users: User[]): DirectMessageConversation {
|
||||
const participants = users.map(toParticipant);
|
||||
const participantIds = participants.map((participant) => participant.userId).sort();
|
||||
|
||||
return {
|
||||
id: conversationId,
|
||||
kind: 'group',
|
||||
lastMessageAt: 10,
|
||||
messages: [],
|
||||
participantProfiles: Object.fromEntries(participants.map((participant) => [participant.userId, participant])),
|
||||
participants: participantIds,
|
||||
title: participants.map((participant) => participant.displayName).join(', '),
|
||||
unreadCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
function participantToUser(participant: DirectMessageParticipant): User {
|
||||
return createUser(participant.userId, participant.displayName);
|
||||
}
|
||||
|
||||
function toParticipant(user: User): DirectMessageParticipant {
|
||||
return {
|
||||
userId: user.oderId || user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName
|
||||
};
|
||||
}
|
||||
|
||||
function createUser(id: string, displayName: string): User {
|
||||
return {
|
||||
id,
|
||||
oderId: id,
|
||||
username: displayName.toLowerCase(),
|
||||
displayName,
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: 1
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,899 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import {
|
||||
VoiceActivityService,
|
||||
VoiceConnectionFacade,
|
||||
VoicePlaybackService
|
||||
} from '../../../voice-connection';
|
||||
import { VoiceSessionFacade } from '../../../voice-session';
|
||||
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
|
||||
import type { DirectMessageConversation } from '../../../direct-message';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import {
|
||||
DirectCallEventPayload,
|
||||
DirectMessageParticipant,
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model';
|
||||
import { toDirectMessageParticipant } from '../../../direct-message';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DirectCallService {
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly delivery = inject(PeerDeliveryService);
|
||||
private readonly directMessages = inject(DirectMessageService);
|
||||
private readonly audio = inject(NotificationAudioService);
|
||||
private readonly voice = inject(VoiceConnectionFacade);
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly playback = inject(VoicePlaybackService);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
||||
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
|
||||
|
||||
readonly sessions = computed(() => this.sessionsSignal());
|
||||
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
|
||||
readonly visibleActiveSessions = computed(() => this.activeSessions().filter((session) => this.hasOngoingActivity(session)));
|
||||
readonly incomingCall = computed<DirectCallSession | null>(() => {
|
||||
if (this.isDoNotDisturb()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const meId = this.currentUserId();
|
||||
|
||||
if (!meId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [...this.activeSessions()]
|
||||
.sort((left, right) => right.createdAt - left.createdAt)
|
||||
.find((session) => session.status === 'ringing'
|
||||
&& this.currentSession()?.callId !== session.callId
|
||||
&& !session.participants[meId]?.joined
|
||||
&& this.hasConnectedParticipant(session)) ?? null;
|
||||
});
|
||||
readonly currentSession = signal<DirectCallSession | null>(null);
|
||||
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
|
||||
|
||||
constructor() {
|
||||
this.delivery.directCallEvents$.subscribe((event) => {
|
||||
if (event.directCall) {
|
||||
void this.handleIncomingCallEvent(event.directCall);
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const session = this.currentSession();
|
||||
|
||||
if (!session || session.status === 'ended') {
|
||||
return;
|
||||
}
|
||||
|
||||
const peerIds = this.remoteParticipantIds(session);
|
||||
|
||||
this.voice.syncOutgoingVoiceRouting(peerIds);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.incomingCall() && !this.isDoNotDisturb()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.audio.stop(AppSound.Call);
|
||||
});
|
||||
}
|
||||
|
||||
sessionById(callId: string | null | undefined): DirectCallSession | null {
|
||||
if (!callId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.sessionsSignal().find((session) => session.callId === callId) ?? null;
|
||||
}
|
||||
|
||||
isCallingUser(user: User): boolean {
|
||||
const userId = this.userKey(user);
|
||||
|
||||
return this.visibleActiveSessions().some((session) => session.participantIds.includes(userId));
|
||||
}
|
||||
|
||||
isCallingConversation(conversationId: string | null | undefined): boolean {
|
||||
if (!conversationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.visibleActiveSessions().some((session) => session.callId === conversationId || session.conversationId === conversationId);
|
||||
}
|
||||
|
||||
hasConnectedParticipant(session: DirectCallSession | null | undefined): boolean {
|
||||
if (!session || session.status === 'ended') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(session.participants).some((participant) => participant.joined);
|
||||
}
|
||||
|
||||
hasOngoingActivity(session: DirectCallSession | null | undefined): boolean {
|
||||
return this.hasConnectedParticipant(session);
|
||||
}
|
||||
|
||||
async startCall(user: User): Promise<DirectCallSession> {
|
||||
const conversation = await this.directMessages.createConversation(user);
|
||||
const me = this.requireCurrentUser();
|
||||
const meParticipant = toDirectMessageParticipant(me);
|
||||
const peerParticipant = toDirectMessageParticipant(user);
|
||||
const participantIds = this.uniqueIds([meParticipant.userId, peerParticipant.userId]);
|
||||
const activeSession = this.findLiveSessionForParticipants(participantIds, conversation.id);
|
||||
|
||||
if (activeSession) {
|
||||
return await this.rejoinLiveSession(activeSession);
|
||||
}
|
||||
|
||||
const existing = this.sessionById(conversation.id);
|
||||
const session = existing ?? this.createSession({
|
||||
callId: conversation.id,
|
||||
conversationId: conversation.id,
|
||||
createdAt: Date.now(),
|
||||
initiatorId: meParticipant.userId,
|
||||
participantIds,
|
||||
participants: [meParticipant, peerParticipant],
|
||||
status: 'calling'
|
||||
});
|
||||
|
||||
this.upsertSession(session);
|
||||
this.currentSession.set(session);
|
||||
await this.joinCall(session.callId, false);
|
||||
this.sendCallEvent(peerParticipant.userId, 'ring', session);
|
||||
await this.router.navigate(['/call', session.callId]);
|
||||
return session;
|
||||
}
|
||||
|
||||
async startConversationCall(conversation: DirectMessageConversation): Promise<DirectCallSession> {
|
||||
if (this.isGroupConversation(conversation)) {
|
||||
return await this.startGroupCall(conversation);
|
||||
}
|
||||
|
||||
const meId = this.currentUserId();
|
||||
const peerId = conversation.participants.find((participantId) => participantId !== meId);
|
||||
|
||||
if (!peerId) {
|
||||
throw new Error('Direct message conversation has no recipient to call.');
|
||||
}
|
||||
|
||||
const peer = this.userForParticipant(peerId) ?? participantToUser(this.participantFromConversation(conversation, peerId));
|
||||
|
||||
return await this.startCall(peer);
|
||||
}
|
||||
|
||||
async openCall(callId: string): Promise<void> {
|
||||
const session = this.sessionById(callId);
|
||||
|
||||
if (session?.conversationId) {
|
||||
await this.directMessages.openConversation(session.conversationId);
|
||||
}
|
||||
|
||||
this.currentSession.set(session);
|
||||
}
|
||||
|
||||
async answerIncomingCall(callId: string): Promise<void> {
|
||||
const session = this.sessionById(callId);
|
||||
|
||||
if (!session || session.status === 'ended') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.audio.stop(AppSound.Call);
|
||||
this.currentSession.set(session);
|
||||
await this.joinCall(callId);
|
||||
await this.router.navigate(['/call', callId]);
|
||||
}
|
||||
|
||||
declineIncomingCall(callId: string): void {
|
||||
const session = this.sessionById(callId);
|
||||
|
||||
if (!session || session.status === 'ended') {
|
||||
return;
|
||||
}
|
||||
|
||||
const meId = this.currentUserId();
|
||||
const nextSession = meId
|
||||
? {
|
||||
...this.markParticipantJoined(session, meId, false, 'ended'),
|
||||
status: 'ended' as const
|
||||
}
|
||||
: {
|
||||
...session,
|
||||
status: 'ended' as const
|
||||
};
|
||||
|
||||
this.audio.stop(AppSound.Call);
|
||||
|
||||
if (meId) {
|
||||
this.broadcastCallEvent('leave', session);
|
||||
}
|
||||
|
||||
this.upsertSession(nextSession);
|
||||
|
||||
if (this.currentSession()?.callId === callId) {
|
||||
this.currentSession.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
async joinCall(callId: string, notifyPeers = true): Promise<void> {
|
||||
const session = this.sessionById(callId);
|
||||
const me = this.requireCurrentUser();
|
||||
const meId = this.userKey(me);
|
||||
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.leaveOtherJoinedCalls(callId);
|
||||
this.leaveCurrentVoiceTargetForCall(callId);
|
||||
this.audio.stop(AppSound.Call);
|
||||
|
||||
const ok = await this.voice.ensureSignalingConnected();
|
||||
|
||||
if (!ok || !navigator.mediaDevices?.getUserMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: false
|
||||
}
|
||||
});
|
||||
|
||||
await this.voice.setLocalStream(stream);
|
||||
this.voiceActivity.trackLocalMic(meId, stream);
|
||||
this.voice.startVoiceHeartbeat(session.callId, session.callId);
|
||||
this.updateLocalVoiceState(session, true);
|
||||
this.playback.playPendingStreams({
|
||||
isConnected: true,
|
||||
outputVolume: 1,
|
||||
isDeafened: this.voice.isDeafened()
|
||||
});
|
||||
|
||||
const nextSession = this.markParticipantJoined(session, meId, true, 'connected');
|
||||
|
||||
this.upsertSession(nextSession);
|
||||
this.currentSession.set(nextSession);
|
||||
|
||||
if (notifyPeers) {
|
||||
this.broadcastCallEvent('join', nextSession);
|
||||
}
|
||||
}
|
||||
|
||||
leaveCall(callId: string, endForEveryone = false): void {
|
||||
const session = this.sessionById(callId);
|
||||
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.leaveJoinedSession(session, endForEveryone);
|
||||
}
|
||||
|
||||
leaveCurrentJoinedCall(exceptCallId?: string): void {
|
||||
for (const session of this.sessionsSignal()) {
|
||||
if (session.callId === exceptCallId || !this.isCurrentUserJoined(session)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.leaveJoinedSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
private leaveJoinedSession(session: DirectCallSession, endForEveryone = false): void {
|
||||
const action = endForEveryone ? 'end' : 'leave';
|
||||
const nextSession = this.markCurrentUserLeft(session, endForEveryone);
|
||||
|
||||
this.audio.stop(AppSound.Call);
|
||||
this.broadcastCallEvent(action, nextSession);
|
||||
this.stopLocalMedia(nextSession);
|
||||
this.upsertSession(nextSession);
|
||||
|
||||
this.currentSession.set(null);
|
||||
}
|
||||
|
||||
async inviteUser(callId: string, user: User): Promise<void> {
|
||||
const session = this.sessionById(callId);
|
||||
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
const participant = toDirectMessageParticipant(user);
|
||||
const nextSession = this.createSession({
|
||||
...session,
|
||||
participantIds: this.uniqueIds([...session.participantIds, participant.userId]),
|
||||
participants: [...Object.values(session.participants).map((entry) => entry.profile), participant],
|
||||
status: session.status
|
||||
});
|
||||
const convertedSession = await this.convertToGroupConversationIfNeeded(this.preserveJoinedParticipants(session, nextSession));
|
||||
|
||||
this.upsertSession(convertedSession);
|
||||
this.currentSession.set(convertedSession);
|
||||
this.broadcastCallEvent('update', convertedSession, [participant.userId]);
|
||||
this.sendCallEvent(participant.userId, 'ring', convertedSession);
|
||||
}
|
||||
|
||||
remoteParticipantIds(session: DirectCallSession): string[] {
|
||||
const meId = this.currentUserId();
|
||||
|
||||
return session.participantIds.filter((participantId) => participantId !== meId);
|
||||
}
|
||||
|
||||
userForParticipant(participantId: string): User | null {
|
||||
const known = this.users().find((user) => user.id === participantId || user.oderId === participantId || user.peerId === participantId);
|
||||
|
||||
if (known) {
|
||||
return known;
|
||||
}
|
||||
|
||||
const participant = this.currentSession()?.participants[participantId]?.profile;
|
||||
|
||||
return participant ? participantToUser(participant) : null;
|
||||
}
|
||||
|
||||
private async handleIncomingCallEvent(payload: DirectCallEventPayload): Promise<void> {
|
||||
const meId = this.currentUserId();
|
||||
|
||||
if (!meId || payload.sender.userId === meId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.callPayloadIncludesParticipant(payload, meId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const participants = this.callParticipantsFromPayload(payload);
|
||||
const existing = this.sessionById(payload.callId);
|
||||
const incomingSession = this.createSession({
|
||||
callId: payload.callId,
|
||||
conversationId: payload.conversationId,
|
||||
createdAt: payload.createdAt,
|
||||
initiatorId: existing?.initiatorId ?? payload.sender.userId,
|
||||
participantIds: this.uniqueIds([
|
||||
...payload.participantIds,
|
||||
meId,
|
||||
payload.sender.userId
|
||||
]),
|
||||
participants,
|
||||
status: this.resolveIncomingStatus(payload.action, existing?.status)
|
||||
});
|
||||
const preservedSession = existing ? this.preserveJoinedParticipants(existing, incomingSession) : incomingSession;
|
||||
const session = this.applyIncomingParticipantState(preservedSession, payload);
|
||||
|
||||
this.upsertSession(session);
|
||||
this.currentSession.set(this.currentSession()?.callId === session.callId ? session : this.currentSession());
|
||||
this.markRemoteVoiceState(payload.sender.userId, session, payload.action === 'join');
|
||||
|
||||
if (payload.action === 'update') {
|
||||
await this.ensureCallConversation(session);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.action === 'ring') {
|
||||
await this.ensureCallConversation(session);
|
||||
|
||||
if (this.shouldAlertIncomingCall(session)) {
|
||||
this.audio.playLoop(AppSound.Call);
|
||||
} else {
|
||||
this.audio.stop(AppSound.Call);
|
||||
}
|
||||
|
||||
if (this.shouldAlertIncomingCall(session)) {
|
||||
await this.showIncomingNotification(payload.sender.displayName, payload.callId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.audio.stop(AppSound.Call);
|
||||
|
||||
if (payload.action === 'end') {
|
||||
if (this.currentSession()?.callId === payload.callId) {
|
||||
this.stopLocalMedia(session);
|
||||
this.currentSession.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async startGroupCall(conversation: DirectMessageConversation): Promise<DirectCallSession> {
|
||||
const me = this.requireCurrentUser();
|
||||
const meParticipant = toDirectMessageParticipant(me);
|
||||
const participantIds = this.uniqueIds([...conversation.participants, meParticipant.userId]);
|
||||
const conversationParticipants = participantIds.map((participantId) => this.participantFromConversation(conversation, participantId));
|
||||
const participants = this.uniqueParticipants([meParticipant, ...conversationParticipants]);
|
||||
const activeSession = this.findLiveSessionForParticipants(participantIds, conversation.id);
|
||||
|
||||
if (activeSession) {
|
||||
return await this.rejoinLiveSession(activeSession);
|
||||
}
|
||||
|
||||
const existing = this.sessionById(conversation.id);
|
||||
const session = existing && existing.status !== 'ended'
|
||||
? existing
|
||||
: this.createSession({
|
||||
callId: conversation.id,
|
||||
conversationId: conversation.id,
|
||||
createdAt: Date.now(),
|
||||
initiatorId: meParticipant.userId,
|
||||
participantIds,
|
||||
participants,
|
||||
status: 'calling'
|
||||
});
|
||||
|
||||
this.upsertSession(session);
|
||||
this.currentSession.set(session);
|
||||
await this.joinCall(session.callId, false);
|
||||
this.broadcastCallEvent('ring', this.sessionById(session.callId) ?? session);
|
||||
await this.router.navigate(['/call', session.callId]);
|
||||
return this.sessionById(session.callId) ?? session;
|
||||
}
|
||||
|
||||
private async rejoinLiveSession(session: DirectCallSession): Promise<DirectCallSession> {
|
||||
this.upsertSession(session);
|
||||
this.currentSession.set(session);
|
||||
|
||||
if (!this.isCurrentUserJoined(session)) {
|
||||
await this.joinCall(session.callId);
|
||||
}
|
||||
|
||||
const nextSession = this.sessionById(session.callId) ?? session;
|
||||
|
||||
await this.router.navigate(['/call', nextSession.callId]);
|
||||
return nextSession;
|
||||
}
|
||||
|
||||
private leaveOtherJoinedCalls(callId: string): void {
|
||||
this.leaveCurrentJoinedCall(callId);
|
||||
}
|
||||
|
||||
private leaveCurrentVoiceTargetForCall(callId: string): void {
|
||||
const user = this.currentUser();
|
||||
const voiceState = user?.voiceState;
|
||||
|
||||
if (!voiceState?.isConnected || (voiceState.roomId === callId && voiceState.serverId === callId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = user?.id;
|
||||
const userKey = user ? this.userKey(user) : undefined;
|
||||
|
||||
this.voice.stopVoiceHeartbeat();
|
||||
|
||||
if (userKey) {
|
||||
this.voiceActivity.untrackLocalMic(userKey);
|
||||
}
|
||||
|
||||
this.voice.disableVoice();
|
||||
this.playback.teardownAll();
|
||||
this.voiceSession.endSession();
|
||||
|
||||
if (userId) {
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
userId,
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
this.voice.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: userKey,
|
||||
displayName: user?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: voiceState.roomId,
|
||||
serverId: voiceState.serverId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureConversation(sender: DirectMessageParticipant): Promise<void> {
|
||||
await this.directMessages.createConversation(participantToUser(sender));
|
||||
}
|
||||
|
||||
private isGroupConversation(conversation: DirectMessageConversation): boolean {
|
||||
return conversation.kind === 'group' || conversation.participants.length > 2;
|
||||
}
|
||||
|
||||
private participantFromConversation(conversation: DirectMessageConversation, participantId: string): DirectMessageParticipant {
|
||||
const knownUser = this.userForParticipant(participantId);
|
||||
const profile = conversation.participantProfiles[participantId];
|
||||
|
||||
if (knownUser) {
|
||||
return toDirectMessageParticipant(knownUser);
|
||||
}
|
||||
|
||||
return profile ?? {
|
||||
userId: participantId,
|
||||
username: participantId,
|
||||
displayName: participantId
|
||||
};
|
||||
}
|
||||
|
||||
private resolveIncomingStatus(action: DirectCallEventPayload['action'], currentStatus?: DirectCallSession['status']): DirectCallSession['status'] {
|
||||
if (action === 'ring') {
|
||||
return currentStatus === 'connected' ? 'connected' : 'ringing';
|
||||
}
|
||||
|
||||
if (action === 'join') {
|
||||
return 'connected';
|
||||
}
|
||||
|
||||
if (action === 'end') {
|
||||
return 'ended';
|
||||
}
|
||||
|
||||
if (action === 'update') {
|
||||
return currentStatus ?? 'ringing';
|
||||
}
|
||||
|
||||
if (action === 'leave') {
|
||||
return currentStatus === 'ringing' ? 'ringing' : 'connected';
|
||||
}
|
||||
|
||||
return currentStatus ?? 'ringing';
|
||||
}
|
||||
|
||||
private applyIncomingParticipantState(session: DirectCallSession, payload: DirectCallEventPayload): DirectCallSession {
|
||||
if (payload.action === 'ring' || payload.action === 'join') {
|
||||
return this.markParticipantJoined(session, payload.sender.userId, true, payload.action === 'join' ? 'connected' : session.status);
|
||||
}
|
||||
|
||||
if (payload.action === 'leave') {
|
||||
const nextSession = this.markParticipantJoined(session, payload.sender.userId, false, session.status);
|
||||
|
||||
return this.hasConnectedParticipant(nextSession)
|
||||
? nextSession
|
||||
: {
|
||||
...nextSession,
|
||||
status: 'ended'
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.action === 'end') {
|
||||
return {
|
||||
...session,
|
||||
status: 'ended'
|
||||
};
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private sendCallEvent(recipientId: string, action: DirectCallEventPayload['action'], session: DirectCallSession): void {
|
||||
const me = this.requireCurrentUser();
|
||||
|
||||
this.delivery.sendCallEvent(recipientId, {
|
||||
type: 'direct-call',
|
||||
directCall: {
|
||||
action,
|
||||
callId: session.callId,
|
||||
conversationId: session.conversationId,
|
||||
createdAt: session.createdAt,
|
||||
sender: toDirectMessageParticipant(me),
|
||||
participantIds: session.participantIds,
|
||||
participants: Object.values(session.participants).map((participant) => participant.profile)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private broadcastCallEvent(action: DirectCallEventPayload['action'], session: DirectCallSession, excludedParticipantIds: string[] = []): void {
|
||||
const excluded = new Set(excludedParticipantIds);
|
||||
|
||||
for (const participantId of this.remoteParticipantIds(session)) {
|
||||
if (excluded.has(participantId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.sendCallEvent(participantId, action, session);
|
||||
}
|
||||
}
|
||||
|
||||
private async convertToGroupConversationIfNeeded(session: DirectCallSession): Promise<DirectCallSession> {
|
||||
if (session.participantIds.length <= 2) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const conversation = await this.directMessages.createGroupConversation(
|
||||
Object.values(session.participants).map((participant) => participant.profile),
|
||||
this.groupConversationTitle(session),
|
||||
session.conversationId.startsWith('dm-group-') ? session.conversationId : undefined
|
||||
);
|
||||
|
||||
return {
|
||||
...session,
|
||||
conversationId: conversation.id
|
||||
};
|
||||
}
|
||||
|
||||
private preserveJoinedParticipants(previousSession: DirectCallSession, nextSession: DirectCallSession): DirectCallSession {
|
||||
return {
|
||||
...nextSession,
|
||||
participants: Object.fromEntries(Object.values(nextSession.participants).map((participant) => [
|
||||
participant.userId,
|
||||
{
|
||||
...participant,
|
||||
joined: previousSession.participants[participant.userId]?.joined ?? participant.joined
|
||||
}
|
||||
]))
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureCallConversation(session: DirectCallSession): Promise<void> {
|
||||
if (session.participantIds.length > 2) {
|
||||
await this.directMessages.createGroupConversation(
|
||||
Object.values(session.participants).map((participant) => participant.profile),
|
||||
this.groupConversationTitle(session),
|
||||
session.conversationId
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const sender = Object.values(session.participants)
|
||||
.map((participant) => participant.profile)
|
||||
.find((participant) => participant.userId !== this.currentUserId());
|
||||
|
||||
if (sender) {
|
||||
await this.ensureConversation(sender);
|
||||
}
|
||||
}
|
||||
|
||||
private callParticipantsFromPayload(payload: DirectCallEventPayload): DirectMessageParticipant[] {
|
||||
return this.uniqueParticipants([
|
||||
payload.sender,
|
||||
toDirectMessageParticipant(this.requireCurrentUser()),
|
||||
...(payload.participants ?? []),
|
||||
...payload.participantIds
|
||||
.map((participantId) => this.userForParticipant(participantId))
|
||||
.filter((user): user is User => !!user)
|
||||
.map((user) => toDirectMessageParticipant(user))
|
||||
]);
|
||||
}
|
||||
|
||||
private callPayloadIncludesParticipant(payload: DirectCallEventPayload, participantId: string): boolean {
|
||||
return payload.participantIds.includes(participantId)
|
||||
|| (payload.participants ?? []).some((participant) => participant.userId === participantId);
|
||||
}
|
||||
|
||||
private groupConversationTitle(session: DirectCallSession): string {
|
||||
const names = Object.values(session.participants)
|
||||
.map((participant) => participant.profile.displayName || participant.profile.username || participant.userId);
|
||||
|
||||
if (names.length <= 3) {
|
||||
return names.join(', ');
|
||||
}
|
||||
|
||||
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
|
||||
}
|
||||
|
||||
private createSession(input: {
|
||||
callId: string;
|
||||
conversationId: string;
|
||||
createdAt: number;
|
||||
initiatorId: string;
|
||||
participantIds: string[];
|
||||
participants: DirectMessageParticipant[];
|
||||
status: DirectCallSession['status'];
|
||||
}): DirectCallSession {
|
||||
const participants = Object.fromEntries(this.uniqueParticipants(input.participants).map((participant) => [
|
||||
participant.userId,
|
||||
{
|
||||
userId: participant.userId,
|
||||
profile: participant,
|
||||
joined: input.status === 'connected' && participant.userId === this.currentUserId()
|
||||
}
|
||||
]));
|
||||
|
||||
return {
|
||||
callId: input.callId,
|
||||
conversationId: input.conversationId,
|
||||
createdAt: input.createdAt,
|
||||
initiatorId: input.initiatorId,
|
||||
participantIds: this.uniqueIds(input.participantIds),
|
||||
participants,
|
||||
status: input.status
|
||||
};
|
||||
}
|
||||
|
||||
private markParticipantJoined(
|
||||
session: DirectCallSession,
|
||||
participantId: string,
|
||||
joined: boolean,
|
||||
status: DirectCallSession['status']
|
||||
): DirectCallSession {
|
||||
const participant = session.participants[participantId];
|
||||
|
||||
return {
|
||||
...session,
|
||||
status,
|
||||
participants: {
|
||||
...session.participants,
|
||||
...(participant
|
||||
? {
|
||||
[participantId]: {
|
||||
...participant,
|
||||
joined
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private upsertSession(session: DirectCallSession): void {
|
||||
this.sessionsSignal.update((sessions) => [...sessions.filter((entry) => entry.callId !== session.callId), session]);
|
||||
}
|
||||
|
||||
private markCurrentUserLeft(session: DirectCallSession, endForEveryone: boolean): DirectCallSession {
|
||||
const meId = this.currentUserId();
|
||||
const locallyLeftSession = meId
|
||||
? this.markParticipantJoined(session, meId, false, session.status)
|
||||
: session;
|
||||
|
||||
return {
|
||||
...locallyLeftSession,
|
||||
status: endForEveryone || !this.hasConnectedParticipant(locallyLeftSession) ? 'ended' as const : 'connected' as const
|
||||
};
|
||||
}
|
||||
|
||||
private findLiveSessionForParticipants(participantIds: string[], conversationId?: string | null): DirectCallSession | null {
|
||||
const normalizedParticipantIds = this.uniqueIds(participantIds).sort();
|
||||
|
||||
return this.visibleActiveSessions().find((session) => {
|
||||
if (conversationId && (session.callId === conversationId || session.conversationId === conversationId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sessionParticipantIds = this.uniqueIds(session.participantIds).sort();
|
||||
|
||||
return sessionParticipantIds.length === normalizedParticipantIds.length
|
||||
&& sessionParticipantIds.every((participantId, index) => participantId === normalizedParticipantIds[index]);
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
private isCurrentUserJoined(session: DirectCallSession): boolean {
|
||||
const meId = this.currentUserId();
|
||||
|
||||
return !!meId && !!session.participants[meId]?.joined;
|
||||
}
|
||||
|
||||
private stopLocalMedia(session: DirectCallSession): void {
|
||||
const meId = this.currentUserId();
|
||||
|
||||
if (meId) {
|
||||
this.voiceActivity.untrackLocalMic(meId);
|
||||
}
|
||||
|
||||
this.voice.stopVoiceHeartbeat();
|
||||
this.voice.disableVoice();
|
||||
this.playback.teardownAll();
|
||||
this.updateLocalVoiceState(session, false);
|
||||
}
|
||||
|
||||
private updateLocalVoiceState(session: DirectCallSession, connected: boolean): void {
|
||||
const user = this.currentUser();
|
||||
|
||||
if (!user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: {
|
||||
isConnected: connected,
|
||||
isMuted: connected ? this.voice.isMuted() : false,
|
||||
isDeafened: connected ? this.voice.isDeafened() : false,
|
||||
roomId: connected ? session.callId : undefined,
|
||||
serverId: connected ? session.callId : undefined
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private markRemoteVoiceState(userId: string, session: DirectCallSession, connected: boolean): void {
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
userId,
|
||||
voiceState: {
|
||||
isConnected: connected,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: connected ? session.callId : undefined,
|
||||
serverId: connected ? session.callId : undefined
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private shouldAlertIncomingCall(session: DirectCallSession): boolean {
|
||||
return session.status !== 'connected' && !this.isDoNotDisturb();
|
||||
}
|
||||
|
||||
private isDoNotDisturb(): boolean {
|
||||
return this.currentUser()?.status === 'busy';
|
||||
}
|
||||
|
||||
private async showIncomingNotification(displayName: string, callId: string): Promise<void> {
|
||||
if (typeof Notification === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
let permission = Notification.permission;
|
||||
|
||||
if (permission === 'default') {
|
||||
permission = await Notification.requestPermission();
|
||||
}
|
||||
|
||||
if (permission !== 'granted') {
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = new Notification('Incoming call', {
|
||||
body: `${displayName} is calling you`
|
||||
});
|
||||
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
void this.router.navigate(['/call', callId]);
|
||||
};
|
||||
}
|
||||
|
||||
private uniqueParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return participants.filter((participant) => {
|
||||
if (seen.has(participant.userId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(participant.userId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private uniqueIds(ids: string[]): string[] {
|
||||
return ids.filter((id, index) => !!id && ids.indexOf(id) === index);
|
||||
}
|
||||
|
||||
private userKey(user: User): string {
|
||||
return user.oderId || user.id;
|
||||
}
|
||||
|
||||
private currentUserId(): string | null {
|
||||
const user = this.currentUser();
|
||||
|
||||
return user ? this.userKey(user) : null;
|
||||
}
|
||||
|
||||
private requireCurrentUser(): User {
|
||||
const user = this.currentUser();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Cannot use calls without a current user.');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { DirectMessageParticipant, User } from '../../../../shared-kernel';
|
||||
|
||||
export type DirectCallStatus = 'calling' | 'ringing' | 'connected' | 'ended';
|
||||
|
||||
export interface DirectCallParticipant {
|
||||
userId: string;
|
||||
profile: DirectMessageParticipant;
|
||||
joined: boolean;
|
||||
}
|
||||
|
||||
export interface DirectCallSession {
|
||||
callId: string;
|
||||
conversationId: string;
|
||||
createdAt: number;
|
||||
initiatorId: string;
|
||||
participantIds: string[];
|
||||
participants: Record<string, DirectCallParticipant>;
|
||||
status: DirectCallStatus;
|
||||
}
|
||||
|
||||
export function participantToUser(participant: DirectMessageParticipant): User {
|
||||
return {
|
||||
id: participant.userId,
|
||||
oderId: participant.userId,
|
||||
username: participant.username,
|
||||
displayName: participant.displayName,
|
||||
description: participant.description,
|
||||
avatarUrl: participant.avatarUrl,
|
||||
avatarHash: participant.avatarHash,
|
||||
avatarMime: participant.avatarMime,
|
||||
avatarUpdatedAt: participant.avatarUpdatedAt,
|
||||
profileUpdatedAt: participant.profileUpdatedAt,
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: Date.now()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
@if (session()) {
|
||||
<div class="fixed inset-0 z-[120] bg-black/60 backdrop-blur-sm"></div>
|
||||
|
||||
<div class="pointer-events-none fixed inset-0 z-[121] flex items-center justify-center p-4">
|
||||
<section
|
||||
class="pointer-events-auto w-full max-w-sm rounded-lg border border-border bg-card shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="incoming-call-title"
|
||||
>
|
||||
<div class="flex flex-col items-center px-6 pb-6 pt-7 text-center">
|
||||
<div class="relative">
|
||||
@if (caller(); as callerUser) {
|
||||
<app-user-avatar
|
||||
[avatarUrl]="callerUser.avatarUrl"
|
||||
[name]="callerUser.displayName || callerUser.username"
|
||||
[showStatusBadge]="true"
|
||||
[status]="callerUser.status"
|
||||
size="xl"
|
||||
/>
|
||||
} @else {
|
||||
<div class="grid h-16 w-16 place-items-center rounded-full bg-secondary text-xl font-semibold text-secondary-foreground">
|
||||
{{ callerName().charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="absolute -bottom-1 -right-1 grid h-7 w-7 place-items-center rounded-full border border-card bg-green-600 text-white shadow-lg">
|
||||
<ng-icon
|
||||
name="lucidePhone"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-5 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">Incoming call</p>
|
||||
<h2
|
||||
id="incoming-call-title"
|
||||
class="mt-2 text-xl font-semibold text-foreground"
|
||||
>
|
||||
{{ callerName() }} is calling
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{{ callKindLabel() }}</p>
|
||||
|
||||
<div class="mt-6 grid w-full grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-border bg-secondary px-4 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||
(click)="decline()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePhoneOff"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
Decline
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-green-600 px-4 text-sm font-semibold text-white transition-colors hover:bg-green-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
[disabled]="answering()"
|
||||
(click)="answer()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePhone"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
Answer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Component,
|
||||
HostListener,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucidePhone, lucidePhoneOff } from '@ng-icons/lucide';
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { DirectCallService } from '../../application/services/direct-call.service';
|
||||
import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-incoming-call-modal',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucidePhone,
|
||||
lucidePhoneOff
|
||||
})
|
||||
],
|
||||
templateUrl: './incoming-call-modal.component.html'
|
||||
})
|
||||
export class IncomingCallModalComponent {
|
||||
readonly calls = inject(DirectCallService);
|
||||
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
|
||||
readonly session = this.calls.incomingCall;
|
||||
readonly answering = signal(false);
|
||||
readonly caller = computed(() => {
|
||||
const session = this.session();
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const callerId = this.callerIdFor(session);
|
||||
const participant = callerId ? session.participants[callerId]?.profile : null;
|
||||
|
||||
return (callerId ? this.calls.userForParticipant(callerId) : null)
|
||||
?? (participant ? participantToUser(participant) : null);
|
||||
});
|
||||
readonly callerName = computed(() => this.caller()?.displayName || 'Someone');
|
||||
readonly callKindLabel = computed(() => {
|
||||
const participantCount = this.session()?.participantIds.length ?? 0;
|
||||
|
||||
return participantCount > 2 ? `${participantCount} person call` : 'Direct call';
|
||||
});
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.decline();
|
||||
}
|
||||
|
||||
async answer(): Promise<void> {
|
||||
const session = this.session();
|
||||
|
||||
if (!session || this.answering()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.answering.set(true);
|
||||
|
||||
try {
|
||||
await this.calls.answerIncomingCall(session.callId);
|
||||
} finally {
|
||||
this.answering.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
decline(): void {
|
||||
const session = this.session();
|
||||
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.calls.declineIncomingCall(session.callId);
|
||||
}
|
||||
|
||||
private callerIdFor(session: DirectCallSession): string | null {
|
||||
const currentUserId = this.currentUserKey();
|
||||
|
||||
if (session.initiatorId && session.initiatorId !== currentUserId) {
|
||||
return session.initiatorId;
|
||||
}
|
||||
|
||||
return session.participantIds.find((participantId) => participantId !== currentUserId) ?? null;
|
||||
}
|
||||
|
||||
private currentUserKey(): string | null {
|
||||
const user = this.currentUser();
|
||||
|
||||
return user ? this.userKey(user) : null;
|
||||
}
|
||||
|
||||
private userKey(user: User): string {
|
||||
return user.oderId || user.id;
|
||||
}
|
||||
}
|
||||
3
toju-app/src/app/domains/direct-call/index.ts
Normal file
3
toju-app/src/app/domains/direct-call/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './application/services/direct-call.service';
|
||||
export * from './domain/models/direct-call.model';
|
||||
export * from './feature/incoming-call-modal/incoming-call-modal.component';
|
||||
@@ -1,6 +1,8 @@
|
||||
# Direct Message Domain
|
||||
|
||||
Direct messages provide local, offline-safe one-to-one messaging over the existing WebRTC data channel, with a signaling relay fallback when no peer data channel is available but a route to the recipient is known.
|
||||
Direct messages provide local, offline-safe one-to-one and small-group messaging over the existing WebRTC data channel, with a signaling relay fallback when no peer data channel is available but a route to the recipient is known.
|
||||
|
||||
The same `PeerDeliveryService` also exposes direct-call events for the `direct-call` domain so private calls can ring through either an open peer data channel or a known signaling route without adding a second recipient lookup path.
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -15,12 +17,14 @@ direct-message/
|
||||
## Flow
|
||||
|
||||
1. `DirectMessageService.sendMessage()` stores the message locally with `QUEUED`.
|
||||
2. `PeerDeliveryService` tries to send a `direct-message` P2P event to the recipient's current peer id.
|
||||
3. If no data channel is connected, `PeerDeliveryService` tries the recipient's known signaling route before leaving the message queued.
|
||||
2. `PeerDeliveryService` tries to send a `direct-message` P2P event to every other participant's current peer id.
|
||||
3. If no data channel is connected, `PeerDeliveryService` tries each participant's known signaling route before leaving the message queued.
|
||||
4. If either transport sends, the sender advances to `SENT`; otherwise the message id remains in `OfflineMessageQueueService`.
|
||||
5. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back.
|
||||
6. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event.
|
||||
|
||||
Incoming PM and group-chat events are ignored unless the current user is declared in the message recipients, participant profiles, or existing local conversation. Sync requests are only answered for conversation participants, so a stray peer route cannot create unread state or expose private history.
|
||||
|
||||
Status transitions are monotonic, so a stale `SENT` event cannot overwrite `DELIVERED` or `ACKNOWLEDGED`.
|
||||
|
||||
## Chat View
|
||||
@@ -29,6 +33,14 @@ The DM view reuses the chat domain's shared message list, composer, overlays, ma
|
||||
|
||||
Message edits, deletions, and reaction changes are stored locally and mirrored to the peer with `direct-message-mutation` events. Delivery state remains direct-message-owned and is exposed separately from the visible shared chat row UI.
|
||||
|
||||
When a private call grows beyond two participants, the direct-call domain creates a new empty `group` conversation and points the call chat panel at it. The previous one-to-one conversation remains untouched, so private history is not copied into the group chat. Group conversations reuse the same composer, message list, attachment, GIF, markdown, link-embed, typing, mutation, and sync paths as one-to-one DMs; delivery simply fans out to every participant except the sender.
|
||||
|
||||
The DM header and conversation list can start calls from both one-to-one and group conversations. Group calls reuse the group conversation id as the call id and send the same ring notification to every other participant.
|
||||
|
||||
Typing state is DM-owned as well. The composer emits `direct-message-typing` events, and the chat view renders the active peer names with a short TTL so the embedded private-call chat has the same typing feedback as a standalone PM.
|
||||
|
||||
When a conversation opens, a peer reconnects, or network service is restored, the selected conversation requests a bounded `direct-message-sync` snapshot from the peer. Incoming snapshots merge the newest messages by id instead of replacing local history, which lets clients backfill older PMs when their local stores drift.
|
||||
|
||||
## GIFs
|
||||
|
||||
The DM composer reuses the chat domain's KLIPY integration. Availability and GIF search go through the configured signal server API, and selected GIFs are sent as markdown image messages so the same proxy-fallback image rendering path is used in DMs and server chat.
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import {
|
||||
advanceDirectMessageStatus,
|
||||
createDirectConversation,
|
||||
createGroupConversation,
|
||||
directMessageEventIncludesUser,
|
||||
directMessageSyncIncludesUser,
|
||||
getDirectConversationId,
|
||||
isGroupDirectConversation,
|
||||
updateMessageStatusInConversation,
|
||||
upsertDirectMessage
|
||||
} from '../../domain/logic/direct-message.logic';
|
||||
@@ -17,6 +21,11 @@ const bob: DirectMessageParticipant = {
|
||||
username: 'bob',
|
||||
displayName: 'Bob'
|
||||
};
|
||||
const charlie: DirectMessageParticipant = {
|
||||
userId: 'charlie',
|
||||
username: 'charlie',
|
||||
displayName: 'Charlie'
|
||||
};
|
||||
|
||||
describe('DirectMessageService domain flow', () => {
|
||||
it('should create conversation', () => {
|
||||
@@ -44,20 +53,85 @@ describe('DirectMessageService domain flow', () => {
|
||||
expect(updatedConversation.messages[0].status).toBe('QUEUED');
|
||||
});
|
||||
|
||||
it('should create empty group conversation without direct-message history', () => {
|
||||
const directConversation = upsertDirectMessage(createDirectConversation(alice, bob, 10), createMessage('message-1', 'SENT'), false);
|
||||
const groupConversation = createGroupConversation('dm-group-test', [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
], 30, 'Alice, Bob, Charlie');
|
||||
|
||||
expect(isGroupDirectConversation(groupConversation)).toBe(true);
|
||||
expect(groupConversation.id).toBe('dm-group-test');
|
||||
expect(groupConversation.title).toBe('Alice, Bob, Charlie');
|
||||
expect(groupConversation.participants).toEqual([
|
||||
'alice',
|
||||
'bob',
|
||||
'charlie'
|
||||
]);
|
||||
|
||||
expect(groupConversation.messages).toEqual([]);
|
||||
expect(directConversation.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should preserve group message recipient metadata', () => {
|
||||
const conversation = createGroupConversation('dm-group-test', [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
], 10);
|
||||
const recipientIds = ['bob', 'charlie'];
|
||||
const message = createMessage('message-1', 'QUEUED', conversation.id, recipientIds);
|
||||
const updatedConversation = upsertDirectMessage(conversation, message, false);
|
||||
|
||||
expect(updatedConversation.messages[0].recipientId).toBe('bob');
|
||||
expect(updatedConversation.messages[0].recipientIds).toEqual(recipientIds);
|
||||
});
|
||||
|
||||
it('should update status correctly', () => {
|
||||
expect(advanceDirectMessageStatus('QUEUED', 'SENT')).toBe('SENT');
|
||||
expect(advanceDirectMessageStatus('SENT', 'DELIVERED')).toBe('DELIVERED');
|
||||
expect(advanceDirectMessageStatus('DELIVERED', 'SENT')).toBe('DELIVERED');
|
||||
expect(advanceDirectMessageStatus('DELIVERED', 'ACKNOWLEDGED')).toBe('ACKNOWLEDGED');
|
||||
});
|
||||
|
||||
it('recognises only declared direct-message recipients and participants', () => {
|
||||
const payload = {
|
||||
message: createMessage('message-1', 'SENT', 'dm-group-test', ['bob']),
|
||||
participants: [alice, bob],
|
||||
sender: alice
|
||||
};
|
||||
|
||||
expect(directMessageEventIncludesUser(payload, 'bob')).toBe(true);
|
||||
expect(directMessageEventIncludesUser(payload, 'charlie')).toBe(false);
|
||||
});
|
||||
|
||||
it('recognises only declared sync participants', () => {
|
||||
const payload = {
|
||||
conversationId: 'dm-group-test',
|
||||
messages: [],
|
||||
participants: [alice, bob],
|
||||
sender: alice,
|
||||
syncedAt: 30
|
||||
};
|
||||
|
||||
expect(directMessageSyncIncludesUser(payload, 'alice')).toBe(true);
|
||||
expect(directMessageSyncIncludesUser(payload, 'charlie')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function createMessage(id: string, status: DirectMessage['status']): DirectMessage {
|
||||
function createMessage(
|
||||
id: string,
|
||||
status: DirectMessage['status'],
|
||||
conversationId = getDirectConversationId('alice', 'bob'),
|
||||
recipientIds = ['bob']
|
||||
): DirectMessage {
|
||||
return {
|
||||
id,
|
||||
conversationId: getDirectConversationId('alice', 'bob'),
|
||||
conversationId,
|
||||
senderId: 'alice',
|
||||
recipientId: 'bob',
|
||||
recipientId: recipientIds[0],
|
||||
recipientIds,
|
||||
content: 'Hello',
|
||||
timestamp: 20,
|
||||
status
|
||||
|
||||
@@ -12,10 +12,16 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { DirectMessageRepository } from '../../infrastructure/direct-message.repository';
|
||||
import { OfflineMessageQueueService } from './offline-message-queue.service';
|
||||
import { PeerDeliveryService } from './peer-delivery.service';
|
||||
import { AttachmentFacade } from '../../../attachment';
|
||||
import {
|
||||
advanceDirectMessageStatus,
|
||||
createDirectConversation,
|
||||
createGroupConversation,
|
||||
directMessageConversationIncludesUser,
|
||||
directMessageEventIncludesUser,
|
||||
directMessageSyncIncludesUser,
|
||||
getDirectConversationId,
|
||||
isGroupDirectConversation,
|
||||
updateMessageStatusInConversation,
|
||||
upsertDirectMessage
|
||||
} from '../../domain/logic/direct-message.logic';
|
||||
@@ -24,8 +30,12 @@ import {
|
||||
DirectMessageConversation,
|
||||
DirectMessageEventPayload,
|
||||
DirectMessageMutationEventPayload,
|
||||
DirectMessageParticipant,
|
||||
DirectMessageSyncEventPayload,
|
||||
DirectMessageSyncRequestEventPayload,
|
||||
DirectMessageStatus,
|
||||
DirectMessageStatusEventPayload,
|
||||
DirectMessageTypingEventPayload,
|
||||
toDirectMessageParticipant
|
||||
} from '../../domain/models/direct-message.model';
|
||||
import type {
|
||||
@@ -35,16 +45,32 @@ import type {
|
||||
} from '../../../../shared-kernel';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
|
||||
const DIRECT_MESSAGE_SYNC_LIMIT = 1000;
|
||||
const DIRECT_MESSAGE_SYNC_REQUEST_COOLDOWN_MS = 5000;
|
||||
const DIRECT_MESSAGE_TYPING_TTL_MS = 3000;
|
||||
const DIRECT_MESSAGE_TYPING_PURGE_MS = 1000;
|
||||
const DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX = 'direct-message:';
|
||||
|
||||
interface DirectMessageTypingEntry {
|
||||
conversationId: string;
|
||||
userId: string;
|
||||
displayName: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DirectMessageService {
|
||||
private readonly repository = inject(DirectMessageRepository);
|
||||
private readonly offlineQueue = inject(OfflineMessageQueueService);
|
||||
private readonly delivery = inject(PeerDeliveryService);
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
private readonly store = inject(Store);
|
||||
private readonly router = inject(Router);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
|
||||
private readonly selectedConversationIdSignal = signal<string | null>(null);
|
||||
private readonly typingEntriesSignal = signal<DirectMessageTypingEntry[]>([]);
|
||||
private readonly lastSyncRequestAt = new Map<string, number>();
|
||||
private loadedOwnerId: string | null = null;
|
||||
|
||||
readonly conversations = computed(() => [...this.conversationsSignal()].sort(
|
||||
@@ -62,6 +88,7 @@ export class DirectMessageService {
|
||||
(total, conversation) => total + conversation.unreadCount,
|
||||
0
|
||||
));
|
||||
readonly typingEntries = this.typingEntriesSignal.asReadonly();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -76,11 +103,15 @@ export class DirectMessageService {
|
||||
|
||||
this.delivery.peerConnected$.subscribe(() => {
|
||||
void this.retryPending();
|
||||
void this.requestOpenConversationSync();
|
||||
});
|
||||
|
||||
this.delivery.networkRestored$.subscribe(() => {
|
||||
void this.retryPending();
|
||||
void this.requestOpenConversationSync();
|
||||
});
|
||||
|
||||
window.setInterval(() => this.purgeExpiredTypingEntries(), DIRECT_MESSAGE_TYPING_PURGE_MS);
|
||||
}
|
||||
|
||||
async createConversation(user: User): Promise<DirectMessageConversation> {
|
||||
@@ -106,12 +137,47 @@ export class DirectMessageService {
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async createGroupConversation(
|
||||
participants: DirectMessageParticipant[],
|
||||
title?: string,
|
||||
conversationId = `dm-group-${uuidv4()}`
|
||||
): Promise<DirectMessageConversation> {
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const allParticipants = this.uniqueParticipants([currentParticipant, ...participants]);
|
||||
|
||||
await this.loadForOwner(ownerId);
|
||||
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
|
||||
?? await this.repository.getConversation(ownerId, conversationId);
|
||||
|
||||
if (existingConversation) {
|
||||
const mergedConversation = this.mergeConversationParticipants({
|
||||
...existingConversation,
|
||||
kind: 'group',
|
||||
title: existingConversation.title || title
|
||||
}, allParticipants);
|
||||
|
||||
await this.persistConversation(ownerId, mergedConversation);
|
||||
this.selectedConversationIdSignal.set(mergedConversation.id);
|
||||
return mergedConversation;
|
||||
}
|
||||
|
||||
const conversation = createGroupConversation(conversationId, allParticipants, Date.now(), title);
|
||||
|
||||
await this.persistConversation(ownerId, conversation);
|
||||
this.selectedConversationIdSignal.set(conversation.id);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async openConversation(conversationId: string): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
|
||||
await this.loadForOwner(ownerId);
|
||||
this.selectedConversationIdSignal.set(conversationId);
|
||||
await this.markRead(conversationId);
|
||||
this.requestConversationSync(conversationId);
|
||||
}
|
||||
|
||||
closeConversationView(conversationId?: string | null): void {
|
||||
@@ -152,10 +218,11 @@ export class DirectMessageService {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const conversation = await this.requireConversation(ownerId, conversationId);
|
||||
const senderId = currentUser.oderId || currentUser.id;
|
||||
const recipientId = conversation.participants.find((participantId) => participantId !== senderId);
|
||||
const recipientIds = this.recipientIdsFor(conversation, senderId);
|
||||
const recipientId = recipientIds[0];
|
||||
|
||||
if (!recipientId) {
|
||||
throw new Error('Direct message conversation has no recipient.');
|
||||
throw new Error('Direct message conversation has no recipients.');
|
||||
}
|
||||
|
||||
const message: DirectMessage = {
|
||||
@@ -163,6 +230,7 @@ export class DirectMessageService {
|
||||
conversationId,
|
||||
senderId,
|
||||
recipientId,
|
||||
recipientIds,
|
||||
content: normalizedContent,
|
||||
timestamp: Date.now(),
|
||||
status: 'QUEUED',
|
||||
@@ -172,7 +240,7 @@ export class DirectMessageService {
|
||||
};
|
||||
|
||||
await this.persistConversation(ownerId, upsertDirectMessage(conversation, message, false));
|
||||
await this.attemptDelivery(ownerId, message);
|
||||
await this.attemptDelivery(ownerId, message, conversation);
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -249,9 +317,8 @@ export class DirectMessageService {
|
||||
requestPeerAvatarSync(conversationId: string): void {
|
||||
const currentUserId = this.getCurrentUserId();
|
||||
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
|
||||
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
|
||||
|
||||
if (peerId) {
|
||||
for (const peerId of this.recipientIdsFor(conversation, currentUserId)) {
|
||||
this.delivery.requestUserAvatar(peerId);
|
||||
}
|
||||
}
|
||||
@@ -321,13 +388,51 @@ export class DirectMessageService {
|
||||
|
||||
for (const messageId of pendingMessageIds) {
|
||||
const message = messages.find((entry) => entry.id === messageId);
|
||||
const conversation = message
|
||||
? this.conversationsSignal().find((entry) => entry.id === message.conversationId)
|
||||
: null;
|
||||
|
||||
if (message) {
|
||||
await this.attemptDelivery(ownerId, message);
|
||||
if (message && conversation) {
|
||||
await this.attemptDelivery(ownerId, message, conversation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typingUsers(conversationId: string | null | undefined): string[] {
|
||||
if (!conversationId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
return this.typingEntriesSignal()
|
||||
.filter((entry) => entry.conversationId === conversationId && entry.expiresAt > now)
|
||||
.map((entry) => entry.displayName);
|
||||
}
|
||||
|
||||
sendTyping(conversationId: string, isTyping = true): void {
|
||||
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
|
||||
const currentUser = this.currentUser();
|
||||
const currentUserId = this.getCurrentUserId();
|
||||
const recipientIds = this.recipientIdsFor(conversation, currentUserId);
|
||||
|
||||
if (!conversation || !currentUser || recipientIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const recipientId of recipientIds) {
|
||||
this.delivery.sendViaWebRTC(recipientId, {
|
||||
type: 'direct-message-typing',
|
||||
directMessageTyping: {
|
||||
conversationId,
|
||||
sender: toDirectMessageParticipant(currentUser),
|
||||
isTyping,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePeerEvent(event: ChatEvent): Promise<void> {
|
||||
if (event.type === 'direct-message' && event.directMessage) {
|
||||
await this.handleIncomingMessage(event.directMessage);
|
||||
@@ -341,18 +446,46 @@ export class DirectMessageService {
|
||||
|
||||
if (event.type === 'direct-message-mutation' && event.directMessageMutation) {
|
||||
await this.handleIncomingMutation(event.directMessageMutation);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'direct-message-typing' && event.directMessageTyping) {
|
||||
this.handleIncomingTyping(event.directMessageTyping);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'direct-message-sync-request' && event.directMessageSyncRequest) {
|
||||
await this.handleIncomingSyncRequest(event.directMessageSyncRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'direct-message-sync' && event.directMessageSync) {
|
||||
await this.handleIncomingSync(event.directMessageSync);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentUser = this.requireCurrentUser();
|
||||
|
||||
if (!directMessageEventIncludesUser(payload, ownerId) || payload.sender.userId === ownerId || payload.message.senderId === ownerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const sender = payload.sender;
|
||||
const conversationId = payload.message.conversationId
|
||||
|| getDirectConversationId(currentParticipant.userId, sender.userId);
|
||||
const participants = this.uniqueParticipants([
|
||||
currentParticipant,
|
||||
sender,
|
||||
...(payload.participants ?? [])
|
||||
]);
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
|
||||
?? createDirectConversation(currentParticipant, sender, payload.message.timestamp);
|
||||
?? (payload.conversationKind === 'group' || participants.length > 2
|
||||
? createGroupConversation(conversationId, participants, payload.message.timestamp, payload.conversationTitle)
|
||||
: createDirectConversation(currentParticipant, sender, payload.message.timestamp));
|
||||
const conversationWithParticipants = this.mergeConversationParticipants(existingConversation, participants);
|
||||
const incomingMessage: DirectMessage = {
|
||||
...payload.message,
|
||||
conversationId,
|
||||
@@ -360,7 +493,7 @@ export class DirectMessageService {
|
||||
};
|
||||
const shouldIncrementUnread = !this.isConversationVisible(conversationId);
|
||||
|
||||
await this.persistConversation(ownerId, upsertDirectMessage(existingConversation, incomingMessage, shouldIncrementUnread));
|
||||
await this.persistConversation(ownerId, upsertDirectMessage(conversationWithParticipants, incomingMessage, shouldIncrementUnread));
|
||||
this.sendStatusUpdate(incomingMessage.senderId, {
|
||||
conversationId,
|
||||
messageId: incomingMessage.id,
|
||||
@@ -384,24 +517,140 @@ export class DirectMessageService {
|
||||
private isConversationVisible(conversationId: string): boolean {
|
||||
const currentUrl = this.router.url.split(/[?#]/, 1)[0];
|
||||
|
||||
if (!currentUrl.startsWith('/dm/')) {
|
||||
if (!currentUrl.startsWith('/dm/') && !currentUrl.startsWith('/pm/')) {
|
||||
if (currentUrl.startsWith('/call/')) {
|
||||
return this.selectedConversationIdSignal() === conversationId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const prefix = currentUrl.startsWith('/pm/') ? '/pm/' : '/dm/';
|
||||
|
||||
try {
|
||||
return decodeURIComponent(currentUrl.slice('/dm/'.length)) === conversationId;
|
||||
return decodeURIComponent(currentUrl.slice(prefix.length)) === conversationId;
|
||||
} catch {
|
||||
return currentUrl.slice('/dm/'.length) === conversationId;
|
||||
return currentUrl.slice(prefix.length) === conversationId;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const conversation = await this.requireConversation(ownerId, payload.conversationId);
|
||||
const conversation = await this.findConversation(ownerId, payload.conversationId);
|
||||
|
||||
if (!conversation || !directMessageConversationIncludesUser(conversation, ownerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.persistConversation(ownerId, this.applyMutation(conversation, payload));
|
||||
}
|
||||
|
||||
private handleIncomingTyping(payload: DirectMessageTypingEventPayload): void {
|
||||
const currentUserId = this.getCurrentUserId();
|
||||
|
||||
if (!currentUserId || payload.sender.userId === currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId);
|
||||
|
||||
if (!conversation
|
||||
|| !directMessageConversationIncludesUser(conversation, currentUserId)
|
||||
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.isTyping) {
|
||||
this.typingEntriesSignal.update((entries) => entries.filter((entry) =>
|
||||
!(entry.conversationId === payload.conversationId && entry.userId === payload.sender.userId)
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEntry: DirectMessageTypingEntry = {
|
||||
conversationId: payload.conversationId,
|
||||
userId: payload.sender.userId,
|
||||
displayName: payload.sender.displayName,
|
||||
expiresAt: Date.now() + DIRECT_MESSAGE_TYPING_TTL_MS
|
||||
};
|
||||
|
||||
this.typingEntriesSignal.update((entries) => [
|
||||
...entries.filter((entry) =>
|
||||
!(entry.conversationId === nextEntry.conversationId && entry.userId === nextEntry.userId)
|
||||
),
|
||||
nextEntry
|
||||
]);
|
||||
}
|
||||
|
||||
private async handleIncomingSyncRequest(payload: DirectMessageSyncRequestEventPayload): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const conversation = await this.findConversation(ownerId, payload.conversationId);
|
||||
|
||||
if (!conversation
|
||||
|| payload.sender.userId === ownerId
|
||||
|| !directMessageConversationIncludesUser(conversation, ownerId)
|
||||
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.delivery.sendViaWebRTC(payload.sender.userId, {
|
||||
type: 'direct-message-sync',
|
||||
directMessageSync: {
|
||||
conversationId: conversation.id,
|
||||
sender: toDirectMessageParticipant(currentUser),
|
||||
participants: Object.values(conversation.participantProfiles),
|
||||
conversationKind: this.conversationKind(conversation),
|
||||
conversationTitle: conversation.title,
|
||||
messages: conversation.messages.slice(-DIRECT_MESSAGE_SYNC_LIMIT),
|
||||
syncedAt: Date.now()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async handleIncomingSync(payload: DirectMessageSyncEventPayload): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
|
||||
if (payload.sender.userId === ownerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!directMessageSyncIncludesUser(payload, ownerId) || !directMessageSyncIncludesUser(payload, payload.sender.userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === payload.conversationId)
|
||||
?? await this.repository.getConversation(ownerId, payload.conversationId)
|
||||
?? (payload.conversationKind === 'group' || payload.participants.length > 2
|
||||
? createGroupConversation(payload.conversationId, [currentParticipant, ...payload.participants], payload.syncedAt, payload.conversationTitle)
|
||||
: createDirectConversation(currentParticipant, payload.sender, payload.syncedAt));
|
||||
const participantProfiles = {
|
||||
...existingConversation.participantProfiles,
|
||||
...Object.fromEntries(payload.participants.map((participant) => [participant.userId, participant])),
|
||||
[currentParticipant.userId]: currentParticipant
|
||||
};
|
||||
const syncBaseConversation: DirectMessageConversation = {
|
||||
...existingConversation,
|
||||
kind: payload.conversationKind ?? existingConversation.kind,
|
||||
title: payload.conversationTitle ?? existingConversation.title,
|
||||
participants: Object.keys(participantProfiles).sort(),
|
||||
participantProfiles
|
||||
};
|
||||
const mergedConversation = payload.messages.reduce<DirectMessageConversation>(
|
||||
(conversation, message) => upsertDirectMessage(conversation, message, false),
|
||||
syncBaseConversation
|
||||
);
|
||||
|
||||
await this.persistConversation(ownerId, mergedConversation);
|
||||
|
||||
if (this.selectedConversationIdSignal() === payload.conversationId) {
|
||||
await this.markRead(payload.conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
private async applyAndSendMutation(
|
||||
conversationId: string,
|
||||
payload: DirectMessageMutationEventPayload
|
||||
@@ -409,11 +658,11 @@ export class DirectMessageService {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const conversation = await this.requireConversation(ownerId, conversationId);
|
||||
const updatedConversation = this.applyMutation(conversation, payload);
|
||||
const recipientId = conversation.participants.find((participantId) => participantId !== ownerId);
|
||||
const recipientIds = this.recipientIdsFor(conversation, ownerId);
|
||||
|
||||
await this.persistConversation(ownerId, updatedConversation);
|
||||
|
||||
if (recipientId) {
|
||||
for (const recipientId of recipientIds) {
|
||||
this.delivery.sendViaWebRTC(recipientId, {
|
||||
type: 'direct-message-mutation',
|
||||
directMessageMutation: payload
|
||||
@@ -474,23 +723,38 @@ export class DirectMessageService {
|
||||
return { ...conversation, messages };
|
||||
}
|
||||
|
||||
private async attemptDelivery(ownerId: string, message: DirectMessage): Promise<void> {
|
||||
private async attemptDelivery(ownerId: string, message: DirectMessage, conversation: DirectMessageConversation): Promise<void> {
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const sent = this.delivery.sendViaWebRTC(message.recipientId, {
|
||||
type: 'direct-message',
|
||||
directMessage: {
|
||||
message,
|
||||
sender: toDirectMessageParticipant(currentUser)
|
||||
}
|
||||
});
|
||||
const recipientIds = this.recipientIdsFor(conversation, ownerId);
|
||||
|
||||
if (!sent) {
|
||||
await this.offlineQueue.enqueue(ownerId, message.id);
|
||||
return;
|
||||
let sentCount = 0;
|
||||
|
||||
for (const recipientId of recipientIds) {
|
||||
if (this.delivery.sendViaWebRTC(recipientId, {
|
||||
type: 'direct-message',
|
||||
directMessage: {
|
||||
message,
|
||||
sender: toDirectMessageParticipant(currentUser),
|
||||
participants: Object.values(conversation.participantProfiles),
|
||||
conversationKind: this.conversationKind(conversation),
|
||||
conversationTitle: conversation.title
|
||||
}
|
||||
})) {
|
||||
sentCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
await this.offlineQueue.markDelivered(ownerId, message.id);
|
||||
await this.updateStatus(message.id, 'SENT');
|
||||
if (sentCount < recipientIds.length) {
|
||||
await this.offlineQueue.enqueue(ownerId, message.id);
|
||||
}
|
||||
|
||||
if (sentCount > 0) {
|
||||
await this.updateStatus(message.id, 'SENT');
|
||||
}
|
||||
|
||||
if (sentCount === recipientIds.length) {
|
||||
await this.offlineQueue.markDelivered(ownerId, message.id);
|
||||
}
|
||||
}
|
||||
|
||||
private sendStatusUpdate(recipientId: string, payload: DirectMessageStatusEventPayload): void {
|
||||
@@ -500,6 +764,52 @@ export class DirectMessageService {
|
||||
});
|
||||
}
|
||||
|
||||
private requestOpenConversationSync(): void {
|
||||
const conversationId = this.selectedConversationIdSignal();
|
||||
|
||||
if (conversationId) {
|
||||
this.requestConversationSync(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
private requestConversationSync(conversationId: string): void {
|
||||
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
|
||||
const currentUser = this.currentUser();
|
||||
const currentUserId = this.getCurrentUserId();
|
||||
const recipientIds = this.recipientIdsFor(conversation, currentUserId);
|
||||
|
||||
if (!conversation || !currentUser || recipientIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
for (const recipientId of recipientIds) {
|
||||
const syncKey = `${conversationId}:${recipientId}`;
|
||||
|
||||
if (now - (this.lastSyncRequestAt.get(syncKey) ?? 0) < DIRECT_MESSAGE_SYNC_REQUEST_COOLDOWN_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.lastSyncRequestAt.set(syncKey, now);
|
||||
|
||||
this.delivery.sendViaWebRTC(recipientId, {
|
||||
type: 'direct-message-sync-request',
|
||||
directMessageSyncRequest: {
|
||||
conversationId,
|
||||
sender: toDirectMessageParticipant(currentUser),
|
||||
requestedAt: Date.now()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private purgeExpiredTypingEntries(): void {
|
||||
const now = Date.now();
|
||||
|
||||
this.typingEntriesSignal.update((entries) => entries.filter((entry) => entry.expiresAt > now));
|
||||
}
|
||||
|
||||
private async loadForOwner(ownerId: string | null): Promise<void> {
|
||||
if (!ownerId) {
|
||||
this.loadedOwnerId = null;
|
||||
@@ -512,10 +822,14 @@ export class DirectMessageService {
|
||||
}
|
||||
|
||||
this.loadedOwnerId = ownerId;
|
||||
this.conversationsSignal.set(await this.repository.loadConversations(ownerId));
|
||||
const conversations = await this.repository.loadConversations(ownerId);
|
||||
|
||||
conversations.forEach((conversation) => this.rememberConversationAttachmentStorage(conversation));
|
||||
this.conversationsSignal.set(conversations);
|
||||
}
|
||||
|
||||
private async persistConversation(ownerId: string, conversation: DirectMessageConversation): Promise<void> {
|
||||
this.rememberConversationAttachmentStorage(conversation);
|
||||
await this.repository.saveConversation(ownerId, conversation);
|
||||
this.conversationsSignal.update((conversations) => {
|
||||
const nextConversations = conversations.filter((entry) => entry.id !== conversation.id);
|
||||
@@ -525,11 +839,57 @@ export class DirectMessageService {
|
||||
});
|
||||
}
|
||||
|
||||
private async requireConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation> {
|
||||
await this.loadForOwner(ownerId);
|
||||
private rememberConversationAttachmentStorage(conversation: DirectMessageConversation): void {
|
||||
const storageContainer = `${DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX}${conversation.id}`;
|
||||
|
||||
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId)
|
||||
?? await this.repository.getConversation(ownerId, conversationId);
|
||||
for (const message of conversation.messages) {
|
||||
this.attachments.rememberMessageRoom(message.id, storageContainer);
|
||||
}
|
||||
}
|
||||
|
||||
private mergeConversationParticipants(
|
||||
conversation: DirectMessageConversation,
|
||||
participants: DirectMessageParticipant[]
|
||||
): DirectMessageConversation {
|
||||
const participantProfiles = {
|
||||
...conversation.participantProfiles,
|
||||
...Object.fromEntries(participants.map((participant) => [participant.userId, participant]))
|
||||
};
|
||||
|
||||
return {
|
||||
...conversation,
|
||||
participants: Object.keys(participantProfiles).sort(),
|
||||
participantProfiles
|
||||
};
|
||||
}
|
||||
|
||||
private recipientIdsFor(conversation: DirectMessageConversation | null | undefined, currentUserId: string | null | undefined): string[] {
|
||||
if (!conversation || !currentUserId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return conversation.participants.filter((participantId) => participantId !== currentUserId);
|
||||
}
|
||||
|
||||
private conversationKind(conversation: DirectMessageConversation): 'direct' | 'group' {
|
||||
return isGroupDirectConversation(conversation) ? 'group' : 'direct';
|
||||
}
|
||||
|
||||
private uniqueParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return participants.filter((participant) => {
|
||||
if (!participant.userId || seen.has(participant.userId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(participant.userId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private async requireConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation> {
|
||||
const conversation = await this.findConversation(ownerId, conversationId);
|
||||
|
||||
if (!conversation) {
|
||||
throw new Error('Direct message conversation not found.');
|
||||
@@ -538,6 +898,13 @@ export class DirectMessageService {
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private async findConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation | null> {
|
||||
await this.loadForOwner(ownerId);
|
||||
|
||||
return this.conversationsSignal().find((entry) => entry.id === conversationId)
|
||||
?? await this.repository.getConversation(ownerId, conversationId);
|
||||
}
|
||||
|
||||
private requireCurrentUser(): User {
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
|
||||
@@ -22,7 +22,19 @@ export class PeerDeliveryService {
|
||||
this.webrtc.onMessageReceived,
|
||||
this.webrtc.onSignalingMessage as Observable<ChatEvent>
|
||||
).pipe(
|
||||
filter((event) => event.type === 'direct-message' || event.type === 'direct-message-status' || event.type === 'direct-message-mutation')
|
||||
filter((event) => event.type === 'direct-message'
|
||||
|| event.type === 'direct-message-status'
|
||||
|| event.type === 'direct-message-mutation'
|
||||
|| event.type === 'direct-message-typing'
|
||||
|| event.type === 'direct-message-sync-request'
|
||||
|| event.type === 'direct-message-sync')
|
||||
);
|
||||
|
||||
readonly directCallEvents$: Observable<ChatEvent> = merge(
|
||||
this.webrtc.onMessageReceived,
|
||||
this.webrtc.onSignalingMessage as Observable<ChatEvent>
|
||||
).pipe(
|
||||
filter((event) => event.type === 'direct-call')
|
||||
);
|
||||
|
||||
readonly peerConnected$ = this.webrtc.onPeerConnected;
|
||||
@@ -60,6 +72,10 @@ export class PeerDeliveryService {
|
||||
});
|
||||
}
|
||||
|
||||
sendCallEvent(recipientId: string, event: ChatEvent): boolean {
|
||||
return this.sendViaWebRTC(recipientId, event);
|
||||
}
|
||||
|
||||
syncOnReconnect(onReconnect: () => void): void {
|
||||
this.peerConnected$.subscribe(() => onReconnect());
|
||||
}
|
||||
@@ -84,7 +100,15 @@ export class PeerDeliveryService {
|
||||
}
|
||||
|
||||
private sendViaSignaling(recipientId: string, event: ChatEvent): boolean {
|
||||
if (event.type !== 'direct-message' && event.type !== 'direct-message-status' && event.type !== 'direct-message-mutation') {
|
||||
if (
|
||||
event.type !== 'direct-message'
|
||||
&& event.type !== 'direct-message-status'
|
||||
&& event.type !== 'direct-message-mutation'
|
||||
&& event.type !== 'direct-message-typing'
|
||||
&& event.type !== 'direct-message-sync-request'
|
||||
&& event.type !== 'direct-message-sync'
|
||||
&& event.type !== 'direct-call'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type {
|
||||
DirectMessage,
|
||||
DirectMessageConversation,
|
||||
DirectMessageEventPayload,
|
||||
DirectMessageParticipant,
|
||||
DirectMessageSyncEventPayload,
|
||||
DirectMessageStatus
|
||||
} from '../models/direct-message.model';
|
||||
|
||||
@@ -37,6 +39,7 @@ export function createDirectConversation(
|
||||
|
||||
return {
|
||||
id: getDirectConversationId(currentUser.userId, peer.userId),
|
||||
kind: 'direct',
|
||||
participants,
|
||||
participantProfiles: {
|
||||
[currentUser.userId]: currentUser,
|
||||
@@ -48,6 +51,52 @@ export function createDirectConversation(
|
||||
};
|
||||
}
|
||||
|
||||
export function createGroupConversation(
|
||||
conversationId: string,
|
||||
participants: DirectMessageParticipant[],
|
||||
now: number,
|
||||
title?: string
|
||||
): DirectMessageConversation {
|
||||
const uniqueParticipants = uniqueDirectMessageParticipants(participants);
|
||||
const participantIds = uniqueParticipants.map((participant) => participant.userId).sort();
|
||||
|
||||
return {
|
||||
id: conversationId,
|
||||
kind: 'group',
|
||||
title: title || buildGroupConversationTitle(uniqueParticipants),
|
||||
participants: participantIds,
|
||||
participantProfiles: Object.fromEntries(uniqueParticipants.map((participant) => [participant.userId, participant])),
|
||||
messages: [],
|
||||
lastMessageAt: now,
|
||||
unreadCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
export function isGroupDirectConversation(conversation: DirectMessageConversation): boolean {
|
||||
return conversation.kind === 'group' || conversation.participants.length > 2;
|
||||
}
|
||||
|
||||
export function directMessageConversationIncludesUser(
|
||||
conversation: Pick<DirectMessageConversation, 'participantProfiles' | 'participants'>,
|
||||
userId: string
|
||||
): boolean {
|
||||
return conversation.participants.includes(userId) || !!conversation.participantProfiles[userId];
|
||||
}
|
||||
|
||||
export function directMessageEventIncludesUser(
|
||||
payload: DirectMessageEventPayload,
|
||||
userId: string
|
||||
): boolean {
|
||||
return collectDirectMessageEventParticipantIds(payload).has(userId);
|
||||
}
|
||||
|
||||
export function directMessageSyncIncludesUser(
|
||||
payload: DirectMessageSyncEventPayload,
|
||||
userId: string
|
||||
): boolean {
|
||||
return payload.participants.some((participant) => participant.userId === userId);
|
||||
}
|
||||
|
||||
export function upsertDirectMessage(
|
||||
conversation: DirectMessageConversation,
|
||||
message: DirectMessage,
|
||||
@@ -89,3 +138,50 @@ export function updateMessageStatusInConversation(
|
||||
|
||||
return { ...conversation, messages };
|
||||
}
|
||||
|
||||
function uniqueDirectMessageParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return participants.filter((participant) => {
|
||||
if (!participant.userId || seen.has(participant.userId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(participant.userId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function collectDirectMessageEventParticipantIds(payload: DirectMessageEventPayload): Set<string> {
|
||||
const participantIds = new Set<string>();
|
||||
|
||||
if (payload.message.senderId) {
|
||||
participantIds.add(payload.message.senderId);
|
||||
}
|
||||
|
||||
if (payload.message.recipientId) {
|
||||
participantIds.add(payload.message.recipientId);
|
||||
}
|
||||
|
||||
for (const recipientId of payload.message.recipientIds ?? []) {
|
||||
participantIds.add(recipientId);
|
||||
}
|
||||
|
||||
for (const participant of payload.participants ?? []) {
|
||||
if (participant.userId) {
|
||||
participantIds.add(participant.userId);
|
||||
}
|
||||
}
|
||||
|
||||
return participantIds;
|
||||
}
|
||||
|
||||
function buildGroupConversationTitle(participants: DirectMessageParticipant[]): string {
|
||||
const names = participants.map((participant) => participant.displayName || participant.username || participant.userId);
|
||||
|
||||
if (names.length <= 3) {
|
||||
return names.join(', ');
|
||||
}
|
||||
|
||||
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
import type { DirectMessage, DirectMessageParticipant } from '../../../../shared-kernel';
|
||||
|
||||
export type DirectMessageConversationKind = 'direct' | 'group';
|
||||
|
||||
export type {
|
||||
DirectMessage,
|
||||
DirectMessageEventPayload,
|
||||
DirectMessageMutationEventPayload,
|
||||
DirectMessageParticipant,
|
||||
DirectMessageSyncEventPayload,
|
||||
DirectMessageSyncRequestEventPayload,
|
||||
DirectMessageStatus,
|
||||
DirectMessageStatusEventPayload
|
||||
DirectMessageStatusEventPayload,
|
||||
DirectMessageTypingEventPayload
|
||||
} from '../../../../shared-kernel';
|
||||
|
||||
export interface DirectMessageConversation {
|
||||
id: string;
|
||||
kind?: DirectMessageConversationKind;
|
||||
title?: string;
|
||||
participants: string[];
|
||||
participantProfiles: Record<string, DirectMessageParticipant>;
|
||||
messages: DirectMessage[];
|
||||
|
||||
@@ -13,10 +13,25 @@
|
||||
[showStatusBadge]="true"
|
||||
size="md"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
|
||||
<p class="text-xs text-muted-foreground">Direct Message</p>
|
||||
<p class="text-xs text-muted-foreground">{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}</p>
|
||||
</div>
|
||||
@if (showCallButton() && conversation()) {
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-9 w-9 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||
[disabled]="!canCallConversation()"
|
||||
[attr.aria-label]="'Call ' + peerName()"
|
||||
[title]="'Call ' + peerName()"
|
||||
(click)="callConversation()"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="peerCallIcon()"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (conversation()) {
|
||||
@@ -58,6 +73,15 @@
|
||||
appThemeNode="chatComposerBar"
|
||||
class="chat-bottom-bar absolute bottom-0 left-0 right-2 z-10 bg-background/85 backdrop-blur-md"
|
||||
>
|
||||
@if (typingUsers().length > 0) {
|
||||
<div
|
||||
data-testid="dm-typing-indicator"
|
||||
class="px-4 pb-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ typingUsers().join(', ') }} {{ typingUsers().length === 1 ? 'is' : 'are' }} typing...
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-chat-message-composer
|
||||
[replyTo]="replyTo()"
|
||||
[showKlipyGifPicker]="showGifPicker()"
|
||||
@@ -65,6 +89,7 @@
|
||||
[klipySignalSource]="null"
|
||||
[textareaTestId]="'dm-input'"
|
||||
(messageSubmitted)="handleMessageSubmitted($event)"
|
||||
(typingStarted)="handleTypingStarted()"
|
||||
(replyCleared)="clearReply()"
|
||||
(heightChanged)="composerBottomPadding.set($event + 20)"
|
||||
(klipyGifPickerToggleRequested)="toggleGifPicker()"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
effect,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
@@ -15,10 +16,13 @@ import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { map } from 'rxjs';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
import { DirectCallService } from '../../../direct-call';
|
||||
import { Attachment, AttachmentFacade } from '../../../attachment';
|
||||
import { ThemeNodeDirective } from '../../../theme';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide';
|
||||
import {
|
||||
ChatMessageComposerSubmitEvent,
|
||||
ChatMessageComposerComponent,
|
||||
@@ -57,9 +61,11 @@ interface DmStatusLabel {
|
||||
ChatMessageListComponent,
|
||||
ChatMessageOverlaysComponent,
|
||||
KlipyGifPickerComponent,
|
||||
NgIcon,
|
||||
ThemeNodeDirective,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucidePhone, lucidePhoneCall })],
|
||||
templateUrl: './dm-chat.component.html',
|
||||
host: {
|
||||
class: 'block h-full'
|
||||
@@ -74,10 +80,15 @@ export class DmChatComponent {
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly linkMetadata = inject(LinkMetadataService);
|
||||
private readonly metadataRequestKeys = new Set<string>();
|
||||
private openedConversationId: string | null = null;
|
||||
readonly directCalls = inject(DirectCallService);
|
||||
readonly directMessages = inject(DirectMessageService);
|
||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||
readonly showGifPicker = signal(false);
|
||||
readonly conversationId = input<string | null>(null);
|
||||
readonly showCallButton = input(true);
|
||||
readonly composerBottomPadding = signal(140);
|
||||
readonly gifPickerAnchorRight = signal(16);
|
||||
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
|
||||
@@ -87,15 +98,26 @@ export class DmChatComponent {
|
||||
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
||||
});
|
||||
readonly effectiveConversationId = computed(() => this.conversationId() ?? this.routeConversationId());
|
||||
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
|
||||
readonly conversation = this.directMessages.selectedConversation;
|
||||
readonly klipyEnabled = computed(() => this.klipy.isEnabled(null));
|
||||
readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none');
|
||||
readonly typingUsers = computed(() => {
|
||||
void this.directMessages.typingEntries();
|
||||
|
||||
return this.directMessages.typingUsers(this.conversation()?.id);
|
||||
});
|
||||
readonly peerUser = computed(() => {
|
||||
const conversation = this.conversation();
|
||||
|
||||
return conversation ? this.peerUserFor(conversation) : null;
|
||||
});
|
||||
readonly isGroupConversation = computed(() => {
|
||||
const conversation = this.conversation();
|
||||
|
||||
return !!conversation && (conversation.kind === 'group' || conversation.participants.length > 2);
|
||||
});
|
||||
readonly participantUsers = computed<User[]>(() => {
|
||||
const conversation = this.conversation();
|
||||
const knownUsers = this.allUsers();
|
||||
@@ -173,22 +195,57 @@ export class DmChatComponent {
|
||||
readonly peerName = computed(() => {
|
||||
const conversation = this.conversation();
|
||||
const currentUserId = this.currentUserId();
|
||||
|
||||
if (conversation && this.isGroupConversation()) {
|
||||
return conversation.title || this.groupConversationTitle(conversation);
|
||||
}
|
||||
|
||||
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
|
||||
|
||||
return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
|
||||
});
|
||||
readonly peerCallIcon = computed(() => {
|
||||
const conversation = this.conversation();
|
||||
|
||||
if (conversation && this.isGroupConversation()) {
|
||||
return this.directCalls.isCallingConversation(conversation.id) ? 'lucidePhoneCall' : 'lucidePhone';
|
||||
}
|
||||
|
||||
const peer = this.peerUser();
|
||||
|
||||
return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone';
|
||||
});
|
||||
readonly canCallConversation = computed(() => {
|
||||
const conversation = this.conversation();
|
||||
|
||||
if (!conversation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isGroupConversation()) {
|
||||
return conversation.participants.some((participantId) => participantId !== this.currentUserId());
|
||||
}
|
||||
|
||||
return !!this.peerUser();
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const conversationId = this.routeConversationId();
|
||||
const conversationId = this.effectiveConversationId();
|
||||
|
||||
if (conversationId) {
|
||||
if (!conversationId) {
|
||||
this.openedConversationId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (conversationId !== this.openedConversationId) {
|
||||
this.openedConversationId = conversationId;
|
||||
void this.directMessages.openConversation(conversationId);
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
void this.routeConversationId();
|
||||
void this.effectiveConversationId();
|
||||
void this.klipy.refreshAvailability(null);
|
||||
});
|
||||
|
||||
@@ -226,11 +283,28 @@ export class DmChatComponent {
|
||||
this.replyTo.set(null);
|
||||
|
||||
if (event.pendingFiles.length > 0) {
|
||||
this.attachments.rememberMessageRoom(message.id, `direct-message:${conversation.id}`);
|
||||
this.attachments.publishAttachments(message.id, event.pendingFiles, this.currentUserId() || undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleTypingStarted(): void {
|
||||
const conversationId = this.conversation()?.id;
|
||||
|
||||
if (conversationId) {
|
||||
this.directMessages.sendTyping(conversationId, true);
|
||||
}
|
||||
}
|
||||
|
||||
async callConversation(): Promise<void> {
|
||||
const conversation = this.conversation();
|
||||
|
||||
if (conversation && this.canCallConversation()) {
|
||||
await this.directCalls.startConversationCall(conversation);
|
||||
}
|
||||
}
|
||||
|
||||
setReplyTo(message: ChatMessageReplyEvent): void {
|
||||
this.replyTo.set(message);
|
||||
}
|
||||
@@ -325,6 +399,20 @@ export class DmChatComponent {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (electronApi) {
|
||||
const diskPath = this.getAttachmentDiskPath(attachment);
|
||||
|
||||
if (diskPath && electronApi.saveExistingFileAs) {
|
||||
try {
|
||||
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
|
||||
|
||||
if (result.saved || result.cancelled) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall back to blob/browser download */
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await this.getAttachmentBlob(attachment);
|
||||
|
||||
if (blob) {
|
||||
@@ -391,12 +479,16 @@ export class DmChatComponent {
|
||||
continue;
|
||||
}
|
||||
|
||||
const urls = this.linkMetadata.extractUrls(message.content).filter((url) => !hasDedicatedChatEmbed(url));
|
||||
const urls = this.linkMetadata.extractUrls(message.content)
|
||||
.filter((url) => !hasDedicatedChatEmbed(url))
|
||||
.filter((url) => !this.metadataRequestKeys.has(this.metadataRequestKey(message.id, url)));
|
||||
|
||||
if (urls.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
urls.forEach((url) => this.metadataRequestKeys.add(this.metadataRequestKey(message.id, url)));
|
||||
|
||||
const metadata = (await this.linkMetadata.fetchAllMetadata(urls)).filter((entry) => !entry.failed);
|
||||
|
||||
if (metadata.length === 0) {
|
||||
@@ -410,11 +502,19 @@ export class DmChatComponent {
|
||||
}
|
||||
}
|
||||
|
||||
private metadataRequestKey(messageId: string, url: string): string {
|
||||
return `${messageId}:${url}`;
|
||||
}
|
||||
|
||||
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
|
||||
if (!attachment.objectUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (attachment.objectUrl.startsWith('file:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(attachment.objectUrl);
|
||||
|
||||
@@ -424,6 +524,10 @@ export class DmChatComponent {
|
||||
}
|
||||
}
|
||||
|
||||
private getAttachmentDiskPath(attachment: Attachment): string | null {
|
||||
return attachment.savedPath || attachment.filePath || null;
|
||||
}
|
||||
|
||||
private blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -445,6 +549,10 @@ export class DmChatComponent {
|
||||
}
|
||||
|
||||
private peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null {
|
||||
if (conversation.kind === 'group' || conversation.participants.length > 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentUserId = this.currentUserId();
|
||||
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
|
||||
|
||||
@@ -454,4 +562,16 @@ export class DmChatComponent {
|
||||
|
||||
return this.participantUsers().find((user) => user.id === peerId || user.oderId === peerId) ?? null;
|
||||
}
|
||||
|
||||
private groupConversationTitle(conversation: NonNullable<ReturnType<typeof this.conversation>>): string {
|
||||
const names = conversation.participants
|
||||
.filter((participantId) => participantId !== this.currentUserId())
|
||||
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
|
||||
|
||||
if (names.length <= 3) {
|
||||
return names.join(', ');
|
||||
}
|
||||
|
||||
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,11 @@
|
||||
[class.dm-rail-slide-out]="item.isExiting"
|
||||
[class.pointer-events-none]="item.isExiting"
|
||||
[ngClass]="isSelectedItem(item) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"
|
||||
[attr.data-testid]="'dm-rail-item-' + item.id"
|
||||
[title]="item.label"
|
||||
[attr.aria-current]="isSelectedItem(item) ? 'page' : null"
|
||||
(click)="openItem(item)"
|
||||
(contextmenu)="openContextMenu($event, item)"
|
||||
>
|
||||
<div class="h-full w-full overflow-hidden rounded-[inherit]">
|
||||
@if (item.avatarUrl) {
|
||||
@@ -58,7 +60,7 @@
|
||||
class="absolute -bottom-1 -right-1 grid h-4 w-4 place-items-center rounded-full bg-secondary text-muted-foreground shadow-sm ring-2 ring-card"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUser"
|
||||
[name]="iconFor(item)"
|
||||
class="h-2.5 w-2.5"
|
||||
/>
|
||||
</span>
|
||||
@@ -72,3 +74,24 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (contextMenu(); as menu) {
|
||||
<app-context-menu
|
||||
[x]="menu.x"
|
||||
[y]="menu.y"
|
||||
width="w-44"
|
||||
(closed)="closeContextMenu()"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="context-menu-item-icon-danger"
|
||||
(click)="forgetContextItem()"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="forgetContextIcon(menu.item)"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{{ forgetContextLabel(menu.item) }}
|
||||
</button>
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
@@ -12,8 +12,15 @@ import { CommonModule } from '@angular/common';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMessageCircle, lucideUser } from '@ng-icons/lucide';
|
||||
import {
|
||||
lucideLogOut,
|
||||
lucideMessageCircle,
|
||||
lucideTrash2,
|
||||
lucideUser,
|
||||
lucideUsers
|
||||
} from '@ng-icons/lucide';
|
||||
import { filter, map } from 'rxjs';
|
||||
import { ContextMenuComponent } from '../../../../shared';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { FriendService } from '../../application/services/friend.service';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
@@ -30,13 +37,23 @@ interface DmRailItem {
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
interface DmRailContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
item: DmRailItem;
|
||||
}
|
||||
|
||||
const EXIT_ANIMATION_MS = 160;
|
||||
|
||||
@Component({
|
||||
selector: 'app-dm-rail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideMessageCircle, lucideUser })],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ContextMenuComponent,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideLogOut, lucideMessageCircle, lucideTrash2, lucideUser, lucideUsers })],
|
||||
templateUrl: './dm-rail.component.html',
|
||||
styleUrl: './dm-rail.component.scss'
|
||||
})
|
||||
@@ -60,11 +77,29 @@ export class DmRailComponent implements OnDestroy {
|
||||
this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId()
|
||||
));
|
||||
readonly railItems = signal<DmRailItem[]>([]);
|
||||
readonly contextMenu = signal<DmRailContextMenuState | null>(null);
|
||||
readonly unreadRailItems = computed<DmRailItem[]>(() => {
|
||||
const currentUserId = this.currentUserId();
|
||||
const items = new Map<string, DmRailItem>();
|
||||
|
||||
for (const conversation of this.directMessages.conversations()) {
|
||||
if (conversation.unreadCount === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.isGroupConversation(conversation)) {
|
||||
items.set(conversation.id, {
|
||||
id: conversation.id,
|
||||
label: this.titleFor(conversation),
|
||||
conversation,
|
||||
isExiting: false,
|
||||
user: null,
|
||||
unreadCount: conversation.unreadCount
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
|
||||
|
||||
if (!peerId) {
|
||||
@@ -103,7 +138,7 @@ export class DmRailComponent implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(items.values()).filter((item) => item.unreadCount > 0);
|
||||
return Array.from(items.values()).filter((item) => item.conversation && item.unreadCount > 0);
|
||||
});
|
||||
readonly isOnDirectMessages = toSignal(
|
||||
this.router.events.pipe(
|
||||
@@ -140,6 +175,8 @@ export class DmRailComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
async openItem(item: DmRailItem): Promise<void> {
|
||||
this.closeContextMenu();
|
||||
|
||||
if (item.conversation) {
|
||||
await this.openConversation(item.conversation);
|
||||
return;
|
||||
@@ -155,6 +192,10 @@ export class DmRailComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
titleFor(conversation: DirectMessageConversation): string {
|
||||
if (this.isGroupConversation(conversation)) {
|
||||
return conversation.title || this.groupConversationTitle(conversation);
|
||||
}
|
||||
|
||||
const peerId = conversation.participants.find((participantId) => participantId !== this.currentUserId());
|
||||
|
||||
return peerId ? conversation.participantProfiles[peerId]?.displayName || peerId : 'DM';
|
||||
@@ -184,6 +225,51 @@ export class DmRailComponent implements OnDestroy {
|
||||
return !!item.conversation && this.isSelectedConversation(item.conversation);
|
||||
}
|
||||
|
||||
iconFor(item: DmRailItem): string {
|
||||
return item.conversation && this.isGroupConversation(item.conversation) ? 'lucideUsers' : 'lucideUser';
|
||||
}
|
||||
|
||||
openContextMenu(event: MouseEvent, item: DmRailItem): void {
|
||||
if (!item.conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.contextMenu.set({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
item
|
||||
});
|
||||
}
|
||||
|
||||
closeContextMenu(): void {
|
||||
this.contextMenu.set(null);
|
||||
}
|
||||
|
||||
async forgetContextItem(): Promise<void> {
|
||||
const item = this.contextMenu()?.item;
|
||||
|
||||
if (!item?.conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.directMessages.forgetConversation(item.conversation.id);
|
||||
this.closeContextMenu();
|
||||
|
||||
if (this.isSelectedConversation(item.conversation)) {
|
||||
await this.router.navigate(['/dm']);
|
||||
}
|
||||
}
|
||||
|
||||
forgetContextLabel(item: DmRailItem): string {
|
||||
return item.conversation && this.isGroupConversation(item.conversation) ? 'Leave chat' : 'Forget chat';
|
||||
}
|
||||
|
||||
forgetContextIcon(item: DmRailItem): string {
|
||||
return item.conversation && this.isGroupConversation(item.conversation) ? 'lucideLogOut' : 'lucideTrash2';
|
||||
}
|
||||
|
||||
formatUnreadCount(count: number): string {
|
||||
return count > 99 ? '99+' : String(count);
|
||||
}
|
||||
@@ -227,4 +313,20 @@ export class DmRailComponent implements OnDestroy {
|
||||
|
||||
this.railItems.set(nextItems);
|
||||
}
|
||||
|
||||
private isGroupConversation(conversation: DirectMessageConversation): boolean {
|
||||
return conversation.kind === 'group' || conversation.participants.length > 2;
|
||||
}
|
||||
|
||||
private groupConversationTitle(conversation: DirectMessageConversation): string {
|
||||
const names = conversation.participants
|
||||
.filter((participantId) => participantId !== this.currentUserId())
|
||||
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
|
||||
|
||||
if (names.length <= 3) {
|
||||
return names.join(', ');
|
||||
}
|
||||
|
||||
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<main
|
||||
appThemeNode="dmChatPanel"
|
||||
class="relative min-h-0 min-w-0 overflow-hidden bg-background"
|
||||
[ngStyle]="chatPanelStyles()"
|
||||
>
|
||||
<app-dm-chat />
|
||||
</main>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ThemeNodeDirective, ThemeService } from '../../../theme';
|
||||
import { DmChatComponent } from '../dm-chat/dm-chat.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dm-chat-panel',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ThemeNodeDirective,
|
||||
DmChatComponent
|
||||
],
|
||||
host: { class: 'contents' },
|
||||
templateUrl: './dm-chat-panel.component.html'
|
||||
})
|
||||
export class DmChatPanelComponent {
|
||||
private readonly theme = inject(ThemeService);
|
||||
|
||||
readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel'));
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<div
|
||||
appThemeNode="dmConversationItem"
|
||||
class="group flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
|
||||
[class.bg-primary/10]="isSelected()"
|
||||
[class.text-foreground]="isSelected()"
|
||||
[attr.aria-current]="isSelected() ? 'page' : null"
|
||||
(click)="openConversation()"
|
||||
(keydown.enter)="openConversation()"
|
||||
(keydown.space)="openConversation()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="peerName()"
|
||||
[avatarUrl]="peerAvatarUrl()"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="truncate text-sm font-medium text-foreground">{{ peerName() }}</p>
|
||||
@if (conversation().unreadCount > 0) {
|
||||
<span class="rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-semibold text-black">
|
||||
{{ formatUnreadCount(conversation().unreadCount) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ lastMessagePreview() }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="invisible grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition hover:bg-emerald-500/10 hover:text-emerald-600 focus:visible focus:opacity-100 group-focus-within:visible group-focus-within:opacity-100 group-hover:visible group-hover:opacity-100 disabled:group-focus-within:opacity-30 disabled:group-hover:opacity-30"
|
||||
[disabled]="!canCall()"
|
||||
[attr.aria-label]="'Call ' + peerName()"
|
||||
[title]="'Call ' + peerName()"
|
||||
(click)="callConversationPeer($event)"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="callIcon()"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition hover:bg-destructive/10 hover:text-destructive focus:opacity-100 group-hover:opacity-100"
|
||||
[attr.aria-label]="'Forget ' + peerName()"
|
||||
[title]="'Forget ' + peerName()"
|
||||
(click)="forgetConversation($event)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,216 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucidePhone,
|
||||
lucidePhoneCall,
|
||||
lucideTrash2
|
||||
} from '@ng-icons/lucide';
|
||||
import { map } from 'rxjs';
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
import { ThemeNodeDirective } from '../../../theme';
|
||||
import { AttachmentFacade } from '../../../attachment';
|
||||
import { DirectCallService } from '../../../direct-call';
|
||||
import { selectAllUsers } from '../../../../store/users/users.selectors';
|
||||
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
|
||||
import type { Attachment } from '../../../attachment';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dm-conversation-item',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [provideIcons({ lucidePhone, lucidePhoneCall, lucideTrash2 })],
|
||||
host: { class: 'block' },
|
||||
templateUrl: './dm-conversation-item.component.html'
|
||||
})
|
||||
export class DmConversationItemComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
private readonly directMessages = inject(DirectMessageService);
|
||||
private readonly directCalls = inject(DirectCallService);
|
||||
readonly conversation = input.required<DirectMessageConversation>();
|
||||
readonly users = this.store.selectSignal(selectAllUsers);
|
||||
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
||||
});
|
||||
readonly isSelected = computed(() => this.routeConversationId() === this.conversation().id);
|
||||
readonly peerName = computed(() => this.resolvePeerName(this.conversation()));
|
||||
readonly peerAvatarUrl = computed(() => this.resolvePeerAvatarUrl(this.conversation()));
|
||||
readonly lastMessagePreview = computed(() => this.resolveLastMessagePreview(this.conversation()));
|
||||
readonly canCall = computed(() => this.canCallConversation(this.conversation()));
|
||||
readonly callIcon = computed(() => this.conversationCallIcon(this.conversation()));
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const conversation = this.conversation();
|
||||
const peer = this.peerUser(conversation, this.users());
|
||||
|
||||
if (!peer?.avatarUrl) {
|
||||
this.directMessages.requestPeerAvatarSync(conversation.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openConversation(): void {
|
||||
void this.router.navigate(['/dm', this.conversation().id]);
|
||||
}
|
||||
|
||||
async forgetConversation(event: Event): Promise<void> {
|
||||
event.stopPropagation();
|
||||
const conversation = this.conversation();
|
||||
const conversations = this.directMessages.conversations();
|
||||
const nextConversation = conversations.find((entry) => entry.id !== conversation.id) ?? null;
|
||||
|
||||
await this.directMessages.forgetConversation(conversation.id);
|
||||
|
||||
if (this.routeConversationId() === conversation.id) {
|
||||
await this.router.navigate(nextConversation ? ['/dm', nextConversation.id] : ['/dm']);
|
||||
}
|
||||
}
|
||||
|
||||
async callConversationPeer(event: Event): Promise<void> {
|
||||
event.stopPropagation();
|
||||
await this.directCalls.startConversationCall(this.conversation());
|
||||
}
|
||||
|
||||
formatUnreadCount(count: number): string {
|
||||
return count > 99 ? '99+' : String(count);
|
||||
}
|
||||
|
||||
private resolvePeerName(conversation: DirectMessageConversation): string {
|
||||
if (this.isGroupConversation(conversation)) {
|
||||
return conversation.title || this.groupConversationTitle(conversation);
|
||||
}
|
||||
|
||||
const peerId = this.peerId(conversation);
|
||||
const knownUser = this.peerUser(conversation);
|
||||
|
||||
return peerId ? knownUser?.displayName || conversation.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
|
||||
}
|
||||
|
||||
private resolvePeerAvatarUrl(conversation: DirectMessageConversation): string | undefined {
|
||||
if (this.isGroupConversation(conversation)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const peerId = this.peerId(conversation);
|
||||
const knownUser = this.peerUser(conversation);
|
||||
|
||||
return peerId ? knownUser?.avatarUrl || conversation.participantProfiles[peerId]?.avatarUrl : undefined;
|
||||
}
|
||||
|
||||
private resolveLastMessagePreview(conversation: DirectMessageConversation): string {
|
||||
const lastMessage = conversation.messages.at(-1);
|
||||
|
||||
if (!lastMessage) {
|
||||
return 'No messages yet';
|
||||
}
|
||||
|
||||
if (lastMessage.isDeleted) {
|
||||
return 'Message deleted';
|
||||
}
|
||||
|
||||
if (this.isKlipyGif(lastMessage.content)) {
|
||||
return 'Sent a GIF';
|
||||
}
|
||||
|
||||
this.attachments.updated();
|
||||
const attachments = this.attachments.getForMessage(lastMessage.id);
|
||||
|
||||
if (attachments.length > 0) {
|
||||
return this.attachmentPreview(attachments);
|
||||
}
|
||||
|
||||
return lastMessage.content || 'Attachment';
|
||||
}
|
||||
|
||||
private conversationCallIcon(conversation: DirectMessageConversation): string {
|
||||
if (this.isGroupConversation(conversation)) {
|
||||
return this.directCalls.isCallingConversation(conversation.id) ? 'lucidePhoneCall' : 'lucidePhone';
|
||||
}
|
||||
|
||||
const peer = this.peerUser(conversation);
|
||||
|
||||
return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone';
|
||||
}
|
||||
|
||||
private canCallConversation(conversation: DirectMessageConversation): boolean {
|
||||
if (this.isGroupConversation(conversation)) {
|
||||
return conversation.participants.some((participantId) => participantId !== this.directMessages.currentUserId());
|
||||
}
|
||||
|
||||
return !!this.peerUser(conversation);
|
||||
}
|
||||
|
||||
private peerId(conversation: DirectMessageConversation): string | undefined {
|
||||
const currentUserId = this.directMessages.currentUserId();
|
||||
|
||||
return conversation.participants.find((participantId) => participantId !== currentUserId);
|
||||
}
|
||||
|
||||
private peerUser(conversation: DirectMessageConversation, users = this.users()): User | undefined {
|
||||
if (this.isGroupConversation(conversation)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const peerId = this.peerId(conversation);
|
||||
|
||||
return peerId ? users.find((user) => user.id === peerId || user.oderId === peerId) : undefined;
|
||||
}
|
||||
|
||||
private isGroupConversation(conversation: DirectMessageConversation): boolean {
|
||||
return conversation.kind === 'group' || conversation.participants.length > 2;
|
||||
}
|
||||
|
||||
private groupConversationTitle(conversation: DirectMessageConversation): string {
|
||||
const currentUserId = this.directMessages.currentUserId();
|
||||
const names = conversation.participants
|
||||
.filter((participantId) => participantId !== currentUserId)
|
||||
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
|
||||
|
||||
if (names.length <= 3) {
|
||||
return names.join(', ');
|
||||
}
|
||||
|
||||
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
|
||||
}
|
||||
|
||||
private isKlipyGif(content: string): boolean {
|
||||
return /!\[KLIPY GIF\]\([^)]*static\.klipy\.com[^)]*\)/i.test(content.trim());
|
||||
}
|
||||
|
||||
private attachmentPreview(attachments: Attachment[]): string {
|
||||
if (attachments.some((attachment) => attachment.mime.startsWith('image/'))) {
|
||||
return 'Sent an image';
|
||||
}
|
||||
|
||||
if (attachments.some((attachment) => attachment.mime.startsWith('video/'))) {
|
||||
return 'Sent a video';
|
||||
}
|
||||
|
||||
if (attachments.some((attachment) => attachment.mime.startsWith('audio/'))) {
|
||||
return 'Sent audio';
|
||||
}
|
||||
|
||||
return attachments.length === 1 ? 'Sent an attachment' : 'Sent attachments';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<aside
|
||||
appThemeNode="dmConversationsPanel"
|
||||
class="flex min-h-0 overflow-hidden border-r border-border bg-card"
|
||||
[ngStyle]="listPanelStyles()"
|
||||
>
|
||||
<section class="flex h-full w-full min-w-0 flex-col">
|
||||
<header
|
||||
appThemeNode="dmConversationsHeader"
|
||||
class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-3"
|
||||
>
|
||||
<div class="grid h-8 w-8 place-items-center rounded-lg bg-secondary text-muted-foreground">
|
||||
<ng-icon
|
||||
name="lucideMessageCircle"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-sm font-semibold text-foreground">Direct Messages</h1>
|
||||
<p class="text-xs text-muted-foreground">{{ directMessages.conversations().length }} chats</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
appThemeNode="dmConversationList"
|
||||
class="min-h-0 flex-1 overflow-y-auto p-2"
|
||||
>
|
||||
@if (directMessages.conversations().length === 0) {
|
||||
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
|
||||
} @else {
|
||||
<div class="space-y-1">
|
||||
<app-dm-conversation-item
|
||||
*ngFor="let conversation of directMessages.conversations(); trackBy: trackConversationId"
|
||||
[conversation]="conversation"
|
||||
></app-dm-conversation-item>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
appThemeNode="dmVoiceControlsArea"
|
||||
class="border-t border-border px-2 py-3"
|
||||
>
|
||||
<app-voice-controls />
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMessageCircle } from '@ng-icons/lucide';
|
||||
import { ThemeNodeDirective, ThemeService } from '../../../theme';
|
||||
import { VoiceControlsComponent } from '../../../voice-session';
|
||||
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { DmConversationItemComponent } from './dm-conversation-item.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dm-conversations-panel',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
DmConversationItemComponent,
|
||||
NgIcon,
|
||||
ThemeNodeDirective,
|
||||
VoiceControlsComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideMessageCircle })],
|
||||
host: { class: 'contents' },
|
||||
templateUrl: './dm-conversations-panel.component.html'
|
||||
})
|
||||
export class DmConversationsPanelComponent {
|
||||
private readonly theme = inject(ThemeService);
|
||||
readonly directMessages = inject(DirectMessageService);
|
||||
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
|
||||
|
||||
trackConversationId(index: number, conversation: DirectMessageConversation): string {
|
||||
return conversation.id;
|
||||
}
|
||||
}
|
||||
@@ -2,97 +2,6 @@
|
||||
class="grid h-full min-h-0 overflow-hidden bg-background"
|
||||
[ngStyle]="layoutStyles()"
|
||||
>
|
||||
<aside
|
||||
appThemeNode="dmConversationsPanel"
|
||||
class="flex min-h-0 overflow-hidden border-r border-border bg-card"
|
||||
[ngStyle]="listPanelStyles()"
|
||||
>
|
||||
<section class="flex h-full w-full min-w-0 flex-col">
|
||||
<header
|
||||
appThemeNode="dmConversationsHeader"
|
||||
class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-3"
|
||||
>
|
||||
<div class="grid h-8 w-8 place-items-center rounded-lg bg-secondary text-muted-foreground">
|
||||
<ng-icon
|
||||
name="lucideMessageCircle"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-sm font-semibold text-foreground">Direct Messages</h1>
|
||||
<p class="text-xs text-muted-foreground">{{ directMessages.conversations().length }} chats</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
appThemeNode="dmConversationList"
|
||||
class="min-h-0 flex-1 overflow-y-auto p-2"
|
||||
>
|
||||
@if (directMessages.conversations().length === 0) {
|
||||
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
|
||||
} @else {
|
||||
<div class="space-y-1">
|
||||
@for (conversation of directMessages.conversations(); track conversation.id) {
|
||||
<div
|
||||
appThemeNode="dmConversationItem"
|
||||
class="group flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
|
||||
[class.bg-primary/10]="isSelectedConversation(conversation)"
|
||||
[class.text-foreground]="isSelectedConversation(conversation)"
|
||||
[attr.aria-current]="isSelectedConversation(conversation) ? 'page' : null"
|
||||
(click)="openConversation(conversation)"
|
||||
(keydown.enter)="openConversation(conversation)"
|
||||
(keydown.space)="openConversation(conversation)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="peerName(conversation)"
|
||||
[avatarUrl]="peerAvatarUrl(conversation)"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="truncate text-sm font-medium text-foreground">{{ peerName(conversation) }}</p>
|
||||
@if (conversation.unreadCount > 0) {
|
||||
<span class="rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-semibold text-black">
|
||||
{{ formatUnreadCount(conversation.unreadCount) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ lastMessagePreview(conversation) }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition hover:bg-destructive/10 hover:text-destructive focus:opacity-100 group-hover:opacity-100"
|
||||
[attr.aria-label]="'Forget ' + peerName(conversation)"
|
||||
[title]="'Forget ' + peerName(conversation)"
|
||||
(click)="forgetConversation($event, conversation)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
appThemeNode="dmVoiceControlsArea"
|
||||
class="border-t border-border px-2 py-3"
|
||||
>
|
||||
<app-voice-controls />
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
appThemeNode="dmChatPanel"
|
||||
class="relative min-h-0 min-w-0 overflow-hidden bg-background"
|
||||
[ngStyle]="chatPanelStyles()"
|
||||
>
|
||||
<app-dm-chat />
|
||||
</main>
|
||||
<app-dm-conversations-panel />
|
||||
<app-dm-chat-panel />
|
||||
</div>
|
||||
|
||||
@@ -9,50 +9,31 @@ import {
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMessageCircle, lucideTrash2 } from '@ng-icons/lucide';
|
||||
import { map } from 'rxjs';
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
import { ThemeNodeDirective, ThemeService } from '../../../theme';
|
||||
import { AttachmentFacade } from '../../../attachment';
|
||||
import { VoiceControlsComponent } from '../../../voice-session';
|
||||
import { ThemeService } from '../../../theme';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { DmChatComponent } from '../dm-chat/dm-chat.component';
|
||||
import { selectAllUsers } from '../../../../store/users/users.selectors';
|
||||
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
|
||||
import type { Attachment } from '../../../attachment';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
import { DmChatPanelComponent } from './dm-chat-panel.component';
|
||||
import { DmConversationsPanelComponent } from './dm-conversations-panel.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dm-workspace',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent,
|
||||
ThemeNodeDirective,
|
||||
DmChatComponent,
|
||||
VoiceControlsComponent
|
||||
DmChatPanelComponent,
|
||||
DmConversationsPanelComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideMessageCircle, lucideTrash2 })],
|
||||
templateUrl: './dm-workspace.component.html'
|
||||
})
|
||||
export class DmWorkspaceComponent implements OnDestroy {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly theme = inject(ThemeService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
|
||||
readonly directMessages = inject(DirectMessageService);
|
||||
readonly users = this.store.selectSignal(selectAllUsers);
|
||||
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
||||
});
|
||||
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
|
||||
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
|
||||
readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel'));
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -69,116 +50,9 @@ export class DmWorkspaceComponent implements OnDestroy {
|
||||
void this.router.navigate(['/dm', firstConversation.id], { replaceUrl: true });
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const users = this.users();
|
||||
|
||||
for (const conversation of this.directMessages.conversations()) {
|
||||
const peer = this.peerUser(conversation, users);
|
||||
|
||||
if (!peer?.avatarUrl) {
|
||||
this.directMessages.requestPeerAvatarSync(conversation.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openConversation(conversation: DirectMessageConversation): void {
|
||||
void this.router.navigate(['/dm', conversation.id]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.directMessages.closeConversationView(this.routeConversationId());
|
||||
}
|
||||
|
||||
isSelectedConversation(conversation: DirectMessageConversation): boolean {
|
||||
return this.routeConversationId() === conversation.id;
|
||||
}
|
||||
|
||||
peerName(conversation: DirectMessageConversation): string {
|
||||
const peerId = this.peerId(conversation);
|
||||
const knownUser = this.peerUser(conversation);
|
||||
|
||||
return peerId ? knownUser?.displayName || conversation.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
|
||||
}
|
||||
|
||||
peerAvatarUrl(conversation: DirectMessageConversation): string | undefined {
|
||||
const peerId = this.peerId(conversation);
|
||||
const knownUser = this.peerUser(conversation);
|
||||
|
||||
return peerId ? knownUser?.avatarUrl || conversation.participantProfiles[peerId]?.avatarUrl : undefined;
|
||||
}
|
||||
|
||||
lastMessagePreview(conversation: DirectMessageConversation): string {
|
||||
const lastMessage = conversation.messages.at(-1);
|
||||
|
||||
if (!lastMessage) {
|
||||
return 'No messages yet';
|
||||
}
|
||||
|
||||
if (lastMessage.isDeleted) {
|
||||
return 'Message deleted';
|
||||
}
|
||||
|
||||
if (this.isKlipyGif(lastMessage.content)) {
|
||||
return 'Sent a GIF';
|
||||
}
|
||||
|
||||
this.attachments.updated();
|
||||
const attachments = this.attachments.getForMessage(lastMessage.id);
|
||||
|
||||
if (attachments.length > 0) {
|
||||
return this.attachmentPreview(attachments);
|
||||
}
|
||||
|
||||
return lastMessage.content || 'Attachment';
|
||||
}
|
||||
|
||||
async forgetConversation(event: Event, conversation: DirectMessageConversation): Promise<void> {
|
||||
event.stopPropagation();
|
||||
const conversations = this.directMessages.conversations();
|
||||
const nextConversation = conversations.find((entry) => entry.id !== conversation.id) ?? null;
|
||||
|
||||
await this.directMessages.forgetConversation(conversation.id);
|
||||
|
||||
if (this.routeConversationId() === conversation.id) {
|
||||
await this.router.navigate(nextConversation ? ['/dm', nextConversation.id] : ['/dm']);
|
||||
}
|
||||
}
|
||||
|
||||
formatUnreadCount(count: number): string {
|
||||
return count > 99 ? '99+' : String(count);
|
||||
}
|
||||
|
||||
private peerId(conversation: DirectMessageConversation): string | undefined {
|
||||
const currentUserId = this.directMessages.currentUserId();
|
||||
|
||||
return conversation.participants.find((participantId) => participantId !== currentUserId);
|
||||
}
|
||||
|
||||
private peerUser(conversation: DirectMessageConversation, users = this.users()): User | undefined {
|
||||
const peerId = this.peerId(conversation);
|
||||
|
||||
return peerId ? users.find((user) => user.id === peerId || user.oderId === peerId) : undefined;
|
||||
}
|
||||
|
||||
private isKlipyGif(content: string): boolean {
|
||||
return /!\[KLIPY GIF\]\([^)]*static\.klipy\.com[^)]*\)/i.test(content.trim());
|
||||
}
|
||||
|
||||
private attachmentPreview(attachments: Attachment[]): string {
|
||||
if (attachments.some((attachment) => attachment.mime.startsWith('image/'))) {
|
||||
return 'Sent an image';
|
||||
}
|
||||
|
||||
if (attachments.some((attachment) => attachment.mime.startsWith('video/'))) {
|
||||
return 'Sent a video';
|
||||
}
|
||||
|
||||
if (attachments.some((attachment) => attachment.mime.startsWith('audio/'))) {
|
||||
return 'Sent audio';
|
||||
}
|
||||
|
||||
return attachments.length === 1 ? 'Sent an attachment' : 'Sent attachments';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,19 @@
|
||||
class="pointer-events-none flex scale-95 shrink-0 items-center gap-2 opacity-0 transition-[opacity,transform] duration-75 ease-out group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:scale-100 group-focus-within:opacity-100"
|
||||
>
|
||||
<app-friend-button [user]="user" />
|
||||
<button
|
||||
type="button"
|
||||
[attr.data-testid]="'call-friend-' + userKey(user)"
|
||||
class="grid h-8 w-8 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
|
||||
[attr.aria-label]="'Call ' + user.displayName"
|
||||
[title]="'Call ' + user.displayName"
|
||||
(click)="callUser(user)"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="callIcon(user)"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[attr.data-testid]="'message-friend-' + userKey(user)"
|
||||
@@ -98,6 +111,19 @@
|
||||
class="pointer-events-none flex scale-95 shrink-0 items-center gap-2 opacity-0 transition-[opacity,transform] duration-75 ease-out group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:scale-100 group-focus-within:opacity-100"
|
||||
>
|
||||
<app-friend-button [user]="user" />
|
||||
<button
|
||||
type="button"
|
||||
[attr.data-testid]="'call-user-' + userKey(user)"
|
||||
class="grid h-8 w-8 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
|
||||
[attr.aria-label]="'Call ' + user.displayName"
|
||||
[title]="'Call ' + user.displayName"
|
||||
(click)="callUser(user)"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="callIcon(user)"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[attr.data-testid]="'message-user-' + userKey(user)"
|
||||
|
||||
@@ -9,11 +9,17 @@ import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMessageCircle, lucideSearch } from '@ng-icons/lucide';
|
||||
import {
|
||||
lucideMessageCircle,
|
||||
lucidePhone,
|
||||
lucidePhoneCall,
|
||||
lucideSearch
|
||||
} from '@ng-icons/lucide';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { DirectCallService } from '../../../direct-call';
|
||||
import { FriendService } from '../../application/services/friend.service';
|
||||
import { FriendButtonComponent } from '../friend-button/friend-button.component';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
@@ -27,13 +33,14 @@ import type { User } from '../../../../shared-kernel';
|
||||
UserAvatarComponent,
|
||||
FriendButtonComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideMessageCircle, lucideSearch })],
|
||||
viewProviders: [provideIcons({ lucideMessageCircle, lucidePhone, lucidePhoneCall, lucideSearch })],
|
||||
templateUrl: './user-search-list.component.html'
|
||||
})
|
||||
export class UserSearchListComponent {
|
||||
private readonly store = inject(Store);
|
||||
private readonly router = inject(Router);
|
||||
private readonly directMessages = inject(DirectMessageService);
|
||||
readonly directCalls = inject(DirectCallService);
|
||||
readonly friends = inject(FriendService);
|
||||
readonly searchQuery = input('');
|
||||
readonly users = this.store.selectSignal(selectAllUsers);
|
||||
@@ -93,6 +100,14 @@ export class UserSearchListComponent {
|
||||
await this.router.navigate(['/dm', conversation.id]);
|
||||
}
|
||||
|
||||
async callUser(user: User): Promise<void> {
|
||||
await this.directCalls.startCall(user);
|
||||
}
|
||||
|
||||
callIcon(user: User): string {
|
||||
return this.directCalls.isCallingUser(user) ? 'lucidePhoneCall' : 'lucidePhone';
|
||||
}
|
||||
|
||||
userKey(user: User): string {
|
||||
return user.oderId || user.id;
|
||||
}
|
||||
|
||||
38
toju-app/src/app/domains/experimental-media/README.md
Normal file
38
toju-app/src/app/domains/experimental-media/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Experimental Media Domain
|
||||
|
||||
Optional media experiments live here so they can be removed without disturbing the attachment transfer domain or chat rendering.
|
||||
|
||||
## VLC.js Playback
|
||||
|
||||
The VLC.js player is off by default and is only offered for audio/video attachments that do not use the native Chromium player path. Chat does not instantiate VLC.js while scrolling; the runtime is loaded only after the user chooses the experimental player on a downloaded attachment.
|
||||
|
||||
The app does not bundle VideoLAN's proof-of-concept build directly. Instead, `ExperimentalVlcRuntimeService` loads this browser adapter script:
|
||||
|
||||
```text
|
||||
/vlcjs/metoyou-vlc-player.js
|
||||
```
|
||||
|
||||
That script must register this adapter on `window`:
|
||||
|
||||
```ts
|
||||
window.MetoYouVlcJs = {
|
||||
createPlayer({ container, sourceUrl, filename, mime }) {
|
||||
// Mount VLC.js/WebAssembly UI into container and return an optional cleanup handle.
|
||||
return { destroy() {} };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
The repository includes a small placeholder at `toju-app/public/vlcjs/metoyou-vlc-player.js` so dev/prod servers return JavaScript instead of the Angular HTML fallback. The placeholder sets `isPlaceholder: true`; the settings toggle stays disabled and chat does not show the experimental Play action while only the placeholder is present. To enable real playback, replace that file with an adapter that mounts the chosen VLC.js/WebAssembly runtime, removes `isPlaceholder`, and returns an optional cleanup handle.
|
||||
|
||||
On Electron, downloaded file-backed attachments also expose an Open action in the generic file interface. Use that for MKV/AVI or other unsupported formats until a real VLC.js adapter is bundled; Electron opens the saved file with the operating system's default player.
|
||||
|
||||
## Removal
|
||||
|
||||
To remove the experiment later:
|
||||
|
||||
1. Delete this domain folder.
|
||||
2. Remove `ExperimentalMediaSettingsService` and `ExperimentalVlcPlayerComponent` imports/usages from the chat message item and general settings.
|
||||
3. Delete any bundled `public/vlcjs/` runtime files.
|
||||
|
||||
The attachment transport, persistence, and default file UI do not depend on this runtime.
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { ExperimentalVlcRuntimeService } from '../../infrastructure/services/experimental-vlc-runtime.service';
|
||||
|
||||
const STORAGE_KEY_EXPERIMENTAL_MEDIA_SETTINGS = 'metoyou_experimental_media_settings';
|
||||
|
||||
export interface ExperimentalMediaSettings {
|
||||
vlcJsPlaybackEnabled: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_EXPERIMENTAL_MEDIA_SETTINGS: ExperimentalMediaSettings = {
|
||||
vlcJsPlaybackEnabled: false
|
||||
};
|
||||
|
||||
@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');
|
||||
|
||||
constructor() {
|
||||
void this.refreshVlcRuntimeStatus();
|
||||
}
|
||||
|
||||
setVlcJsPlaybackEnabled(enabled: boolean): void {
|
||||
if (enabled && !this.vlcJsRuntimeAvailable()) {
|
||||
this.vlcJsPlaybackEnabled.set(false);
|
||||
saveExperimentalMediaSettings({ vlcJsPlaybackEnabled: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = saveExperimentalMediaSettings({
|
||||
vlcJsPlaybackEnabled: enabled
|
||||
});
|
||||
|
||||
this.vlcJsPlaybackEnabled.set(settings.vlcJsPlaybackEnabled);
|
||||
}
|
||||
|
||||
async refreshVlcRuntimeStatus(): Promise<void> {
|
||||
this.vlcJsRuntimeStatus.set('checking');
|
||||
|
||||
const available = await this.vlcRuntime.hasBundledRuntime();
|
||||
|
||||
this.vlcJsRuntimeAvailable.set(available);
|
||||
this.vlcJsRuntimeStatus.set(available ? 'available' : 'missing');
|
||||
this.vlcJsPlaybackEnabled.set(available && this.storedSettings.vlcJsPlaybackEnabled);
|
||||
|
||||
if (!available && this.storedSettings.vlcJsPlaybackEnabled) {
|
||||
saveExperimentalMediaSettings({ vlcJsPlaybackEnabled: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadExperimentalMediaSettings(): ExperimentalMediaSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_EXPERIMENTAL_MEDIA_SETTINGS);
|
||||
|
||||
if (!raw) {
|
||||
return { ...DEFAULT_EXPERIMENTAL_MEDIA_SETTINGS };
|
||||
}
|
||||
|
||||
return normaliseExperimentalMediaSettings(JSON.parse(raw) as Partial<ExperimentalMediaSettings>);
|
||||
} catch {
|
||||
return { ...DEFAULT_EXPERIMENTAL_MEDIA_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
function saveExperimentalMediaSettings(patch: Partial<ExperimentalMediaSettings>): ExperimentalMediaSettings {
|
||||
const nextSettings = normaliseExperimentalMediaSettings({
|
||||
...loadExperimentalMediaSettings(),
|
||||
...patch
|
||||
});
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_EXPERIMENTAL_MEDIA_SETTINGS, JSON.stringify(nextSettings));
|
||||
} catch {}
|
||||
|
||||
return nextSettings;
|
||||
}
|
||||
|
||||
function normaliseExperimentalMediaSettings(raw: Partial<ExperimentalMediaSettings>): ExperimentalMediaSettings {
|
||||
return {
|
||||
vlcJsPlaybackEnabled: typeof raw.vlcJsPlaybackEnabled === 'boolean'
|
||||
? raw.vlcJsPlaybackEnabled
|
||||
: DEFAULT_EXPERIMENTAL_MEDIA_SETTINGS.vlcJsPlaybackEnabled
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<section class="mt-2 max-w-xl overflow-hidden rounded-md border border-border bg-card shadow-sm">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-border px-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-foreground">{{ filename() }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ sizeLabel() }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title="Download"
|
||||
aria-label="Download"
|
||||
(click)="requestDownload()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideDownload"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
(click)="close()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative min-h-72 bg-black">
|
||||
<div
|
||||
#playerMount
|
||||
class="min-h-72 w-full"
|
||||
></div>
|
||||
|
||||
@if (status() === 'loading') {
|
||||
<div class="absolute inset-0 grid place-items-center bg-black/80 px-4 text-sm text-white/80">Loading experimental player...</div>
|
||||
}
|
||||
|
||||
@if (status() === 'error') {
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/85 px-6 text-center">
|
||||
<p class="max-w-md text-sm text-white/80">{{ errorMessage() }}</p>
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-md bg-white/10 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-white/20"
|
||||
(click)="retry()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideRefreshCw"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-md bg-white px-3 py-1.5 text-xs font-medium text-black transition-colors hover:bg-white/90"
|
||||
(click)="requestDownload()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideDownload"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideDownload,
|
||||
lucideRefreshCw,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import {
|
||||
ExperimentalVlcPlayerHandle,
|
||||
ExperimentalVlcRuntimeService
|
||||
} from '../../infrastructure/services/experimental-vlc-runtime.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-experimental-vlc-player',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideDownload,
|
||||
lucideRefreshCw,
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './experimental-vlc-player.component.html'
|
||||
})
|
||||
export class ExperimentalVlcPlayerComponent implements AfterViewInit, OnDestroy {
|
||||
@ViewChild('playerMount') playerMount?: ElementRef<HTMLDivElement>;
|
||||
|
||||
src = input.required<string>();
|
||||
filename = input.required<string>();
|
||||
mime = input.required<string>();
|
||||
sizeLabel = input<string>('');
|
||||
|
||||
closed = output<void>();
|
||||
downloadRequested = output<void>();
|
||||
|
||||
private readonly runtime = inject(ExperimentalVlcRuntimeService);
|
||||
private playerHandle: ExperimentalVlcPlayerHandle | null = null;
|
||||
|
||||
readonly status = signal<'loading' | 'ready' | 'error'>('loading');
|
||||
readonly errorMessage = signal('');
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
void this.loadPlayer();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyPlayer();
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.destroyPlayer();
|
||||
void this.loadPlayer();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
requestDownload(): void {
|
||||
this.downloadRequested.emit();
|
||||
}
|
||||
|
||||
private async loadPlayer(): Promise<void> {
|
||||
const container = this.playerMount?.nativeElement;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.status.set('loading');
|
||||
this.errorMessage.set('');
|
||||
container.replaceChildren();
|
||||
|
||||
try {
|
||||
this.playerHandle = await this.runtime.createPlayer({
|
||||
container,
|
||||
sourceUrl: this.src(),
|
||||
filename: this.filename(),
|
||||
mime: this.mime()
|
||||
});
|
||||
this.status.set('ready');
|
||||
} catch (error) {
|
||||
this.status.set('error');
|
||||
this.errorMessage.set(error instanceof Error ? error.message : 'Experimental VLC.js playback failed to start.');
|
||||
}
|
||||
}
|
||||
|
||||
private destroyPlayer(): void {
|
||||
try {
|
||||
this.playerHandle?.destroy?.();
|
||||
} catch {}
|
||||
|
||||
this.playerHandle = null;
|
||||
this.playerMount?.nativeElement.replaceChildren();
|
||||
}
|
||||
}
|
||||
2
toju-app/src/app/domains/experimental-media/index.ts
Normal file
2
toju-app/src/app/domains/experimental-media/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ExperimentalMediaSettingsService } from './application/services/experimental-media-settings.service';
|
||||
export { ExperimentalVlcPlayerComponent } from './feature/experimental-vlc-player/experimental-vlc-player.component';
|
||||
@@ -0,0 +1,83 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
|
||||
export interface ExperimentalVlcPlayerOptions {
|
||||
container: HTMLElement;
|
||||
sourceUrl: string;
|
||||
filename: string;
|
||||
mime: string;
|
||||
}
|
||||
|
||||
export interface ExperimentalVlcPlayerHandle {
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
export interface ExperimentalVlcRuntime {
|
||||
createPlayer: (options: ExperimentalVlcPlayerOptions) => ExperimentalVlcPlayerHandle | Promise<ExperimentalVlcPlayerHandle>;
|
||||
isPlaceholder?: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
MetoYouVlcJs?: ExperimentalVlcRuntime;
|
||||
}
|
||||
}
|
||||
|
||||
const VLC_RUNTIME_SCRIPT_URL = environment.experimentalMedia.vlcRuntimeScriptUrl;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExperimentalVlcRuntimeService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private runtimeLoadPromise: Promise<ExperimentalVlcRuntime> | null = null;
|
||||
|
||||
async createPlayer(options: ExperimentalVlcPlayerOptions): Promise<ExperimentalVlcPlayerHandle> {
|
||||
const runtime = await this.loadRuntime();
|
||||
|
||||
if (runtime.isPlaceholder) {
|
||||
throw new Error('No VLC.js runtime is bundled. Use Open in Electron, or replace /vlcjs/metoyou-vlc-player.js with a real runtime adapter.');
|
||||
}
|
||||
|
||||
return runtime.createPlayer(options);
|
||||
}
|
||||
|
||||
async hasBundledRuntime(): Promise<boolean> {
|
||||
try {
|
||||
const runtime = await this.loadRuntime();
|
||||
|
||||
return !!runtime.createPlayer && !runtime.isPlaceholder;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private loadRuntime(): Promise<ExperimentalVlcRuntime> {
|
||||
if (this.document.defaultView?.MetoYouVlcJs?.createPlayer) {
|
||||
return Promise.resolve(this.document.defaultView.MetoYouVlcJs);
|
||||
}
|
||||
|
||||
this.runtimeLoadPromise ??= new Promise<ExperimentalVlcRuntime>((resolve, reject) => {
|
||||
const script = this.document.createElement('script');
|
||||
|
||||
script.src = VLC_RUNTIME_SCRIPT_URL;
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
const runtime = this.document.defaultView?.MetoYouVlcJs;
|
||||
|
||||
if (!runtime?.createPlayer) {
|
||||
reject(new Error('The experimental VLC.js runtime did not register a MetoYouVlcJs.createPlayer adapter.'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(runtime);
|
||||
};
|
||||
|
||||
script.onerror = () => reject(new Error(`The experimental VLC.js runtime was not found at ${VLC_RUNTIME_SCRIPT_URL}.`));
|
||||
|
||||
this.document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return this.runtimeLoadPromise;
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export class GameActivityService implements OnDestroy {
|
||||
|
||||
const api = this.electron.getApi();
|
||||
|
||||
if (!api?.getRunningProcessNames) {
|
||||
if (!api?.getRunningProcessNames && !api?.getActiveGameCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -154,14 +154,33 @@ export class GameActivityService implements OnDestroy {
|
||||
|
||||
const api = this.electron.getApi();
|
||||
|
||||
if (!api?.getRunningProcessNames) {
|
||||
if (!api?.getRunningProcessNames && !api?.getActiveGameCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scanInFlight = true;
|
||||
|
||||
try {
|
||||
const processNames = (await api.getRunningProcessNames()).slice(0, MAX_PROCESS_NAMES_PER_REQUEST);
|
||||
const candidateResult = api.getActiveGameCandidate
|
||||
? await api.getActiveGameCandidate().catch(() => null)
|
||||
: null;
|
||||
|
||||
let processNames: string[];
|
||||
let preferredProcessName: string | undefined;
|
||||
|
||||
if (candidateResult?.candidate) {
|
||||
// Main process already scored & filtered this; trust it.
|
||||
preferredProcessName = candidateResult.candidate.rawProcessName ?? candidateResult.candidate.processName;
|
||||
processNames = [preferredProcessName];
|
||||
} else if (candidateResult && candidateResult.fallbackProcessNames.length > 0) {
|
||||
processNames = candidateResult.fallbackProcessNames.slice(0, MAX_PROCESS_NAMES_PER_REQUEST);
|
||||
} else if (!candidateResult && api.getRunningProcessNames) {
|
||||
// Old preload without the new API: fall back to legacy whole-system scan.
|
||||
processNames = (await api.getRunningProcessNames()).slice(0, MAX_PROCESS_NAMES_PER_REQUEST);
|
||||
} else {
|
||||
processNames = [];
|
||||
}
|
||||
|
||||
const processHash = this.buildProcessHash(processNames);
|
||||
|
||||
if (processHash === this.lastProcessHash) {
|
||||
@@ -170,6 +189,12 @@ export class GameActivityService implements OnDestroy {
|
||||
|
||||
this.lastProcessHash = processHash;
|
||||
|
||||
if (processNames.length === 0) {
|
||||
this.ngZone.run(() => this.applyMatchedGame(null));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedGame = await this.matchRunningGame(processNames);
|
||||
|
||||
this.ngZone.run(() => this.applyMatchedGame(matchedGame));
|
||||
|
||||
@@ -195,6 +195,7 @@ Additional runtime guards:
|
||||
|
||||
- Deleted messages never notify.
|
||||
- The current user's own messages never notify.
|
||||
- Live room messages only notify when the message room is the current room or one of the user's saved rooms.
|
||||
- Duplicate live events are suppressed with a rolling in-memory set of the last 500 notified message IDs.
|
||||
- Unread badges are independent from mute state. Muting changes delivery only; it does not hide unread indicators.
|
||||
|
||||
|
||||
@@ -172,6 +172,12 @@ export class NotificationsService {
|
||||
return;
|
||||
}
|
||||
|
||||
const room = this.getKnownRoom(message.roomId);
|
||||
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rememberMessageId(message.id);
|
||||
|
||||
const channelId = resolveMessageChannelId(message);
|
||||
@@ -198,7 +204,6 @@ export class NotificationsService {
|
||||
return;
|
||||
}
|
||||
|
||||
const room = getRoomById(context.rooms, message.roomId);
|
||||
const payload = buildNotificationDisplayPayload(
|
||||
message,
|
||||
room,
|
||||
@@ -512,6 +517,11 @@ export class NotificationsService {
|
||||
return this.getCurrentUserIds().has(senderId);
|
||||
}
|
||||
|
||||
private getKnownRoom(roomId: string): Room | null {
|
||||
return getRoomById(this.savedRooms(), roomId)
|
||||
?? (this.currentRoom()?.id === roomId ? this.currentRoom() ?? null : null);
|
||||
}
|
||||
|
||||
private setSettings(settings: NotificationsSettings): void {
|
||||
this._settings.set(settings);
|
||||
this.storage.save(settings);
|
||||
|
||||
@@ -6,13 +6,13 @@ The signal server stores plugin install metadata and event definitions, but it m
|
||||
|
||||
Desktop local plugins are discovered from the Electron app data `plugins` folder. Discovery reads `toju-plugin.json` or `plugin.json` from immediate child folders and resolves declared entrypoint/readme paths only when they stay inside that plugin folder.
|
||||
|
||||
The standalone plugin store is available from the title bar Plugins button, the title-bar Plugin Store menu item, the legacy Settings page button, and the Plugin Manager header. It owns source manifest management, search, readmes, install/update/uninstall actions, and links back to installed-plugin management. Manifest `kind` describes runtime shape (`client` or `library`), while top-level manifest `scope` describes installation scope: omit it or use `scope: "client"` for global client plugins, and use `scope: "server"` for chat-server plugins. Server-scoped store entries are presented as Install to Server, Update Server, or Remove from Server. Server plugin downloads are user-local and server-specific: a server can publish requirement metadata, but each account must consent before those plugins are downloaded or activated on join. Members who are already in a server see new required plugin requirements as a blocking prompt with Install plugins or Leave server actions; new optional or recommended requirements appear as a title-bar banner that can be installed, rejected for the current session, or hidden for that server/plugin requirement version.
|
||||
The standalone plugin store is available from the title bar Plugins button, the title-bar Plugin Store menu item, the legacy Settings page button, and the Plugin Manager header. It owns source manifest management, search, readmes, install/update/uninstall actions, and links back to installed-plugin management. Manifest `kind` describes runtime shape (`client` or `library`), while top-level manifest `scope` describes installation scope: omit it or use `scope: "client"` for global client plugins, and use `scope: "server"` for chat-server plugins. Server-scoped store entries are presented as Install to Server, Update Server, or Remove from Server. Server plugin downloads are user-local and server-specific: a server can publish requirement metadata, but each account must consent before those plugins are downloaded or activated on join. The join consent dialog lets users inspect requested capabilities, open the plugin source in the system browser, and view a formatted readme when one is declared. Accepting the join prompt grants every declared capability for the accepted plugins and activates them for the server. Members who are already in a server see new required plugin requirements as a blocking prompt with Install plugins or Leave server actions; new optional or recommended requirements appear as a title-bar banner that can be installed, rejected for the current session, or hidden for that server/plugin requirement version.
|
||||
|
||||
The plugin manager UI is split between Settings -> Client plugins for global client plugins and Settings -> Server -> Server plugins for chat-server plugins. The two pages filter by manifest `scope` and include installed plugins, capability grant toggles, per-plugin activate/reload/unload actions, runtime logs, extension-point counts, server requirements, generated settings, and docs.
|
||||
|
||||
The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, `bundle`/`bundleUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. When a different user joins that server, required plugins block the join until the user accepts the download; optional and recommended plugins are offered as selectable downloads and can be skipped. Once a server has local server-scoped plugins installed, the title bar shows a compact Server plugins button for that server. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client.
|
||||
The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. New users and legacy source lists are seeded with the official Toju plugin repository at `https://raw.githubusercontent.com/Myxelium/official-toju-plugin-repository/refs/heads/master/plugin-source.json`, while source removal remains persisted after migration. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, `bundle`/`bundleUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. When a different user joins that server, required plugins block the join until the user accepts the download; optional and recommended plugins are offered as selectable downloads and can be skipped. Once a server has local server-scoped plugins installed, the title bar shows a compact Server plugins button for that server. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client.
|
||||
|
||||
Store plugins can be published as cached browser bundles by adding `bundle` or `bundleUrl` to the source manifest entry. The bundle is a browser-safe ESM JavaScript file. During install, Electron downloads the bundle into app data under `plugin-bundles/<plugin-id>/<version>/main.js`, writes a cached manifest next to it, and registers the plugin from that local cached manifest path. If no bundle URL is provided and the manifest entrypoint is a relative browser module, Electron caches that entrypoint path instead. Browser-only clients still load directly from the source URL. Saved store sources refresh during app bootstrap; when a source advertises a higher version for an installed plugin, the store attempts to update the local cached bundle and persisted install metadata automatically.
|
||||
Store plugins can be published as cached browser bundles by adding `bundle` or `bundleUrl` to the source manifest entry. The bundle is a browser-safe ESM JavaScript file. During install, Electron downloads the bundle into app data under `plugin-bundles/<plugin-id>/<version>/main.js`, writes a cached manifest next to it, and registers the plugin from that local cached manifest path. If no bundle URL is provided and the manifest entrypoint is a relative browser module, Electron caches that entrypoint path instead. Browser-only clients still load directly from the source URL; the renderer CSP allows HTTP(S), `file://` via local cache, and blob-backed plugin entrypoints. Saved store sources refresh during app bootstrap; when a source advertises a higher version for an installed plugin, the store attempts to update the local cached bundle and persisted install metadata automatically.
|
||||
|
||||
The server-side plugin support API is metadata-only. The signal server can keep plugin id, requirement status, version range, install/source URLs, and the validated manifest snapshot needed for member clients to install required plugins. Plugin `serverData` API calls are handled as local per-user/per-server client state; HTTP plugin data persistence on the signal server returns `PLUGIN_DATA_DISABLED`.
|
||||
|
||||
@@ -22,8 +22,10 @@ Plugins can communicate over a plugin-only message bus through `api.messageBus`.
|
||||
|
||||
Plugins can inspect the current interaction context through `api.context.getCurrent()`. Composer action callbacks also receive this context directly, including the local user, current chat server, active text channel, and the user's current voice channel when connected. Plugins with message access can call `api.messages.setTyping(true | false, channelId?)` and can observe peer typing state with `api.messages.subscribeTyping(handler)`, where typing events include the user, server, text channel, and voice channel when those records are available locally.
|
||||
|
||||
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.
|
||||
|
||||
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. 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.
|
||||
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. HTTP(S) entrypoints are imported directly when the host serves module-compatible JavaScript; if a source host serves JavaScript with a non-module MIME type, the runtime fetches the source and imports it through a blob URL. Successfully activated plugin ids are remembered locally, and store-installed plugins are reactivated for the active server when their persisted manifests load again. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id.
|
||||
|
||||
Plugins that need fully custom UI can call `api.ui.mountElement(id, { target, element, position })` with the `ui.dom` capability. The runtime tags mounted elements with plugin ownership metadata, replaces duplicate mounts for the same plugin/id pair, and removes remaining mounted elements when the plugin is unloaded.
|
||||
|
||||
@@ -375,9 +375,20 @@ export class PluginHostService {
|
||||
return { module, moduleObjectUrl };
|
||||
}
|
||||
|
||||
return {
|
||||
module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule
|
||||
};
|
||||
try {
|
||||
return {
|
||||
module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule
|
||||
};
|
||||
} catch (error) {
|
||||
if (!entrypointUrl.startsWith('http://') && !entrypointUrl.startsWith('https://')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const moduleObjectUrl = await this.createRemoteModuleObjectUrl(entrypointUrl);
|
||||
const module = await import(/* @vite-ignore */ moduleObjectUrl) as TojuClientPluginModule;
|
||||
|
||||
return { module, moduleObjectUrl };
|
||||
}
|
||||
}
|
||||
|
||||
private async createLocalModuleObjectUrl(entrypointUrl: string): Promise<string> {
|
||||
@@ -394,6 +405,18 @@ export class PluginHostService {
|
||||
return URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
|
||||
}
|
||||
|
||||
private async createRemoteModuleObjectUrl(entrypointUrl: string): Promise<string> {
|
||||
const response = await fetch(entrypointUrl, { headers: { Accept: 'text/javascript,*/*' } });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Plugin entrypoint returned ${response.status}`);
|
||||
}
|
||||
|
||||
const source = await response.text();
|
||||
|
||||
return URL.createObjectURL(new Blob([`${source}\n//# sourceURL=${entrypointUrl}`], { type: 'text/javascript' }));
|
||||
}
|
||||
|
||||
private revokeModuleObjectUrl(pluginId: string): void {
|
||||
const moduleObjectUrl = this.activePlugins.get(pluginId)?.moduleObjectUrl;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injector } from '@angular/core';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import type { TojuPluginManifest } from '../../../../shared-kernel';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { PluginStoreService } from './plugin-store.service';
|
||||
@@ -6,8 +7,11 @@ import { PluginHostService } from './plugin-host.service';
|
||||
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
||||
import { PluginRequirementService } from './plugin-requirement.service';
|
||||
import { PluginRegistryService } from './plugin-registry.service';
|
||||
import { PluginCapabilityService } from './plugin-capability.service';
|
||||
import type { PluginStoreEntry } from '../../domain/models/plugin-store.models';
|
||||
|
||||
const OFFICIAL_PLUGIN_SOURCE_URL = environment.pluginStore.defaultSourceUrls[0];
|
||||
|
||||
describe('PluginStoreService', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let registerLocalManifest: ReturnType<typeof vi.fn>;
|
||||
@@ -36,42 +40,79 @@ describe('PluginStoreService', () => {
|
||||
});
|
||||
|
||||
it('loads plugin entries from source manifests and resolves relative links', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({
|
||||
plugins: [
|
||||
{
|
||||
author: 'Ada Example',
|
||||
description: 'Adds better channel tools.',
|
||||
github: 'https://github.com/example/better-channels',
|
||||
id: 'example.better-channels',
|
||||
image: './images/better.png',
|
||||
install: './better/toju-plugin.json',
|
||||
readme: './better/README.md',
|
||||
title: 'Better Channels',
|
||||
version: '1.2.0'
|
||||
}
|
||||
],
|
||||
title: 'Example Plugins'
|
||||
}));
|
||||
mockFetchResponses(fetchMock, {
|
||||
'https://plugins.example.test/index.json': jsonResponse({
|
||||
plugins: [
|
||||
{
|
||||
author: 'Ada Example',
|
||||
description: 'Adds better channel tools.',
|
||||
github: 'https://github.com/example/better-channels',
|
||||
id: 'example.better-channels',
|
||||
image: './images/better.png',
|
||||
install: './better/toju-plugin.json',
|
||||
readme: './better/README.md',
|
||||
title: 'Better Channels',
|
||||
version: '1.2.0'
|
||||
}
|
||||
],
|
||||
title: 'Example Plugins'
|
||||
})
|
||||
});
|
||||
|
||||
const service = createService(registerLocalManifest, unregister);
|
||||
|
||||
await service.addSourceUrl('https://plugins.example.test/index.json#latest');
|
||||
|
||||
expect(service.sourceUrls()).toEqual(['https://plugins.example.test/index.json']);
|
||||
expect(service.sources()[0]?.title).toBe('Example Plugins');
|
||||
expect(service.availablePlugins()).toEqual([
|
||||
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'https://plugins.example.test/index.json']);
|
||||
|
||||
expect(service.sources().some((source) => source.title === 'Example Plugins')).toBe(true);
|
||||
expect(service.availablePlugins()).toContainEqual(expect.objectContaining({
|
||||
author: 'Ada Example',
|
||||
githubUrl: 'https://github.com/example/better-channels',
|
||||
id: 'example.better-channels',
|
||||
imageUrl: 'https://plugins.example.test/images/better.png',
|
||||
installUrl: 'https://plugins.example.test/better/toju-plugin.json',
|
||||
readmeUrl: 'https://plugins.example.test/better/README.md',
|
||||
sourceTitle: 'Example Plugins',
|
||||
title: 'Better Channels',
|
||||
version: '1.2.0'
|
||||
}));
|
||||
});
|
||||
|
||||
it('seeds the official plugin repository for new users', () => {
|
||||
const service = createService(registerLocalManifest, unregister);
|
||||
|
||||
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL]);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
OFFICIAL_PLUGIN_SOURCE_URL,
|
||||
expect.objectContaining({
|
||||
author: 'Ada Example',
|
||||
githubUrl: 'https://github.com/example/better-channels',
|
||||
id: 'example.better-channels',
|
||||
imageUrl: 'https://plugins.example.test/images/better.png',
|
||||
installUrl: 'https://plugins.example.test/better/toju-plugin.json',
|
||||
readmeUrl: 'https://plugins.example.test/better/README.md',
|
||||
sourceTitle: 'Example Plugins',
|
||||
title: 'Better Channels',
|
||||
version: '1.2.0'
|
||||
headers: { Accept: 'application/json' }
|
||||
})
|
||||
]);
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the official plugin repository when loading legacy source lists', () => {
|
||||
storage.setItem('metoyou_plugin_store', JSON.stringify({
|
||||
installedPlugins: [],
|
||||
schemaVersion: 1,
|
||||
sourceUrls: ['https://plugins.example.test/index.json']
|
||||
}));
|
||||
|
||||
const service = createService(registerLocalManifest, unregister);
|
||||
|
||||
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'https://plugins.example.test/index.json']);
|
||||
});
|
||||
|
||||
it('keeps user-removed default sources removed after schema migration', () => {
|
||||
storage.setItem('metoyou_plugin_store', JSON.stringify({
|
||||
installedPlugins: [],
|
||||
schemaVersion: 2,
|
||||
sourceUrls: ['https://plugins.example.test/index.json']
|
||||
}));
|
||||
|
||||
const service = createService(registerLocalManifest, unregister);
|
||||
|
||||
expect(service.sourceUrls()).toEqual(['https://plugins.example.test/index.json']);
|
||||
});
|
||||
|
||||
it('accepts local source manifest paths and resolves relative file links', async () => {
|
||||
@@ -94,9 +135,10 @@ describe('PluginStoreService', () => {
|
||||
|
||||
await service.addSourceUrl('/home/ludde/Desktop/TestPlugin/plugin-source.json');
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json', expect.anything());
|
||||
expect(readFile).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json');
|
||||
expect(service.sourceUrls()).toEqual(['file:///home/ludde/Desktop/TestPlugin/plugin-source.json']);
|
||||
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'file:///home/ludde/Desktop/TestPlugin/plugin-source.json']);
|
||||
|
||||
expect(service.availablePlugins()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'example.local-plugin',
|
||||
@@ -112,7 +154,9 @@ describe('PluginStoreService', () => {
|
||||
const manifest = createManifest({ version: '1.0.0' });
|
||||
const plugin = createStoreEntry({ version: '1.0.0' });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(manifest));
|
||||
mockFetchResponses(fetchMock, {
|
||||
[plugin.installUrl ?? '']: jsonResponse(manifest)
|
||||
});
|
||||
|
||||
const service = createService(registerLocalManifest, unregister);
|
||||
|
||||
@@ -141,9 +185,10 @@ describe('PluginStoreService', () => {
|
||||
writeFile: vi.fn(async () => true)
|
||||
};
|
||||
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse(manifest))
|
||||
.mockResolvedValueOnce(textResponse('export function activate() {}'));
|
||||
mockFetchResponses(fetchMock, {
|
||||
[plugin.bundleUrl ?? '']: textResponse('export function activate() {}'),
|
||||
[plugin.installUrl ?? '']: jsonResponse(manifest)
|
||||
});
|
||||
|
||||
const service = createService(registerLocalManifest, unregister, electronApi);
|
||||
|
||||
@@ -171,7 +216,9 @@ describe('PluginStoreService', () => {
|
||||
it('loads plugin readmes as markdown text', async () => {
|
||||
const plugin = createStoreEntry({ readmeUrl: 'https://plugins.example.test/better/README.md' });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(textResponse('# Better Channels'));
|
||||
mockFetchResponses(fetchMock, {
|
||||
[plugin.readmeUrl ?? '']: textResponse('# Better Channels')
|
||||
});
|
||||
|
||||
const service = createService(registerLocalManifest, unregister);
|
||||
const readme = await service.loadReadme(plugin);
|
||||
@@ -185,6 +232,21 @@ describe('PluginStoreService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
function mockFetchResponses(fetchMock: ReturnType<typeof vi.fn>, responses: Record<string, Response>): void {
|
||||
fetchMock.mockImplementation(async (url: RequestInfo | URL) => {
|
||||
const requestUrl = typeof url === 'string'
|
||||
? url
|
||||
: url instanceof URL
|
||||
? url.toString()
|
||||
: url.url;
|
||||
|
||||
return responses[requestUrl]
|
||||
?? (requestUrl === OFFICIAL_PLUGIN_SOURCE_URL
|
||||
? jsonResponse({ plugins: [], title: 'Official Toju Plugins' })
|
||||
: textResponse(''));
|
||||
});
|
||||
}
|
||||
|
||||
function createService(
|
||||
registerLocalManifest: ReturnType<typeof vi.fn>,
|
||||
unregister: ReturnType<typeof vi.fn>,
|
||||
@@ -220,6 +282,12 @@ function createService(
|
||||
writeJson: vi.fn(async () => undefined)
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: PluginCapabilityService,
|
||||
useValue: {
|
||||
grantAll: vi.fn()
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: PluginRegistryService,
|
||||
useValue: { unregister }
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
@@ -39,18 +40,16 @@ import type {
|
||||
PluginStoreSourceResult
|
||||
} from '../../domain/models/plugin-store.models';
|
||||
import { PluginHostService } from './plugin-host.service';
|
||||
import { PluginCapabilityService } from './plugin-capability.service';
|
||||
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
||||
import { PluginRequirementService } from './plugin-requirement.service';
|
||||
import { PluginRegistryService } from './plugin-registry.service';
|
||||
|
||||
const STORE_SCHEMA_VERSION = 1;
|
||||
const STORE_SCHEMA_VERSION = 2;
|
||||
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
|
||||
const STORAGE_KEY_SERVER_PLUGIN_INSTALLS = 'metoyou_server_plugin_installs';
|
||||
const PLUGIN_CACHE_DIR = 'plugin-bundles';
|
||||
const DEFAULT_STORE_STATE: PersistedPluginStoreState = {
|
||||
installedPlugins: [],
|
||||
sourceUrls: []
|
||||
};
|
||||
const DEFAULT_PLUGIN_SOURCE_URLS = [...environment.pluginStore.defaultSourceUrls];
|
||||
|
||||
export interface PluginStoreInstallOptions {
|
||||
activate?: boolean;
|
||||
@@ -69,6 +68,7 @@ interface ServerInstalledPluginsLoadState {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PluginStoreService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly capabilities = inject(PluginCapabilityService);
|
||||
private readonly desktopState = inject(PluginDesktopStateService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly host = inject(PluginHostService);
|
||||
@@ -368,6 +368,10 @@ export class PluginStoreService {
|
||||
updatedAt: installedPlugin.updatedAt
|
||||
});
|
||||
|
||||
if (options.activate) {
|
||||
this.capabilities.grantAll(cachedPlugin.manifest);
|
||||
}
|
||||
|
||||
nextById.set(cachedPlugin.manifest.id, cachedPlugin);
|
||||
}
|
||||
|
||||
@@ -402,6 +406,28 @@ export class PluginStoreService {
|
||||
};
|
||||
}
|
||||
|
||||
async loadRequirementReadme(requirement: PluginRequirementSummary): Promise<PluginStoreReadme> {
|
||||
const rawReadmeUrl = requirement.manifest?.readme;
|
||||
|
||||
if (!rawReadmeUrl) {
|
||||
throw new Error('Plugin does not provide a readme URL');
|
||||
}
|
||||
|
||||
const baseUrl = requirement.installUrl ?? requirement.sourceUrl ?? rawReadmeUrl;
|
||||
const readmeUrl = resolveOptionalUrl(baseUrl, rawReadmeUrl);
|
||||
|
||||
if (!readmeUrl) {
|
||||
throw new Error('Plugin readme URL is not valid');
|
||||
}
|
||||
|
||||
return {
|
||||
markdown: await this.fetchText(readmeUrl, 'text/markdown,text/plain,*/*'),
|
||||
pluginId: requirement.pluginId,
|
||||
title: requirement.manifest?.title ?? requirement.pluginId,
|
||||
url: readmeUrl
|
||||
};
|
||||
}
|
||||
|
||||
getInstallState(plugin: PluginStoreEntry): PluginStoreInstallState {
|
||||
const installed = this.installedPluginForScope(plugin.id, getStoreEntryInstallScope(plugin));
|
||||
|
||||
@@ -925,12 +951,12 @@ export class PluginStoreService {
|
||||
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE));
|
||||
|
||||
if (!raw) {
|
||||
return { ...DEFAULT_STORE_STATE };
|
||||
return createDefaultStoreState();
|
||||
}
|
||||
|
||||
return normalizePersistedState(JSON.parse(raw) as unknown);
|
||||
} catch {
|
||||
return { ...DEFAULT_STORE_STATE };
|
||||
return createDefaultStoreState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1104,22 +1130,63 @@ function readPluginInstallScope(record: Record<string, unknown>): TojuPluginInst
|
||||
|
||||
function normalizePersistedState(value: unknown): PersistedPluginStoreState {
|
||||
if (!isRecord(value)) {
|
||||
return { ...DEFAULT_STORE_STATE };
|
||||
return createDefaultStoreState();
|
||||
}
|
||||
|
||||
const schemaVersion = typeof value['schemaVersion'] === 'number' ? value['schemaVersion'] : 0;
|
||||
const sourceUrls = Array.isArray(value['sourceUrls'])
|
||||
? normalizePluginSourceUrls(value['sourceUrls'])
|
||||
: [];
|
||||
|
||||
return {
|
||||
installedPlugins: Array.isArray(value['installedPlugins'])
|
||||
? value['installedPlugins'].filter(isInstalledStorePlugin)
|
||||
: [],
|
||||
sourceUrls: Array.isArray(value['sourceUrls'])
|
||||
? value['sourceUrls']
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => normalizeOptionalSourceUrl(entry))
|
||||
.filter((entry): entry is string => !!entry)
|
||||
: []
|
||||
schemaVersion: STORE_SCHEMA_VERSION,
|
||||
sourceUrls: schemaVersion < STORE_SCHEMA_VERSION
|
||||
? mergePluginSourceUrls(DEFAULT_PLUGIN_SOURCE_URLS, sourceUrls)
|
||||
: sourceUrls
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultStoreState(): PersistedPluginStoreState {
|
||||
return {
|
||||
installedPlugins: [],
|
||||
schemaVersion: STORE_SCHEMA_VERSION,
|
||||
sourceUrls: [...DEFAULT_PLUGIN_SOURCE_URLS]
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePluginSourceUrls(sourceUrls: unknown[]): string[] {
|
||||
const normalizedSourceUrls: string[] = [];
|
||||
|
||||
for (const entry of sourceUrls) {
|
||||
if (typeof entry !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceUrl = normalizeOptionalSourceUrl(entry);
|
||||
|
||||
if (sourceUrl && !normalizedSourceUrls.includes(sourceUrl)) {
|
||||
normalizedSourceUrls.push(sourceUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedSourceUrls;
|
||||
}
|
||||
|
||||
function mergePluginSourceUrls(defaultSourceUrls: string[], sourceUrls: string[]): string[] {
|
||||
const mergedSourceUrls: string[] = [];
|
||||
|
||||
for (const sourceUrl of defaultSourceUrls.concat(sourceUrls)) {
|
||||
if (!mergedSourceUrls.includes(sourceUrl)) {
|
||||
mergedSourceUrls.push(sourceUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return mergedSourceUrls;
|
||||
}
|
||||
|
||||
function normalizePersistedServerPluginInstallState(value: unknown): { servers: Record<string, InstalledStorePlugin[]> } {
|
||||
if (!isRecord(value) || !isRecord(value['servers'])) {
|
||||
return { servers: {} };
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface PluginStoreReadme {
|
||||
|
||||
export interface PersistedPluginStoreState {
|
||||
installedPlugins: InstalledStorePlugin[];
|
||||
schemaVersion?: number;
|
||||
sourceUrls: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<div
|
||||
appThemeNode="contextMenuSurface"
|
||||
class="w-80 rounded-lg border border-border bg-card p-3 shadow-xl"
|
||||
role="menu"
|
||||
aria-label="Plugin actions"
|
||||
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-foreground">Plugins</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ actions().length }} available actions</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
aria-label="Close plugin menu"
|
||||
title="Close"
|
||||
(click)="close()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (actions().length > 0) {
|
||||
<div class="grid max-h-80 grid-cols-3 gap-2 overflow-auto pr-1">
|
||||
@for (record of actions(); track record.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="group flex min-h-20 flex-col items-center justify-start gap-2 rounded-md px-2 py-2 text-center transition-colors hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||
role="menuitem"
|
||||
[attr.aria-label]="actionTitle(record)"
|
||||
[title]="actionTitle(record)"
|
||||
(click)="runAction(record)"
|
||||
>
|
||||
<span
|
||||
class="grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-md border border-border bg-secondary text-xs font-semibold text-foreground transition-colors group-hover:border-primary/40"
|
||||
>
|
||||
@if (isImageIcon(record)) {
|
||||
<img
|
||||
class="h-full w-full object-cover"
|
||||
[src]="iconText(record)"
|
||||
[alt]="record.contribution.label"
|
||||
/>
|
||||
} @else {
|
||||
<span class="max-w-full truncate px-1">{{ iconText(record) }}</span>
|
||||
}
|
||||
</span>
|
||||
<span class="line-clamp-2 min-h-8 text-[11px] font-medium leading-4 text-foreground">
|
||||
{{ record.contribution.label }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="rounded-md border border-dashed border-border bg-background/40 px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
No plugin actions available.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,111 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
HostListener,
|
||||
computed,
|
||||
inject,
|
||||
output
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideX } from '@ng-icons/lucide';
|
||||
import type { PluginApiActionContribution } from '../../domain/models/plugin-api.models';
|
||||
import { PluginClientApiService } from '../../application/services/plugin-client-api.service';
|
||||
import { PluginLoggerService } from '../../application/services/plugin-logger.service';
|
||||
import { PluginRegistryService } from '../../application/services/plugin-registry.service';
|
||||
import type { PluginUiContributionRecord } from '../../application/services/plugin-ui-registry.service';
|
||||
import { PluginUiRegistryService } from '../../application/services/plugin-ui-registry.service';
|
||||
import { ThemeNodeDirective } from '../../../theme';
|
||||
|
||||
@Component({
|
||||
selector: 'app-plugin-action-menu',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideX })],
|
||||
templateUrl: './plugin-action-menu.component.html'
|
||||
})
|
||||
export class PluginActionMenuComponent {
|
||||
readonly closed = output<undefined>();
|
||||
|
||||
private readonly logger = inject(PluginLoggerService);
|
||||
private readonly pluginApi = inject(PluginClientApiService);
|
||||
private readonly pluginRegistry = inject(PluginRegistryService);
|
||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||
|
||||
readonly actions = computed(() => [...this.pluginUi.toolbarActionRecords()]
|
||||
.sort((left, right) => this.sortActionRecords(left, right)));
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
close(): void {
|
||||
this.closed.emit(undefined);
|
||||
}
|
||||
|
||||
runAction(record: PluginUiContributionRecord<PluginApiActionContribution>): void {
|
||||
this.closed.emit(undefined);
|
||||
|
||||
void Promise.resolve()
|
||||
.then(() => record.contribution.run(this.pluginApi.createActionContext('toolbarAction')))
|
||||
.catch((error: unknown) => this.logger.error(record.pluginId, 'Toolbar action failed', error));
|
||||
}
|
||||
|
||||
pluginName(pluginId: string): string {
|
||||
return this.pluginRegistry.find(pluginId)?.manifest.title ?? pluginId;
|
||||
}
|
||||
|
||||
actionTitle(record: PluginUiContributionRecord<PluginApiActionContribution>): string {
|
||||
return `${this.pluginName(record.pluginId)}: ${record.contribution.label}`;
|
||||
}
|
||||
|
||||
iconText(record: PluginUiContributionRecord<PluginApiActionContribution>): string {
|
||||
const icon = record.contribution.icon?.trim();
|
||||
|
||||
if (icon) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
return createInitials(this.pluginName(record.pluginId), record.contribution.label);
|
||||
}
|
||||
|
||||
isImageIcon(record: PluginUiContributionRecord<PluginApiActionContribution>): boolean {
|
||||
const icon = record.contribution.icon?.trim() ?? '';
|
||||
|
||||
return icon.startsWith('http://')
|
||||
|| icon.startsWith('https://')
|
||||
|| icon.startsWith('data:image/')
|
||||
|| icon.startsWith('blob:');
|
||||
}
|
||||
|
||||
private sortActionRecords(
|
||||
left: PluginUiContributionRecord<PluginApiActionContribution>,
|
||||
right: PluginUiContributionRecord<PluginApiActionContribution>
|
||||
): number {
|
||||
const leftPlugin = this.pluginName(left.pluginId);
|
||||
const rightPlugin = this.pluginName(right.pluginId);
|
||||
const pluginCompare = leftPlugin.localeCompare(rightPlugin);
|
||||
|
||||
if (pluginCompare !== 0) {
|
||||
return pluginCompare;
|
||||
}
|
||||
|
||||
return left.contribution.label.localeCompare(right.contribution.label);
|
||||
}
|
||||
}
|
||||
|
||||
function createInitials(pluginName: string, actionLabel: string): string {
|
||||
const words = `${pluginName} ${actionLabel}`
|
||||
.split(/[^a-zA-Z0-9]+/)
|
||||
.filter((word) => word.length > 0);
|
||||
|
||||
if (words.length === 0) {
|
||||
return 'PL';
|
||||
}
|
||||
|
||||
return words
|
||||
.slice(0, 2)
|
||||
.map((word) => word.charAt(0).toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
ElementRef,
|
||||
Injectable,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ConnectedPosition,
|
||||
Overlay,
|
||||
OverlayRef
|
||||
} from '@angular/cdk/overlay';
|
||||
import { ComponentPortal } from '@angular/cdk/portal';
|
||||
import {
|
||||
Subscription,
|
||||
filter,
|
||||
fromEvent
|
||||
} from 'rxjs';
|
||||
import { PluginActionMenuComponent } from './plugin-action-menu.component';
|
||||
|
||||
const GAP = 10;
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
const POSITIONS: ConnectedPosition[] = [
|
||||
{ originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top', offsetX: GAP },
|
||||
{ originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom', offsetX: GAP },
|
||||
{ originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top', offsetX: -GAP },
|
||||
{ originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom', offsetX: -GAP }
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PluginActionMenuService {
|
||||
private readonly overlay = inject(Overlay);
|
||||
private currentOrigin: HTMLElement | null = null;
|
||||
private overlayRef: OverlayRef | null = null;
|
||||
private overlaySubscriptions: Subscription | null = null;
|
||||
private scrollBlocker: (() => void) | null = null;
|
||||
|
||||
open(origin: ElementRef | HTMLElement): void {
|
||||
const rawEl = origin instanceof ElementRef ? origin.nativeElement : origin;
|
||||
|
||||
if (this.overlayRef) {
|
||||
const sameOrigin = rawEl === this.currentOrigin;
|
||||
|
||||
this.close();
|
||||
|
||||
if (sameOrigin) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin);
|
||||
|
||||
this.currentOrigin = rawEl;
|
||||
|
||||
const positionStrategy = this.overlay
|
||||
.position()
|
||||
.flexibleConnectedTo(elementRef)
|
||||
.withPositions(POSITIONS)
|
||||
.withViewportMargin(VIEWPORT_MARGIN)
|
||||
.withPush(true);
|
||||
|
||||
this.overlayRef = this.overlay.create({
|
||||
positionStrategy,
|
||||
scrollStrategy: this.overlay.scrollStrategies.noop()
|
||||
});
|
||||
|
||||
this.syncThemeVars();
|
||||
|
||||
const componentRef = this.overlayRef.attach(new ComponentPortal(PluginActionMenuComponent));
|
||||
const subscriptions = new Subscription();
|
||||
|
||||
subscriptions.add(componentRef.instance.closed.subscribe(() => this.close()));
|
||||
subscriptions.add(fromEvent<PointerEvent>(document, 'pointerdown')
|
||||
.pipe(
|
||||
filter((event) => {
|
||||
const target = event.target as Node;
|
||||
|
||||
if (this.overlayRef?.overlayElement.contains(target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.currentOrigin?.contains(target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
)
|
||||
.subscribe(() => this.close()));
|
||||
|
||||
this.overlaySubscriptions = subscriptions;
|
||||
this.blockScroll();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.scrollBlocker?.();
|
||||
this.scrollBlocker = null;
|
||||
this.overlaySubscriptions?.unsubscribe();
|
||||
this.overlaySubscriptions = null;
|
||||
|
||||
if (this.overlayRef) {
|
||||
this.overlayRef.dispose();
|
||||
this.overlayRef = null;
|
||||
this.currentOrigin = null;
|
||||
}
|
||||
}
|
||||
|
||||
private blockScroll(): void {
|
||||
const handler = (event: Event): void => {
|
||||
if (this.overlayRef?.overlayElement.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
const opts: AddEventListenerOptions = { passive: false, capture: true };
|
||||
|
||||
document.addEventListener('wheel', handler, opts);
|
||||
document.addEventListener('touchmove', handler, opts);
|
||||
|
||||
this.scrollBlocker = () => {
|
||||
document.removeEventListener('wheel', handler, opts);
|
||||
document.removeEventListener('touchmove', handler, opts);
|
||||
};
|
||||
}
|
||||
|
||||
private syncThemeVars(): void {
|
||||
const appRoot = document.querySelector<HTMLElement>('[data-theme-key="appRoot"]');
|
||||
const container = document.querySelector<HTMLElement>('.cdk-overlay-container');
|
||||
|
||||
if (!appRoot || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const prop of Array.from(appRoot.style)) {
|
||||
if (prop.startsWith('--')) {
|
||||
container.style.setProperty(prop, appRoot.style.getPropertyValue(prop));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,5 @@ export * from './domain/logic/plugin-manifest-validation.logic';
|
||||
export * from './domain/models/plugin-api.models';
|
||||
export * from './domain/models/plugin-runtime.models';
|
||||
export * from './domain/models/plugin-store.models';
|
||||
export * from './feature/plugin-action-menu/plugin-action-menu.service';
|
||||
export * from './infrastructure/local-plugin-discovery.service';
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
<section class="grid gap-2">
|
||||
<h3 class="text-sm font-semibold">Required before joining</h3>
|
||||
@for (requirement of dialog.required; track requirement.pluginId) {
|
||||
<div class="rounded-lg border border-border bg-background/50 px-3 py-2">
|
||||
<div class="grid gap-3 rounded-lg border border-border bg-background/50 px-3 py-2">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</p>
|
||||
@@ -333,6 +333,48 @@
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">Required</span>
|
||||
</div>
|
||||
|
||||
@if (requirement.manifest?.capabilities; as capabilities) {
|
||||
<details class="rounded-md border border-border bg-secondary/40 px-2 py-1.5 text-xs text-muted-foreground">
|
||||
<summary class="cursor-pointer font-semibold text-foreground">Capabilities</summary>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
@for (capability of capabilities; track capability) {
|
||||
<span class="rounded-full bg-background px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ capability }}</span>
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if (getPluginSourceUrl(requirement)) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="openPluginSource(requirement)"
|
||||
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold transition-colors hover:bg-secondary"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideExternalLink"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
Source
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (hasPluginReadme(requirement)) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="openPluginConsentReadme(requirement)"
|
||||
[disabled]="pluginConsentReadmeLoadingId() === requirement.pluginId"
|
||||
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideFileText"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
{{ pluginConsentReadmeLoadingId() === requirement.pluginId ? 'Loading' : 'Readme' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -342,25 +384,73 @@
|
||||
<section class="grid gap-2">
|
||||
<h3 class="text-sm font-semibold">Optional plugins</h3>
|
||||
@for (requirement of dialog.optional; track requirement.pluginId) {
|
||||
<label class="flex items-start gap-3 rounded-lg border border-border bg-background/50 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border bg-secondary"
|
||||
[checked]="selectedOptionalPluginIds().has(requirement.pluginId)"
|
||||
[disabled]="pluginConsentBusy()"
|
||||
(change)="toggleOptionalPluginInstall(requirement.pluginId, $any($event.target).checked)"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</span>
|
||||
@if (requirement.reason) {
|
||||
<span class="mt-1 block text-xs text-muted-foreground">{{ requirement.reason }}</span>
|
||||
<div class="grid gap-3 rounded-lg border border-border bg-background/50 px-3 py-2">
|
||||
<label class="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border bg-secondary"
|
||||
[checked]="selectedOptionalPluginIds().has(requirement.pluginId)"
|
||||
[disabled]="pluginConsentBusy()"
|
||||
(change)="toggleOptionalPluginInstall(requirement.pluginId, $any($event.target).checked)"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</span>
|
||||
@if (requirement.reason) {
|
||||
<span class="mt-1 block text-xs text-muted-foreground">{{ requirement.reason }}</span>
|
||||
}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@if (requirement.manifest?.capabilities; as capabilities) {
|
||||
<details class="rounded-md border border-border bg-secondary/40 px-2 py-1.5 text-xs text-muted-foreground">
|
||||
<summary class="cursor-pointer font-semibold text-foreground">Capabilities</summary>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
@for (capability of capabilities; track capability) {
|
||||
<span class="rounded-full bg-background px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ capability }}</span>
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if (getPluginSourceUrl(requirement)) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="openPluginSource(requirement)"
|
||||
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold transition-colors hover:bg-secondary"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideExternalLink"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
Source
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@if (hasPluginReadme(requirement)) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="openPluginConsentReadme(requirement)"
|
||||
[disabled]="pluginConsentReadmeLoadingId() === requirement.pluginId"
|
||||
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideFileText"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
{{ pluginConsentReadmeLoadingId() === requirement.pluginId ? 'Loading' : 'Readme' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (pluginConsentReadmeError()) {
|
||||
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ pluginConsentReadmeError() }}</p>
|
||||
}
|
||||
|
||||
@if (pluginConsentError()) {
|
||||
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ pluginConsentError() }}</p>
|
||||
}
|
||||
@@ -385,6 +475,46 @@
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@if (pluginConsentReadme(); as readme) {
|
||||
<div
|
||||
class="fixed inset-0 z-[52] bg-black/60"
|
||||
role="presentation"
|
||||
(click)="closePluginConsentReadme()"
|
||||
></div>
|
||||
<section
|
||||
class="fixed left-1/2 top-1/2 z-[53] flex max-h-[min(44rem,calc(100vh-2rem))] w-[min(44rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="join-plugin-readme-title"
|
||||
>
|
||||
<header class="flex items-start justify-between gap-3 border-b border-border p-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-muted-foreground">Plugin readme</p>
|
||||
<h2
|
||||
id="join-plugin-readme-title"
|
||||
class="mt-1 truncate text-lg font-semibold"
|
||||
>
|
||||
{{ readme.title }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="closePluginConsentReadme()"
|
||||
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title="Close readme"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="min-h-0 overflow-auto p-4 text-sm leading-6 [&_a]:text-primary [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-background [&_code]:px-1 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mb-2 [&_h2]:mt-4 [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:mb-1 [&_h3]:mt-3 [&_h3]:font-semibold [&_li]:ml-5 [&_ol]:list-decimal [&_p]:mb-3 [&_pre]:mb-3 [&_pre]:overflow-auto [&_pre]:rounded-lg [&_pre]:bg-background [&_pre]:p-3 [&_ul]:list-disc"
|
||||
>
|
||||
<app-chat-message-markdown [content]="readme.markdown" />
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Create Server Dialog -->
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from 'rxjs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideExternalLink,
|
||||
lucideFileText,
|
||||
lucideSearch,
|
||||
lucideUsers,
|
||||
lucideLock,
|
||||
@@ -39,6 +41,7 @@ import {
|
||||
User,
|
||||
type PluginRequirementSummary
|
||||
} from '../../../../shared-kernel';
|
||||
import { ExternalLinkService } from '../../../../core/platform';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { type ServerInfo } from '../../domain/models/server-directory.model';
|
||||
@@ -49,10 +52,15 @@ import {
|
||||
LeaveServerDialogComponent,
|
||||
type LeaveServerDialogResult
|
||||
} from '../../../../shared';
|
||||
import { ChatMessageMarkdownComponent } from '../../../chat';
|
||||
import { hasRoomBanForUser } from '../../../access-control';
|
||||
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { PluginRequirementService, PluginStoreService } from '../../../plugins';
|
||||
import {
|
||||
PluginRequirementService,
|
||||
PluginStoreService,
|
||||
type PluginStoreReadme
|
||||
} from '../../../plugins';
|
||||
|
||||
interface JoinPluginConsentDialog {
|
||||
optional: PluginRequirementSummary[];
|
||||
@@ -68,12 +76,15 @@ interface JoinPluginConsentDialog {
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ChatMessageMarkdownComponent,
|
||||
ConfirmDialogComponent,
|
||||
LeaveServerDialogComponent,
|
||||
UserSearchListComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideExternalLink,
|
||||
lucideFileText,
|
||||
lucideSearch,
|
||||
lucideUsers,
|
||||
lucideLock,
|
||||
@@ -94,6 +105,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
private router = inject(Router);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private db = inject(DatabaseService);
|
||||
private externalLinks = inject(ExternalLinkService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private pluginRequirements = inject(PluginRequirementService);
|
||||
@@ -122,6 +134,9 @@ export class ServerSearchComponent implements OnInit {
|
||||
selectedOptionalPluginIds = signal<Set<string>>(new Set());
|
||||
pluginConsentBusy = signal(false);
|
||||
pluginConsentError = signal<string | null>(null);
|
||||
pluginConsentReadme = signal<PluginStoreReadme | null>(null);
|
||||
pluginConsentReadmeLoadingId = signal<string | null>(null);
|
||||
pluginConsentReadmeError = signal<string | null>(null);
|
||||
|
||||
// Create dialog state
|
||||
showCreateDialog = signal(false);
|
||||
@@ -306,6 +321,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
this.pluginConsentDialog.set(null);
|
||||
this.selectedOptionalPluginIds.set(new Set());
|
||||
this.pluginConsentError.set(null);
|
||||
this.closePluginConsentReadme();
|
||||
}
|
||||
|
||||
toggleOptionalPluginInstall(pluginId: string, checked: boolean): void {
|
||||
@@ -345,6 +361,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
|
||||
this.pluginConsentDialog.set(null);
|
||||
this.selectedOptionalPluginIds.set(new Set());
|
||||
this.closePluginConsentReadme();
|
||||
} catch (error) {
|
||||
this.pluginConsentError.set(error instanceof Error ? error.message : 'Unable to install server plugins');
|
||||
} finally {
|
||||
@@ -352,6 +369,45 @@ export class ServerSearchComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
async openPluginConsentReadme(requirement: PluginRequirementSummary): Promise<void> {
|
||||
this.pluginConsentReadmeError.set(null);
|
||||
this.pluginConsentReadmeLoadingId.set(requirement.pluginId);
|
||||
|
||||
try {
|
||||
const readme = await this.pluginStore.loadRequirementReadme(requirement);
|
||||
|
||||
this.pluginConsentReadme.set(readme);
|
||||
} catch (error) {
|
||||
this.pluginConsentReadmeError.set(error instanceof Error ? error.message : 'Unable to load plugin readme');
|
||||
} finally {
|
||||
this.pluginConsentReadmeLoadingId.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
closePluginConsentReadme(): void {
|
||||
this.pluginConsentReadme.set(null);
|
||||
this.pluginConsentReadmeError.set(null);
|
||||
this.pluginConsentReadmeLoadingId.set(null);
|
||||
}
|
||||
|
||||
openPluginSource(requirement: PluginRequirementSummary): void {
|
||||
const sourceUrl = this.getPluginSourceUrl(requirement);
|
||||
|
||||
if (sourceUrl) {
|
||||
this.externalLinks.open(sourceUrl);
|
||||
}
|
||||
}
|
||||
|
||||
getPluginSourceUrl(requirement: PluginRequirementSummary): string | null {
|
||||
const candidate = requirement.manifest?.homepage ?? requirement.sourceUrl ?? requirement.installUrl ?? requirement.manifest?.bugs ?? null;
|
||||
|
||||
return candidate?.startsWith('http://') || candidate?.startsWith('https://') ? candidate : null;
|
||||
}
|
||||
|
||||
hasPluginReadme(requirement: PluginRequirementSummary): boolean {
|
||||
return !!requirement.manifest?.readme;
|
||||
}
|
||||
|
||||
async confirmPasswordJoin(): Promise<void> {
|
||||
const server = this.passwordPromptServer();
|
||||
|
||||
|
||||
@@ -80,6 +80,8 @@ stateDiagram-v2
|
||||
|
||||
When a voice session is active and the user navigates away from the voice-connected server, `showFloatingControls` becomes `true` and the floating overlay appears. Clicking the overlay dispatches `RoomsActions.viewServer` to navigate back.
|
||||
|
||||
Joining a new voice target is exclusive: entering another voice channel or private call first disconnects the current call/channel, clears local voice state, and broadcasts the leave for the previous target. Users never need to manually leave one voice target before joining another.
|
||||
|
||||
Remote voice playback is scoped to the active voice channel, not the whole server. Users stay connected to the shared peer mesh for text, presence, and screen-share control, but voice transport and playback only stay active for peers whose `voiceState.roomId` and `voiceState.serverId` match the local user's current voice session.
|
||||
|
||||
Owners and admins can also move connected users between voice channels from the room sidebar by dragging a user onto a different voice channel. The moved client updates its local heartbeat and voice-session metadata to the new channel, so routing, floating controls, and occupancy stay in sync after the move.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<div class="flex w-full flex-wrap items-center justify-center gap-3 rounded-2xl bg-background/75 px-4 py-3 backdrop-blur">
|
||||
@if (!connected()) {
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-12 items-center gap-2 rounded-full bg-emerald-500 px-6 text-sm font-semibold text-white transition-colors hover:bg-emerald-600"
|
||||
(click)="joinRequested.emit()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePhone"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
Join call
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
|
||||
[disabled]="!connected()"
|
||||
(click)="muteToggled.emit()"
|
||||
[attr.aria-label]="muted() ? 'Unmute' : 'Mute'"
|
||||
[title]="muted() ? 'Unmute' : 'Mute'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="muted() ? 'lucideMicOff' : 'lucideMic'"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
|
||||
[disabled]="!connected()"
|
||||
(click)="cameraToggled.emit()"
|
||||
[attr.aria-label]="cameraEnabled() ? 'Turn camera off' : 'Turn camera on'"
|
||||
[title]="cameraEnabled() ? 'Turn camera off' : 'Turn camera on'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="cameraEnabled() ? 'lucideVideoOff' : 'lucideVideo'"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
|
||||
[disabled]="!connected()"
|
||||
(click)="screenShareToggled.emit()"
|
||||
[attr.aria-label]="screenSharing() ? 'Stop sharing screen' : 'Share screen'"
|
||||
[title]="screenSharing() ? 'Stop sharing screen' : 'Share screen'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="screenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-12 w-12 place-items-center rounded-full bg-destructive/10 text-destructive transition-colors hover:bg-destructive/15 disabled:opacity-45"
|
||||
[disabled]="!connected()"
|
||||
(click)="leaveRequested.emit()"
|
||||
aria-label="Leave call"
|
||||
title="Leave call"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePhoneOff"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhone,
|
||||
lucidePhoneOff,
|
||||
lucideVideo,
|
||||
lucideVideoOff
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
@Component({
|
||||
selector: 'app-private-call-controls',
|
||||
standalone: true,
|
||||
imports: [NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhone,
|
||||
lucidePhoneOff,
|
||||
lucideVideo,
|
||||
lucideVideoOff
|
||||
})
|
||||
],
|
||||
templateUrl: './private-call-controls.component.html'
|
||||
})
|
||||
export class PrivateCallControlsComponent {
|
||||
readonly connected = input.required<boolean>();
|
||||
readonly muted = input.required<boolean>();
|
||||
readonly cameraEnabled = input.required<boolean>();
|
||||
readonly screenSharing = input.required<boolean>();
|
||||
|
||||
readonly joinRequested = output<void>();
|
||||
readonly muteToggled = output<void>();
|
||||
readonly cameraToggled = output<void>();
|
||||
readonly screenShareToggled = output<void>();
|
||||
readonly leaveRequested = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<article
|
||||
class="flex aspect-square min-w-0 flex-col items-center justify-center overflow-hidden rounded-2xl border border-border/80 bg-card/80 text-center shadow-sm backdrop-blur"
|
||||
[class.w-[11rem]]="compact()"
|
||||
[class.shrink-0]="compact()"
|
||||
[class.p-4]="compact()"
|
||||
[class.sm:w-[12.5rem]]="compact()"
|
||||
[class.w-full]="!compact()"
|
||||
[class.p-[clamp(1rem,4vw,1.5rem)]]="!compact()"
|
||||
>
|
||||
<div
|
||||
class="relative h-[var(--participant-avatar-size)] w-[var(--participant-avatar-size)] rounded-full ring-2 transition-all duration-150 sm:h-[var(--participant-avatar-size-sm)] sm:w-[var(--participant-avatar-size-sm)]"
|
||||
[attr.data-testid]="'call-participant-' + (user().oderId || user().id)"
|
||||
[style.--participant-avatar-size]="avatarSize()"
|
||||
[style.--participant-avatar-size-sm]="avatarSizeSm()"
|
||||
[class.p-1.5]="compact()"
|
||||
[class.p-2]="!compact()"
|
||||
[class.ring-emerald-400]="speaking()"
|
||||
[class.shadow-[0_0_0_6px_rgba(16,185,129,0.12)]]="speaking() && compact()"
|
||||
[class.shadow-[0_0_0_8px_rgba(16,185,129,0.12)]]="speaking() && !compact()"
|
||||
[class.ring-border]="!speaking()"
|
||||
[class.opacity-55]="!connected()"
|
||||
>
|
||||
@if (user().avatarUrl) {
|
||||
<img
|
||||
[src]="user().avatarUrl"
|
||||
[alt]="user().displayName"
|
||||
[width]="compact() ? 96 : 160"
|
||||
[height]="compact() ? 96 : 160"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
class="block h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="grid h-full w-full place-items-center rounded-full bg-primary/15 font-semibold text-primary"
|
||||
[class.text-3xl]="compact()"
|
||||
[class.text-[clamp(1.75rem,8vw,3.5rem)]]="!compact()"
|
||||
>
|
||||
{{ participantInitial() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!connected()) {
|
||||
<div
|
||||
class="absolute grid place-items-center rounded-full bg-background/72 backdrop-blur-[1px]"
|
||||
[class.inset-1.5]="compact()"
|
||||
[class.inset-2]="!compact()"
|
||||
>
|
||||
<div
|
||||
class="grid place-items-center rounded-full border border-border bg-card text-muted-foreground shadow-sm"
|
||||
[class.h-10]="compact()"
|
||||
[class.w-10]="compact()"
|
||||
[class.h-14]="!compact()"
|
||||
[class.w-14]="!compact()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideWifiOff"
|
||||
[class.h-5]="compact()"
|
||||
[class.w-5]="compact()"
|
||||
[class.h-7]="!compact()"
|
||||
[class.w-7]="!compact()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (connected()) {
|
||||
<span
|
||||
class="absolute rounded-full border-card"
|
||||
[class.bottom-3]="compact()"
|
||||
[class.right-3]="compact()"
|
||||
[class.h-4]="compact()"
|
||||
[class.w-4]="compact()"
|
||||
[class.border-[3px]]="compact()"
|
||||
[class.bottom-5]="!compact()"
|
||||
[class.right-5]="!compact()"
|
||||
[class.h-5]="!compact()"
|
||||
[class.w-5]="!compact()"
|
||||
[class.border-4]="!compact()"
|
||||
[class.bg-emerald-400]="speaking()"
|
||||
[class.bg-muted-foreground]="!speaking()"
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="min-w-0 max-w-full"
|
||||
[class.mt-3]="compact()"
|
||||
[class.mt-5]="!compact()"
|
||||
>
|
||||
<h2
|
||||
class="truncate font-semibold text-foreground"
|
||||
[class.text-sm]="compact()"
|
||||
[class.text-[clamp(1rem,4vw,1.25rem)]]="!compact()"
|
||||
>
|
||||
{{ user().displayName }}
|
||||
</h2>
|
||||
@if (issueLabel(); as label) {
|
||||
<p class="mt-1 text-xs font-semibold text-muted-foreground">{{ label }}</p>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideWifiOff } from '@ng-icons/lucide';
|
||||
import type { User } from '../../shared-kernel';
|
||||
|
||||
@Component({
|
||||
selector: 'app-private-call-participant-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideWifiOff })],
|
||||
host: { class: 'block min-w-0' },
|
||||
templateUrl: './private-call-participant-card.component.html'
|
||||
})
|
||||
export class PrivateCallParticipantCardComponent {
|
||||
readonly user = input.required<User>();
|
||||
readonly connected = input.required<boolean>();
|
||||
readonly speaking = input.required<boolean>();
|
||||
readonly issueLabel = input<string | null>(null);
|
||||
readonly compact = input(false);
|
||||
|
||||
avatarSize(): string {
|
||||
return this.compact() ? '5rem' : 'clamp(4.25rem, 22vw, 10rem)';
|
||||
}
|
||||
|
||||
avatarSizeSm(): string {
|
||||
return this.compact() ? '6rem' : this.avatarSize();
|
||||
}
|
||||
|
||||
participantInitial(): string {
|
||||
return this.user().displayName.charAt(0).toUpperCase() || '?';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<section
|
||||
class="grid h-full min-h-0 bg-background lg:grid-cols-[minmax(0,1fr)_var(--private-call-chat-width)]"
|
||||
[style.--private-call-chat-width]="chatWidthPx() + 'px'"
|
||||
>
|
||||
<main class="flex min-h-0 min-w-0 flex-col overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.10),transparent_34rem)]">
|
||||
<header class="flex min-h-16 shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-5 backdrop-blur">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-emerald-500/10 text-emerald-500">
|
||||
<ng-icon
|
||||
name="lucidePhone"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-base font-semibold text-foreground">Private Call</h1>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
@if (session()) {
|
||||
{{ participantUsers().length }} participants
|
||||
} @else {
|
||||
Call not found
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (session()) {
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
class="h-9 max-w-44 rounded-md border border-border bg-secondary px-2 text-sm text-foreground"
|
||||
[ngModel]="inviteUserId()"
|
||||
(ngModelChange)="inviteUserId.set($event)"
|
||||
aria-label="Add user to call"
|
||||
>
|
||||
<option value="">Add user</option>
|
||||
@for (user of inviteCandidates(); track userKey(user)) {
|
||||
<option [value]="userKey(user)">{{ user.displayName }}</option>
|
||||
}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-50"
|
||||
[disabled]="!inviteUserId()"
|
||||
(click)="inviteSelectedUser()"
|
||||
aria-label="Add user"
|
||||
title="Add user"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUserPlus"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (session()) {
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-4 sm:px-5">
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden rounded-2xl border border-border/80 bg-card/45 shadow-sm">
|
||||
@if (activeShares().length > 0) {
|
||||
@if (focusedShare()) {
|
||||
@if (hasMultipleShares()) {
|
||||
<div class="absolute right-3 top-3 z-10 sm:right-4 sm:top-4">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="private-call-show-all-streams"
|
||||
class="inline-flex h-10 items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 text-xs font-medium text-white/80 backdrop-blur transition hover:bg-black/65 hover:text-white"
|
||||
title="Show all streams"
|
||||
(click)="showAllStreams()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
All streams
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-voice-workspace-stream-tile
|
||||
[item]="focusedShare()!"
|
||||
[featured]="true"
|
||||
[focused]="true"
|
||||
data-testid="private-call-focused-stream"
|
||||
[immersive]="true"
|
||||
(focusRequested)="focusShare($event)"
|
||||
/>
|
||||
} @else if (hasMultipleShares()) {
|
||||
<div
|
||||
class="grid h-full min-h-0 auto-rows-[minmax(12rem,1fr)] grid-cols-1 gap-3 p-3 sm:grid-cols-2 sm:gap-4 sm:p-4"
|
||||
[ngClass]="{ '2xl:grid-cols-3': activeShares().length > 2 }"
|
||||
data-testid="private-call-stream-grid"
|
||||
>
|
||||
@for (share of activeShares(); track share.id) {
|
||||
<div class="min-h-0 overflow-hidden rounded-2xl bg-black">
|
||||
<app-voice-workspace-stream-tile
|
||||
[item]="share"
|
||||
[focused]="false"
|
||||
(focusRequested)="focusShare($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="flex h-full min-h-0 items-center justify-center p-4 sm:p-6">
|
||||
<div
|
||||
class="grid w-full max-w-5xl grid-cols-[repeat(auto-fit,minmax(min(10rem,100%),1fr))] items-stretch justify-center gap-3 sm:grid-cols-[repeat(auto-fit,minmax(min(13rem,100%),1fr))] sm:gap-5 lg:gap-7"
|
||||
>
|
||||
<app-private-call-participant-card
|
||||
*ngFor="let user of participantUsers(); trackBy: trackUserKey"
|
||||
[user]="user"
|
||||
[connected]="isParticipantConnected(user)"
|
||||
[speaking]="isSpeaking(user)"
|
||||
[issueLabel]="participantIssueLabel(user)"
|
||||
></app-private-call-participant-card>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (activeShares().length > 0) {
|
||||
<div class="shrink-0 pt-4">
|
||||
<div class="flex w-full items-stretch gap-3 overflow-x-auto pb-1">
|
||||
<app-private-call-participant-card
|
||||
*ngFor="let user of participantUsers(); trackBy: trackUserKey"
|
||||
[user]="user"
|
||||
[connected]="isParticipantConnected(user)"
|
||||
[speaking]="isSpeaking(user)"
|
||||
[issueLabel]="participantIssueLabel(user)"
|
||||
[compact]="true"
|
||||
></app-private-call-participant-card>
|
||||
|
||||
@if (hasMultipleShares()) {
|
||||
@for (share of focusedShare() ? thumbnailShares() : activeShares(); track share.id) {
|
||||
<article
|
||||
class="flex min-h-[8.75rem] w-[11rem] shrink-0 flex-col overflow-hidden rounded-2xl border border-border/80 bg-black shadow-sm sm:w-[12.5rem]"
|
||||
>
|
||||
<div class="min-h-0 flex-1">
|
||||
<app-voice-workspace-stream-tile
|
||||
[item]="share"
|
||||
[mini]="true"
|
||||
[focused]="false"
|
||||
(focusRequested)="focusShare($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="shrink-0 bg-black/80 px-3 py-2 text-xs font-semibold text-white/75">
|
||||
{{ streamLabel(share) }}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="shrink-0 pt-3">
|
||||
<app-private-call-controls
|
||||
class="mx-auto block w-full max-w-5xl"
|
||||
[connected]="isConnected()"
|
||||
[muted]="isMuted()"
|
||||
[cameraEnabled]="isCameraEnabled()"
|
||||
[screenSharing]="isScreenSharing()"
|
||||
(joinRequested)="join()"
|
||||
(muteToggled)="toggleMute()"
|
||||
(cameraToggled)="toggleCamera()"
|
||||
(screenShareToggled)="toggleScreenShare()"
|
||||
(leaveRequested)="leave()"
|
||||
></app-private-call-controls>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">No active call for this route.</div>
|
||||
}
|
||||
</main>
|
||||
|
||||
<aside class="relative hidden min-h-0 border-l border-border bg-card lg:block">
|
||||
<div
|
||||
class="group absolute inset-y-0 left-0 z-10 w-3 -translate-x-1/2 cursor-col-resize bg-transparent"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title="Resize chat"
|
||||
data-testid="private-call-chat-resizer"
|
||||
(mousedown)="startChatResize($event)"
|
||||
>
|
||||
<div class="mx-auto h-full w-px bg-border transition group-hover:bg-primary"></div>
|
||||
</div>
|
||||
<app-dm-chat
|
||||
[conversationId]="session()?.conversationId ?? null"
|
||||
[showCallButton]="false"
|
||||
/>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
@if (showScreenShareQualityDialog()) {
|
||||
<app-screen-share-quality-dialog
|
||||
[selectedQuality]="screenShareQuality()"
|
||||
[includeSystemAudio]="includeSystemAudio()"
|
||||
(cancelled)="onScreenShareQualityCancelled()"
|
||||
(confirmed)="onScreenShareQualityConfirmed($event)"
|
||||
/>
|
||||
}
|
||||
564
toju-app/src/app/features/direct-call/private-call.component.ts
Normal file
564
toju-app/src/app/features/direct-call/private-call.component.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
HostListener,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
untracked
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucidePhone,
|
||||
lucideUsers,
|
||||
lucideUserPlus
|
||||
} from '@ng-icons/lucide';
|
||||
import { map } from 'rxjs';
|
||||
import {
|
||||
DirectCallService,
|
||||
participantToUser,
|
||||
type DirectCallSession
|
||||
} from '../../domains/direct-call';
|
||||
import { DmChatComponent } from '../../domains/direct-message/feature/dm-chat/dm-chat.component';
|
||||
import {
|
||||
VoiceActivityService,
|
||||
VoiceConnectionFacade,
|
||||
VoicePlaybackService
|
||||
} from '../../domains/voice-connection';
|
||||
import {
|
||||
ScreenShareFacade,
|
||||
ScreenShareQuality,
|
||||
ScreenShareStartOptions
|
||||
} from '../../domains/screen-share';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session';
|
||||
import { ScreenShareQualityDialogComponent } from '../../shared';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { UsersActions } from '../../store/users/users.actions';
|
||||
import { User } from '../../shared-kernel';
|
||||
import { VoiceWorkspaceStreamItem } from '../room/voice-workspace/voice-workspace.models';
|
||||
import { VoiceWorkspaceStreamTileComponent } from '../room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component';
|
||||
import { PrivateCallControlsComponent } from './private-call-controls.component';
|
||||
import { PrivateCallParticipantCardComponent } from './private-call-participant-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-private-call',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
DmChatComponent,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
PrivateCallControlsComponent,
|
||||
PrivateCallParticipantCardComponent,
|
||||
ScreenShareQualityDialogComponent,
|
||||
VoiceWorkspaceStreamTileComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucidePhone,
|
||||
lucideUsers,
|
||||
lucideUserPlus
|
||||
})
|
||||
],
|
||||
templateUrl: './private-call.component.html'
|
||||
})
|
||||
export class PrivateCallComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly store = inject(Store);
|
||||
private readonly calls = inject(DirectCallService);
|
||||
private readonly voice = inject(VoiceConnectionFacade);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly playback = inject(VoicePlaybackService);
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
private chatResizing = false;
|
||||
|
||||
readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
readonly callId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), {
|
||||
initialValue: this.route.snapshot.paramMap.get('callId')
|
||||
});
|
||||
readonly session = computed(() => this.calls.sessionById(this.callId()));
|
||||
readonly participantUsers = computed(() => {
|
||||
const session = this.session();
|
||||
|
||||
if (!session) {
|
||||
return [] as User[];
|
||||
}
|
||||
|
||||
return session.participantIds
|
||||
.map((participantId) => this.userForSessionParticipant(session, participantId))
|
||||
.filter((user): user is User => !!user);
|
||||
});
|
||||
readonly isConnected = computed(() => {
|
||||
const session = this.session();
|
||||
const currentUserId = this.currentUserKey();
|
||||
|
||||
return !!session && !!currentUserId && !!session.participants[currentUserId]?.joined;
|
||||
});
|
||||
readonly isMuted = this.voice.isMuted;
|
||||
readonly isDeafened = this.voice.isDeafened;
|
||||
readonly isCameraEnabled = this.voice.isCameraEnabled;
|
||||
readonly isScreenSharing = this.screenShare.isScreenSharing;
|
||||
readonly remoteStreamRevision = signal(0);
|
||||
readonly includeSystemAudio = signal(false);
|
||||
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
readonly askScreenShareQuality = signal(true);
|
||||
readonly showScreenShareQualityDialog = signal(false);
|
||||
readonly inviteUserId = signal('');
|
||||
readonly focusedStreamId = signal<string | null>(null);
|
||||
readonly showAllStreamsMode = signal(false);
|
||||
readonly chatWidthPx = signal(384);
|
||||
readonly inviteCandidates = computed(() => {
|
||||
const participantIds = new Set(this.session()?.participantIds ?? []);
|
||||
const currentUserId = this.currentUserKey();
|
||||
|
||||
return this.allUsers().filter((user) => {
|
||||
const userId = this.userKey(user);
|
||||
|
||||
return userId !== currentUserId && !participantIds.has(userId);
|
||||
});
|
||||
});
|
||||
readonly activeShares = computed<VoiceWorkspaceStreamItem[]>(() => {
|
||||
this.remoteStreamRevision();
|
||||
|
||||
const shares: VoiceWorkspaceStreamItem[] = [];
|
||||
const localUser = this.currentUser();
|
||||
const localPeerKey = localUser ? this.userKey(localUser) : null;
|
||||
const isJoinedToCurrentCall = this.isConnected();
|
||||
const localScreenStream = isJoinedToCurrentCall ? this.screenShare.screenStream() : null;
|
||||
const localCameraStream = isJoinedToCurrentCall && this.voice.isCameraEnabled() ? this.voice.getLocalCameraStream() : null;
|
||||
|
||||
if (localUser && localPeerKey && localScreenStream) {
|
||||
shares.push(this.buildShare(localPeerKey, localUser, localScreenStream, true, 'screen'));
|
||||
}
|
||||
|
||||
if (localUser && localPeerKey && localCameraStream) {
|
||||
shares.push(this.buildShare(localPeerKey, localUser, localCameraStream, true, 'camera'));
|
||||
}
|
||||
|
||||
for (const user of this.participantUsers()) {
|
||||
const peerKey = this.getPeerKeyCandidates(user).find(
|
||||
(candidate) => candidate !== localPeerKey
|
||||
&& (
|
||||
!!this.screenShare.getRemoteScreenShareStream(candidate)
|
||||
|| !!this.voice.getRemoteCameraStream(candidate)
|
||||
)
|
||||
) ?? this.userKey(user);
|
||||
|
||||
if (peerKey === localPeerKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const screenStream = this.screenShare.getRemoteScreenShareStream(peerKey);
|
||||
const cameraStream = this.voice.getRemoteCameraStream(peerKey);
|
||||
|
||||
if (screenStream && this.hasActiveVideo(screenStream)) {
|
||||
shares.push(this.buildShare(peerKey, user, screenStream, false, 'screen'));
|
||||
}
|
||||
|
||||
if (cameraStream && this.hasActiveVideo(cameraStream)) {
|
||||
shares.push(this.buildShare(peerKey, user, cameraStream, false, 'camera'));
|
||||
}
|
||||
}
|
||||
|
||||
return shares;
|
||||
});
|
||||
readonly featuredShare = computed(() => this.activeShares()[0] ?? null);
|
||||
readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
|
||||
readonly focusedShareId = computed(() => {
|
||||
const requested = this.focusedStreamId();
|
||||
const activeShares = this.activeShares();
|
||||
|
||||
if (this.showAllStreamsMode() && activeShares.length > 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (requested && activeShares.some((share) => share.id === requested)) {
|
||||
return requested;
|
||||
}
|
||||
|
||||
if (activeShares.length === 1) {
|
||||
return activeShares[0].id;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
readonly focusedShare = computed(
|
||||
() => this.activeShares().find((share) => share.id === this.focusedShareId()) ?? null
|
||||
);
|
||||
readonly thumbnailShares = computed(() => {
|
||||
const focusedShareId = this.focusedShareId();
|
||||
|
||||
if (!focusedShareId) {
|
||||
return [] as VoiceWorkspaceStreamItem[];
|
||||
}
|
||||
|
||||
return this.activeShares().filter((share) => share.id !== focusedShareId);
|
||||
});
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const callId = this.callId();
|
||||
|
||||
if (callId) {
|
||||
untracked(() => void this.calls.openCall(callId));
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const session = this.session();
|
||||
|
||||
if (session && !this.calls.hasOngoingActivity(session)) {
|
||||
untracked(() => void this.router.navigate(['/dm', session.conversationId], { replaceUrl: true }));
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const session = this.session();
|
||||
const currentUserId = this.currentUserKey();
|
||||
const peerIds = (session ? this.remoteParticipantPeerIds(session, currentUserId) : []);
|
||||
|
||||
this.screenShare.syncRemoteScreenShareRequests(peerIds, this.isConnected() && !!session && session.status === 'connected');
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.session();
|
||||
|
||||
if (this.isConnected()) {
|
||||
this.trackLocalMic();
|
||||
return;
|
||||
}
|
||||
|
||||
this.untrackLocalMic();
|
||||
});
|
||||
|
||||
this.screenShare.onRemoteStream
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.bumpRemoteStreamRevision());
|
||||
|
||||
this.screenShare.onPeerDisconnected
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.bumpRemoteStreamRevision());
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.screenShare.syncRemoteScreenShareRequests([], false);
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('window:mousemove', ['$event'])
|
||||
onWindowMouseMove(event: MouseEvent): void {
|
||||
if (!this.chatResizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.chatWidthPx.set(this.clampChatWidth(window.innerWidth - event.clientX));
|
||||
}
|
||||
|
||||
@HostListener('window:mouseup')
|
||||
onWindowMouseUp(): void {
|
||||
this.chatResizing = false;
|
||||
}
|
||||
|
||||
async join(): Promise<void> {
|
||||
const session = this.session();
|
||||
|
||||
if (session) {
|
||||
await this.calls.joinCall(session.callId);
|
||||
}
|
||||
}
|
||||
|
||||
leave(): void {
|
||||
const session = this.session();
|
||||
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.calls.leaveCall(session.callId);
|
||||
this.untrackLocalMic();
|
||||
void this.router.navigate(['/dm', session.conversationId]);
|
||||
}
|
||||
|
||||
toggleMute(): void {
|
||||
this.voice.toggleMute(!this.isMuted());
|
||||
this.broadcastLocalVoiceState();
|
||||
}
|
||||
|
||||
toggleDeafen(): void {
|
||||
const nextDeafened = !this.isDeafened();
|
||||
|
||||
this.voice.toggleDeafen(nextDeafened);
|
||||
this.playback.updateDeafened(nextDeafened);
|
||||
|
||||
if (nextDeafened && !this.isMuted()) {
|
||||
this.voice.toggleMute(true);
|
||||
}
|
||||
|
||||
this.broadcastLocalVoiceState();
|
||||
}
|
||||
|
||||
async toggleCamera(): Promise<void> {
|
||||
const user = this.currentUser();
|
||||
|
||||
if (!this.isConnected() || !user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isCameraEnabled()) {
|
||||
this.voice.disableCamera();
|
||||
this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: false } }));
|
||||
this.bumpRemoteStreamRevision();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.voice.enableCamera();
|
||||
this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: true } }));
|
||||
this.bumpRemoteStreamRevision();
|
||||
}
|
||||
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
if (this.isScreenSharing()) {
|
||||
this.screenShare.stopScreenShare();
|
||||
this.bumpRemoteStreamRevision();
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncScreenShareSettings();
|
||||
|
||||
if (this.askScreenShareQuality()) {
|
||||
this.showScreenShareQualityDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startScreenShareWithOptions(this.screenShareQuality());
|
||||
}
|
||||
|
||||
onScreenShareQualityCancelled(): void {
|
||||
this.showScreenShareQualityDialog.set(false);
|
||||
}
|
||||
|
||||
async onScreenShareQualityConfirmed(quality: ScreenShareQuality): Promise<void> {
|
||||
this.showScreenShareQualityDialog.set(false);
|
||||
this.screenShareQuality.set(quality);
|
||||
saveVoiceSettingsToStorage({ screenShareQuality: quality });
|
||||
await this.startScreenShareWithOptions(quality);
|
||||
}
|
||||
|
||||
inviteSelectedUser(): void {
|
||||
const callId = this.callId();
|
||||
const userId = this.inviteUserId();
|
||||
const user = this.allUsers().find((candidate) => this.userKey(candidate) === userId);
|
||||
|
||||
if (!callId || !user) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.calls.inviteUser(callId, user);
|
||||
this.inviteUserId.set('');
|
||||
}
|
||||
|
||||
isSpeaking(user: User): boolean {
|
||||
return this.voiceActivity.isSpeaking(this.userKey(user))();
|
||||
}
|
||||
|
||||
isParticipantConnected(user: User): boolean {
|
||||
const session = this.session();
|
||||
const userId = this.userKey(user);
|
||||
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!session.participants[userId]?.joined
|
||||
|| !!(
|
||||
user.voiceState?.isConnected
|
||||
&& user.voiceState.roomId === session.callId
|
||||
&& user.voiceState.serverId === session.callId
|
||||
);
|
||||
}
|
||||
|
||||
participantIssueLabel(user: User): string | null {
|
||||
return this.isParticipantConnected(user) ? null : 'Waiting';
|
||||
}
|
||||
|
||||
streamLabel(share: VoiceWorkspaceStreamItem): string {
|
||||
if (!share.isLocal) {
|
||||
return share.user.displayName;
|
||||
}
|
||||
|
||||
return share.kind === 'camera' ? 'Your camera' : 'Your screen';
|
||||
}
|
||||
|
||||
focusShare(shareId: string): void {
|
||||
this.showAllStreamsMode.set(false);
|
||||
this.focusedStreamId.set(shareId);
|
||||
}
|
||||
|
||||
showAllStreams(): void {
|
||||
this.showAllStreamsMode.set(true);
|
||||
this.focusedStreamId.set(null);
|
||||
}
|
||||
|
||||
startChatResize(event: MouseEvent): void {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.chatResizing = true;
|
||||
}
|
||||
|
||||
userKey(user: User): string {
|
||||
return user.oderId || user.id;
|
||||
}
|
||||
|
||||
readonly trackUserKey = (index: number, user: User): string => this.userKey(user);
|
||||
|
||||
private currentUserKey(): string {
|
||||
const user = this.currentUser();
|
||||
|
||||
return user ? this.userKey(user) : '';
|
||||
}
|
||||
|
||||
private broadcastLocalVoiceState(): void {
|
||||
const session = this.session();
|
||||
const user = this.currentUser();
|
||||
|
||||
if (!session || !user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
roomId: session.callId,
|
||||
serverId: session.callId
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private remoteParticipantPeerIds(session: DirectCallSession, currentUserId: string): string[] {
|
||||
const peerIds = new Set<string>();
|
||||
|
||||
for (const participantId of session.participantIds) {
|
||||
if (participantId === currentUserId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const user = this.userForSessionParticipant(session, participantId);
|
||||
|
||||
for (const peerId of [participantId, ...this.getPeerKeyCandidates(user)]) {
|
||||
if (peerId && peerId !== currentUserId) {
|
||||
peerIds.add(peerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(peerIds);
|
||||
}
|
||||
|
||||
private clampChatWidth(width: number): number {
|
||||
const maxWidth = Math.min(640, Math.max(360, window.innerWidth - 560));
|
||||
|
||||
return Math.round(Math.max(320, Math.min(maxWidth, width)));
|
||||
}
|
||||
|
||||
private getPeerKeyCandidates(user: User | null | undefined): string[] {
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
user.oderId,
|
||||
user.peerId,
|
||||
user.id
|
||||
].filter((peerId, index, peerIds): peerId is string => !!peerId && peerIds.indexOf(peerId) === index);
|
||||
}
|
||||
|
||||
private userForSessionParticipant(session: DirectCallSession, participantId: string): User | null {
|
||||
const knownUser = this.calls.userForParticipant(participantId);
|
||||
|
||||
if (knownUser) {
|
||||
return knownUser;
|
||||
}
|
||||
|
||||
const participant = session.participants[participantId]?.profile;
|
||||
|
||||
return participant ? participantToUser(participant) : null;
|
||||
}
|
||||
|
||||
private trackLocalMic(): void {
|
||||
const userId = this.currentUserKey();
|
||||
const stream = this.voice.getRawMicStream() ?? this.voice.getLocalStream();
|
||||
|
||||
if (userId && stream) {
|
||||
this.voiceActivity.trackLocalMic(userId, stream);
|
||||
}
|
||||
}
|
||||
|
||||
private untrackLocalMic(): void {
|
||||
const userId = this.currentUserKey();
|
||||
|
||||
if (userId) {
|
||||
this.voiceActivity.untrackLocalMic(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private syncScreenShareSettings(): void {
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.includeSystemAudio.set(settings.includeSystemAudio);
|
||||
this.screenShareQuality.set(settings.screenShareQuality);
|
||||
this.askScreenShareQuality.set(settings.askScreenShareQuality);
|
||||
}
|
||||
|
||||
private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise<void> {
|
||||
const options: ScreenShareStartOptions = {
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
quality
|
||||
};
|
||||
|
||||
try {
|
||||
await this.screenShare.startScreenShare(options);
|
||||
this.bumpRemoteStreamRevision();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private buildShare(
|
||||
peerKey: string,
|
||||
user: User,
|
||||
stream: MediaStream,
|
||||
isLocal: boolean,
|
||||
kind: VoiceWorkspaceStreamItem['kind']
|
||||
): VoiceWorkspaceStreamItem {
|
||||
return {
|
||||
id: `${kind}:${peerKey}`,
|
||||
peerKey,
|
||||
user,
|
||||
stream,
|
||||
isLocal,
|
||||
kind,
|
||||
hasAudio: stream.getAudioTracks().some((track) => track.readyState === 'live')
|
||||
};
|
||||
}
|
||||
|
||||
private hasActiveVideo(stream: MediaStream): boolean {
|
||||
return stream.getVideoTracks().some((track) => track.readyState === 'live');
|
||||
}
|
||||
|
||||
private bumpRemoteStreamRevision(): void {
|
||||
this.remoteStreamRevision.update((value) => value + 1);
|
||||
}
|
||||
}
|
||||
@@ -259,13 +259,26 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (pluginChannelSections().length > 0 || pluginSidePanels().length > 0) {
|
||||
@if (pluginChannelSections().length > 0 || pluginMenuActions().length > 0 || pluginSidePanels().length > 0) {
|
||||
<section
|
||||
class="border-t border-border px-2 py-3"
|
||||
data-testid="plugin-room-side-panel"
|
||||
>
|
||||
<div class="mb-2 px-1">
|
||||
<div class="mb-2 flex items-center justify-between gap-2 px-1">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Plugins</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground"
|
||||
aria-haspopup="menu"
|
||||
title="View plugins"
|
||||
(click)="openPluginActionMenu($event)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePackage"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
<span>View plugins</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (pluginChannelSections().length > 0) {
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
lucideUsers,
|
||||
lucidePlus,
|
||||
lucideVolumeX,
|
||||
lucideGamepad2
|
||||
lucideGamepad2,
|
||||
lucidePackage
|
||||
} from '@ng-icons/lucide';
|
||||
import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import {
|
||||
@@ -47,12 +48,13 @@ import {
|
||||
} from '../../../domains/voice-connection';
|
||||
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
import { DirectMessageService } from '../../../domains/direct-message';
|
||||
import { DirectCallService } from '../../../domains/direct-call';
|
||||
import { VoicePlaybackService } from '../../../domains/voice-connection';
|
||||
import { formatGameActivityElapsed } from '../../../domains/game-activity';
|
||||
import { ExternalLinkService } from '../../../core/platform/external-link.service';
|
||||
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
|
||||
import { PluginRenderHostComponent } from '../../../domains/plugins/feature/plugin-render-host/plugin-render-host.component';
|
||||
import { PluginUiRegistryService } from '../../../domains/plugins';
|
||||
import { PluginActionMenuService, PluginUiRegistryService } from '../../../domains/plugins';
|
||||
import { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules';
|
||||
import {
|
||||
canManageMember,
|
||||
@@ -107,7 +109,8 @@ type PanelMode = 'channels' | 'users';
|
||||
lucideUsers,
|
||||
lucidePlus,
|
||||
lucideVolumeX,
|
||||
lucideGamepad2
|
||||
lucideGamepad2,
|
||||
lucidePackage
|
||||
})
|
||||
],
|
||||
templateUrl: './rooms-side-panel.component.html'
|
||||
@@ -122,9 +125,11 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
private voiceSessionService = inject(VoiceSessionFacade);
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private directCalls = inject(DirectCallService);
|
||||
private profileCard = inject(ProfileCardService);
|
||||
private directMessages = inject(DirectMessageService);
|
||||
private readonly externalLinks = inject(ExternalLinkService);
|
||||
private readonly pluginActionMenu = inject(PluginActionMenuService);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
|
||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||
@@ -142,6 +147,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
||||
pluginChannelSections = this.pluginUi.channelSectionRecords;
|
||||
pluginMenuActions = this.pluginUi.toolbarActionRecords;
|
||||
pluginSidePanels = this.pluginUi.sidePanelRecords;
|
||||
localUserHasDesync = this.voiceConnectivity.localUserHasDesync;
|
||||
roomMembers = computed(() => this.currentRoom()?.members ?? []);
|
||||
@@ -217,6 +223,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
ngOnDestroy(): void {
|
||||
clearInterval(this.activityTimer);
|
||||
this.cancelQueuedProfileCardOpen();
|
||||
this.pluginActionMenu.close();
|
||||
}
|
||||
|
||||
gameActivityElapsed(user: User | null | undefined): string {
|
||||
@@ -256,6 +263,12 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
this.queueProfileCardOpen(event.currentTarget as HTMLElement, this.roomMemberToUser(member), false);
|
||||
}
|
||||
|
||||
openPluginActionMenu(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.cancelQueuedProfileCardOpen();
|
||||
this.pluginActionMenu.open(event.currentTarget as HTMLElement);
|
||||
}
|
||||
|
||||
async openDirectMessage(event: Event, user: User): Promise<void> {
|
||||
event.stopPropagation();
|
||||
this.cancelQueuedProfileCardOpen();
|
||||
@@ -623,31 +636,12 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
|
||||
}
|
||||
|
||||
private prepareCrossServerVoiceJoin(room: Room, current: User | null): boolean {
|
||||
private prepareVoiceJoin(room: Room, current: User | null): void {
|
||||
if (!current?.voiceState?.isConnected || current.voiceState.serverId === room.id) {
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.voiceConnection.isVoiceConnected()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
this.disconnectCurrentVoiceTarget(current);
|
||||
}
|
||||
|
||||
private enableVoiceForJoin(room: Room, current: User | null, roomId: string): Promise<void> {
|
||||
@@ -675,10 +669,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.prepareCrossServerVoiceJoin(room, current ?? null)) {
|
||||
this.voiceConnection.reportConnectionError('Disconnect from the current voice server before joining a different server.');
|
||||
return;
|
||||
}
|
||||
this.directCalls.leaveCurrentJoinedCall();
|
||||
this.prepareVoiceJoin(room, current ?? null);
|
||||
|
||||
this.enableVoiceForJoin(room, current ?? null, roomId)
|
||||
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
||||
@@ -775,10 +767,14 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId))
|
||||
return;
|
||||
|
||||
this.disconnectCurrentVoiceTarget(current);
|
||||
}
|
||||
|
||||
private disconnectCurrentVoiceTarget(current: User | null): void {
|
||||
const previousVoiceState = current?.voiceState;
|
||||
|
||||
this.voiceConnection.stopVoiceHeartbeat();
|
||||
|
||||
this.untrackCurrentUserMic();
|
||||
|
||||
this.voiceConnection.disableVoice();
|
||||
|
||||
if (current?.id) {
|
||||
@@ -811,8 +807,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
roomId: previousVoiceState?.roomId,
|
||||
serverId: previousVoiceState?.serverId
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user