From 71548ec2ad34bc09bd52ddd591548db5a3b2dba1 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Fri, 22 May 2026 23:41:31 +0100 Subject: [PATCH] Resolve invitees via /state and accept 'invite' membership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For chats where we sent an opening message but the recipient never accepted, /sync drops the room with no heroes and the recipient is in state as m.room.member with membership:'invite' — not 'join'. chat.reddit.com's web UI labels these rooms with the invitee anyway, because it reads the full room state. - roomToActiveChat now accepts 'invite' membership in addition to 'join' when scanning member-join state events. - enrichUsernames pass 2 hits /_matrix/client/v3/rooms/{id}/state first (cheap, returns all member events including invites) and only falls back to /messages history if /state yields nothing. - Added MatrixClient.getRoomFullState to back that lookup. Co-Authored-By: Claude Opus 4.7 --- src/services/chat/matrix/client.ts | 13 +++++ src/services/chat/service.ts | 76 ++++++++++++++++++++---------- 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/src/services/chat/matrix/client.ts b/src/services/chat/matrix/client.ts index add9ebe..16140ac 100644 --- a/src/services/chat/matrix/client.ts +++ b/src/services/chat/matrix/client.ts @@ -276,6 +276,19 @@ export class MatrixClient { return res.joined; } + /** + * Returns the room's full state — every state event, including + * m.room.member entries with `membership: 'invite'` for chats the + * recipient hasn't accepted yet. Used to recover usernames for chats + * where /sync omitted heroes and state. + */ + async getRoomFullState(roomId: string): Promise { + return this.request( + 'GET', + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state`, + ); + } + /** Returns the room's m.room.name content if set, else null. */ async getRoomName(roomId: string): Promise { try { diff --git a/src/services/chat/service.ts b/src/services/chat/service.ts index a312a54..3a324f0 100644 --- a/src/services/chat/service.ts +++ b/src/services/chat/service.ts @@ -830,7 +830,14 @@ export class ChatService { if (ev.type !== 'm.room.member') continue; const stateKey = (ev as { state_key?: string }).state_key; const membership = (ev.content as { membership?: string }).membership; - if (membership === 'join' && stateKey && stateKey !== this.matrix.userId) { + // Include 'invite' too — for outgoing chats the recipient never + // accepted, their MXID is only in state as an invite, not a join. + // chat.reddit.com's web UI reads the same to display the invitee. + if ( + (membership === 'join' || membership === 'invite') && + stateKey && + stateKey !== this.matrix.userId + ) { memberJoins.push(stateKey); } } @@ -1028,9 +1035,10 @@ export class ChatService { } // Pass 2 — chats still stuck on 'unknown' (or a bare MXID) didn't get a - // t2 ID from /sync heroes/state/timeline. Try to mine one from /messages: - // a single 'b' page is enough to surface the recipient's MXID on any - // room that has ever exchanged a message. + // t2 ID from /sync heroes/state/timeline. Try /state first — it returns + // every m.room.member event including 'invite'-membership ones, which is + // how chat.reddit.com's web UI names rooms the recipient never accepted. + // Fall back to mining a /messages page if /state somehow yields nothing. for (const chat of this.cache.get()) { if (!chat.roomId) continue; if (this.unresolvableRooms.has(chat.roomId)) continue; @@ -1039,30 +1047,48 @@ export class ChatService { chat.username === 'unknown' || chat.username.startsWith('@'); if (!needsLookup) continue; + let t2: string | undefined; try { - const res = await this.withAuth(() => - this.matrix.getRoomMessages(chat.roomId!, { limit: 20, dir: 'b' }), - ); - const otherSender = res.chunk?.find( - (e) => e.sender && e.sender !== this.matrix.userId, - )?.sender; - const t2 = otherSender?.match(/^@(t2_[^:]+):/)?.[1]; - if (t2) { - const bare = t2.replace(/^t2_/, ''); - const known = this.t2ToUsername.get(bare); - chat.username = known ?? t2; - if (!chat.roomName || chat.roomName === 'unknown' || chat.roomName.startsWith('@')) { - chat.roomName = chat.username; - } - dirty = true; - } else { - // No other party has ever sent a message here (we sent an opener - // that was never accepted, etc). Don't retry every sync. - this.unresolvableRooms.add(chat.roomId); - } + const state = await this.withAuth(() => this.matrix.getRoomFullState(chat.roomId!)); + const otherMember = state.find((e) => { + if (e.type !== 'm.room.member') return false; + const sk = (e as { state_key?: string }).state_key; + const membership = (e.content as { membership?: string }).membership; + return ( + sk && + sk !== this.matrix.userId && + (membership === 'join' || membership === 'invite') + ); + }) as { state_key?: string } | undefined; + t2 = otherMember?.state_key?.match(/^@(t2_[^:]+):/)?.[1]; } catch (err) { + this.logger.debug(`[chat] /state lookup failed for ${chat.roomId}:`, err); + } + if (!t2) { + try { + const res = await this.withAuth(() => + this.matrix.getRoomMessages(chat.roomId!, { limit: 20, dir: 'b' }), + ); + const otherSender = res.chunk?.find( + (e) => e.sender && e.sender !== this.matrix.userId, + )?.sender; + t2 = otherSender?.match(/^@(t2_[^:]+):/)?.[1]; + } catch (err) { + this.logger.debug(`[chat] /messages lookup failed for ${chat.roomId}:`, err); + } + } + if (t2) { + const bare = t2.replace(/^t2_/, ''); + const known = this.t2ToUsername.get(bare); + chat.username = known ?? `t2_${bare}`; + if (!chat.roomName || chat.roomName === 'unknown' || chat.roomName.startsWith('@')) { + chat.roomName = chat.username; + } + dirty = true; + } else { + // Neither /state nor /messages surfaced a t2 — give up on this room + // so we don't keep hammering the homeserver every sync. this.unresolvableRooms.add(chat.roomId); - this.logger.debug(`[chat] history lookup failed for ${chat.roomId}:`, err); } }