Commit 824dfd38cdf3
tarball14 files changed+292-11
@@ -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",@@ -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"@@ -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::NotFound5757
}5858
ErrorKind::MediaTooLarge => Failure::TooLarge,@@ -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)),@@ -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",@@ -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"] }@@ -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
<div23522352
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}@@ -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;