From c247024bcd4faa7c86561cfc8a6c9a25befac793 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Wed, 28 Oct 2026 06:28:36 -0400 Subject: [PATCH] fix(presence): your own status shows at once (and clears) in the member list Reported: after changing your status it did not appear in the members drawer until you switched rooms. The SDK creates our own User object with `new User()` instead of User.createUser, so its User.* events are never re-emitted on the client. The shared presence store (PERF-1) listens on the client, so it never heard our own presence changes; a remount (room switch) re-read the User and showed it. The store now also refreshes on the ClientEvent.Event the SDK emits for every sync presence event (after updating the User). Also read the status from the latest presence event: the SDK only overwrites presenceStatusMsg with a non-empty status_msg, so a cleared status lingered (for everyone) until reload. e2e: set a quick status, close settings, it shows in the drawer; clear it, it goes. Fails without the fix (status never appears). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- e2e/local-homeserver.spec.ts | 31 +++++++++++++++++++++++++++++++ src/app/hooks/useUserPresence.ts | 31 +++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) 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; }