Improve threads and timelines

This commit is contained in:
tezlm 2023-12-10 04:41:35 -08:00
parent b9f6b82611
commit ac59e44ac5
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
25 changed files with 356 additions and 63 deletions

7
dist/src/api.d.ts vendored
View file

@ -2,11 +2,12 @@ export type RoomId = string;
export type UserId = string;
export type EventId = string;
export interface ApiEvent {
event_id: EventId;
sender: UserId;
type: string;
content: any;
event_id: EventId;
origin_server_ts: number;
sender: UserId;
state_key?: string;
type: string;
unsigned?: any;
}
export type ApiStateEvent = ApiEvent & {

1
dist/src/event.d.ts vendored
View file

@ -6,6 +6,7 @@ export declare class Event {
client: Client;
content: any;
id: EventId;
originTs: number;
sender: UserId;
stateKey?: string;
type: string;

11
dist/src/event.js vendored
View file

@ -1,4 +1,8 @@
export class Event {
// How do I do the relations api? Event or EventId?
// Event is more ergonomic, but EventId means I don't have to wait
// public relationsFrom: Array<any>;
// public relationsTo: Array<any>;
constructor(room, json) {
Object.defineProperty(this, "room", {
enumerable: true,
@ -24,6 +28,12 @@ export class Event {
writable: true,
value: void 0
});
Object.defineProperty(this, "originTs", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "sender", {
enumerable: true,
configurable: true,
@ -50,6 +60,7 @@ export class Event {
});
this.content = json.content;
this.id = json.event_id;
this.originTs = json.origin_server_ts;
this.sender = json.sender;
this.stateKey = json.state_key;
this.type = json.type;

View file

@ -1 +1 @@
{"version":3,"file":"event.js","sourceRoot":"","sources":["../../src/event.ts"],"names":[],"mappings":"AAIA,MAAM,OAAO,KAAK;IAShB,YACS,IAAU,EACjB,IAAc;QADd;;;;mBAAO,IAAI;WAAM;QATZ;;;;mBAAiB,IAAI,CAAC,IAAI,CAAC,MAAM;WAAC;QAClC;;;;;WAAa;QACb;;;;;WAAY;QACZ;;;;;WAAe;QACf;;;;;WAAkB;QAClB;;;;;WAAa;QACb;;;;;WAAc;QAMnB,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC;QAC5B,IAAI,CAAC,EAAE,GAAG,IAAI,CAAC,QAAQ,CAAC;QACxB,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;QAC1B,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC;QAC/B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC;QACtB,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC;IAChC,CAAC;IAED,WAAW;QACT,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,CAAA;IACjC,CAAC;IAED,aAAa;IAEb,CAAC;CACF;AAED,MAAM,OAAO,UAAW,SAAQ,KAAK;IAGnC,YAAY,IAAU,EAAE,IAAmB;QACzC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QAHb;;;;;WAAiB;QAItB,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC;IACjC,CAAC;CACF"}
{"version":3,"file":"event.js","sourceRoot":"","sources":["../../src/event.ts"],"names":[],"mappings":"AAIA,MAAM,OAAO,KAAK;IAUhB,mDAAmD;IACnD,kEAAkE;IAClE,oCAAoC;IACpC,kCAAkC;IAElC,YACS,IAAU,EACjB,IAAc;QADd;;;;mBAAO,IAAI;WAAM;QAfZ;;;;mBAAiB,IAAI,CAAC,IAAI,CAAC,MAAM;WAAC;QAClC;;;;;WAAa;QACb;;;;;WAAY;QACZ;;;;;WAAiB;QACjB;;;;;WAAe;QACf;;;;;WAAkB;QAClB;;;;;WAAa;QACb;;;;;WAAc;QAWnB,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC;QAC5B,IAAI,CAAC,EAAE,GAAG,IAAI,CAAC,QAAQ,CAAC;QACxB,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,gBAAgB,CAAC;QACtC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;QAC1B,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC;QAC/B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC;QACtB,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC;IAChC,CAAC;IAED,WAAW;QACT,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,CAAA;IACjC,CAAC;IAED,aAAa;IAEb,CAAC;CACF;AAED,MAAM,OAAO,UAAW,SAAQ,KAAK;IAGnC,YAAY,IAAU,EAAE,IAAmB;QACzC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QAHb;;;;;WAAiB;QAItB,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC;IACjC,CAAC;CACF"}

1
dist/src/index.d.ts vendored
View file

@ -1,4 +1,5 @@
export { Client } from "./client.js";
export { ThreadPaginator } from "./room.js";
export type { Event, StateEvent } from "./event.js";
export type { Room } from "./room.js";
export type { Thread } from "./thread.js";

1
dist/src/index.js vendored
View file

@ -1,2 +1,3 @@
export { Client } from "./client.js";
export { ThreadPaginator } from "./room.js";
//# sourceMappingURL=index.js.map

View file

@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC"}

2
dist/src/net.js vendored
View file

@ -134,7 +134,7 @@ export class Network {
fetchRelations(roomId, eventId, opts) {
if (opts.eventType && !opts.relType)
throw new Error("you can't set eventType without relType");
let path = `/_matrix/client/v3/rooms/${e(roomId)}/relations/${e(eventId)}`;
let path = `/_matrix/client/v1/rooms/${e(roomId)}/relations/${e(eventId)}`;
if (opts.relType)
path += `/${e(opts.relType)}`;
if (opts.eventType)

13
dist/src/room.d.ts vendored
View file

@ -1,5 +1,5 @@
import TypedEmitter from "typed-emitter";
import { ApiEphemeralEvent, EventId, SyncResponseRoom, Unreads } from "./api.js";
import { ApiEphemeralEvent, EventId, IncludeThreads, SyncResponseRoom, Unreads } from "./api.js";
import { Client } from "./client.js";
import { Event, StateEvent } from "./event.js";
import { TimelineSet } from "./timeline.js";
@ -32,4 +32,15 @@ export declare class Room extends Room_base {
leave(reason?: string): Promise<void>;
ack(eventId?: EventId): Promise<void>;
}
export declare class ThreadPaginator extends Map<EventId, Thread> {
client: Client;
rooms: Array<Room>;
watching: boolean;
include: Array<IncludeThreads>;
list: Array<Thread>;
private prevBatch;
private isAtEnd;
constructor(client: Client, rooms: Array<Room>, watching: boolean, include: Array<IncludeThreads>);
paginate(limit?: number): Promise<boolean>;
}
export {};

97
dist/src/room.js vendored
View file

@ -58,8 +58,8 @@ export class Room extends EventEmitter {
value: new Map()
});
this.timelines = new TimelineSet(this);
this.timelines.live.prevBatch = data.prev_batch || null;
this.threads = new Threads(this);
this.timelines.live.prevBatch = data.prev_batch;
this.threads = new RoomThreads(this);
this._merge(data);
}
// should be private to only the library like pub(crate), but there's no way currently
@ -75,8 +75,12 @@ export class Room extends EventEmitter {
}
}
if (data.timeline?.length) {
const events = data.timeline.map(raw => new Event(this, raw));
const events = data.timeline
.filter(raw => !this.events.has(raw.event_id))
.map(raw => new Event(this, raw));
this.timelines._appendEvents(events);
for (const ev of events)
this.emit("timeline", ev);
}
}
getState(type, stateKey = "") {
@ -113,7 +117,8 @@ export class Room extends EventEmitter {
});
}
}
class Threads extends Map {
// TODO: threads across multiple rooms
class RoomThreads extends Map {
constructor(room) {
super();
Object.defineProperty(this, "room", {
@ -133,10 +138,86 @@ class Threads extends Map {
}
async paginate(opts) {
const data = await this.room.client.net.fetchThreads({ ...opts, roomIds: [this.room.id] });
return {
threads: data.chunk?.map(raw => new Thread(new Event(this.room, raw))),
next: data.next_batch
};
const threads = data.chunk?.map(raw => new Thread(new Event(this.room, raw))) || [];
for (const th of threads)
this.set(th.id, th);
return { threads, nextBatch: data.next_batch };
}
}
export class ThreadPaginator extends Map {
constructor(client,
// i need the user to pass a room, not id, so that its guaranteed to exist on the client
rooms, watching, include) {
super();
Object.defineProperty(this, "client", {
enumerable: true,
configurable: true,
writable: true,
value: client
});
Object.defineProperty(this, "rooms", {
enumerable: true,
configurable: true,
writable: true,
value: rooms
});
Object.defineProperty(this, "watching", {
enumerable: true,
configurable: true,
writable: true,
value: watching
});
Object.defineProperty(this, "include", {
enumerable: true,
configurable: true,
writable: true,
value: include
});
Object.defineProperty(this, "list", {
enumerable: true,
configurable: true,
writable: true,
value: []
});
Object.defineProperty(this, "prevBatch", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "isAtEnd", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
}
async paginate(limit = 10) {
if (this.isAtEnd)
return false;
const data = await this.client.net.fetchThreads({
roomIds: this.rooms.map(i => i.id),
watching: this.watching,
include: this.include,
from: this.prevBatch,
limit,
});
for (const raw of data.chunk ?? []) {
const room = this.client.rooms.get(raw.room_id);
const roomId = raw.room_id;
const existing = room.threads.get(roomId);
if (existing) {
this.set(existing.id, existing);
this.list.unshift(existing);
}
else {
const thread = new Thread(new Event(room, raw));
this.set(thread.id, thread);
room.threads.set(thread.id, thread);
this.list.unshift(thread);
}
}
return !!data.next_batch;
}
}
//# sourceMappingURL=room.js.map

File diff suppressed because one or more lines are too long

View file

@ -12,6 +12,7 @@ export declare class Thread extends Thread_base {
baseEvent: Event;
room: Room;
timeline: ThreadTimeline;
id: string;
constructor(baseEvent: Event);
ack(eventId?: EventId): Promise<void>;
}

6
dist/src/thread.js vendored
View file

@ -21,6 +21,12 @@ export class Thread extends EventEmitter {
writable: true,
value: new ThreadTimeline(this)
});
Object.defineProperty(this, "id", {
enumerable: true,
configurable: true,
writable: true,
value: this.baseEvent.id
});
}
// // TODO: local echo(?), return event
// async sendEvent(type: string, content: any) {

View file

@ -1 +1 @@
{"version":3,"file":"thread.js","sourceRoot":"","sources":["../../src/thread.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,QAAQ,CAAC;AAKlC,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAU/C,MAAM,OAAO,MAAO,SAAS,YAAgE;IAI3F,YAAmB,SAAgB;QACjC,KAAK,EAAE,CAAC;QADE;;;;mBAAO,SAAS;WAAO;QAH5B;;;;mBAAa,IAAI,CAAC,SAAS,CAAC,IAAI;WAAC;QACjC;;;;mBAA2B,IAAI,cAAc,CAAC,IAAI,CAAC;WAAC;IAI3D,CAAC;IAED,uCAAuC;IACvC,gDAAgD;IAChD,uEAAuE;IACvE,IAAI;IAEJ,sDAAsD;IACtD,sDAAsD;IACtD,IAAI;IAEJ,KAAK,CAAC,GAAG,CAAC,OAAiB;QACzB,MAAM,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC;YAC7B,IAAI,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC;SACnF,CAAC,CAAC;IACL,CAAC;CACF"}
{"version":3,"file":"thread.js","sourceRoot":"","sources":["../../src/thread.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,QAAQ,CAAC;AAKlC,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAU/C,MAAM,OAAO,MAAO,SAAS,YAAgE;IAK3F,YAAmB,SAAgB;QACjC,KAAK,EAAE,CAAC;QADE;;;;mBAAO,SAAS;WAAO;QAJ5B;;;;mBAAa,IAAI,CAAC,SAAS,CAAC,IAAI;WAAC;QACjC;;;;mBAA2B,IAAI,cAAc,CAAC,IAAI,CAAC;WAAC;QACpD;;;;mBAAK,IAAI,CAAC,SAAS,CAAC,EAAE;WAAC;IAI9B,CAAC;IAED,uCAAuC;IACvC,gDAAgD;IAChD,uEAAuE;IACvE,IAAI;IAEJ,sDAAsD;IACtD,sDAAsD;IACtD,IAAI;IAEJ,KAAK,CAAC,GAAG,CAAC,OAAiB;QACzB,MAAM,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC;YAC7B,IAAI,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC;SACnF,CAAC,CAAC;IACL,CAAC;CACF"}

View file

@ -4,6 +4,8 @@ import { Event } from "./event.js";
import { Thread } from "./thread.js";
export interface Timeline {
isLive: boolean;
isAtBeginning: boolean;
isAtEnd: boolean;
events: Array<Event>;
paginate(dir: "f" | "b", limit: number): Promise<boolean>;
}
@ -11,8 +13,10 @@ export declare class RoomTimeline implements Timeline {
room: Room;
events: Array<Event>;
isLive: boolean;
prevBatch: string | null | undefined;
nextBatch: string | null | undefined;
isAtBeginning: boolean;
isAtEnd: boolean;
prevBatch: string | undefined;
nextBatch: string | undefined;
constructor(room: Room);
paginate(dir: "f" | "b", limit?: number): Promise<boolean>;
}
@ -20,8 +24,10 @@ export declare class ThreadTimeline implements Timeline {
thread: Thread;
events: Array<Event>;
isLive: boolean;
prevBatch: string | null | undefined;
nextBatch: string | null | undefined;
isAtBeginning: boolean;
isAtEnd: boolean;
prevBatch: string | undefined;
nextBatch: string | undefined;
constructor(thread: Thread);
paginate(dir: "f" | "b", limit?: number): Promise<boolean>;
}

95
dist/src/timeline.js vendored
View file

@ -20,21 +20,37 @@ export class RoomTimeline {
writable: true,
value: false
});
Object.defineProperty(this, "isAtBeginning", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "isAtEnd", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "prevBatch", {
enumerable: true,
configurable: true,
writable: true,
value: null
value: void 0
});
Object.defineProperty(this, "nextBatch", {
enumerable: true,
configurable: true,
writable: true,
value: null
value: void 0
});
}
async paginate(dir, limit = 50) {
const from = (dir === "f" ? this.nextBatch : this.prevBatch) || undefined; // FIXME: don't paginate at ends
if (dir === "b" && this.isAtBeginning)
return false;
if (dir === "f" && this.isAtEnd)
return false;
const from = dir === "f" ? this.nextBatch : this.prevBatch;
const data = await this.room.client.net.fetchMessages({
roomId: this.room.id,
dir,
@ -43,14 +59,24 @@ export class RoomTimeline {
});
if (dir === "f") {
const events = data.chunk.map(raw => new Event(this.room, raw));
this.nextBatch = data.end || null;
if (data.start) {
this.nextBatch = data.start;
}
else {
this.isAtEnd = true;
}
this.events.push(...events);
for (const event of events)
this.room.events.set(event.id, event);
}
else {
const events = data.chunk.reverse().map(raw => new Event(this.room, raw));
this.prevBatch = data.end || null;
if (data.end) {
this.prevBatch = data.end;
}
else {
this.isAtBeginning = true;
}
this.events.push(...events);
for (const event of events)
this.room.events.set(event.id, event);
@ -73,6 +99,18 @@ export class ThreadTimeline {
value: []
});
Object.defineProperty(this, "isLive", {
enumerable: true,
configurable: true,
writable: true,
value: true
}); // FIXME: live threads
Object.defineProperty(this, "isAtBeginning", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "isAtEnd", {
enumerable: true,
configurable: true,
writable: true,
@ -82,18 +120,30 @@ export class ThreadTimeline {
enumerable: true,
configurable: true,
writable: true,
value: undefined
value: void 0
});
Object.defineProperty(this, "nextBatch", {
enumerable: true,
configurable: true,
writable: true,
value: undefined
value: void 0
});
}
async paginate(dir, limit = 50) {
if (dir === "b" && this.isAtBeginning)
return false;
if (dir === "f" && this.isAtEnd)
return false;
// This is to prevent someone from trying to paginate forwards,
// then paginate backwards. The timeline will end up with events from
// the beginning and end!
// FIXME: implement /context in threads
if (dir === "f")
this.isAtBeginning = true;
if (dir === "b")
this.isAtEnd = true;
const { room } = this.thread;
const from = (dir === "f" ? this.nextBatch : this.prevBatch) || undefined; // FIXME: don't paginate at ends
const from = dir === "f" ? this.nextBatch : this.prevBatch;
const data = await room.client.net.fetchRelations(room.id, this.thread.baseEvent.id, {
dir,
from,
@ -101,19 +151,31 @@ export class ThreadTimeline {
});
if (dir === "f") {
const events = data.chunk.map(raw => new Event(room, raw));
this.nextBatch = data.next_batch || null;
if (data.next_batch) {
this.nextBatch = data.next_batch;
}
else {
this.isAtEnd = true;
}
this.events.push(...events);
for (const event of events)
room.events.set(event.id, event);
return !!data.next_batch;
}
else {
// FIXME: conduit doesn't implement ?dir=b
const events = data.chunk.map(raw => new Event(room, raw));
this.prevBatch = data.prev_batch || null;
if (data.prev_batch) {
this.prevBatch = data.prev_batch;
}
else {
this.isAtBeginning = true;
}
this.events.push(...events);
for (const event of events)
room.events.set(event.id, event);
return !!data.prev_batch;
}
return true;
}
}
export class TimelineSet {
@ -166,13 +228,20 @@ export class TimelineSet {
_appendEvents(events) {
// FIXME: there should only be one live timeline for each room and
// thread, they need to be merged together
for (const event of events)
for (const event of events) {
this.room.events.set(event.id, event);
const threadId = event.content["m.relations"]?.find((rel) => rel.rel_type === "m.thread")?.event_id;
if (threadId) {
const tl = this.room.threads.get(threadId)?.timeline;
if (tl?.isLive)
tl.events.push(event);
}
}
for (const timeline of this.timelines) {
// if (timeline.isLive) timeline.events.push(...events);
if (timeline.isLive && timeline instanceof RoomTimeline)
timeline.events.push(...events);
// events[0].content["m.relations"].find(rel => rel.rel_type === "m.thread")?.event_id
// this.room.threads.get()
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,11 +5,12 @@ export type UserId = string;
export type EventId = string;
export interface ApiEvent {
event_id: EventId,
sender: UserId,
type: string,
content: any,
event_id: EventId,
origin_server_ts: number,
sender: UserId,
state_key?: string,
type: string,
unsigned?: any,
}

View file

@ -6,17 +6,24 @@ export class Event {
public client: Client = this.room.client;
public content: any;
public id: EventId;
public originTs: number;
public sender: UserId;
public stateKey?: string;
public type: string;
public unsigned: any;
// How do I do the relations api? Event or EventId?
// Event is more ergonomic, but EventId means I don't have to wait
// public relationsFrom: Array<any>;
// public relationsTo: Array<any>;
constructor(
public room: Room,
json: ApiEvent
) {
this.content = json.content;
this.id = json.event_id;
this.originTs = json.origin_server_ts;
this.sender = json.sender;
this.stateKey = json.state_key;
this.type = json.type;

View file

@ -1,4 +1,5 @@
export { Client } from "./client.js";
export { ThreadPaginator } from "./room.js";
export type { Event, StateEvent } from "./event.js";
export type { Room } from "./room.js";
export type { Thread } from "./thread.js";

View file

@ -162,7 +162,7 @@ export class Network {
to?: string,
}): Promise<t.RelationsResponse> {
if (opts.eventType && !opts.relType) throw new Error("you can't set eventType without relType");
let path = `/_matrix/client/v3/rooms/${e(roomId)}/relations/${e(eventId)}`;
let path = `/_matrix/client/v1/rooms/${e(roomId)}/relations/${e(eventId)}`;
if (opts.relType) path += `/${e(opts.relType)}`;
if (opts.eventType) path += `/${e(opts.eventType)}`;
path += `?limit=${opts.limit || 50}&dir=${opts.dir}`;

View file

@ -1,6 +1,6 @@
import EventEmitter from "events";
import TypedEmitter from "typed-emitter";
import { ApiEphemeralEvent, EventId, IncludeThreads, SyncResponseRoom, Unreads } from "./api.js";
import { ApiEphemeralEvent, EventId, IncludeThreads, RoomId, SyncResponseRoom, Unreads } from "./api.js";
import { Client } from "./client.js";
import { Event, StateEvent } from "./event.js";
import { TimelineSet } from "./timeline.js";
@ -57,8 +57,8 @@ room.unban(userid)
) {
super();
this.timelines = new TimelineSet(this);
this.timelines.live.prevBatch = data.prev_batch || null;
this.threads = new Threads(this);
this.timelines.live.prevBatch = data.prev_batch;
this.threads = new RoomThreads(this);
this._merge(data);
}
@ -74,8 +74,11 @@ room.unban(userid)
}
}
if (data.timeline?.length) {
const events = data.timeline.map(raw => new Event(this, raw));
const events = data.timeline
.filter(raw => !this.events.has(raw.event_id))
.map(raw => new Event(this, raw));
this.timelines._appendEvents(events);
for (const ev of events) this.emit("timeline", ev);
}
}
@ -120,7 +123,8 @@ room.unban(userid)
}
}
class Threads extends Map<EventId, Thread> {
// TODO: threads across multiple rooms
class RoomThreads extends Map<EventId, Thread> {
constructor(public room: Room) {
super();
}
@ -135,9 +139,50 @@ class Threads extends Map<EventId, Thread> {
async paginate(opts: { from?: string, limit?: number, watching?: boolean, include?: Array<IncludeThreads> }) {
const data = await this.room.client.net.fetchThreads({ ...opts, roomIds: [this.room.id] });
return {
threads: data.chunk?.map(raw => new Thread(new Event(this.room, raw))),
next: data.next_batch
}
const threads = data.chunk?.map(raw => new Thread(new Event(this.room, raw))) || [];
for (const th of threads) this.set(th.id, th);
return { threads, nextBatch: data.next_batch };
}
}
export class ThreadPaginator extends Map<EventId, Thread> {
public list: Array<Thread> = [];
private prevBatch: string | undefined;
private isAtEnd = false;
constructor(
public client: Client,
// i need the user to pass a room, not id, so that its guaranteed to exist on the client
public rooms: Array<Room>,
public watching: boolean,
public include: Array<IncludeThreads>,
) {
super();
}
async paginate(limit = 10): Promise<boolean> {
if (this.isAtEnd) return false;
const data = await this.client.net.fetchThreads({
roomIds: this.rooms.map(i => i.id),
watching: this.watching,
include: this.include,
from: this.prevBatch,
limit,
});
for (const raw of data.chunk ?? []) {
const room = this.client.rooms.get((raw as any).room_id)!;
const roomId: RoomId = (raw as any).room_id;
const existing = room.threads.get(roomId);
if (existing) {
this.set(existing.id, existing);
this.list.unshift(existing);
} else {
const thread = new Thread(new Event(room, raw));
this.set(thread.id, thread);
room.threads.set(thread.id, thread);
this.list.unshift(thread);
}
}
return !!data.next_batch;
}
}

View file

@ -16,6 +16,7 @@ type ThreadEvents = {
export class Thread extends (EventEmitter as unknown as new () => TypedEmitter<ThreadEvents>) {
public room: Room = this.baseEvent.room;
public timeline: ThreadTimeline = new ThreadTimeline(this);
public id = this.baseEvent.id;
constructor(public baseEvent: Event) {
super();

View file

@ -10,6 +10,12 @@ export interface Timeline {
// if this timeline is actively receiving new events
isLive: boolean,
// if this timeline has reached the beginning (earliest event)
isAtBeginning: boolean,
// if this timeline has reached the end (latest event)
isAtEnd: boolean,
// isPaginating: boolean,
// the events in this timeline
@ -24,14 +30,19 @@ export interface Timeline {
export class RoomTimeline implements Timeline {
public events: Array<Event> = [];
public isLive: boolean = false;
public isAtBeginning: boolean = false;
public isAtEnd: boolean = false;
prevBatch: string | null | undefined = null;
nextBatch: string | null | undefined = null;
prevBatch: string | undefined;
nextBatch: string | undefined;
constructor(public room: Room) {}
public async paginate(dir: "f" | "b", limit: number = 50): Promise<boolean> {
const from = (dir === "f" ? this.nextBatch : this.prevBatch) || undefined; // FIXME: don't paginate at ends
if (dir === "b" && this.isAtBeginning) return false;
if (dir === "f" && this.isAtEnd) return false;
const from = dir === "f" ? this.nextBatch : this.prevBatch;
const data = await this.room.client.net.fetchMessages({
roomId: this.room.id,
dir,
@ -40,12 +51,20 @@ export class RoomTimeline implements Timeline {
});
if (dir === "f") {
const events = data.chunk.map(raw => new Event(this.room, raw));
this.nextBatch = data.end || null;
if (data.start) {
this.nextBatch = data.start;
} else {
this.isAtEnd = true;
}
this.events.push(...events);
for (const event of events) this.room.events.set(event.id, event);
} else {
const events = data.chunk.reverse().map(raw => new Event(this.room, raw));
this.prevBatch = data.end || null;
if (data.end) {
this.prevBatch = data.end;
} else {
this.isAtBeginning = true;
}
this.events.push(...events);
for (const event of events) this.room.events.set(event.id, event);
}
@ -62,16 +81,28 @@ export class RoomTimeline implements Timeline {
export class ThreadTimeline implements Timeline {
public events: Array<Event> = [];
public isLive: boolean = false;
public isLive: boolean = true; // FIXME: live threads
public isAtBeginning: boolean = false;
public isAtEnd: boolean = false;
prevBatch: string | null | undefined = undefined;
nextBatch: string | null | undefined = undefined;
prevBatch: string | undefined;
nextBatch: string | undefined;
constructor(public thread: Thread) {}
public async paginate(dir: "f" | "b", limit: number = 50): Promise<boolean> {
if (dir === "b" && this.isAtBeginning) return false;
if (dir === "f" && this.isAtEnd) return false;
// This is to prevent someone from trying to paginate forwards,
// then paginate backwards. The timeline will end up with events from
// the beginning and end!
// FIXME: implement /context in threads
if (dir === "f") this.isAtBeginning = true;
if (dir === "b") this.isAtEnd = true;
const { room } = this.thread;
const from = (dir === "f" ? this.nextBatch : this.prevBatch) || undefined; // FIXME: don't paginate at ends
const from = dir === "f" ? this.nextBatch : this.prevBatch;
const data = await room.client.net.fetchRelations(room.id, this.thread.baseEvent.id, {
dir,
from,
@ -79,16 +110,26 @@ export class ThreadTimeline implements Timeline {
});
if (dir === "f") {
const events = data.chunk.map(raw => new Event(room, raw));
this.nextBatch = data.next_batch || null;
if (data.next_batch) {
this.nextBatch = data.next_batch;
} else {
this.isAtEnd = true;
}
this.events.push(...events);
for (const event of events) room.events.set(event.id, event);
return !!data.next_batch;
} else {
// FIXME: conduit doesn't implement ?dir=b
const events = data.chunk.map(raw => new Event(room, raw));
this.prevBatch = data.prev_batch || null;
if (data.prev_batch) {
this.prevBatch = data.prev_batch;
} else {
this.isAtBeginning = true;
}
this.events.push(...events);
for (const event of events) room.events.set(event.id, event);
return !!data.prev_batch;
}
return true;
}
}
@ -130,11 +171,19 @@ export class TimelineSet {
_appendEvents(events: Array<Event>) {
// FIXME: there should only be one live timeline for each room and
// thread, they need to be merged together
for (const event of events) this.room.events.set(event.id, event);
for (const event of events) {
this.room.events.set(event.id, event);
const threadId = event.content["m.relations"]?.find((rel: any) => rel.rel_type === "m.thread")?.event_id;
if (threadId) {
const tl = this.room.threads.get(threadId)?.timeline;
if (tl?.isLive) tl.events.push(event);
}
}
for (const timeline of this.timelines) {
// if (timeline.isLive) timeline.events.push(...events);
if (timeline.isLive && timeline instanceof RoomTimeline) timeline.events.push(...events);
// events[0].content["m.relations"].find(rel => rel.rel_type === "m.thread")?.event_id
// this.room.threads.get()
}
}
}