atagen

malice

public
Star0
Watch0

Commit d4c0d65f690a

tarball

atagen <boss@atagen.co>2026-10-24 12:59 UTC

11 files changed+303-10
web: animate navigation and quiet message actions
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: web/src/App.svelte+15 additions−3 deletions
@@ -1,5 +1,6 @@
11 <script lang="ts">
22 import { onMount, tick } from 'svelte';
3+ import { fade, fly } from 'svelte/transition';
34 import LoginPanel from './lib/LoginPanel.svelte';
45 import MessageRow from './lib/MessageRow.svelte';
56 import MxcAvatar from './lib/MxcAvatar.svelte';
@@ -159,6 +160,10 @@
159160 let avatarByUser: Record<string, string | null> = {};
160161 let restoringRoute = false;
161162
163+ function motionDuration(ms: number): number {
164+ return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : ms;
165+ }
166+
162167 // account data is read from the local cache, which is empty on a fresh device until the first sync lands
163168 $: {
164169 const online = $connection === 'online';
@@ -1773,12 +1778,12 @@
17731778 />
17741779
17751780 {#if !booted}
1776- <div class="splash" role="status" aria-label="Loading Malice">
1781+ <div class="splash" role="status" aria-label="Loading Malice" out:fade={{ duration: motionDuration(260) }}>
17771782 <div class="splash-mark" aria-hidden="true">M</div>
17781783 <p>Loading Malice…</p>
17791784 </div>
17801785 {:else if $blocked}
1781- <div class="splash" role="status" aria-label="Malice is open in another tab">
1786+ <div class="splash" role="status" aria-label="Malice is open in another tab" in:fade={{ duration: motionDuration(220) }}>
17821787 <div class="splash-mark" aria-hidden="true">M</div>
17831788 <p>Malice is open in another tab. Close this tab or use that one instead.</p>
17841789 </div>
@@ -1787,6 +1792,7 @@
17871792 {:else}
17881793 <div
17891794 class="app"
1795+ in:fade={{ duration: motionDuration(220) }}
17901796 class:nav-folded={$navFolded}
17911797 class:rail-folded={$railFolded && selectedRoom}
17921798 class:members-open={selectedRoom && membersOpen}
@@ -2031,7 +2037,8 @@
20312037 <span aria-hidden="true">⟳</span>
20322038 </button>
20332039 </div>
2034- <div class="side-scroll">
2040+ {#key selectedSpaceId ?? 'home'}
2041+ <div class="side-scroll" in:fly={{ y: 5, duration: motionDuration(180) }} out:fade={{ duration: motionDuration(120) }}>
20352042 {#if selectedSpace && spaceParents.has(selectedSpace.room_id)}
20362043 {@const parentId = spaceParents.get(selectedSpace.room_id) ?? ''}
20372044 <button type="button" class="room-row up-row" on:click={() => void selectSpace(parentId)}>
@@ -2187,6 +2194,7 @@
21872194 {/if}
21882195 {/if}
21892196 </div>
2197+ {/key}
21902198
21912199 </aside>
21922200 <div class="account">
@@ -2234,6 +2242,8 @@
22342242 {/each}
22352243 </div>
22362244 {/if}
2245+ {#key $selectedRoomId ?? 'home'}
2246+ <div class="chat-view" in:fly={{ y: 8, duration: motionDuration(220) }} out:fade={{ duration: motionDuration(140) }}>
22372247 {#if !selectedRoom}
22382248 <div class="empty">
22392249 <div class="empty-mark" aria-hidden="true">◈</div>
@@ -2634,6 +2644,8 @@
26342644 </div>
26352645 {/if}
26362646 {/if}
2647+ </div>
2648+ {/key}
26372649 </main>
26382650
26392651 {#if selectedRoom}
Modified file: web/src/app.css+1 additions
@@ -465,6 +465,7 @@ input, textarea { font: inherit; }
465465
466466 /* ——— Chat ——— */
467467 .chat { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--bg); }
468+.chat-view { display: flex; flex: 1; flex-direction: column; min-height: 0; min-width: 0; }
468469 .empty {
469470 margin: auto;
470471 text-align: center;
Modified file: web/src/lib/MessageRow.svelte+4 additions−3 deletions
@@ -431,7 +431,7 @@
431431 type="button"
432432 class="reaction"
433433 class:mine={r.own}
434- aria-describedby={`${itemId}-reactors-${r.key}`}
434+ aria-label={`${r.shortcode ? `:${r.shortcode}:` : r.key.startsWith('mxc://') ? 'custom emote' : r.key}, ${r.count} reaction${r.count === 1 ? '' : 's'}`}
435435 disabled={!event.event_id || reacting}
436436 on:click={() => event.event_id && onReact(event.event_id, r.key)}
437437 >
@@ -578,10 +578,11 @@
578578 border-radius: var(--r-md);
579579 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
580580 opacity: 0;
581+ visibility: hidden;
581582 pointer-events: none;
582583 }
583- .msg:hover .actions, .msg:focus-within .actions, .msg.selected .actions { opacity: 1; pointer-events: auto; }
584- :global(.timeline-inner:has(.msg.selected)) .msg:not(.selected) .actions { opacity: 0; pointer-events: none; }
584+ .msg:hover .actions, .msg:focus-within .actions, .msg.selected .actions { opacity: 1; visibility: visible; pointer-events: auto; }
585+ :global(.timeline-inner:has(.msg.selected)) .msg:not(.selected) .actions { opacity: 0; visibility: hidden; pointer-events: none; }
585586 .actions button {
586587 background: transparent;
587588 border: 1px solid transparent;
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;