Commit 48af82777108
tarball3 files changed+133-4
@@ -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>@@ -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
+}@@ -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: false26
+ }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
+});