Resolve invitees via /state and accept 'invite' membership

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 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.7 committed 2026-05-22 23:41:31 +01:00
1 parent cf7fc41761
commit 71548ec2ad
2 files changed
+64 -25

No files matched your search

+13
View File
@@ -276,6 +276,19 @@ export class MatrixClient {
return res.joined; 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<MatrixTimelineEvent[]> {
return this.request<MatrixTimelineEvent[]>(
'GET',
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state`,
);
}
/** Returns the room's m.room.name content if set, else null. */ /** Returns the room's m.room.name content if set, else null. */
async getRoomName(roomId: string): Promise<string | null> { async getRoomName(roomId: string): Promise<string | null> {
try { try {
+51 -25
View File
@@ -830,7 +830,14 @@ export class ChatService {
if (ev.type !== 'm.room.member') continue; if (ev.type !== 'm.room.member') continue;
const stateKey = (ev as { state_key?: string }).state_key; const stateKey = (ev as { state_key?: string }).state_key;
const membership = (ev.content as { membership?: string }).membership; 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); 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 // 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: // t2 ID from /sync heroes/state/timeline. Try /state first — it returns
// a single 'b' page is enough to surface the recipient's MXID on any // every m.room.member event including 'invite'-membership ones, which is
// room that has ever exchanged a message. // 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()) { for (const chat of this.cache.get()) {
if (!chat.roomId) continue; if (!chat.roomId) continue;
if (this.unresolvableRooms.has(chat.roomId)) continue; if (this.unresolvableRooms.has(chat.roomId)) continue;
@@ -1039,30 +1047,48 @@ export class ChatService {
chat.username === 'unknown' || chat.username === 'unknown' ||
chat.username.startsWith('@'); chat.username.startsWith('@');
if (!needsLookup) continue; if (!needsLookup) continue;
let t2: string | undefined;
try { try {
const res = await this.withAuth(() => const state = await this.withAuth(() => this.matrix.getRoomFullState(chat.roomId!));
this.matrix.getRoomMessages(chat.roomId!, { limit: 20, dir: 'b' }), const otherMember = state.find((e) => {
); if (e.type !== 'm.room.member') return false;
const otherSender = res.chunk?.find( const sk = (e as { state_key?: string }).state_key;
(e) => e.sender && e.sender !== this.matrix.userId, const membership = (e.content as { membership?: string }).membership;
)?.sender; return (
const t2 = otherSender?.match(/^@(t2_[^:]+):/)?.[1]; sk &&
if (t2) { sk !== this.matrix.userId &&
const bare = t2.replace(/^t2_/, ''); (membership === 'join' || membership === 'invite')
const known = this.t2ToUsername.get(bare); );
chat.username = known ?? t2; }) as { state_key?: string } | undefined;
if (!chat.roomName || chat.roomName === 'unknown' || chat.roomName.startsWith('@')) { t2 = otherMember?.state_key?.match(/^@(t2_[^:]+):/)?.[1];
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);
}
} catch (err) { } 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.unresolvableRooms.add(chat.roomId);
this.logger.debug(`[chat] history lookup failed for ${chat.roomId}:`, err);
} }
} }