From 1656b8a17f21dbf5bf3cdae045a3703348d9545d Mon Sep 17 00:00:00 2001 From: Myx Date: Fri, 24 Apr 2026 22:19:57 +0200 Subject: [PATCH] fix: multiple bug fixes isolated users, db backup, weird disconnect issues for long voice sessions, --- e2e/pages/login.page.ts | 6 +- e2e/pages/register.page.ts | 3 + .../auth/user-session-data-isolation.spec.ts | 269 ++++++++++++++++++ e2e/tests/chat/chat-message-features.spec.ts | 158 ++++++++++ e2e/tests/chat/notifications.spec.ts | 264 +++++++++++++++++ .../cqrs/queries/handlers/getCurrentUserId.ts | 9 + electron/cqrs/queries/index.ts | 3 + electron/cqrs/types.ts | 3 + server/README.md | 2 + server/data/metoyou.sqlite | Bin 94208 -> 172032 bytes server/src/db/database.ts | 93 ++++-- server/src/runtime-paths.ts | 30 ++ toju-app/src/app/app.ts | 62 +++- .../app/core/storage/current-user-storage.ts | 59 ++++ .../src/app/domains/authentication/README.md | 11 +- .../feature/login/login.component.ts | 5 +- .../feature/register/register.component.ts | 5 +- .../src/app/domains/notifications/README.md | 3 +- .../effects/notifications.effects.spec.ts | 34 +++ .../effects/notifications.effects.ts | 35 ++- .../app/domains/server-directory/README.md | 3 +- .../server-endpoint-state.service.spec.ts | 134 +++++++++ .../services/server-endpoint-state.service.ts | 76 ++++- ...rver-directory.infrastructure.constants.ts | 1 + .../server-endpoint-storage.service.ts | 42 ++- .../shell/title-bar/title-bar.component.ts | 11 +- .../app/infrastructure/persistence/README.md | 6 +- .../persistence/app-resume.storage.ts | 14 +- .../persistence/browser-database.service.ts | 87 +++++- .../persistence/database.service.ts | 3 + .../persistence/electron-database.service.ts | 5 + .../messages-incoming.handlers.spec.ts | 104 +++++++ .../messages/messages-incoming.handlers.ts | 15 +- .../store/rooms/room-state-sync.effects.ts | 9 +- .../rooms/rooms-helpers-snapshot.spec.ts | 45 +++ toju-app/src/app/store/rooms/rooms.actions.ts | 2 + toju-app/src/app/store/rooms/rooms.helpers.ts | 27 +- toju-app/src/app/store/rooms/rooms.reducer.ts | 4 + .../store/users/users-status.reducer.spec.ts | 65 +++++ toju-app/src/app/store/users/users.actions.ts | 4 +- toju-app/src/app/store/users/users.effects.ts | 30 ++ toju-app/src/app/store/users/users.reducer.ts | 18 +- 42 files changed, 1671 insertions(+), 88 deletions(-) create mode 100644 e2e/tests/auth/user-session-data-isolation.spec.ts create mode 100644 e2e/tests/chat/notifications.spec.ts create mode 100644 electron/cqrs/queries/handlers/getCurrentUserId.ts create mode 100644 toju-app/src/app/core/storage/current-user-storage.ts create mode 100644 toju-app/src/app/domains/notifications/application/effects/notifications.effects.spec.ts create mode 100644 toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.spec.ts create mode 100644 toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts create mode 100644 toju-app/src/app/store/rooms/rooms-helpers-snapshot.spec.ts diff --git a/e2e/pages/login.page.ts b/e2e/pages/login.page.ts index 9a9f905..6d58d9c 100644 --- a/e2e/pages/login.page.ts +++ b/e2e/pages/login.page.ts @@ -1,6 +1,7 @@ import { type Page, type Locator } from '@playwright/test'; export class LoginPage { + readonly form: Locator; readonly usernameInput: Locator; readonly passwordInput: Locator; readonly serverSelect: Locator; @@ -9,12 +10,13 @@ export class LoginPage { readonly registerLink: Locator; constructor(private page: Page) { + this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]').first(); this.usernameInput = page.locator('#login-username'); this.passwordInput = page.locator('#login-password'); this.serverSelect = page.locator('#login-server'); - this.submitButton = page.getByRole('button', { name: 'Login' }); + this.submitButton = this.form.getByRole('button', { name: 'Login' }); this.errorText = page.locator('.text-destructive'); - this.registerLink = page.getByRole('button', { name: 'Register' }); + this.registerLink = this.form.getByRole('button', { name: 'Register' }); } async goto() { diff --git a/e2e/pages/register.page.ts b/e2e/pages/register.page.ts index 1887b47..6a1aa01 100644 --- a/e2e/pages/register.page.ts +++ b/e2e/pages/register.page.ts @@ -43,8 +43,11 @@ export class RegisterPage { async register(username: string, displayName: string, password: string) { await this.usernameInput.fill(username); + await expect(this.usernameInput).toHaveValue(username); await this.displayNameInput.fill(displayName); + await expect(this.displayNameInput).toHaveValue(displayName); await this.passwordInput.fill(password); + await expect(this.passwordInput).toHaveValue(password); await this.submitButton.click(); } } diff --git a/e2e/tests/auth/user-session-data-isolation.spec.ts b/e2e/tests/auth/user-session-data-isolation.spec.ts new file mode 100644 index 0000000..e017969 --- /dev/null +++ b/e2e/tests/auth/user-session-data-isolation.spec.ts @@ -0,0 +1,269 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + chromium, + type BrowserContext, + type Page +} from '@playwright/test'; +import { test, expect } from '../../fixtures/multi-client'; +import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint'; +import { ChatMessagesPage } from '../../pages/chat-messages.page'; +import { LoginPage } from '../../pages/login.page'; +import { RegisterPage } from '../../pages/register.page'; +import { ServerSearchPage } from '../../pages/server-search.page'; + +interface TestUser { + username: string; + displayName: string; + password: string; +} + +interface PersistentClient { + context: BrowserContext; + page: Page; + userDataDir: string; +} + +const CLIENT_LAUNCH_ARGS = [ + '--use-fake-device-for-media-stream', + '--use-fake-ui-for-media-stream' +]; + +test.describe('User session data isolation', () => { + test.describe.configure({ timeout: 240_000 }); + + test('preserves a user saved rooms and local history across app restarts', async ({ testServer }) => { + const suffix = uniqueName('persist'); + const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-auth-persist-')); + const alice: TestUser = { + username: `alice_${suffix}`, + displayName: 'Alice', + password: 'TestPass123!' + }; + const aliceServerName = `Alice Session Server ${suffix}`; + const aliceMessage = `Alice persisted message ${suffix}`; + let client: PersistentClient | null = null; + + try { + client = await launchPersistentClient(userDataDir, testServer.port); + + await test.step('Alice registers and creates local chat history', async () => { + await registerUser(client.page, alice); + await createServerAndSendMessage(client.page, aliceServerName, aliceMessage); + }); + + await test.step('Alice sees the same saved room and message after a full restart', async () => { + await restartPersistentClient(client, testServer.port); + await openApp(client.page); + await expect(client.page).not.toHaveURL(/\/login/, { timeout: 15_000 }); + await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage); + }); + } finally { + await closePersistentClient(client); + await rm(userDataDir, { recursive: true, force: true }); + } + }); + + test('gives a new user a blank slate and restores only that user local data after account switches', async ({ testServer }) => { + const suffix = uniqueName('isolation'); + const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-auth-isolation-')); + const alice: TestUser = { + username: `alice_${suffix}`, + displayName: 'Alice', + password: 'TestPass123!' + }; + const bob: TestUser = { + username: `bob_${suffix}`, + displayName: 'Bob', + password: 'TestPass123!' + }; + const aliceServerName = `Alice Private Server ${suffix}`; + const bobServerName = `Bob Private Server ${suffix}`; + const aliceMessage = `Alice history ${suffix}`; + const bobMessage = `Bob history ${suffix}`; + let client: PersistentClient | null = null; + + try { + client = await launchPersistentClient(userDataDir, testServer.port); + + await test.step('Alice creates persisted local data and verifies it survives a restart', async () => { + await registerUser(client.page, alice); + await createServerAndSendMessage(client.page, aliceServerName, aliceMessage); + + await restartPersistentClient(client, testServer.port); + await openApp(client.page); + await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage); + }); + + await test.step('Bob starts from a blank slate in the same browser profile', async () => { + await logoutUser(client.page); + await registerUser(client.page, bob); + await expectBlankSlate(client.page, [aliceServerName]); + }); + + await test.step('Bob gets only his own saved room and history after a restart', async () => { + await createServerAndSendMessage(client.page, bobServerName, bobMessage); + + await restartPersistentClient(client, testServer.port); + await openApp(client.page); + await expectSavedRoomAndHistory(client.page, bobServerName, bobMessage); + await expectSavedRoomHidden(client.page, aliceServerName); + }); + + await test.step('When Alice logs back in she sees only Alice local data, not Bob data', async () => { + await logoutUser(client.page); + await restartPersistentClient(client, testServer.port); + await loginUser(client.page, alice); + + await expectSavedRoomVisible(client.page, aliceServerName); + await expectSavedRoomHidden(client.page, bobServerName); + await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage); + }); + } finally { + await closePersistentClient(client); + await rm(userDataDir, { recursive: true, force: true }); + } + }); +}); + +async function launchPersistentClient(userDataDir: string, testServerPort: number): Promise { + const context = await chromium.launchPersistentContext(userDataDir, { + args: CLIENT_LAUNCH_ARGS, + baseURL: 'http://localhost:4200', + permissions: ['microphone', 'camera'] + }); + + await installTestServerEndpoint(context, testServerPort); + + const page = context.pages()[0] ?? await context.newPage(); + + return { + context, + page, + userDataDir + }; +} + +async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise { + await client.context.close(); + + const restartedClient = await launchPersistentClient(client.userDataDir, testServerPort); + + client.context = restartedClient.context; + client.page = restartedClient.page; +} + +async function closePersistentClient(client: PersistentClient | null): Promise { + if (!client) { + return; + } + + await client.context.close().catch(() => {}); +} + +async function openApp(page: Page): Promise { + await retryTransientNavigation(() => page.goto('/', { waitUntil: 'domcontentloaded' })); +} + +async function registerUser(page: Page, user: TestUser): Promise { + const registerPage = new RegisterPage(page); + + await retryTransientNavigation(() => registerPage.goto()); + await registerPage.register(user.username, user.displayName, user.password); + await expect(page).toHaveURL(/\/search/, { timeout: 15_000 }); +} + +async function loginUser(page: Page, user: TestUser): Promise { + const loginPage = new LoginPage(page); + + await retryTransientNavigation(() => loginPage.goto()); + await loginPage.login(user.username, user.password); + await expect(page).toHaveURL(/\/(search|room)(\/|$)/, { timeout: 15_000 }); +} + +async function logoutUser(page: Page): Promise { + const menuButton = page.getByRole('button', { name: 'Menu' }); + const logoutButton = page.getByRole('button', { name: 'Logout' }); + const loginPage = new LoginPage(page); + + await expect(menuButton).toBeVisible({ timeout: 10_000 }); + await menuButton.click(); + await expect(logoutButton).toBeVisible({ timeout: 10_000 }); + await logoutButton.click(); + await expect(page).toHaveURL(/\/login/, { timeout: 15_000 }); + await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 }); +} + +async function createServerAndSendMessage(page: Page, serverName: string, messageText: string): Promise { + const searchPage = new ServerSearchPage(page); + const messagesPage = new ChatMessagesPage(page); + + await searchPage.createServer(serverName, { + description: `User session isolation coverage for ${serverName}` + }); + await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 }); + + await messagesPage.sendMessage(messageText); + await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 }); +} + +async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise { + const roomButton = getSavedRoomButton(page, roomName); + const messagesPage = new ChatMessagesPage(page); + + await expect(roomButton).toBeVisible({ timeout: 20_000 }); + await roomButton.click(); + await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); + await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 }); +} + +async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise { + const searchPage = new ServerSearchPage(page); + + await expect(page).toHaveURL(/\/search/, { timeout: 15_000 }); + await expect(searchPage.createServerButton).toBeVisible({ timeout: 15_000 }); + + for (const roomName of hiddenRoomNames) { + await expectSavedRoomHidden(page, roomName); + } +} + +async function expectSavedRoomVisible(page: Page, roomName: string): Promise { + await expect(getSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 }); +} + +async function expectSavedRoomHidden(page: Page, roomName: string): Promise { + await expect(getSavedRoomButton(page, roomName)).toHaveCount(0); +} + +function getSavedRoomButton(page: Page, roomName: string) { + return page.locator(`button[title="${roomName}"]`).first(); +} + +async function retryTransientNavigation(navigate: () => Promise, attempts = 4): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await navigate(); + } catch (error) { + lastError = error; + + const message = error instanceof Error ? error.message : String(error); + const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET'); + + if (!isTransientNavigationError || attempt === attempts) { + throw error; + } + } + } + + throw lastError instanceof Error + ? lastError + : new Error(`Navigation failed after ${attempts} attempts`); +} + +function uniqueName(prefix: string): string { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} \ No newline at end of file diff --git a/e2e/tests/chat/chat-message-features.spec.ts b/e2e/tests/chat/chat-message-features.spec.ts index e6c7689..548e25d 100644 --- a/e2e/tests/chat/chat-message-features.spec.ts +++ b/e2e/tests/chat/chat-message-features.spec.ts @@ -18,6 +18,81 @@ const DELETED_MESSAGE_CONTENT = '[Message deleted]'; test.describe('Chat messaging features', () => { test.describe.configure({ timeout: 180_000 }); + test('shows per-server channel lists on first saved-server click', async ({ createClient }) => { + const scenario = await createSingleClientChatScenario(createClient); + const alphaServerName = `Alpha Server ${uniqueName('rail')}`; + const betaServerName = `Beta Server ${uniqueName('rail')}`; + const alphaChannelName = uniqueName('alpha-updates'); + const betaChannelName = uniqueName('beta-plans'); + const channelsPanel = scenario.room.channelsSidePanel; + + await test.step('Create first saved server with a unique text channel', async () => { + await createServerAndOpenRoom(scenario.search, scenario.client.page, alphaServerName, 'Rail switch alpha server'); + await scenario.room.ensureTextChannelExists(alphaChannelName); + await expect( + channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`) + ).toBeVisible({ timeout: 20_000 }); + }); + + await test.step('Create second saved server with a different text channel', async () => { + await createServerAndOpenRoom(scenario.search, scenario.client.page, betaServerName, 'Rail switch beta server'); + await scenario.room.ensureTextChannelExists(betaChannelName); + await expect( + channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`) + ).toBeVisible({ timeout: 20_000 }); + }); + + await test.step('Opening first server once restores only its channels', async () => { + await openSavedRoomByName(scenario.client.page, alphaServerName); + await expect( + channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`) + ).toBeVisible({ timeout: 20_000 }); + await expect( + channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`) + ).toHaveCount(0); + }); + + await test.step('Opening second server once restores only its channels', async () => { + await openSavedRoomByName(scenario.client.page, betaServerName); + await expect( + channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`) + ).toBeVisible({ timeout: 20_000 }); + await expect( + channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`) + ).toHaveCount(0); + }); + }); + + test('shows local room history on first saved-server click', async ({ createClient }) => { + const scenario = await createSingleClientChatScenario(createClient); + const alphaServerName = `History Alpha ${uniqueName('rail')}`; + const betaServerName = `History Beta ${uniqueName('rail')}`; + const alphaMessage = `Alpha history message ${uniqueName('msg')}`; + const betaMessage = `Beta history message ${uniqueName('msg')}`; + + await test.step('Create first server and send a local message', async () => { + await createServerAndOpenRoom(scenario.search, scenario.client.page, alphaServerName, 'Rail history alpha server'); + await scenario.messages.sendMessage(alphaMessage); + await expect(scenario.messages.getMessageItemByText(alphaMessage)).toBeVisible({ timeout: 20_000 }); + }); + + await test.step('Create second server and send a different local message', async () => { + await createServerAndOpenRoom(scenario.search, scenario.client.page, betaServerName, 'Rail history beta server'); + await scenario.messages.sendMessage(betaMessage); + await expect(scenario.messages.getMessageItemByText(betaMessage)).toBeVisible({ timeout: 20_000 }); + }); + + await test.step('Opening first server once restores its history immediately', async () => { + await openSavedRoomByName(scenario.client.page, alphaServerName); + await expect(scenario.messages.getMessageItemByText(alphaMessage)).toBeVisible({ timeout: 20_000 }); + }); + + await test.step('Opening second server once restores its history immediately', async () => { + await openSavedRoomByName(scenario.client.page, betaServerName); + await expect(scenario.messages.getMessageItemByText(betaMessage)).toBeVisible({ timeout: 20_000 }); + }); + }); + test('syncs messages in a newly created text channel', async ({ createClient }) => { const scenario = await createChatScenario(createClient); const channelName = uniqueName('updates'); @@ -143,6 +218,43 @@ interface ChatScenario { bobMessages: ChatMessagesPage; } +interface SingleClientChatScenario { + client: Client; + messages: ChatMessagesPage; + room: ChatRoomPage; + search: ServerSearchPage; +} + +async function createSingleClientChatScenario(createClient: () => Promise): Promise { + const suffix = uniqueName('solo'); + const client = await createClient(); + const credentials = { + username: `solo_${suffix}`, + displayName: 'Solo', + password: 'TestPass123!' + }; + + await installChatFeatureMocks(client.page); + + const registerPage = new RegisterPage(client.page); + + await registerPage.goto(); + await registerPage.register( + credentials.username, + credentials.displayName, + credentials.password + ); + + await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 }); + + return { + client, + messages: new ChatMessagesPage(client.page), + room: new ChatRoomPage(client.page), + search: new ServerSearchPage(client.page) + }; +} + async function createChatScenario(createClient: () => Promise): Promise { const suffix = uniqueName('chat'); const serverName = `Chat Server ${suffix}`; @@ -217,6 +329,52 @@ async function createChatScenario(createClient: () => Promise): Promise< }; } +async function createServerAndOpenRoom( + searchPage: ServerSearchPage, + page: Page, + serverName: string, + description: string +): Promise { + await searchPage.createServer(serverName, { description }); + await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 }); + await waitForCurrentRoomName(page, serverName); +} + +async function openSavedRoomByName(page: Page, roomName: string): Promise { + const roomButton = page.locator(`button[title="${roomName}"]`); + + await expect(roomButton).toBeVisible({ timeout: 20_000 }); + await roomButton.click(); + await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); + await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); + await waitForCurrentRoomName(page, roomName); +} + +async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise { + await page.waitForFunction( + (expectedRoomName) => { + interface RoomShape { name?: string } + interface AngularDebugApi { + getComponent: (element: Element) => Record; + } + + const host = document.querySelector('app-rooms-side-panel'); + const debugApi = (window as { ng?: AngularDebugApi }).ng; + + if (!host || !debugApi?.getComponent) { + return false; + } + + const component = debugApi.getComponent(host); + const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null; + + return currentRoom?.name === expectedRoomName; + }, + roomName, + { timeout } + ); +} + async function installChatFeatureMocks(page: Page): Promise { await page.route('**/api/klipy/config', async (route) => { await route.fulfill({ diff --git a/e2e/tests/chat/notifications.spec.ts b/e2e/tests/chat/notifications.spec.ts new file mode 100644 index 0000000..461794d --- /dev/null +++ b/e2e/tests/chat/notifications.spec.ts @@ -0,0 +1,264 @@ +import { + expect, + type Locator, + type Page +} from '@playwright/test'; +import { + test, + type Client +} from '../../fixtures/multi-client'; +import { RegisterPage } from '../../pages/register.page'; +import { ServerSearchPage } from '../../pages/server-search.page'; +import { ChatRoomPage } from '../../pages/chat-room.page'; +import { ChatMessagesPage } from '../../pages/chat-messages.page'; + +interface DesktopNotificationRecord { + title: string; + body: string; +} + +interface NotificationScenario { + alice: Client; + bob: Client; + aliceRoom: ChatRoomPage; + bobRoom: ChatRoomPage; + bobMessages: ChatMessagesPage; + serverName: string; + channelName: string; +} + +test.describe('Chat notifications', () => { + test.describe.configure({ timeout: 180_000 }); + + test('shows desktop notifications and unread badges for inactive channels', async ({ createClient }) => { + const scenario = await createNotificationScenario(createClient); + const message = `Background notification ${uniqueName('msg')}`; + + await test.step('Bob sends a message to Alice\'s inactive channel', async () => { + await clearDesktopNotifications(scenario.alice.page); + await scenario.bobRoom.joinTextChannel(scenario.channelName); + await scenario.bobMessages.sendMessage(message); + }); + + await test.step('Alice receives a desktop notification with the channel preview', async () => { + const notification = await waitForDesktopNotification(scenario.alice.page); + + expect(notification).toEqual({ + title: `${scenario.serverName} ยท #${scenario.channelName}`, + body: `Bob: ${message}` + }); + }); + + await test.step('Alice sees unread badges for the room and the inactive channel', async () => { + await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 }); + await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 }); + }); + }); + + test('keeps unread badges visible when a muted channel suppresses desktop popups', async ({ createClient }) => { + const scenario = await createNotificationScenario(createClient); + const message = `Muted notification ${uniqueName('msg')}`; + + await test.step('Alice mutes the inactive text channel', async () => { + await muteTextChannel(scenario.alice.page, scenario.channelName); + await clearDesktopNotifications(scenario.alice.page); + }); + + await test.step('Bob sends a message into the muted channel', async () => { + await scenario.bobRoom.joinTextChannel(scenario.channelName); + await scenario.bobMessages.sendMessage(message); + }); + + await test.step('Alice still sees unread badges for the room and channel', async () => { + await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 }); + await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 }); + }); + + await test.step('Alice does not get a muted desktop popup', async () => { + const notificationAppeared = await waitForAnyDesktopNotification(scenario.alice.page, 1_500); + + expect(notificationAppeared).toBe(false); + }); + }); +}); + +async function createNotificationScenario(createClient: () => Promise): Promise { + const suffix = uniqueName('notify'); + const serverName = `Notifications Server ${suffix}`; + const channelName = uniqueName('updates'); + const aliceCredentials = { + username: `alice_${suffix}`, + displayName: 'Alice', + password: 'TestPass123!' + }; + const bobCredentials = { + username: `bob_${suffix}`, + displayName: 'Bob', + password: 'TestPass123!' + }; + const alice = await createClient(); + const bob = await createClient(); + + await installDesktopNotificationSpy(alice.page); + + await registerUser(alice.page, aliceCredentials.username, aliceCredentials.displayName, aliceCredentials.password); + await registerUser(bob.page, bobCredentials.username, bobCredentials.displayName, bobCredentials.password); + + const aliceSearch = new ServerSearchPage(alice.page); + + await aliceSearch.createServer(serverName, { + description: 'E2E notification coverage server' + }); + await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 }); + + const bobSearch = new ServerSearchPage(bob.page); + const serverCard = bob.page.locator('button', { hasText: serverName }).first(); + + await bobSearch.searchInput.fill(serverName); + await expect(serverCard).toBeVisible({ timeout: 15_000 }); + await serverCard.click(); + await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); + + const aliceRoom = new ChatRoomPage(alice.page); + const bobRoom = new ChatRoomPage(bob.page); + const aliceMessages = new ChatMessagesPage(alice.page); + const bobMessages = new ChatMessagesPage(bob.page); + + await aliceMessages.waitForReady(); + await bobMessages.waitForReady(); + await aliceRoom.ensureTextChannelExists(channelName); + await expect(getTextChannelButton(alice.page, channelName)).toBeVisible({ timeout: 20_000 }); + + return { + alice, + bob, + aliceRoom, + bobRoom, + bobMessages, + serverName, + channelName + }; +} + +async function registerUser(page: Page, username: string, displayName: string, password: string): Promise { + const registerPage = new RegisterPage(page); + + await registerPage.goto(); + await registerPage.register(username, displayName, password); + await expect(page).toHaveURL(/\/search/, { timeout: 15_000 }); +} + +async function installDesktopNotificationSpy(page: Page): Promise { + await page.addInitScript(() => { + const notifications: DesktopNotificationRecord[] = []; + + class MockNotification { + static permission = 'granted'; + + static async requestPermission(): Promise { + return 'granted'; + } + + onclick: (() => void) | null = null; + + constructor(title: string, options?: NotificationOptions) { + notifications.push({ + title, + body: options?.body ?? '' + }); + } + + close(): void { + return; + } + } + + Object.defineProperty(window, '__desktopNotifications', { + value: notifications, + configurable: true + }); + + Object.defineProperty(window, 'Notification', { + value: MockNotification, + configurable: true, + writable: true + }); + }); +} + +async function clearDesktopNotifications(page: Page): Promise { + await page.evaluate(() => { + (window as WindowWithDesktopNotifications).__desktopNotifications.length = 0; + }); +} + +async function waitForDesktopNotification(page: Page): Promise { + await expect.poll( + async () => (await readDesktopNotifications(page)).length, + { + timeout: 20_000, + message: 'Expected a desktop notification to be emitted' + } + ).toBeGreaterThan(0); + + const notifications = await readDesktopNotifications(page); + + return notifications[notifications.length - 1]; +} + +async function waitForAnyDesktopNotification(page: Page, timeout: number): Promise { + try { + await page.waitForFunction( + () => (window as WindowWithDesktopNotifications).__desktopNotifications.length > 0, + undefined, + { timeout } + ); + + return true; + } catch (error) { + if (error instanceof Error && error.name === 'TimeoutError') { + return false; + } + + throw error; + } +} + +async function readDesktopNotifications(page: Page): Promise { + return page.evaluate(() => { + return [...(window as WindowWithDesktopNotifications).__desktopNotifications]; + }); +} + +async function muteTextChannel(page: Page, channelName: string): Promise { + const channelButton = getTextChannelButton(page, channelName); + const contextMenu = page.locator('app-context-menu'); + + await expect(channelButton).toBeVisible({ timeout: 20_000 }); + await channelButton.click({ button: 'right' }); + await expect(contextMenu.getByRole('button', { name: 'Mute Notifications' })).toBeVisible({ timeout: 10_000 }); + await contextMenu.getByRole('button', { name: 'Mute Notifications' }).click(); + await expect(contextMenu).toHaveCount(0); +} + +function getSavedRoomButton(page: Page, roomName: string): Locator { + return page.locator(`button[title="${roomName}"]`).first(); +} + +function getTextChannelButton(page: Page, channelName: string): Locator { + return page.locator('app-rooms-side-panel').first() + .locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`) + .first(); +} + +function getUnreadBadge(container: Locator): Locator { + return container.locator('span.rounded-full').first(); +} + +function uniqueName(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +interface WindowWithDesktopNotifications extends Window { + __desktopNotifications: DesktopNotificationRecord[]; +} diff --git a/electron/cqrs/queries/handlers/getCurrentUserId.ts b/electron/cqrs/queries/handlers/getCurrentUserId.ts new file mode 100644 index 0000000..b9cac48 --- /dev/null +++ b/electron/cqrs/queries/handlers/getCurrentUserId.ts @@ -0,0 +1,9 @@ +import { DataSource } from 'typeorm'; +import { MetaEntity } from '../../../entities'; + +export async function handleGetCurrentUserId(dataSource: DataSource): Promise { + const metaRepo = dataSource.getRepository(MetaEntity); + const metaRow = await metaRepo.findOne({ where: { key: 'currentUserId' } }); + + return metaRow?.value?.trim() || null; +} \ No newline at end of file diff --git a/electron/cqrs/queries/index.ts b/electron/cqrs/queries/index.ts index cdb5465..85bf63b 100644 --- a/electron/cqrs/queries/index.ts +++ b/electron/cqrs/queries/index.ts @@ -8,6 +8,7 @@ import { GetMessageByIdQuery, GetReactionsForMessageQuery, GetUserQuery, + GetCurrentUserIdQuery, GetRoomQuery, GetBansForRoomQuery, IsUserBannedQuery, @@ -19,6 +20,7 @@ import { handleGetMessageById } from './handlers/getMessageById'; import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage'; import { handleGetUser } from './handlers/getUser'; import { handleGetCurrentUser } from './handlers/getCurrentUser'; +import { handleGetCurrentUserId } from './handlers/getCurrentUserId'; import { handleGetUsersByRoom } from './handlers/getUsersByRoom'; import { handleGetRoom } from './handlers/getRoom'; import { handleGetAllRooms } from './handlers/getAllRooms'; @@ -34,6 +36,7 @@ export const buildQueryHandlers = (dataSource: DataSource): Record handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource), [QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource), [QueryType.GetCurrentUser]: () => handleGetCurrentUser(dataSource), + [QueryType.GetCurrentUserId]: () => handleGetCurrentUserId(dataSource), [QueryType.GetUsersByRoom]: () => handleGetUsersByRoom(dataSource), [QueryType.GetRoom]: (query) => handleGetRoom(query as GetRoomQuery, dataSource), [QueryType.GetAllRooms]: () => handleGetAllRooms(dataSource), diff --git a/electron/cqrs/types.ts b/electron/cqrs/types.ts index e1e714b..5c30f1e 100644 --- a/electron/cqrs/types.ts +++ b/electron/cqrs/types.ts @@ -27,6 +27,7 @@ export const QueryType = { GetReactionsForMessage: 'get-reactions-for-message', GetUser: 'get-user', GetCurrentUser: 'get-current-user', + GetCurrentUserId: 'get-current-user-id', GetUsersByRoom: 'get-users-by-room', GetRoom: 'get-room', GetAllRooms: 'get-all-rooms', @@ -214,6 +215,7 @@ export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; pa export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } } export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } } export interface GetCurrentUserQuery { type: typeof QueryType.GetCurrentUser; payload: Record } +export interface GetCurrentUserIdQuery { type: typeof QueryType.GetCurrentUserId; payload: Record } export interface GetUsersByRoomQuery { type: typeof QueryType.GetUsersByRoom; payload: { roomId: string } } export interface GetRoomQuery { type: typeof QueryType.GetRoom; payload: { roomId: string } } export interface GetAllRoomsQuery { type: typeof QueryType.GetAllRooms; payload: Record } @@ -229,6 +231,7 @@ export type Query = | GetReactionsForMessageQuery | GetUserQuery | GetCurrentUserQuery + | GetCurrentUserIdQuery | GetUsersByRoomQuery | GetRoomQuery | GetAllRoomsQuery diff --git a/server/README.md b/server/README.md index 0ad24bb..f5813f1 100644 --- a/server/README.md +++ b/server/README.md @@ -18,7 +18,9 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi - The server loads the repository-root `.env` file on startup. - `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port. +- `DB_PATH` can override the SQLite database file location. - `data/variables.json` is normalized on startup and stores `klipyApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`. +- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable. - When HTTPS is enabled, certificates are read from the repository `.certs/` directory. ## Structure diff --git a/server/data/metoyou.sqlite b/server/data/metoyou.sqlite index d146c46745675cefda6c6dfecf2354aae3785466..a9ce375095c6b3dcdf90de3fe6d8dfe8780a4324 100644 GIT binary patch delta 8908 zcmcgyYj7Lab>0OQPws=H1cD$$f+9tcl(^!)0PM=PD%z1F#b#_%LTit!(7V_L#h3&n zfKsK{C5hN|GM$M^c{Ab2W7YAa)22Tv$4u){>rOm%+IpHY$xNF><3y3GOx#TEI_XSC z$;5WubMG!5e85J3NEqVcKF*%U_nmw1?p-dv>05j?ywufB2MB`L1>f!P^}x6H+=#Rl zdiOjr?1LuBy$2-`=iWP49Q1yR_|~~+hdjwn`$4L|V@{tcO^PL}c*-hHqDBlPgJ1vf zeHK-^Vhogi_SXdW2KQIo7q|>J$o_hzt@WpD#~wdL_%g)L|NYNeeTVV&6NMSeel-yr zO^*0r>^?k3eY#Mb)n}$ZXE{P+#iD+uBnq{UvXV*sWh?1_NuY#2&c8}3+Vn;u>R-%NV$z`&kRK4o=`j<#h-XQZvXPij)l^h(wsHR z>*nlqehd};DM3%>baG@mf6OXQ&$&PP%uL}A8WwA1-Bx)!+n(VQ*eICAe{oe*WkWJ7 zUNOxyuh<`6iBx=;EtpnOpDPr{E0umxFf>&pUf)!Iy=Q9$u%BzY+*WxT)1KL#hz9`( z=M#;R(Rw5-p!Q55Z|$#C`cp<)QiV;`HPN{avvM1~k%_dRWo>Jrw}QltVWVXu8^|!? zgEwSd1vZW^++;Swo3WuURP(Yz!VP01v>2EMQ=>K9batZclu;ZVlsU(#duzzu7psjv`=QJwAquz+yTitS5 zmCEc!BU{~(%EHTFMGq~kRYYS&(7QoV3swXp>lKBuqOqT>RTRRCpm&3!AXZd2Qt>E& z6p?ruwATmcN2;9uj`iXBkTPd%yc9q7o;0WmA=TzP$&? z3$K3l+drM}axCOTNtCTrmN#V??zWPqz#G|=#+!yLidM?VrbQXu{4{NVe;FJ2`FzYV zV40Gp=(5F2k}C5`3gXbSU?r=VR??6KD<{Y{>>y1W=zRqn_|N%H*MI~e%Md{>n*#%~ zCiALgn!Kv&sjMW5f~A>=jHV3)MzMjP%|~4lw45eLrozLWX^L0$lnT%$P2SKn5tf@= zT9#EDfh3EDsF@OPX&P(?imvdQf_PK4oFr$H;EfC%+{1kvl^yTRcQ^(VF=b{`g2?A& z(*Of%4me0_ypT*=MmjARsicTGXwtx^-`fucpq!6526Q2rO{;>&=TfrBE25GF4svPU z6a)pFO3A9BI%(9Th4EW|gG2b?{1(T8l!6R1^)wF1(76SA_bnTn-I0wzIB zr!82e4c=065Np;<@wy=ac`4aabGan=k|QrH?>qZ7^1^xfxA)C&bquJct|$iNjgl6j z$XZ!mPm2XaFUl~%6h#0QOvorhH-Ty0N@}u%@IjUvHjre6mVtW2YYkD5mj zB-zY#*n$L`N$aghus%KSY?PmENV1tUYej-~@Y0Tzw^w7HBuWVG3GOI6#Uz-6^dsyw z_7c0qU4}&1$2>*9Ku<95Ft5?8^f0HORAElg*J;Rye};VMA&1Dg{mzvkvdg}Bb%>1F zN1^P*^(gM|aD*awU<)p{I$dp8sufACynV%Wgn5cN0hxIj98%azVE7;yTcuy%E^eL+f(+D z0k|QG`FxU^Q#81B;6*i^g{>^9@rG)_re=snN{|Fm5)`~l?{6l8yHDozVa1>)U2O3-_yF&3-l% z-HNRC^$ztp0*B1LVdv0O-RMsFp=co5-%maknA43Jt4?KIwbR~CZVlp7Qc!C@6OPzh zf}Nq`g%TVp7V>>I+0yMhrO%wS?5~Hrci%g4Xnbtqf!@0gJW%h%NN->Dis5iC{;VAL zhQbt3IlA(P%;TN~=3as&Bro07@}ri+zA4`xYQ_6CZ_e`r@)qJPLJI8-1-Wl>-y94? z9~>jghY6xAR_g1a${y46d243!xK*5;E|tK~(t6}Jh`5xIu!(H-ym^zc!N)oW0@1NC@{ew*XJQjcmzYgPcHS{@VC=|&-XmkT-))bO zpWMjL% zM0W;IR4(8P=qMfPy+;n*e`KUeVPmY35eH6JOBKp9+XKS6Ka^;suB6WPK{p|~EO+L~kkk7rwGx^x)7Ch4=gJwq*dead}1(`Ucp=}fpU z0S0UE_RZ=@XaZ#ymg#CJbB8_d?d@qed?<@n$_7>*p3XizZp|82(Xq0b8bi;2oQUJ> z?RwtP(zl5-rZr>DS>slz1YtR1-J6JgwlJOl-NJMhpl#rC>6l)$4rhy&)i|t^$7c$< zdFONmynP!jHO^m@4v@dqtmTL8N0^bmUE~+Nk9Q0O;4Ku?>T;>%q)}-Y=GadAGxUyH zYS_<^jC~Pqc41E0Z;?axGTR=hBYC=XZ*lsRJ_pfudjz}3GhmegZCYW9j(MY2rx_~!6-0JSyS|F+4-Ji4ycQweWtU;KaH-$aHU9{}263Hc&Z7h7pE zZ=IfVxyN;s0Fel;u31B-VP88+58TwW@TZ6Is<^=PMv<{=e{n02o(Ct2z<&%aeq)bUZh2|zMJh%A$neIpg zNpxyCE0;8r>9J3Yjn9EO?_M7aZgB<`DVc^7pX5R73>x#Ols^;O5)Gjvs}9hu=N=xr z^DeA(zt^AH9*+jmP!9}ES$TM&JM&5B2aD|Wz}2w`zPhuh=gq>bQzG5BlKxCQ9>LcS zxfi=XXc*mT2iWHc2=y}eJ?^XQ^W3f63QbF4}-dWx7!(Qwh*t?WsiN z8>KQaB-7%ALdjHSJ8E+^|GgC%+Hutb?KqC0+x5=z>>4~)z+)^tY2)YUYv9?xviL~F zRu^`V0sOe^$E6RK6fV7}^m)(|1p=-e{SN`r3Xkl^*dMa{na|Q!LD4GBm4iMyvxUfH zf@IL=Bao~;`S9JHiLl|Ar(A5@$9sGNfleLZpbDb}A_PFt z`n&X#A(F;r3ogU>P$7iNAT9&=w856I?pgkF54nS+@EL>`l`g8_C_0;eo>~-GAN>Zw zO|jo+Z)0-w8({Wzy0skgF&J3_papCz(83vF9kfR%7DHQRTq$@>ZGUApWB+z`f44+l z@ErUuocww|#m34JFR%cJSM<4%cddb(^Dr24EM&i#KfP@Y*c~K;!LI0V(O&sgfo}aj D!-Ulo delta 1154 zcma)*T}V@57{||hc2@Ij=Y7w#xwOPJ7A9=5&3!m8gh8Kv6bMI(v5UE#BeUE@mn3O! z5bG+Mo{L?S3%u+i!;!Kw2!sO5Fv19t3U9Tju<#=Rc%J9~d;ZVMd*1jn zI{rzSDlb-c5Coyew|ucfX8Qr0J)W#)aIO?62;@UW%*If6m1tx;vI?*Ub?3OIrmZ@K zI07x82k0i^vRcDQl;tY#vw#}6QdX!`R})BpZ($u1P*-P;}x5%bJpC62zop<)*=R3_$-!)@6WUMo`^AWx~aM@@!MgpDuABk9Bmz0R{ zS7W%`73~Sf!qJGa&N^V;fx82dP_(na=HtJhgIlaE&-Ml_ySz{5huLY+8x05fJ5sOdjwu^hMevK={@0X{s&r~7wa8+7aA$qTy z(mfmCLIvLQyEEntl%9*r36|7wdYvpw2P%OnO>cshZ5`?Qf^|n{ClY#~;VKWwIx+Ck zl)keGE@HdM)!U?ooiLD;RRN0#asR`LtdU(*4d?o)M(y(nF`oNHHL~C5#Q0;JVSgMR zeuCd6PZz|v^ilfq#rPCO86Y!Fp!djulFT$xLmgaVR?rwsAwO(~IarT>=X=cXD@s)5 zOxfkxGIE1YP>8}xFwXNY3n&dc6r&L-!0hi!%xGilSchKVS2qBBd77SW= zyATwFHg~Nn6qI&FoSQP0H(%-D!3fz9p9dq(nS=ARboy~)n0iEm*T{~eWB=2zz)jLD c1}O=YTxhw1GLy6ctRN|-x6MZ5*3lIE8zje74*&oF diff --git a/server/src/db/database.ts b/server/src/db/database.ts index f8d9c88..2252f83 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -17,7 +17,21 @@ import { ServerBanEntity } from '../entities'; import { serverMigrations } from '../migrations'; -import { findExistingPath, resolveRuntimePath } from '../runtime-paths'; +import { + findExistingPath, + isPackagedRuntime, + resolvePersistentDataPath, + resolveRuntimePath +} from '../runtime-paths'; + +const LEGACY_PACKAGED_DB_FILE = path.join(resolveRuntimePath('data'), 'metoyou.sqlite'); +const LEGACY_PACKAGED_DB_BACKUP = LEGACY_PACKAGED_DB_FILE + '.bak'; + +function resolveDefaultDbFile(): string { + return isPackagedRuntime() + ? resolvePersistentDataPath('metoyou.sqlite') + : LEGACY_PACKAGED_DB_FILE; +} function resolveDbFile(): string { const envPath = process.env.DB_PATH; @@ -26,7 +40,7 @@ function resolveDbFile(): string { return path.resolve(envPath); } - return path.join(resolveRuntimePath('data'), 'metoyou.sqlite'); + return resolveDefaultDbFile(); } const DB_FILE = resolveDbFile(); @@ -37,6 +51,55 @@ const SQLITE_MAGIC = 'SQLite format 3\0'; let applicationDataSource: DataSource | undefined; +function restoreFromBackup(reason: string): Uint8Array | undefined { + if (!fs.existsSync(DB_BACKUP)) { + console.error(`[DB] ${reason}. No backup available - starting with a fresh database`); + + return undefined; + } + + const backup = new Uint8Array(fs.readFileSync(DB_BACKUP)); + + if (!isValidSqlite(backup)) { + console.error(`[DB] ${reason}. Backup is also invalid - starting with a fresh database`); + + return undefined; + } + + fs.copyFileSync(DB_BACKUP, DB_FILE); + console.warn('[DB] Restored database from backup', DB_BACKUP); + + return backup; +} + +async function migrateLegacyPackagedDatabase(): Promise { + if (process.env.DB_PATH || !isPackagedRuntime() || path.resolve(DB_FILE) === path.resolve(LEGACY_PACKAGED_DB_FILE)) { + return; + } + + let migrated = false; + + if (!fs.existsSync(DB_FILE)) { + if (fs.existsSync(LEGACY_PACKAGED_DB_FILE)) { + await fsp.copyFile(LEGACY_PACKAGED_DB_FILE, DB_FILE); + migrated = true; + } else if (fs.existsSync(LEGACY_PACKAGED_DB_BACKUP)) { + await fsp.copyFile(LEGACY_PACKAGED_DB_BACKUP, DB_FILE); + migrated = true; + } + } + + if (!fs.existsSync(DB_BACKUP) && fs.existsSync(LEGACY_PACKAGED_DB_BACKUP)) { + await fsp.copyFile(LEGACY_PACKAGED_DB_BACKUP, DB_BACKUP); + migrated = true; + } + + if (migrated) { + console.log('[DB] Migrated packaged database files to:', DATA_DIR); + console.log('[DB] Legacy packaged database location was:', LEGACY_PACKAGED_DB_FILE); + } +} + /** * Returns true when `data` looks like a valid SQLite file * (correct header magic and at least one complete page). @@ -56,8 +119,11 @@ function isValidSqlite(data: Uint8Array): boolean { * restore the backup before the server loads the database. */ function safeguardDbFile(): Uint8Array | undefined { - if (!fs.existsSync(DB_FILE)) - return undefined; + if (!fs.existsSync(DB_FILE)) { + console.warn(`[DB] ${DB_FILE} is missing - checking backup`); + + return restoreFromBackup('Database file missing'); + } const data = new Uint8Array(fs.readFileSync(DB_FILE)); @@ -72,22 +138,7 @@ function safeguardDbFile(): Uint8Array | undefined { // The main file is corrupt or empty. console.warn(`[DB] ${DB_FILE} appears corrupt (${data.length} bytes) - checking backup`); - if (fs.existsSync(DB_BACKUP)) { - const backup = new Uint8Array(fs.readFileSync(DB_BACKUP)); - - if (isValidSqlite(backup)) { - fs.copyFileSync(DB_BACKUP, DB_FILE); - console.warn('[DB] Restored database from backup', DB_BACKUP); - - return backup; - } - - console.error('[DB] Backup is also invalid - starting with a fresh database'); - } else { - console.error('[DB] No backup available - starting with a fresh database'); - } - - return undefined; + return restoreFromBackup(`Database file is invalid (${data.length} bytes)`); } function resolveSqlJsConfig(): { locateFile: (file: string) => string } { @@ -132,6 +183,8 @@ export async function initDatabase(): Promise { if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); + await migrateLegacyPackagedDatabase(); + const database = safeguardDbFile(); try { diff --git a/server/src/runtime-paths.ts b/server/src/runtime-paths.ts index e9801ef..6a024ca 100644 --- a/server/src/runtime-paths.ts +++ b/server/src/runtime-paths.ts @@ -1,6 +1,9 @@ import fs from 'fs'; +import os from 'os'; import path from 'path'; +const PACKAGED_DATA_DIRECTORY_NAME = 'MetoYou Server'; + type PackagedProcess = NodeJS.Process & { pkg?: unknown }; function uniquePaths(paths: string[]): string[] { @@ -21,6 +24,33 @@ export function resolveRuntimePath(...segments: string[]): string { return path.join(getRuntimeBaseDir(), ...segments); } +function resolvePackagedDataDirectory(): string { + const homeDirectory = os.homedir(); + + switch (process.platform) { + case 'win32': + return path.join( + process.env.APPDATA || path.join(homeDirectory, 'AppData', 'Roaming'), + PACKAGED_DATA_DIRECTORY_NAME + ); + case 'darwin': + return path.join(homeDirectory, 'Library', 'Application Support', PACKAGED_DATA_DIRECTORY_NAME); + default: + return path.join( + process.env.XDG_DATA_HOME || path.join(homeDirectory, '.local', 'share'), + PACKAGED_DATA_DIRECTORY_NAME + ); + } +} + +export function resolvePersistentDataPath(...segments: string[]): string { + if (!isPackagedRuntime()) { + return resolveRuntimePath(...segments); + } + + return path.join(resolvePackagedDataDirectory(), ...segments); +} + export function resolveProjectRootPath(...segments: string[]): string { return path.resolve(__dirname, '..', '..', ...segments); } diff --git a/toju-app/src/app/app.ts b/toju-app/src/app/app.ts index 02bdbd0..c75154a 100644 --- a/toju-app/src/app/app.ts +++ b/toju-app/src/app/app.ts @@ -44,7 +44,11 @@ import { NativeContextMenuComponent } from './features/shell/native-context-menu import { UsersActions } from './store/users/users.actions'; import { RoomsActions } from './store/rooms/rooms.actions'; import { selectCurrentRoom } from './store/rooms/rooms.selectors'; -import { ROOM_URL_PATTERN, STORAGE_KEY_CURRENT_USER_ID } from './core/constants'; +import { ROOM_URL_PATTERN } from './core/constants'; +import { + clearStoredCurrentUserId, + getStoredCurrentUserId +} from './core/storage/current-user-storage'; import { ThemeNodeDirective, ThemePickerOverlayComponent, @@ -219,8 +223,19 @@ export class App implements OnInit, OnDestroy { void this.desktopUpdates.initialize(); + let currentUserId = getStoredCurrentUserId(); + await this.databaseService.initialize(); + if (currentUserId) { + const persistedUserId = await this.databaseService.getCurrentUserId(); + + if (persistedUserId !== currentUserId) { + clearStoredCurrentUserId(); + currentUserId = null; + } + } + try { const apiBase = this.servers.getApiBaseUrl(); @@ -231,31 +246,28 @@ export class App implements OnInit, OnDestroy { await this.setupDesktopDeepLinks(); - this.store.dispatch(UsersActions.loadCurrentUser()); - this.userStatus.start(); - - this.store.dispatch(RoomsActions.loadRooms()); - - const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID); + const currentUrl = this.getCurrentRouteUrl(); if (!currentUserId) { - if (!this.isPublicRoute(this.router.url)) { + if (!this.isPublicRoute(currentUrl)) { this.router.navigate(['/login'], { queryParams: { - returnUrl: this.router.url + returnUrl: currentUrl } }).catch(() => {}); } } else { - const current = this.router.url; + this.store.dispatch(UsersActions.loadCurrentUser()); + this.store.dispatch(RoomsActions.loadRooms()); + const generalSettings = loadGeneralSettingsFromStorage(); const lastViewedChat = loadLastViewedChatFromStorage(currentUserId); if ( generalSettings.reopenLastViewedChat && lastViewedChat - && (current === '/' || current === '/search') + && (currentUrl === '/' || currentUrl === '/search') ) { this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {}); } @@ -388,9 +400,31 @@ export class App implements OnInit, OnDestroy { } private isPublicRoute(url: string): boolean { - return url === '/login' || - url === '/register' || - url.startsWith('/invite/'); + const path = this.getRoutePath(url); + + return path === '/login' || + path === '/register' || + path.startsWith('/invite/'); + } + + private getCurrentRouteUrl(): string { + if (typeof window === 'undefined') { + return this.router.url; + } + + const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; + + return currentUrl || this.router.url; + } + + private getRoutePath(url: string): string { + if (!url) { + return '/'; + } + + const [path] = url.split(/[?#]/, 1); + + return path || '/'; } private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null { diff --git a/toju-app/src/app/core/storage/current-user-storage.ts b/toju-app/src/app/core/storage/current-user-storage.ts new file mode 100644 index 0000000..a6a7777 --- /dev/null +++ b/toju-app/src/app/core/storage/current-user-storage.ts @@ -0,0 +1,59 @@ +import { STORAGE_KEY_CURRENT_USER_ID } from '../constants'; + +const METOYOU_STORAGE_PREFIX = 'metoyou_'; + +function normaliseStorageUserId(userId?: string | null): string | null { + const trimmedUserId = userId?.trim(); + + return trimmedUserId || null; +} + +export function getStoredCurrentUserId(): string | null { + try { + const raw = normaliseStorageUserId(localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID)); + + return raw || null; + } catch { + return null; + } +} + +export function getUserScopedStorageKey(baseKey: string, userId?: string | null): string { + const scopedUserId = userId === undefined + ? getStoredCurrentUserId() + : normaliseStorageUserId(userId); + + return scopedUserId + ? `${baseKey}__${encodeURIComponent(scopedUserId)}` + : baseKey; +} + +export function setStoredCurrentUserId(userId: string): void { + try { + localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, userId); + } catch {} +} + +export function clearStoredCurrentUserId(): void { + try { + localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID); + } catch {} +} + +export function clearStoredLocalAppData(): void { + try { + const keysToRemove: string[] = []; + + for (let index = 0; index < localStorage.length; index += 1) { + const key = localStorage.key(index); + + if (key?.startsWith(METOYOU_STORAGE_PREFIX)) { + keysToRemove.push(key); + } + } + + for (const key of keysToRemove) { + localStorage.removeItem(key); + } + } catch {} +} \ No newline at end of file diff --git a/toju-app/src/app/domains/authentication/README.md b/toju-app/src/app/domains/authentication/README.md index be1ad1e..41ea7f2 100644 --- a/toju-app/src/app/domains/authentication/README.md +++ b/toju-app/src/app/domains/authentication/README.md @@ -24,7 +24,7 @@ authentication/ ## Service overview -`AuthenticationService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component stores `currentUserId` in localStorage and dispatches `UsersActions.setCurrentUser` into the NgRx store. +`AuthenticationService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component dispatches `UsersActions.authenticateUser`, and the users effects prepare the local persistence boundary before exposing the new user in the NgRx store. ```mermaid graph TD @@ -58,6 +58,7 @@ sequenceDiagram participant SD as ServerDirectoryFacade participant API as Server API participant Store as NgRx Store + participant Effects as UsersEffects User->>Login: Submit credentials Login->>Auth: login(username, password) @@ -66,13 +67,15 @@ sequenceDiagram Auth->>API: POST /api/auth/login API-->>Auth: { userId, displayName } Auth-->>Login: success - Login->>Store: UsersActions.setCurrentUser - Login->>Login: localStorage.setItem(currentUserId) + Login->>Store: UsersActions.authenticateUser + Store->>Effects: prepare persisted user scope + Effects->>Store: reset stale room/user/message state + Effects->>Store: UsersActions.setCurrentUser ``` ## Registration flow -Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same store dispatch happens. +Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same authenticated-user transition runs, switching the browser persistence layer to that user's local scope before the app reloads rooms and user state. ## User bar diff --git a/toju-app/src/app/domains/authentication/feature/login/login.component.ts b/toju-app/src/app/domains/authentication/feature/login/login.component.ts index e518830..43fa381 100644 --- a/toju-app/src/app/domains/authentication/feature/login/login.component.ts +++ b/toju-app/src/app/domains/authentication/feature/login/login.component.ts @@ -15,7 +15,6 @@ import { AuthenticationService } from '../../application/services/authentication import { ServerDirectoryFacade } from '../../../server-directory'; import { UsersActions } from '../../../../store/users/users.actions'; import { User } from '../../../../shared-kernel'; -import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants'; @Component({ selector: 'app-login', @@ -70,9 +69,7 @@ export class LoginComponent { joinedAt: Date.now() }; - try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {} - - this.store.dispatch(UsersActions.setCurrentUser({ user })); + this.store.dispatch(UsersActions.authenticateUser({ user })); const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim(); if (returnUrl?.startsWith('/')) { diff --git a/toju-app/src/app/domains/authentication/feature/register/register.component.ts b/toju-app/src/app/domains/authentication/feature/register/register.component.ts index 9239f25..1287e86 100644 --- a/toju-app/src/app/domains/authentication/feature/register/register.component.ts +++ b/toju-app/src/app/domains/authentication/feature/register/register.component.ts @@ -15,7 +15,6 @@ import { AuthenticationService } from '../../application/services/authentication import { ServerDirectoryFacade } from '../../../server-directory'; import { UsersActions } from '../../../../store/users/users.actions'; import { User } from '../../../../shared-kernel'; -import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants'; @Component({ selector: 'app-register', @@ -72,9 +71,7 @@ export class RegisterComponent { joinedAt: Date.now() }; - try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {} - - this.store.dispatch(UsersActions.setCurrentUser({ user })); + this.store.dispatch(UsersActions.authenticateUser({ user })); const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim(); if (returnUrl?.startsWith('/')) { diff --git a/toju-app/src/app/domains/notifications/README.md b/toju-app/src/app/domains/notifications/README.md index d3775af..0014aef 100644 --- a/toju-app/src/app/domains/notifications/README.md +++ b/toju-app/src/app/domains/notifications/README.md @@ -90,7 +90,8 @@ All effects in this domain are `dispatch: false`. The effect layer never owns no | `hydrateUnreadCounts$` | `loadRoomsSuccess` and `loadCurrentUserSuccess` | Rebuilds unread counts from persisted messages once both rooms and user identity exist | | `markVisibleChannelRead$` | Room activation and channel selection | Clears unread for the current visible channel | | `handleIncomingMessage$` | `MessagesActions.receiveMessage` | Updates unread counts and triggers desktop delivery for live messages | -| `refreshCurrentRoomUnread$` | `loadMessagesSuccess` and `syncMessages` | Recomputes unread counts for the active room from the latest message snapshot | +| `refreshViewedRoomUnread$` | `loadMessagesSuccess` | Recomputes unread counts for the active room from the latest viewed-room snapshot | +| `refreshSyncedRoomUnread$` | `syncMessages` | Recomputes unread counts for every room represented in a sync batch, including background servers | ## Incoming message flow diff --git a/toju-app/src/app/domains/notifications/application/effects/notifications.effects.spec.ts b/toju-app/src/app/domains/notifications/application/effects/notifications.effects.spec.ts new file mode 100644 index 0000000..8e62b81 --- /dev/null +++ b/toju-app/src/app/domains/notifications/application/effects/notifications.effects.spec.ts @@ -0,0 +1,34 @@ +import { type Message } from '../../../../shared-kernel'; +import { groupMessagesByRoom } from './notifications.effects'; + +function createMessage(overrides: Partial = {}): Message { + return { + id: 'message-1', + roomId: 'room-1', + senderId: 'user-1', + senderName: 'User 1', + content: 'hello', + timestamp: 1, + reactions: [], + isDeleted: false, + ...overrides + }; +} + +describe('groupMessagesByRoom', () => { + it('groups sync batches by room id', () => { + const grouped = groupMessagesByRoom([ + createMessage({ id: 'a1', roomId: 'room-a' }), + createMessage({ id: 'b1', roomId: 'room-b' }), + createMessage({ id: 'a2', roomId: 'room-a' }) + ]); + + expect(Array.from(grouped.keys())).toEqual(['room-a', 'room-b']); + expect(grouped.get('room-a')?.map((message) => message.id)).toEqual(['a1', 'a2']); + expect(grouped.get('room-b')?.map((message) => message.id)).toEqual(['b1']); + }); + + it('returns empty map for empty sync batch', () => { + expect(groupMessagesByRoom([]).size).toBe(0); + }); +}); diff --git a/toju-app/src/app/domains/notifications/application/effects/notifications.effects.ts b/toju-app/src/app/domains/notifications/application/effects/notifications.effects.ts index f259110..7d44f25 100644 --- a/toju-app/src/app/domains/notifications/application/effects/notifications.effects.ts +++ b/toju-app/src/app/domains/notifications/application/effects/notifications.effects.ts @@ -11,6 +11,7 @@ import { tap, withLatestFrom } from 'rxjs/operators'; +import type { Message } from '../../../../shared-kernel'; import { MessagesActions } from '../../../../store/messages/messages.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/rooms.selectors'; @@ -18,6 +19,23 @@ import { UsersActions } from '../../../../store/users/users.actions'; import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { NotificationsFacade } from '../facades/notifications.facade'; +export function groupMessagesByRoom(messages: Message[]): Map { + const messagesByRoom = new Map(); + + for (const message of messages) { + const roomMessages = messagesByRoom.get(message.roomId); + + if (roomMessages) { + roomMessages.push(message); + continue; + } + + messagesByRoom.set(message.roomId, [message]); + } + + return messagesByRoom; +} + @Injectable() export class NotificationsEffects { private readonly actions$ = inject(Actions); @@ -92,10 +110,10 @@ export class NotificationsEffects { { dispatch: false } ); - refreshCurrentRoomUnread$ = createEffect( + refreshViewedRoomUnread$ = createEffect( () => this.actions$.pipe( - ofType(MessagesActions.loadMessagesSuccess, MessagesActions.syncMessages), + ofType(MessagesActions.loadMessagesSuccess), withLatestFrom(this.store.select(selectCurrentRoom)), tap(([{ messages }, room]) => { if (room) { @@ -105,4 +123,17 @@ export class NotificationsEffects { ), { dispatch: false } ); + + refreshSyncedRoomUnread$ = createEffect( + () => + this.actions$.pipe( + ofType(MessagesActions.syncMessages), + tap(({ messages }) => { + for (const [roomId, roomMessages] of groupMessagesByRoom(messages)) { + this.notifications.refreshRoomUnreadFromMessages(roomId, roomMessages); + } + }) + ) + , { dispatch: false } + ); } diff --git a/toju-app/src/app/domains/server-directory/README.md b/toju-app/src/app/domains/server-directory/README.md index ca0f51c..38ebeda 100644 --- a/toju-app/src/app/domains/server-directory/README.md +++ b/toju-app/src/app/domains/server-directory/README.md @@ -79,7 +79,7 @@ graph TD ## Endpoint lifecycle -On startup, `ServerEndpointStateService` loads endpoints from localStorage, reconciles them with the configured defaults from the environment, and ensures at least one endpoint is active. +On startup, `ServerEndpointStateService` loads endpoints from localStorage, reconciles them with the configured defaults from the environment, and ensures at least one endpoint is active. Configured default endpoints are treated as active by default unless the user explicitly disabled or removed them. ```mermaid stateDiagram-v2 @@ -167,6 +167,7 @@ Default servers are configured in the environment file. The state service builds - Stored endpoints are matched to defaults by `defaultKey` or URL - Missing defaults are added unless the user explicitly removed them (tracked in a separate localStorage key) +- Default endpoints stay active by default unless the user explicitly disabled them (tracked separately from the endpoint payload) - `restoreDefaultServers()` re-adds any removed defaults and clears the removal tracking - The primary default URL is used as a fallback when no endpoint is resolved diff --git a/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.spec.ts b/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.spec.ts new file mode 100644 index 0000000..0ef5c6d --- /dev/null +++ b/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.spec.ts @@ -0,0 +1,134 @@ +import { Injector, runInInjectionContext } from '@angular/core'; +import { environment } from '../../../../../environments/environment'; +import type { ServerEndpoint } from '../../domain/models/server-directory.model'; +import { + DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY, + SERVER_ENDPOINTS_STORAGE_KEY +} from '../../infrastructure/constants/server-directory.infrastructure.constants'; +import { ServerEndpointStorageService } from '../../infrastructure/services/server-endpoint-storage.service'; +import { ServerEndpointStateService } from './server-endpoint-state.service'; + +function createLocalStorageMock(): Storage { + const store = new Map(); + + return { + get length(): number { + return store.size; + }, + clear(): void { + store.clear(); + }, + getItem(key: string): string | null { + return store.get(key) ?? null; + }, + key(index: number): string | null { + return [...store.keys()][index] ?? null; + }, + removeItem(key: string): void { + store.delete(key); + }, + setItem(key: string, value: string): void { + store.set(key, value); + } + }; +} + +Object.defineProperty(globalThis, 'localStorage', { + value: createLocalStorageMock(), + configurable: true +}); + +function getConfiguredDefaultServer(key: string): { key?: string; name?: string; url?: string } { + const defaultServer = environment.defaultServers.find((server) => server.key === key); + + if (!defaultServer) { + throw new Error(`Missing configured default server for key: ${key}`); + } + + return defaultServer; +} + +function seedStoredEndpoints(endpoints: ServerEndpoint[]): void { + localStorage.setItem(SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints)); +} + +function createService(): ServerEndpointStateService { + const injector = Injector.create({ + providers: [ + { + provide: ServerEndpointStorageService, + useClass: ServerEndpointStorageService, + deps: [] + } + ] + }); + + return runInInjectionContext(injector, () => new ServerEndpointStateService()); +} + +describe('ServerEndpointStateService', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('reactivates configured default endpoints unless the user disabled them', () => { + const defaultServer = getConfiguredDefaultServer('toju-primary'); + + seedStoredEndpoints([ + { + id: 'default-server', + name: 'Stored Default', + url: defaultServer.url ?? '', + isActive: false, + isDefault: true, + defaultKey: defaultServer.key, + status: 'unknown' + } + ]); + + const service = createService(); + const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key); + + expect(endpoint?.isActive).toBe(true); + }); + + it('keeps a configured default endpoint inactive after the user turned it off', () => { + const defaultServer = getConfiguredDefaultServer('toju-primary'); + + seedStoredEndpoints([ + { + id: 'default-server', + name: 'Stored Default', + url: defaultServer.url ?? '', + isActive: true, + isDefault: true, + defaultKey: defaultServer.key, + status: 'unknown' + } + ]); + localStorage.setItem(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY, JSON.stringify([defaultServer.key])); + + const service = createService(); + const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key); + + expect(endpoint?.isActive).toBe(false); + }); + + it('persists turning a configured default endpoint off and back on', () => { + const defaultServer = getConfiguredDefaultServer('toju-primary'); + const service = createService(); + const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key); + + expect(endpoint).toBeDefined(); + + service.deactivateServer(endpoint!.id); + + expect(service.servers().find((candidate) => candidate.id === endpoint!.id)?.isActive).toBe(false); + expect(JSON.parse(localStorage.getItem(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY) ?? '[]')).toContain(defaultServer.key); + + service.setActiveServer(endpoint!.id); + + expect(service.servers().find((candidate) => candidate.id === endpoint!.id)?.isActive).toBe(true); + expect(localStorage.getItem(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.ts b/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.ts index 77a5ac3..89e3818 100644 --- a/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.ts +++ b/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.ts @@ -145,6 +145,7 @@ export class ServerEndpointStateService { if (target.isDefault) { this.markDefaultEndpointRemoved(target); + this.clearDefaultEndpointDisabled(target); } const updatedEndpoints = ensureAnyActiveEndpoint( @@ -171,6 +172,7 @@ export class ServerEndpointStateService { this._servers.update((endpoints) => ensureAnyActiveEndpoint([...endpoints, ...restoredEndpoints])); this.storage.clearRemovedDefaultEndpointKeys(); + this.clearDisabledDefaultEndpointKeys(restoredEndpoints); this.saveEndpoints(); return restoredEndpoints; } @@ -190,6 +192,12 @@ export class ServerEndpointStateService { ); }); + const target = this._servers().find((endpoint) => endpoint.id === endpointId); + + if (target?.isDefault) { + this.clearDefaultEndpointDisabled(target); + } + this.saveEndpoints(); } @@ -206,6 +214,12 @@ export class ServerEndpointStateService { ) ); + const target = this._servers().find((endpoint) => endpoint.id === endpointId); + + if (target?.isDefault) { + this.markDefaultEndpointDisabled(target); + } + this.saveEndpoints(); } @@ -258,6 +272,7 @@ export class ServerEndpointStateService { private reconcileStoredEndpoints(storedEndpoints: ServerEndpoint[]): ServerEndpoint[] { const reconciled: ServerEndpoint[] = []; const claimedDefaultKeys = new Set(); + const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys(); const removedDefaultKeys = this.storage.loadRemovedDefaultEndpointKeys(); for (const endpoint of storedEndpoints) { @@ -279,6 +294,7 @@ export class ServerEndpointStateService { ...endpoint, name: matchedDefault.name, url: matchedDefault.url, + isActive: this.isDefaultEndpointActive(matchedDefault.defaultKey, endpoint.status ?? 'unknown', disabledDefaultKeys), isDefault: true, defaultKey: matchedDefault.defaultKey, status: endpoint.status ?? 'unknown' @@ -303,7 +319,7 @@ export class ServerEndpointStateService { reconciled.push({ ...defaultEndpoint, id: uuidv4(), - isActive: defaultEndpoint.isActive + isActive: this.isDefaultEndpointActive(defaultEndpoint.defaultKey, defaultEndpoint.status, disabledDefaultKeys) }); } } @@ -324,6 +340,64 @@ export class ServerEndpointStateService { this.storage.saveRemovedDefaultEndpointKeys(removedDefaultKeys); } + private markDefaultEndpointDisabled(endpoint: ServerEndpoint): void { + const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url); + + if (!defaultKey) { + return; + } + + const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys(); + + disabledDefaultKeys.add(defaultKey); + this.storage.saveDisabledDefaultEndpointKeys(disabledDefaultKeys); + } + + private clearDefaultEndpointDisabled(endpoint: ServerEndpoint): void { + const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url); + + if (!defaultKey) { + return; + } + + const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys(); + + if (!disabledDefaultKeys.delete(defaultKey)) { + return; + } + + this.storage.saveDisabledDefaultEndpointKeys(disabledDefaultKeys); + } + + private clearDisabledDefaultEndpointKeys(endpoints: ServerEndpoint[]): void { + const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys(); + let didChange = false; + + for (const endpoint of endpoints) { + const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url); + + if (!defaultKey) { + continue; + } + + didChange = disabledDefaultKeys.delete(defaultKey) || didChange; + } + + if (!didChange) { + return; + } + + this.storage.saveDisabledDefaultEndpointKeys(disabledDefaultKeys); + } + + private isDefaultEndpointActive( + defaultKey: string, + status: ServerEndpoint['status'], + disabledDefaultKeys: Set + ): boolean { + return status !== 'incompatible' && !disabledDefaultKeys.has(defaultKey); + } + private saveEndpoints(): void { this.storage.saveEndpoints(this._servers()); } diff --git a/toju-app/src/app/domains/server-directory/infrastructure/constants/server-directory.infrastructure.constants.ts b/toju-app/src/app/domains/server-directory/infrastructure/constants/server-directory.infrastructure.constants.ts index 9940614..ea961f6 100644 --- a/toju-app/src/app/domains/server-directory/infrastructure/constants/server-directory.infrastructure.constants.ts +++ b/toju-app/src/app/domains/server-directory/infrastructure/constants/server-directory.infrastructure.constants.ts @@ -1,3 +1,4 @@ export const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints'; export const REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys'; +export const DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY = 'metoyou_disabled_default_server_keys'; export const SERVER_HEALTH_CHECK_TIMEOUT_MS = 5000; diff --git a/toju-app/src/app/domains/server-directory/infrastructure/services/server-endpoint-storage.service.ts b/toju-app/src/app/domains/server-directory/infrastructure/services/server-endpoint-storage.service.ts index 7ec12fe..86cb3a2 100644 --- a/toju-app/src/app/domains/server-directory/infrastructure/services/server-endpoint-storage.service.ts +++ b/toju-app/src/app/domains/server-directory/infrastructure/services/server-endpoint-storage.service.ts @@ -1,5 +1,9 @@ import { Injectable } from '@angular/core'; -import { REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, SERVER_ENDPOINTS_STORAGE_KEY } from '../constants/server-directory.infrastructure.constants'; +import { + DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY, + REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, + SERVER_ENDPOINTS_STORAGE_KEY +} from '../constants/server-directory.infrastructure.constants'; import type { ServerEndpoint } from '../../domain/models/server-directory.model'; @Injectable({ providedIn: 'root' }) @@ -26,8 +30,32 @@ export class ServerEndpointStorageService { localStorage.setItem(SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints)); } + loadDisabledDefaultEndpointKeys(): Set { + return this.loadStringSet(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY); + } + + saveDisabledDefaultEndpointKeys(keys: Set): void { + this.saveStringSet(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY, keys); + } + + clearDisabledDefaultEndpointKeys(): void { + localStorage.removeItem(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY); + } + loadRemovedDefaultEndpointKeys(): Set { - const stored = localStorage.getItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY); + return this.loadStringSet(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY); + } + + saveRemovedDefaultEndpointKeys(keys: Set): void { + this.saveStringSet(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, keys); + } + + clearRemovedDefaultEndpointKeys(): void { + localStorage.removeItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY); + } + + private loadStringSet(storageKey: string): Set { + const stored = localStorage.getItem(storageKey); if (!stored) { return new Set(); @@ -46,16 +74,12 @@ export class ServerEndpointStorageService { } } - saveRemovedDefaultEndpointKeys(keys: Set): void { + private saveStringSet(storageKey: string, keys: Set): void { if (keys.size === 0) { - this.clearRemovedDefaultEndpointKeys(); + localStorage.removeItem(storageKey); return; } - localStorage.setItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, JSON.stringify([...keys])); - } - - clearRemovedDefaultEndpointKeys(): void { - localStorage.removeItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY); + localStorage.setItem(storageKey, JSON.stringify([...keys])); } } diff --git a/toju-app/src/app/features/shell/title-bar/title-bar.component.ts b/toju-app/src/app/features/shell/title-bar/title-bar.component.ts index 034c1cb..6357722 100644 --- a/toju-app/src/app/features/shell/title-bar/title-bar.component.ts +++ b/toju-app/src/app/features/shell/title-bar/title-bar.component.ts @@ -27,13 +27,15 @@ import { selectIsSignalServerReconnecting, selectSignalServerCompatibilityError } from '../../../store/rooms/rooms.selectors'; +import { MessagesActions } from '../../../store/messages/messages.actions'; import { RoomsActions } from '../../../store/rooms/rooms.actions'; +import { UsersActions } from '../../../store/users/users.actions'; import { selectCurrentUser } from '../../../store/users/users.selectors'; import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; import { RealtimeSessionFacade } from '../../../core/realtime'; import { ServerDirectoryFacade } from '../../../domains/server-directory'; import { PlatformService } from '../../../core/platform'; -import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; +import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage'; import { LeaveServerDialogComponent } from '../../../shared'; import { Room } from '../../../shared-kernel'; import { VoiceWorkspaceService } from '../../../domains/voice-session'; @@ -256,9 +258,10 @@ export class TitleBarComponent { // servers the user was a member of, so other users see them go offline. this.webrtc.disconnect(); - try { - localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID); - } catch {} + clearStoredCurrentUserId(); + this.store.dispatch(MessagesActions.clearMessages()); + this.store.dispatch(RoomsActions.resetRoomsState()); + this.store.dispatch(UsersActions.resetUsersState()); this.router.navigate(['/login']); } diff --git a/toju-app/src/app/infrastructure/persistence/README.md b/toju-app/src/app/infrastructure/persistence/README.md index a545a48..e466911 100644 --- a/toju-app/src/app/infrastructure/persistence/README.md +++ b/toju-app/src/app/infrastructure/persistence/README.md @@ -2,6 +2,8 @@ Offline-first storage layer that keeps messages, users, rooms, reactions, bans, and attachments on the client. The rest of the app only ever talks to `DatabaseService`, which picks the right backend for the current platform at runtime. +Persisted data is treated as belonging to the authenticated user that created it. In the browser runtime, IndexedDB is user-scoped: the renderer opens a per-user database for the active account and switches scopes during authentication so one account never boots into another account's stored rooms, messages, or settings. + ## Files ``` @@ -13,7 +15,7 @@ persistence/ โ””โ”€โ”€ electron-database.service.ts IPC/SQLite backend (desktop) ``` -`app-resume.storage.ts` is the one exception to the `DatabaseService` facade. It stores lightweight UI-level launch preferences and the last viewed room/channel snapshot in `localStorage`, which would be unnecessary overhead to route through IndexedDB or SQLite. +`app-resume.storage.ts` is the one exception to the `DatabaseService` facade. It stores lightweight UI-level launch preferences and the last viewed room/channel snapshot in `localStorage`, which would be unnecessary overhead to route through IndexedDB or SQLite. Those values use user-scoped storage keys so each account restores its own resume state instead of overwriting another user's snapshot. ## Platform routing @@ -55,7 +57,7 @@ The persisted `rooms` store is a local cache of room metadata. Channel topology ### Browser (IndexedDB) -All operations run inside IndexedDB transactions in the renderer thread. Queries like `getMessages` pull all messages for a room via the `roomId` index, sort them by timestamp in JS, then apply limit/offset. Deleted messages are normalised on read (content replaced with a sentinel string). +All operations run inside IndexedDB transactions in the renderer thread. The browser backend resolves the active database name from the logged-in user, reusing a legacy shared database only when it already belongs to that same account. Queries like `getMessages` pull all messages for a room via the `roomId` index, sort them by timestamp in JS, then apply limit/offset. Deleted messages are normalised on read (content replaced with a sentinel string). ```mermaid sequenceDiagram diff --git a/toju-app/src/app/infrastructure/persistence/app-resume.storage.ts b/toju-app/src/app/infrastructure/persistence/app-resume.storage.ts index 20ce2a9..9fbeed4 100644 --- a/toju-app/src/app/infrastructure/persistence/app-resume.storage.ts +++ b/toju-app/src/app/infrastructure/persistence/app-resume.storage.ts @@ -1,4 +1,5 @@ import { STORAGE_KEY_GENERAL_SETTINGS, STORAGE_KEY_LAST_VIEWED_CHAT } from '../../core/constants'; +import { getUserScopedStorageKey } from '../../core/storage/current-user-storage'; export interface GeneralSettings { reopenLastViewedChat: boolean; @@ -16,7 +17,8 @@ export interface LastViewedChatSnapshot { export function loadGeneralSettingsFromStorage(): GeneralSettings { try { - const raw = localStorage.getItem(STORAGE_KEY_GENERAL_SETTINGS); + const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS)) + ?? localStorage.getItem(STORAGE_KEY_GENERAL_SETTINGS); if (!raw) { return { ...DEFAULT_GENERAL_SETTINGS }; @@ -35,7 +37,7 @@ export function saveGeneralSettingsToStorage(patch: Partial): G }); try { - localStorage.setItem(STORAGE_KEY_GENERAL_SETTINGS, JSON.stringify(nextSettings)); + localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS), JSON.stringify(nextSettings)); } catch {} return nextSettings; @@ -43,7 +45,8 @@ export function saveGeneralSettingsToStorage(patch: Partial): G export function loadLastViewedChatFromStorage(userId?: string | null): LastViewedChatSnapshot | null { try { - const raw = localStorage.getItem(STORAGE_KEY_LAST_VIEWED_CHAT); + const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, userId)) + ?? localStorage.getItem(STORAGE_KEY_LAST_VIEWED_CHAT); if (!raw) { return null; @@ -73,12 +76,13 @@ export function saveLastViewedChatToStorage(snapshot: LastViewedChatSnapshot): v } try { - localStorage.setItem(STORAGE_KEY_LAST_VIEWED_CHAT, JSON.stringify(normalised)); + localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, normalised.userId), JSON.stringify(normalised)); } catch {} } -export function clearLastViewedChatFromStorage(): void { +export function clearLastViewedChatFromStorage(userId?: string | null): void { try { + localStorage.removeItem(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, userId)); localStorage.removeItem(STORAGE_KEY_LAST_VIEWED_CHAT); } catch {} } diff --git a/toju-app/src/app/infrastructure/persistence/browser-database.service.ts b/toju-app/src/app/infrastructure/persistence/browser-database.service.ts index 5d07fb5..1d177d0 100644 --- a/toju-app/src/app/infrastructure/persistence/browser-database.service.ts +++ b/toju-app/src/app/infrastructure/persistence/browser-database.service.ts @@ -9,9 +9,11 @@ import { BanEntry } from '../../shared-kernel'; import type { ChatAttachmentMeta } from '../../shared-kernel'; +import { getStoredCurrentUserId } from '../../core/storage/current-user-storage'; /** IndexedDB database name for the MetoYou application. */ const DATABASE_NAME = 'metoyou'; +const ANONYMOUS_DATABASE_SCOPE = 'anonymous'; /** IndexedDB schema version - bump when adding/changing object stores. */ const DATABASE_VERSION = 2; /** Names of every object store used by the application. */ @@ -44,13 +46,18 @@ const ALL_STORE_NAMES: string[] = [ export class BrowserDatabaseService { /** Handle to the opened IndexedDB database, or `null` before {@link initialize}. */ private database: IDBDatabase | null = null; + private activeDatabaseName: string | null = null; /** Open (or create) the IndexedDB database. Safe to call multiple times. */ async initialize(): Promise { - if (this.database) + const databaseName = await this.resolveDatabaseName(); + + if (this.database && this.activeDatabaseName === databaseName) return; - this.database = await this.openDatabase(); + this.closeDatabase(); + this.database = await this.openDatabase(databaseName); + this.activeDatabaseName = databaseName; } /** Persist a single message. */ @@ -180,6 +187,15 @@ export class BrowserDatabaseService { return this.getUser(meta.value); } + /** Retrieve the persisted current user ID without loading the full user. */ + async getCurrentUserId(): Promise { + const meta = await this.get<{ id: string; value: string }>( + STORE_META, 'currentUserId' + ); + + return meta?.value?.trim() || null; + } + /** Store which user ID is considered "current" (logged-in). */ async setCurrentUserId(userId: string): Promise { await this.put(STORE_META, { id: 'currentUserId', @@ -313,9 +329,66 @@ export class BrowserDatabaseService { await this.awaitTransaction(transaction); } - private openDatabase(): Promise { + private async resolveDatabaseName(): Promise { + const currentUserId = getStoredCurrentUserId(); + const scopedDatabaseName = this.createScopedDatabaseName(currentUserId); + + if (!currentUserId) { + return scopedDatabaseName; + } + + if (await this.databaseExists(scopedDatabaseName)) { + return scopedDatabaseName; + } + + const legacyCurrentUserId = await this.readCurrentUserIdFromDatabase(DATABASE_NAME); + + return legacyCurrentUserId === currentUserId + ? DATABASE_NAME + : scopedDatabaseName; + } + + private createScopedDatabaseName(userId: string | null): string { + return `${DATABASE_NAME}::${encodeURIComponent(userId || ANONYMOUS_DATABASE_SCOPE)}`; + } + + private async databaseExists(name: string): Promise { + const hasDatabasesApi = typeof indexedDB.databases === 'function'; + + if (!hasDatabasesApi) { + return false; + } + + const databases = await indexedDB.databases(); + + return databases.some((database) => database.name === name); + } + + private async readCurrentUserIdFromDatabase(databaseName: string): Promise { + if (!await this.databaseExists(databaseName)) { + return null; + } + + const database = await this.openDatabase(databaseName); + + try { + const transaction = database.transaction(STORE_META, 'readonly'); + const request = transaction.objectStore(STORE_META).get('currentUserId'); + + return await new Promise((resolve, reject) => { + request.onsuccess = () => resolve((request.result as { value?: string } | undefined)?.value?.trim() || null); + request.onerror = () => reject(request.error); + }); + } catch { + return null; + } finally { + database.close(); + } + } + + private openDatabase(databaseName: string): Promise { return new Promise((resolve, reject) => { - const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + const request = indexedDB.open(databaseName, DATABASE_VERSION); request.onerror = () => reject(request.error); request.onupgradeneeded = () => this.setupSchema(request.result); @@ -323,6 +396,12 @@ export class BrowserDatabaseService { }); } + private closeDatabase(): void { + this.database?.close(); + this.database = null; + this.activeDatabaseName = null; + } + private setupSchema(database: IDBDatabase): void { const messagesStore = this.ensureStore(database, STORE_MESSAGES, { keyPath: 'id' }); diff --git a/toju-app/src/app/infrastructure/persistence/database.service.ts b/toju-app/src/app/infrastructure/persistence/database.service.ts index ab811f3..07fd8cf 100644 --- a/toju-app/src/app/infrastructure/persistence/database.service.ts +++ b/toju-app/src/app/infrastructure/persistence/database.service.ts @@ -85,6 +85,9 @@ export class DatabaseService { /** Retrieve the current (logged-in) user. */ getCurrentUser() { return this.backend.getCurrentUser(); } + /** Retrieve the persisted current user ID without loading the full user. */ + getCurrentUserId() { return this.backend.getCurrentUserId(); } + /** Store the current user ID. */ setCurrentUserId(userId: string) { return this.backend.setCurrentUserId(userId); } diff --git a/toju-app/src/app/infrastructure/persistence/electron-database.service.ts b/toju-app/src/app/infrastructure/persistence/electron-database.service.ts index 4b364c9..4eb5aab 100644 --- a/toju-app/src/app/infrastructure/persistence/electron-database.service.ts +++ b/toju-app/src/app/infrastructure/persistence/electron-database.service.ts @@ -101,6 +101,11 @@ export class ElectronDatabaseService { return this.api.query({ type: 'get-current-user', payload: {} }); } + /** Retrieve the persisted current user ID without loading the full user. */ + getCurrentUserId(): Promise { + return this.api.query({ type: 'get-current-user-id', payload: {} }); + } + /** Store which user ID is considered "current" (logged-in). */ setCurrentUserId(userId: string): Promise { return this.api.command({ type: 'set-current-user-id', payload: { userId } }); diff --git a/toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts b/toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts new file mode 100644 index 0000000..9f1bb92 --- /dev/null +++ b/toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts @@ -0,0 +1,104 @@ +import { + defaultIfEmpty, + firstValueFrom +} from 'rxjs'; + +import { type Message } from '../../shared-kernel'; +import { dispatchIncomingMessage } from './messages-incoming.handlers'; + +function createMessage(overrides: Partial = {}): Message { + return { + id: 'message-1', + roomId: 'room-1', + senderId: 'user-1', + senderName: 'User 1', + content: 'hello', + timestamp: 1, + reactions: [], + isDeleted: false, + ...overrides + }; +} + +function createContext(overrides: Record = {}) { + return { + db: { + getMessages: vi.fn() + }, + webrtc: { + sendToPeer: vi.fn() + }, + attachments: {}, + debugging: {}, + currentUser: null, + currentRoom: null, + ...overrides + } as const; +} + +describe('dispatchIncomingMessage room-scoped sync', () => { + it('requests sync for event room even when another room is viewed', async () => { + const getMessages = vi.fn(async (roomId: string) => roomId === 'room-b' + ? [createMessage({ roomId: 'room-b', timestamp: 10, editedAt: 10 })] + : [createMessage({ roomId: 'room-a', timestamp: 100, editedAt: 100 })]); + const sendToPeer = vi.fn(); + const context = createContext({ + db: { getMessages }, + webrtc: { sendToPeer }, + currentRoom: { id: 'room-a' } + }); + + await firstValueFrom( + dispatchIncomingMessage( + { + type: 'chat-sync-summary', + roomId: 'room-b', + fromPeerId: 'peer-1', + count: 2, + lastUpdated: 20 + } as never, + context as never + ).pipe(defaultIfEmpty(null)) + ); + + expect(getMessages).toHaveBeenCalledWith('room-b', expect.any(Number), 0); + expect(sendToPeer).toHaveBeenCalledWith('peer-1', { + type: 'chat-sync-request', + roomId: 'room-b' + }); + }); + + it('sends full sync for requested room even when another room is viewed', async () => { + const roomBMessages = [ + createMessage({ id: 'message-b1', roomId: 'room-b', timestamp: 5 }), + createMessage({ id: 'message-b2', roomId: 'room-b', timestamp: 15 }) + ]; + const getMessages = vi.fn(async (roomId: string) => roomId === 'room-b' + ? roomBMessages + : [createMessage({ id: 'message-a1', roomId: 'room-a', timestamp: 200 })]); + const sendToPeer = vi.fn(); + const context = createContext({ + db: { getMessages }, + webrtc: { sendToPeer }, + currentRoom: { id: 'room-a' } + }); + + await firstValueFrom( + dispatchIncomingMessage( + { + type: 'chat-sync-request', + roomId: 'room-b', + fromPeerId: 'peer-2' + } as never, + context as never + ).pipe(defaultIfEmpty(null)) + ); + + expect(getMessages).toHaveBeenCalledWith('room-b', expect.any(Number), 0); + expect(sendToPeer).toHaveBeenCalledWith('peer-2', { + type: 'chat-sync-full', + roomId: 'room-b', + messages: roomBMessages + }); + }); +}); diff --git a/toju-app/src/app/store/messages/messages-incoming.handlers.ts b/toju-app/src/app/store/messages/messages-incoming.handlers.ts index 38db2a6..1d38a67 100644 --- a/toju-app/src/app/store/messages/messages-incoming.handlers.ts +++ b/toju-app/src/app/store/messages/messages-incoming.handlers.ts @@ -538,12 +538,14 @@ function handleSyncSummary( event: IncomingMessageEvent, { db, webrtc, currentRoom }: IncomingMessageContext ): Observable { - if (!currentRoom) + const targetRoomId = event.roomId || currentRoom?.id; + + if (!targetRoomId) return EMPTY; return from( (async () => { - const local = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0); + const local = await db.getMessages(targetRoomId, FULL_SYNC_LIMIT, 0); const localCount = local.length; const localLastUpdated = local.reduce( (maxTimestamp, message) => Math.max(maxTimestamp, message.editedAt || message.timestamp || 0), @@ -561,7 +563,7 @@ function handleSyncSummary( if (!identical && needsSync && fromPeerId) { const syncRequestEvent: ChatEvent = { type: 'chat-sync-request', - roomId: currentRoom.id + roomId: targetRoomId }; webrtc.sendToPeer(fromPeerId, syncRequestEvent); @@ -575,17 +577,18 @@ function handleSyncRequest( event: IncomingMessageEvent, { db, webrtc, currentRoom }: IncomingMessageContext ): Observable { + const targetRoomId = event.roomId || currentRoom?.id; const fromPeerId = event.fromPeerId; - if (!currentRoom || !fromPeerId) + if (!targetRoomId || !fromPeerId) return EMPTY; return from( (async () => { - const all = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0); + const all = await db.getMessages(targetRoomId, FULL_SYNC_LIMIT, 0); const syncFullEvent: ChatEvent = { type: 'chat-sync-full', - roomId: currentRoom.id, + roomId: targetRoomId, messages: all }; diff --git a/toju-app/src/app/store/rooms/room-state-sync.effects.ts b/toju-app/src/app/store/rooms/room-state-sync.effects.ts index 991043a..fd399b3 100644 --- a/toju-app/src/app/store/rooms/room-state-sync.effects.ts +++ b/toju-app/src/app/store/rooms/room-state-sync.effects.ts @@ -48,6 +48,7 @@ import { buildKnownUserExtras, isWrongServer, resolveRoom, + reconcileRoomSnapshotChannels, sanitizeRoomSnapshot, normalizeIncomingBans, getPersistedCurrentUserId @@ -122,7 +123,8 @@ export class RoomStateSyncEffects { const actions: Action[] = [ UsersActions.syncServerPresence({ roomId: signalingMessage.serverId, - users: syncedUsers + users: syncedUsers, + connectedPeerIds: this.webrtc.getConnectedPeers() }) ]; @@ -641,7 +643,10 @@ export class RoomStateSyncEffects { if (!room || !incomingRoom) return EMPTY; - const roomChanges = sanitizeRoomSnapshot(incomingRoom); + const roomChanges = { + ...sanitizeRoomSnapshot(incomingRoom), + channels: reconcileRoomSnapshotChannels(room.channels, incomingRoom.channels) + }; const bans = normalizeIncomingBans(room.id, event.bans); return this.syncBansToLocalRoom(room.id, bans).pipe( diff --git a/toju-app/src/app/store/rooms/rooms-helpers-snapshot.spec.ts b/toju-app/src/app/store/rooms/rooms-helpers-snapshot.spec.ts new file mode 100644 index 0000000..859d0cb --- /dev/null +++ b/toju-app/src/app/store/rooms/rooms-helpers-snapshot.spec.ts @@ -0,0 +1,45 @@ +import { + reconcileRoomSnapshotChannels, + sanitizeRoomSnapshot +} from './rooms.helpers'; + +describe('room snapshot helpers', () => { + it('drops empty channel arrays from outgoing snapshots', () => { + expect(sanitizeRoomSnapshot({ channels: [] }).channels).toBeUndefined(); + }); + + it('keeps cached channels when incoming snapshot has none', () => { + const cachedChannels = [ + { id: 'general', name: 'general', type: 'text', position: 0 }, + { id: 'updates', name: 'updates', type: 'text', position: 1 } + ] as const; + + expect(reconcileRoomSnapshotChannels(cachedChannels as never, undefined)).toEqual(cachedChannels); + expect(reconcileRoomSnapshotChannels(cachedChannels as never, [] as never)).toEqual(cachedChannels); + }); + + it('keeps richer cached channels when incoming snapshot is smaller', () => { + const cachedChannels = [ + { id: 'general', name: 'general', type: 'text', position: 0 }, + { id: 'updates', name: 'updates', type: 'text', position: 1 }, + { id: 'voice', name: 'General', type: 'voice', position: 0 } + ] as const; + const incomingChannels = [ + { id: 'general', name: 'general', type: 'text', position: 0 } + ] as const; + + expect(reconcileRoomSnapshotChannels(cachedChannels as never, incomingChannels as never)).toEqual(cachedChannels); + }); + + it('accepts incoming channels when snapshot is at least as complete', () => { + const cachedChannels = [ + { id: 'general', name: 'general', type: 'text', position: 0 } + ] as const; + const incomingChannels = [ + { id: 'general', name: 'general', type: 'text', position: 0 }, + { id: 'updates', name: 'updates', type: 'text', position: 1 } + ] as const; + + expect(reconcileRoomSnapshotChannels(cachedChannels as never, incomingChannels as never)).toEqual(incomingChannels); + }); +}); diff --git a/toju-app/src/app/store/rooms/rooms.actions.ts b/toju-app/src/app/store/rooms/rooms.actions.ts index de7ee1a..0e7a7b6 100644 --- a/toju-app/src/app/store/rooms/rooms.actions.ts +++ b/toju-app/src/app/store/rooms/rooms.actions.ts @@ -57,6 +57,8 @@ export const RoomsActions = createActionGroup({ 'Forget Room': props<{ roomId: string; nextOwnerKey?: string }>(), 'Forget Room Success': props<{ roomId: string }>(), + 'Reset Rooms State': emptyProps(), + 'Update Room Settings': props<{ roomId: string; settings: Partial }>(), 'Update Room Settings Success': props<{ roomId: string; settings: RoomSettings }>(), 'Update Room Settings Failure': props<{ error: string }>(), diff --git a/toju-app/src/app/store/rooms/rooms.helpers.ts b/toju-app/src/app/store/rooms/rooms.helpers.ts index a770436..edf20fa 100644 --- a/toju-app/src/app/store/rooms/rooms.helpers.ts +++ b/toju-app/src/app/store/rooms/rooms.helpers.ts @@ -97,6 +97,31 @@ export function resolveRoomChannels( return undefined; } +/** + * Peer room-state snapshots can lag behind cached room metadata. + * Keep richer cached channels until an equally rich or richer snapshot arrives. + */ +export function reconcileRoomSnapshotChannels( + cachedChannels: Room['channels'] | undefined, + incomingChannels: Room['channels'] | undefined +): Room['channels'] | undefined { + if (hasPersistedChannels(cachedChannels) && !hasPersistedChannels(incomingChannels)) { + return cachedChannels; + } + + if (hasPersistedChannels(cachedChannels) && hasPersistedChannels(incomingChannels)) { + return incomingChannels.length >= cachedChannels.length + ? incomingChannels + : cachedChannels; + } + + if (hasPersistedChannels(incomingChannels)) { + return incomingChannels; + } + + return undefined; +} + export function resolveTextChannelId( channels: Room['channels'] | undefined, preferredChannelId?: string | null @@ -136,7 +161,7 @@ export function sanitizeRoomSnapshot(room: Partial): Partial { iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined, slowModeInterval: typeof room.slowModeInterval === 'number' ? room.slowModeInterval : undefined, permissions: room.permissions ? { ...room.permissions } : undefined, - channels: Array.isArray(room.channels) ? room.channels : undefined, + channels: hasPersistedChannels(room.channels) ? room.channels : undefined, members: Array.isArray(room.members) ? room.members : undefined, roles: Array.isArray(room.roles) ? room.roles : undefined, roleAssignments: Array.isArray(room.roleAssignments) ? room.roleAssignments : undefined, diff --git a/toju-app/src/app/store/rooms/rooms.reducer.ts b/toju-app/src/app/store/rooms/rooms.reducer.ts index 266e7bb..542cb5e 100644 --- a/toju-app/src/app/store/rooms/rooms.reducer.ts +++ b/toju-app/src/app/store/rooms/rooms.reducer.ts @@ -105,6 +105,10 @@ export const initialState: RoomsState = { export const roomsReducer = createReducer( initialState, + on(RoomsActions.resetRoomsState, () => ({ + ...initialState + })), + // Load rooms on(RoomsActions.loadRooms, (state) => ({ ...state, diff --git a/toju-app/src/app/store/users/users-status.reducer.spec.ts b/toju-app/src/app/store/users/users-status.reducer.spec.ts index c4a3211..14e22f6 100644 --- a/toju-app/src/app/store/users/users-status.reducer.spec.ts +++ b/toju-app/src/app/store/users/users-status.reducer.spec.ts @@ -235,6 +235,71 @@ describe('users reducer - status', () => { // The buildPresenceAwareUser function takes incoming status when non-offline expect(state.entities['u1']?.status).toBe('online'); }); + + it('preserves omitted live peer presence and voice state during stale server snapshot', () => { + const remoteUser = createUser({ + id: 'u2', + oderId: 'u2', + displayName: 'Voice Peer', + presenceServerIds: ['s1'], + voiceState: { + isConnected: true, + isMuted: false, + isDeafened: false, + isSpeaking: true, + roomId: 'voice-1', + serverId: 's1' + }, + cameraState: { isEnabled: true }, + screenShareState: { isSharing: true } + }); + const withUser = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser })); + const state = usersReducer(withUser, UsersActions.syncServerPresence({ + roomId: 's1', + users: [], + connectedPeerIds: ['u2'] + })); + + expect(state.entities['u2']?.presenceServerIds).toEqual(['s1']); + expect(state.entities['u2']?.isOnline).toBe(true); + expect(state.entities['u2']?.voiceState?.isConnected).toBe(true); + expect(state.entities['u2']?.voiceState?.roomId).toBe('voice-1'); + expect(state.entities['u2']?.cameraState?.isEnabled).toBe(true); + expect(state.entities['u2']?.screenShareState?.isSharing).toBe(true); + }); + + it('clears omitted peer live state when transport is gone', () => { + const remoteUser = createUser({ + id: 'u3', + oderId: 'u3', + displayName: 'Dropped Peer', + presenceServerIds: ['s1'], + voiceState: { + isConnected: true, + isMuted: false, + isDeafened: false, + isSpeaking: true, + roomId: 'voice-1', + serverId: 's1' + }, + cameraState: { isEnabled: true }, + screenShareState: { isSharing: true } + }); + const withUser = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser })); + const state = usersReducer(withUser, UsersActions.syncServerPresence({ + roomId: 's1', + users: [], + connectedPeerIds: [] + })); + + expect(state.entities['u3']?.presenceServerIds).toBeUndefined(); + expect(state.entities['u3']?.isOnline).toBe(false); + expect(state.entities['u3']?.status).toBe('offline'); + expect(state.entities['u3']?.voiceState?.isConnected).toBe(false); + expect(state.entities['u3']?.voiceState?.roomId).toBeUndefined(); + expect(state.entities['u3']?.cameraState?.isEnabled).toBe(false); + expect(state.entities['u3']?.screenShareState?.isSharing).toBe(false); + }); }); describe('manual status overrides auto idle', () => { diff --git a/toju-app/src/app/store/users/users.actions.ts b/toju-app/src/app/store/users/users.actions.ts index b591125..f8d3dc0 100644 --- a/toju-app/src/app/store/users/users.actions.ts +++ b/toju-app/src/app/store/users/users.actions.ts @@ -18,6 +18,7 @@ import { export const UsersActions = createActionGroup({ source: 'Users', events: { + 'Authenticate User': props<{ user: User }>(), 'Load Current User': emptyProps(), 'Load Current User Success': props<{ user: User }>(), 'Load Current User Failure': props<{ error: string }>(), @@ -31,7 +32,7 @@ export const UsersActions = createActionGroup({ 'User Joined': props<{ user: User }>(), 'User Left': props<{ userId: string; serverId?: string; serverIds?: string[] }>(), - 'Sync Server Presence': props<{ roomId: string; users: User[] }>(), + 'Sync Server Presence': props<{ roomId: string; users: User[]; connectedPeerIds?: string[] }>(), 'Update User': props<{ userId: string; updates: Partial }>(), 'Update User Role': props<{ userId: string; role: User['role'] }>(), @@ -58,6 +59,7 @@ export const UsersActions = createActionGroup({ 'Sync Users': props<{ users: User[] }>(), 'Clear Users': emptyProps(), + 'Reset Users State': emptyProps(), 'Update Host': props<{ userId: string }>(), 'Update Voice State': props<{ userId: string; voiceState: Partial }>(), diff --git a/toju-app/src/app/store/users/users.effects.ts b/toju-app/src/app/store/users/users.effects.ts index aecda71..92bfd26 100644 --- a/toju-app/src/app/store/users/users.effects.ts +++ b/toju-app/src/app/store/users/users.effects.ts @@ -23,6 +23,7 @@ import { switchMap } from 'rxjs/operators'; import { v4 as uuidv4 } from 'uuid'; +import { MessagesActions } from '../messages/messages.actions'; import { UsersActions } from './users.actions'; import { RoomsActions } from '../rooms/rooms.actions'; import { @@ -46,6 +47,9 @@ import { Room, User } from '../../shared-kernel'; +import { + setStoredCurrentUserId +} from '../../core/storage/current-user-storage'; import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers'; type IncomingModerationExtraAction = @@ -65,6 +69,27 @@ export class UsersEffects { private serverDirectory = inject(ServerDirectoryFacade); private webrtc = inject(RealtimeSessionFacade); + /** Prepares persisted state for a successful login before exposing the user in-memory. */ + authenticateUser$ = createEffect(() => + this.actions$.pipe( + ofType(UsersActions.authenticateUser), + switchMap(({ user }) => + from(this.prepareAuthenticatedUserStorage(user.id)).pipe( + mergeMap(() => [ + MessagesActions.clearMessages(), + UsersActions.resetUsersState(), + RoomsActions.resetRoomsState(), + UsersActions.setCurrentUser({ user }), + RoomsActions.loadRooms() + ]), + catchError((error: Error) => + of(UsersActions.loadCurrentUserFailure({ error: error.message || 'Failed to prepare local user state.' })) + ) + ) + ) + ) + ); + // Load current user from storage /** Loads the persisted current user from the local database on startup. */ loadCurrentUser$ = createEffect(() => @@ -124,6 +149,11 @@ export class UsersEffects { }; } + private async prepareAuthenticatedUserStorage(userId: string): Promise { + setStoredCurrentUserId(userId); + await this.db.initialize(); + } + /** Loads all users associated with a specific room from the local database. */ loadRoomUsers$ = createEffect(() => this.actions$.pipe( diff --git a/toju-app/src/app/store/users/users.reducer.ts b/toju-app/src/app/store/users/users.reducer.ts index c8b4ea0..f8a72e1 100644 --- a/toju-app/src/app/store/users/users.reducer.ts +++ b/toju-app/src/app/store/users/users.reducer.ts @@ -30,6 +30,16 @@ function mergePresenceServerIds( return normalizePresenceServerIds([...(existingServerIds ?? []), ...(incomingServerIds ?? [])]); } +function hasLivePeerTransport(user: User, connectedPeerIds: ReadonlySet): boolean { + if (connectedPeerIds.size === 0) { + return false; + } + + return connectedPeerIds.has(user.id) + || connectedPeerIds.has(user.oderId) + || (!!user.peerId && connectedPeerIds.has(user.peerId)); +} + interface AvatarFields { avatarUrl?: string; avatarHash?: string; @@ -262,6 +272,10 @@ export const initialState: UsersState = usersAdapter.getInitialState({ export const usersReducer = createReducer( initialState, + on(UsersActions.resetUsersState, () => ({ + ...initialState + })), + on(UsersActions.loadCurrentUser, (state) => ({ ...state, loading: true, @@ -344,10 +358,11 @@ export const usersReducer = createReducer( on(UsersActions.userJoined, (state, { user }) => usersAdapter.upsertOne(buildPresenceAwareUser(state.entities[user.id], user), state) ), - on(UsersActions.syncServerPresence, (state, { roomId, users }) => { + on(UsersActions.syncServerPresence, (state, { roomId, users, connectedPeerIds }) => { let nextState = state; const seenUserIds = new Set(); + const connectedPeerIdSet = new Set(connectedPeerIds ?? []); for (const user of users) { seenUserIds.add(user.id); @@ -363,6 +378,7 @@ export const usersReducer = createReducer( && user.id !== nextState.currentUserId && user.presenceServerIds?.includes(roomId) === true && !seenUserIds.has(user.id) + && !hasLivePeerTransport(user, connectedPeerIdSet) ) .map((user) => ({ id: user.id,