atagen

malice

public
Star0
Watch0

Commit 48af82777108

tarball

atagen <boss@atagen.co>2026-10-26 10:41 UTC

3 files changed+133-4
web: fill mention senders from the room roster

macltok fe81a9c records events that arrive after a mention's capture without sender profiles and never recaptures them, so saved context rows and pending inbox rows show bare user ids until a live context fetch. Rooms holding such rows fetch their members once and fill the missing senders until the engine fix lands.
Modified file: web/src/lib/MentionsInbox.svelte+25 additions−4 deletions
@@ -1,7 +1,7 @@
11 <script lang="ts">
22 import { get } from 'svelte/store';
33 import { onDestroy, onMount, tick } from 'svelte';
4- import { mentionContext, mentionsPage, recoverMentions, retryMentionContext, setMentionsRead, markMentionsReadThrough } from './api';
4+ import { getMembers, mentionContext, mentionsPage, recoverMentions, retryMentionContext, setMentionsRead, markMentionsReadThrough } from './api';
55 import ContextTimeline from './ContextTimeline.svelte';
66 import EmojiText from './EmojiText.svelte';
77 import MessageRow from './MessageRow.svelte';
@@ -10,6 +10,8 @@
1010 import { ApiError } from './transport';
1111 import type { TimelineItem } from './types';
1212 import { mentionKey } from './mentions-model';
13+ import { contextWithProfiles, roomsLackingProfiles, withProfiles } from './mention-profiles';
14+ import type { RoomProfiles } from './mention-profiles';
1315 import type { MentionEntry, MentionsPage, SavedMentionContext } from './mentions-model';
1416
1517 export let self: string;
@@ -40,10 +42,16 @@
4042 let queuedContext: MentionEntry | null = null;
4143 let pendingExplicitOpen = false;
4244 let savedListTop = 0;
45+ let roomProfiles: RoomProfiles = {};
46+ let profileRequests = new Set<string>();
4347 $: unreadCount = $mentionsSnapshot?.boundary.account === self ? $mentionsSnapshot.unread_count : page?.unread_count ?? 0;
4448 $: joinedRooms = new Map($rooms.filter((room) => room.membership === 'Joined').map((room) => [room.room_id, room]));
4549 $: selectedRoom = selected ? joinedRooms.get(selected.key.room_id) : undefined;
4650 $: contextCanPaginate = !!selectedRoom;
51+ $: requestProfiles([
52+ ...entries.map((entry) => ({ roomId: entry.key.room_id, item: entry.event })),
53+ ...(selected && saved?.context ? [...saved.context.events_before, saved.context.event, ...saved.context.events_after].map((item) => ({ roomId: selected!.key.room_id, item })) : [])
54+ ], joinedRooms);
4755
4856 function itemEvent(item: TimelineItem) {
4957 return item.kind === 'event' ? item.event : null;
@@ -75,6 +83,17 @@
7583 return state.kind === 'Ready' ? 'Context saved' : state.kind === 'Partial' ? 'Partial context saved' : state.kind === 'Pending' ? 'Saving context' : 'Context unavailable';
7684 }
7785 function keyOf(entry: MentionEntry): string { return mentionKey(entry.key); }
86+ function requestProfiles(items: { roomId: string; item: TimelineItem }[], joined: Map<string, unknown>): void {
87+ const account = self;
88+ const requests = profileRequests;
89+ for (const roomId of roomsLackingProfiles(items)) {
90+ if (requests.has(roomId) || !joined.has(roomId)) continue;
91+ requests.add(roomId);
92+ getMembers(roomId).then(({ members }) => {
93+ if (currentAccount(account) && requests === profileRequests) roomProfiles = { ...roomProfiles, [roomId]: Object.fromEntries(members.map((member) => [member.user_id, member])) };
94+ }, () => requests.delete(roomId));
95+ }
96+ }
7897 function bindViewport(node: HTMLDivElement) {
7998 list = node;
8099 viewport?.releaseScroll();
@@ -394,6 +413,8 @@
394413 pendingExplicitOpen = false;
395414 pendingRevision = 0;
396415 savedListTop = 0;
416+ roomProfiles = {};
417+ profileRequests = new Set();
397418 if (refreshTimer) clearTimeout(refreshTimer);
398419 renderedAccount = self;
399420 if (mounted) void fetchRange(1, generation, self);
@@ -418,7 +439,7 @@
418439 {#if selected}
419440 {#if error}<div class="error" role="alert"><EmojiText text={error} /><button disabled={loadingContext} onclick={() => void loadContext(selected!)}>Retry</button></div>{/if}
420441 {#if saved?.context}
421- <ContextTimeline context={saved.context} roomId={selected.key.room_id} eventId={selected.key.event_id} roomName={roomName(selected)} {self} canPaginate={contextCanPaginate} onClose={backToList} {onLive} />
442+ <ContextTimeline context={contextWithProfiles(saved.context, roomProfiles[selected.key.room_id])} roomId={selected.key.room_id} eventId={selected.key.event_id} roomName={roomName(selected)} {self} canPaginate={contextCanPaginate} onClose={backToList} {onLive} />
422443 {#if selected.capture.kind === 'Partial' || selected.capture.kind === 'Failed' || selected.capture.kind === 'Unavailable' || saved.state.kind === 'Partial' || saved.state.kind === 'Failed'}<button class="capture-retry" disabled={!selectedRoom || actionBusy} onclick={retryCapture}>Retry context capture</button>{/if}
423444 {#if !contextCanPaginate}<p class="status">This room is no longer joined. Saved context remains available.</p>{/if}
424445 {#if saved.state.kind === 'Partial'}<p class="status">This saved context is partial.</p>{/if}
@@ -431,7 +452,7 @@
431452 {:else if saved?.state.kind === 'Unavailable'}<p class="status">Saved context is unavailable.</p>
432453 {:else if saved?.state.kind === 'Partial'}<p class="status">Saved context is partial and currently unavailable.</p>
433454 {:else}<p class="status">Saved context is not available yet.</p>{/if}
434- {@const preview = itemEvent(selected.event)}
455+ {@const preview = itemEvent(withProfiles(selected.event, roomProfiles[selected.key.room_id]))}
435456 {#if preview}<MessageRow itemId={preview.item_id} event={preview} showSender={true} reacting={false} onReact={() => {}} readOnly={true} showSent={false} {self} senderAvatar={preview.sender.avatar} />{/if}
436457 {#if selected.capture.kind === 'Failed' || selected.capture.kind === 'Partial' || selected.capture.kind === 'Unavailable'}
437458 <button disabled={!selectedRoom || actionBusy} onclick={retryCapture}>Retry context capture</button>
@@ -458,7 +479,7 @@
458479 <div class="entries" use:bindViewport onscroll={() => { if (!viewport?.consumeScroll()) viewport?.capture(); }}>
459480 <div class="entry-content">
460481 {#each entries as entry (keyOf(entry))}
461- {@const event = itemEvent(entry.event)}
482+ {@const event = itemEvent(withProfiles(entry.event, roomProfiles[entry.key.room_id]))}
462483 <article class:unread={!entry.read}>
463484 <div class="entry-head"><button class="room" onclick={() => openContext(entry)}><EmojiText text={roomName(entry)} /></button><time>{new Date(entry.timestamp_ms).toLocaleString()}</time><span>{entry.read ? 'Read' : 'Unread'}</span></div>
464485 <div class="labels">{#each entry.reasons as reason}<span>{reasonLabel(reason)}</span>{/each}<span>{captureLabel(entry.capture)}</span>{#if entry.notification_suppressed}<span>Notifications suppressed</span>{/if}</div>
Added file: web/src/lib/mention-profiles.ts+45 additions
@@ -0,0 +1,45 @@
1+import type { EventContext, MemberSummary, Sender, TimelineItem } from './types';
2+
3+export type RoomProfiles = Record<string, Record<string, MemberSummary>>;
4+
5+export function lacksProfile(sender: Sender): boolean {
6+ return !sender.display_name && !sender.avatar;
7+}
8+
9+/** Rooms holding a sender or reply sender that came back with no name and no avatar. */
10+export function roomsLackingProfiles(items: { roomId: string; item: TimelineItem }[]): Set<string> {
11+ const rooms = new Set<string>();
12+ for (const { roomId, item } of items) {
13+ if (item.kind !== 'event') continue;
14+ const reply = item.event.reply_to?.details;
15+ if (lacksProfile(item.event.sender) || (reply?.kind === 'Ready' && lacksProfile(reply.sender))) rooms.add(roomId);
16+ }
17+ return rooms;
18+}
19+
20+function filled(sender: Sender, members: Record<string, MemberSummary> | undefined): Sender {
21+ const member = members?.[sender.user_id];
22+ if (!member || !lacksProfile(sender) || (!member.display_name && !member.avatar)) return sender;
23+ return { ...sender, display_name: member.display_name, avatar: member.avatar, name_ambiguous: member.name_ambiguous };
24+}
25+
26+export function withProfiles(item: TimelineItem, members: Record<string, MemberSummary> | undefined): TimelineItem {
27+ if (item.kind !== 'event' || !members) return item;
28+ const { reply_to: reply } = item.event;
29+ const details = reply?.details;
30+ const sender = filled(item.event.sender, members);
31+ const replySender = details?.kind === 'Ready' ? filled(details.sender, members) : null;
32+ if (sender === item.event.sender && (details?.kind !== 'Ready' || replySender === details.sender)) return item;
33+ const reply_to = reply && details?.kind === 'Ready' && replySender ? { ...reply, details: { ...details, sender: replySender } } : reply;
34+ return { ...item, event: { ...item.event, sender, reply_to } };
35+}
36+
37+export function contextWithProfiles(context: EventContext, members: Record<string, MemberSummary> | undefined): EventContext {
38+ if (!members) return context;
39+ return {
40+ ...context,
41+ events_before: context.events_before.map((item) => withProfiles(item, members)),
42+ event: withProfiles(context.event, members),
43+ events_after: context.events_after.map((item) => withProfiles(item, members))
44+ };
45+}
Added file: web/test/mention-profiles.test.ts+63 additions
@@ -0,0 +1,63 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { contextWithProfiles, roomsLackingProfiles, withProfiles } from '../src/lib/mention-profiles.ts';
4+import type { MemberSummary, Sender, TimelineItem } from '../src/lib/types.ts';
5+
6+const bare = (user_id: string): Sender => ({ user_id, display_name: null, avatar: null, name_ambiguous: false });
7+
8+function message(sender: Sender, replySender: Sender | null = null): TimelineItem {
9+ return {
10+ kind: 'event',
11+ event: {
12+ item_id: `i-${sender.user_id}`,
13+ event_id: `$e-${sender.user_id}`,
14+ transaction_id: null,
15+ sender,
16+ timestamp_ms: 1,
17+ own: false,
18+ send_state: { kind: 'Sent' },
19+ content: { kind: 'Redacted' },
20+ reply_to: replySender ? { event_id: '$target', details: { kind: 'Ready', sender: replySender, content: { kind: 'Redacted' }, timestamp_ms: 0 } } : null,
21+ thread_root: null,
22+ thread_summary: null,
23+ reactions: [],
24+ read_by: [],
25+ editable: false
26+ }
27+ };
28+}
29+
30+function member(user_id: string, display_name: string | null, avatar: string | null = null): MemberSummary {
31+ return { user_id, name: display_name ?? user_id, display_name, avatar, membership: 'Join', own: false, name_ambiguous: false, ignored: false, service: false };
32+}
33+
34+const members = { '@carol:x': member('@carol:x', 'Carol', 'mxc://x/c'), '@dave:x': member('@dave:x', 'Dave') };
35+
36+test('lists only rooms with a sender or reply sender missing both name and avatar', () => {
37+ const named = { ...bare('@carol:x'), display_name: 'Carol' };
38+ const rooms = roomsLackingProfiles([
39+ { roomId: '!a:x', item: message(named) },
40+ { roomId: '!b:x', item: message(bare('@carol:x')) },
41+ { roomId: '!c:x', item: message(named, bare('@dave:x')) },
42+ { roomId: '!d:x', item: { kind: 'readMarker' } }
43+ ]);
44+ assert.deepEqual([...rooms].sort(), ['!b:x', '!c:x']);
45+});
46+
47+test('fills the sender and reply sender from the room members', () => {
48+ const filled = withProfiles(message(bare('@carol:x'), bare('@dave:x')), members);
49+ assert.equal(filled.kind, 'event');
50+ if (filled.kind !== 'event') return;
51+ assert.equal(filled.event.sender.display_name, 'Carol');
52+ assert.equal(filled.event.sender.avatar, 'mxc://x/c');
53+ assert.equal(filled.event.reply_to?.details.kind === 'Ready' && filled.event.reply_to.details.sender.display_name, 'Dave');
54+});
55+
56+test('keeps an engine profile and the same item when nothing changes', () => {
57+ const named = message({ ...bare('@carol:x'), display_name: 'Carol from the engine' });
58+ assert.equal(withProfiles(named, members), named);
59+ const unknown = message(bare('@erin:x'));
60+ assert.equal(withProfiles(unknown, members), unknown);
61+ const context = { events_before: [unknown], event: named, events_after: [], start_token: null, end_token: null };
62+ assert.equal(contextWithProfiles(context, undefined), context);
63+});