atagen

malice

public
Star0
Watch0

Commit 824dfd38cdf3

tarball

atagen <boss@atagen.co>2026-10-24 13:48 UTC

14 files changed+292-11
core: add event context support from macltok
Modified file: Cargo.lock+1 additions−1 deletions
@@ -1935,7 +1935,7 @@ checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
19351935 [[package]]
19361936 name = "macltok"
19371937 version = "0.1.0"
1938-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=6ed8c0ef3a2a5cab4ea62b9694d6b5fa2c60f3df#6ed8c0ef3a2a5cab4ea62b9694d6b5fa2c60f3df"
1938+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=89bc9f8d246d2a446218c05b78de187c2fdab6a0#89bc9f8d246d2a446218c05b78de187c2fdab6a0"
19391939 dependencies = [
19401940 "futures-util",
19411941 "getrandom 0.2.17",
Modified file: Cargo.toml+1 additions−1 deletions
@@ -3,7 +3,7 @@ resolver = "3"
33 members = ["crates/malice-core", "crates/malice-server", "crates/malice-static"]
44
55 [workspace.dependencies]
6-macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "6ed8c0ef3a2a5cab4ea62b9694d6b5fa2c60f3df" }
6+macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "89bc9f8d246d2a446218c05b78de187c2fdab6a0" }
77 ruma = { version = "0.16", default-features = false }
88 serde = { version = "1", features = ["derive"] }
99 serde_json = "1"
Modified file: crates/malice-core/src/error.rs+1 additions−1 deletions
@@ -52,7 +52,7 @@ impl From<macltok::Error> for CoreError {
5252 );
5353 }
5454 ErrorKind::QueueFull | ErrorKind::RoomBusy | ErrorKind::RateLimited => Failure::Busy,
55- ErrorKind::RoomUnknown | ErrorKind::MediaUnknown | ErrorKind::MediaUnavailable => {
55+ ErrorKind::RoomUnknown | ErrorKind::EventNotFound | ErrorKind::MediaUnknown | ErrorKind::MediaUnavailable => {
5656 Failure::NotFound
5757 }
5858 ErrorKind::MediaTooLarge => Failure::TooLarge,
Modified file: crates/malice-core/src/request.rs+61 additions
@@ -77,6 +77,32 @@ pub enum Request {
7777 #[serde(default)]
7878 room_id: String,
7979 },
80+ EventContext {
81+ #[serde(default)]
82+ room_id: String,
83+ #[serde(default)]
84+ event_id: String,
85+ #[serde(default)]
86+ before_limit: u32,
87+ #[serde(default)]
88+ after_limit: u32,
89+ },
90+ EventContextBefore {
91+ #[serde(default)]
92+ room_id: String,
93+ #[serde(default)]
94+ token: String,
95+ #[serde(default)]
96+ limit: u32,
97+ },
98+ EventContextAfter {
99+ #[serde(default)]
100+ room_id: String,
101+ #[serde(default)]
102+ token: String,
103+ #[serde(default)]
104+ limit: u32,
105+ },
80106 Join {
81107 #[serde(default)]
82108 room: String,
@@ -481,6 +507,41 @@ pub async fn dispatch(
481507 handle.paginate(TimelineId::Room(room_id), 30).await?;
482508 Ok(Reply::Done)
483509 }
510+ Request::EventContext {
511+ room_id,
512+ event_id,
513+ before_limit,
514+ after_limit,
515+ } => {
516+ let room_id = parse_room_id(&room_id)?;
517+ let event_id = parse_event_id(&event_id)?;
518+ let context = handle
519+ .event_context(&room_id, &event_id, before_limit, after_limit)
520+ .await?;
521+ Ok(Reply::Json(json!(context)))
522+ }
523+ Request::EventContextBefore {
524+ room_id,
525+ token,
526+ limit,
527+ } => {
528+ let room_id = parse_room_id(&room_id)?;
529+ let page = handle
530+ .event_context_before(&room_id, &token, limit)
531+ .await?;
532+ Ok(Reply::Json(json!(page)))
533+ }
534+ Request::EventContextAfter {
535+ room_id,
536+ token,
537+ limit,
538+ } => {
539+ let room_id = parse_room_id(&room_id)?;
540+ let page = handle
541+ .event_context_after(&room_id, &token, limit)
542+ .await?;
543+ Ok(Reply::Json(json!(page)))
544+ }
484545 Request::Join { room } => {
485546 let room = parse_room_or_alias(&room)?;
486547 let room_id = handle.join_room(room, Vec::new()).await?;
Modified file: crates/malice-server/src/routes.rs+95 additions
@@ -56,6 +56,28 @@ struct PaginateBody {
5656 }
5757
5858 #[derive(Debug, Deserialize)]
59+struct EventContextBody {
60+ #[serde(default)]
61+ room_id: String,
62+ #[serde(default)]
63+ event_id: String,
64+ #[serde(default)]
65+ before_limit: u32,
66+ #[serde(default)]
67+ after_limit: u32,
68+}
69+
70+#[derive(Debug, Deserialize)]
71+struct EventContextPageBody {
72+ #[serde(default)]
73+ room_id: String,
74+ #[serde(default)]
75+ token: String,
76+ #[serde(default)]
77+ limit: u32,
78+}
79+
80+#[derive(Debug, Deserialize)]
5981 struct JoinBody {
6082 #[serde(default)]
6183 room: String,
@@ -660,6 +682,76 @@ async fn rooms_paginate(
660682 )
661683 }
662684
685+async fn rooms_context(
686+ State(state): State<AppState>,
687+ headers: HeaderMap,
688+ body: axum::Json<EventContextBody>,
689+) -> Response {
690+ let (user, _) = match auth(&state, &headers).await {
691+ Ok(pair) => pair,
692+ Err(error) => return error.into_response(),
693+ };
694+ respond(
695+ dispatch(
696+ &user.handle,
697+ &user.mirror,
698+ Request::EventContext {
699+ room_id: body.0.room_id,
700+ event_id: body.0.event_id,
701+ before_limit: body.0.before_limit,
702+ after_limit: body.0.after_limit,
703+ },
704+ )
705+ .await,
706+ )
707+}
708+
709+async fn rooms_context_before(
710+ State(state): State<AppState>,
711+ headers: HeaderMap,
712+ body: axum::Json<EventContextPageBody>,
713+) -> Response {
714+ let (user, _) = match auth(&state, &headers).await {
715+ Ok(pair) => pair,
716+ Err(error) => return error.into_response(),
717+ };
718+ respond(
719+ dispatch(
720+ &user.handle,
721+ &user.mirror,
722+ Request::EventContextBefore {
723+ room_id: body.0.room_id,
724+ token: body.0.token,
725+ limit: body.0.limit,
726+ },
727+ )
728+ .await,
729+ )
730+}
731+
732+async fn rooms_context_after(
733+ State(state): State<AppState>,
734+ headers: HeaderMap,
735+ body: axum::Json<EventContextPageBody>,
736+) -> Response {
737+ let (user, _) = match auth(&state, &headers).await {
738+ Ok(pair) => pair,
739+ Err(error) => return error.into_response(),
740+ };
741+ respond(
742+ dispatch(
743+ &user.handle,
744+ &user.mirror,
745+ Request::EventContextAfter {
746+ room_id: body.0.room_id,
747+ token: body.0.token,
748+ limit: body.0.limit,
749+ },
750+ )
751+ .await,
752+ )
753+}
754+
663755 async fn rooms_join(
664756 State(state): State<AppState>,
665757 headers: HeaderMap,
@@ -1046,6 +1138,9 @@ pub fn router(state: AppState, web_dir: Option<String>) -> Router {
10461138 .route("/rooms/close", post(rooms_close))
10471139 .route("/rooms/send", post(rooms_send))
10481140 .route("/rooms/paginate", post(rooms_paginate))
1141+ .route("/rooms/context", post(rooms_context))
1142+ .route("/rooms/context/before", post(rooms_context_before))
1143+ .route("/rooms/context/after", post(rooms_context_after))
10491144 .route(
10501145 "/rooms/upload",
10511146 post(rooms_upload).layer(DefaultBodyLimit::max(21 * 1024 * 1024)),
Modified file: crates/malice-worker/Cargo.lock+1 additions−1 deletions
@@ -1722,7 +1722,7 @@ checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
17221722 [[package]]
17231723 name = "macltok"
17241724 version = "0.1.0"
1725-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=6ed8c0ef3a2a5cab4ea62b9694d6b5fa2c60f3df#6ed8c0ef3a2a5cab4ea62b9694d6b5fa2c60f3df"
1725+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=89bc9f8d246d2a446218c05b78de187c2fdab6a0#89bc9f8d246d2a446218c05b78de187c2fdab6a0"
17261726 dependencies = [
17271727 "futures-util",
17281728 "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 = "6ed8c0ef3a2a5cab4ea62b9694d6b5fa2c60f3df", features = ["indexeddb", "serde", "mirror"] }
17+macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "89bc9f8d246d2a446218c05b78de187c2fdab6a0", 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/App.svelte+4 additions−2 deletions
@@ -2243,9 +2243,9 @@
22432243 </div>
22442244 {/if}
22452245 {#key $selectedRoomId ?? 'home'}
2246- <div class="chat-view" in:fly={{ y: 8, duration: motionDuration(220) }} out:fade={{ duration: motionDuration(140) }}>
2246+ <div class="chat-view">
22472247 {#if !selectedRoom}
2248- <div class="empty">
2248+ <div class="empty" in:fade={{ duration: motionDuration(180) }}>
22492249 <div class="empty-mark" aria-hidden="true">◈</div>
22502250 {#if selectedSpace}
22512251 <h1>{selectedSpace.name}</h1>
@@ -2350,6 +2350,8 @@
23502350 <!-- svelte-ignore a11y_no_static_element_interactions -->
23512351 <div
23522352 class="timeline"
2353+ in:fly={{ y: 6, duration: motionDuration(180) }}
2354+ out:fade={{ duration: motionDuration(120) }}
23532355 bind:this={timelineEl}
23542356 on:scroll={onTimelineScroll}
23552357 on:wheel={noteUserScroll}
Modified file: web/src/lib/api.ts+7 additions−1 deletions
@@ -1,6 +1,6 @@
11 // Backend-agnostic API surface. Every call forwards to whichever transport
22 // this build was compiled for, hosted (fetch + SSE) or on-device (worker).
3-import type { MemberSummary, RoomSummary } from './types';
3+import type { EventContext, EventContextPage, MemberSummary, RoomSummary } from './types';
44 import type { Emote } from './emotes';
55 import { formatBody } from './markdown';
66 import { mentionedUserIds, type Mention } from './mentions';
@@ -50,6 +50,12 @@ export const uploadAvatar = (file: File) => transport.uploadAvatar(file);
5050 export const uploadRoomAvatar = (room_id: string, file: File) => transport.uploadRoomAvatar(room_id, file);
5151 export const uploadMedia = (file: File) => transport.uploadMedia(file);
5252 export const paginateRoom = (room_id: string) => transport.paginateRoom(room_id);
53+export const eventContext = (room_id: string, event_id: string, limit?: number): Promise<EventContext> =>
54+ transport.eventContext(room_id, event_id, limit);
55+export const eventContextBefore = (room_id: string, event_id: string, from: string, limit?: number): Promise<EventContextPage> =>
56+ transport.eventContextBefore(room_id, event_id, from, limit);
57+export const eventContextAfter = (room_id: string, event_id: string, from: string, limit?: number): Promise<EventContextPage> =>
58+ transport.eventContextAfter(room_id, event_id, from, limit);
5359 export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);
5460 export const getAccountData = (eventType: string) => transport.getAccountData(eventType);
5561 export const getUrlPreview = (room_id: string, url: string) => transport.getUrlPreview(room_id, url);
Modified file: web/src/lib/store.ts+24 additions
@@ -14,6 +14,7 @@ import {
1414 } from './types';
1515 import type {
1616 EncryptionState,
17+ EventContext,
1718 ImagePacks,
1819 MemberSummary,
1920 RoomSummary,
@@ -28,6 +29,12 @@ export const blocked = writable<boolean>(false);
2829 export const rooms = writable<RoomSummary[]>([]);
2930 export const timelines = writable<Record<string, TimelineItem[]>>({});
3031 export const pinnedTimelines = writable<Record<string, TimelineItem[]>>({});
32+export interface EventContextState {
33+ room_id: string;
34+ request_id: string;
35+ context: EventContext;
36+}
37+export const eventContexts = writable<Record<string, EventContextState>>({});
3138 export const connection = writable<string>('connecting');
3239 export const selectedRoomId = writable<string | null>(null);
3340 export const membersByRoom = writable<Record<string, MemberSummary[]>>({});
@@ -57,6 +64,7 @@ export function resetClientState(): void {
5764 rooms.set([]);
5865 timelines.set({});
5966 pinnedTimelines.set({});
67+ eventContexts.set({});
6068 connection.set('connecting');
6169 selectedRoomId.set(null);
6270 membersByRoom.set({});
@@ -69,6 +77,22 @@ export function resetClientState(): void {
6977 resetLinkPreviews();
7078 }
7179
80+export function setEventContext(state: EventContextState): void {
81+ const key = `${state.room_id}:${state.request_id}`;
82+ eventContexts.update((all) => ({ ...all, [key]: state }));
83+}
84+
85+export function clearEventContext(roomId: string, requestId?: string): void {
86+ eventContexts.update((all) => {
87+ const prefix = `${roomId}:`;
88+ const next = { ...all };
89+ for (const key of Object.keys(next)) {
90+ if (key.startsWith(prefix) && (requestId === undefined || key === `${prefix}${requestId}`)) delete next[key];
91+ }
92+ return next;
93+ });
94+}
95+
7296 function applyTimelinePayload(roomId: string, payload: Record<string, unknown>): void {
7397 if (!Array.isArray(payload['items'])) return;
7498 const items = parseTimelineList(payload['items']);
Modified file: web/src/lib/transport-http.ts+30 additions−1 deletions
@@ -1,4 +1,5 @@
1-import type { MemberSummary, RoomSummary } from './types';
1+import type { EventContext, EventContextPage, MemberSummary, RoomSummary } from './types';
2+import { parseEventContext, parseEventContextPage } from './types';
23 import { ApiError, type CommandRequest, type SendKind, type SessionInfo, type Transport, type UploadExtras, type VerifyAction } from './transport';
34
45 async function parseFailure(res: Response, fallback: string): Promise<never> {
@@ -103,6 +104,34 @@ export class HttpTransport implements Transport {
103104 paginateRoom(room_id: string): Promise<void> {
104105 return this.roomCommand(ROOM_COMMAND_PATHS.paginate, { room_id });
105106 }
107+ async eventContext(room_id: string, event_id: string, limit = 20): Promise<EventContext> {
108+ const res = await fetch('/api/rooms/context', {
109+ method: 'POST',
110+ credentials: 'same-origin',
111+ headers: { 'content-type': 'application/json' },
112+ body: JSON.stringify({ room_id, event_id, limit })
113+ });
114+ const parsed = parseEventContext(await json<unknown>(res, 'Could not load event context'));
115+ if (!parsed) throw new ApiError(502, 'Invalid event context response');
116+ return parsed;
117+ }
118+ async eventContextBefore(room_id: string, event_id: string, from: string, limit = 20): Promise<EventContextPage> {
119+ return this.eventContextPage('before', room_id, event_id, from, limit);
120+ }
121+ async eventContextAfter(room_id: string, event_id: string, from: string, limit = 20): Promise<EventContextPage> {
122+ return this.eventContextPage('after', room_id, event_id, from, limit);
123+ }
124+ private async eventContextPage(direction: 'before' | 'after', room_id: string, event_id: string, from: string, limit: number): Promise<EventContextPage> {
125+ const res = await fetch(`/api/rooms/context/${direction}`, {
126+ method: 'POST',
127+ credentials: 'same-origin',
128+ headers: { 'content-type': 'application/json' },
129+ body: JSON.stringify({ room_id, event_id, from, limit })
130+ });
131+ const parsed = parseEventContextPage(await json<unknown>(res, 'Could not load event context'));
132+ if (!parsed) throw new ApiError(502, 'Invalid event context page response');
133+ return parsed;
134+ }
106135 setTyping(room_id: string, typing: boolean): Promise<void> {
107136 return this.roomCommand(ROOM_COMMAND_PATHS.typing, { room_id, typing });
108137 }
Modified file: web/src/lib/transport-worker.ts+21 additions−1 deletions
@@ -1,4 +1,5 @@
1-import type { MemberSummary, RoomSummary } from './types';
1+import { parseEventContext, parseEventContextPage } from './types';
2+import type { EventContext, EventContextPage, MemberSummary, RoomSummary } from './types';
23 import { ApiError, type CommandRequest, type SendKind, type SessionInfo, type SoftLogout, type Transport, type UploadExtras, type VerifyAction } from './transport';
34 import { statusForKind } from './wire-status';
45
@@ -229,6 +230,25 @@ export class WorkerTransport implements Transport {
229230 paginateRoom(room_id: string): Promise<void> {
230231 return this.client.call('paginate', { room_id });
231232 }
233+ eventContext(room_id: string, event_id: string, limit = 20): Promise<EventContext> {
234+ return this.contextCall('event_context', { room_id, event_id, limit });
235+ }
236+ eventContextBefore(room_id: string, event_id: string, from: string, limit = 20): Promise<EventContextPage> {
237+ return this.contextPageCall('event_context_before', { room_id, event_id, from, limit });
238+ }
239+ eventContextAfter(room_id: string, event_id: string, from: string, limit = 20): Promise<EventContextPage> {
240+ return this.contextPageCall('event_context_after', { room_id, event_id, from, limit });
241+ }
242+ private async contextCall(op: string, fields: Record<string, unknown>): Promise<EventContext> {
243+ const parsed = parseEventContext(await this.client.call(op, fields));
244+ if (!parsed) throw new ApiError(502, 'Invalid event context response');
245+ return parsed;
246+ }
247+ private async contextPageCall(op: string, fields: Record<string, unknown>): Promise<EventContextPage> {
248+ const parsed = parseEventContextPage(await this.client.call(op, fields));
249+ if (!parsed) throw new ApiError(502, 'Invalid event context page response');
250+ return parsed;
251+ }
232252 setTyping(room_id: string, typing: boolean): Promise<void> {
233253 return this.client.call('typing', { room_id, typing });
234254 }
Modified file: web/src/lib/transport.ts+4 additions−1 deletions
@@ -1,4 +1,4 @@
1-import type { MemberSummary, PackUsage, RoomSummary } from './types';
1+import type { EventContext, EventContextPage, MemberSummary, PackUsage, RoomSummary } from './types';
22
33 export class ApiError extends Error {
44 status: number;
@@ -98,6 +98,9 @@ export interface Transport {
9898 kind?: SendKind
9999 ): Promise<void>;
100100 paginateRoom(room_id: string): Promise<void>;
101+ eventContext(room_id: string, event_id: string, limit?: number): Promise<EventContext>;
102+ eventContextBefore(room_id: string, event_id: string, from: string, limit?: number): Promise<EventContextPage>;
103+ eventContextAfter(room_id: string, event_id: string, from: string, limit?: number): Promise<EventContextPage>;
101104 setTyping(room_id: string, typing: boolean): Promise<void>;
102105 getAccountData(eventType: string): Promise<unknown | null>;
103106 getUrlPreview(room_id: string, url: string): Promise<unknown | null>;
Modified file: web/src/lib/types.ts+41 additions
@@ -654,6 +654,23 @@ export type TimelineItem =
654654 | { kind: 'start' }
655655 | { kind: 'unknown'; label: string };
656656
657+export interface EventContext {
658+ events_before: TimelineItem[];
659+ event: TimelineItem | null;
660+ events_after: TimelineItem[];
661+ start_token: string | null;
662+ end_token: string | null;
663+}
664+
665+export type EventContextDirection = 'Before' | 'After';
666+
667+export interface EventContextPage {
668+ direction: EventContextDirection;
669+ events: TimelineItem[];
670+ next_token: string | null;
671+ end_reached: boolean;
672+}
673+
657674 /** Normalize one TimelineItem from external-tag serde form. */
658675 export function parseTimelineItem(raw: unknown): TimelineItem | null {
659676 if (raw === 'ReadMarker') return { kind: 'readMarker' };
@@ -680,6 +697,30 @@ export function parseTimelineList(raw: unknown): TimelineItem[] {
680697 return out;
681698 }
682699
700+export function parseEventContext(raw: unknown): EventContext | null {
701+ if (!isRecord(raw)) return null;
702+ const eventRaw = raw['event'];
703+ return {
704+ events_before: parseTimelineList(raw['events_before']),
705+ event: eventRaw == null ? null : parseTimelineItem(eventRaw),
706+ events_after: parseTimelineList(raw['events_after']),
707+ start_token: optionalString(raw, 'start_token'),
708+ end_token: optionalString(raw, 'end_token')
709+ };
710+}
711+
712+export function parseEventContextPage(raw: unknown): EventContextPage | null {
713+ if (!isRecord(raw)) return null;
714+ const direction = raw['direction'];
715+ if (direction !== 'Before' && direction !== 'After') return null;
716+ return {
717+ direction,
718+ events: parseTimelineList(raw['events']),
719+ next_token: optionalString(raw, 'next_token'),
720+ end_reached: raw['end_reached'] === true
721+ };
722+}
723+
683724 export type PackUsage = 'Emoticon' | 'Sticker';
684725 export interface PackImage {
685726 shortcode: string;