Commit d4c0d65f690a
tarball11 files changed+303-10
@@ -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 = handle519
+ .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 = handle530
+ .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 = handle541
+ .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?;@@ -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)),@@ -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 lands163168
$: {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
<div17891794
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}@@ -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;@@ -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;@@ -1,6 +1,6 @@
11
// Backend-agnostic API surface. Every call forwards to whichever transport22
// 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);@@ -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']);@@ -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
}@@ -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
}@@ -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?: SendKind9999
): 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>;@@ -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'] === true721
+ };722
+}723
+683724
export type PackUsage = 'Emoticon' | 'Sticker';684725
export interface PackImage {685726
shortcode: string;