diff --git a/e2e/local-homeserver.spec.ts b/e2e/local-homeserver.spec.ts index c2a279828..18586e2a6 100644 --- a/e2e/local-homeserver.spec.ts +++ b/e2e/local-homeserver.spec.ts @@ -473,6 +473,37 @@ test.describe('local homeserver regression', () => { await expect(page.getByText(/Status set\. Sharing it with others/)).toHaveCount(0); }); + test('own status shows in the members drawer without switching rooms, and clears', async ({ + page, + }) => { + // The SDK creates our own User with `new User()` (no event re-emit), so the + // shared presence store missed our own presence until a remount. Reported: + // a new status only appeared after switching rooms. + await page.setViewportSize({ width: 1440, height: 900 }); + const me = await ensureUser(uniq('own')); + const room = await createRoom(me, 'Own Status Room'); + await sendText(me, room, 'hello'); + await loginUI(page, me); + await openRoom(page, room); + await page.getByRole('button', { name: 'User Settings' }).click(); + await page.getByRole('dialog').first().getByText('Account', { exact: true }).first().click(); + await page + .getByRole('group', { name: 'Quick statuses' }) + .getByRole('button', { name: /Gaming/ }) + .click(); + await page.keyboard.press('Escape'); + await expect(page.getByText('🎮 Gaming')).toBeVisible({ timeout: 15_000 }); + + await page.getByRole('button', { name: 'User Settings' }).click(); + await page.getByRole('dialog').first().getByText('Account', { exact: true }).first().click(); + await page + .getByRole('button', { name: /^Clear/ }) + .first() + .click(); + await page.keyboard.press('Escape'); + await expect(page.getByText('🎮 Gaming')).toHaveCount(0, { timeout: 15_000 }); + }); + test('touch: long-press opens the message action sheet (#166)', async ({ browser }) => { const ctx = await browser.newContext({ ...devices['Pixel 7'] }); const page = await ctx.newPage(); diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index fea6a5526..612bba747 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -1,5 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; -import { MatrixClient, User, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; +import { + ClientEvent, + MatrixClient, + MatrixEvent, + User, + UserEvent, + UserEventHandlerMap, +} from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; export enum Presence { @@ -19,7 +26,12 @@ export type UserPresence = { const getUserPresence = (user: User): UserPresence => ({ presence: user.presence as Presence, - status: user.presenceStatusMsg, + // From the latest presence event rather than user.presenceStatusMsg: the SDK + // only overwrites that field with a non-empty status_msg, so a cleared status + // would otherwise linger until reload. + status: user.events.presence + ? (user.events.presence.getContent().status_msg as string | undefined) || undefined + : user.presenceStatusMsg, active: user.currentlyActive, lastActiveTs: user.getLastActiveTs(), }); @@ -55,6 +67,19 @@ class PresenceStore { subs.forEach((cb) => cb()); }; + // Our own User object is created by the SDK with `new User()` (not + // User.createUser), so its User.* events are never re-emitted on the client + // and the listeners above miss our own presence changes. The client does + // emit every sync presence event as ClientEvent.Event (after updating the + // User), so refresh from that too. + private handleClientEvent = (event: MatrixEvent): void => { + if (event.getType() !== 'm.presence' || !this.mx) return; + const userId = event.getSender(); + if (!userId) return; + const user = this.mx.getUser(userId); + if (user) this.handleEvent(event, user); + }; + private start(mx: MatrixClient): void { if (this.started && this.mx === mx) return; // A different client (re-login without reload) — drop the old listeners. @@ -62,12 +87,14 @@ class PresenceStore { this.mx.removeListener(UserEvent.Presence, this.handleEvent); this.mx.removeListener(UserEvent.CurrentlyActive, this.handleEvent); this.mx.removeListener(UserEvent.LastPresenceTs, this.handleEvent); + this.mx.removeListener(ClientEvent.Event, this.handleClientEvent); this.cache.clear(); } this.mx = mx; mx.on(UserEvent.Presence, this.handleEvent); mx.on(UserEvent.CurrentlyActive, this.handleEvent); mx.on(UserEvent.LastPresenceTs, this.handleEvent); + mx.on(ClientEvent.Event, this.handleClientEvent); this.started = true; }