atagen

malice

public
Star0
Watch0

Commit fe847416ebd8

tarball

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

7 files changed+10-139
macltok: bump to 3eee54c and drop the mention roster fill

macltok now repairs saved mention rows whose sender has no profile in a background pass and refetches members when a capture misses senders, so the per-room getMembers fill from 48af8277 is redundant.
Modified file: Cargo.lock+2 additions−2 deletions
@@ -2250,7 +2250,7 @@ checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
22502250 [[package]]
22512251 name = "macltok"
22522252 version = "0.1.0"
2253-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=aced11e80b47de7a887296b10eedff310df5360f#aced11e80b47de7a887296b10eedff310df5360f"
2253+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=3eee54c8ed63154fa8fe3d292e7556b4c19379fa#3eee54c8ed63154fa8fe3d292e7556b4c19379fa"
22542254 dependencies = [
22552255 "futures-util",
22562256 "getrandom 0.2.17",
@@ -2283,7 +2283,7 @@ dependencies = [
22832283 [[package]]
22842284 name = "macltok-push-gateway"
22852285 version = "0.1.0"
2286-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=aced11e80b47de7a887296b10eedff310df5360f#aced11e80b47de7a887296b10eedff310df5360f"
2286+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=3eee54c8ed63154fa8fe3d292e7556b4c19379fa#3eee54c8ed63154fa8fe3d292e7556b4c19379fa"
22872287 dependencies = [
22882288 "ruma-common",
22892289 "serde",
Modified file: Cargo.toml+2 additions−2 deletions
@@ -3,8 +3,8 @@ resolver = "3"
33 members = ["crates/malice-core", "crates/malice-push", "crates/malice-server", "crates/malice-static"]
44
55 [workspace.dependencies]
6-macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "aced11e80b47de7a887296b10eedff310df5360f" }
7-macltok-push-gateway = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "aced11e80b47de7a887296b10eedff310df5360f" }
6+macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "3eee54c8ed63154fa8fe3d292e7556b4c19379fa" }
7+macltok-push-gateway = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "3eee54c8ed63154fa8fe3d292e7556b4c19379fa" }
88 ruma = { version = "0.16", default-features = false }
99 serde = { version = "1", features = ["derive"] }
1010 serde_json = "1"
Modified file: crates/malice-worker/Cargo.lock+1 additions−1 deletions
@@ -1999,7 +1999,7 @@ checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
19991999 [[package]]
20002000 name = "macltok"
20012001 version = "0.1.0"
2002-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=aced11e80b47de7a887296b10eedff310df5360f#aced11e80b47de7a887296b10eedff310df5360f"
2002+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=3eee54c8ed63154fa8fe3d292e7556b4c19379fa#3eee54c8ed63154fa8fe3d292e7556b4c19379fa"
20032003 dependencies = [
20042004 "futures-util",
20052005 "getrandom 0.2.17",
Modified file: crates/malice-worker/Cargo.toml+1 additions−1 deletions
@@ -14,7 +14,7 @@ publish = false
1414 crate-type = ["cdylib", "rlib"]
1515
1616 [target.'cfg(target_family = "wasm")'.dependencies]
17-macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "aced11e80b47de7a887296b10eedff310df5360f", features = ["indexeddb", "serde", "mirror"] }
17+macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "3eee54c8ed63154fa8fe3d292e7556b4c19379fa", features = ["indexeddb", "serde", "mirror"] }
1818 malice-core = { path = "../malice-core" }
1919 ruma = { version = "0.16", default-features = false }
2020 futures-util = { version = "0.3", default-features = false, features = ["std"] }
Modified file: web/src/lib/MentionsInbox.svelte+4 additions−25 deletions
@@ -1,7 +1,7 @@
11 <script lang="ts">
22 import { get } from 'svelte/store';
33 import { onDestroy, onMount, tick } from 'svelte';
4- import { getMembers, mentionContext, mentionsPage, recoverMentions, retryMentionContext, setMentionsRead, markMentionsReadThrough } from './api';
4+ import { 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,8 +10,6 @@
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';
1513 import type { MentionEntry, MentionsPage, SavedMentionContext } from './mentions-model';
1614
1715 export let self: string;
@@ -42,16 +40,10 @@
4240 let queuedContext: MentionEntry | null = null;
4341 let pendingExplicitOpen = false;
4442 let savedListTop = 0;
45- let roomProfiles: RoomProfiles = {};
46- let profileRequests = new Set<string>();
4743 $: unreadCount = $mentionsSnapshot?.boundary.account === self ? $mentionsSnapshot.unread_count : page?.unread_count ?? 0;
4844 $: joinedRooms = new Map($rooms.filter((room) => room.membership === 'Joined').map((room) => [room.room_id, room]));
4945 $: selectedRoom = selected ? joinedRooms.get(selected.key.room_id) : undefined;
5046 $: 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);
5547
5648 function itemEvent(item: TimelineItem) {
5749 return item.kind === 'event' ? item.event : null;
@@ -83,17 +75,6 @@
8375 return state.kind === 'Ready' ? 'Context saved' : state.kind === 'Partial' ? 'Partial context saved' : state.kind === 'Pending' ? 'Saving context' : 'Context unavailable';
8476 }
8577 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- }
9778 function bindViewport(node: HTMLDivElement) {
9879 list = node;
9980 viewport?.releaseScroll();
@@ -413,8 +394,6 @@
413394 pendingExplicitOpen = false;
414395 pendingRevision = 0;
415396 savedListTop = 0;
416- roomProfiles = {};
417- profileRequests = new Set();
418397 if (refreshTimer) clearTimeout(refreshTimer);
419398 renderedAccount = self;
420399 if (mounted) void fetchRange(1, generation, self);
@@ -439,7 +418,7 @@
439418 {#if selected}
440419 {#if error}<div class="error" role="alert"><EmojiText text={error} /><button disabled={loadingContext} onclick={() => void loadContext(selected!)}>Retry</button></div>{/if}
441420 {#if saved?.context}
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} />
421+ <ContextTimeline context={saved.context} roomId={selected.key.room_id} eventId={selected.key.event_id} roomName={roomName(selected)} {self} canPaginate={contextCanPaginate} onClose={backToList} {onLive} />
443422 {#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}
444423 {#if !contextCanPaginate}<p class="status">This room is no longer joined. Saved context remains available.</p>{/if}
445424 {#if saved.state.kind === 'Partial'}<p class="status">This saved context is partial.</p>{/if}
@@ -452,7 +431,7 @@
452431 {:else if saved?.state.kind === 'Unavailable'}<p class="status">Saved context is unavailable.</p>
453432 {:else if saved?.state.kind === 'Partial'}<p class="status">Saved context is partial and currently unavailable.</p>
454433 {:else}<p class="status">Saved context is not available yet.</p>{/if}
455- {@const preview = itemEvent(withProfiles(selected.event, roomProfiles[selected.key.room_id]))}
434+ {@const preview = itemEvent(selected.event)}
456435 {#if preview}<MessageRow itemId={preview.item_id} event={preview} showSender={true} reacting={false} onReact={() => {}} readOnly={true} showSent={false} {self} senderAvatar={preview.sender.avatar} />{/if}
457436 {#if selected.capture.kind === 'Failed' || selected.capture.kind === 'Partial' || selected.capture.kind === 'Unavailable'}
458437 <button disabled={!selectedRoom || actionBusy} onclick={retryCapture}>Retry context capture</button>
@@ -479,7 +458,7 @@
479458 <div class="entries" use:bindViewport onscroll={() => { if (!viewport?.consumeScroll()) viewport?.capture(); }}>
480459 <div class="entry-content">
481460 {#each entries as entry (keyOf(entry))}
482- {@const event = itemEvent(withProfiles(entry.event, roomProfiles[entry.key.room_id]))}
461+ {@const event = itemEvent(entry.event)}
483462 <article class:unread={!entry.read}>
484463 <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>
485464 <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>
Deleted file: web/src/lib/mention-profiles.ts−45 deletions
@@ -1,45 +0,0 @@
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-}
Deleted file: web/test/mention-profiles.test.ts−63 deletions
@@ -1,63 +0,0 @@
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-});