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 d146c46..a9ce375 100644 Binary files a/server/data/metoyou.sqlite and b/server/data/metoyou.sqlite differ 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()) {