From bc2fa7de2271ed7094fb7a010e5198761b419641 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 ++ server/src/websocket/handler-status.spec.ts | 8 + server/src/websocket/handler.ts | 6 + server/src/websocket/types.ts | 2 +- 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 +- .../application/services/klipy.service.ts | 95 ++++--- .../chat-image-proxy-fallback.directive.ts | 4 +- .../chat-messages.component.html | 3 + .../chat-messages/chat-messages.component.ts | 13 +- .../chat-message-composer.component.html | 7 +- .../chat-message-composer.component.ts | 7 +- .../klipy-gif-picker.component.html | 1 + .../klipy-gif-picker.component.ts | 6 +- .../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 | 177 ++++++++++++ .../services/server-endpoint-state.service.ts | 78 ++++- ...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 + .../src/app/infrastructure/realtime/README.md | 4 + .../realtime/realtime.constants.ts | 3 + .../realtime/signaling/signaling.manager.ts | 31 +- .../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 +- 56 files changed, 1861 insertions(+), 133 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 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 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/server/src/websocket/handler-status.spec.ts b/server/src/websocket/handler-status.spec.ts index ff834ba..d27603b 100644 --- a/server/src/websocket/handler-status.spec.ts +++ b/server/src/websocket/handler-status.spec.ts @@ -67,6 +67,14 @@ describe('server websocket handler - status_update', () => { connectedUsers.clear(); }); + it('treats signaling keepalive messages as connection liveness', async () => { + createConnectedUser('conn-1', 'user-1', { lastPong: 1 }); + + await handleWebSocketMessage('conn-1', { type: 'keepalive' }); + + expect(connectedUsers.get('conn-1')?.lastPong).toBeGreaterThan(1); + }); + it('updates user status on valid status_update message', async () => { const user = createConnectedUser('conn-1', 'user-1'); diff --git a/server/src/websocket/handler.ts b/server/src/websocket/handler.ts index 51a3a27..6d19786 100644 --- a/server/src/websocket/handler.ts +++ b/server/src/websocket/handler.ts @@ -293,7 +293,13 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe if (!user) return; + user.lastPong = Date.now(); + connectedUsers.set(connectionId, user); + switch (message.type) { + case 'keepalive': + break; + case 'identify': handleIdentify(user, message, connectionId); break; diff --git a/server/src/websocket/types.ts b/server/src/websocket/types.ts index f41437c..3e40c66 100644 --- a/server/src/websocket/types.ts +++ b/server/src/websocket/types.ts @@ -17,6 +17,6 @@ export interface ConnectedUser { connectionScope?: string; /** User availability status (online, away, busy, offline). */ status?: 'online' | 'away' | 'busy' | 'offline'; - /** Timestamp of the last pong received (used to detect dead connections). */ + /** Timestamp of the last pong or client message received (used to detect dead connections). */ lastPong: number; } 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/chat/application/services/klipy.service.ts b/toju-app/src/app/domains/chat/application/services/klipy.service.ts index bca817f..3e30e67 100644 --- a/toju-app/src/app/domains/chat/application/services/klipy.service.ts +++ b/toju-app/src/app/domains/chat/application/services/klipy.service.ts @@ -1,8 +1,5 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, - computed, - effect, inject, signal } from '@angular/core'; @@ -13,7 +10,11 @@ import { throwError } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; -import { ServerDirectoryFacade } from '../../../server-directory'; +import { + ServerDirectoryFacade, + type RoomSignalSourceInput, + type ServerSourceSelector +} from '../../../server-directory'; export interface KlipyGif { id: string; @@ -37,51 +38,47 @@ export interface KlipyGifSearchResponse { const DEFAULT_PAGE_SIZE = 24; const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id'; +const DEFAULT_AVAILABILITY_KEY = 'default'; + +interface KlipyAvailabilityState { + enabled: boolean; + loading: boolean; +} @Injectable({ providedIn: 'root' }) export class KlipyService { private readonly http = inject(HttpClient); private readonly serverDirectory = inject(ServerDirectoryFacade); - private readonly availabilityState = signal({ - enabled: false, - loading: true - }); - private lastAvailabilityKey = ''; + private readonly availabilityByKey = signal>({}); - readonly isEnabled = computed(() => this.availabilityState().enabled); - readonly isLoading = computed(() => this.availabilityState().loading); - - constructor() { - effect(() => { - const activeServer = this.serverDirectory.activeServer(); - const apiBaseUrl = this.serverDirectory.getApiBaseUrl(); - const nextKey = `${activeServer?.id ?? 'default'}:${apiBaseUrl}`; - - if (nextKey === this.lastAvailabilityKey) - return; - - this.lastAvailabilityKey = nextKey; - void this.refreshAvailability(); - }); + isEnabled(source?: RoomSignalSourceInput | null): boolean { + return this.getAvailabilityState(source).enabled; } - async refreshAvailability(): Promise { - this.availabilityState.set({ enabled: false, + isLoading(source?: RoomSignalSourceInput | null): boolean { + return this.getAvailabilityState(source).loading; + } + + async refreshAvailability(source?: RoomSignalSourceInput | null): Promise { + const selector = this.getSourceSelector(source); + const key = this.getAvailabilityKey(selector); + + this.setAvailabilityState(key, { enabled: false, loading: true }); try { const response = await firstValueFrom( this.http.get( - `${this.serverDirectory.getApiBaseUrl()}/klipy/config` + `${this.serverDirectory.getApiBaseUrl(selector)}/klipy/config` ) ); - this.availabilityState.set({ + this.setAvailabilityState(key, { enabled: response.enabled === true, loading: false }); } catch { - this.availabilityState.set({ enabled: false, + this.setAvailabilityState(key, { enabled: false, loading: false }); } } @@ -89,8 +86,11 @@ export class KlipyService { searchGifs( query: string, page = 1, - perPage = DEFAULT_PAGE_SIZE + perPage = DEFAULT_PAGE_SIZE, + source?: RoomSignalSourceInput | null ): Observable { + const selector = this.getSourceSelector(source); + let params = new HttpParams() .set('page', String(Math.max(1, Math.floor(page)))) .set('per_page', String(Math.max(1, Math.floor(perPage)))) @@ -109,7 +109,7 @@ export class KlipyService { } return this.http - .get(`${this.serverDirectory.getApiBaseUrl()}/klipy/gifs`, { params }) + .get(`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/gifs`, { params }) .pipe( map((response) => ({ enabled: response.enabled !== false, @@ -138,7 +138,7 @@ export class KlipyService { return this.normalizeMediaUrl(url); } - buildImageProxyUrl(url: string): string { + buildImageProxyUrl(url: string, source?: RoomSignalSourceInput | null): string { const trimmed = this.normalizeMediaUrl(url); if (!trimmed) @@ -147,7 +147,36 @@ export class KlipyService { if (!/^https?:\/\//i.test(trimmed)) return trimmed; - return `${this.serverDirectory.getApiBaseUrl()}/image-proxy?url=${encodeURIComponent(trimmed)}`; + return `${this.serverDirectory.getApiBaseUrl(this.getSourceSelector(source))}/image-proxy?url=${encodeURIComponent(trimmed)}`; + } + + private getAvailabilityState(source?: RoomSignalSourceInput | null): KlipyAvailabilityState { + return this.availabilityByKey()[this.getAvailabilityKey(this.getSourceSelector(source))] + ?? { enabled: false, + loading: true }; + } + + private setAvailabilityState(key: string, state: KlipyAvailabilityState): void { + this.availabilityByKey.update((availabilityByKey) => ({ + ...availabilityByKey, + [key]: state + })); + } + + private getSourceSelector(source?: RoomSignalSourceInput | null): ServerSourceSelector | undefined { + return this.serverDirectory.buildRoomSignalSelector(source ?? undefined); + } + + private getAvailabilityKey(selector?: ServerSourceSelector): string { + if (selector?.sourceId) { + return `id:${selector.sourceId}`; + } + + if (selector?.sourceUrl) { + return `url:${selector.sourceUrl}`; + } + + return DEFAULT_AVAILABILITY_KEY; } private getPreferredLocale(): string | null { diff --git a/toju-app/src/app/domains/chat/feature/chat-image-proxy-fallback.directive.ts b/toju-app/src/app/domains/chat/feature/chat-image-proxy-fallback.directive.ts index 8793ce8..3230113 100644 --- a/toju-app/src/app/domains/chat/feature/chat-image-proxy-fallback.directive.ts +++ b/toju-app/src/app/domains/chat/feature/chat-image-proxy-fallback.directive.ts @@ -8,6 +8,7 @@ import { signal } from '@angular/core'; import { KlipyService } from '../application/services/klipy.service'; +import type { RoomSignalSourceInput } from '../../server-directory'; @Directive({ selector: 'img[appChatImageProxyFallback]', @@ -15,6 +16,7 @@ import { KlipyService } from '../application/services/klipy.service'; }) export class ChatImageProxyFallbackDirective { readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' }); + readonly signalSource = input(null); private readonly klipy = inject(KlipyService); private readonly renderedSource = signal(''); @@ -38,7 +40,7 @@ export class ChatImageProxyFallbackDirective { return; } - const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl()); + const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl(), this.signalSource()); if (!proxyUrl || proxyUrl === this.renderedSource()) { return; diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html index 0c15dee..7014f6d 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.html @@ -23,6 +23,8 @@ diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts index ec369b1..140337c 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts @@ -4,6 +4,7 @@ import { HostListener, ViewChild, computed, + effect, inject, signal } from '@angular/core'; @@ -11,7 +12,7 @@ import { Store } from '@ngrx/store'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { RealtimeSessionFacade } from '../../../../core/realtime'; import { Attachment, AttachmentFacade } from '../../../attachment'; -import { KlipyGif } from '../../application/services/klipy.service'; +import { KlipyGif, KlipyService } from '../../application/services/klipy.service'; import { MessagesActions } from '../../../../store/messages/messages.actions'; import { selectAllMessages, @@ -54,10 +55,11 @@ export class ChatMessagesComponent { private readonly store = inject(Store); private readonly webrtc = inject(RealtimeSessionFacade); private readonly attachmentsSvc = inject(AttachmentFacade); + private readonly klipy = inject(KlipyService); readonly allMessages = this.store.selectSignal(selectAllMessages); private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); - private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); + readonly currentRoom = this.store.selectSignal(selectCurrentRoom); readonly loading = this.store.selectSignal(selectMessagesLoading); readonly syncing = this.store.selectSignal(selectMessagesSyncing); @@ -78,6 +80,7 @@ export class ChatMessagesComponent { readonly conversationKey = computed( () => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}` ); + readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom())); readonly composerBottomPadding = signal(140); readonly klipyGifPickerAnchorRight = signal(16); readonly replyTo = signal(null); @@ -85,6 +88,12 @@ export class ChatMessagesComponent { readonly lightboxAttachment = signal(null); readonly imageContextMenu = signal(null); + constructor() { + effect(() => { + void this.klipy.refreshAvailability(this.currentRoom()); + }); + } + @HostListener('window:resize') onWindowResize(): void { if (this.showKlipyGifPicker()) { diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html index 56f0350..ce2cc57 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html @@ -133,7 +133,7 @@ (drop)="onDrop($event)" >
- @if (klipy.isEnabled()) { + @if (klipyEnabled()) {