fix: improve plugins functionality with server management
This commit is contained in:
@@ -69,6 +69,7 @@ function applySeededEndpointStorageState(storageState: SeededEndpointStorageStat
|
|||||||
'toju-primary',
|
'toju-primary',
|
||||||
'toju-sweden'
|
'toju-sweden'
|
||||||
]));
|
]));
|
||||||
|
|
||||||
storage.setItem('metoyou_general_settings', generalSettings);
|
storage.setItem('metoyou_general_settings', generalSettings);
|
||||||
|
|
||||||
if (currentUserId) {
|
if (currentUserId) {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ export class LoginPage {
|
|||||||
readonly registerLink: Locator;
|
readonly registerLink: Locator;
|
||||||
|
|
||||||
constructor(private page: Page) {
|
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.usernameInput = page.locator('#login-username');
|
||||||
this.passwordInput = page.locator('#login-password');
|
this.passwordInput = page.locator('#login-password');
|
||||||
this.serverSelect = page.locator('#login-server');
|
this.serverSelect = page.locator('#login-server');
|
||||||
|
|||||||
@@ -79,12 +79,19 @@ export class ServerSearchPage {
|
|||||||
await this.page.getByRole('button', { name }).click();
|
await this.page.getByRole('button', { name }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async joinServerFromSearch(name: string) {
|
async joinServerFromSearch(name: string, options: { acceptPluginDownloads?: boolean } = {}) {
|
||||||
await this.searchInput.fill(name);
|
await this.searchInput.fill(name);
|
||||||
|
|
||||||
const serverCard = this.page.locator('div[title]', { hasText: name }).first();
|
const serverCard = this.page.locator('div[title]', { hasText: name }).first();
|
||||||
|
|
||||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||||
await serverCard.dblclick();
|
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;
|
userDataDir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLIENT_LAUNCH_ARGS = [
|
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
|
||||||
'--use-fake-device-for-media-stream',
|
|
||||||
'--use-fake-ui-for-media-stream'
|
|
||||||
];
|
|
||||||
|
|
||||||
test.describe('User session data isolation', () => {
|
test.describe('User session data isolation', () => {
|
||||||
test.describe.configure({ timeout: 240_000 });
|
test.describe.configure({ timeout: 240_000 });
|
||||||
@@ -43,6 +40,7 @@ test.describe('User session data isolation', () => {
|
|||||||
};
|
};
|
||||||
const aliceServerName = `Alice Session Server ${suffix}`;
|
const aliceServerName = `Alice Session Server ${suffix}`;
|
||||||
const aliceMessage = `Alice persisted message ${suffix}`;
|
const aliceMessage = `Alice persisted message ${suffix}`;
|
||||||
|
|
||||||
let client: PersistentClient | null = null;
|
let client: PersistentClient | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -82,6 +80,7 @@ test.describe('User session data isolation', () => {
|
|||||||
const bobServerName = `Bob Private Server ${suffix}`;
|
const bobServerName = `Bob Private Server ${suffix}`;
|
||||||
const aliceMessage = `Alice history ${suffix}`;
|
const aliceMessage = `Alice history ${suffix}`;
|
||||||
const bobMessage = `Bob history ${suffix}`;
|
const bobMessage = `Bob history ${suffix}`;
|
||||||
|
|
||||||
let client: PersistentClient | null = null;
|
let client: PersistentClient | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -136,7 +135,7 @@ async function launchPersistentClient(userDataDir: string, testServerPort: numbe
|
|||||||
|
|
||||||
await installTestServerEndpoint(context, testServerPort);
|
await installTestServerEndpoint(context, testServerPort);
|
||||||
|
|
||||||
const page = context.pages()[0] ?? await context.newPage();
|
const page = context.pages()[0] ?? (await context.newPage());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
context,
|
context,
|
||||||
@@ -202,6 +201,7 @@ async function createServerAndSendMessage(page: Page, serverName: string, messag
|
|||||||
await searchPage.createServer(serverName, {
|
await searchPage.createServer(serverName, {
|
||||||
description: `User session isolation coverage for ${serverName}`
|
description: `User session isolation coverage for ${serverName}`
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
await messagesPage.sendMessage(messageText);
|
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> {
|
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);
|
const messagesPage = new ChatMessagesPage(page);
|
||||||
|
|
||||||
await expect(roomButton).toBeVisible({ timeout: 20_000 });
|
await expect(railRoomButton).toBeVisible({ timeout: 20_000 });
|
||||||
await roomButton.click();
|
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(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ 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> {
|
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> {
|
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();
|
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> {
|
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
|
||||||
@@ -259,11 +275,10 @@ async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastError instanceof Error
|
throw lastError instanceof Error ? lastError : new Error(`Navigation failed after ${attempts} attempts`);
|
||||||
? lastError
|
|
||||||
: new Error(`Navigation failed after ${attempts} attempts`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueName(prefix: string): string {
|
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 { mkdtemp, rm } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
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 { test, expect } from '../../fixtures/multi-client';
|
||||||
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
|
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
|
||||||
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
|
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
|
||||||
@@ -31,7 +37,11 @@ interface PersistentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
|
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 CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
|
||||||
const SERVER_ICON_SYNC_TIMEOUT_MS = 45_000;
|
const SERVER_ICON_SYNC_TIMEOUT_MS = 45_000;
|
||||||
|
|
||||||
@@ -77,6 +87,7 @@ test.describe('Server icon sync', () => {
|
|||||||
await new ServerSearchPage(alice.page).createServer(serverName, {
|
await new ServerSearchPage(alice.page).createServer(serverName, {
|
||||||
description: 'Server icon synchronization E2E coverage'
|
description: 'Server icon synchronization E2E coverage'
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
await joinServerFromSearch(bob.page, serverName);
|
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> {
|
async function openSettingsModalThroughAngularDevMode(page: Page): Promise<void> {
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
type SettingsModalComponentHandle = {
|
interface SettingsModalComponentHandle {
|
||||||
modal?: {
|
modal?: {
|
||||||
open: (page: string) => void;
|
open: (page: string) => void;
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
type AngularDebugApi = {
|
interface AngularDebugApi {
|
||||||
getComponent: (element: Element) => SettingsModalComponentHandle;
|
getComponent: (element: Element) => SettingsModalComponentHandle;
|
||||||
applyChanges?: (component: SettingsModalComponentHandle) => void;
|
applyChanges?: (component: SettingsModalComponentHandle) => void;
|
||||||
};
|
}
|
||||||
|
|
||||||
const host = document.querySelector('app-settings-modal');
|
const host = document.querySelector('app-settings-modal');
|
||||||
const debugApi = (window as Window & { ng?: AngularDebugApi }).ng;
|
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> {
|
async function expectServerSettingsIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
|
||||||
const settingsPanel = page.locator('app-server-settings');
|
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> {
|
async function expectRoomHeaderIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
|
||||||
const channelsPanel = page.locator('app-rooms-side-panel').first();
|
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> {
|
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> {
|
async function expectSearchResultIcon(page: Page, serverName: string, expectedDataUrl: string): Promise<void> {
|
||||||
const serverCard = page.locator('app-server-search div[title]', { hasText: serverName }).first();
|
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 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
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -407,14 +418,14 @@ async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string,
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return image.getAttribute('src');
|
return image.evaluate((element) => getComputedStyle(element).backgroundImage);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timeout: SERVER_ICON_SYNC_TIMEOUT_MS,
|
timeout: SERVER_ICON_SYNC_TIMEOUT_MS,
|
||||||
message: `${label} src should update`
|
message: `${label} background should update`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.toBe(expectedDataUrl);
|
.toContain(expectedDataUrl);
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -423,11 +434,23 @@ async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return image.evaluate((element) => {
|
return image.evaluate(
|
||||||
const img = element as HTMLImageElement;
|
(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,
|
timeout: SERVER_ICON_SYNC_TIMEOUT_MS,
|
||||||
@@ -448,8 +471,22 @@ function buildGifUpload(label: string): ImageUploadPayload {
|
|||||||
const header = baseGif.subarray(0, frameStart);
|
const header = baseGif.subarray(0, frameStart);
|
||||||
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
|
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
|
||||||
const commentData = Buffer.from(label, 'ascii');
|
const commentData = Buffer.from(label, 'ascii');
|
||||||
const commentExtension = Buffer.concat([Buffer.from([0x21, 0xfe, commentData.length]), commentData, Buffer.from([0x00])]);
|
const commentExtension = Buffer.concat([
|
||||||
const buffer = Buffer.concat([header, commentExtension, frame, Buffer.from([0x3b])]);
|
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');
|
const base64 = buffer.toString('base64');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -461,5 +498,6 @@ function buildGifUpload(label: string): ImageUploadPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function uniqueName(prefix: string): string {
|
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 }) => {
|
test('runs chat, embed, soundboard, and profile APIs between two users', async ({ createClient }) => {
|
||||||
const scenario = await createPluginApiScenario(createClient);
|
const scenario = await createPluginApiScenario(createClient);
|
||||||
|
|
||||||
await test.step('Install the server plugin as Alice', async () => {
|
await test.step('Alice has the server plugin active', async () => {
|
||||||
await installGrantAndActivatePlugin(scenario.alice.page, true);
|
|
||||||
await closeSettingsModal(scenario.alice.page);
|
|
||||||
await expect(soundboardComposerButton(scenario.alice.page)).toBeVisible({ timeout: 20_000 });
|
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.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');
|
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);
|
const aliceRoom = new ChatRoomPage(alice.page);
|
||||||
|
|
||||||
await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
|
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);
|
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 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 });
|
||||||
|
|
||||||
const bobRoom = new ChatRoomPage(bob.page);
|
const bobRoom = new ChatRoomPage(bob.page);
|
||||||
|
|||||||
@@ -28,6 +28,6 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together.
|
- 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.
|
- Treat `dist/electron/` and `dist-electron/` as generated output.
|
||||||
- See [AGENTS.md](AGENTS.md) for package-level editing rules.
|
- 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 TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const tokens = new Map<string, IssuedToken>();
|
const tokens = new Map<string, IssuedToken>();
|
||||||
|
|
||||||
export function issueToken(params: {
|
export function issueToken(params: {
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ export function getDocsHtml(specUrl: string): string {
|
|||||||
disabled: true
|
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>
|
return `<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -67,7 +78,7 @@ export function getDocsHtml(specUrl: string): string {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
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>
|
<title>MetoYou Local API</title>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -72,7 +72,11 @@ export async function resolveDocusaurusRoute(pathname: string): Promise<{ filePa
|
|||||||
const root = await getDocusaurusBuildRoot();
|
const root = await getDocusaurusBuildRoot();
|
||||||
|
|
||||||
if (!root) {
|
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);
|
let filePath = resolveAssetPath(root, pathname);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
let received = 0;
|
let received = 0;
|
||||||
|
|
||||||
for await (const chunk of req) {
|
for await (const chunk of req) {
|
||||||
|
|||||||
@@ -196,9 +196,9 @@ export async function startLocalApiServer(settings: LocalApiSettings): Promise<S
|
|||||||
currentError = null;
|
currentError = null;
|
||||||
currentBindHost = pickBindHost(settings);
|
currentBindHost = pickBindHost(settings);
|
||||||
currentBindPort = settings.port;
|
currentBindPort = settings.port;
|
||||||
|
const requestSettings = activeSettings;
|
||||||
const httpServer = createServer((req, res) => {
|
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);
|
console.error('[LocalApi] Unhandled request error:', error);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
|
|||||||
},
|
},
|
||||||
LoginRequest: {
|
LoginRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['username', 'password', 'serverUrl'],
|
required: [
|
||||||
|
'username',
|
||||||
|
'password',
|
||||||
|
'serverUrl'
|
||||||
|
],
|
||||||
properties: {
|
properties: {
|
||||||
username: { type: 'string' },
|
username: { type: 'string' },
|
||||||
password: { type: 'string' },
|
password: { type: 'string' },
|
||||||
@@ -49,7 +53,11 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
|
|||||||
},
|
},
|
||||||
LoginResponse: {
|
LoginResponse: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['token', 'expiresAt', 'user'],
|
required: [
|
||||||
|
'token',
|
||||||
|
'expiresAt',
|
||||||
|
'user'
|
||||||
|
],
|
||||||
properties: {
|
properties: {
|
||||||
token: { type: 'string' },
|
token: { type: 'string' },
|
||||||
expiresAt: { type: 'integer', format: 'int64' },
|
expiresAt: { type: 'integer', format: 'int64' },
|
||||||
@@ -58,7 +66,11 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
|
|||||||
},
|
},
|
||||||
AuthUser: {
|
AuthUser: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['id', 'username', 'displayName'],
|
required: [
|
||||||
|
'id',
|
||||||
|
'username',
|
||||||
|
'displayName'
|
||||||
|
],
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string' },
|
id: { type: 'string' },
|
||||||
username: { type: 'string' },
|
username: { type: 'string' },
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { app, net } from 'electron';
|
import { app, net } from 'electron';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { buildQueryHandlers } from '../cqrs/queries';
|
import { buildQueryHandlers } from '../cqrs/queries';
|
||||||
import { QueryType, QueryTypeKey, Query } from '../cqrs/types';
|
import {
|
||||||
import { issueToken, consumeToken, revokeToken, IssuedToken } from './auth-store';
|
QueryType,
|
||||||
|
QueryTypeKey,
|
||||||
|
Query
|
||||||
|
} from '../cqrs/types';
|
||||||
|
import {
|
||||||
|
issueToken,
|
||||||
|
consumeToken,
|
||||||
|
revokeToken,
|
||||||
|
IssuedToken
|
||||||
|
} from './auth-store';
|
||||||
import { buildOpenApiDocument } from './openapi';
|
import { buildOpenApiDocument } from './openapi';
|
||||||
import { HttpError, RequestContext, readJsonBody } from './http-helpers';
|
import { HttpError, RequestContext } from './http-helpers';
|
||||||
import { getDocsHtml, getScalarApiReferenceBundlePath } from './docs-html';
|
import { getDocsHtml, getScalarApiReferenceBundlePath } from './docs-html';
|
||||||
import { resolveDocusaurusRoute } from './docusaurus-static';
|
import { resolveDocusaurusRoute } from './docusaurus-static';
|
||||||
import { LocalApiSettings } from '../desktop-settings';
|
import { LocalApiSettings } from '../desktop-settings';
|
||||||
@@ -48,12 +57,14 @@ function compilePattern(template: string): { pattern: RegExp; paramKeys: string[
|
|||||||
const escaped = template.replace(/[.*+?^${}()|[\]\\]/g, (match) => {
|
const escaped = template.replace(/[.*+?^${}()|[\]\\]/g, (match) => {
|
||||||
if (match === '*' || match === '+' || match === '?')
|
if (match === '*' || match === '+' || match === '?')
|
||||||
return `\\${match}`;
|
return `\\${match}`;
|
||||||
|
|
||||||
return `\\${match}`;
|
return `\\${match}`;
|
||||||
});
|
});
|
||||||
const source = template.replace(/\{([^}]+)\}/g, (_full, key: string) => {
|
const source = template.replace(/\{([^}]+)\}/g, (_full, key: string) => {
|
||||||
paramKeys.push(key);
|
paramKeys.push(key);
|
||||||
return '([^/]+)';
|
return '([^/]+)';
|
||||||
});
|
});
|
||||||
|
|
||||||
void escaped;
|
void escaped;
|
||||||
|
|
||||||
return { pattern: new RegExp(`^${source}$`), paramKeys };
|
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 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 offset = clampInt(ctx.request.url.searchParams.get('offset'), 0, Number.MAX_SAFE_INTEGER, 0);
|
||||||
|
|
||||||
const messages = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
|
const messages = await runQuery<unknown[]>(requireDataSource(ctx.dataSource), {
|
||||||
type: QueryType.GetMessages,
|
type: QueryType.GetMessages,
|
||||||
payload: { roomId: decodeURIComponent(roomId), limit, offset }
|
payload: { roomId: decodeURIComponent(roomId), limit, offset }
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { MessageEntity } from '../../../entities';
|
import { MessageEntity } from '../../../entities';
|
||||||
import { ClearRoomMessagesCommand } from '../../types';
|
import { ClearRoomMessagesCommand } from '../../types';
|
||||||
|
import { getCurrentUserScope } from '../../current-user-scope';
|
||||||
|
|
||||||
export async function handleClearRoomMessages(command: ClearRoomMessagesCommand, dataSource: DataSource): Promise<void> {
|
export async function handleClearRoomMessages(command: ClearRoomMessagesCommand, dataSource: DataSource): Promise<void> {
|
||||||
const repo = dataSource.getRepository(MessageEntity);
|
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 { DataSource } from 'typeorm';
|
||||||
import { MessageEntity } from '../../../entities';
|
import { MessageEntity } from '../../../entities';
|
||||||
import { DeleteMessageCommand } from '../../types';
|
import { DeleteMessageCommand } from '../../types';
|
||||||
|
import { getCurrentUserScope } from '../../current-user-scope';
|
||||||
|
|
||||||
export async function handleDeleteMessage(command: DeleteMessageCommand, dataSource: DataSource): Promise<void> {
|
export async function handleDeleteMessage(command: DeleteMessageCommand, dataSource: DataSource): Promise<void> {
|
||||||
const repo = dataSource.getRepository(MessageEntity);
|
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 { DataSource } from 'typeorm';
|
||||||
|
import { getCurrentUserScope } from '../../current-user-scope';
|
||||||
import { PluginDataEntity } from '../../../entities';
|
import { PluginDataEntity } from '../../../entities';
|
||||||
import { DeletePluginDataCommand } from '../../types';
|
import { DeletePluginDataCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleDeletePluginData(command: DeletePluginDataCommand, dataSource: DataSource): Promise<void> {
|
export async function handleDeletePluginData(command: DeletePluginDataCommand, dataSource: DataSource): Promise<void> {
|
||||||
const { payload } = command;
|
const { payload } = command;
|
||||||
|
const ownerUserId = await getCurrentUserScope(dataSource);
|
||||||
|
|
||||||
|
if (!ownerUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await dataSource.getRepository(PluginDataEntity).delete({
|
await dataSource.getRepository(PluginDataEntity).delete({
|
||||||
key: payload.key,
|
key: payload.key,
|
||||||
|
ownerUserId,
|
||||||
pluginId: payload.pluginId,
|
pluginId: payload.pluginId,
|
||||||
scope: payload.scope,
|
scope: payload.scope,
|
||||||
serverId: payload.serverId ?? ''
|
serverId: payload.serverId ?? ''
|
||||||
|
|||||||
@@ -3,23 +3,39 @@ import {
|
|||||||
RoomChannelPermissionEntity,
|
RoomChannelPermissionEntity,
|
||||||
RoomChannelEntity,
|
RoomChannelEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomOwnerEntity,
|
||||||
RoomMemberEntity,
|
RoomMemberEntity,
|
||||||
RoomRoleEntity,
|
RoomRoleEntity,
|
||||||
RoomUserRoleEntity,
|
RoomUserRoleEntity,
|
||||||
MessageEntity
|
MessageEntity
|
||||||
} from '../../../entities';
|
} from '../../../entities';
|
||||||
import { DeleteRoomCommand } from '../../types';
|
import { DeleteRoomCommand } from '../../types';
|
||||||
|
import { getCurrentUserScope } from '../../current-user-scope';
|
||||||
|
|
||||||
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
|
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
|
||||||
const { roomId } = command.payload;
|
const { roomId } = command.payload;
|
||||||
|
|
||||||
await dataSource.transaction(async (manager) => {
|
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(RoomChannelPermissionEntity).delete({ roomId });
|
||||||
await manager.getRepository(RoomChannelEntity).delete({ roomId });
|
await manager.getRepository(RoomChannelEntity).delete({ roomId });
|
||||||
await manager.getRepository(RoomMemberEntity).delete({ roomId });
|
await manager.getRepository(RoomMemberEntity).delete({ roomId });
|
||||||
await manager.getRepository(RoomRoleEntity).delete({ roomId });
|
await manager.getRepository(RoomRoleEntity).delete({ roomId });
|
||||||
await manager.getRepository(RoomUserRoleEntity).delete({ roomId });
|
await manager.getRepository(RoomUserRoleEntity).delete({ roomId });
|
||||||
await manager.getRepository(RoomEntity).delete({ id: 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 { MessageEntity } from '../../../entities';
|
||||||
import { replaceMessageReactions } from '../../relations';
|
import { replaceMessageReactions } from '../../relations';
|
||||||
import { SaveMessageCommand } from '../../types';
|
import { SaveMessageCommand } from '../../types';
|
||||||
|
import { getCurrentUserScope } from '../../current-user-scope';
|
||||||
|
|
||||||
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
|
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
|
||||||
const { message } = command.payload;
|
const { message } = command.payload;
|
||||||
|
|
||||||
await dataSource.transaction(async (manager) => {
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const currentUserId = await getCurrentUserScope(manager);
|
||||||
const repo = manager.getRepository(MessageEntity);
|
const repo = manager.getRepository(MessageEntity);
|
||||||
const entity = repo.create({
|
const entity = repo.create({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
roomId: message.roomId,
|
roomId: message.roomId,
|
||||||
|
ownerUserId: currentUserId,
|
||||||
channelId: message.channelId ?? null,
|
channelId: message.channelId ?? null,
|
||||||
senderId: message.senderId,
|
senderId: message.senderId,
|
||||||
senderName: message.senderName,
|
senderName: message.senderName,
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
import { getCurrentUserScope } from '../../current-user-scope';
|
||||||
import { PluginDataEntity } from '../../../entities';
|
import { PluginDataEntity } from '../../../entities';
|
||||||
import { SavePluginDataCommand } from '../../types';
|
import { SavePluginDataCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleSavePluginData(command: SavePluginDataCommand, dataSource: DataSource): Promise<void> {
|
export async function handleSavePluginData(command: SavePluginDataCommand, dataSource: DataSource): Promise<void> {
|
||||||
const { payload } = command;
|
const { payload } = command;
|
||||||
|
const ownerUserId = await getCurrentUserScope(dataSource);
|
||||||
|
|
||||||
|
if (!ownerUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await dataSource.getRepository(PluginDataEntity).save({
|
await dataSource.getRepository(PluginDataEntity).save({
|
||||||
key: payload.key,
|
key: payload.key,
|
||||||
|
ownerUserId,
|
||||||
pluginId: payload.pluginId,
|
pluginId: payload.pluginId,
|
||||||
scope: payload.scope,
|
scope: payload.scope,
|
||||||
serverId: payload.serverId ?? '',
|
serverId: payload.serverId ?? '',
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { RoomEntity } from '../../../entities';
|
import { RoomEntity, RoomOwnerEntity } from '../../../entities';
|
||||||
import { replaceRoomRelations } from '../../relations';
|
import { replaceRoomRelations } from '../../relations';
|
||||||
import { SaveRoomCommand } from '../../types';
|
import { SaveRoomCommand } from '../../types';
|
||||||
|
import { getCurrentUserScope } from '../../current-user-scope';
|
||||||
|
|
||||||
function extractSlowModeInterval(room: SaveRoomCommand['payload']['room']): number {
|
function extractSlowModeInterval(room: SaveRoomCommand['payload']['room']): number {
|
||||||
if (typeof room.slowModeInterval === 'number' && Number.isFinite(room.slowModeInterval)) {
|
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;
|
const { room } = command.payload;
|
||||||
|
|
||||||
await dataSource.transaction(async (manager) => {
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const currentUserId = await getCurrentUserScope(manager);
|
||||||
const repo = manager.getRepository(RoomEntity);
|
const repo = manager.getRepository(RoomEntity);
|
||||||
const entity = repo.create({
|
const entity = repo.create({
|
||||||
id: room.id,
|
id: room.id,
|
||||||
@@ -43,6 +45,15 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
|||||||
});
|
});
|
||||||
|
|
||||||
await repo.save(entity);
|
await repo.save(entity);
|
||||||
|
|
||||||
|
if (currentUserId) {
|
||||||
|
await manager.getRepository(RoomOwnerEntity).save({
|
||||||
|
roomId: room.id,
|
||||||
|
userId: currentUserId,
|
||||||
|
savedAt: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await replaceRoomRelations(manager, room.id, {
|
await replaceRoomRelations(manager, room.id, {
|
||||||
channels: room.channels ?? [],
|
channels: room.channels ?? [],
|
||||||
members: room.members ?? [],
|
members: room.members ?? [],
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ import { DataSource } from 'typeorm';
|
|||||||
import { MessageEntity } from '../../../entities';
|
import { MessageEntity } from '../../../entities';
|
||||||
import { replaceMessageReactions } from '../../relations';
|
import { replaceMessageReactions } from '../../relations';
|
||||||
import { UpdateMessageCommand } from '../../types';
|
import { UpdateMessageCommand } from '../../types';
|
||||||
|
import { getCurrentUserScope } from '../../current-user-scope';
|
||||||
|
|
||||||
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
|
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
|
||||||
const { messageId, updates } = command.payload;
|
const { messageId, updates } = command.payload;
|
||||||
|
|
||||||
await dataSource.transaction(async (manager) => {
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const currentUserId = await getCurrentUserScope(manager);
|
||||||
|
|
||||||
|
if (!currentUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const repo = manager.getRepository(MessageEntity);
|
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)
|
if (!existing)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
boolToInt,
|
boolToInt,
|
||||||
TransformMap
|
TransformMap
|
||||||
} from './utils/applyUpdates';
|
} from './utils/applyUpdates';
|
||||||
|
import { getCurrentUserScope, userOwnsRoom } from '../../current-user-scope';
|
||||||
|
|
||||||
const ROOM_TRANSFORMS: TransformMap = {
|
const ROOM_TRANSFORMS: TransformMap = {
|
||||||
hasPassword: boolToInt,
|
hasPassword: boolToInt,
|
||||||
@@ -32,6 +33,12 @@ export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: D
|
|||||||
const { roomId, updates } = command.payload;
|
const { roomId, updates } = command.payload;
|
||||||
|
|
||||||
await dataSource.transaction(async (manager) => {
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const currentUserId = await getCurrentUserScope(manager);
|
||||||
|
|
||||||
|
if (!await userOwnsRoom(manager, roomId, currentUserId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const repo = manager.getRepository(RoomEntity);
|
const repo = manager.getRepository(RoomEntity);
|
||||||
const existing = await repo.findOne({ where: { id: roomId } });
|
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 { DataSource } from 'typeorm';
|
||||||
import { RoomEntity } from '../../../entities';
|
import { RoomEntity, RoomOwnerEntity } from '../../../entities';
|
||||||
import { rowToRoom } from '../../mappers';
|
import { rowToRoom } from '../../mappers';
|
||||||
import { loadRoomRelationsMap } from '../../relations';
|
import { loadRoomRelationsMap } from '../../relations';
|
||||||
|
import { getCurrentUserScope } from '../../current-user-scope';
|
||||||
|
|
||||||
export async function handleGetAllRooms(dataSource: DataSource) {
|
export async function handleGetAllRooms(dataSource: DataSource) {
|
||||||
|
const currentUserId = await getCurrentUserScope(dataSource);
|
||||||
|
|
||||||
|
if (!currentUserId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const repo = dataSource.getRepository(RoomEntity);
|
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));
|
const relationsByRoomId = await loadRoomRelationsMap(dataSource, rows.map((row) => row.id));
|
||||||
|
|
||||||
return rows.map((row) => rowToRoom(row, relationsByRoomId.get(row.id)));
|
return rows.map((row) => rowToRoom(row, relationsByRoomId.get(row.id)));
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ export async function handleGetCurrentUserId(dataSource: DataSource): Promise<st
|
|||||||
const metaRow = await metaRepo.findOne({ where: { key: 'currentUserId' } });
|
const metaRow = await metaRepo.findOne({ where: { key: 'currentUserId' } });
|
||||||
|
|
||||||
return metaRow?.value?.trim() || null;
|
return metaRow?.value?.trim() || null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ import { MessageEntity } from '../../../entities';
|
|||||||
import { GetMessageByIdQuery } from '../../types';
|
import { GetMessageByIdQuery } from '../../types';
|
||||||
import { rowToMessage } from '../../mappers';
|
import { rowToMessage } from '../../mappers';
|
||||||
import { loadMessageReactionsMap } from '../../relations';
|
import { loadMessageReactionsMap } from '../../relations';
|
||||||
|
import { getCurrentUserScope } from '../../current-user-scope';
|
||||||
|
|
||||||
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
|
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
|
||||||
const repo = dataSource.getRepository(MessageEntity);
|
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) {
|
if (!row) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -3,12 +3,19 @@ import { MessageEntity } from '../../../entities';
|
|||||||
import { GetMessagesQuery } from '../../types';
|
import { GetMessagesQuery } from '../../types';
|
||||||
import { rowToMessage } from '../../mappers';
|
import { rowToMessage } from '../../mappers';
|
||||||
import { loadMessageReactionsMap } from '../../relations';
|
import { loadMessageReactionsMap } from '../../relations';
|
||||||
|
import { getCurrentUserScope } from '../../current-user-scope';
|
||||||
|
|
||||||
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
|
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
|
||||||
const repo = dataSource.getRepository(MessageEntity);
|
const repo = dataSource.getRepository(MessageEntity);
|
||||||
const { roomId, limit = 100, offset = 0 } = query.payload;
|
const { roomId, limit = 100, offset = 0 } = query.payload;
|
||||||
|
const currentUserId = await getCurrentUserScope(dataSource);
|
||||||
|
|
||||||
|
if (!currentUserId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await repo.find({
|
const rows = await repo.find({
|
||||||
where: { roomId },
|
where: { roomId, ownerUserId: currentUserId },
|
||||||
order: { timestamp: 'ASC' },
|
order: { timestamp: 'ASC' },
|
||||||
take: limit,
|
take: limit,
|
||||||
skip: offset
|
skip: offset
|
||||||
|
|||||||
@@ -3,13 +3,21 @@ import { MessageEntity } from '../../../entities';
|
|||||||
import { GetMessagesSinceQuery } from '../../types';
|
import { GetMessagesSinceQuery } from '../../types';
|
||||||
import { rowToMessage } from '../../mappers';
|
import { rowToMessage } from '../../mappers';
|
||||||
import { loadMessageReactionsMap } from '../../relations';
|
import { loadMessageReactionsMap } from '../../relations';
|
||||||
|
import { getCurrentUserScope } from '../../current-user-scope';
|
||||||
|
|
||||||
export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataSource: DataSource) {
|
export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataSource: DataSource) {
|
||||||
const repo = dataSource.getRepository(MessageEntity);
|
const repo = dataSource.getRepository(MessageEntity);
|
||||||
const { roomId, sinceTimestamp } = query.payload;
|
const { roomId, sinceTimestamp } = query.payload;
|
||||||
|
const currentUserId = await getCurrentUserScope(dataSource);
|
||||||
|
|
||||||
|
if (!currentUserId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await repo.find({
|
const rows = await repo.find({
|
||||||
where: {
|
where: {
|
||||||
roomId,
|
roomId,
|
||||||
|
ownerUserId: currentUserId,
|
||||||
timestamp: MoreThan(sinceTimestamp)
|
timestamp: MoreThan(sinceTimestamp)
|
||||||
},
|
},
|
||||||
order: { timestamp: 'ASC' }
|
order: { timestamp: 'ASC' }
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
import { getCurrentUserScope } from '../../current-user-scope';
|
||||||
import { PluginDataEntity } from '../../../entities';
|
import { PluginDataEntity } from '../../../entities';
|
||||||
import { GetPluginDataQuery } from '../../types';
|
import { GetPluginDataQuery } from '../../types';
|
||||||
|
|
||||||
export async function handleGetPluginData(query: GetPluginDataQuery, dataSource: DataSource): Promise<unknown> {
|
export async function handleGetPluginData(query: GetPluginDataQuery, dataSource: DataSource): Promise<unknown> {
|
||||||
const { payload } = query;
|
const { payload } = query;
|
||||||
|
const ownerUserId = await getCurrentUserScope(dataSource);
|
||||||
|
|
||||||
|
if (!ownerUserId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const record = await dataSource.getRepository(PluginDataEntity).findOne({
|
const record = await dataSource.getRepository(PluginDataEntity).findOne({
|
||||||
where: {
|
where: {
|
||||||
key: payload.key,
|
key: payload.key,
|
||||||
|
ownerUserId,
|
||||||
pluginId: payload.pluginId,
|
pluginId: payload.pluginId,
|
||||||
scope: payload.scope,
|
scope: payload.scope,
|
||||||
serverId: payload.serverId ?? ''
|
serverId: payload.serverId ?? ''
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ import { RoomEntity } from '../../../entities';
|
|||||||
import { GetRoomQuery } from '../../types';
|
import { GetRoomQuery } from '../../types';
|
||||||
import { rowToRoom } from '../../mappers';
|
import { rowToRoom } from '../../mappers';
|
||||||
import { loadRoomRelationsMap } from '../../relations';
|
import { loadRoomRelationsMap } from '../../relations';
|
||||||
|
import { getCurrentUserScope, userOwnsRoom } from '../../current-user-scope';
|
||||||
|
|
||||||
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
|
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 repo = dataSource.getRepository(RoomEntity);
|
||||||
const row = await repo.findOne({ where: { id: query.payload.roomId } });
|
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_STORE_METHOD = 0;
|
||||||
const ZIP_VERSION = 20;
|
const ZIP_VERSION = 20;
|
||||||
const MAX_UINT32 = 0xffffffff;
|
const MAX_UINT32 = 0xffffffff;
|
||||||
|
|
||||||
const crcTable = buildCrcTable();
|
const crcTable = buildCrcTable();
|
||||||
|
|
||||||
export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
|
export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
|
||||||
const localParts: Buffer[] = [];
|
const localParts: Buffer[] = [];
|
||||||
const centralEntries: CentralDirectoryEntry[] = [];
|
const centralEntries: CentralDirectoryEntry[] = [];
|
||||||
|
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -93,7 +93,6 @@ export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
|
|||||||
|
|
||||||
return Buffer.concat([header, entry.name]);
|
return Buffer.concat([header, entry.name]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const centralDirectorySize = offset - centralDirectoryOffset;
|
const centralDirectorySize = offset - centralDirectoryOffset;
|
||||||
|
|
||||||
if (centralEntries.length > 0xffff || centralDirectoryOffset > MAX_UINT32 || centralDirectorySize > MAX_UINT32) {
|
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.writeUInt32LE(centralDirectoryOffset, 16);
|
||||||
end.writeUInt16LE(0, 20);
|
end.writeUInt16LE(0, 20);
|
||||||
|
|
||||||
return Buffer.concat([...localParts, ...centralParts, end]);
|
return Buffer.concat([
|
||||||
|
...localParts,
|
||||||
|
...centralParts,
|
||||||
|
end
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readZipArchive(data: Buffer): ZipArchiveEntry[] {
|
export function readZipArchive(data: Buffer): ZipArchiveEntry[] {
|
||||||
@@ -124,6 +127,7 @@ export function readZipArchive(data: Buffer): ZipArchiveEntry[] {
|
|||||||
const entryCount = data.readUInt16LE(endOffset + 10);
|
const entryCount = data.readUInt16LE(endOffset + 10);
|
||||||
const centralDirectoryOffset = data.readUInt32LE(endOffset + 16);
|
const centralDirectoryOffset = data.readUInt32LE(endOffset + 16);
|
||||||
const entries: ZipArchiveEntry[] = [];
|
const entries: ZipArchiveEntry[] = [];
|
||||||
|
|
||||||
let offset = centralDirectoryOffset;
|
let offset = centralDirectoryOffset;
|
||||||
|
|
||||||
for (let index = 0; index < entryCount; index += 1) {
|
for (let index = 0; index < entryCount; index += 1) {
|
||||||
|
|||||||
@@ -43,12 +43,11 @@ export async function openCurrentDataFolder(): Promise<boolean> {
|
|||||||
|
|
||||||
export async function exportUserData(): Promise<ExportUserDataResult> {
|
export async function exportUserData(): Promise<ExportUserDataResult> {
|
||||||
const dataPath = app.getPath('userData');
|
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({
|
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||||
defaultPath: path.join(app.getPath('documents'), defaultFileName),
|
defaultPath: path.join(app.getPath('documents'), defaultFileName),
|
||||||
filters: [
|
filters: [{ extensions: ['dat'], name: 'MetoYou data archive' }],
|
||||||
{ extensions: ['dat'], name: 'MetoYou data archive' }
|
|
||||||
],
|
|
||||||
title: 'Export MetoYou data'
|
title: 'Export MetoYou data'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,9 +87,7 @@ export async function exportUserData(): Promise<ExportUserDataResult> {
|
|||||||
|
|
||||||
export async function importUserData(): Promise<ImportUserDataResult> {
|
export async function importUserData(): Promise<ImportUserDataResult> {
|
||||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||||
filters: [
|
filters: [{ extensions: ['dat', 'zip'], name: 'MetoYou data archive' }],
|
||||||
{ extensions: ['dat', 'zip'], name: 'MetoYou data archive' }
|
|
||||||
],
|
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
title: 'Import MetoYou data'
|
title: 'Import MetoYou data'
|
||||||
});
|
});
|
||||||
@@ -184,7 +181,8 @@ async function collectDataFiles(directoryPath: string): Promise<string[]> {
|
|||||||
async function moveCurrentDataAside(): Promise<string | undefined> {
|
async function moveCurrentDataAside(): Promise<string | undefined> {
|
||||||
const dataPath = app.getPath('userData');
|
const dataPath = app.getPath('userData');
|
||||||
const backupRoot = path.join(dataPath, BACKUP_DIRECTORY_NAME);
|
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(() => []);
|
const entries = await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => []);
|
||||||
|
|
||||||
await fsp.mkdir(backupPath, { recursive: true });
|
await fsp.mkdir(backupPath, { recursive: true });
|
||||||
@@ -204,6 +202,7 @@ async function moveCurrentDataAside(): Promise<string | undefined> {
|
|||||||
await copyPath(sourcePath, targetPath);
|
await copyPath(sourcePath, targetPath);
|
||||||
await fsp.rm(sourcePath, { force: true, recursive: true });
|
await fsp.rm(sourcePath, { force: true, recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
movedAny = true;
|
movedAny = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomOwnerEntity,
|
||||||
RoomChannelEntity,
|
RoomChannelEntity,
|
||||||
RoomMemberEntity,
|
RoomMemberEntity,
|
||||||
RoomRoleEntity,
|
RoomRoleEntity,
|
||||||
@@ -27,8 +28,18 @@ let dbBackupPath = '';
|
|||||||
|
|
||||||
// SQLite files start with this 16-byte header string.
|
// SQLite files start with this 16-byte header string.
|
||||||
const SQLITE_MAGIC = 'SQLite format 3\0';
|
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||||
const SAVE_RETRY_DELAYS_MS = [25, 75, 150, 300, 600];
|
const SAVE_RETRY_DELAYS_MS = [
|
||||||
const RETRYABLE_SAVE_ERROR_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
25,
|
||||||
|
75,
|
||||||
|
150,
|
||||||
|
300,
|
||||||
|
600
|
||||||
|
];
|
||||||
|
const RETRYABLE_SAVE_ERROR_CODES = new Set([
|
||||||
|
'EPERM',
|
||||||
|
'EACCES',
|
||||||
|
'EBUSY'
|
||||||
|
]);
|
||||||
|
|
||||||
let saveQueue: Promise<void> = Promise.resolve();
|
let saveQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
@@ -164,6 +175,7 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomOwnerEntity,
|
||||||
RoomChannelEntity,
|
RoomChannelEntity,
|
||||||
RoomMemberEntity,
|
RoomMemberEntity,
|
||||||
RoomRoleEntity,
|
RoomRoleEntity,
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ const DEFAULT_LOCAL_API_SETTINGS: LocalApiSettings = {
|
|||||||
docusaurusEnabled: false,
|
docusaurusEnabled: false,
|
||||||
allowedSignalingServers: []
|
allowedSignalingServers: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||||
autoUpdateMode: 'auto',
|
autoUpdateMode: 'auto',
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export class MessageEntity {
|
|||||||
@Column('text')
|
@Column('text')
|
||||||
roomId!: string;
|
roomId!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
ownerUserId!: string | null;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
channelId!: string | null;
|
channelId!: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
|
|
||||||
@Entity('plugin_data')
|
@Entity('plugin_data')
|
||||||
export class PluginDataEntity {
|
export class PluginDataEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
ownerUserId!: string;
|
||||||
|
|
||||||
@PrimaryColumn('text')
|
@PrimaryColumn('text')
|
||||||
pluginId!: string;
|
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 { MessageEntity } from './MessageEntity';
|
||||||
export { UserEntity } from './UserEntity';
|
export { UserEntity } from './UserEntity';
|
||||||
export { RoomEntity } from './RoomEntity';
|
export { RoomEntity } from './RoomEntity';
|
||||||
|
export { RoomOwnerEntity } from './RoomOwnerEntity';
|
||||||
export { RoomChannelEntity } from './RoomChannelEntity';
|
export { RoomChannelEntity } from './RoomChannelEntity';
|
||||||
export { RoomMemberEntity } from './RoomMemberEntity';
|
export { RoomMemberEntity } from './RoomMemberEntity';
|
||||||
export { RoomRoleEntity } from './RoomRoleEntity';
|
export { RoomRoleEntity } from './RoomRoleEntity';
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ import {
|
|||||||
updateDesktopSettings,
|
updateDesktopSettings,
|
||||||
type DesktopSettings
|
type DesktopSettings
|
||||||
} from '../desktop-settings';
|
} from '../desktop-settings';
|
||||||
import {
|
import { applyLocalApiSettings, getLocalApiSnapshot } from '../api';
|
||||||
applyLocalApiSettings,
|
|
||||||
getLocalApiSnapshot
|
|
||||||
} from '../api';
|
|
||||||
import {
|
import {
|
||||||
activateLinuxScreenShareAudioRouting,
|
activateLinuxScreenShareAudioRouting,
|
||||||
deactivateLinuxScreenShareAudioRouting,
|
deactivateLinuxScreenShareAudioRouting,
|
||||||
@@ -490,6 +487,7 @@ export function setupSystemHandlers(): void {
|
|||||||
docusaurusEnabled: true
|
docusaurusEnabled: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await applyLocalApiSettings();
|
await applyLocalApiSettings();
|
||||||
snapshot = getLocalApiSnapshot();
|
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 { connectedUsers } from './state';
|
||||||
import { ConnectedUser } from './types';
|
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 { 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 {
|
interface WsMessage {
|
||||||
[key: string]: unknown;
|
[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> {
|
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||||
const sid = readMessageId(message['serverId']);
|
const sid = readMessageId(message['serverId']);
|
||||||
|
|
||||||
if (!sid) return;
|
if (!sid)
|
||||||
|
return;
|
||||||
|
|
||||||
const authorization = await authorizeWebSocketJoin(sid, user.oderId);
|
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> {
|
async function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||||
const viewSid = readMessageId(message['serverId']);
|
const viewSid = readMessageId(message['serverId']);
|
||||||
|
|
||||||
if (!viewSid) return;
|
if (!viewSid)
|
||||||
|
return;
|
||||||
|
|
||||||
if (!user.serverIds.has(viewSid)) {
|
if (!user.serverIds.has(viewSid)) {
|
||||||
return;
|
return;
|
||||||
@@ -212,11 +224,13 @@ async function handleViewServer(user: ConnectedUser, message: WsMessage, connect
|
|||||||
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId;
|
const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||||
|
|
||||||
if (!leaveSid) return;
|
if (!leaveSid)
|
||||||
|
return;
|
||||||
|
|
||||||
user.serverIds.delete(leaveSid);
|
user.serverIds.delete(leaveSid);
|
||||||
|
|
||||||
if (user.viewedServerId === leaveSid) user.viewedServerId = undefined;
|
if (user.viewedServerId === leaveSid)
|
||||||
|
user.viewedServerId = undefined;
|
||||||
|
|
||||||
connectedUsers.set(connectionId, user);
|
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 {
|
function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const status = typeof message['status'] === 'string' ? message['status'] : undefined;
|
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'];
|
user.status = status as ConnectedUser['status'];
|
||||||
connectedUsers.set(connectionId, user);
|
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> {
|
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||||
const user = connectedUsers.get(connectionId);
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
if (!user) return;
|
if (!user)
|
||||||
|
return;
|
||||||
|
|
||||||
user.lastPong = Date.now();
|
user.lastPong = Date.now();
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
|
|||||||
@@ -79,11 +79,10 @@ import {
|
|||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
export class App implements OnInit, OnDestroy {
|
export class App implements OnInit, OnDestroy {
|
||||||
readonly plugins = inject(PluginBootstrapService);
|
|
||||||
|
|
||||||
private static readonly THEME_STUDIO_CONTROLS_MARGIN = 16;
|
private static readonly THEME_STUDIO_CONTROLS_MARGIN = 16;
|
||||||
private static readonly TITLE_BAR_HEIGHT = 40;
|
private static readonly TITLE_BAR_HEIGHT = 40;
|
||||||
|
|
||||||
|
readonly plugins = inject(PluginBootstrapService);
|
||||||
store = inject(Store);
|
store = inject(Store);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
desktopUpdates = inject(DesktopAppUpdateService);
|
desktopUpdates = inject(DesktopAppUpdateService);
|
||||||
|
|||||||
@@ -56,4 +56,4 @@ export function clearStoredLocalAppData(): void {
|
|||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable max-statements-per-line */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable max-statements-per-line */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
|
|||||||
@@ -5,17 +5,13 @@ import {
|
|||||||
updateMessageStatusInConversation,
|
updateMessageStatusInConversation,
|
||||||
upsertDirectMessage
|
upsertDirectMessage
|
||||||
} from '../../domain/logic/direct-message.logic';
|
} from '../../domain/logic/direct-message.logic';
|
||||||
import type {
|
import type { DirectMessage, DirectMessageParticipant } from '../../domain/models/direct-message.model';
|
||||||
DirectMessage,
|
|
||||||
DirectMessageParticipant
|
|
||||||
} from '../../domain/models/direct-message.model';
|
|
||||||
|
|
||||||
const alice: DirectMessageParticipant = {
|
const alice: DirectMessageParticipant = {
|
||||||
userId: 'alice',
|
userId: 'alice',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
displayName: 'Alice'
|
displayName: 'Alice'
|
||||||
};
|
};
|
||||||
|
|
||||||
const bob: DirectMessageParticipant = {
|
const bob: DirectMessageParticipant = {
|
||||||
userId: 'bob',
|
userId: 'bob',
|
||||||
username: 'bob',
|
username: 'bob',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
computed,
|
computed,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export class NotificationsEffects {
|
|||||||
this.notifications.refreshRoomUnreadFromMessages(roomId, roomMessages);
|
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.
|
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 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.
|
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`.
|
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.
|
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> {
|
private async readRaw(key: string): Promise<string | null> {
|
||||||
|
const scopedKey = getUserScopedStorageKey(key);
|
||||||
const api = this.electronBridge.getApi();
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
if (api) {
|
if (api) {
|
||||||
return await api.query<string | null>({
|
return await api.query<string | null>({
|
||||||
type: 'get-meta',
|
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> {
|
private async writeRaw(key: string, value: string): Promise<void> {
|
||||||
|
const scopedKey = getUserScopedStorageKey(key);
|
||||||
const api = this.electronBridge.getApi();
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
if (api) {
|
if (api) {
|
||||||
await api.command({
|
await api.command({
|
||||||
type: 'save-meta',
|
type: 'save-meta',
|
||||||
payload: { key, value }
|
payload: { key: scopedKey, value }
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem(getUserScopedStorageKey(key), value);
|
localStorage.setItem(scopedKey, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ import type {
|
|||||||
TojuPluginManifest
|
TojuPluginManifest
|
||||||
} from '../../../../shared-kernel';
|
} from '../../../../shared-kernel';
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
|
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
|
||||||
import { selectCurrentRoom, selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom, selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
|
||||||
import { ServerDirectoryFacade, type ServerSourceSelector } from '../../../server-directory';
|
import { ServerDirectoryFacade, type ServerSourceSelector } from '../../../server-directory';
|
||||||
import { PluginRegistryService } from './plugin-registry.service';
|
import { PluginRegistryService } from './plugin-registry.service';
|
||||||
import { PluginRequirementService } from './plugin-requirement.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 =
|
export type PluginRequirementComparisonStatus =
|
||||||
| 'blockedByServer'
|
| 'blockedByServer'
|
||||||
| 'disabled'
|
| 'disabled'
|
||||||
@@ -48,6 +53,8 @@ export class PluginRequirementStateService {
|
|||||||
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
|
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
|
||||||
private readonly snapshotsSignal = signal<Record<string, PluginRequirementsSnapshot>>({});
|
private readonly snapshotsSignal = signal<Record<string, PluginRequirementsSnapshot>>({});
|
||||||
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
|
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
|
||||||
|
private readonly sessionDismissedOptionalSignal = signal<Record<string, string[]>>({});
|
||||||
|
private readonly hiddenOptionalSignal = signal<RequirementDismissalState>(loadRequirementDismissals());
|
||||||
|
|
||||||
readonly currentSnapshot = computed(() => {
|
readonly currentSnapshot = computed(() => {
|
||||||
const roomId = this.currentRoomId();
|
const roomId = this.currentRoomId();
|
||||||
@@ -55,6 +62,22 @@ export class PluginRequirementStateService {
|
|||||||
return roomId ? this.snapshotsSignal()[roomId] ?? null : null;
|
return roomId ? this.snapshotsSignal()[roomId] ?? null : null;
|
||||||
});
|
});
|
||||||
readonly refreshErrors = this.refreshErrorsSignal.asReadonly();
|
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[]>(() => {
|
readonly comparisons = computed<PluginRequirementComparison[]>(() => {
|
||||||
const snapshot = this.currentSnapshot();
|
const snapshot = this.currentSnapshot();
|
||||||
const installedEntries = this.registry.entries();
|
const installedEntries = this.registry.entries();
|
||||||
@@ -138,6 +161,36 @@ export class PluginRequirementStateService {
|
|||||||
return this.comparisons().find((comparison) => comparison.pluginId === pluginId) ?? null;
|
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 {
|
private setSnapshot(serverId: string, snapshot: PluginRequirementsSnapshot): void {
|
||||||
this.snapshotsSignal.update((snapshots) => ({
|
this.snapshotsSignal.update((snapshots) => ({
|
||||||
...snapshots,
|
...snapshots,
|
||||||
@@ -184,6 +237,70 @@ export class PluginRequirementStateService {
|
|||||||
|
|
||||||
return 'enabled';
|
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 } {
|
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 { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
|
||||||
import type {
|
import type {
|
||||||
InstalledStorePlugin,
|
InstalledStorePlugin,
|
||||||
|
PersistedServerPluginInstallState,
|
||||||
PersistedPluginStoreState,
|
PersistedPluginStoreState,
|
||||||
PluginStoreEntry,
|
PluginStoreEntry,
|
||||||
PluginStoreInstallState,
|
PluginStoreInstallState,
|
||||||
@@ -44,6 +45,7 @@ import { PluginRegistryService } from './plugin-registry.service';
|
|||||||
|
|
||||||
const STORE_SCHEMA_VERSION = 1;
|
const STORE_SCHEMA_VERSION = 1;
|
||||||
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
|
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 PLUGIN_CACHE_DIR = 'plugin-bundles';
|
||||||
const DEFAULT_STORE_STATE: PersistedPluginStoreState = {
|
const DEFAULT_STORE_STATE: PersistedPluginStoreState = {
|
||||||
installedPlugins: [],
|
installedPlugins: [],
|
||||||
@@ -238,6 +240,10 @@ export class PluginStoreService {
|
|||||||
installedPlugin: InstalledStorePlugin,
|
installedPlugin: InstalledStorePlugin,
|
||||||
options: PluginStoreInstallOptions
|
options: PluginStoreInstallOptions
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (installScope === 'server' && targetServerId) {
|
||||||
|
await this.writeLocalServerInstalledPlugins(targetServerId, nextInstalledPlugins);
|
||||||
|
}
|
||||||
|
|
||||||
if (installScope !== 'client' && targetServerId !== this.currentRoomId?.()) {
|
if (installScope !== 'client' && targetServerId !== this.currentRoomId?.()) {
|
||||||
if (options.activate) {
|
if (options.activate) {
|
||||||
await this.host.rememberActivation(installedPlugin.manifest.id);
|
await this.host.rememberActivation(installedPlugin.manifest.id);
|
||||||
@@ -248,9 +254,7 @@ export class PluginStoreService {
|
|||||||
|
|
||||||
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.cachedSourcePath ?? installedPlugin.installUrl);
|
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.cachedSourcePath ?? installedPlugin.installUrl);
|
||||||
|
|
||||||
if (installScope === 'client' || options.optional !== true) {
|
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
|
||||||
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.activate) {
|
if (options.activate) {
|
||||||
await this.host.activatePluginById(installedPlugin.manifest.id);
|
await this.host.activatePluginById(installedPlugin.manifest.id);
|
||||||
@@ -291,6 +295,63 @@ export class PluginStoreService {
|
|||||||
return await this.installedPluginsForServer(serverId);
|
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> {
|
async loadReadme(plugin: PluginStoreEntry): Promise<PluginStoreReadme> {
|
||||||
if (!plugin.readmeUrl) {
|
if (!plugin.readmeUrl) {
|
||||||
throw new Error('Plugin does not provide a readme URL');
|
throw new Error('Plugin does not provide a readme URL');
|
||||||
@@ -604,7 +665,7 @@ export class PluginStoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const installedPlugins = await this.readServerInstalledPlugins(roomId);
|
const installedPlugins = await this.readLocalServerInstalledPlugins(roomId);
|
||||||
|
|
||||||
if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) {
|
if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) {
|
||||||
await this.applyInstalledPlugins(installedPlugins, 'server');
|
await this.applyInstalledPlugins(installedPlugins, 'server');
|
||||||
@@ -650,6 +711,56 @@ export class PluginStoreService {
|
|||||||
.sort(sortInstalledPlugins);
|
.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(
|
private async saveServerPluginRequirement(
|
||||||
installedPlugin: InstalledStorePlugin,
|
installedPlugin: InstalledStorePlugin,
|
||||||
roomId: string | null,
|
roomId: string | null,
|
||||||
@@ -735,10 +846,6 @@ export class PluginStoreService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (serverId === this.currentRoomId?.()) {
|
|
||||||
return this.serverInstalledPluginsSignal();
|
|
||||||
}
|
|
||||||
|
|
||||||
const actorUserId = this.currentActorUserId();
|
const actorUserId = this.currentActorUserId();
|
||||||
|
|
||||||
if (!actorUserId || !this.serverDirectory) {
|
if (!actorUserId || !this.serverDirectory) {
|
||||||
@@ -839,8 +946,15 @@ function isPluginRequirementsChangedMessage(message: unknown): message is { serv
|
|||||||
&& typeof message['serverId'] === 'string';
|
&& typeof message['serverId'] === 'string';
|
||||||
}
|
}
|
||||||
|
|
||||||
function installedPluginFromRequirement(requirement: PluginRequirementSummary): InstalledStorePlugin | null {
|
function installedPluginFromRequirement(
|
||||||
if (requirement.status === 'optional' || requirement.status === 'blocked' || requirement.status === 'incompatible') {
|
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;
|
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 {
|
function isInstalledStorePlugin(value: unknown): value is InstalledStorePlugin {
|
||||||
if (!isRecord(value) || !isRecord(value['manifest'])) {
|
if (!isRecord(value) || !isRecord(value['manifest'])) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -50,3 +50,8 @@ export interface PersistedPluginStoreState {
|
|||||||
installedPlugins: InstalledStorePlugin[];
|
installedPlugins: InstalledStorePlugin[];
|
||||||
sourceUrls: string[];
|
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">
|
<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) {
|
@if (page(); as pageRecord) {
|
||||||
<section class="mx-auto mt-6 max-w-5xl">
|
<section class="mx-auto mt-6 max-w-5xl">
|
||||||
<p class="text-xs uppercase tracking-[0.18em] text-muted-foreground">{{ pageRecord.pluginId }}</p>
|
<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.
|
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.
|
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
|
## 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.
|
`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.
|
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
|
## Default endpoint management
|
||||||
|
|||||||
@@ -103,11 +103,11 @@
|
|||||||
<div class="flex min-w-0 items-start gap-3">
|
<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">
|
<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) {
|
@if (server.icon) {
|
||||||
<img
|
<div
|
||||||
[src]="server.icon"
|
aria-hidden="true"
|
||||||
[alt]="server.name + ' icon'"
|
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||||
class="h-full w-full object-cover"
|
[style.backgroundImage]="'url(' + server.icon + ')'"
|
||||||
/>
|
></div>
|
||||||
} @else {
|
} @else {
|
||||||
{{ server.name[0] || '?' }}
|
{{ server.name[0] || '?' }}
|
||||||
}
|
}
|
||||||
@@ -297,6 +297,96 @@
|
|||||||
</app-confirm-dialog>
|
</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 -->
|
<!-- Create Server Dialog -->
|
||||||
@if (showCreateDialog()) {
|
@if (showCreateDialog()) {
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,30 +1,77 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* 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 { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
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 { 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 { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||||
import { selectSearchResults, selectIsSearching, selectRoomsError, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
import {
|
||||||
import { Room, User } from '../../../../shared-kernel';
|
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 { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||||
import { type ServerInfo } from '../../domain/models/server-directory.model';
|
import { type ServerInfo } from '../../domain/models/server-directory.model';
|
||||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
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 { hasRoomBanForUser } from '../../../access-control';
|
||||||
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
|
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
|
import { PluginRequirementService, PluginStoreService } from '../../../plugins';
|
||||||
|
|
||||||
|
interface JoinPluginConsentDialog {
|
||||||
|
optional: PluginRequirementSummary[];
|
||||||
|
password?: string;
|
||||||
|
required: PluginRequirementSummary[];
|
||||||
|
server: ServerInfo;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-server-search',
|
selector: 'app-server-search',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgIcon, ConfirmDialogComponent, LeaveServerDialogComponent, UserSearchListComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgIcon,
|
||||||
|
ConfirmDialogComponent,
|
||||||
|
LeaveServerDialogComponent,
|
||||||
|
UserSearchListComponent
|
||||||
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideSearch,
|
lucideSearch,
|
||||||
@@ -49,6 +96,8 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
private db = inject(DatabaseService);
|
private db = inject(DatabaseService);
|
||||||
private serverDirectory = inject(ServerDirectoryFacade);
|
private serverDirectory = inject(ServerDirectoryFacade);
|
||||||
private webrtc = inject(RealtimeSessionFacade);
|
private webrtc = inject(RealtimeSessionFacade);
|
||||||
|
private pluginRequirements = inject(PluginRequirementService);
|
||||||
|
private pluginStore = inject(PluginStoreService);
|
||||||
private searchSubject = new Subject<string>();
|
private searchSubject = new Subject<string>();
|
||||||
private banLookupRequestVersion = 0;
|
private banLookupRequestVersion = 0;
|
||||||
|
|
||||||
@@ -69,6 +118,10 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
joinErrorMessage = signal<string | null>(null);
|
joinErrorMessage = signal<string | null>(null);
|
||||||
joinedServerMenuId = signal<string | null>(null);
|
joinedServerMenuId = signal<string | null>(null);
|
||||||
leaveDialogRoom = signal<Room | 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
|
// Create dialog state
|
||||||
showCreateDialog = signal(false);
|
showCreateDialog = signal(false);
|
||||||
@@ -138,7 +191,8 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
|
|
||||||
/** Submit the new server creation form and dispatch the create action. */
|
/** Submit the new server creation form and dispatch the create action. */
|
||||||
createServer(): void {
|
createServer(): void {
|
||||||
if (!this.newServerName()) return;
|
if (!this.newServerName())
|
||||||
|
return;
|
||||||
|
|
||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
|
|
||||||
@@ -244,10 +298,65 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
this.joinPasswordError.set(null);
|
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> {
|
async confirmPasswordJoin(): Promise<void> {
|
||||||
const server = this.passwordPromptServer();
|
const server = this.passwordPromptServer();
|
||||||
|
|
||||||
if (!server) return;
|
if (!server)
|
||||||
|
return;
|
||||||
|
|
||||||
await this.attemptJoinServer(server, this.joinPassword());
|
await this.attemptJoinServer(server, this.joinPassword());
|
||||||
}
|
}
|
||||||
@@ -259,7 +368,8 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
getServerUserCount(server: ServerInfo): number {
|
getServerUserCount(server: ServerInfo): number {
|
||||||
const candidate = server as ServerInfo & { currentUsers?: 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;
|
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 currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
const currentUser = this.currentUser();
|
const currentUser = this.currentUser();
|
||||||
|
|
||||||
@@ -315,6 +429,16 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
this.joinPasswordError.set(null);
|
this.joinPasswordError.set(null);
|
||||||
|
|
||||||
try {
|
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(
|
const response = await firstValueFrom(
|
||||||
this.serverDirectory.requestJoin(
|
this.serverDirectory.requestJoin(
|
||||||
{
|
{
|
||||||
@@ -351,6 +475,11 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.closePasswordDialog();
|
this.closePasswordDialog();
|
||||||
|
|
||||||
|
if (options.acceptedRequirements?.length) {
|
||||||
|
await this.pluginStore.installServerRequirementsLocally(resolvedServer.id, options.acceptedRequirements, { activate: true });
|
||||||
|
}
|
||||||
|
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
RoomsActions.joinRoom({
|
RoomsActions.joinRoom({
|
||||||
roomId: resolvedServer.id,
|
roomId: resolvedServer.id,
|
||||||
@@ -378,9 +507,40 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.joinErrorMessage.set(message);
|
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> {
|
private async requestMissingServerIcons(servers: ServerInfo[], currentUser: User | null): Promise<void> {
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return;
|
return;
|
||||||
@@ -415,6 +575,7 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
description: currentUser.description,
|
description: currentUser.description,
|
||||||
profileUpdatedAt: currentUser.profileUpdatedAt
|
profileUpdatedAt: currentUser.profileUpdatedAt
|
||||||
});
|
});
|
||||||
|
|
||||||
this.webrtc.sendRawMessageToSignalUrl(wsUrl, {
|
this.webrtc.sendRawMessageToSignalUrl(wsUrl, {
|
||||||
type: 'server_icon_sync_request',
|
type: 'server_icon_sync_request',
|
||||||
serverId: server.id,
|
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));
|
this.bannedServerLookup.set(Object.fromEntries(entries));
|
||||||
}
|
}
|
||||||
@@ -453,7 +615,8 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
const currentUser = this.currentUser();
|
const currentUser = this.currentUser();
|
||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
|
|
||||||
if (!currentUser && !currentUserId) return false;
|
if (!currentUser && !currentUserId)
|
||||||
|
return false;
|
||||||
|
|
||||||
const bans = await this.db.getBansForRoom(server.id);
|
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 {
|
import { applyThemeStyleDeclaration, toCssStylePropertyName } from './theme-style-application.logic';
|
||||||
applyThemeStyleDeclaration,
|
|
||||||
toCssStylePropertyName
|
|
||||||
} from './theme-style-application.logic';
|
|
||||||
|
|
||||||
describe('theme style application', () => {
|
describe('theme style application', () => {
|
||||||
it('applies camelCase theme properties as real CSS declarations', () => {
|
it('applies camelCase theme properties as real CSS declarations', () => {
|
||||||
|
|||||||
@@ -16,13 +16,11 @@
|
|||||||
class="w-3.5 h-3.5"
|
class="w-3.5 h-3.5"
|
||||||
/>
|
/>
|
||||||
@if (voiceSession()?.serverIcon) {
|
@if (voiceSession()?.serverIcon) {
|
||||||
<img
|
<span
|
||||||
[ngSrc]="voiceSession()?.serverIcon || ''"
|
aria-hidden="true"
|
||||||
class="w-5 h-5 rounded object-cover"
|
class="h-5 w-5 rounded bg-cover bg-center bg-no-repeat"
|
||||||
alt=""
|
[style.backgroundImage]="'url(' + voiceSession()!.serverIcon + ')'"
|
||||||
width="20"
|
></span>
|
||||||
height="20"
|
|
||||||
/>
|
|
||||||
} @else {
|
} @else {
|
||||||
<div class="flex h-5 w-5 items-center justify-center rounded-sm bg-muted text-[10px] font-semibold">
|
<div class="flex h-5 w-5 items-center justify-center rounded-sm bg-muted text-[10px] font-semibold">
|
||||||
{{ voiceSession()?.serverName?.charAt(0)?.toUpperCase() || '?' }}
|
{{ voiceSession()?.serverName?.charAt(0)?.toUpperCase() || '?' }}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +34,6 @@ import { ThemeNodeDirective } from '../../../../domains/theme';
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
NgOptimizedImage,
|
|
||||||
NgIcon,
|
NgIcon,
|
||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareQualityDialogComponent,
|
ScreenShareQualityDialogComponent,
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
<div class="flex items-center gap-3">
|
<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">
|
<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) {
|
@if (currentRoom()?.icon) {
|
||||||
<img
|
<div
|
||||||
[src]="currentRoom()!.icon"
|
aria-hidden="true"
|
||||||
[alt]="currentRoom()!.name + ' icon'"
|
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||||
class="h-full w-full object-cover"
|
[style.backgroundImage]="'url(' + currentRoom()!.icon + ')'"
|
||||||
/>
|
></div>
|
||||||
} @else {
|
} @else {
|
||||||
{{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }}
|
{{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,11 +42,11 @@
|
|||||||
>
|
>
|
||||||
<div class="h-full w-full overflow-hidden rounded-[inherit]">
|
<div class="h-full w-full overflow-hidden rounded-[inherit]">
|
||||||
@if (room.icon) {
|
@if (room.icon) {
|
||||||
<img
|
<div
|
||||||
[src]="room.icon"
|
aria-hidden="true"
|
||||||
[alt]="room.name + ' icon'"
|
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||||
class="h-full w-full object-cover"
|
[style.backgroundImage]="'url(' + room.icon + ')'"
|
||||||
/>
|
></div>
|
||||||
} @else {
|
} @else {
|
||||||
<div
|
<div
|
||||||
class="flex h-full w-full items-center justify-center bg-secondary transition-colors"
|
class="flex h-full w-full items-center justify-center bg-secondary transition-colors"
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* 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 { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
@@ -7,7 +15,17 @@ import { Store } from '@ngrx/store';
|
|||||||
import { NavigationEnd, Router } from '@angular/router';
|
import { NavigationEnd, Router } from '@angular/router';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucidePlus } from '@ng-icons/lucide';
|
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 { Room, User } from '../../../shared-kernel';
|
||||||
import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component';
|
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 { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||||
import { hasRoomBanForUser } from '../../../domains/access-control';
|
import { hasRoomBanForUser } from '../../../domains/access-control';
|
||||||
import { ConfirmDialogComponent, ContextMenuComponent, LeaveServerDialogComponent } from '../../../shared';
|
import {
|
||||||
|
ConfirmDialogComponent,
|
||||||
|
ContextMenuComponent,
|
||||||
|
LeaveServerDialogComponent
|
||||||
|
} from '../../../shared';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-servers-rail',
|
selector: 'app-servers-rail',
|
||||||
@@ -143,7 +165,8 @@ export class ServersRailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initial(name?: string): string {
|
initial(name?: string): string {
|
||||||
if (!name) return '?';
|
if (!name)
|
||||||
|
return '?';
|
||||||
|
|
||||||
const ch = name.trim()[0]?.toUpperCase();
|
const ch = name.trim()[0]?.toUpperCase();
|
||||||
|
|
||||||
@@ -195,7 +218,8 @@ export class ServersRailComponent {
|
|||||||
confirmPasswordJoin(): void {
|
confirmPasswordJoin(): void {
|
||||||
const room = this.passwordPromptRoom();
|
const room = this.passwordPromptRoom();
|
||||||
|
|
||||||
if (!room) return;
|
if (!room)
|
||||||
|
return;
|
||||||
|
|
||||||
this.joinPasswordError.set(null);
|
this.joinPasswordError.set(null);
|
||||||
this.savedRoomJoinRequests.next({ room, password: this.joinPassword() });
|
this.savedRoomJoinRequests.next({ room, password: this.joinPassword() });
|
||||||
@@ -235,7 +259,8 @@ export class ServersRailComponent {
|
|||||||
confirmLeave(result: { nextOwnerKey?: string }): void {
|
confirmLeave(result: { nextOwnerKey?: string }): void {
|
||||||
const ctx = this.contextRoom();
|
const ctx = this.contextRoom();
|
||||||
|
|
||||||
if (!ctx) return;
|
if (!ctx)
|
||||||
|
return;
|
||||||
|
|
||||||
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
|
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
|
||||||
|
|
||||||
@@ -338,7 +363,8 @@ export class ServersRailComponent {
|
|||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
const currentUser = this.currentUser();
|
const currentUser = this.currentUser();
|
||||||
|
|
||||||
if (!currentUserId) return EMPTY;
|
if (!currentUserId)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
this.joinPasswordError.set(null);
|
this.joinPasswordError.set(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
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 { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
@@ -54,7 +62,7 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
|||||||
case 'running':
|
case 'running':
|
||||||
return `Running at ${snapshot.baseUrl ?? 'unknown'}`;
|
return `Running at ${snapshot.baseUrl ?? 'unknown'}`;
|
||||||
case 'starting':
|
case 'starting':
|
||||||
return 'Starting…';
|
return 'Starting...';
|
||||||
case 'error':
|
case 'error':
|
||||||
return `Error: ${snapshot.error ?? 'unknown error'}`;
|
return `Error: ${snapshot.error ?? 'unknown error'}`;
|
||||||
case 'stopped':
|
case 'stopped':
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
<div class="flex items-center gap-3">
|
<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">
|
<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) {
|
@if (serverData()?.icon) {
|
||||||
<img
|
<div
|
||||||
[src]="serverData()!.icon"
|
aria-hidden="true"
|
||||||
[alt]="serverData()!.name + ' icon'"
|
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||||
class="h-full w-full object-cover"
|
[style.backgroundImage]="'url(' + serverData()!.icon + ')'"
|
||||||
/>
|
></div>
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideImage"
|
name="lucideImage"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { Room } from '../../../../shared-kernel';
|
|||||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||||
import { ConfirmDialogComponent } from '../../../../shared';
|
import { ConfirmDialogComponent } from '../../../../shared';
|
||||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||||
|
import { ServerIconImageService } from '../../../../domains/server-directory/infrastructure/services/server-icon-image.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-server-settings',
|
selector: 'app-server-settings',
|
||||||
@@ -50,6 +51,7 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
|
|||||||
export class ServerSettingsComponent {
|
export class ServerSettingsComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private modal = inject(SettingsModalService);
|
private modal = inject(SettingsModalService);
|
||||||
|
private serverIconImages = inject(ServerIconImageService);
|
||||||
|
|
||||||
/** The currently selected server, passed from the parent. */
|
/** The currently selected server, passed from the parent. */
|
||||||
server = input<Room | null>(null);
|
server = input<Room | null>(null);
|
||||||
@@ -181,7 +183,7 @@ export class ServerSettingsComponent {
|
|||||||
this.modal.navigate('network');
|
this.modal.navigate('network');
|
||||||
}
|
}
|
||||||
|
|
||||||
onServerIconSelected(event: Event): void {
|
async onServerIconSelected(event: Event): Promise<void> {
|
||||||
const inputElement = event.target as HTMLInputElement;
|
const inputElement = event.target as HTMLInputElement;
|
||||||
const file = inputElement.files?.[0];
|
const file = inputElement.files?.[0];
|
||||||
|
|
||||||
@@ -191,37 +193,24 @@ export class ServerSettingsComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file.type.startsWith('image/')) {
|
try {
|
||||||
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 = () => {
|
|
||||||
const room = this.server();
|
const room = this.server();
|
||||||
const icon = typeof reader.result === 'string' ? reader.result : '';
|
const icon = await this.serverIconImages.process(file);
|
||||||
|
|
||||||
if (!room || !icon) {
|
if (!room) {
|
||||||
this.iconError.set('Could not read that image.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.iconError.set(null);
|
this.iconError.set(null);
|
||||||
this.store.dispatch(RoomsActions.updateServerIcon({
|
this.store.dispatch(RoomsActions.updateServerIcon({
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
icon
|
icon: icon.dataUrl
|
||||||
}));
|
}));
|
||||||
this.showSaveSuccess('icon');
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.onerror = () => this.iconError.set('Could not read that image.');
|
this.showSaveSuccess('icon');
|
||||||
reader.readAsDataURL(file);
|
} catch (error) {
|
||||||
|
this.iconError.set(error instanceof Error ? error.message : 'Could not read that image.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
removeServerIcon(): void {
|
removeServerIcon(): void {
|
||||||
@@ -236,6 +225,7 @@ export class ServerSettingsComponent {
|
|||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
icon: ''
|
icon: ''
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.showSaveSuccess('icon');
|
this.showSaveSuccess('icon');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,24 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</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()) {
|
@if (isElectron()) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -227,6 +245,123 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Click-away overlay to close dropdown -->
|
||||||
@if (showMenu()) {
|
@if (showMenu()) {
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
lucideHash,
|
lucideHash,
|
||||||
lucideMenu,
|
lucideMenu,
|
||||||
lucidePackage,
|
lucidePackage,
|
||||||
lucideRefreshCw
|
lucideRefreshCw,
|
||||||
|
lucideShield
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
import { NavigationEnd, Router } from '@angular/router';
|
import { NavigationEnd, Router } from '@angular/router';
|
||||||
import { toSignal } from '@angular/core/rxjs-interop';
|
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 { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage';
|
||||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||||
import { LeaveServerDialogComponent } from '../../../shared';
|
import { LeaveServerDialogComponent } from '../../../shared';
|
||||||
import { Room } from '../../../shared-kernel';
|
import { Room, type PluginRequirementSummary } from '../../../shared-kernel';
|
||||||
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||||
|
import {
|
||||||
|
PluginRegistryService,
|
||||||
|
PluginRequirementStateService,
|
||||||
|
PluginStoreService
|
||||||
|
} from '../../../domains/plugins';
|
||||||
|
import { getPluginInstallScope } from '../../../domains/plugins/domain/logic/plugin-install-scope.logic';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-title-bar',
|
selector: 'app-title-bar',
|
||||||
@@ -64,7 +71,8 @@ import { ThemeNodeDirective } from '../../../domains/theme';
|
|||||||
lucideHash,
|
lucideHash,
|
||||||
lucideMenu,
|
lucideMenu,
|
||||||
lucidePackage,
|
lucidePackage,
|
||||||
lucideRefreshCw })
|
lucideRefreshCw,
|
||||||
|
lucideShield })
|
||||||
],
|
],
|
||||||
templateUrl: './title-bar.component.html'
|
templateUrl: './title-bar.component.html'
|
||||||
})
|
})
|
||||||
@@ -80,6 +88,9 @@ export class TitleBarComponent {
|
|||||||
private platform = inject(PlatformService);
|
private platform = inject(PlatformService);
|
||||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||||
private settingsModal = inject(SettingsModalService);
|
private settingsModal = inject(SettingsModalService);
|
||||||
|
private pluginRegistry = inject(PluginRegistryService);
|
||||||
|
private pluginRequirements = inject(PluginRequirementStateService);
|
||||||
|
private pluginStore = inject(PluginStoreService);
|
||||||
|
|
||||||
private getWindowControlsApi() {
|
private getWindowControlsApi() {
|
||||||
return this.electronBridge.getApi();
|
return this.electronBridge.getApi();
|
||||||
@@ -153,11 +164,20 @@ export class TitleBarComponent {
|
|||||||
|| this.isReconnecting()
|
|| 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);
|
private _showMenu = signal(false);
|
||||||
showMenu = computed(() => this._showMenu());
|
showMenu = computed(() => this._showMenu());
|
||||||
showLeaveConfirm = signal(false);
|
showLeaveConfirm = signal(false);
|
||||||
inviteStatus = signal<string | null>(null);
|
inviteStatus = signal<string | null>(null);
|
||||||
creatingInvite = signal(false);
|
creatingInvite = signal(false);
|
||||||
|
pluginRequirementBusy = signal(false);
|
||||||
|
pluginRequirementError = signal<string | null>(null);
|
||||||
|
|
||||||
/** Minimize the Electron window. */
|
/** Minimize the Electron window. */
|
||||||
minimize() {
|
minimize() {
|
||||||
@@ -192,6 +212,17 @@ export class TitleBarComponent {
|
|||||||
void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } });
|
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 {
|
openSettings(): void {
|
||||||
this._showMenu.set(false);
|
this._showMenu.set(false);
|
||||||
this.settingsModal.open('general');
|
this.settingsModal.open('general');
|
||||||
@@ -267,6 +298,24 @@ export class TitleBarComponent {
|
|||||||
this.openLeaveConfirm();
|
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. */
|
/** Confirm the unified leave action and remove the server locally. */
|
||||||
confirmLeave(result: { nextOwnerKey?: string }) {
|
confirmLeave(result: { nextOwnerKey?: string }) {
|
||||||
const roomId = this.currentRoom()?.id;
|
const roomId = this.currentRoom()?.id;
|
||||||
@@ -294,6 +343,25 @@ export class TitleBarComponent {
|
|||||||
this._showMenu.set(false);
|
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. */
|
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||||
logout() {
|
logout() {
|
||||||
this._showMenu.set(false);
|
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.
|
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
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant Eff as NgRx Effect
|
participant Eff as NgRx Effect
|
||||||
|
|||||||
@@ -140,9 +140,11 @@ export class IncomingSignalingMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const user of users) {
|
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);
|
this.clearUserJoinedFallbackOffer(user.oderId);
|
||||||
|
|
||||||
@@ -310,9 +312,11 @@ export class IncomingSignalingMessageHandler {
|
|||||||
const fromUserId = message.fromUserId;
|
const fromUserId = message.fromUserId;
|
||||||
const sdp = message.payload?.sdp;
|
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.clearUserJoinedFallbackOffer(fromUserId);
|
||||||
this.nonInitiatorWaitStart.delete(fromUserId);
|
this.nonInitiatorWaitStart.delete(fromUserId);
|
||||||
@@ -332,9 +336,11 @@ export class IncomingSignalingMessageHandler {
|
|||||||
const fromUserId = message.fromUserId;
|
const fromUserId = message.fromUserId;
|
||||||
const sdp = message.payload?.sdp;
|
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.clearUserJoinedFallbackOffer(fromUserId);
|
||||||
|
|
||||||
@@ -346,9 +352,11 @@ export class IncomingSignalingMessageHandler {
|
|||||||
const fromUserId = message.fromUserId;
|
const fromUserId = message.fromUserId;
|
||||||
const candidate = message.payload?.candidate;
|
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);
|
this.clearUserJoinedFallbackOffer(fromUserId);
|
||||||
|
|
||||||
@@ -507,15 +515,18 @@ export class IncomingSignalingMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private shouldInitiatePeer(peerId: string, localOderId: string | null = this.dependencies.getLocalOderId()): boolean {
|
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;
|
return localOderId < peerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasActivePeerConnection(peer: PeerData | undefined): boolean {
|
private hasActivePeerConnection(peer: PeerData | undefined): boolean {
|
||||||
if (!peer) return false;
|
if (!peer)
|
||||||
|
return false;
|
||||||
|
|
||||||
const connectionState = peer.connection?.connectionState;
|
const connectionState = peer.connection?.connectionState;
|
||||||
|
|
||||||
@@ -523,11 +534,13 @@ export class IncomingSignalingMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isPeerConnectionNegotiating(peer: PeerData | undefined): boolean {
|
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;
|
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 signalingState = peer.connection?.signalingState;
|
||||||
const ageMs = Date.now() - peer.createdAt;
|
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
|
// 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
|
// negotiating with the remote side. Use a much longer grace period so that
|
||||||
// a slow signaling round-trip does not trigger a premature teardown.
|
// 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).
|
// 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.
|
// 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;
|
return ageMs < PEER_NEGOTIATION_GRACE_MS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { defaultIfEmpty, firstValueFrom } from 'rxjs';
|
||||||
defaultIfEmpty,
|
|
||||||
firstValueFrom
|
|
||||||
} from 'rxjs';
|
|
||||||
|
|
||||||
import { type Message } from '../../shared-kernel';
|
import { type Message } from '../../shared-kernel';
|
||||||
import { dispatchIncomingMessage } from './messages-incoming.handlers';
|
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 () => {
|
it('sends full sync for requested room even when another room is viewed', async () => {
|
||||||
const roomBMessages = [
|
const roomBMessageOne = createMessage({ id: 'message-b1', roomId: 'room-b', timestamp: 5 });
|
||||||
createMessage({ id: 'message-b1', roomId: 'room-b', timestamp: 5 }),
|
const roomBMessageTwo = createMessage({ id: 'message-b2', roomId: 'room-b', timestamp: 15 });
|
||||||
createMessage({ id: 'message-b2', roomId: 'room-b', timestamp: 15 })
|
const roomBMessages = [roomBMessageOne, roomBMessageTwo];
|
||||||
];
|
|
||||||
const getMessages = vi.fn(async (roomId: string) => roomId === 'room-b'
|
const getMessages = vi.fn(async (roomId: string) => roomId === 'room-b'
|
||||||
? roomBMessages
|
? roomBMessages
|
||||||
: [createMessage({ id: 'message-a1', roomId: 'room-a', timestamp: 200 })]);
|
: [createMessage({ id: 'message-a1', roomId: 'room-a', timestamp: 200 })]);
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ export class RoomSettingsEffects {
|
|||||||
icon,
|
icon,
|
||||||
iconUpdatedAt
|
iconUpdatedAt
|
||||||
});
|
});
|
||||||
|
|
||||||
this.webrtc.sendRawMessage({
|
this.webrtc.sendRawMessage({
|
||||||
type: 'server_icon_available',
|
type: 'server_icon_available',
|
||||||
serverId: room.id,
|
serverId: room.id,
|
||||||
|
|||||||
@@ -1,17 +1,44 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Injectable, inject } from '@angular/core';
|
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 { Store, type Action } from '@ngrx/store';
|
||||||
import { of, from, EMPTY } from 'rxjs';
|
import {
|
||||||
import { map, mergeMap, withLatestFrom, tap, switchMap, catchError } from 'rxjs/operators';
|
of,
|
||||||
|
from,
|
||||||
|
EMPTY
|
||||||
|
} from 'rxjs';
|
||||||
|
import {
|
||||||
|
map,
|
||||||
|
mergeMap,
|
||||||
|
withLatestFrom,
|
||||||
|
tap,
|
||||||
|
switchMap,
|
||||||
|
catchError
|
||||||
|
} from 'rxjs/operators';
|
||||||
import { RoomsActions } from './rooms.actions';
|
import { RoomsActions } from './rooms.actions';
|
||||||
import { UsersActions } from '../users/users.actions';
|
import { UsersActions } from '../users/users.actions';
|
||||||
import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
|
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 { RealtimeSessionFacade } from '../../core/realtime';
|
||||||
import { DatabaseService } from '../../infrastructure/persistence';
|
import { DatabaseService } from '../../infrastructure/persistence';
|
||||||
import { resolveRoomPermission } from '../../domains/access-control';
|
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 { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||||
import { hasRoomBanForUser } from '../../domains/access-control';
|
import { hasRoomBanForUser } from '../../domains/access-control';
|
||||||
import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants';
|
import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants';
|
||||||
@@ -28,7 +55,12 @@ import {
|
|||||||
} from './rooms.helpers';
|
} from './rooms.helpers';
|
||||||
import type { RoomPresenceSignalingMessage } 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
|
* NgRx effects for real-time state synchronisation: signaling presence
|
||||||
@@ -64,7 +96,12 @@ export class RoomStateSyncEffects {
|
|||||||
signalingMessages$ = createEffect(() =>
|
signalingMessages$ = createEffect(() =>
|
||||||
this.webrtc.onSignalingMessage.pipe(
|
this.webrtc.onSignalingMessage.pipe(
|
||||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms)),
|
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 signalingMessage: RoomPresenceSignalingMessage = message;
|
||||||
const myId = currentUser?.oderId || currentUser?.id;
|
const myId = currentUser?.oderId || currentUser?.id;
|
||||||
const viewedServerId = currentRoom?.id;
|
const viewedServerId = currentRoom?.id;
|
||||||
@@ -73,7 +110,8 @@ export class RoomStateSyncEffects {
|
|||||||
|
|
||||||
switch (signalingMessage.type) {
|
switch (signalingMessage.type) {
|
||||||
case 'server_users': {
|
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
|
const syncedUsers = signalingMessage.users
|
||||||
.filter((user) => user.oderId !== myId)
|
.filter((user) => user.oderId !== myId)
|
||||||
@@ -102,9 +140,11 @@ export class RoomStateSyncEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'user_joined': {
|
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 = {
|
const joinedUser = {
|
||||||
oderId: signalingMessage.oderId,
|
oderId: signalingMessage.oderId,
|
||||||
@@ -132,7 +172,8 @@ export class RoomStateSyncEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'user_left': {
|
case 'user_left': {
|
||||||
if (!signalingMessage.oderId) return EMPTY;
|
if (!signalingMessage.oderId)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
const remainingServerIds = Array.isArray(signalingMessage.serverIds) ? signalingMessage.serverIds : undefined;
|
const remainingServerIds = Array.isArray(signalingMessage.serverIds) ? signalingMessage.serverIds : undefined;
|
||||||
|
|
||||||
@@ -160,11 +201,18 @@ export class RoomStateSyncEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'status_update': {
|
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;
|
// 'offline' from the server means the user chose Invisible;
|
||||||
// display them as disconnected to other users.
|
// display them as disconnected to other users.
|
||||||
@@ -179,14 +227,17 @@ export class RoomStateSyncEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'access_denied': {
|
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
|
// When multiple signal URLs are configured, the room may already
|
||||||
// be successfully joined on a different signal server. Only show
|
// be successfully joined on a different signal server. Only show
|
||||||
// the reconnect notice when the room is not reachable at all.
|
// 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 })];
|
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: true })];
|
||||||
}
|
}
|
||||||
@@ -263,7 +314,8 @@ export class RoomStateSyncEffects {
|
|||||||
this.webrtc.onPeerConnected.pipe(
|
this.webrtc.onPeerConnected.pipe(
|
||||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||||
tap(([peerId, room]) => {
|
tap(([peerId, room]) => {
|
||||||
if (!room) return;
|
if (!room)
|
||||||
|
return;
|
||||||
|
|
||||||
this.webrtc.sendToPeer(peerId, {
|
this.webrtc.sendToPeer(peerId, {
|
||||||
type: 'server-state-request',
|
type: 'server-state-request',
|
||||||
@@ -313,7 +365,14 @@ export class RoomStateSyncEffects {
|
|||||||
this.store.select(selectCurrentUser),
|
this.store.select(selectCurrentUser),
|
||||||
this.store.select(selectActiveChannelId)
|
this.store.select(selectActiveChannelId)
|
||||||
),
|
),
|
||||||
mergeMap(([event, currentRoom, savedRooms, allUsers, currentUser, activeChannelId]) => {
|
mergeMap(([
|
||||||
|
event,
|
||||||
|
currentRoom,
|
||||||
|
savedRooms,
|
||||||
|
allUsers,
|
||||||
|
currentUser,
|
||||||
|
activeChannelId
|
||||||
|
]) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'voice-state':
|
case 'voice-state':
|
||||||
return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice');
|
return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice');
|
||||||
@@ -353,7 +412,8 @@ export class RoomStateSyncEffects {
|
|||||||
this.webrtc.onPeerConnected.pipe(
|
this.webrtc.onPeerConnected.pipe(
|
||||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||||
tap(([_peerId, room]) => {
|
tap(([_peerId, room]) => {
|
||||||
if (!room) return;
|
if (!room)
|
||||||
|
return;
|
||||||
|
|
||||||
const iconUpdatedAt = room.iconUpdatedAt || 0;
|
const iconUpdatedAt = room.iconUpdatedAt || 0;
|
||||||
|
|
||||||
@@ -374,7 +434,8 @@ export class RoomStateSyncEffects {
|
|||||||
tap((peerId) => {
|
tap((peerId) => {
|
||||||
const serverIds = this.pendingServerIconRequestsByPeer.get(peerId);
|
const serverIds = this.pendingServerIconRequestsByPeer.get(peerId);
|
||||||
|
|
||||||
if (!serverIds) return;
|
if (!serverIds)
|
||||||
|
return;
|
||||||
|
|
||||||
for (const serverId of serverIds) {
|
for (const serverId of serverIds) {
|
||||||
this.sendServerIconSyncRequest(peerId, serverId);
|
this.sendServerIconSyncRequest(peerId, serverId);
|
||||||
@@ -389,7 +450,8 @@ export class RoomStateSyncEffects {
|
|||||||
private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], currentUser: User | null, kind: 'voice' | 'screen' | 'camera') {
|
private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], currentUser: User | null, kind: 'voice' | 'screen' | 'camera') {
|
||||||
const userId: string | undefined = event.fromPeerId ?? event.oderId;
|
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 existingUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
|
||||||
const userExists = !!existingUser;
|
const userExists = !!existingUser;
|
||||||
@@ -397,16 +459,17 @@ export class RoomStateSyncEffects {
|
|||||||
if (kind === 'voice') {
|
if (kind === 'voice') {
|
||||||
const vs = event.voiceState as Partial<VoiceState> | undefined;
|
const vs = event.voiceState as Partial<VoiceState> | undefined;
|
||||||
|
|
||||||
if (!vs) return EMPTY;
|
if (!vs)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
const presenceRefreshAction =
|
const presenceRefreshAction =
|
||||||
vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId)
|
vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId)
|
||||||
? UsersActions.userJoined({
|
? UsersActions.userJoined({
|
||||||
user: buildSignalingUser(
|
user: buildSignalingUser(
|
||||||
{ oderId: userId, displayName: event.displayName || existingUser?.displayName || 'User' },
|
{ oderId: userId, displayName: event.displayName || existingUser?.displayName || 'User' },
|
||||||
{ presenceServerIds: [vs.serverId] }
|
{ presenceServerIds: [vs.serverId] }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
// Detect voice-connection transitions to play join/leave sounds.
|
// Detect voice-connection transitions to play join/leave sounds.
|
||||||
const weAreInVoice = this.webrtc.isVoiceConnected();
|
const weAreInVoice = this.webrtc.isVoiceConnected();
|
||||||
@@ -471,7 +534,8 @@ export class RoomStateSyncEffects {
|
|||||||
if (kind === 'screen') {
|
if (kind === 'screen') {
|
||||||
const isSharing = event.isScreenSharing as boolean | undefined;
|
const isSharing = event.isScreenSharing as boolean | undefined;
|
||||||
|
|
||||||
if (isSharing === undefined) return EMPTY;
|
if (isSharing === undefined)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
if (!userExists) {
|
if (!userExists) {
|
||||||
return of(
|
return of(
|
||||||
@@ -491,7 +555,8 @@ export class RoomStateSyncEffects {
|
|||||||
|
|
||||||
const isCameraEnabled = event.isCameraEnabled as boolean | undefined;
|
const isCameraEnabled = event.isCameraEnabled as boolean | undefined;
|
||||||
|
|
||||||
if (isCameraEnabled === undefined) return EMPTY;
|
if (isCameraEnabled === undefined)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
if (!userExists) {
|
if (!userExists) {
|
||||||
return of(
|
return of(
|
||||||
@@ -609,7 +674,8 @@ export class RoomStateSyncEffects {
|
|||||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
const fromPeerId = event.fromPeerId;
|
const fromPeerId = event.fromPeerId;
|
||||||
|
|
||||||
if (!room || !fromPeerId) return EMPTY;
|
if (!room || !fromPeerId)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
return from(this.db.getBansForRoom(room.id)).pipe(
|
return from(this.db.getBansForRoom(room.id)).pipe(
|
||||||
tap((bans) => {
|
tap((bans) => {
|
||||||
@@ -629,7 +695,8 @@ export class RoomStateSyncEffects {
|
|||||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
const incomingRoom = event.room as Partial<Room> | undefined;
|
const incomingRoom = event.room as Partial<Room> | undefined;
|
||||||
|
|
||||||
if (!room || !incomingRoom) return EMPTY;
|
if (!room || !incomingRoom)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
const roomChanges = {
|
const roomChanges = {
|
||||||
...sanitizeRoomSnapshot(incomingRoom),
|
...sanitizeRoomSnapshot(incomingRoom),
|
||||||
@@ -670,7 +737,8 @@ export class RoomStateSyncEffects {
|
|||||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
const settings = event.settings as Partial<RoomSettings> | undefined;
|
const settings = event.settings as Partial<RoomSettings> | undefined;
|
||||||
|
|
||||||
if (!room || !settings) return EMPTY;
|
if (!room || !settings)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
return of(
|
return of(
|
||||||
RoomsActions.updateRoom({
|
RoomsActions.updateRoom({
|
||||||
@@ -699,7 +767,8 @@ export class RoomStateSyncEffects {
|
|||||||
const permissions = event.permissions as Partial<RoomPermissions> | undefined;
|
const permissions = event.permissions as Partial<RoomPermissions> | undefined;
|
||||||
const incomingRoom = event.room as Partial<Room> | undefined;
|
const incomingRoom = event.room as Partial<Room> | undefined;
|
||||||
|
|
||||||
if (!room || (!permissions && !incomingRoom)) return EMPTY;
|
if (!room || (!permissions && !incomingRoom))
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
return of(
|
return of(
|
||||||
RoomsActions.updateRoom({
|
RoomsActions.updateRoom({
|
||||||
@@ -746,7 +815,8 @@ export class RoomStateSyncEffects {
|
|||||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
|
||||||
if (!room) return EMPTY;
|
if (!room)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
const remoteUpdated = event.iconUpdatedAt || 0;
|
const remoteUpdated = event.iconUpdatedAt || 0;
|
||||||
const localUpdated = room.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 roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
|
||||||
if (!room) return EMPTY;
|
if (!room)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
if (event.fromPeerId) {
|
if (event.fromPeerId) {
|
||||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||||
@@ -784,17 +855,20 @@ export class RoomStateSyncEffects {
|
|||||||
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
const senderId = event.fromPeerId;
|
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(
|
return this.store.select(selectAllUsers).pipe(
|
||||||
map((users) => users.find((user) => user.id === senderId)),
|
map((users) => users.find((user) => user.id === senderId)),
|
||||||
mergeMap((sender) => {
|
mergeMap((sender) => {
|
||||||
if (!sender) return EMPTY;
|
if (!sender)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
const isOwner = room.hostId === sender.id;
|
const isOwner = room.hostId === sender.id;
|
||||||
const canByRole = resolveRoomPermission(room, sender, 'manageIcon');
|
const canByRole = resolveRoomPermission(room, sender, 'manageIcon');
|
||||||
|
|
||||||
if (!isOwner && !canByRole) return EMPTY;
|
if (!isOwner && !canByRole)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
const updates: Partial<Room> = {
|
const updates: Partial<Room> = {
|
||||||
icon: event.icon,
|
icon: event.icon,
|
||||||
@@ -807,6 +881,7 @@ export class RoomStateSyncEffects {
|
|||||||
serverId: room.id,
|
serverId: room.id,
|
||||||
iconUpdatedAt: updates.iconUpdatedAt
|
iconUpdatedAt: updates.iconUpdatedAt
|
||||||
});
|
});
|
||||||
|
|
||||||
return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates }));
|
return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates }));
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { reconcileRoomSnapshotChannels, sanitizeRoomSnapshot } from './rooms.helpers';
|
||||||
reconcileRoomSnapshotChannels,
|
|
||||||
sanitizeRoomSnapshot
|
|
||||||
} from './rooms.helpers';
|
|
||||||
|
|
||||||
describe('room snapshot helpers', () => {
|
describe('room snapshot helpers', () => {
|
||||||
it('drops empty channel arrays from outgoing snapshots', () => {
|
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', () => {
|
it('keeps cached channels when incoming snapshot has none', () => {
|
||||||
const cachedChannels = [
|
const generalChannel = { id: 'general', name: 'general', type: 'text', position: 0 } as const;
|
||||||
{ id: 'general', name: 'general', type: 'text', position: 0 },
|
const updatesChannel = { id: 'updates', name: 'updates', type: 'text', position: 1 } as const;
|
||||||
{ id: 'updates', name: 'updates', type: 'text', position: 1 }
|
const cachedChannels = [generalChannel, updatesChannel] as const;
|
||||||
] as const;
|
|
||||||
|
|
||||||
expect(reconcileRoomSnapshotChannels(cachedChannels as never, undefined)).toEqual(cachedChannels);
|
expect(reconcileRoomSnapshotChannels(cachedChannels as never, undefined)).toEqual(cachedChannels);
|
||||||
expect(reconcileRoomSnapshotChannels(cachedChannels as never, [] as never)).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: 'updates', name: 'updates', type: 'text', position: 1 },
|
||||||
{ id: 'voice', name: 'General', type: 'voice', position: 0 }
|
{ id: 'voice', name: 'General', type: 'voice', position: 0 }
|
||||||
] as const;
|
] as const;
|
||||||
const incomingChannels = [
|
const incomingChannels = [{ id: 'general', name: 'general', type: 'text', position: 0 }] as const;
|
||||||
{ id: 'general', name: 'general', type: 'text', position: 0 }
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
expect(reconcileRoomSnapshotChannels(cachedChannels as never, incomingChannels as never)).toEqual(cachedChannels);
|
expect(reconcileRoomSnapshotChannels(cachedChannels as never, incomingChannels as never)).toEqual(cachedChannels);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts incoming channels when snapshot is at least as complete', () => {
|
it('accepts incoming channels when snapshot is at least as complete', () => {
|
||||||
const cachedChannels = [
|
const generalChannel = { id: 'general', name: 'general', type: 'text', position: 0 } as const;
|
||||||
{ id: 'general', name: 'general', type: 'text', position: 0 }
|
const updatesChannel = { id: 'updates', name: 'updates', type: 'text', position: 1 } as const;
|
||||||
] as const;
|
const cachedChannels = [generalChannel] as const;
|
||||||
const incomingChannels = [
|
const incomingChannels = [generalChannel, updatesChannel] as const;
|
||||||
{ id: 'general', name: 'general', type: 'text', position: 0 },
|
|
||||||
{ id: 'updates', name: 'updates', type: 'text', position: 1 }
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
expect(reconcileRoomSnapshotChannels(cachedChannels as never, incomingChannels as never)).toEqual(incomingChannels);
|
expect(reconcileRoomSnapshotChannels(cachedChannels as never, incomingChannels as never)).toEqual(incomingChannels);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
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 { resolveLegacyRole, resolveRoomPermission } from '../../domains/access-control';
|
||||||
import { findRoomMember } from './room-members.helpers';
|
import { findRoomMember } from './room-members.helpers';
|
||||||
import { ROOM_URL_PATTERN } from '../../core/constants';
|
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. */
|
/** Build a minimal User object from signaling payload. */
|
||||||
export function buildSignalingUser(data: { oderId: string; displayName?: string; status?: string }, extras: Record<string, unknown> = {}) {
|
export function buildSignalingUser(data: { oderId: string; displayName?: string; status?: string }, extras: Record<string, unknown> = {}) {
|
||||||
const displayName = data.displayName?.trim() || 'User';
|
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')
|
? (data.status as 'online' | 'away' | 'busy' | 'offline')
|
||||||
: 'online';
|
: 'online';
|
||||||
// 'offline' from the server means the user chose Invisible;
|
// '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> {
|
export function buildKnownUserExtras(room: Room | null, identifier: string): Record<string, unknown> {
|
||||||
const knownMember = room ? findRoomMember(room.members ?? [], identifier) : undefined;
|
const knownMember = room ? findRoomMember(room.members ?? [], identifier) : undefined;
|
||||||
|
|
||||||
if (!knownMember) return {};
|
if (!knownMember)
|
||||||
|
return {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
username: knownMember.username,
|
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 {
|
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;
|
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[] {
|
export function normalizeIncomingBans(roomId: string, bans: unknown): BanEntry[] {
|
||||||
if (!Array.isArray(bans)) return [];
|
if (!Array.isArray(bans))
|
||||||
|
return [];
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { normalizeRoomAccessControl } from '../../domains/access-control';
|
|||||||
import { type ServerInfo } from '../../domains/server-directory';
|
import { type ServerInfo } from '../../domains/server-directory';
|
||||||
import { RoomsActions } from './rooms.actions';
|
import { RoomsActions } from './rooms.actions';
|
||||||
import { defaultChannels } from './room-channels.defaults';
|
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';
|
import { pruneRoomMembers } from './room-members.helpers';
|
||||||
|
|
||||||
/** Deduplicate rooms by id, keeping the last occurrence */
|
/** Deduplicate rooms by id, keeping the last occurrence */
|
||||||
@@ -325,7 +329,8 @@ export const roomsReducer = createReducer(
|
|||||||
on(RoomsActions.updateRoom, (state, { roomId, changes }) => {
|
on(RoomsActions.updateRoom, (state, { roomId, changes }) => {
|
||||||
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null);
|
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 });
|
const updatedRoom = enrichRoom({ ...baseRoom, ...changes });
|
||||||
|
|
||||||
@@ -342,7 +347,8 @@ export const roomsReducer = createReducer(
|
|||||||
on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => {
|
on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => {
|
||||||
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null);
|
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 });
|
const updatedRoom = enrichRoom({ ...baseRoom, icon, iconUpdatedAt });
|
||||||
|
|
||||||
@@ -362,7 +368,8 @@ export const roomsReducer = createReducer(
|
|||||||
|
|
||||||
// Receive room update
|
// Receive room update
|
||||||
on(RoomsActions.receiveRoomUpdate, (state, { room }) => {
|
on(RoomsActions.receiveRoomUpdate, (state, { room }) => {
|
||||||
if (!state.currentRoom) return state;
|
if (!state.currentRoom)
|
||||||
|
return state;
|
||||||
|
|
||||||
const updatedRoom = enrichRoom({ ...state.currentRoom, ...room });
|
const updatedRoom = enrichRoom({ ...state.currentRoom, ...room });
|
||||||
|
|
||||||
@@ -403,7 +410,8 @@ export const roomsReducer = createReducer(
|
|||||||
})),
|
})),
|
||||||
|
|
||||||
on(RoomsActions.addChannel, (state, { channel }) => {
|
on(RoomsActions.addChannel, (state, { channel }) => {
|
||||||
if (!state.currentRoom) return state;
|
if (!state.currentRoom)
|
||||||
|
return state;
|
||||||
|
|
||||||
const existing = state.currentRoom.channels || defaultChannels();
|
const existing = state.currentRoom.channels || defaultChannels();
|
||||||
const normalizedName = normalizeChannelName(channel.name);
|
const normalizedName = normalizeChannelName(channel.name);
|
||||||
@@ -424,7 +432,8 @@ export const roomsReducer = createReducer(
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
on(RoomsActions.removeChannel, (state, { channelId }) => {
|
on(RoomsActions.removeChannel, (state, { channelId }) => {
|
||||||
if (!state.currentRoom) return state;
|
if (!state.currentRoom)
|
||||||
|
return state;
|
||||||
|
|
||||||
const existing = state.currentRoom.channels || defaultChannels();
|
const existing = state.currentRoom.channels || defaultChannels();
|
||||||
const updatedChannels = existing.filter((channel) => channel.id !== channelId);
|
const updatedChannels = existing.filter((channel) => channel.id !== channelId);
|
||||||
@@ -439,7 +448,8 @@ export const roomsReducer = createReducer(
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
on(RoomsActions.renameChannel, (state, { channelId, name }) => {
|
on(RoomsActions.renameChannel, (state, { channelId, name }) => {
|
||||||
if (!state.currentRoom) return state;
|
if (!state.currentRoom)
|
||||||
|
return state;
|
||||||
|
|
||||||
const existing = state.currentRoom.channels || defaultChannels();
|
const existing = state.currentRoom.channels || defaultChannels();
|
||||||
const normalizedName = normalizeChannelName(name);
|
const normalizedName = normalizeChannelName(name);
|
||||||
|
|||||||
@@ -47,9 +47,7 @@ import {
|
|||||||
Room,
|
Room,
|
||||||
User
|
User
|
||||||
} from '../../shared-kernel';
|
} from '../../shared-kernel';
|
||||||
import {
|
import { setStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||||
setStoredCurrentUserId
|
|
||||||
} from '../../core/storage/current-user-storage';
|
|
||||||
import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers';
|
import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers';
|
||||||
|
|
||||||
type IncomingModerationExtraAction =
|
type IncomingModerationExtraAction =
|
||||||
@@ -152,6 +150,7 @@ export class UsersEffects {
|
|||||||
private async prepareAuthenticatedUserStorage(userId: string): Promise<void> {
|
private async prepareAuthenticatedUserStorage(userId: string): Promise<void> {
|
||||||
setStoredCurrentUserId(userId);
|
setStoredCurrentUserId(userId);
|
||||||
await this.db.initialize();
|
await this.db.initialize();
|
||||||
|
await this.db.setCurrentUserId(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads all users associated with a specific room from the local database. */
|
/** Loads all users associated with a specific room from the local database. */
|
||||||
|
|||||||
Reference in New Issue
Block a user