Plugins #14
@@ -69,6 +69,7 @@ function applySeededEndpointStorageState(storageState: SeededEndpointStorageStat
|
||||
'toju-primary',
|
||||
'toju-sweden'
|
||||
]));
|
||||
|
||||
storage.setItem('metoyou_general_settings', generalSettings);
|
||||
|
||||
if (currentUserId) {
|
||||
|
||||
@@ -10,7 +10,9 @@ export class LoginPage {
|
||||
readonly registerLink: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]').first();
|
||||
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]')
|
||||
.first();
|
||||
|
||||
this.usernameInput = page.locator('#login-username');
|
||||
this.passwordInput = page.locator('#login-password');
|
||||
this.serverSelect = page.locator('#login-server');
|
||||
|
||||
@@ -79,12 +79,19 @@ export class ServerSearchPage {
|
||||
await this.page.getByRole('button', { name }).click();
|
||||
}
|
||||
|
||||
async joinServerFromSearch(name: string) {
|
||||
async joinServerFromSearch(name: string, options: { acceptPluginDownloads?: boolean } = {}) {
|
||||
await this.searchInput.fill(name);
|
||||
|
||||
const serverCard = this.page.locator('div[title]', { hasText: name }).first();
|
||||
|
||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||
await serverCard.dblclick();
|
||||
|
||||
if (options.acceptPluginDownloads) {
|
||||
const pluginConsentDialog = this.page.getByRole('dialog', { name: /uses plugins/ });
|
||||
|
||||
await expect(pluginConsentDialog).toBeVisible({ timeout: 20_000 });
|
||||
await pluginConsentDialog.getByRole('button', { name: 'Accept and join' }).click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,7 @@ interface PersistentClient {
|
||||
userDataDir: string;
|
||||
}
|
||||
|
||||
const CLIENT_LAUNCH_ARGS = [
|
||||
'--use-fake-device-for-media-stream',
|
||||
'--use-fake-ui-for-media-stream'
|
||||
];
|
||||
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
|
||||
|
||||
test.describe('User session data isolation', () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
@@ -43,6 +40,7 @@ test.describe('User session data isolation', () => {
|
||||
};
|
||||
const aliceServerName = `Alice Session Server ${suffix}`;
|
||||
const aliceMessage = `Alice persisted message ${suffix}`;
|
||||
|
||||
let client: PersistentClient | null = null;
|
||||
|
||||
try {
|
||||
@@ -82,6 +80,7 @@ test.describe('User session data isolation', () => {
|
||||
const bobServerName = `Bob Private Server ${suffix}`;
|
||||
const aliceMessage = `Alice history ${suffix}`;
|
||||
const bobMessage = `Bob history ${suffix}`;
|
||||
|
||||
let client: PersistentClient | null = null;
|
||||
|
||||
try {
|
||||
@@ -136,7 +135,7 @@ async function launchPersistentClient(userDataDir: string, testServerPort: numbe
|
||||
|
||||
await installTestServerEndpoint(context, testServerPort);
|
||||
|
||||
const page = context.pages()[0] ?? await context.newPage();
|
||||
const page = context.pages()[0] ?? (await context.newPage());
|
||||
|
||||
return {
|
||||
context,
|
||||
@@ -202,6 +201,7 @@ async function createServerAndSendMessage(page: Page, serverName: string, messag
|
||||
await searchPage.createServer(serverName, {
|
||||
description: `User session isolation coverage for ${serverName}`
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
await messagesPage.sendMessage(messageText);
|
||||
@@ -209,11 +209,15 @@ async function createServerAndSendMessage(page: Page, serverName: string, messag
|
||||
}
|
||||
|
||||
async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise<void> {
|
||||
const roomButton = getSavedRoomButton(page, roomName);
|
||||
const railRoomButton = getRailSavedRoomButton(page, roomName);
|
||||
const messagesPage = new ChatMessagesPage(page);
|
||||
|
||||
await expect(roomButton).toBeVisible({ timeout: 20_000 });
|
||||
await roomButton.click();
|
||||
await expect(railRoomButton).toBeVisible({ timeout: 20_000 });
|
||||
await page.goto('/search', { waitUntil: 'domcontentloaded' });
|
||||
const searchRoomButton = getSearchSavedRoomButton(page, roomName);
|
||||
|
||||
await expect(searchRoomButton).toBeVisible({ timeout: 20_000 });
|
||||
await searchRoomButton.click();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
@@ -230,17 +234,29 @@ async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<
|
||||
}
|
||||
|
||||
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
|
||||
await expect(getSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(getRailSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
|
||||
await page.goto('/search', { waitUntil: 'domcontentloaded' });
|
||||
await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
|
||||
await expect(getSavedRoomButton(page, roomName)).toHaveCount(0);
|
||||
await expect(getRailSavedRoomButton(page, roomName)).toHaveCount(0);
|
||||
|
||||
if (!page.url().includes('/search')) {
|
||||
await page.goto('/search', { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
|
||||
await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0);
|
||||
}
|
||||
|
||||
function getSavedRoomButton(page: Page, roomName: string) {
|
||||
function getRailSavedRoomButton(page: Page, roomName: string) {
|
||||
return page.locator(`button[title="${roomName}"]`).first();
|
||||
}
|
||||
|
||||
function getSearchSavedRoomButton(page: Page, roomName: string) {
|
||||
return page.locator('app-server-search').getByRole('button', { name: roomName, exact: true });
|
||||
}
|
||||
|
||||
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
|
||||
let lastError: unknown;
|
||||
|
||||
@@ -259,11 +275,10 @@ async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error(`Navigation failed after ${attempts} attempts`);
|
||||
throw lastError instanceof Error ? lastError : new Error(`Navigation failed after ${attempts} attempts`);
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { chromium, type BrowserContext, type Locator, type Page, type Route } from '@playwright/test';
|
||||
import {
|
||||
chromium,
|
||||
type BrowserContext,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Route
|
||||
} from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
|
||||
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
|
||||
@@ -31,7 +37,11 @@ interface PersistentClient {
|
||||
}
|
||||
|
||||
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
|
||||
const GIF_FRAME_MARKER = Buffer.from([0x21, 0xf9, 0x04]);
|
||||
const GIF_FRAME_MARKER = Buffer.from([
|
||||
0x21,
|
||||
0xf9,
|
||||
0x04
|
||||
]);
|
||||
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
|
||||
const SERVER_ICON_SYNC_TIMEOUT_MS = 45_000;
|
||||
|
||||
@@ -77,6 +87,7 @@ test.describe('Server icon sync', () => {
|
||||
await new ServerSearchPage(alice.page).createServer(serverName, {
|
||||
description: 'Server icon synchronization E2E coverage'
|
||||
});
|
||||
|
||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
await joinServerFromSearch(bob.page, serverName);
|
||||
@@ -263,15 +274,15 @@ async function openServerSettings(page: Page, serverName: string): Promise<void>
|
||||
|
||||
async function openSettingsModalThroughAngularDevMode(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
type SettingsModalComponentHandle = {
|
||||
interface SettingsModalComponentHandle {
|
||||
modal?: {
|
||||
open: (page: string) => void;
|
||||
};
|
||||
};
|
||||
type AngularDebugApi = {
|
||||
}
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => SettingsModalComponentHandle;
|
||||
applyChanges?: (component: SettingsModalComponentHandle) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-settings-modal');
|
||||
const debugApi = (window as Window & { ng?: AngularDebugApi }).ng;
|
||||
@@ -373,33 +384,33 @@ async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts
|
||||
|
||||
async function expectServerSettingsIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
|
||||
const settingsPanel = page.locator('app-server-settings');
|
||||
const image = settingsPanel.locator(`img[alt="${serverName} icon"]`).first();
|
||||
const image = settingsPanel.locator('[style*="background-image"]').first();
|
||||
|
||||
await expectImageLoadedWithSrc(image, expectedDataUrl, 'settings server icon');
|
||||
await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'settings server icon');
|
||||
}
|
||||
|
||||
async function expectRoomHeaderIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
|
||||
const channelsPanel = page.locator('app-rooms-side-panel').first();
|
||||
const image = channelsPanel.locator(`img[alt="${serverName} icon"]`).first();
|
||||
const image = channelsPanel.locator('[style*="background-image"]').first();
|
||||
|
||||
await expectImageLoadedWithSrc(image, expectedDataUrl, 'room header server icon');
|
||||
await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'room header server icon');
|
||||
}
|
||||
|
||||
async function expectRailIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
|
||||
const image = page.locator(`app-servers-rail img[alt="${serverName} icon"]`).first();
|
||||
const image = page.locator(`app-servers-rail button[title="${serverName}"] [style*="background-image"]`).first();
|
||||
|
||||
await expectImageLoadedWithSrc(image, expectedDataUrl, 'servers rail icon');
|
||||
await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'servers rail icon');
|
||||
}
|
||||
|
||||
async function expectSearchResultIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
|
||||
const serverCard = page.locator('app-server-search div[title]', { hasText: serverName }).first();
|
||||
const image = serverCard.locator(`img[alt="${serverName} icon"]`).first();
|
||||
const image = serverCard.locator('[style*="background-image"]').first();
|
||||
|
||||
await expect(serverCard).toBeVisible({ timeout: 20_000 });
|
||||
await expectImageLoadedWithSrc(image, expectedDataUrl, 'search result server icon');
|
||||
await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'search result server icon');
|
||||
}
|
||||
|
||||
async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string, label: string): Promise<void> {
|
||||
async function expectBackgroundImageLoadedWithUrl(image: Locator, expectedDataUrl: string, label: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
@@ -407,14 +418,14 @@ async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string,
|
||||
return null;
|
||||
}
|
||||
|
||||
return image.getAttribute('src');
|
||||
return image.evaluate((element) => getComputedStyle(element).backgroundImage);
|
||||
},
|
||||
{
|
||||
timeout: SERVER_ICON_SYNC_TIMEOUT_MS,
|
||||
message: `${label} src should update`
|
||||
message: `${label} background should update`
|
||||
}
|
||||
)
|
||||
.toBe(expectedDataUrl);
|
||||
.toContain(expectedDataUrl);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -423,11 +434,23 @@ async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string,
|
||||
return false;
|
||||
}
|
||||
|
||||
return image.evaluate((element) => {
|
||||
const img = element as HTMLImageElement;
|
||||
return image.evaluate(
|
||||
(element) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const backgroundImage = getComputedStyle(element).backgroundImage;
|
||||
const match = /^url\("?(.*?)"?\)$/.exec(backgroundImage);
|
||||
const img = new Image();
|
||||
|
||||
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
|
||||
});
|
||||
if (!match?.[1]) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
img.onload = () => resolve(img.naturalWidth > 0 && img.naturalHeight > 0);
|
||||
img.onerror = () => resolve(false);
|
||||
img.src = match[1];
|
||||
})
|
||||
);
|
||||
},
|
||||
{
|
||||
timeout: SERVER_ICON_SYNC_TIMEOUT_MS,
|
||||
@@ -448,8 +471,22 @@ function buildGifUpload(label: string): ImageUploadPayload {
|
||||
const header = baseGif.subarray(0, frameStart);
|
||||
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
|
||||
const commentData = Buffer.from(label, 'ascii');
|
||||
const commentExtension = Buffer.concat([Buffer.from([0x21, 0xfe, commentData.length]), commentData, Buffer.from([0x00])]);
|
||||
const buffer = Buffer.concat([header, commentExtension, frame, Buffer.from([0x3b])]);
|
||||
const commentExtension = Buffer.concat([
|
||||
Buffer.from([
|
||||
0x21,
|
||||
0xfe,
|
||||
commentData.length
|
||||
]),
|
||||
commentData,
|
||||
Buffer.from([0x00])
|
||||
]);
|
||||
const buffer = Buffer.concat([
|
||||
header,
|
||||
commentExtension,
|
||||
frame,
|
||||
frame,
|
||||
Buffer.from([0x3b])
|
||||
]);
|
||||
const base64 = buffer.toString('base64');
|
||||
|
||||
return {
|
||||
@@ -461,5 +498,6 @@ function buildGifUpload(label: string): ImageUploadPayload {
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
|
||||
@@ -28,9 +28,7 @@ test.describe('Plugin API multi-user runtime', () => {
|
||||
test('runs chat, embed, soundboard, and profile APIs between two users', async ({ createClient }) => {
|
||||
const scenario = await createPluginApiScenario(createClient);
|
||||
|
||||
await test.step('Install the server plugin as Alice', async () => {
|
||||
await installGrantAndActivatePlugin(scenario.alice.page, true);
|
||||
await closeSettingsModal(scenario.alice.page);
|
||||
await test.step('Alice has the server plugin active', async () => {
|
||||
await expect(soundboardComposerButton(scenario.alice.page)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.alice.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.alice.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
|
||||
@@ -101,10 +99,13 @@ async function createPluginApiScenario(createClient: () => Promise<Client>): Pro
|
||||
const aliceRoom = new ChatRoomPage(alice.page);
|
||||
|
||||
await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||
await installGrantAndActivatePlugin(alice.page, true);
|
||||
await closeSettingsModal(alice.page);
|
||||
await expect(soundboardComposerButton(alice.page)).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const bobSearch = new ServerSearchPage(bob.page);
|
||||
|
||||
await bobSearch.joinServerFromSearch(serverName);
|
||||
await bobSearch.joinServerFromSearch(serverName, { acceptPluginDownloads: true });
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 });
|
||||
|
||||
const bobRoom = new ChatRoomPage(bob.page);
|
||||
|
||||
@@ -28,6 +28,6 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
|
||||
## Notes
|
||||
|
||||
- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together.
|
||||
- Plugin client data is stored in the local Electron SQLite database in the dedicated `plugin_data` table. Renderer plugins reach it through CQRS commands/queries exposed by the preload bridge; the signal server must not be used for arbitrary plugin data persistence.
|
||||
- Plugin client data is stored in the local Electron SQLite database in the dedicated user-scoped `plugin_data` table. Renderer plugins reach it through CQRS commands/queries exposed by the preload bridge; the signal server must not be used for arbitrary plugin data persistence.
|
||||
- Treat `dist/electron/` and `dist-electron/` as generated output.
|
||||
- See [AGENTS.md](AGENTS.md) for package-level editing rules.
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface IssuedToken {
|
||||
}
|
||||
|
||||
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const tokens = new Map<string, IssuedToken>();
|
||||
|
||||
export function issueToken(params: {
|
||||
|
||||
@@ -59,6 +59,17 @@ export function getDocsHtml(specUrl: string): string {
|
||||
disabled: true
|
||||
}
|
||||
};
|
||||
const contentSecurityPolicy = [
|
||||
"default-src 'none'",
|
||||
"script-src 'self' 'nonce-metoyou-local-api-docs'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'none'",
|
||||
"frame-ancestors 'none'"
|
||||
].join('; ');
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
@@ -67,7 +78,7 @@ export function getDocsHtml(specUrl: string): string {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self' 'nonce-metoyou-local-api-docs'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
||||
content="${contentSecurityPolicy}"
|
||||
/>
|
||||
<title>MetoYou Local API</title>
|
||||
<style>
|
||||
|
||||
@@ -72,7 +72,11 @@ export async function resolveDocusaurusRoute(pathname: string): Promise<{ filePa
|
||||
const root = await getDocusaurusBuildRoot();
|
||||
|
||||
if (!root) {
|
||||
throw new HttpError(503, 'Docusaurus build is not available. Run npm run build:docs before opening the docs endpoint.', 'DOCUSAURUS_BUILD_MISSING');
|
||||
throw new HttpError(
|
||||
503,
|
||||
'Docusaurus build is not available. Run npm run build:docs before opening the docs endpoint.',
|
||||
'DOCUSAURUS_BUILD_MISSING'
|
||||
);
|
||||
}
|
||||
|
||||
let filePath = resolveAssetPath(root, pathname);
|
||||
|
||||
@@ -37,6 +37,7 @@ export async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
let received = 0;
|
||||
|
||||
for await (const chunk of req) {
|
||||
|
||||
@@ -196,9 +196,9 @@ export async function startLocalApiServer(settings: LocalApiSettings): Promise<S
|
||||
currentError = null;
|
||||
currentBindHost = pickBindHost(settings);
|
||||
currentBindPort = settings.port;
|
||||
|
||||
const requestSettings = activeSettings;
|
||||
const httpServer = createServer((req, res) => {
|
||||
void handleRequest(req, res, activeSettings!).catch((error) => {
|
||||
void handleRequest(req, res, requestSettings).catch((error) => {
|
||||
console.error('[LocalApi] Unhandled request error:', error);
|
||||
|
||||
try {
|
||||
|
||||
@@ -36,7 +36,11 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
|
||||
},
|
||||
LoginRequest: {
|
||||
type: 'object',
|
||||
required: ['username', 'password', 'serverUrl'],
|
||||
required: [
|
||||
'username',
|
||||
'password',
|
||||
'serverUrl'
|
||||
],
|
||||
properties: {
|
||||
username: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
@@ -49,7 +53,11 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
|
||||
},
|
||||
LoginResponse: {
|
||||
type: 'object',
|
||||
required: ['token', 'expiresAt', 'user'],
|
||||
required: [
|
||||
'token',
|
||||
'expiresAt',
|
||||
'user'
|
||||
],
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
expiresAt: { type: 'integer', format: 'int64' },
|
||||
@@ -58,7 +66,11 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
|
||||
},
|
||||
AuthUser: {
|
||||
type: 'object',
|
||||
required: ['id', 'username', 'displayName'],
|
||||
required: [
|
||||
'id',
|
||||
'username',
|
||||
'displayName'
|
||||
],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
username: { type: 'string' },
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { app, net } from 'electron';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { buildQueryHandlers } from '../cqrs/queries';
|
||||
import { QueryType, QueryTypeKey, Query } from '../cqrs/types';
|
||||
import { issueToken, consumeToken, revokeToken, IssuedToken } from './auth-store';
|
||||
import {
|
||||
QueryType,
|
||||
QueryTypeKey,
|
||||
Query
|
||||
} from '../cqrs/types';
|
||||
import {
|
||||
issueToken,
|
||||
consumeToken,
|
||||
revokeToken,
|
||||
IssuedToken
|
||||
} from './auth-store';
|
||||
import { buildOpenApiDocument } from './openapi';
|
||||
import { HttpError, RequestContext, readJsonBody } from './http-helpers';
|
||||
import { HttpError, RequestContext } from './http-helpers';
|
||||
import { getDocsHtml, getScalarApiReferenceBundlePath } from './docs-html';
|
||||
import { resolveDocusaurusRoute } from './docusaurus-static';
|
||||
import { LocalApiSettings } from '../desktop-settings';
|
||||
@@ -48,12 +57,14 @@ function compilePattern(template: string): { pattern: RegExp; paramKeys: string[
|
||||
const escaped = template.replace(/[.*+?^${}()|[\]\\]/g, (match) => {
|
||||
if (match === '*' || match === '+' || match === '?')
|
||||
return `\\${match}`;
|
||||
|
||||
return `\\${match}`;
|
||||
});
|
||||
const source = template.replace(/\{([^}]+)\}/g, (_full, key: string) => {
|
||||
paramKeys.push(key);
|
||||
return '([^/]+)';
|
||||
});
|
||||
|
||||
void escaped;
|
||||
|
||||
return { pattern: new RegExp(`^${source}$`), paramKeys };
|
||||
@@ -273,7 +284,6 @@ const ROUTES: RouteDefinition[] = [
|
||||
|
||||
const limit = clampInt(ctx.request.url.searchParams.get('limit'), 1, 500, 100);
|
||||
const offset = clampInt(ctx.request.url.searchParams.get('offset'), 0, Number.MAX_SAFE_INTEGER, 0);
|
||||
|
||||
const messages = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
|
||||
type: QueryType.GetMessages,
|
||||
payload: { roomId: decodeURIComponent(roomId), limit, offset }
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { ClearRoomMessagesCommand } from '../../types';
|
||||
import { getCurrentUserScope } from '../../current-user-scope';
|
||||
|
||||
export async function handleClearRoomMessages(command: ClearRoomMessagesCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const currentUserId = await getCurrentUserScope(dataSource);
|
||||
|
||||
await repo.delete({ roomId: command.payload.roomId });
|
||||
if (!currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await repo.delete({ roomId: command.payload.roomId, ownerUserId: currentUserId });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { DeleteMessageCommand } from '../../types';
|
||||
import { getCurrentUserScope } from '../../current-user-scope';
|
||||
|
||||
export async function handleDeleteMessage(command: DeleteMessageCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const currentUserId = await getCurrentUserScope(dataSource);
|
||||
|
||||
await repo.delete({ id: command.payload.messageId });
|
||||
if (!currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await repo.delete({ id: command.payload.messageId, ownerUserId: currentUserId });
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { getCurrentUserScope } from '../../current-user-scope';
|
||||
import { PluginDataEntity } from '../../../entities';
|
||||
import { DeletePluginDataCommand } from '../../types';
|
||||
|
||||
export async function handleDeletePluginData(command: DeletePluginDataCommand, dataSource: DataSource): Promise<void> {
|
||||
const { payload } = command;
|
||||
const ownerUserId = await getCurrentUserScope(dataSource);
|
||||
|
||||
if (!ownerUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await dataSource.getRepository(PluginDataEntity).delete({
|
||||
key: payload.key,
|
||||
ownerUserId,
|
||||
pluginId: payload.pluginId,
|
||||
scope: payload.scope,
|
||||
serverId: payload.serverId ?? ''
|
||||
|
||||
@@ -3,23 +3,39 @@ import {
|
||||
RoomChannelPermissionEntity,
|
||||
RoomChannelEntity,
|
||||
RoomEntity,
|
||||
RoomOwnerEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
MessageEntity
|
||||
} from '../../../entities';
|
||||
import { DeleteRoomCommand } from '../../types';
|
||||
import { getCurrentUserScope } from '../../current-user-scope';
|
||||
|
||||
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
|
||||
const { roomId } = command.payload;
|
||||
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const currentUserId = await getCurrentUserScope(manager);
|
||||
|
||||
if (!currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await manager.getRepository(RoomOwnerEntity).delete({ roomId, userId: currentUserId });
|
||||
await manager.getRepository(MessageEntity).delete({ roomId, ownerUserId: currentUserId });
|
||||
|
||||
const remainingOwners = await manager.getRepository(RoomOwnerEntity).count({ where: { roomId } });
|
||||
|
||||
if (remainingOwners > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await manager.getRepository(RoomChannelPermissionEntity).delete({ roomId });
|
||||
await manager.getRepository(RoomChannelEntity).delete({ roomId });
|
||||
await manager.getRepository(RoomMemberEntity).delete({ roomId });
|
||||
await manager.getRepository(RoomRoleEntity).delete({ roomId });
|
||||
await manager.getRepository(RoomUserRoleEntity).delete({ roomId });
|
||||
await manager.getRepository(RoomEntity).delete({ id: roomId });
|
||||
await manager.getRepository(MessageEntity).delete({ roomId });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,15 +2,18 @@ import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { replaceMessageReactions } from '../../relations';
|
||||
import { SaveMessageCommand } from '../../types';
|
||||
import { getCurrentUserScope } from '../../current-user-scope';
|
||||
|
||||
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
|
||||
const { message } = command.payload;
|
||||
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const currentUserId = await getCurrentUserScope(manager);
|
||||
const repo = manager.getRepository(MessageEntity);
|
||||
const entity = repo.create({
|
||||
id: message.id,
|
||||
roomId: message.roomId,
|
||||
ownerUserId: currentUserId,
|
||||
channelId: message.channelId ?? null,
|
||||
senderId: message.senderId,
|
||||
senderName: message.senderName,
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { getCurrentUserScope } from '../../current-user-scope';
|
||||
import { PluginDataEntity } from '../../../entities';
|
||||
import { SavePluginDataCommand } from '../../types';
|
||||
|
||||
export async function handleSavePluginData(command: SavePluginDataCommand, dataSource: DataSource): Promise<void> {
|
||||
const { payload } = command;
|
||||
const ownerUserId = await getCurrentUserScope(dataSource);
|
||||
|
||||
if (!ownerUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await dataSource.getRepository(PluginDataEntity).save({
|
||||
key: payload.key,
|
||||
ownerUserId,
|
||||
pluginId: payload.pluginId,
|
||||
scope: payload.scope,
|
||||
serverId: payload.serverId ?? '',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { RoomEntity, RoomOwnerEntity } from '../../../entities';
|
||||
import { replaceRoomRelations } from '../../relations';
|
||||
import { SaveRoomCommand } from '../../types';
|
||||
import { getCurrentUserScope } from '../../current-user-scope';
|
||||
|
||||
function extractSlowModeInterval(room: SaveRoomCommand['payload']['room']): number {
|
||||
if (typeof room.slowModeInterval === 'number' && Number.isFinite(room.slowModeInterval)) {
|
||||
@@ -21,6 +22,7 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
||||
const { room } = command.payload;
|
||||
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const currentUserId = await getCurrentUserScope(manager);
|
||||
const repo = manager.getRepository(RoomEntity);
|
||||
const entity = repo.create({
|
||||
id: room.id,
|
||||
@@ -43,6 +45,15 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
|
||||
if (currentUserId) {
|
||||
await manager.getRepository(RoomOwnerEntity).save({
|
||||
roomId: room.id,
|
||||
userId: currentUserId,
|
||||
savedAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
await replaceRoomRelations(manager, room.id, {
|
||||
channels: room.channels ?? [],
|
||||
members: room.members ?? [],
|
||||
|
||||
@@ -2,13 +2,20 @@ import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { replaceMessageReactions } from '../../relations';
|
||||
import { UpdateMessageCommand } from '../../types';
|
||||
import { getCurrentUserScope } from '../../current-user-scope';
|
||||
|
||||
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
|
||||
const { messageId, updates } = command.payload;
|
||||
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const currentUserId = await getCurrentUserScope(manager);
|
||||
|
||||
if (!currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = manager.getRepository(MessageEntity);
|
||||
const existing = await repo.findOne({ where: { id: messageId } });
|
||||
const existing = await repo.findOne({ where: { id: messageId, ownerUserId: currentUserId } });
|
||||
|
||||
if (!existing)
|
||||
return;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
boolToInt,
|
||||
TransformMap
|
||||
} from './utils/applyUpdates';
|
||||
import { getCurrentUserScope, userOwnsRoom } from '../../current-user-scope';
|
||||
|
||||
const ROOM_TRANSFORMS: TransformMap = {
|
||||
hasPassword: boolToInt,
|
||||
@@ -32,6 +33,12 @@ export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: D
|
||||
const { roomId, updates } = command.payload;
|
||||
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const currentUserId = await getCurrentUserScope(manager);
|
||||
|
||||
if (!await userOwnsRoom(manager, roomId, currentUserId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = manager.getRepository(RoomEntity);
|
||||
const existing = await repo.findOne({ where: { id: roomId } });
|
||||
|
||||
|
||||
24
electron/cqrs/current-user-scope.ts
Normal file
24
electron/cqrs/current-user-scope.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { MetaEntity, RoomOwnerEntity } from '../entities';
|
||||
|
||||
export async function getCurrentUserScope(dataSourceOrManager: DataSource | EntityManager): Promise<string | null> {
|
||||
const repo = dataSourceOrManager.getRepository(MetaEntity);
|
||||
const meta = await repo.findOne({ where: { key: 'currentUserId' } });
|
||||
|
||||
return meta?.value?.trim() || null;
|
||||
}
|
||||
|
||||
export async function userOwnsRoom(
|
||||
dataSourceOrManager: DataSource | EntityManager,
|
||||
roomId: string,
|
||||
userId: string | null
|
||||
): Promise<boolean> {
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const repo = dataSourceOrManager.getRepository(RoomOwnerEntity);
|
||||
const owner = await repo.findOne({ where: { roomId, userId } });
|
||||
|
||||
return !!owner;
|
||||
}
|
||||
@@ -1,11 +1,28 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { RoomEntity, RoomOwnerEntity } from '../../../entities';
|
||||
import { rowToRoom } from '../../mappers';
|
||||
import { loadRoomRelationsMap } from '../../relations';
|
||||
import { getCurrentUserScope } from '../../current-user-scope';
|
||||
|
||||
export async function handleGetAllRooms(dataSource: DataSource) {
|
||||
const currentUserId = await getCurrentUserScope(dataSource);
|
||||
|
||||
if (!currentUserId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const rows = await repo.find();
|
||||
const ownershipRows = await dataSource.getRepository(RoomOwnerEntity).find({ where: { userId: currentUserId } });
|
||||
const roomIds = ownershipRows.map((owner) => owner.roomId);
|
||||
|
||||
if (roomIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await repo
|
||||
.createQueryBuilder('room')
|
||||
.where('room.id IN (:...roomIds)', { roomIds })
|
||||
.getMany();
|
||||
const relationsByRoomId = await loadRoomRelationsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map((row) => rowToRoom(row, relationsByRoomId.get(row.id)));
|
||||
|
||||
@@ -3,10 +3,17 @@ import { MessageEntity } from '../../../entities';
|
||||
import { GetMessageByIdQuery } from '../../types';
|
||||
import { rowToMessage } from '../../mappers';
|
||||
import { loadMessageReactionsMap } from '../../relations';
|
||||
import { getCurrentUserScope } from '../../current-user-scope';
|
||||
|
||||
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const row = await repo.findOne({ where: { id: query.payload.messageId } });
|
||||
const currentUserId = await getCurrentUserScope(dataSource);
|
||||
|
||||
if (!currentUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = await repo.findOne({ where: { id: query.payload.messageId, ownerUserId: currentUserId } });
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
|
||||
@@ -3,12 +3,19 @@ import { MessageEntity } from '../../../entities';
|
||||
import { GetMessagesQuery } from '../../types';
|
||||
import { rowToMessage } from '../../mappers';
|
||||
import { loadMessageReactionsMap } from '../../relations';
|
||||
import { getCurrentUserScope } from '../../current-user-scope';
|
||||
|
||||
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const { roomId, limit = 100, offset = 0 } = query.payload;
|
||||
const currentUserId = await getCurrentUserScope(dataSource);
|
||||
|
||||
if (!currentUserId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await repo.find({
|
||||
where: { roomId },
|
||||
where: { roomId, ownerUserId: currentUserId },
|
||||
order: { timestamp: 'ASC' },
|
||||
take: limit,
|
||||
skip: offset
|
||||
|
||||
@@ -3,13 +3,21 @@ import { MessageEntity } from '../../../entities';
|
||||
import { GetMessagesSinceQuery } from '../../types';
|
||||
import { rowToMessage } from '../../mappers';
|
||||
import { loadMessageReactionsMap } from '../../relations';
|
||||
import { getCurrentUserScope } from '../../current-user-scope';
|
||||
|
||||
export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const { roomId, sinceTimestamp } = query.payload;
|
||||
const currentUserId = await getCurrentUserScope(dataSource);
|
||||
|
||||
if (!currentUserId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await repo.find({
|
||||
where: {
|
||||
roomId,
|
||||
ownerUserId: currentUserId,
|
||||
timestamp: MoreThan(sinceTimestamp)
|
||||
},
|
||||
order: { timestamp: 'ASC' }
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { getCurrentUserScope } from '../../current-user-scope';
|
||||
import { PluginDataEntity } from '../../../entities';
|
||||
import { GetPluginDataQuery } from '../../types';
|
||||
|
||||
export async function handleGetPluginData(query: GetPluginDataQuery, dataSource: DataSource): Promise<unknown> {
|
||||
const { payload } = query;
|
||||
const ownerUserId = await getCurrentUserScope(dataSource);
|
||||
|
||||
if (!ownerUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = await dataSource.getRepository(PluginDataEntity).findOne({
|
||||
where: {
|
||||
key: payload.key,
|
||||
ownerUserId,
|
||||
pluginId: payload.pluginId,
|
||||
scope: payload.scope,
|
||||
serverId: payload.serverId ?? ''
|
||||
|
||||
@@ -3,8 +3,15 @@ import { RoomEntity } from '../../../entities';
|
||||
import { GetRoomQuery } from '../../types';
|
||||
import { rowToRoom } from '../../mappers';
|
||||
import { loadRoomRelationsMap } from '../../relations';
|
||||
import { getCurrentUserScope, userOwnsRoom } from '../../current-user-scope';
|
||||
|
||||
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
|
||||
const currentUserId = await getCurrentUserScope(dataSource);
|
||||
|
||||
if (!await userOwnsRoom(dataSource, query.payload.roomId, currentUserId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const row = await repo.findOne({ where: { id: query.payload.roomId } });
|
||||
|
||||
|
||||
@@ -22,12 +22,12 @@ const ZIP_UTF8_FLAG = 0x0800;
|
||||
const ZIP_STORE_METHOD = 0;
|
||||
const ZIP_VERSION = 20;
|
||||
const MAX_UINT32 = 0xffffffff;
|
||||
|
||||
const crcTable = buildCrcTable();
|
||||
|
||||
export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
|
||||
const localParts: Buffer[] = [];
|
||||
const centralEntries: CentralDirectoryEntry[] = [];
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
@@ -93,7 +93,6 @@ export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
|
||||
|
||||
return Buffer.concat([header, entry.name]);
|
||||
});
|
||||
|
||||
const centralDirectorySize = offset - centralDirectoryOffset;
|
||||
|
||||
if (centralEntries.length > 0xffff || centralDirectoryOffset > MAX_UINT32 || centralDirectorySize > MAX_UINT32) {
|
||||
@@ -111,7 +110,11 @@ export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
|
||||
end.writeUInt32LE(centralDirectoryOffset, 16);
|
||||
end.writeUInt16LE(0, 20);
|
||||
|
||||
return Buffer.concat([...localParts, ...centralParts, end]);
|
||||
return Buffer.concat([
|
||||
...localParts,
|
||||
...centralParts,
|
||||
end
|
||||
]);
|
||||
}
|
||||
|
||||
export function readZipArchive(data: Buffer): ZipArchiveEntry[] {
|
||||
@@ -124,6 +127,7 @@ export function readZipArchive(data: Buffer): ZipArchiveEntry[] {
|
||||
const entryCount = data.readUInt16LE(endOffset + 10);
|
||||
const centralDirectoryOffset = data.readUInt32LE(endOffset + 16);
|
||||
const entries: ZipArchiveEntry[] = [];
|
||||
|
||||
let offset = centralDirectoryOffset;
|
||||
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
|
||||
@@ -43,12 +43,11 @@ export async function openCurrentDataFolder(): Promise<boolean> {
|
||||
|
||||
export async function exportUserData(): Promise<ExportUserDataResult> {
|
||||
const dataPath = app.getPath('userData');
|
||||
const defaultFileName = `metoyou-data-${new Date().toISOString().slice(0, 10)}.dat`;
|
||||
const defaultFileName = `metoyou-data-${new Date().toISOString()
|
||||
.slice(0, 10)}.dat`;
|
||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||
defaultPath: path.join(app.getPath('documents'), defaultFileName),
|
||||
filters: [
|
||||
{ extensions: ['dat'], name: 'MetoYou data archive' }
|
||||
],
|
||||
filters: [{ extensions: ['dat'], name: 'MetoYou data archive' }],
|
||||
title: 'Export MetoYou data'
|
||||
});
|
||||
|
||||
@@ -88,9 +87,7 @@ export async function exportUserData(): Promise<ExportUserDataResult> {
|
||||
|
||||
export async function importUserData(): Promise<ImportUserDataResult> {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
filters: [
|
||||
{ extensions: ['dat', 'zip'], name: 'MetoYou data archive' }
|
||||
],
|
||||
filters: [{ extensions: ['dat', 'zip'], name: 'MetoYou data archive' }],
|
||||
properties: ['openFile'],
|
||||
title: 'Import MetoYou data'
|
||||
});
|
||||
@@ -184,7 +181,8 @@ async function collectDataFiles(directoryPath: string): Promise<string[]> {
|
||||
async function moveCurrentDataAside(): Promise<string | undefined> {
|
||||
const dataPath = app.getPath('userData');
|
||||
const backupRoot = path.join(dataPath, BACKUP_DIRECTORY_NAME);
|
||||
const backupPath = path.join(backupRoot, `before-import-${new Date().toISOString().replace(/[:.]/g, '-')}`);
|
||||
const backupPath = path.join(backupRoot, `before-import-${new Date().toISOString()
|
||||
.replace(/[:.]/g, '-')}`);
|
||||
const entries = await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => []);
|
||||
|
||||
await fsp.mkdir(backupPath, { recursive: true });
|
||||
@@ -204,6 +202,7 @@ async function moveCurrentDataAside(): Promise<string | undefined> {
|
||||
await copyPath(sourcePath, targetPath);
|
||||
await fsp.rm(sourcePath, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
movedAny = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomOwnerEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
@@ -27,8 +28,18 @@ let dbBackupPath = '';
|
||||
|
||||
// SQLite files start with this 16-byte header string.
|
||||
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||
const SAVE_RETRY_DELAYS_MS = [25, 75, 150, 300, 600];
|
||||
const RETRYABLE_SAVE_ERROR_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
||||
const SAVE_RETRY_DELAYS_MS = [
|
||||
25,
|
||||
75,
|
||||
150,
|
||||
300,
|
||||
600
|
||||
];
|
||||
const RETRYABLE_SAVE_ERROR_CODES = new Set([
|
||||
'EPERM',
|
||||
'EACCES',
|
||||
'EBUSY'
|
||||
]);
|
||||
|
||||
let saveQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
@@ -164,6 +175,7 @@ export async function initializeDatabase(): Promise<void> {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomOwnerEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
|
||||
@@ -37,7 +37,6 @@ const DEFAULT_LOCAL_API_SETTINGS: LocalApiSettings = {
|
||||
docusaurusEnabled: false,
|
||||
allowedSignalingServers: []
|
||||
};
|
||||
|
||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||
autoUpdateMode: 'auto',
|
||||
autoStart: true,
|
||||
|
||||
@@ -12,6 +12,9 @@ export class MessageEntity {
|
||||
@Column('text')
|
||||
roomId!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
ownerUserId!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
channelId!: string | null;
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
|
||||
@Entity('plugin_data')
|
||||
export class PluginDataEntity {
|
||||
@PrimaryColumn('text')
|
||||
ownerUserId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
pluginId!: string;
|
||||
|
||||
|
||||
19
electron/entities/RoomOwnerEntity.ts
Normal file
19
electron/entities/RoomOwnerEntity.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('room_owners')
|
||||
export class RoomOwnerEntity {
|
||||
@PrimaryColumn('text')
|
||||
roomId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
@Index()
|
||||
userId!: string;
|
||||
|
||||
@Column('integer')
|
||||
savedAt!: number;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export { MessageEntity } from './MessageEntity';
|
||||
export { UserEntity } from './UserEntity';
|
||||
export { RoomEntity } from './RoomEntity';
|
||||
export { RoomOwnerEntity } from './RoomOwnerEntity';
|
||||
export { RoomChannelEntity } from './RoomChannelEntity';
|
||||
export { RoomMemberEntity } from './RoomMemberEntity';
|
||||
export { RoomRoleEntity } from './RoomRoleEntity';
|
||||
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
updateDesktopSettings,
|
||||
type DesktopSettings
|
||||
} from '../desktop-settings';
|
||||
import {
|
||||
applyLocalApiSettings,
|
||||
getLocalApiSnapshot
|
||||
} from '../api';
|
||||
import { applyLocalApiSettings, getLocalApiSnapshot } from '../api';
|
||||
import {
|
||||
activateLinuxScreenShareAudioRouting,
|
||||
deactivateLinuxScreenShareAudioRouting,
|
||||
@@ -490,6 +487,7 @@ export function setupSystemHandlers(): void {
|
||||
docusaurusEnabled: true
|
||||
}
|
||||
});
|
||||
|
||||
await applyLocalApiSettings();
|
||||
snapshot = getLocalApiSnapshot();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class UserScopedRoomsAndMessages1000000000009 implements MigrationInterface {
|
||||
name = 'UserScopedRoomsAndMessages1000000000009';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "room_owners" (
|
||||
"roomId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"savedAt" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("roomId", "userId")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_owners_userId" ON "room_owners" ("userId")`);
|
||||
|
||||
const columns = await queryRunner.query(`PRAGMA table_info("messages")`) as Array<{ name?: string }>;
|
||||
const hasOwnerUserId = columns.some((column) => column.name === 'ownerUserId');
|
||||
|
||||
if (!hasOwnerUserId) {
|
||||
await queryRunner.query(`ALTER TABLE "messages" ADD COLUMN "ownerUserId" TEXT`);
|
||||
}
|
||||
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_messages_owner_room" ON "messages" ("ownerUserId", "roomId")`);
|
||||
|
||||
const metaRows = await queryRunner.query(`SELECT "value" FROM "meta" WHERE "key" = 'currentUserId' LIMIT 1`) as Array<{ value?: string | null }>;
|
||||
const currentUserId = metaRows[0]?.value?.trim();
|
||||
|
||||
if (!currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
await queryRunner.query(
|
||||
`INSERT OR IGNORE INTO "room_owners" ("roomId", "userId", "savedAt") SELECT "id", ?, ? FROM "rooms"`,
|
||||
[currentUserId, now]
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "messages" SET "ownerUserId" = ? WHERE "ownerUserId" IS NULL`,
|
||||
[currentUserId]
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "idx_messages_owner_room"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "idx_room_owners_userId"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "room_owners"`);
|
||||
}
|
||||
}
|
||||
56
electron/migrations/1000000000010-UserScopedPluginData.ts
Normal file
56
electron/migrations/1000000000010-UserScopedPluginData.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class UserScopedPluginData1000000000010 implements MigrationInterface {
|
||||
name = 'UserScopedPluginData1000000000010';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const columns = await queryRunner.query(`PRAGMA table_info("plugin_data")`) as Array<{ name?: string }>;
|
||||
const hasOwnerUserId = columns.some((column) => column.name === 'ownerUserId');
|
||||
|
||||
if (hasOwnerUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metaRows = await queryRunner.query(`SELECT "value" FROM "meta" WHERE "key" = 'currentUserId' LIMIT 1`) as Array<{ value?: string | null }>;
|
||||
const currentUserId = metaRows[0]?.value?.trim() ?? '';
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "temporary_plugin_data" (
|
||||
"ownerUserId" TEXT NOT NULL,
|
||||
"pluginId" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"valueJson" TEXT NOT NULL,
|
||||
"updatedAt" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("ownerUserId", "pluginId", "scope", "serverId", "key")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_plugin_data" ("ownerUserId", "pluginId", "scope", "serverId", "key", "valueJson", "updatedAt")
|
||||
SELECT ?, "pluginId", "scope", "serverId", "key", "valueJson", "updatedAt" FROM "plugin_data"`,
|
||||
[currentUserId]
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "plugin_data"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_plugin_data" RENAME TO "plugin_data"`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_plugin_data_owner_plugin_scope" ON "plugin_data" ("ownerUserId", "pluginId", "scope")`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE "temporary_plugin_data" (
|
||||
"pluginId" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"valueJson" TEXT NOT NULL,
|
||||
"updatedAt" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("pluginId", "scope", "serverId", "key")
|
||||
)`);
|
||||
await queryRunner.query(`INSERT OR REPLACE INTO "temporary_plugin_data" ("pluginId", "scope", "serverId", "key", "valueJson", "updatedAt")
|
||||
SELECT "pluginId", "scope", "serverId", "key", "valueJson", "updatedAt" FROM "plugin_data"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "idx_plugin_data_owner_plugin_scope"`);
|
||||
await queryRunner.query(`DROP TABLE "plugin_data"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_plugin_data" RENAME TO "plugin_data"`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_plugin_data_plugin_scope" ON "plugin_data" ("pluginId", "scope")`);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
import { connectedUsers } from './state';
|
||||
import { ConnectedUser } from './types';
|
||||
import { broadcastToServer, findUserByOderId, getServerIdsForOderId, getUniqueUsersInServer, isOderIdConnectedToServer } from './broadcast';
|
||||
import {
|
||||
broadcastToServer,
|
||||
findUserByOderId,
|
||||
getServerIdsForOderId,
|
||||
getUniqueUsersInServer,
|
||||
isOderIdConnectedToServer
|
||||
} from './broadcast';
|
||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||
import { getPluginRequirementsSnapshot, PluginSupportError, validatePluginEventEnvelope } from '../services/plugin-support.service';
|
||||
import {
|
||||
getPluginRequirementsSnapshot,
|
||||
PluginSupportError,
|
||||
validatePluginEventEnvelope
|
||||
} from '../services/plugin-support.service';
|
||||
|
||||
interface WsMessage {
|
||||
[key: string]: unknown;
|
||||
@@ -145,7 +155,8 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
||||
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||
const sid = readMessageId(message['serverId']);
|
||||
|
||||
if (!sid) return;
|
||||
if (!sid)
|
||||
return;
|
||||
|
||||
const authorization = await authorizeWebSocketJoin(sid, user.oderId);
|
||||
|
||||
@@ -195,7 +206,8 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
async function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||
const viewSid = readMessageId(message['serverId']);
|
||||
|
||||
if (!viewSid) return;
|
||||
if (!viewSid)
|
||||
return;
|
||||
|
||||
if (!user.serverIds.has(viewSid)) {
|
||||
return;
|
||||
@@ -212,11 +224,13 @@ async function handleViewServer(user: ConnectedUser, message: WsMessage, connect
|
||||
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||
|
||||
if (!leaveSid) return;
|
||||
if (!leaveSid)
|
||||
return;
|
||||
|
||||
user.serverIds.delete(leaveSid);
|
||||
|
||||
if (user.viewedServerId === leaveSid) user.viewedServerId = undefined;
|
||||
if (user.viewedServerId === leaveSid)
|
||||
user.viewedServerId = undefined;
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
@@ -291,12 +305,18 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_STATUSES = new Set(['online', 'away', 'busy', 'offline']);
|
||||
const VALID_STATUSES = new Set([
|
||||
'online',
|
||||
'away',
|
||||
'busy',
|
||||
'offline'
|
||||
]);
|
||||
|
||||
function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const status = typeof message['status'] === 'string' ? message['status'] : undefined;
|
||||
|
||||
if (!status || !VALID_STATUSES.has(status)) return;
|
||||
if (!status || !VALID_STATUSES.has(status))
|
||||
return;
|
||||
|
||||
user.status = status as ConnectedUser['status'];
|
||||
connectedUsers.set(connectionId, user);
|
||||
@@ -410,7 +430,8 @@ async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promi
|
||||
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
if (!user) return;
|
||||
if (!user)
|
||||
return;
|
||||
|
||||
user.lastPong = Date.now();
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
@@ -79,11 +79,10 @@ import {
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
export class App implements OnInit, OnDestroy {
|
||||
readonly plugins = inject(PluginBootstrapService);
|
||||
|
||||
private static readonly THEME_STUDIO_CONTROLS_MARGIN = 16;
|
||||
private static readonly TITLE_BAR_HEIGHT = 40;
|
||||
|
||||
readonly plugins = inject(PluginBootstrapService);
|
||||
store = inject(Store);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
desktopUpdates = inject(DesktopAppUpdateService);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable max-statements-per-line */
|
||||
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable max-statements-per-line */
|
||||
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
|
||||
@@ -5,17 +5,13 @@ import {
|
||||
updateMessageStatusInConversation,
|
||||
upsertDirectMessage
|
||||
} from '../../domain/logic/direct-message.logic';
|
||||
import type {
|
||||
DirectMessage,
|
||||
DirectMessageParticipant
|
||||
} from '../../domain/models/direct-message.model';
|
||||
import type { DirectMessage, DirectMessageParticipant } from '../../domain/models/direct-message.model';
|
||||
|
||||
const alice: DirectMessageParticipant = {
|
||||
userId: 'alice',
|
||||
username: 'alice',
|
||||
displayName: 'Alice'
|
||||
};
|
||||
|
||||
const bob: DirectMessageParticipant = {
|
||||
userId: 'bob',
|
||||
username: 'bob',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
|
||||
@@ -133,7 +133,7 @@ export class NotificationsEffects {
|
||||
this.notifications.refreshRoomUnreadFromMessages(roomId, roomMessages);
|
||||
}
|
||||
})
|
||||
)
|
||||
, { dispatch: false }
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,17 +6,17 @@ 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.
|
||||
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 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. Required server plugins are installed on each member client when that chat server opens; optional server plugins stay listed as server requirements but are not auto-installed. 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. 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.
|
||||
|
||||
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`.
|
||||
|
||||
Plugin data that belongs to the current client uses the Electron database when the desktop bridge is available. The plugin runtime writes `api.clientData.*` and `api.serverData.*` records to Electron's dedicated `plugin_data` table, with renderer localStorage as the browser fallback. The legacy synchronous `api.storage.*` surface remains local and mirrors writes to the same Electron table when possible; plugins that need guaranteed database reads should use the async `api.clientData.*` methods.
|
||||
Plugin data that belongs to the current client uses the Electron database when the desktop bridge is available. The plugin runtime writes `api.clientData.*` and `api.serverData.*` records to Electron's dedicated user-scoped `plugin_data` table, with renderer localStorage as the browser fallback. The legacy synchronous `api.storage.*` surface remains local and mirrors writes to the same Electron table when possible; plugins that need guaranteed database reads should use the async `api.clientData.*` methods.
|
||||
|
||||
Plugins can communicate over a plugin-only message bus through `api.messageBus`. It sends `plugin-message-bus` data-channel events that are ignored by the normal chat message reducers/effects, can target a peer or broadcast to connected users, and can include a bounded latest-message snapshot filtered by channel, timestamp, and deletion state.
|
||||
|
||||
|
||||
@@ -25,30 +25,32 @@ export class PluginDesktopStateService {
|
||||
}
|
||||
|
||||
private async readRaw(key: string): Promise<string | null> {
|
||||
const scopedKey = getUserScopedStorageKey(key);
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (api) {
|
||||
return await api.query<string | null>({
|
||||
type: 'get-meta',
|
||||
payload: { key }
|
||||
payload: { key: scopedKey }
|
||||
});
|
||||
}
|
||||
|
||||
return localStorage.getItem(getUserScopedStorageKey(key));
|
||||
return localStorage.getItem(scopedKey);
|
||||
}
|
||||
|
||||
private async writeRaw(key: string, value: string): Promise<void> {
|
||||
const scopedKey = getUserScopedStorageKey(key);
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (api) {
|
||||
await api.command({
|
||||
type: 'save-meta',
|
||||
payload: { key, value }
|
||||
payload: { key: scopedKey, value }
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(getUserScopedStorageKey(key), value);
|
||||
localStorage.setItem(scopedKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,16 @@ import type {
|
||||
TojuPluginManifest
|
||||
} from '../../../../shared-kernel';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
|
||||
import { selectCurrentRoom, selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
|
||||
import { ServerDirectoryFacade, type ServerSourceSelector } from '../../../server-directory';
|
||||
import { PluginRegistryService } from './plugin-registry.service';
|
||||
import { PluginRequirementService } from './plugin-requirement.service';
|
||||
|
||||
const STORAGE_KEY_OPTIONAL_REQUIREMENT_DISMISSALS = 'metoyou_optional_plugin_requirement_dismissals';
|
||||
|
||||
type RequirementDismissalState = Record<string, Record<string, number>>;
|
||||
|
||||
export type PluginRequirementComparisonStatus =
|
||||
| 'blockedByServer'
|
||||
| 'disabled'
|
||||
@@ -48,6 +53,8 @@ export class PluginRequirementStateService {
|
||||
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
|
||||
private readonly snapshotsSignal = signal<Record<string, PluginRequirementsSnapshot>>({});
|
||||
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
|
||||
private readonly sessionDismissedOptionalSignal = signal<Record<string, string[]>>({});
|
||||
private readonly hiddenOptionalSignal = signal<RequirementDismissalState>(loadRequirementDismissals());
|
||||
|
||||
readonly currentSnapshot = computed(() => {
|
||||
const roomId = this.currentRoomId();
|
||||
@@ -55,6 +62,22 @@ export class PluginRequirementStateService {
|
||||
return roomId ? this.snapshotsSignal()[roomId] ?? null : null;
|
||||
});
|
||||
readonly refreshErrors = this.refreshErrorsSignal.asReadonly();
|
||||
readonly missingInstallableRequirements = computed(() => {
|
||||
const requirements: PluginRequirementSummary[] = [];
|
||||
|
||||
for (const comparison of this.comparisons()) {
|
||||
if (this.isMissingInstallableRequirement(comparison) && comparison.requirement) {
|
||||
requirements.push(comparison.requirement);
|
||||
}
|
||||
}
|
||||
|
||||
return requirements;
|
||||
});
|
||||
readonly missingRequiredRequirements = computed(() => this.missingInstallableRequirements()
|
||||
.filter((requirement) => requirement.status === 'required'));
|
||||
readonly visibleOptionalRequirements = computed(() => this.missingInstallableRequirements()
|
||||
.filter((requirement) => requirement.status === 'optional' || requirement.status === 'recommended')
|
||||
.filter((requirement) => !this.isOptionalRequirementDismissed(requirement)));
|
||||
readonly comparisons = computed<PluginRequirementComparison[]>(() => {
|
||||
const snapshot = this.currentSnapshot();
|
||||
const installedEntries = this.registry.entries();
|
||||
@@ -138,6 +161,36 @@ export class PluginRequirementStateService {
|
||||
return this.comparisons().find((comparison) => comparison.pluginId === pluginId) ?? null;
|
||||
}
|
||||
|
||||
dismissOptionalRequirement(requirement: PluginRequirementSummary, options: { persist?: boolean } = {}): void {
|
||||
const roomId = this.currentRoomId();
|
||||
|
||||
if (!roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.persist) {
|
||||
this.hiddenOptionalSignal.update((dismissals) => {
|
||||
const nextDismissals = {
|
||||
...dismissals,
|
||||
[roomId]: {
|
||||
...(dismissals[roomId] ?? {}),
|
||||
[requirement.pluginId]: requirement.updatedAt
|
||||
}
|
||||
};
|
||||
|
||||
saveRequirementDismissals(nextDismissals);
|
||||
return nextDismissals;
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionDismissedOptionalSignal.update((dismissals) => ({
|
||||
...dismissals,
|
||||
[roomId]: Array.from(new Set([...(dismissals[roomId] ?? []), requirement.pluginId]))
|
||||
}));
|
||||
}
|
||||
|
||||
private setSnapshot(serverId: string, snapshot: PluginRequirementsSnapshot): void {
|
||||
this.snapshotsSignal.update((snapshots) => ({
|
||||
...snapshots,
|
||||
@@ -184,6 +237,70 @@ export class PluginRequirementStateService {
|
||||
|
||||
return 'enabled';
|
||||
}
|
||||
|
||||
private isMissingInstallableRequirement(comparison: PluginRequirementComparison): boolean {
|
||||
const requirement = comparison.requirement;
|
||||
|
||||
return comparison.status === 'missing'
|
||||
&& !!requirement
|
||||
&& (requirement.status === 'required' || requirement.status === 'optional' || requirement.status === 'recommended')
|
||||
&& (!!requirement.manifest || !!requirement.installUrl);
|
||||
}
|
||||
|
||||
private isOptionalRequirementDismissed(requirement: PluginRequirementSummary): boolean {
|
||||
const roomId = this.currentRoomId();
|
||||
|
||||
if (!roomId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((this.sessionDismissedOptionalSignal()[roomId] ?? []).includes(requirement.pluginId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hiddenAt = this.hiddenOptionalSignal()[roomId]?.[requirement.pluginId];
|
||||
|
||||
return typeof hiddenAt === 'number' && hiddenAt >= requirement.updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
function loadRequirementDismissals(): RequirementDismissalState {
|
||||
try {
|
||||
const rawValue = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_OPTIONAL_REQUIREMENT_DISMISSALS));
|
||||
|
||||
if (!rawValue) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return normalizeRequirementDismissals(JSON.parse(rawValue) as unknown);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveRequirementDismissals(dismissals: RequirementDismissalState): void {
|
||||
try {
|
||||
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_OPTIONAL_REQUIREMENT_DISMISSALS), JSON.stringify(dismissals));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function normalizeRequirementDismissals(value: unknown): RequirementDismissalState {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
|
||||
.map(([serverId, serverValue]) => [serverId, normalizeServerDismissals(serverValue)])
|
||||
.filter(([, serverValue]) => Object.keys(serverValue).length > 0));
|
||||
}
|
||||
|
||||
function normalizeServerDismissals(value: unknown): Record<string, number> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
|
||||
.filter((entry): entry is [string, number] => typeof entry[1] === 'number'));
|
||||
}
|
||||
|
||||
function isSnapshotMessage(message: unknown): message is { serverId: string; snapshot: PluginRequirementsSnapshot } {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { getPluginInstallScope } from '../../domain/logic/plugin-install-scope.l
|
||||
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
|
||||
import type {
|
||||
InstalledStorePlugin,
|
||||
PersistedServerPluginInstallState,
|
||||
PersistedPluginStoreState,
|
||||
PluginStoreEntry,
|
||||
PluginStoreInstallState,
|
||||
@@ -44,6 +45,7 @@ import { PluginRegistryService } from './plugin-registry.service';
|
||||
|
||||
const STORE_SCHEMA_VERSION = 1;
|
||||
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: [],
|
||||
@@ -238,6 +240,10 @@ export class PluginStoreService {
|
||||
installedPlugin: InstalledStorePlugin,
|
||||
options: PluginStoreInstallOptions
|
||||
): Promise<void> {
|
||||
if (installScope === 'server' && targetServerId) {
|
||||
await this.writeLocalServerInstalledPlugins(targetServerId, nextInstalledPlugins);
|
||||
}
|
||||
|
||||
if (installScope !== 'client' && targetServerId !== this.currentRoomId?.()) {
|
||||
if (options.activate) {
|
||||
await this.host.rememberActivation(installedPlugin.manifest.id);
|
||||
@@ -248,9 +254,7 @@ export class PluginStoreService {
|
||||
|
||||
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.cachedSourcePath ?? installedPlugin.installUrl);
|
||||
|
||||
if (installScope === 'client' || options.optional !== true) {
|
||||
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
|
||||
}
|
||||
|
||||
if (options.activate) {
|
||||
await this.host.activatePluginById(installedPlugin.manifest.id);
|
||||
@@ -291,6 +295,63 @@ export class PluginStoreService {
|
||||
return await this.installedPluginsForServer(serverId);
|
||||
}
|
||||
|
||||
async getLocalServerInstalledPluginIds(serverId: string): Promise<Set<string>> {
|
||||
const installedPlugins = await this.readLocalServerInstalledPlugins(serverId);
|
||||
|
||||
return new Set(installedPlugins.map((installedPlugin) => installedPlugin.manifest.id));
|
||||
}
|
||||
|
||||
async installServerRequirementsLocally(
|
||||
serverId: string,
|
||||
requirements: PluginRequirementSummary[],
|
||||
options: { activate?: boolean } = {}
|
||||
): Promise<InstalledStorePlugin[]> {
|
||||
const installedPlugins: InstalledStorePlugin[] = [];
|
||||
|
||||
for (const requirement of requirements) {
|
||||
const installedPlugin = await this.resolveLocalInstallFromRequirement(requirement);
|
||||
|
||||
if (installedPlugin) {
|
||||
installedPlugins.push(installedPlugin);
|
||||
}
|
||||
}
|
||||
|
||||
if (installedPlugins.length === 0) {
|
||||
return await this.readLocalServerInstalledPlugins(serverId);
|
||||
}
|
||||
|
||||
const currentInstalledPlugins = await this.readLocalServerInstalledPlugins(serverId);
|
||||
const currentById = new Map(currentInstalledPlugins.map((installedPlugin) => [installedPlugin.manifest.id, installedPlugin]));
|
||||
const nextById = new Map(currentById);
|
||||
|
||||
for (const installedPlugin of installedPlugins) {
|
||||
const existing = currentById.get(installedPlugin.manifest.id);
|
||||
const cachedPlugin = await this.cacheInstalledPlugin({
|
||||
...installedPlugin,
|
||||
installedAt: existing?.installedAt ?? installedPlugin.installedAt,
|
||||
updatedAt: installedPlugin.updatedAt
|
||||
});
|
||||
|
||||
nextById.set(cachedPlugin.manifest.id, cachedPlugin);
|
||||
}
|
||||
|
||||
const nextInstalledPlugins = Array.from(nextById.values()).sort(sortInstalledPlugins);
|
||||
|
||||
if (options.activate) {
|
||||
for (const installedPlugin of installedPlugins) {
|
||||
await this.host.rememberActivation(installedPlugin.manifest.id);
|
||||
}
|
||||
}
|
||||
|
||||
await this.writeLocalServerInstalledPlugins(serverId, nextInstalledPlugins);
|
||||
|
||||
if (serverId === this.currentRoomId?.()) {
|
||||
await this.applyInstalledPlugins(nextInstalledPlugins, 'server');
|
||||
}
|
||||
|
||||
return nextInstalledPlugins;
|
||||
}
|
||||
|
||||
async loadReadme(plugin: PluginStoreEntry): Promise<PluginStoreReadme> {
|
||||
if (!plugin.readmeUrl) {
|
||||
throw new Error('Plugin does not provide a readme URL');
|
||||
@@ -604,7 +665,7 @@ export class PluginStoreService {
|
||||
}
|
||||
|
||||
try {
|
||||
const installedPlugins = await this.readServerInstalledPlugins(roomId);
|
||||
const installedPlugins = await this.readLocalServerInstalledPlugins(roomId);
|
||||
|
||||
if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) {
|
||||
await this.applyInstalledPlugins(installedPlugins, 'server');
|
||||
@@ -650,6 +711,56 @@ export class PluginStoreService {
|
||||
.sort(sortInstalledPlugins);
|
||||
}
|
||||
|
||||
private async resolveLocalInstallFromRequirement(requirement: PluginRequirementSummary): Promise<InstalledStorePlugin | null> {
|
||||
const existingPlugin = installedPluginFromRequirement(requirement, { includeOptional: true });
|
||||
|
||||
if (existingPlugin) {
|
||||
return existingPlugin;
|
||||
}
|
||||
|
||||
if (!requirement.installUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const manifest = await this.fetchPluginManifest(requirement.installUrl);
|
||||
|
||||
if (getPluginInstallScope(manifest) !== 'server') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
bundleUrl: manifest.bundle?.url,
|
||||
installedAt: requirement.updatedAt,
|
||||
installUrl: requirement.installUrl,
|
||||
manifest,
|
||||
sourceUrl: requirement.sourceUrl,
|
||||
updatedAt: requirement.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private async readLocalServerInstalledPlugins(serverId: string): Promise<InstalledStorePlugin[]> {
|
||||
const state = await this.desktopState.readJson<PersistedServerPluginInstallState>(STORAGE_KEY_SERVER_PLUGIN_INSTALLS, {});
|
||||
const normalized = normalizePersistedServerPluginInstallState(state);
|
||||
|
||||
return normalized.servers[serverId] ?? [];
|
||||
}
|
||||
|
||||
private async writeLocalServerInstalledPlugins(serverId: string, installedPlugins: InstalledStorePlugin[]): Promise<void> {
|
||||
const state = await this.desktopState.readJson<PersistedServerPluginInstallState>(STORAGE_KEY_SERVER_PLUGIN_INSTALLS, {});
|
||||
const normalized = normalizePersistedServerPluginInstallState(state);
|
||||
const nextServers = installedPlugins.length === 0
|
||||
? Object.fromEntries(Object.entries(normalized.servers).filter(([candidateServerId]) => candidateServerId !== serverId))
|
||||
: {
|
||||
...normalized.servers,
|
||||
[serverId]: installedPlugins
|
||||
};
|
||||
|
||||
await this.desktopState.writeJson(STORAGE_KEY_SERVER_PLUGIN_INSTALLS, {
|
||||
schemaVersion: STORE_SCHEMA_VERSION,
|
||||
servers: nextServers
|
||||
});
|
||||
}
|
||||
|
||||
private async saveServerPluginRequirement(
|
||||
installedPlugin: InstalledStorePlugin,
|
||||
roomId: string | null,
|
||||
@@ -735,10 +846,6 @@ export class PluginStoreService {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (serverId === this.currentRoomId?.()) {
|
||||
return this.serverInstalledPluginsSignal();
|
||||
}
|
||||
|
||||
const actorUserId = this.currentActorUserId();
|
||||
|
||||
if (!actorUserId || !this.serverDirectory) {
|
||||
@@ -839,8 +946,15 @@ function isPluginRequirementsChangedMessage(message: unknown): message is { serv
|
||||
&& typeof message['serverId'] === 'string';
|
||||
}
|
||||
|
||||
function installedPluginFromRequirement(requirement: PluginRequirementSummary): InstalledStorePlugin | null {
|
||||
if (requirement.status === 'optional' || requirement.status === 'blocked' || requirement.status === 'incompatible') {
|
||||
function installedPluginFromRequirement(
|
||||
requirement: PluginRequirementSummary,
|
||||
options: { includeOptional?: boolean } = {}
|
||||
): InstalledStorePlugin | null {
|
||||
if (requirement.status === 'blocked' || requirement.status === 'incompatible') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (requirement.status === 'optional' && options.includeOptional !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -945,6 +1059,31 @@ function normalizePersistedState(value: unknown): PersistedPluginStoreState {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePersistedServerPluginInstallState(value: unknown): { servers: Record<string, InstalledStorePlugin[]> } {
|
||||
if (!isRecord(value) || !isRecord(value['servers'])) {
|
||||
return { servers: {} };
|
||||
}
|
||||
|
||||
const servers: Record<string, InstalledStorePlugin[]> = {};
|
||||
|
||||
for (const [serverId, installedPlugins] of Object.entries(value['servers'])) {
|
||||
if (!Array.isArray(installedPlugins)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedPlugins = installedPlugins
|
||||
.filter(isInstalledStorePlugin)
|
||||
.filter((installedPlugin) => getPluginInstallScope(installedPlugin.manifest) === 'server')
|
||||
.sort(sortInstalledPlugins);
|
||||
|
||||
if (normalizedPlugins.length > 0) {
|
||||
servers[serverId] = normalizedPlugins;
|
||||
}
|
||||
}
|
||||
|
||||
return { servers };
|
||||
}
|
||||
|
||||
function isInstalledStorePlugin(value: unknown): value is InstalledStorePlugin {
|
||||
if (!isRecord(value) || !isRecord(value['manifest'])) {
|
||||
return false;
|
||||
|
||||
@@ -50,3 +50,8 @@ export interface PersistedPluginStoreState {
|
||||
installedPlugins: InstalledStorePlugin[];
|
||||
sourceUrls: string[];
|
||||
}
|
||||
|
||||
export interface PersistedServerPluginInstallState {
|
||||
schemaVersion?: number;
|
||||
servers?: Record<string, InstalledStorePlugin[]>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<main class="min-h-screen bg-background p-6 text-foreground">
|
||||
<a routerLink="/search" class="text-sm text-muted-foreground hover:text-foreground">Back</a>
|
||||
<a
|
||||
routerLink="/search"
|
||||
class="text-sm text-muted-foreground hover:text-foreground"
|
||||
>Back</a
|
||||
>
|
||||
@if (page(); as pageRecord) {
|
||||
<section class="mx-auto mt-6 max-w-5xl">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-muted-foreground">{{ pageRecord.pluginId }}</p>
|
||||
|
||||
@@ -153,12 +153,16 @@ The API service normalises every `ServerInfo` response, filling in `sourceId`, `
|
||||
|
||||
That search fan-out is discovery only. Once a room is created or joined, the room keeps an authoritative signal-server affinity via its `sourceId` / `sourceUrl`. The join response can repair stale saved metadata, and reconnect logic now retries that authoritative endpoint first before probing any other configured endpoints.
|
||||
|
||||
The `/search` My Servers row and the server rail both read from the active user's local room ownership. Switching accounts reloads that scoped cache so joined servers and local history do not bleed between users.
|
||||
|
||||
Fallback stays temporary. If the authoritative endpoint is unavailable, the client can probe other active compatible endpoints as a last resort for the current session, but it does not rewrite the room's saved affinity to that fallback endpoint.
|
||||
|
||||
## Server-owned room metadata
|
||||
|
||||
`ServerInfo` also carries the server-owned `channels` list for each room. Register and update calls persist this channel metadata on the server, and search or hydration responses return the normalised channel list so text and voice channel topology survives reloads, reconnects, and fresh joins.
|
||||
|
||||
Server icons are uploaded through the server settings page. Static sources are drawn into a `64x64` canvas and encoded using the smallest browser-supported output among WebP, JPEG, and PNG. Small animated GIF/WebP icons are kept animated. Server icon UI surfaces render the image as a CSS background instead of an `<img>` element so the icon cannot be dragged out of the app.
|
||||
|
||||
The renderer may cache room data locally, but channel creation, rename, and removal must round-trip through the server-directory API instead of being treated as client-only state. Server-side normalisation deduplicates channel names within each channel type, so a text `general` channel and a voice `General` channel can coexist while duplicate voice-to-voice or text-to-text names are still rejected.
|
||||
|
||||
## Default endpoint management
|
||||
|
||||
@@ -103,11 +103,11 @@
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground">
|
||||
@if (server.icon) {
|
||||
<img
|
||||
[src]="server.icon"
|
||||
[alt]="server.name + ' icon'"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||
[style.backgroundImage]="'url(' + server.icon + ')'"
|
||||
></div>
|
||||
} @else {
|
||||
{{ server.name[0] || '?' }}
|
||||
}
|
||||
@@ -297,6 +297,96 @@
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
|
||||
@if (pluginConsentDialog(); as dialog) {
|
||||
<div
|
||||
class="fixed inset-0 z-50 bg-black/50"
|
||||
role="presentation"
|
||||
></div>
|
||||
<section
|
||||
class="fixed left-1/2 top-1/2 z-[51] flex max-h-[min(42rem,calc(100vh-2rem))] w-[min(34rem,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-consent-title"
|
||||
>
|
||||
<header class="border-b border-border p-4">
|
||||
<p class="text-sm text-muted-foreground">Plugin downloads</p>
|
||||
<h2
|
||||
id="join-plugin-consent-title"
|
||||
class="mt-1 text-lg font-semibold"
|
||||
>
|
||||
{{ dialog.server.name }} uses plugins
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div class="grid min-h-0 gap-4 overflow-auto p-4">
|
||||
@if (dialog.required.length > 0) {
|
||||
<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="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>
|
||||
@if (requirement.reason) {
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ requirement.reason }}</p>
|
||||
}
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">Required</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (dialog.optional.length > 0) {
|
||||
<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>
|
||||
}
|
||||
</span>
|
||||
</label>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (pluginConsentError()) {
|
||||
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ pluginConsentError() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="flex justify-end gap-2 border-t border-border p-4">
|
||||
<button
|
||||
type="button"
|
||||
(click)="closePluginConsentDialog()"
|
||||
[disabled]="pluginConsentBusy()"
|
||||
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
Cancel join
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="confirmPluginConsent()"
|
||||
[disabled]="pluginConsentBusy()"
|
||||
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-primary bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{{ pluginConsentBusy() ? 'Downloading' : dialog.required.length > 0 ? 'Accept and join' : 'Join' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Create Server Dialog -->
|
||||
@if (showCreateDialog()) {
|
||||
<div
|
||||
|
||||
@@ -1,30 +1,77 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, effect, inject, OnInit, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
OnInit,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { debounceTime, distinctUntilChanged, firstValueFrom, Subject } from 'rxjs';
|
||||
import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
Subject
|
||||
} from 'rxjs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucidePlus, lucideSettings, lucideChevronDown } from '@ng-icons/lucide';
|
||||
import {
|
||||
lucideSearch,
|
||||
lucideUsers,
|
||||
lucideLock,
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings,
|
||||
lucideChevronDown
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { selectSearchResults, selectIsSearching, selectRoomsError, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||
import { Room, User } from '../../../../shared-kernel';
|
||||
import {
|
||||
selectSearchResults,
|
||||
selectIsSearching,
|
||||
selectRoomsError,
|
||||
selectSavedRooms
|
||||
} from '../../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
Room,
|
||||
User,
|
||||
type PluginRequirementSummary
|
||||
} from '../../../../shared-kernel';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { type ServerInfo } from '../../domain/models/server-directory.model';
|
||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { ConfirmDialogComponent, LeaveServerDialogComponent, type LeaveServerDialogResult } from '../../../../shared';
|
||||
import {
|
||||
ConfirmDialogComponent,
|
||||
LeaveServerDialogComponent,
|
||||
type LeaveServerDialogResult
|
||||
} from '../../../../shared';
|
||||
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';
|
||||
|
||||
interface JoinPluginConsentDialog {
|
||||
optional: PluginRequirementSummary[];
|
||||
password?: string;
|
||||
required: PluginRequirementSummary[];
|
||||
server: ServerInfo;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-search',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon, ConfirmDialogComponent, LeaveServerDialogComponent, UserSearchListComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ConfirmDialogComponent,
|
||||
LeaveServerDialogComponent,
|
||||
UserSearchListComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideSearch,
|
||||
@@ -49,6 +96,8 @@ export class ServerSearchComponent implements OnInit {
|
||||
private db = inject(DatabaseService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private pluginRequirements = inject(PluginRequirementService);
|
||||
private pluginStore = inject(PluginStoreService);
|
||||
private searchSubject = new Subject<string>();
|
||||
private banLookupRequestVersion = 0;
|
||||
|
||||
@@ -69,6 +118,10 @@ export class ServerSearchComponent implements OnInit {
|
||||
joinErrorMessage = signal<string | null>(null);
|
||||
joinedServerMenuId = signal<string | null>(null);
|
||||
leaveDialogRoom = signal<Room | null>(null);
|
||||
pluginConsentDialog = signal<JoinPluginConsentDialog | null>(null);
|
||||
selectedOptionalPluginIds = signal<Set<string>>(new Set());
|
||||
pluginConsentBusy = signal(false);
|
||||
pluginConsentError = signal<string | null>(null);
|
||||
|
||||
// Create dialog state
|
||||
showCreateDialog = signal(false);
|
||||
@@ -138,7 +191,8 @@ export class ServerSearchComponent implements OnInit {
|
||||
|
||||
/** Submit the new server creation form and dispatch the create action. */
|
||||
createServer(): void {
|
||||
if (!this.newServerName()) return;
|
||||
if (!this.newServerName())
|
||||
return;
|
||||
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
@@ -244,10 +298,65 @@ export class ServerSearchComponent implements OnInit {
|
||||
this.joinPasswordError.set(null);
|
||||
}
|
||||
|
||||
closePluginConsentDialog(): void {
|
||||
if (this.pluginConsentBusy()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pluginConsentDialog.set(null);
|
||||
this.selectedOptionalPluginIds.set(new Set());
|
||||
this.pluginConsentError.set(null);
|
||||
}
|
||||
|
||||
toggleOptionalPluginInstall(pluginId: string, checked: boolean): void {
|
||||
this.selectedOptionalPluginIds.update((selectedIds) => {
|
||||
const nextIds = new Set(selectedIds);
|
||||
|
||||
if (checked) {
|
||||
nextIds.add(pluginId);
|
||||
} else {
|
||||
nextIds.delete(pluginId);
|
||||
}
|
||||
|
||||
return nextIds;
|
||||
});
|
||||
}
|
||||
|
||||
async confirmPluginConsent(): Promise<void> {
|
||||
const dialog = this.pluginConsentDialog();
|
||||
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOptionalIds = this.selectedOptionalPluginIds();
|
||||
const acceptedRequirements = dialog.required.concat(
|
||||
dialog.optional.filter((requirement) => selectedOptionalIds.has(requirement.pluginId))
|
||||
);
|
||||
|
||||
this.pluginConsentBusy.set(true);
|
||||
this.pluginConsentError.set(null);
|
||||
|
||||
try {
|
||||
await this.attemptJoinServer(dialog.server, dialog.password, {
|
||||
acceptedRequirements,
|
||||
skipPluginConsent: true
|
||||
});
|
||||
|
||||
this.pluginConsentDialog.set(null);
|
||||
this.selectedOptionalPluginIds.set(new Set());
|
||||
} catch (error) {
|
||||
this.pluginConsentError.set(error instanceof Error ? error.message : 'Unable to install server plugins');
|
||||
} finally {
|
||||
this.pluginConsentBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async confirmPasswordJoin(): Promise<void> {
|
||||
const server = this.passwordPromptServer();
|
||||
|
||||
if (!server) return;
|
||||
if (!server)
|
||||
return;
|
||||
|
||||
await this.attemptJoinServer(server, this.joinPassword());
|
||||
}
|
||||
@@ -259,7 +368,8 @@ export class ServerSearchComponent implements OnInit {
|
||||
getServerUserCount(server: ServerInfo): number {
|
||||
const candidate = server as ServerInfo & { currentUsers?: number };
|
||||
|
||||
if (typeof server.userCount === 'number') return server.userCount;
|
||||
if (typeof server.userCount === 'number')
|
||||
return server.userCount;
|
||||
|
||||
return typeof candidate.currentUsers === 'number' ? candidate.currentUsers : 0;
|
||||
}
|
||||
@@ -302,7 +412,11 @@ export class ServerSearchComponent implements OnInit {
|
||||
};
|
||||
}
|
||||
|
||||
private async attemptJoinServer(server: ServerInfo, password?: string): Promise<void> {
|
||||
private async attemptJoinServer(
|
||||
server: ServerInfo,
|
||||
password?: string,
|
||||
options: { acceptedRequirements?: PluginRequirementSummary[]; skipPluginConsent?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
@@ -315,6 +429,16 @@ export class ServerSearchComponent implements OnInit {
|
||||
this.joinPasswordError.set(null);
|
||||
|
||||
try {
|
||||
if (options.skipPluginConsent !== true) {
|
||||
const consentDialog = await this.buildPluginConsentDialog(server, password);
|
||||
|
||||
if (consentDialog) {
|
||||
this.pluginConsentDialog.set(consentDialog);
|
||||
this.selectedOptionalPluginIds.set(new Set(consentDialog.optional.map((requirement) => requirement.pluginId)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.serverDirectory.requestJoin(
|
||||
{
|
||||
@@ -351,6 +475,11 @@ export class ServerSearchComponent implements OnInit {
|
||||
};
|
||||
|
||||
this.closePasswordDialog();
|
||||
|
||||
if (options.acceptedRequirements?.length) {
|
||||
await this.pluginStore.installServerRequirementsLocally(resolvedServer.id, options.acceptedRequirements, { activate: true });
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: resolvedServer.id,
|
||||
@@ -378,8 +507,39 @@ export class ServerSearchComponent implements OnInit {
|
||||
}
|
||||
|
||||
this.joinErrorMessage.set(message);
|
||||
|
||||
if (options.skipPluginConsent) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async buildPluginConsentDialog(server: ServerInfo, password?: string): Promise<JoinPluginConsentDialog | null> {
|
||||
const apiBaseUrl = this.serverDirectory.getApiBaseUrl({
|
||||
sourceId: server.sourceId,
|
||||
sourceUrl: server.sourceUrl
|
||||
});
|
||||
const snapshot = await firstValueFrom(this.pluginRequirements.getSnapshot(apiBaseUrl, server.id));
|
||||
const installedPluginIds = await this.pluginStore.getLocalServerInstalledPluginIds(server.id);
|
||||
const installableRequirements = snapshot.requirements
|
||||
.filter((requirement) => !installedPluginIds.has(requirement.pluginId))
|
||||
.filter((requirement) => !!requirement.manifest || !!requirement.installUrl);
|
||||
const required = installableRequirements.filter((requirement) => requirement.status === 'required');
|
||||
const optional = installableRequirements.filter(
|
||||
(requirement) => requirement.status === 'optional' || requirement.status === 'recommended'
|
||||
);
|
||||
|
||||
if (required.length === 0 && optional.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
optional,
|
||||
password,
|
||||
required,
|
||||
server
|
||||
};
|
||||
}
|
||||
|
||||
private async requestMissingServerIcons(servers: ServerInfo[], currentUser: User | null): Promise<void> {
|
||||
if (!currentUser) {
|
||||
@@ -415,6 +575,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
description: currentUser.description,
|
||||
profileUpdatedAt: currentUser.profileUpdatedAt
|
||||
});
|
||||
|
||||
this.webrtc.sendRawMessageToSignalUrl(wsUrl, {
|
||||
type: 'server_icon_sync_request',
|
||||
serverId: server.id,
|
||||
@@ -444,7 +605,8 @@ export class ServerSearchComponent implements OnInit {
|
||||
})
|
||||
);
|
||||
|
||||
if (requestVersion !== this.banLookupRequestVersion) return;
|
||||
if (requestVersion !== this.banLookupRequestVersion)
|
||||
return;
|
||||
|
||||
this.bannedServerLookup.set(Object.fromEntries(entries));
|
||||
}
|
||||
@@ -453,7 +615,8 @@ export class ServerSearchComponent implements OnInit {
|
||||
const currentUser = this.currentUser();
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUser && !currentUserId) return false;
|
||||
if (!currentUser && !currentUserId)
|
||||
return false;
|
||||
|
||||
const bans = await this.db.getBansForRoom(server.id);
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { isAnimatedGif, isAnimatedWebp } from '../../../profile-avatar/infrastructure/services/profile-avatar-image.service';
|
||||
|
||||
export interface ProcessedServerIcon {
|
||||
dataUrl: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const SERVER_ICON_SIZE = 64;
|
||||
const STATIC_ICON_CANDIDATES = [
|
||||
{ mime: 'image/webp', quality: 0.82 },
|
||||
{ mime: 'image/jpeg', quality: 0.82 },
|
||||
{ mime: 'image/png' }
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerIconImageService {
|
||||
async process(file: File): Promise<ProcessedServerIcon> {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('Choose an image file.');
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
try {
|
||||
const image = await this.loadImage(objectUrl);
|
||||
const isAnimated = await this.isAnimated(file);
|
||||
|
||||
if (isAnimated && image.naturalWidth <= SERVER_ICON_SIZE && image.naturalHeight <= SERVER_ICON_SIZE) {
|
||||
const dataUrl = await this.readBlobAsDataUrl(file);
|
||||
|
||||
return {
|
||||
dataUrl,
|
||||
mime: file.type || this.resolveMimeFromDataUrl(dataUrl),
|
||||
size: file.size
|
||||
};
|
||||
}
|
||||
|
||||
return await this.renderStaticIcon(image);
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private async renderStaticIcon(image: HTMLImageElement): Promise<ProcessedServerIcon> {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Canvas not supported.');
|
||||
}
|
||||
|
||||
canvas.width = SERVER_ICON_SIZE;
|
||||
canvas.height = SERVER_ICON_SIZE;
|
||||
|
||||
const scale = Math.max(SERVER_ICON_SIZE / image.naturalWidth, SERVER_ICON_SIZE / image.naturalHeight);
|
||||
const drawWidth = image.naturalWidth * scale;
|
||||
const drawHeight = image.naturalHeight * scale;
|
||||
const drawX = (SERVER_ICON_SIZE - drawWidth) / 2;
|
||||
const drawY = (SERVER_ICON_SIZE - drawHeight) / 2;
|
||||
|
||||
context.clearRect(0, 0, SERVER_ICON_SIZE, SERVER_ICON_SIZE);
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
|
||||
const candidates = await Promise.all(
|
||||
STATIC_ICON_CANDIDATES.map(async (candidate) => {
|
||||
const blob = await this.canvasToBlob(canvas, candidate.mime, candidate.quality);
|
||||
const dataUrl = await this.readBlobAsDataUrl(blob);
|
||||
|
||||
return {
|
||||
dataUrl,
|
||||
mime: blob.type || candidate.mime,
|
||||
size: blob.size
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return candidates.reduce((smallest, candidate) => (candidate.size < smallest.size ? candidate : smallest));
|
||||
}
|
||||
|
||||
private async isAnimated(file: File): Promise<boolean> {
|
||||
const mime = file.type.toLowerCase();
|
||||
|
||||
if (mime !== 'image/gif' && mime !== 'image/webp') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
|
||||
return mime === 'image/gif' ? isAnimatedGif(buffer) : isAnimatedWebp(buffer);
|
||||
}
|
||||
|
||||
private canvasToBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Failed to render server image.'));
|
||||
},
|
||||
type,
|
||||
quality
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private readBlobAsDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Failed to encode server image.'));
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read server image.'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
private loadImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('Failed to load server image.'));
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
private resolveMimeFromDataUrl(dataUrl: string): string {
|
||||
const match = /^data:([^;,]+)/.exec(dataUrl);
|
||||
|
||||
return match?.[1] || 'image/webp';
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
applyThemeStyleDeclaration,
|
||||
toCssStylePropertyName
|
||||
} from './theme-style-application.logic';
|
||||
import { applyThemeStyleDeclaration, toCssStylePropertyName } from './theme-style-application.logic';
|
||||
|
||||
describe('theme style application', () => {
|
||||
it('applies camelCase theme properties as real CSS declarations', () => {
|
||||
|
||||
@@ -16,13 +16,11 @@
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
@if (voiceSession()?.serverIcon) {
|
||||
<img
|
||||
[ngSrc]="voiceSession()?.serverIcon || ''"
|
||||
class="w-5 h-5 rounded object-cover"
|
||||
alt=""
|
||||
width="20"
|
||||
height="20"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 rounded bg-cover bg-center bg-no-repeat"
|
||||
[style.backgroundImage]="'url(' + voiceSession()!.serverIcon + ')'"
|
||||
></span>
|
||||
} @else {
|
||||
<div class="flex h-5 w-5 items-center justify-center rounded-sm bg-muted text-[10px] font-semibold">
|
||||
{{ voiceSession()?.serverName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
computed,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
@@ -34,7 +34,6 @@ import { ThemeNodeDirective } from '../../../../domains/theme';
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgOptimizedImage,
|
||||
NgIcon,
|
||||
DebugConsoleComponent,
|
||||
ScreenShareQualityDialogComponent,
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid h-9 w-9 place-items-center overflow-hidden rounded-md bg-secondary text-sm font-semibold text-foreground">
|
||||
@if (currentRoom()?.icon) {
|
||||
<img
|
||||
[src]="currentRoom()!.icon"
|
||||
[alt]="currentRoom()!.name + ' icon'"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||
[style.backgroundImage]="'url(' + currentRoom()!.icon + ')'"
|
||||
></div>
|
||||
} @else {
|
||||
{{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }}
|
||||
}
|
||||
|
||||
@@ -42,11 +42,11 @@
|
||||
>
|
||||
<div class="h-full w-full overflow-hidden rounded-[inherit]">
|
||||
@if (room.icon) {
|
||||
<img
|
||||
[src]="room.icon"
|
||||
[alt]="room.name + ' icon'"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||
[style.backgroundImage]="'url(' + room.icon + ')'"
|
||||
></div>
|
||||
} @else {
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center bg-secondary transition-colors"
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, DestroyRef, Type, computed, effect, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
Type,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -7,7 +15,17 @@ import { Store } from '@ngrx/store';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucidePlus } from '@ng-icons/lucide';
|
||||
import { EMPTY, Subject, catchError, filter, firstValueFrom, from, map, switchMap, tap } from 'rxjs';
|
||||
import {
|
||||
EMPTY,
|
||||
Subject,
|
||||
catchError,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
switchMap,
|
||||
tap
|
||||
} from 'rxjs';
|
||||
|
||||
import { Room, User } from '../../../shared-kernel';
|
||||
import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component';
|
||||
@@ -20,7 +38,11 @@ import { NotificationsFacade } from '../../../domains/notifications';
|
||||
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
import { hasRoomBanForUser } from '../../../domains/access-control';
|
||||
import { ConfirmDialogComponent, ContextMenuComponent, LeaveServerDialogComponent } from '../../../shared';
|
||||
import {
|
||||
ConfirmDialogComponent,
|
||||
ContextMenuComponent,
|
||||
LeaveServerDialogComponent
|
||||
} from '../../../shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-servers-rail',
|
||||
@@ -143,7 +165,8 @@ export class ServersRailComponent {
|
||||
}
|
||||
|
||||
initial(name?: string): string {
|
||||
if (!name) return '?';
|
||||
if (!name)
|
||||
return '?';
|
||||
|
||||
const ch = name.trim()[0]?.toUpperCase();
|
||||
|
||||
@@ -195,7 +218,8 @@ export class ServersRailComponent {
|
||||
confirmPasswordJoin(): void {
|
||||
const room = this.passwordPromptRoom();
|
||||
|
||||
if (!room) return;
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.joinPasswordError.set(null);
|
||||
this.savedRoomJoinRequests.next({ room, password: this.joinPassword() });
|
||||
@@ -235,7 +259,8 @@ export class ServersRailComponent {
|
||||
confirmLeave(result: { nextOwnerKey?: string }): void {
|
||||
const ctx = this.contextRoom();
|
||||
|
||||
if (!ctx) return;
|
||||
if (!ctx)
|
||||
return;
|
||||
|
||||
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
|
||||
|
||||
@@ -338,7 +363,8 @@ export class ServersRailComponent {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
if (!currentUserId) return EMPTY;
|
||||
if (!currentUserId)
|
||||
return EMPTY;
|
||||
|
||||
this.joinPasswordError.set(null);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
@@ -54,7 +62,7 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
||||
case 'running':
|
||||
return `Running at ${snapshot.baseUrl ?? 'unknown'}`;
|
||||
case 'starting':
|
||||
return 'Starting…';
|
||||
return 'Starting...';
|
||||
case 'error':
|
||||
return `Error: ${snapshot.error ?? 'unknown error'}`;
|
||||
case 'stopped':
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-base font-semibold text-foreground">
|
||||
@if (serverData()?.icon) {
|
||||
<img
|
||||
[src]="serverData()!.icon"
|
||||
[alt]="serverData()!.name + ' icon'"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||
[style.backgroundImage]="'url(' + serverData()!.icon + ')'"
|
||||
></div>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideImage"
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Room } from '../../../../shared-kernel';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { ConfirmDialogComponent } from '../../../../shared';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { ServerIconImageService } from '../../../../domains/server-directory/infrastructure/services/server-icon-image.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-settings',
|
||||
@@ -50,6 +51,7 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
|
||||
export class ServerSettingsComponent {
|
||||
private store = inject(Store);
|
||||
private modal = inject(SettingsModalService);
|
||||
private serverIconImages = inject(ServerIconImageService);
|
||||
|
||||
/** The currently selected server, passed from the parent. */
|
||||
server = input<Room | null>(null);
|
||||
@@ -181,7 +183,7 @@ export class ServerSettingsComponent {
|
||||
this.modal.navigate('network');
|
||||
}
|
||||
|
||||
onServerIconSelected(event: Event): void {
|
||||
async onServerIconSelected(event: Event): Promise<void> {
|
||||
const inputElement = event.target as HTMLInputElement;
|
||||
const file = inputElement.files?.[0];
|
||||
|
||||
@@ -191,37 +193,24 @@ export class ServerSettingsComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
this.iconError.set('Choose an image file.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 512 * 1024) {
|
||||
this.iconError.set('Choose an image smaller than 512 KB.');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const room = this.server();
|
||||
const icon = typeof reader.result === 'string' ? reader.result : '';
|
||||
const icon = await this.serverIconImages.process(file);
|
||||
|
||||
if (!room || !icon) {
|
||||
this.iconError.set('Could not read that image.');
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iconError.set(null);
|
||||
this.store.dispatch(RoomsActions.updateServerIcon({
|
||||
roomId: room.id,
|
||||
icon
|
||||
icon: icon.dataUrl
|
||||
}));
|
||||
this.showSaveSuccess('icon');
|
||||
};
|
||||
|
||||
reader.onerror = () => this.iconError.set('Could not read that image.');
|
||||
reader.readAsDataURL(file);
|
||||
this.showSaveSuccess('icon');
|
||||
} catch (error) {
|
||||
this.iconError.set(error instanceof Error ? error.message : 'Could not read that image.');
|
||||
}
|
||||
}
|
||||
|
||||
removeServerIcon(): void {
|
||||
@@ -236,6 +225,7 @@ export class ServerSettingsComponent {
|
||||
roomId: room.id,
|
||||
icon: ''
|
||||
}));
|
||||
|
||||
this.showSaveSuccess('icon');
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,24 @@
|
||||
/>
|
||||
</button>
|
||||
|
||||
@if (hasServerPlugins()) {
|
||||
<button
|
||||
type="button"
|
||||
class="relative grid h-8 w-8 place-items-center rounded-md text-foreground transition-colors hover:bg-secondary"
|
||||
(click)="openServerPlugins()"
|
||||
title="Server plugins"
|
||||
aria-label="Server plugins"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideShield"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="absolute right-0 top-0 min-w-3 rounded-full bg-primary px-1 text-[9px] font-semibold leading-3 text-primary-foreground">
|
||||
{{ serverPluginCount() }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (isElectron()) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -227,6 +245,123 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (optionalPluginRequirement(); as requirement) {
|
||||
<section
|
||||
class="flex min-h-10 items-center justify-between gap-3 border-b border-border bg-primary/10 px-4 py-2 text-sm text-foreground"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style="-webkit-app-region: no-drag"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ng-icon
|
||||
name="lucidePackage"
|
||||
class="h-4 w-4 shrink-0 text-primary"
|
||||
/>
|
||||
<p class="truncate">
|
||||
Optional server plugin available:
|
||||
<span class="font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</span>
|
||||
@if (optionalPluginRequirementCount() > 1) {
|
||||
<span class="text-muted-foreground">+{{ optionalPluginRequirementCount() - 1 }} more</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
@if (pluginRequirementError()) {
|
||||
<span class="max-w-56 truncate text-xs text-destructive">{{ pluginRequirementError() }}</span>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border bg-card px-2.5 py-1 text-xs font-semibold transition-colors hover:bg-secondary disabled:opacity-60"
|
||||
[disabled]="pluginRequirementBusy()"
|
||||
(click)="rejectOptionalServerPlugin(requirement)"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border bg-card px-2.5 py-1 text-xs font-semibold transition-colors hover:bg-secondary disabled:opacity-60"
|
||||
[disabled]="pluginRequirementBusy()"
|
||||
(click)="hideOptionalServerPlugin(requirement)"
|
||||
>
|
||||
Don't show again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-primary bg-primary px-2.5 py-1 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-60"
|
||||
[disabled]="pluginRequirementBusy()"
|
||||
(click)="installOptionalServerPlugin(requirement)"
|
||||
>
|
||||
{{ pluginRequirementBusy() ? 'Installing' : 'Install' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (requiredPluginRequirements().length > 0 && currentRoom()) {
|
||||
<div
|
||||
class="fixed inset-0 z-[80] bg-black/60"
|
||||
role="presentation"
|
||||
></div>
|
||||
<section
|
||||
class="fixed left-1/2 top-1/2 z-[81] flex max-h-[min(38rem,calc(100vh-2rem))] w-[min(32rem,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="required-server-plugin-title"
|
||||
style="-webkit-app-region: no-drag"
|
||||
>
|
||||
<header class="border-b border-border p-4">
|
||||
<p class="text-sm text-muted-foreground">Required server plugins</p>
|
||||
<h2
|
||||
id="required-server-plugin-title"
|
||||
class="mt-1 text-lg font-semibold"
|
||||
>
|
||||
{{ currentRoom()!.name }} requires a plugin update
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 space-y-3 overflow-auto p-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
An admin added required plugins for this server. Install them to keep using the server, or leave the server.
|
||||
</p>
|
||||
@for (requirement of requiredPluginRequirements(); track requirement.pluginId) {
|
||||
<article class="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>
|
||||
@if (requirement.reason) {
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ requirement.reason }}</p>
|
||||
}
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">Required</span>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
@if (pluginRequirementError()) {
|
||||
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ pluginRequirementError() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="flex justify-end gap-2 border-t border-border p-4">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary disabled:opacity-60"
|
||||
[disabled]="pluginRequirementBusy()"
|
||||
(click)="confirmLeave({})"
|
||||
>
|
||||
Leave server
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-primary bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-60"
|
||||
[disabled]="pluginRequirementBusy()"
|
||||
(click)="installRequiredServerPlugins()"
|
||||
>
|
||||
{{ pluginRequirementBusy() ? 'Installing' : 'Install plugins' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
}
|
||||
<!-- Click-away overlay to close dropdown -->
|
||||
@if (showMenu()) {
|
||||
<div
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
lucideHash,
|
||||
lucideMenu,
|
||||
lucidePackage,
|
||||
lucideRefreshCw
|
||||
lucideRefreshCw,
|
||||
lucideShield
|
||||
} from '@ng-icons/lucide';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
@@ -42,9 +43,15 @@ import { PlatformService } from '../../../core/platform';
|
||||
import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage';
|
||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||
import { LeaveServerDialogComponent } from '../../../shared';
|
||||
import { Room } from '../../../shared-kernel';
|
||||
import { Room, type PluginRequirementSummary } from '../../../shared-kernel';
|
||||
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
import {
|
||||
PluginRegistryService,
|
||||
PluginRequirementStateService,
|
||||
PluginStoreService
|
||||
} from '../../../domains/plugins';
|
||||
import { getPluginInstallScope } from '../../../domains/plugins/domain/logic/plugin-install-scope.logic';
|
||||
|
||||
@Component({
|
||||
selector: 'app-title-bar',
|
||||
@@ -64,7 +71,8 @@ import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
lucideHash,
|
||||
lucideMenu,
|
||||
lucidePackage,
|
||||
lucideRefreshCw })
|
||||
lucideRefreshCw,
|
||||
lucideShield })
|
||||
],
|
||||
templateUrl: './title-bar.component.html'
|
||||
})
|
||||
@@ -80,6 +88,9 @@ export class TitleBarComponent {
|
||||
private platform = inject(PlatformService);
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private pluginRegistry = inject(PluginRegistryService);
|
||||
private pluginRequirements = inject(PluginRequirementStateService);
|
||||
private pluginStore = inject(PluginStoreService);
|
||||
|
||||
private getWindowControlsApi() {
|
||||
return this.electronBridge.getApi();
|
||||
@@ -153,11 +164,20 @@ export class TitleBarComponent {
|
||||
|| this.isReconnecting()
|
||||
)
|
||||
);
|
||||
serverPluginCount = computed(() => this.pluginRegistry.entries()
|
||||
.filter((entry) => getPluginInstallScope(entry.manifest) === 'server')
|
||||
.length);
|
||||
hasServerPlugins = computed(() => this.inRoom() && this.serverPluginCount() > 0);
|
||||
requiredPluginRequirements = this.pluginRequirements.missingRequiredRequirements;
|
||||
optionalPluginRequirement = computed(() => this.inRoom() ? this.pluginRequirements.visibleOptionalRequirements()[0] ?? null : null);
|
||||
optionalPluginRequirementCount = computed(() => this.pluginRequirements.visibleOptionalRequirements().length);
|
||||
private _showMenu = signal(false);
|
||||
showMenu = computed(() => this._showMenu());
|
||||
showLeaveConfirm = signal(false);
|
||||
inviteStatus = signal<string | null>(null);
|
||||
creatingInvite = signal(false);
|
||||
pluginRequirementBusy = signal(false);
|
||||
pluginRequirementError = signal<string | null>(null);
|
||||
|
||||
/** Minimize the Electron window. */
|
||||
minimize() {
|
||||
@@ -192,6 +212,17 @@ export class TitleBarComponent {
|
||||
void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } });
|
||||
}
|
||||
|
||||
openServerPlugins(): void {
|
||||
const roomId = this.currentRoom()?.id;
|
||||
|
||||
if (!roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._showMenu.set(false);
|
||||
this.settingsModal.open('serverPlugins', roomId);
|
||||
}
|
||||
|
||||
openSettings(): void {
|
||||
this._showMenu.set(false);
|
||||
this.settingsModal.open('general');
|
||||
@@ -267,6 +298,24 @@ export class TitleBarComponent {
|
||||
this.openLeaveConfirm();
|
||||
}
|
||||
|
||||
installRequiredServerPlugins(): void {
|
||||
void this.installServerRequirements(this.requiredPluginRequirements());
|
||||
}
|
||||
|
||||
installOptionalServerPlugin(requirement: PluginRequirementSummary): void {
|
||||
void this.installServerRequirements([requirement]);
|
||||
}
|
||||
|
||||
rejectOptionalServerPlugin(requirement: PluginRequirementSummary): void {
|
||||
this.pluginRequirements.dismissOptionalRequirement(requirement);
|
||||
this.pluginRequirementError.set(null);
|
||||
}
|
||||
|
||||
hideOptionalServerPlugin(requirement: PluginRequirementSummary): void {
|
||||
this.pluginRequirements.dismissOptionalRequirement(requirement, { persist: true });
|
||||
this.pluginRequirementError.set(null);
|
||||
}
|
||||
|
||||
/** Confirm the unified leave action and remove the server locally. */
|
||||
confirmLeave(result: { nextOwnerKey?: string }) {
|
||||
const roomId = this.currentRoom()?.id;
|
||||
@@ -294,6 +343,25 @@ export class TitleBarComponent {
|
||||
this._showMenu.set(false);
|
||||
}
|
||||
|
||||
private async installServerRequirements(requirements: PluginRequirementSummary[]): Promise<void> {
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (!room || requirements.length === 0 || this.pluginRequirementBusy()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pluginRequirementBusy.set(true);
|
||||
this.pluginRequirementError.set(null);
|
||||
|
||||
try {
|
||||
await this.pluginStore.installServerRequirementsLocally(room.id, requirements, { activate: true });
|
||||
} catch (error) {
|
||||
this.pluginRequirementError.set(error instanceof Error ? error.message : 'Unable to install server plugin');
|
||||
} finally {
|
||||
this.pluginRequirementBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||
logout() {
|
||||
this._showMenu.set(false);
|
||||
|
||||
@@ -81,6 +81,8 @@ The renderer sends structured command/query objects through the Electron preload
|
||||
|
||||
The Electron schema now normalises reaction rows and room channel/member rosters into separate SQLite tables instead of storing those arrays inline on the parent message or room rows. The renderer-facing API is unchanged: CQRS handlers rehydrate the same `Message` and `Room` payloads before returning them over IPC.
|
||||
|
||||
Electron room membership is user-scoped through `room_owners`, and messages carry `ownerUserId`. Auth setup writes the current user ID to the database before room loading, so `/search`, the server rail, and local history only hydrate rooms/messages owned by the active account. A room row can still hold shared server metadata for the same server ID, but each account has its own ownership edge and message history.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Eff as NgRx Effect
|
||||
|
||||
@@ -140,9 +140,11 @@ export class IncomingSignalingMessageHandler {
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.oderId) continue;
|
||||
if (!user.oderId)
|
||||
continue;
|
||||
|
||||
if (localOderId && user.oderId === localOderId) continue;
|
||||
if (localOderId && user.oderId === localOderId)
|
||||
continue;
|
||||
|
||||
this.clearUserJoinedFallbackOffer(user.oderId);
|
||||
|
||||
@@ -310,9 +312,11 @@ export class IncomingSignalingMessageHandler {
|
||||
const fromUserId = message.fromUserId;
|
||||
const sdp = message.payload?.sdp;
|
||||
|
||||
if (!fromUserId || !sdp) return;
|
||||
if (!fromUserId || !sdp)
|
||||
return;
|
||||
|
||||
if (fromUserId === this.dependencies.getLocalOderId()) return;
|
||||
if (fromUserId === this.dependencies.getLocalOderId())
|
||||
return;
|
||||
|
||||
this.clearUserJoinedFallbackOffer(fromUserId);
|
||||
this.nonInitiatorWaitStart.delete(fromUserId);
|
||||
@@ -332,9 +336,11 @@ export class IncomingSignalingMessageHandler {
|
||||
const fromUserId = message.fromUserId;
|
||||
const sdp = message.payload?.sdp;
|
||||
|
||||
if (!fromUserId || !sdp) return;
|
||||
if (!fromUserId || !sdp)
|
||||
return;
|
||||
|
||||
if (fromUserId === this.dependencies.getLocalOderId()) return;
|
||||
if (fromUserId === this.dependencies.getLocalOderId())
|
||||
return;
|
||||
|
||||
this.clearUserJoinedFallbackOffer(fromUserId);
|
||||
|
||||
@@ -346,9 +352,11 @@ export class IncomingSignalingMessageHandler {
|
||||
const fromUserId = message.fromUserId;
|
||||
const candidate = message.payload?.candidate;
|
||||
|
||||
if (!fromUserId || !candidate) return;
|
||||
if (!fromUserId || !candidate)
|
||||
return;
|
||||
|
||||
if (fromUserId === this.dependencies.getLocalOderId()) return;
|
||||
if (fromUserId === this.dependencies.getLocalOderId())
|
||||
return;
|
||||
|
||||
this.clearUserJoinedFallbackOffer(fromUserId);
|
||||
|
||||
@@ -507,15 +515,18 @@ export class IncomingSignalingMessageHandler {
|
||||
}
|
||||
|
||||
private shouldInitiatePeer(peerId: string, localOderId: string | null = this.dependencies.getLocalOderId()): boolean {
|
||||
if (!localOderId) return false;
|
||||
if (!localOderId)
|
||||
return false;
|
||||
|
||||
if (peerId === localOderId) return false;
|
||||
if (peerId === localOderId)
|
||||
return false;
|
||||
|
||||
return localOderId < peerId;
|
||||
}
|
||||
|
||||
private hasActivePeerConnection(peer: PeerData | undefined): boolean {
|
||||
if (!peer) return false;
|
||||
if (!peer)
|
||||
return false;
|
||||
|
||||
const connectionState = peer.connection?.connectionState;
|
||||
|
||||
@@ -523,11 +534,13 @@ export class IncomingSignalingMessageHandler {
|
||||
}
|
||||
|
||||
private isPeerConnectionNegotiating(peer: PeerData | undefined): boolean {
|
||||
if (!peer || this.hasActivePeerConnection(peer)) return false;
|
||||
if (!peer || this.hasActivePeerConnection(peer))
|
||||
return false;
|
||||
|
||||
const connectionState = peer.connection?.connectionState;
|
||||
|
||||
if (connectionState === 'closed' || connectionState === 'failed') return false;
|
||||
if (connectionState === 'closed' || connectionState === 'failed')
|
||||
return false;
|
||||
|
||||
const signalingState = peer.connection?.signalingState;
|
||||
const ageMs = Date.now() - peer.createdAt;
|
||||
@@ -535,11 +548,13 @@ export class IncomingSignalingMessageHandler {
|
||||
// If a local offer (or pranswer) has already been sent, the peer is actively
|
||||
// negotiating with the remote side. Use a much longer grace period so that
|
||||
// a slow signaling round-trip does not trigger a premature teardown.
|
||||
if (signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer') return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
|
||||
if (signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer')
|
||||
return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
|
||||
|
||||
// ICE negotiation in progress (offer/answer exchange already complete, candidates being checked).
|
||||
// TURN relay can take 5-15 s on high-latency networks, so use the same extended grace.
|
||||
if (connectionState === 'connecting') return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
|
||||
if (connectionState === 'connecting')
|
||||
return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
|
||||
|
||||
return ageMs < PEER_NEGOTIATION_GRACE_MS;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
defaultIfEmpty,
|
||||
firstValueFrom
|
||||
} from 'rxjs';
|
||||
import { defaultIfEmpty, firstValueFrom } from 'rxjs';
|
||||
|
||||
import { type Message } from '../../shared-kernel';
|
||||
import { dispatchIncomingMessage } from './messages-incoming.handlers';
|
||||
@@ -69,10 +66,9 @@ describe('dispatchIncomingMessage room-scoped sync', () => {
|
||||
});
|
||||
|
||||
it('sends full sync for requested room even when another room is viewed', async () => {
|
||||
const roomBMessages = [
|
||||
createMessage({ id: 'message-b1', roomId: 'room-b', timestamp: 5 }),
|
||||
createMessage({ id: 'message-b2', roomId: 'room-b', timestamp: 15 })
|
||||
];
|
||||
const roomBMessageOne = createMessage({ id: 'message-b1', roomId: 'room-b', timestamp: 5 });
|
||||
const roomBMessageTwo = createMessage({ id: 'message-b2', roomId: 'room-b', timestamp: 15 });
|
||||
const roomBMessages = [roomBMessageOne, roomBMessageTwo];
|
||||
const getMessages = vi.fn(async (roomId: string) => roomId === 'room-b'
|
||||
? roomBMessages
|
||||
: [createMessage({ id: 'message-a1', roomId: 'room-a', timestamp: 200 })]);
|
||||
|
||||
@@ -347,6 +347,7 @@ export class RoomSettingsEffects {
|
||||
icon,
|
||||
iconUpdatedAt
|
||||
});
|
||||
|
||||
this.webrtc.sendRawMessage({
|
||||
type: 'server_icon_available',
|
||||
serverId: room.id,
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
import {
|
||||
Actions,
|
||||
createEffect,
|
||||
ofType
|
||||
} from '@ngrx/effects';
|
||||
import { Store, type Action } from '@ngrx/store';
|
||||
import { of, from, EMPTY } from 'rxjs';
|
||||
import { map, mergeMap, withLatestFrom, tap, switchMap, catchError } from 'rxjs/operators';
|
||||
import {
|
||||
of,
|
||||
from,
|
||||
EMPTY
|
||||
} from 'rxjs';
|
||||
import {
|
||||
map,
|
||||
mergeMap,
|
||||
withLatestFrom,
|
||||
tap,
|
||||
switchMap,
|
||||
catchError
|
||||
} from 'rxjs/operators';
|
||||
import { RoomsActions } from './rooms.actions';
|
||||
import { UsersActions } from '../users/users.actions';
|
||||
import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
|
||||
import { selectActiveChannelId, selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
|
||||
import {
|
||||
selectActiveChannelId,
|
||||
selectCurrentRoom,
|
||||
selectSavedRooms
|
||||
} from './rooms.selectors';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { resolveRoomPermission } from '../../domains/access-control';
|
||||
import type { ChatEvent, Room, RoomSettings, RoomPermissions, BanEntry, User, VoiceState } from '../../shared-kernel';
|
||||
import type {
|
||||
ChatEvent,
|
||||
Room,
|
||||
RoomSettings,
|
||||
RoomPermissions,
|
||||
BanEntry,
|
||||
User,
|
||||
VoiceState
|
||||
} from '../../shared-kernel';
|
||||
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||
import { hasRoomBanForUser } from '../../domains/access-control';
|
||||
import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants';
|
||||
@@ -28,7 +55,12 @@ import {
|
||||
} from './rooms.helpers';
|
||||
import type { RoomPresenceSignalingMessage } from './rooms.helpers';
|
||||
|
||||
const SERVER_ICON_SYNC_REQUEST_DELAYS_MS = [1_500, 3_000, 5_000, 8_000];
|
||||
const SERVER_ICON_SYNC_REQUEST_DELAYS_MS = [
|
||||
1_500,
|
||||
3_000,
|
||||
5_000,
|
||||
8_000
|
||||
];
|
||||
|
||||
/**
|
||||
* NgRx effects for real-time state synchronisation: signaling presence
|
||||
@@ -64,7 +96,12 @@ export class RoomStateSyncEffects {
|
||||
signalingMessages$ = createEffect(() =>
|
||||
this.webrtc.onSignalingMessage.pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms)),
|
||||
mergeMap(([message, currentUser, currentRoom, savedRooms]) => {
|
||||
mergeMap(([
|
||||
message,
|
||||
currentUser,
|
||||
currentRoom,
|
||||
savedRooms
|
||||
]) => {
|
||||
const signalingMessage: RoomPresenceSignalingMessage = message;
|
||||
const myId = currentUser?.oderId || currentUser?.id;
|
||||
const viewedServerId = currentRoom?.id;
|
||||
@@ -73,7 +110,8 @@ export class RoomStateSyncEffects {
|
||||
|
||||
switch (signalingMessage.type) {
|
||||
case 'server_users': {
|
||||
if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId) return EMPTY;
|
||||
if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId)
|
||||
return EMPTY;
|
||||
|
||||
const syncedUsers = signalingMessage.users
|
||||
.filter((user) => user.oderId !== myId)
|
||||
@@ -102,9 +140,11 @@ export class RoomStateSyncEffects {
|
||||
}
|
||||
|
||||
case 'user_joined': {
|
||||
if (!signalingMessage.serverId || signalingMessage.oderId === myId) return EMPTY;
|
||||
if (!signalingMessage.serverId || signalingMessage.oderId === myId)
|
||||
return EMPTY;
|
||||
|
||||
if (!signalingMessage.oderId) return EMPTY;
|
||||
if (!signalingMessage.oderId)
|
||||
return EMPTY;
|
||||
|
||||
const joinedUser = {
|
||||
oderId: signalingMessage.oderId,
|
||||
@@ -132,7 +172,8 @@ export class RoomStateSyncEffects {
|
||||
}
|
||||
|
||||
case 'user_left': {
|
||||
if (!signalingMessage.oderId) return EMPTY;
|
||||
if (!signalingMessage.oderId)
|
||||
return EMPTY;
|
||||
|
||||
const remainingServerIds = Array.isArray(signalingMessage.serverIds) ? signalingMessage.serverIds : undefined;
|
||||
|
||||
@@ -160,11 +201,18 @@ export class RoomStateSyncEffects {
|
||||
}
|
||||
|
||||
case 'status_update': {
|
||||
if (!signalingMessage.oderId || !signalingMessage.status) return EMPTY;
|
||||
if (!signalingMessage.oderId || !signalingMessage.status)
|
||||
return EMPTY;
|
||||
|
||||
const validStatuses = ['online', 'away', 'busy', 'offline'];
|
||||
const validStatuses = [
|
||||
'online',
|
||||
'away',
|
||||
'busy',
|
||||
'offline'
|
||||
];
|
||||
|
||||
if (!validStatuses.includes(signalingMessage.status)) return EMPTY;
|
||||
if (!validStatuses.includes(signalingMessage.status))
|
||||
return EMPTY;
|
||||
|
||||
// 'offline' from the server means the user chose Invisible;
|
||||
// display them as disconnected to other users.
|
||||
@@ -179,14 +227,17 @@ export class RoomStateSyncEffects {
|
||||
}
|
||||
|
||||
case 'access_denied': {
|
||||
if (isWrongServer(signalingMessage.serverId, viewedServerId)) return EMPTY;
|
||||
if (isWrongServer(signalingMessage.serverId, viewedServerId))
|
||||
return EMPTY;
|
||||
|
||||
if (signalingMessage.reason !== 'SERVER_NOT_FOUND') return EMPTY;
|
||||
if (signalingMessage.reason !== 'SERVER_NOT_FOUND')
|
||||
return EMPTY;
|
||||
|
||||
// When multiple signal URLs are configured, the room may already
|
||||
// be successfully joined on a different signal server. Only show
|
||||
// the reconnect notice when the room is not reachable at all.
|
||||
if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId)) return EMPTY;
|
||||
if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId))
|
||||
return EMPTY;
|
||||
|
||||
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: true })];
|
||||
}
|
||||
@@ -263,7 +314,8 @@ export class RoomStateSyncEffects {
|
||||
this.webrtc.onPeerConnected.pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
tap(([peerId, room]) => {
|
||||
if (!room) return;
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.webrtc.sendToPeer(peerId, {
|
||||
type: 'server-state-request',
|
||||
@@ -313,7 +365,14 @@ export class RoomStateSyncEffects {
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectActiveChannelId)
|
||||
),
|
||||
mergeMap(([event, currentRoom, savedRooms, allUsers, currentUser, activeChannelId]) => {
|
||||
mergeMap(([
|
||||
event,
|
||||
currentRoom,
|
||||
savedRooms,
|
||||
allUsers,
|
||||
currentUser,
|
||||
activeChannelId
|
||||
]) => {
|
||||
switch (event.type) {
|
||||
case 'voice-state':
|
||||
return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice');
|
||||
@@ -353,7 +412,8 @@ export class RoomStateSyncEffects {
|
||||
this.webrtc.onPeerConnected.pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
tap(([_peerId, room]) => {
|
||||
if (!room) return;
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
const iconUpdatedAt = room.iconUpdatedAt || 0;
|
||||
|
||||
@@ -374,7 +434,8 @@ export class RoomStateSyncEffects {
|
||||
tap((peerId) => {
|
||||
const serverIds = this.pendingServerIconRequestsByPeer.get(peerId);
|
||||
|
||||
if (!serverIds) return;
|
||||
if (!serverIds)
|
||||
return;
|
||||
|
||||
for (const serverId of serverIds) {
|
||||
this.sendServerIconSyncRequest(peerId, serverId);
|
||||
@@ -389,7 +450,8 @@ export class RoomStateSyncEffects {
|
||||
private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], currentUser: User | null, kind: 'voice' | 'screen' | 'camera') {
|
||||
const userId: string | undefined = event.fromPeerId ?? event.oderId;
|
||||
|
||||
if (!userId) return EMPTY;
|
||||
if (!userId)
|
||||
return EMPTY;
|
||||
|
||||
const existingUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
|
||||
const userExists = !!existingUser;
|
||||
@@ -397,7 +459,8 @@ export class RoomStateSyncEffects {
|
||||
if (kind === 'voice') {
|
||||
const vs = event.voiceState as Partial<VoiceState> | undefined;
|
||||
|
||||
if (!vs) return EMPTY;
|
||||
if (!vs)
|
||||
return EMPTY;
|
||||
|
||||
const presenceRefreshAction =
|
||||
vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId)
|
||||
@@ -471,7 +534,8 @@ export class RoomStateSyncEffects {
|
||||
if (kind === 'screen') {
|
||||
const isSharing = event.isScreenSharing as boolean | undefined;
|
||||
|
||||
if (isSharing === undefined) return EMPTY;
|
||||
if (isSharing === undefined)
|
||||
return EMPTY;
|
||||
|
||||
if (!userExists) {
|
||||
return of(
|
||||
@@ -491,7 +555,8 @@ export class RoomStateSyncEffects {
|
||||
|
||||
const isCameraEnabled = event.isCameraEnabled as boolean | undefined;
|
||||
|
||||
if (isCameraEnabled === undefined) return EMPTY;
|
||||
if (isCameraEnabled === undefined)
|
||||
return EMPTY;
|
||||
|
||||
if (!userExists) {
|
||||
return of(
|
||||
@@ -609,7 +674,8 @@ export class RoomStateSyncEffects {
|
||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||
const fromPeerId = event.fromPeerId;
|
||||
|
||||
if (!room || !fromPeerId) return EMPTY;
|
||||
if (!room || !fromPeerId)
|
||||
return EMPTY;
|
||||
|
||||
return from(this.db.getBansForRoom(room.id)).pipe(
|
||||
tap((bans) => {
|
||||
@@ -629,7 +695,8 @@ export class RoomStateSyncEffects {
|
||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||
const incomingRoom = event.room as Partial<Room> | undefined;
|
||||
|
||||
if (!room || !incomingRoom) return EMPTY;
|
||||
if (!room || !incomingRoom)
|
||||
return EMPTY;
|
||||
|
||||
const roomChanges = {
|
||||
...sanitizeRoomSnapshot(incomingRoom),
|
||||
@@ -670,7 +737,8 @@ export class RoomStateSyncEffects {
|
||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||
const settings = event.settings as Partial<RoomSettings> | undefined;
|
||||
|
||||
if (!room || !settings) return EMPTY;
|
||||
if (!room || !settings)
|
||||
return EMPTY;
|
||||
|
||||
return of(
|
||||
RoomsActions.updateRoom({
|
||||
@@ -699,7 +767,8 @@ export class RoomStateSyncEffects {
|
||||
const permissions = event.permissions as Partial<RoomPermissions> | undefined;
|
||||
const incomingRoom = event.room as Partial<Room> | undefined;
|
||||
|
||||
if (!room || (!permissions && !incomingRoom)) return EMPTY;
|
||||
if (!room || (!permissions && !incomingRoom))
|
||||
return EMPTY;
|
||||
|
||||
return of(
|
||||
RoomsActions.updateRoom({
|
||||
@@ -746,7 +815,8 @@ export class RoomStateSyncEffects {
|
||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||
|
||||
if (!room) return EMPTY;
|
||||
if (!room)
|
||||
return EMPTY;
|
||||
|
||||
const remoteUpdated = event.iconUpdatedAt || 0;
|
||||
const localUpdated = room.iconUpdatedAt || 0;
|
||||
@@ -765,7 +835,8 @@ export class RoomStateSyncEffects {
|
||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||
|
||||
if (!room) return EMPTY;
|
||||
if (!room)
|
||||
return EMPTY;
|
||||
|
||||
if (event.fromPeerId) {
|
||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||
@@ -784,17 +855,20 @@ export class RoomStateSyncEffects {
|
||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||
const senderId = event.fromPeerId;
|
||||
|
||||
if (!room || typeof event.icon !== 'string' || !senderId) return this.handleSearchResultIconData(event, roomId);
|
||||
if (!room || typeof event.icon !== 'string' || !senderId)
|
||||
return this.handleSearchResultIconData(event, roomId);
|
||||
|
||||
return this.store.select(selectAllUsers).pipe(
|
||||
map((users) => users.find((user) => user.id === senderId)),
|
||||
mergeMap((sender) => {
|
||||
if (!sender) return EMPTY;
|
||||
if (!sender)
|
||||
return EMPTY;
|
||||
|
||||
const isOwner = room.hostId === sender.id;
|
||||
const canByRole = resolveRoomPermission(room, sender, 'manageIcon');
|
||||
|
||||
if (!isOwner && !canByRole) return EMPTY;
|
||||
if (!isOwner && !canByRole)
|
||||
return EMPTY;
|
||||
|
||||
const updates: Partial<Room> = {
|
||||
icon: event.icon,
|
||||
@@ -807,6 +881,7 @@ export class RoomStateSyncEffects {
|
||||
serverId: room.id,
|
||||
iconUpdatedAt: updates.iconUpdatedAt
|
||||
});
|
||||
|
||||
return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates }));
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
reconcileRoomSnapshotChannels,
|
||||
sanitizeRoomSnapshot
|
||||
} from './rooms.helpers';
|
||||
import { reconcileRoomSnapshotChannels, sanitizeRoomSnapshot } from './rooms.helpers';
|
||||
|
||||
describe('room snapshot helpers', () => {
|
||||
it('drops empty channel arrays from outgoing snapshots', () => {
|
||||
@@ -9,10 +6,9 @@ describe('room snapshot helpers', () => {
|
||||
});
|
||||
|
||||
it('keeps cached channels when incoming snapshot has none', () => {
|
||||
const cachedChannels = [
|
||||
{ id: 'general', name: 'general', type: 'text', position: 0 },
|
||||
{ id: 'updates', name: 'updates', type: 'text', position: 1 }
|
||||
] as const;
|
||||
const generalChannel = { id: 'general', name: 'general', type: 'text', position: 0 } as const;
|
||||
const updatesChannel = { id: 'updates', name: 'updates', type: 'text', position: 1 } as const;
|
||||
const cachedChannels = [generalChannel, updatesChannel] as const;
|
||||
|
||||
expect(reconcileRoomSnapshotChannels(cachedChannels as never, undefined)).toEqual(cachedChannels);
|
||||
expect(reconcileRoomSnapshotChannels(cachedChannels as never, [] as never)).toEqual(cachedChannels);
|
||||
@@ -24,21 +20,16 @@ describe('room snapshot helpers', () => {
|
||||
{ id: 'updates', name: 'updates', type: 'text', position: 1 },
|
||||
{ id: 'voice', name: 'General', type: 'voice', position: 0 }
|
||||
] as const;
|
||||
const incomingChannels = [
|
||||
{ id: 'general', name: 'general', type: 'text', position: 0 }
|
||||
] as const;
|
||||
const incomingChannels = [{ id: 'general', name: 'general', type: 'text', position: 0 }] as const;
|
||||
|
||||
expect(reconcileRoomSnapshotChannels(cachedChannels as never, incomingChannels as never)).toEqual(cachedChannels);
|
||||
});
|
||||
|
||||
it('accepts incoming channels when snapshot is at least as complete', () => {
|
||||
const cachedChannels = [
|
||||
{ id: 'general', name: 'general', type: 'text', position: 0 }
|
||||
] as const;
|
||||
const incomingChannels = [
|
||||
{ id: 'general', name: 'general', type: 'text', position: 0 },
|
||||
{ id: 'updates', name: 'updates', type: 'text', position: 1 }
|
||||
] as const;
|
||||
const generalChannel = { id: 'general', name: 'general', type: 'text', position: 0 } as const;
|
||||
const updatesChannel = { id: 'updates', name: 'updates', type: 'text', position: 1 } as const;
|
||||
const cachedChannels = [generalChannel] as const;
|
||||
const incomingChannels = [generalChannel, updatesChannel] as const;
|
||||
|
||||
expect(reconcileRoomSnapshotChannels(cachedChannels as never, incomingChannels as never)).toEqual(incomingChannels);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Room, BanEntry, User } from '../../shared-kernel';
|
||||
import {
|
||||
Room,
|
||||
BanEntry,
|
||||
User
|
||||
} from '../../shared-kernel';
|
||||
import { resolveLegacyRole, resolveRoomPermission } from '../../domains/access-control';
|
||||
import { findRoomMember } from './room-members.helpers';
|
||||
import { ROOM_URL_PATTERN } from '../../core/constants';
|
||||
@@ -7,7 +11,12 @@ import { ROOM_URL_PATTERN } from '../../core/constants';
|
||||
/** Build a minimal User object from signaling payload. */
|
||||
export function buildSignalingUser(data: { oderId: string; displayName?: string; status?: string }, extras: Record<string, unknown> = {}) {
|
||||
const displayName = data.displayName?.trim() || 'User';
|
||||
const rawStatus = (['online', 'away', 'busy', 'offline'] as const).includes(data.status as 'online')
|
||||
const rawStatus = ([
|
||||
'online',
|
||||
'away',
|
||||
'busy',
|
||||
'offline'
|
||||
] as const).includes(data.status as 'online')
|
||||
? (data.status as 'online' | 'away' | 'busy' | 'offline')
|
||||
: 'online';
|
||||
// 'offline' from the server means the user chose Invisible;
|
||||
@@ -31,7 +40,8 @@ export function buildSignalingUser(data: { oderId: string; displayName?: string;
|
||||
export function buildKnownUserExtras(room: Room | null, identifier: string): Record<string, unknown> {
|
||||
const knownMember = room ? findRoomMember(room.members ?? [], identifier) : undefined;
|
||||
|
||||
if (!knownMember) return {};
|
||||
if (!knownMember)
|
||||
return {};
|
||||
|
||||
return {
|
||||
username: knownMember.username,
|
||||
@@ -115,9 +125,11 @@ export function resolveTextChannelId(channels: Room['channels'] | undefined, pre
|
||||
}
|
||||
|
||||
export function resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
|
||||
if (!roomId) return currentRoom;
|
||||
if (!roomId)
|
||||
return currentRoom;
|
||||
|
||||
if (currentRoom?.id === roomId) return currentRoom;
|
||||
if (currentRoom?.id === roomId)
|
||||
return currentRoom;
|
||||
|
||||
return savedRooms.find((room) => room.id === roomId) ?? null;
|
||||
}
|
||||
@@ -148,7 +160,8 @@ export function sanitizeRoomSnapshot(room: Partial<Room>): Partial<Room> {
|
||||
}
|
||||
|
||||
export function normalizeIncomingBans(roomId: string, bans: unknown): BanEntry[] {
|
||||
if (!Array.isArray(bans)) return [];
|
||||
if (!Array.isArray(bans))
|
||||
return [];
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ import { normalizeRoomAccessControl } from '../../domains/access-control';
|
||||
import { type ServerInfo } from '../../domains/server-directory';
|
||||
import { RoomsActions } from './rooms.actions';
|
||||
import { defaultChannels } from './room-channels.defaults';
|
||||
import { isChannelNameTaken, normalizeChannelName, normalizeRoomChannels } from './room-channels.rules';
|
||||
import {
|
||||
isChannelNameTaken,
|
||||
normalizeChannelName,
|
||||
normalizeRoomChannels
|
||||
} from './room-channels.rules';
|
||||
import { pruneRoomMembers } from './room-members.helpers';
|
||||
|
||||
/** Deduplicate rooms by id, keeping the last occurrence */
|
||||
@@ -325,7 +329,8 @@ export const roomsReducer = createReducer(
|
||||
on(RoomsActions.updateRoom, (state, { roomId, changes }) => {
|
||||
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null);
|
||||
|
||||
if (!baseRoom) return state;
|
||||
if (!baseRoom)
|
||||
return state;
|
||||
|
||||
const updatedRoom = enrichRoom({ ...baseRoom, ...changes });
|
||||
|
||||
@@ -342,7 +347,8 @@ export const roomsReducer = createReducer(
|
||||
on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => {
|
||||
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null);
|
||||
|
||||
if (!baseRoom) return state;
|
||||
if (!baseRoom)
|
||||
return state;
|
||||
|
||||
const updatedRoom = enrichRoom({ ...baseRoom, icon, iconUpdatedAt });
|
||||
|
||||
@@ -362,7 +368,8 @@ export const roomsReducer = createReducer(
|
||||
|
||||
// Receive room update
|
||||
on(RoomsActions.receiveRoomUpdate, (state, { room }) => {
|
||||
if (!state.currentRoom) return state;
|
||||
if (!state.currentRoom)
|
||||
return state;
|
||||
|
||||
const updatedRoom = enrichRoom({ ...state.currentRoom, ...room });
|
||||
|
||||
@@ -403,7 +410,8 @@ export const roomsReducer = createReducer(
|
||||
})),
|
||||
|
||||
on(RoomsActions.addChannel, (state, { channel }) => {
|
||||
if (!state.currentRoom) return state;
|
||||
if (!state.currentRoom)
|
||||
return state;
|
||||
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const normalizedName = normalizeChannelName(channel.name);
|
||||
@@ -424,7 +432,8 @@ export const roomsReducer = createReducer(
|
||||
}),
|
||||
|
||||
on(RoomsActions.removeChannel, (state, { channelId }) => {
|
||||
if (!state.currentRoom) return state;
|
||||
if (!state.currentRoom)
|
||||
return state;
|
||||
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const updatedChannels = existing.filter((channel) => channel.id !== channelId);
|
||||
@@ -439,7 +448,8 @@ export const roomsReducer = createReducer(
|
||||
}),
|
||||
|
||||
on(RoomsActions.renameChannel, (state, { channelId, name }) => {
|
||||
if (!state.currentRoom) return state;
|
||||
if (!state.currentRoom)
|
||||
return state;
|
||||
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const normalizedName = normalizeChannelName(name);
|
||||
|
||||
@@ -47,9 +47,7 @@ import {
|
||||
Room,
|
||||
User
|
||||
} from '../../shared-kernel';
|
||||
import {
|
||||
setStoredCurrentUserId
|
||||
} from '../../core/storage/current-user-storage';
|
||||
import { setStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers';
|
||||
|
||||
type IncomingModerationExtraAction =
|
||||
@@ -152,6 +150,7 @@ export class UsersEffects {
|
||||
private async prepareAuthenticatedUserStorage(userId: string): Promise<void> {
|
||||
setStoredCurrentUserId(userId);
|
||||
await this.db.initialize();
|
||||
await this.db.setCurrentUserId(userId);
|
||||
}
|
||||
|
||||
/** Loads all users associated with a specific room from the local database. */
|
||||
|
||||
Reference in New Issue
Block a user