diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..f1b2cee --- /dev/null +++ b/deno.json @@ -0,0 +1,7 @@ +{ + "imports": { + "nanoid": "https://deno.land/x/nanoid@v3.0.0/mod.ts", + "events": "https://deno.land/x/events@v1.0.0/mod.ts", + "typed-emitter": "npm:typed-emitter" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..8a76ecb --- /dev/null +++ b/deno.lock @@ -0,0 +1,15 @@ +{ + "version": "3", + "redirects": { + "https://deno.land/x/nanoid/mod.ts": "https://deno.land/x/nanoid@v3.0.0/mod.ts" + }, + "remote": { + "https://deno.land/x/events@v1.0.0/mod.ts": "3e2655ffa5e86a6ee01022f964b7fdc6152c007106c47b02958e766c6614dbaf", + "https://deno.land/x/nanoid@v3.0.0/customAlphabet.ts": "1cfd7cfd2f07ca8d78a7e7855fcc9f59abf01ef2a127484ef94328fadf940ead", + "https://deno.land/x/nanoid@v3.0.0/customRandom.ts": "af56e19038c891a4b4ef2be931554c27579bd407ee5bbea5cb64f6ee1347cbe3", + "https://deno.land/x/nanoid@v3.0.0/mod.ts": "3ead610e40c58d8fdca21d5da9ec661445a2b82526e19c34d05de5f90be8a1be", + "https://deno.land/x/nanoid@v3.0.0/nanoid.ts": "8d119bc89a0f34e7bbe0c2dbdc280d01753e431af553d189663492310a31085d", + "https://deno.land/x/nanoid@v3.0.0/random.ts": "4da71d5f72f2bfcc6a4ee79b5d4e72f48dcf4fe4c3835fd5ebab08b9f33cd598", + "https://deno.land/x/nanoid@v3.0.0/urlAlphabet.ts": "8b1511deb1ecb23c66202b6000dc10fb68f9a96b5550c6c8cef5009324793431" + } +} diff --git a/dist/src/Connection.d.ts b/dist/src/Connection.d.ts new file mode 100644 index 0000000..e69de29 diff --git a/dist/src/Connection.js b/dist/src/Connection.js new file mode 100644 index 0000000..3918c74 --- /dev/null +++ b/dist/src/Connection.js @@ -0,0 +1 @@ +"use strict"; diff --git a/dist/src/api.d.ts b/dist/src/api.d.ts new file mode 100644 index 0000000..6a2080a --- /dev/null +++ b/dist/src/api.d.ts @@ -0,0 +1,178 @@ +export type RoomId = string; +export type UserId = string; +export type EventId = string; +export interface ApiEvent { + event_id: EventId; + sender: UserId; + type: string; + content: any; + state_key?: string; + unsigned?: any; +} +export type ApiStateEvent = ApiEvent & { + state_key: string; +}; +export type ApiDeviceEvent = { + sender: UserId; + type: string; + content: any; +}; +export type ApiEphemeralEvent = { + sender: UserId; + type: string; + content: any; + key?: string; + persist_until?: number; +}; +export type RoomSubscription = { + query: string; +}; +export type ListSubscription = { + ranges?: Array<[number, number]>; + query?: string; + filters?: { + spaces?: Array; + types?: Array; + purposes?: Array; + is_invite_knock?: boolean; + }; +}; +export interface SyncRequest { + pos?: string; + timeout?: number; + txn_id?: string; + conn_id?: string; + delta_token?: string; + queries?: Record; + timeline?: { + limit?: number; + types?: Array; + not_types?: Array; + }; + ephemeral?: { + limit?: number; + types?: Array; + include_old?: boolean; + }; + }>; + lists?: Record; + rooms?: Record; + extensions?: { + "m.to_device"?: { + enabled: boolean; + limit?: number; + }; + "m.e2ee"?: { + enabled: boolean; + }; + "m.account_data"?: { + enabled: boolean; + lists: Array; + rooms: Array; + }; + "m.presence"?: {}; + }; +} +export interface SyncResponseRoom { + initial?: boolean; + required_state?: Array; + timeline?: Array; + ephemeral?: Array; + prev_batch?: string; + joined_count?: number; + invited_count?: number; + unreads?: Unreads; +} +export interface SyncResponse { + pos: string; + txn_id?: string; + delta_token?: string; + lists?: Record; + } | { + op: "INSERT"; + index: number; + room_id: RoomId; + } | { + op: "DELETE"; + index: number; + room_ids: Array; + } | { + op: "INVALIDATE"; + range: [number, number]; + }>; + }>; + rooms?: Record; + extensions?: { + "m.to_device"?: { + enabled: boolean; + events: Array; + }; + "m.account_data"?: { + enabled: boolean; + lists: Array; + rooms: Array; + }; + "m.e2ee"?: { + "device_one_time_keys_count": { + "signed_curve25519": number; + }; + "device_lists": { + "changed": Array; + "left": Array; + }; + "device_unused_fallback_key_types": [ + "signed_curve25519" + ]; + }; + "m.presence"?: {}; + }; +} +export interface Unreads { + last_ack?: EventId; + mention_user?: number; + mention_bulk?: number; + notify?: number; + messages?: number; +} +export interface ContextResponse { + start: string; + end: string; + event: ApiEvent; + events_before: Array; + events_after: Array; + state: Array; +} +export interface MessagesResponse { + chunk: Array; + start: string; + end: string; + state: Array; +} +export interface RelationsResponse { + chunk: Array; + next_batch: string; + prev_batch: string; +} +export interface SendResponse { + event_id: EventId; +} +export type InboxFilter = "default" | "mentions_user" | "mentions_bulk" | "threads_participating" | "threads_interesting" | "include_read"; +export interface InboxResponse { + next_batch?: string; + threads?: Array; + chunk?: Array<{ + event: ApiEvent; + read: boolean; + }>; +} +export type IncludeThreads = "read" | "ignoring"; +export interface ThreadsResponse { + chunk?: Array; + next_batch?: string; +} diff --git a/dist/src/api.js b/dist/src/api.js new file mode 100644 index 0000000..8c49e7b --- /dev/null +++ b/dist/src/api.js @@ -0,0 +1,2 @@ +// all the types for the api +export {}; diff --git a/dist/src/client.d.ts b/dist/src/client.d.ts new file mode 100644 index 0000000..296cb43 --- /dev/null +++ b/dist/src/client.d.ts @@ -0,0 +1,53 @@ +import { ApiDeviceEvent, ListSubscription, RoomSubscription } from "./api.js"; +import { Network } from "./net.js"; +import { Room } from "./room.js"; +import { Connection } from "./sync.js"; +import TypedEmitter from "typed-emitter"; +interface ClientConfig { + baseUrl: string; + token: string; + userId: string; + deviceId: string; +} +type ClientState = { + state: "stop"; +} | { + state: "sync"; +} | { + state: "catchup"; +} | { + state: "error"; + reason: any; +} | { + state: "retry"; + backoff: number; +}; +type ClientEvents = { + state: (state: ClientState) => void; + list: (name: string, list: RoomList) => void; + roomInit: (room: Room) => void; + roomDeinit: (room: Room) => void; + accountData: (type: string, content: string) => void; + toDevice: (event: ApiDeviceEvent) => void; +}; +type RoomList = { + count: number; + rooms: Array; +}; +declare const Client_base: new () => TypedEmitter; +export declare class Client extends Client_base { + config: ClientConfig; + state: ClientState; + net: Network; + conn: Connection; + lists: Map; + rooms: Map; + constructor(config: ClientConfig); + start(): void; + stop(): void; + listSubscribe(name: string, subscription: ListSubscription): void; + listUnsubscribe(name: string): void; + roomSubscribe(roomId: string, subscription: RoomSubscription): void; + roomUnsubscribe(roomId: string): void; +} +export {}; diff --git a/dist/src/client.js b/dist/src/client.js new file mode 100644 index 0000000..cc4bc76 --- /dev/null +++ b/dist/src/client.js @@ -0,0 +1,85 @@ +// The "main" class that all interaction can be done through. +import { Network } from "./net.js"; +import { Connection } from "./sync.js"; +import EventEmitter from "events"; +export class Client extends EventEmitter { + constructor(config) { + super(); + Object.defineProperty(this, "config", { + enumerable: true, + configurable: true, + writable: true, + value: config + }); + // these shouldn't be public, but ah typescript + Object.defineProperty(this, "state", { + enumerable: true, + configurable: true, + writable: true, + value: { state: "stop" } + }); + Object.defineProperty(this, "net", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "conn", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "lists", { + enumerable: true, + configurable: true, + writable: true, + value: new Map() + }); + Object.defineProperty(this, "rooms", { + enumerable: true, + configurable: true, + writable: true, + value: new Map() + }); + this.net = new Network(this, { + verbose: true, + }); + this.conn = new Connection(this); + } + // Start receiving events from /sync. + // WARN: if you lose the reference to Client, the poller will leak + start() { + this.state = { state: "sync" }; + (async () => { + while (this.state.state === "sync") { + try { + await this.conn.sync(); + } + catch (err) { + this.state = { state: "error", reason: err }; + } + } + })(); + } + // Stop receiving events from /sync. + stop() { + this.conn.abort(); + this.conn = new Connection(this); + this.state = { state: "stop" }; + } + listSubscribe(name, subscription) { + this.lists.set(name, { count: 0, rooms: [] }); + this.conn.listSubscribe(name, subscription); + } + listUnsubscribe(name) { + this.lists.delete(name); + this.conn.listUnsubscribe(name); + } + roomSubscribe(roomId, subscription) { + this.conn.roomSubscribe(roomId, subscription); + } + roomUnsubscribe(roomId) { + this.conn.roomUnsubscribe(roomId); + } +} diff --git a/dist/src/event.d.ts b/dist/src/event.d.ts new file mode 100644 index 0000000..ae10470 --- /dev/null +++ b/dist/src/event.d.ts @@ -0,0 +1,18 @@ +import { Client } from "./client.js"; +import { ApiEvent, ApiStateEvent, EventId, UserId } from "./api.js"; +import { Room } from "./room.js"; +export declare class Event { + room: Room; + client: Client; + content: any; + id: EventId; + sender: UserId; + stateKey?: string; + type: string; + unsigned: any; + constructor(room: Room, json: ApiEvent); +} +export declare class StateEvent extends Event { + stateKey: string; + constructor(room: Room, json: ApiStateEvent); +} diff --git a/dist/src/event.js b/dist/src/event.js new file mode 100644 index 0000000..390722d --- /dev/null +++ b/dist/src/event.js @@ -0,0 +1,70 @@ +export class Event { + constructor(room, json) { + Object.defineProperty(this, "room", { + enumerable: true, + configurable: true, + writable: true, + value: room + }); + Object.defineProperty(this, "client", { + enumerable: true, + configurable: true, + writable: true, + value: this.room.client + }); + Object.defineProperty(this, "content", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "id", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "sender", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "stateKey", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "type", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "unsigned", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + this.content = json.content; + this.id = json.event_id; + this.sender = json.sender; + this.stateKey = json.state_key; + this.type = json.type; + this.unsigned = json.unsigned; + } +} +export class StateEvent extends Event { + constructor(room, json) { + super(room, json); + Object.defineProperty(this, "stateKey", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + this.stateKey = json.state_key; + } +} diff --git a/dist/src/index.d.ts b/dist/src/index.d.ts index f4f59c7..57eacf9 100644 --- a/dist/src/index.d.ts +++ b/dist/src/index.d.ts @@ -1 +1 @@ -export declare function hello(name: string): void; +export { Client } from "./client.js"; diff --git a/dist/src/index.js b/dist/src/index.js index 97d23dc..57eacf9 100644 --- a/dist/src/index.js +++ b/dist/src/index.js @@ -1,3 +1 @@ -export function hello(name) { - console.log(`hello ${name}!`); -} +export { Client } from "./client.js"; diff --git a/dist/src/net.d.ts b/dist/src/net.d.ts new file mode 100644 index 0000000..1318bc0 --- /dev/null +++ b/dist/src/net.d.ts @@ -0,0 +1,46 @@ +import * as t from "./api.js"; +import { Client } from "./client.js"; +interface NetworkConfig { + fetchImpl?: typeof fetch; + verbose?: boolean; +} +export declare class Network { + private client; + private config; + constructor(client: Client, config: NetworkConfig); + private fetch; + sync(options: t.SyncRequest, signal: AbortSignal): Promise; + fetchEvent(roomId: t.RoomId, eventId: t.EventId): Promise; + fetchContext(roomId: t.RoomId, eventId: t.EventId, limit?: number): Promise; + fetchMessages(opts: { + roomId: t.RoomId; + dir: "b" | "f"; + limit?: number; + from: string; + to?: string; + }): Promise; + fetchRelations(roomId: t.RoomId, eventId: t.EventId, opts: { + relType?: string; + eventType?: string; + limit?: string; + dir: "b" | "f"; + from?: string; + to?: string; + }): Promise; + fetchThreads(opts: { + from?: string; + limit?: number; + roomIds?: Array; + watching: boolean; + include: Array; + }): Promise; + fetchInbox(opts: { + roomIds?: Array; + from?: string; + filter?: t.InboxFilter; + limit?: number; + }): Promise; + sendEvent(roomId: t.RoomId, type: string, txnId: string, content: any): Promise; + sendState(roomId: t.RoomId, type: string, stateKey: string, content: any): Promise; +} +export {}; diff --git a/dist/src/net.js b/dist/src/net.js new file mode 100644 index 0000000..6f1ad08 --- /dev/null +++ b/dist/src/net.js @@ -0,0 +1,128 @@ +const e = (s) => encodeURIComponent(s); +function getMethodColor(method) { + // i love using the more obscure css colors + switch (method) { + case "GET": return "deepskyblue"; + case "POST": return "springgreen"; + case "PUT": return "lightsalmon"; + case "PATCH": return "sandybrown"; + case "DELETE": return "salmon"; + default: return "orchid"; + } +} +export class Network { + constructor(client, config) { + Object.defineProperty(this, "client", { + enumerable: true, + configurable: true, + writable: true, + value: client + }); + Object.defineProperty(this, "config", { + enumerable: true, + configurable: true, + writable: true, + value: config + }); + if (!config.fetchImpl) + config.fetchImpl = globalThis.fetch; + } + async fetch(options) { + const url = `${this.client.config.baseUrl}${options.path}`; + const isJson = typeof options.body === "object" + && !ArrayBuffer.isView(options.body) + && !(options.body instanceof ReadableStream); + if (this.config.verbose) { + console.log("%c%s%c %s", getMethodColor(options.method), options.method, "color: initial", options.path); + } + const req = await this.config.fetchImpl(url, { + method: options.method, + headers: { + "authorization": `Bearer ${this.client.config.token}`, + }, + body: (isJson ? JSON.stringify(options.body) : options.body), + ...options.extra, + }); + if (!req.ok) + throw new Error(`Request failed: ${await req.text()}`); + if (options.raw) + return req.body; + return req.json(); + } + async sync(options, signal) { + return this.fetch({ + method: "POST", + path: `/_matrix/client/unstable/org.matrix.msc3575/sync?timeout=${options.timeout}&pos=${options.pos}`, + body: options, + extra: { signal }, + }); + } + async fetchEvent(roomId, eventId) { + return this.fetch({ + method: "GET", + path: `/_matrix/client/v3/rooms/${e(roomId)}/event/${e(eventId)}`, + }); + } + async fetchContext(roomId, eventId, limit = 50) { + return this.fetch({ + method: "GET", + path: `/_matrix/client/v3/rooms/${e(roomId)}/context/${e(eventId)}?limit=${limit}`, + }); + } + async fetchMessages(opts) { + let path = `/_matrix/client/v3/rooms/${e(opts.roomId)}/messages?limit=${opts.limit || 50}&from=${e(opts.from)}&dir=${e(opts.dir)}`; + if (opts.to) + path += `&to=${e(opts.to)}`; + return this.fetch({ method: "GET", path }); + } + 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)}`; + if (opts.relType) + path += `/${e(opts.relType)}`; + if (opts.eventType) + path += `/${e(opts.eventType)}`; + path += `?limit=${opts.limit || 50}&dir=${opts.dir}`; + if (opts.from) + path += `&from=${opts.from}`; + if (opts.to) + path += `&from=${opts.to}`; + return this.fetch({ method: "GET", path }); + } + async fetchThreads(opts) { + return this.fetch({ + method: "POST", + path: `/_matrix/client/v1/threads?limit=${opts.limit}${opts.from ? `&from=${e(opts.from)}` : ""}`, + body: { + watching: opts.watching, + room_ids: opts.roomIds, + include: opts.include, + } + }); + } + async fetchInbox(opts) { + return this.fetch({ + method: "POST", + path: `/_matrix/client/v1/inbox?limit=${opts.limit}${opts.from ? `&from=${e(opts.from)}` : ""}`, + body: { + filter: opts.filter, + room_ids: opts.roomIds, + } + }); + } + async sendEvent(roomId, type, txnId, content) { + return this.fetch({ + method: "PUT", + path: `/_matrix/client/v3/rooms/${e(roomId)}/send/${e(type)}/${e(txnId)}`, + body: content, + }); + } + async sendState(roomId, type, stateKey, content) { + return this.fetch({ + method: "PUT", + path: `/_matrix/client/v3/rooms/${e(roomId)}/state/${e(type)}/${e(stateKey)}`, + body: content, + }); + } +} diff --git a/dist/src/room.d.ts b/dist/src/room.d.ts new file mode 100644 index 0000000..c4308d1 --- /dev/null +++ b/dist/src/room.d.ts @@ -0,0 +1,30 @@ +import TypedEmitter from "typed-emitter"; +import { ApiEphemeralEvent, SyncResponseRoom, Unreads } from "./api.js"; +import { Client } from "./client.js"; +import { Event, StateEvent } from "./event.js"; +import { TimelineSet } from "./timeline.js"; +type RoomEvents = { + timeline: (event: Event) => void; + ephemeral: (event: ApiEphemeralEvent) => void; + state: (event: StateEvent) => void; + notifications: (notifs: Unreads) => void; + summary: (x: { + invited_count: number; + joined_count: number; + }) => void; + accountData: (type: string, content: string) => void; +}; +declare const Room_base: new () => TypedEmitter; +export declare class Room extends Room_base { + client: Client; + id: string; + private state; + timelines: TimelineSet; + constructor(client: Client, id: string, data: SyncResponseRoom); + _merge(data: SyncResponseRoom): void; + getState(type: string, stateKey?: string): StateEvent | null; + getAllState(type: string): Array; + sendState(type: string, stateKey: string, content: any): Promise; + sendEvent(type: string, content: any): Promise; +} +export {}; diff --git a/dist/src/room.js b/dist/src/room.js new file mode 100644 index 0000000..3ce248b --- /dev/null +++ b/dist/src/room.js @@ -0,0 +1,69 @@ +import EventEmitter from "events"; +import { Event, StateEvent } from "./event.js"; +import { TimelineSet } from "./timeline.js"; +import { nanoid } from "nanoid"; +export class Room extends EventEmitter { + constructor(client, id, data) { + super(); + Object.defineProperty(this, "client", { + enumerable: true, + configurable: true, + writable: true, + value: client + }); + Object.defineProperty(this, "id", { + enumerable: true, + configurable: true, + writable: true, + value: id + }); + // The (possibly incomplete) state of this room + Object.defineProperty(this, "state", { + enumerable: true, + configurable: true, + writable: true, + value: new Map() + }); + Object.defineProperty(this, "timelines", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + this.timelines = new TimelineSet(this); + this.timelines.live.prevBatch = data.prev_batch || null; + this._merge(data); + } + // should be private to only the library like pub(crate), but there's no way currently + _merge(data) { + for (const raw of data.required_state || []) { + const event = new StateEvent(this, raw); + const map = this.state.get(event.type); + if (map) { + map.set(event.stateKey, event); + } + else { + this.state.set(event.type, new Map([[event.stateKey, event]])); + } + } + if (data.timeline?.length) { + const events = data.timeline.map(raw => new Event(this, raw)); + this.timelines._appendEvents(events); + } + } + getState(type, stateKey = "") { + return this.state.get(type)?.get(stateKey) || null; + } + getAllState(type) { + return [...this.state.get(type)?.values() ?? []]; + } + // TODO: return event + async sendState(type, stateKey, content) { + // const { event_id } = await this.client.net.sendState(this.id, type, stateKey, content); + await this.client.net.sendState(this.id, type, stateKey, content); + } + // TODO: return event + async sendEvent(type, content) { + await this.client.net.sendEvent(this.id, type, nanoid(), content); + } +} diff --git a/dist/src/setup.d.ts b/dist/src/setup.d.ts new file mode 100644 index 0000000..bb523dc --- /dev/null +++ b/dist/src/setup.d.ts @@ -0,0 +1,2 @@ +declare class Setup { +} diff --git a/dist/src/setup.js b/dist/src/setup.js new file mode 100644 index 0000000..7bb6731 --- /dev/null +++ b/dist/src/setup.js @@ -0,0 +1,4 @@ +"use strict"; +// Used for initiating a client, handling well-known and authentication. +class Setup { +} diff --git a/dist/src/sync.d.ts b/dist/src/sync.d.ts new file mode 100644 index 0000000..de742a1 --- /dev/null +++ b/dist/src/sync.d.ts @@ -0,0 +1,19 @@ +import type { Client } from "./index.js"; +import { ListSubscription, RoomSubscription } from "./api.js"; +export declare class Connection { + private client; + private connId; + private controller; + private pos; + private delta; + private query; + constructor(client: Client); + sync(timeout?: number): Promise; + private refresh; + private stripSticky; + listSubscribe(name: string, subscription: ListSubscription): void; + listUnsubscribe(name: string): void; + roomSubscribe(roomId: string, subscription: RoomSubscription): void; + roomUnsubscribe(roomId: string): void; + abort(reason?: string): void; +} diff --git a/dist/src/sync.js b/dist/src/sync.js new file mode 100644 index 0000000..53c2ecd --- /dev/null +++ b/dist/src/sync.js @@ -0,0 +1,139 @@ +// A sliding sync connection. One of these is active per client. +// It is part of `Client`, but split out for maintainability. +import { nanoid } from "nanoid"; +import { Room } from "./room.js"; +export class Connection { + constructor(client) { + Object.defineProperty(this, "client", { + enumerable: true, + configurable: true, + writable: true, + value: client + }); + Object.defineProperty(this, "connId", { + enumerable: true, + configurable: true, + writable: true, + value: nanoid() + }); + Object.defineProperty(this, "controller", { + enumerable: true, + configurable: true, + writable: true, + value: new AbortController() + }); + Object.defineProperty(this, "pos", { + enumerable: true, + configurable: true, + writable: true, + value: "0" + }); + Object.defineProperty(this, "delta", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "query", { + enumerable: true, + configurable: true, + writable: true, + value: {} + }); + } + // run sync once + async sync(timeout = 30000) { + const json = await this.client.net.sync({ + conn_id: this.connId, + pos: this.pos, + delta_token: this.delta || undefined, + timeout, + ...this.query, + }, this.controller.signal).catch((reason) => { + if (reason === "update query") + return null; + throw reason; + }); + if (!json) + return; + console.log(json); + this.stripSticky(); + this.pos = json.pos; + this.delta = json.delta_token; + const { rooms, lists } = this.client; + for (const roomId in json.rooms) { + const data = json.rooms[roomId]; + if (rooms.has(roomId) && !data.initial) { + rooms.get(roomId)._merge(data); + } + else { + const room = new Room(this.client, roomId, data); + rooms.set(roomId, room); + this.client.emit("roomInit", room); + } + } + for (const listId in json.lists) { + const list = lists.get(listId); + if (!list) + continue; + list.count = json.lists[listId].count; + for (const op of json.lists[listId].ops) { + switch (op.op) { + case "SYNC": + list.rooms.splice(op.range[0], op.range[1] - op.range[0] + 1, ...op.room_ids.map(id => rooms.get(id))); + break; + // case "INVALIDATE": + // list.rooms.splice(op.range[0], op.range[1] - op.range[0] + 1); + // break; + default: + // the entire op system should probably be reworked + // if there's no sorting, i only need `sync` and `invalidate`? + throw new Error("unimplemented"); + } + } + } + return json; + } + refresh() { + this.controller.abort("update query"); + this.controller = new AbortController(); + } + stripSticky() { + const { query } = this; + const { lists } = query; + if (lists) { + for (const list in lists) { + lists[list] = { ranges: lists[list].ranges }; + } + } + query.queries = {}; + query.rooms = {}; + } + listSubscribe(name, subscription) { + if (!this.query.lists) + this.query.lists = {}; + this.query.lists[name] = subscription; + this.refresh(); + } + listUnsubscribe(name) { + if (!this.query.lists) + return; + delete this.query.lists[name]; + this.refresh(); + } + roomSubscribe(roomId, subscription) { + if (!this.query.rooms) + this.query.rooms = {}; + this.query.rooms[roomId] = subscription; + this.refresh(); + } + roomUnsubscribe(roomId) { + if (!this.query.rooms) + this.query.rooms = {}; + this.query.rooms[roomId] = null; + this.refresh(); + } + abort(reason) { + this.controller.abort(reason); + } +} diff --git a/dist/src/thread.d.ts b/dist/src/thread.d.ts new file mode 100644 index 0000000..73797b5 --- /dev/null +++ b/dist/src/thread.d.ts @@ -0,0 +1,15 @@ +import TypedEmitter from "typed-emitter"; +import { Unreads } from "./api.js"; +import { Room } from "./room.js"; +import { Event } from "./event.js"; +type ThreadEvents = { + timeline: (event: Event) => void; + notifications: (notifs: Unreads) => void; +}; +declare const Thread_base: new () => TypedEmitter; +export declare class Thread extends Thread_base { + baseEvent: Event; + room: Room; + constructor(baseEvent: Event); +} +export {}; diff --git a/dist/src/thread.js b/dist/src/thread.js new file mode 100644 index 0000000..11266ff --- /dev/null +++ b/dist/src/thread.js @@ -0,0 +1,18 @@ +import EventEmitter from "events"; +export class Thread extends EventEmitter { + constructor(baseEvent) { + super(); + Object.defineProperty(this, "baseEvent", { + enumerable: true, + configurable: true, + writable: true, + value: baseEvent + }); + Object.defineProperty(this, "room", { + enumerable: true, + configurable: true, + writable: true, + value: this.baseEvent.room + }); + } +} diff --git a/dist/src/timeline.d.ts b/dist/src/timeline.d.ts new file mode 100644 index 0000000..f05ba63 --- /dev/null +++ b/dist/src/timeline.d.ts @@ -0,0 +1,33 @@ +import { EventId } from "./api.js"; +import { Room } from "./room.js"; +import { Event } from "./event.js"; +import { Thread } from "./thread.js"; +interface Timeline { + isLive: boolean; + events: Array; +} +export declare class RoomTimeline implements Timeline { + room: Room; + events: Array; + isLive: boolean; + prevBatch: string | null; + nextBatch: string | null; + constructor(room: Room); +} +export declare class ThreadTimeline implements Timeline { + thread: Thread; + events: never[]; + isLive: boolean; + prevBatch: string | null; + nextBatch: string | null; + constructor(thread: Thread); +} +export declare class TimelineSet { + room: Room; + live: RoomTimeline; + private timelines; + constructor(room: Room); + forEvent(eventId: EventId): Promise; + _appendEvents(events: Array): void; +} +export {}; diff --git a/dist/src/timeline.js b/dist/src/timeline.js new file mode 100644 index 0000000..f0447e5 --- /dev/null +++ b/dist/src/timeline.js @@ -0,0 +1,129 @@ +// Timelines are ordered sequences of events. +import { Event } from "./event.js"; +export class RoomTimeline { + constructor(room) { + Object.defineProperty(this, "room", { + enumerable: true, + configurable: true, + writable: true, + value: room + }); + Object.defineProperty(this, "events", { + enumerable: true, + configurable: true, + writable: true, + value: [] + }); + Object.defineProperty(this, "isLive", { + enumerable: true, + configurable: true, + writable: true, + value: false + }); + Object.defineProperty(this, "prevBatch", { + enumerable: true, + configurable: true, + writable: true, + value: null + }); + Object.defineProperty(this, "nextBatch", { + enumerable: true, + configurable: true, + writable: true, + value: null + }); + } +} +export class ThreadTimeline { + constructor(thread) { + Object.defineProperty(this, "thread", { + enumerable: true, + configurable: true, + writable: true, + value: thread + }); + Object.defineProperty(this, "events", { + enumerable: true, + configurable: true, + writable: true, + value: [] + }); + Object.defineProperty(this, "isLive", { + enumerable: true, + configurable: true, + writable: true, + value: false + }); + Object.defineProperty(this, "prevBatch", { + enumerable: true, + configurable: true, + writable: true, + value: null + }); + Object.defineProperty(this, "nextBatch", { + enumerable: true, + configurable: true, + writable: true, + value: null + }); + } +} +export class TimelineSet { + // private eventIdToTimeline: Map = new Map(); + constructor(room) { + Object.defineProperty(this, "room", { + enumerable: true, + configurable: true, + writable: true, + value: room + }); + // Other timelines *may* be live, but this one is guaranteed to be live + Object.defineProperty(this, "live", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "timelines", { + enumerable: true, + configurable: true, + writable: true, + value: new Set() + }); + this.live = new RoomTimeline(room); + this.live.isLive = true; + this.timelines.add(this.live); + } + // Get a timeline for a thread. Becomes a live timeline if `atEnd = true`. + // public async forThread(eventId: EventId, atEnd: boolean): Promise { + // throw "todo"; + // // const thread = new Thread(); + // // const tl = new ThreadTimeline(thread); + // // return tl; + // } + // Get a timeline for an event (context). + async forEvent(eventId) { + const context = await this.room.client.net.fetchContext(this.room.id, eventId); + const tl = new RoomTimeline(this.room); + tl.events = context.events_before + .reverse() + .concat([context.event]) + .concat(context.events_after) + .map(raw => new Event(this.room, raw)); + tl.prevBatch = context.start; + tl.nextBatch = context.end; + this.timelines.add(tl); + return tl; + } + // Paginate a timeline for more events + // public paginate(timeline: Timeline, dir: "f" | "b", limit: number = 50) { + // throw "todo"; + // } + _appendEvents(events) { + // FIXME: merge timelines together + for (const timeline of this.timelines) { + if (timeline.isLive) + timeline.events.push(...events); + } + } +} diff --git a/dist/src/utils.d.ts b/dist/src/utils.d.ts new file mode 100644 index 0000000..e69de29 diff --git a/dist/src/utils.js b/dist/src/utils.js new file mode 100644 index 0000000..42828c8 --- /dev/null +++ b/dist/src/utils.js @@ -0,0 +1,2 @@ +"use strict"; +// Various useful utilities diff --git a/dist/tsconfig.tsbuildinfo b/dist/tsconfig.tsbuildinfo index 08986b5..9fb55b6 100644 --- a/dist/tsconfig.tsbuildinfo +++ b/dist/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"program":{"fileNames":["../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es5.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2016.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2017.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2018.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2019.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.dom.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.dom.iterable.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.core.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.collection.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.generator.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.promise.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2017.date.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2017.object.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2017.string.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2017.intl.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2018.intl.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2018.promise.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2019.array.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2019.object.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2019.string.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2019.intl.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.date.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.promise.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.string.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.intl.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.number.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.decorators.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.decorators.legacy.d.ts","../src/index.ts"],"fileInfos":[{"version":"f33e5332b24c3773e930e212cbb8b6867c8ba3ec4492064ea78e55a524d57450","affectsGlobalScope":true},"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","26f2f787e82c4222710f3b676b4d83eb5ad0a72fa7b746f03449e7a026ce5073","9a68c0c07ae2fa71b44384a839b7b8d81662a236d4b9ac30916718f7510b1b2d","5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4",{"version":"21e41a76098aa7a191028256e52a726baafd45a925ea5cf0222eb430c96c1d83","affectsGlobalScope":true},{"version":"35299ae4a62086698444a5aaee27fc7aa377c68cbb90b441c9ace246ffd05c97","affectsGlobalScope":true},{"version":"138fb588d26538783b78d1e3b2c2cc12d55840b97bf5e08bca7f7a174fbe2f17","affectsGlobalScope":true},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true},{"version":"4443e68b35f3332f753eacc66a04ac1d2053b8b035a0e0ac1d455392b5e243b3","affectsGlobalScope":true},{"version":"bc47685641087c015972a3f072480889f0d6c65515f12bd85222f49a98952ed7","affectsGlobalScope":true},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true},{"version":"93495ff27b8746f55d19fcbcdbaccc99fd95f19d057aed1bd2c0cafe1335fbf0","affectsGlobalScope":true},{"version":"6fc23bb8c3965964be8c597310a2878b53a0306edb71d4b5a4dfe760186bcc01","affectsGlobalScope":true},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true},{"version":"bb42a7797d996412ecdc5b2787720de477103a0b2e53058569069a0e2bae6c7e","affectsGlobalScope":true},{"version":"4738f2420687fd85629c9efb470793bb753709c2379e5f85bc1815d875ceadcd","affectsGlobalScope":true},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true},{"version":"9fc46429fbe091ac5ad2608c657201eb68b6f1b8341bd6d670047d32ed0a88fa","affectsGlobalScope":true},{"version":"61c37c1de663cf4171e1192466e52c7a382afa58da01b1dc75058f032ddf0839","affectsGlobalScope":true},{"version":"b541a838a13f9234aba650a825393ffc2292dc0fc87681a5d81ef0c96d281e7a","affectsGlobalScope":true},{"version":"e0275cd0e42990dc3a16f0b7c8bca3efe87f1c8ad404f80c6db1c7c0b828c59f","affectsGlobalScope":true},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true},{"version":"49ed889be54031e1044af0ad2c603d627b8bda8b50c1a68435fe85583901d072","affectsGlobalScope":true},{"version":"e93d098658ce4f0c8a0779e6cab91d0259efb88a318137f686ad76f8410ca270","affectsGlobalScope":true},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true},{"version":"bf14a426dbbf1022d11bd08d6b8e709a2e9d246f0c6c1032f3b2edb9a902adbe","affectsGlobalScope":true},{"version":"ec0104fee478075cb5171e5f4e3f23add8e02d845ae0165bfa3f1099241fa2aa","affectsGlobalScope":true},{"version":"2b72d528b2e2fe3c57889ca7baef5e13a56c957b946906d03767c642f386bbc3","affectsGlobalScope":true},{"version":"acae90d417bee324b1372813b5a00829d31c7eb670d299cd7f8f9a648ac05688","affectsGlobalScope":true},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true},{"version":"33358442698bb565130f52ba79bfd3d4d484ac85fe33f3cb1759c54d18201393","affectsGlobalScope":true},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true},{"version":"c96e8fb023fd21edca8da30bc7f554903e1ad82d1bd49b0405adb53f709d62b7","signature":"c333b3df7f75326c9f1f1e577345dce2bf607674316f3805f14d80a30045b765"}],"root":[46],"options":{"composite":true,"jsx":1,"jsxImportSource":"solid-js","module":99,"noFallthroughCasesInSwitch":true,"noUnusedLocals":true,"noUnusedParameters":true,"outDir":"./","skipLibCheck":true,"strict":true,"strictNullChecks":true,"target":7,"useDefineForClassFields":true},"referencedMap":[],"exportedModulesMap":[],"semanticDiagnosticsPerFile":[44,45,8,9,11,10,2,12,13,14,15,16,17,18,19,3,4,20,24,21,22,23,25,26,27,5,28,29,30,31,6,35,32,33,34,36,7,37,42,43,38,39,40,41,1,46],"latestChangedDtsFile":"./src/index.d.ts"},"version":"5.3.2"} \ No newline at end of file +{"program":{"fileNames":["../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es5.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2016.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2017.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2018.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2019.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2021.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2022.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.dom.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.dom.iterable.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.core.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.collection.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.generator.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.promise.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2017.date.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2017.object.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2017.string.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2017.intl.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2018.intl.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2018.promise.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2019.array.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2019.object.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2019.string.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2019.intl.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.date.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.promise.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.string.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.intl.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2020.number.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2021.promise.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2021.string.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2021.weakref.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2021.intl.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2022.array.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2022.error.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2022.intl.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2022.object.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2022.sharedmemory.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2022.string.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.es2022.regexp.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.decorators.d.ts","../node_modules/.pnpm/typescript@5.3.2/node_modules/typescript/lib/lib.decorators.legacy.d.ts","../src/api.ts","../src/net.ts","../node_modules/.pnpm/@types+events@3.0.3/node_modules/@types/events/index.d.ts","../node_modules/.pnpm/typed-emitter@2.1.0/node_modules/typed-emitter/index.d.ts","../src/event.ts","../src/thread.ts","../src/timeline.ts","../node_modules/.pnpm/nanoid@5.0.4/node_modules/nanoid/index.d.ts","../src/room.ts","../src/index.ts","../src/sync.ts","../src/client.ts","../src/setup.ts","../src/utils.ts"],"fileInfos":[{"version":"f33e5332b24c3773e930e212cbb8b6867c8ba3ec4492064ea78e55a524d57450","affectsGlobalScope":true},"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","26f2f787e82c4222710f3b676b4d83eb5ad0a72fa7b746f03449e7a026ce5073","9a68c0c07ae2fa71b44384a839b7b8d81662a236d4b9ac30916718f7510b1b2d","5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","5514e54f17d6d74ecefedc73c504eadffdeda79c7ea205cf9febead32d45c4bc",{"version":"21e41a76098aa7a191028256e52a726baafd45a925ea5cf0222eb430c96c1d83","affectsGlobalScope":true},{"version":"35299ae4a62086698444a5aaee27fc7aa377c68cbb90b441c9ace246ffd05c97","affectsGlobalScope":true},{"version":"138fb588d26538783b78d1e3b2c2cc12d55840b97bf5e08bca7f7a174fbe2f17","affectsGlobalScope":true},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true},{"version":"4443e68b35f3332f753eacc66a04ac1d2053b8b035a0e0ac1d455392b5e243b3","affectsGlobalScope":true},{"version":"bc47685641087c015972a3f072480889f0d6c65515f12bd85222f49a98952ed7","affectsGlobalScope":true},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true},{"version":"93495ff27b8746f55d19fcbcdbaccc99fd95f19d057aed1bd2c0cafe1335fbf0","affectsGlobalScope":true},{"version":"6fc23bb8c3965964be8c597310a2878b53a0306edb71d4b5a4dfe760186bcc01","affectsGlobalScope":true},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true},{"version":"bb42a7797d996412ecdc5b2787720de477103a0b2e53058569069a0e2bae6c7e","affectsGlobalScope":true},{"version":"4738f2420687fd85629c9efb470793bb753709c2379e5f85bc1815d875ceadcd","affectsGlobalScope":true},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true},{"version":"9fc46429fbe091ac5ad2608c657201eb68b6f1b8341bd6d670047d32ed0a88fa","affectsGlobalScope":true},{"version":"61c37c1de663cf4171e1192466e52c7a382afa58da01b1dc75058f032ddf0839","affectsGlobalScope":true},{"version":"b541a838a13f9234aba650a825393ffc2292dc0fc87681a5d81ef0c96d281e7a","affectsGlobalScope":true},{"version":"e0275cd0e42990dc3a16f0b7c8bca3efe87f1c8ad404f80c6db1c7c0b828c59f","affectsGlobalScope":true},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true},{"version":"49ed889be54031e1044af0ad2c603d627b8bda8b50c1a68435fe85583901d072","affectsGlobalScope":true},{"version":"e93d098658ce4f0c8a0779e6cab91d0259efb88a318137f686ad76f8410ca270","affectsGlobalScope":true},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true},{"version":"bf14a426dbbf1022d11bd08d6b8e709a2e9d246f0c6c1032f3b2edb9a902adbe","affectsGlobalScope":true},{"version":"ec0104fee478075cb5171e5f4e3f23add8e02d845ae0165bfa3f1099241fa2aa","affectsGlobalScope":true},{"version":"2b72d528b2e2fe3c57889ca7baef5e13a56c957b946906d03767c642f386bbc3","affectsGlobalScope":true},{"version":"acae90d417bee324b1372813b5a00829d31c7eb670d299cd7f8f9a648ac05688","affectsGlobalScope":true},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true},{"version":"51e547984877a62227042850456de71a5c45e7fe86b7c975c6e68896c86fa23b","affectsGlobalScope":true},{"version":"62a4966981264d1f04c44eb0f4b5bdc3d81c1a54725608861e44755aa24ad6a5","affectsGlobalScope":true},{"version":"4fa6ed14e98aa80b91f61b9805c653ee82af3502dc21c9da5268d3857772ca05","affectsGlobalScope":true},{"version":"e6633e05da3ff36e6da2ec170d0d03ccf33de50ca4dc6f5aeecb572cedd162fb","affectsGlobalScope":true},{"version":"86a34c7a13de9cabc43161348f663624b56871ed80986e41d214932ddd8d6719","affectsGlobalScope":true},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true},{"version":"caccc56c72713969e1cfe5c3d44e5bab151544d9d2b373d7dbe5a1e4166652be","affectsGlobalScope":true},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true},{"version":"50d53ccd31f6667aff66e3d62adf948879a3a16f05d89882d1188084ee415bbc","affectsGlobalScope":true},{"version":"33358442698bb565130f52ba79bfd3d4d484ac85fe33f3cb1759c54d18201393","affectsGlobalScope":true},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true},{"version":"7bebf5f8130e2f02b4b0d32d70e906d091d92ce546a3e98862e49939fe677029","signature":"2b16d07f53c1bf9718274ca1284465b331dae774830487594edec1cbdb27f604"},{"version":"c43490a0bcc53f66bdaed79aab4d1e17b365cac6ca5b613edbb0985d0a45c16c","signature":"0b47e8aebfb8c3462965b297ab270fad7d30baf8b059c7ef9c95b722e029913f"},"93d28b4eb12c68fccc1f2fc04a4ef83ea3b2a03b18055d3bf29cab267aa7042e","6c27d4b5ba01295ef334456d9af4366aca789f228eee70fcb874b903a59b0e5b",{"version":"66069bef82848fcb47e478e97906407d618c0773ab38a15388cb632800299a13","signature":"0d900340fc15ca03067d53541dce3594a564f51dc810501aa1f8b88d2feef6f9"},{"version":"cedda797de7142c2b4076d753dbe24232fdaa24d57194df1ded8308e2af32c97","signature":"1451da38e2ef2fb096667c499ad288c4d6d59dc88b6fe0eae353b3201315bf5c"},{"version":"eddc98001c0bfab9eced637445b17fc2d546186de5cca1fd032dbb952ca6e62b","signature":"7b14c946e5a10f4d1d1c04bd754586f6c6beebfc30c3940a1374abf65e03c8eb"},"a45ee7555d019a67fbe092898d1aef0b1d02a9f6679ab84461ff515b4460d706",{"version":"41e3bcdbd5c22f032b214fb4a79ccdbb861e813502ace8c932c200ab5c8d7e27","signature":"5693169f466994c4639269795f136563d2f9171432c6f004a253639a2fc2e236"},"e341e61cb710c752f5154d3e7c88aa0f90b2791f48a779a7d2c248674d1175bd",{"version":"004b7f7d2f57ea0c3140579ffc0c5b2d50f1b764a262f21098b9ca98ef96565c","signature":"72848e8c0680b2f2b81304c45137ce94af346b19ba084a90eb37437b8f14a163"},{"version":"b59872a86ed21fadc90ca10c963c81b051a431855d085952f0fd68f0d8decf19","signature":"9717fd731613a916e16615ac737efb44ee6850a6c8f250d90668b7c11bbd9751"},{"version":"8f8d0bf3babdb97438eb27caaae3fb6186208704a82446d03971e185c1f5f009","signature":"16f4188a578c2e2a5278d75399ac88b039a9a0c08f852c221d3b03410dff08ff","affectsGlobalScope":true},{"version":"00732cfad7bfbc344b48cebb977fb5c06b7394f3008488947d27383cccfb4e54","signature":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}],"root":[59,60,[63,65],[67,72]],"options":{"composite":true,"jsx":1,"jsxImportSource":"solid-js","module":99,"noFallthroughCasesInSwitch":true,"noUnusedLocals":true,"noUnusedParameters":true,"outDir":"./","skipLibCheck":true,"strict":true,"strictNullChecks":true,"target":7,"useDefineForClassFields":true},"fileIdsList":[[59,60,61,62,67,69],[59,67,70],[70],[59,70],[59,61,62,63,65,66,70],[59,66,67,68],[59,61,62,63,67],[59,63,64,67],[59,60,62,67,69],[59,62,63,65,70],[59,68],[59,62,63,67]],"referencedMap":[[70,1],[63,2],[68,3],[60,4],[67,5],[69,6],[64,7],[65,8]],"exportedModulesMap":[[70,9],[63,2],[68,3],[60,4],[67,10],[69,11],[64,12],[65,8]],"semanticDiagnosticsPerFile":[61,66,62,57,58,10,11,13,12,2,14,15,16,17,18,19,20,21,3,4,22,26,23,24,25,27,28,29,5,30,31,32,33,6,37,34,35,36,38,7,39,44,45,40,41,42,43,8,49,46,47,48,50,9,51,52,53,56,54,55,1,59,70,63,68,60,67,71,69,64,65,72],"latestChangedDtsFile":"./src/net.d.ts"},"version":"5.3.2"} \ No newline at end of file diff --git a/docs/name.md b/docs/name.md new file mode 100644 index 0000000..e220e8b --- /dev/null +++ b/docs/name.md @@ -0,0 +1,22 @@ +```ts +function getName(room: Room): string { + const nameEvent = room.getState("m.room.name", "")?.content; + if (nameEvent) return nameEvent; + + const purposes = room.getState("m.room.purpose", "")?.content?.purposes; + if (Array.isArray(purposes) && purposes.includes("m.direct")) { + const members = room.getAllState("m.room.member") + .filter(i => i.content.membership === "join") + .map(i => i.content.displayname || i.stateKey); + switch (members.length) { + case 1: return members[0]; + case 2: return `${members[0]} and ${members[1]}`; + case 3: return `${members[0]}, ${members[1]}, and ${members[2]}`; + case 4: return `${members[0]}, ${members[1]}, ${members[2]}, and ${members[3]}`; + default: return `${members[0]}, ${members[1]}, ${members[2]}, and ${members.length - 3} others`; + } + } + + return "Unnamed room"; +} +``` diff --git a/docs/stream.md b/docs/stream.md new file mode 100644 index 0000000..81cecf3 --- /dev/null +++ b/docs/stream.md @@ -0,0 +1,34 @@ +During media upload, it would be nice to know the progress of an +upload. This is possible with a `TransformStream`: + +```ts +function progress() { + let bytes = 0; + return new TransformStream({ + transform(chunk, control) { + if (chunk === null) { + control.terminate(); + } else if (ArrayBuffer.isView(chunk)) { + bytes += chunk.byteLength; + console.log(`sent chunk (size = ${chunk.byteLength}, total = ${bytes})`); + control.enqueue(chunk); + } else { + throw new Error("invalid bytes"); + } + }, + }); +} +``` + +You can use it in ie. Deno as follows: + +```ts +const file = await Deno.open("file.ext"); +const { readable, writable } = progress(); +file.readable.pipeTo(writable); +const req = await fetch("https://httpbin.org/anything", { + method: "POST", + body: readable, +}); +console.log(req); +``` diff --git a/package.json b/package.json index b34c735..f169907 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,12 @@ "license": "ISC", "type": "module", "devDependencies": { + "@types/events": "^3.0.3", "typescript": "^5.3.2" + }, + "dependencies": { + "events": "^3.3.0", + "nanoid": "^5.0.4", + "typed-emitter": "^2.1.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26161fc..b13c541 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,13 +4,62 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +dependencies: + events: + specifier: ^3.3.0 + version: 3.3.0 + nanoid: + specifier: ^5.0.4 + version: 5.0.4 + typed-emitter: + specifier: ^2.1.0 + version: 2.1.0 + devDependencies: + '@types/events': + specifier: ^3.0.3 + version: 3.0.3 typescript: specifier: ^5.3.2 version: 5.3.2 packages: + /@types/events@3.0.3: + resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==} + dev: true + + /events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + dev: false + + /nanoid@5.0.4: + resolution: {integrity: sha512-vAjmBf13gsmhXSgBrtIclinISzFFy22WwCYoyilZlsrRXNIHSwgFQ1bEdjRwMT3aoadeIF6HMuDRlOxzfXV8ig==} + engines: {node: ^18 || >=20} + hasBin: true + dev: false + + /rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + requiresBuild: true + dependencies: + tslib: 2.6.2 + dev: false + optional: true + + /tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + requiresBuild: true + dev: false + optional: true + + /typed-emitter@2.1.0: + resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==} + optionalDependencies: + rxjs: 7.8.1 + dev: false + /typescript@5.3.2: resolution: {integrity: sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==} engines: {node: '>=14.17'} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..4fe6f0f --- /dev/null +++ b/src/api.ts @@ -0,0 +1,200 @@ +// all the types for the api + +export type RoomId = string; +export type UserId = string; +export type EventId = string; + +export interface ApiEvent { + event_id: EventId, + sender: UserId, + type: string, + content: any, + state_key?: string, + unsigned?: any, +} + +export type ApiStateEvent = ApiEvent & { + state_key: string, +} + +export type ApiDeviceEvent = { + sender: UserId, + type: string, + content: any, +} + +export type ApiEphemeralEvent = { + sender: UserId, + type: string, + content: any, + key?: string, + persist_until?: number, +} + +export type RoomSubscription = { + query: string, +}; + +export type ListSubscription = { + ranges?: Array<[number, number]>, + query?: string, + filters?: { + spaces?: Array, + types?: Array, + purposes?: Array, + is_invite_knock?: boolean, + }, +}; + +// how much do i split it? do i make ephemeral events their own extension? +export interface SyncRequest { + pos?: string, + timeout?: number, + txn_id?: string, + conn_id?: string, + delta_token?: string, + queries?: Record, + timeline?: { + limit?: number, + types?: Array, + not_types?: Array, + + // theoretically, the best option would be to hide m.reaction events + // in the historical timeline while showing them in the live timeline + // i'd need to implement proper filtering for that though + // live_filter?: string, + // historical_filter?: string, + }, + ephemeral?: { + limit?: number, + types?: Array, + include_old?: boolean, // include temporarily persisted events replaced with the same key + }, + }> + lists?: Record, + rooms?: Record, + extensions?: { + "m.to_device"?: { + enabled: boolean, + limit?: number, + }, + "m.e2ee"?: { + enabled: boolean, + }, + "m.account_data"?: { + enabled: boolean, + lists: Array, + rooms: Array, + }, + "m.presence"?: { + + }, + } +} + +export interface SyncResponseRoom { + initial?: boolean, + required_state?: Array, + timeline?: Array, + ephemeral?: Array, + prev_batch?: string, + joined_count?: number, + invited_count?: number, + unreads?: Unreads, +} + +export interface SyncResponse { + pos: string, + txn_id?: string, + delta_token?: string, + lists?: Record } | + { op: "INSERT", index: number, room_id: RoomId } | + { op: "DELETE", index: number, room_ids: Array } | + { op: "INVALIDATE", range: [number, number] } + > + }>, + rooms?: Record, + extensions?: { + "m.to_device"?: { + enabled: boolean, + events: Array, + }, + "m.account_data"?: { + enabled: boolean, + lists: Array, + rooms: Array, + }, + "m.e2ee"?: { + "device_one_time_keys_count": { + "signed_curve25519": number, + }, + "device_lists": { + "changed": Array, + "left": Array, + }, + "device_unused_fallback_key_types": [ + "signed_curve25519" + ] + }, + "m.presence"?: { + + }, + }, +} + +export interface Unreads { + last_ack?: EventId, + mention_user?: number, + mention_bulk?: number, // @room (entire room) or @thread (in a thread) + notify?: number, + messages?: number, +} + +export interface ContextResponse { + start: string, + end: string, + event: ApiEvent, + events_before: Array, // in reverse chronological order! + events_after: Array, + state: Array, +} + +export interface MessagesResponse { + chunk: Array, // in reverse chronological order, if dir=b! + start: string, + end: string, + state: Array, +} + +export interface RelationsResponse { + chunk: Array, + next_batch: string, + prev_batch: string, +} + +export interface SendResponse { + event_id: EventId, +} + +export type InboxFilter = "default" | "mentions_user" | "mentions_bulk" | "threads_participating" | "threads_interesting" | "include_read"; + +export interface InboxResponse { + next_batch?: string, + threads?: Array, + chunk?: Array<{ + event: ApiEvent, + read: boolean, + }>, +} + +export type IncludeThreads = "read" | "ignoring"; + +export interface ThreadsResponse { + chunk?: Array, + next_batch?: string, +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..0addd8c --- /dev/null +++ b/src/client.ts @@ -0,0 +1,111 @@ +// The "main" class that all interaction can be done through. + +import { ApiDeviceEvent, ListSubscription, RoomSubscription } from "./api.js"; +import { Network } from "./net.js"; +import { Room } from "./room.js"; +import { Connection } from "./sync.js"; +import EventEmitter from "events"; +import TypedEmitter from "typed-emitter"; + +interface ClientConfig { + baseUrl: string, + token: string, + userId: string, + deviceId: string, +} + +type ClientState = { state: "stop" } // The client is stopped and inactive + | { state: "sync" } // The client is active and syncing + | { state: "catchup" } // The client is catching up after a `retry` + | { state: "error", reason: any } // The client failed and will not retry + | { state: "retry", backoff: number } // The client failed and is retrying + +type ClientEvents = { + // The client's state changed. + state: (state: ClientState) => void, + + // A list is created or updated. + list: (name: string, list: RoomList) => void, + + // A room is initialized for the first time, and is now available to + // interact with. This may be executed multiple times, if the homeserver + // resends `initial: true` + roomInit: (room: Room) => void, + + // A room is invalidated and has stale data. It is still accessible, + // but is not in any lists and only exists as cache. + roomDeinit: (room: Room) => void, + + // Global account data is updated. + accountData: (type: string, content: string) => void, + + // A `to_device` message was received. + toDevice: (event: ApiDeviceEvent) => void, +}; + +type RoomList = { + // The total number of rooms in this list + count: number, + + // The currently visible room list + rooms: Array, +}; + +export class Client extends (EventEmitter as unknown as new () => TypedEmitter) { + // these shouldn't be public, but ah typescript + state: ClientState = { state: "stop" }; + net: Network; + conn: Connection; + + public lists: Map = new Map(); + public rooms: Map = new Map(); + + constructor(public config: ClientConfig) { + super(); + this.net = new Network(this, { + verbose: true, + }); + this.conn = new Connection(this); + } + + // Start receiving events from /sync. + // WARN: if you lose the reference to Client, the poller will leak + start() { + this.state = { state: "sync" }; + + (async () => { + while (this.state.state === "sync") { + try { + await this.conn.sync(); + } catch (err) { + this.state = { state: "error", reason: err }; + } + } + })() + } + + // Stop receiving events from /sync. + stop() { + this.conn.abort(); + this.conn = new Connection(this); + this.state = { state: "stop" }; + } + + listSubscribe(name: string, subscription: ListSubscription) { + this.lists.set(name, { count: 0, rooms: [] }); + this.conn.listSubscribe(name, subscription); + } + + listUnsubscribe(name: string) { + this.lists.delete(name); + this.conn.listUnsubscribe(name); + } + + roomSubscribe(roomId: string, subscription: RoomSubscription) { + this.conn.roomSubscribe(roomId, subscription); + } + + roomUnsubscribe(roomId: string) { + this.conn.roomUnsubscribe(roomId); + } +} diff --git a/src/event.ts b/src/event.ts new file mode 100644 index 0000000..9a6e2e2 --- /dev/null +++ b/src/event.ts @@ -0,0 +1,34 @@ +import { Client } from "./client.js"; +import { ApiEvent, ApiStateEvent, EventId, UserId } from "./api.js"; +import { Room } from "./room.js"; + +export class Event { + public client: Client = this.room.client; + public content: any; + public id: EventId; + public sender: UserId; + public stateKey?: string; + public type: string; + public unsigned: any; + + constructor( + public room: Room, + json: ApiEvent + ) { + this.content = json.content; + this.id = json.event_id; + this.sender = json.sender; + this.stateKey = json.state_key; + this.type = json.type; + this.unsigned = json.unsigned; + } +} + +export class StateEvent extends Event { + public stateKey: string; + + constructor(room: Room, json: ApiStateEvent) { + super(room, json); + this.stateKey = json.state_key; + } +} diff --git a/src/index.ts b/src/index.ts index da396c5..57eacf9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1 @@ -export function hello(name: string) { - console.log(`hello ${name}!`); -} +export { Client } from "./client.js"; diff --git a/src/net.ts b/src/net.ts new file mode 100644 index 0000000..4b2f762 --- /dev/null +++ b/src/net.ts @@ -0,0 +1,146 @@ +// Handles *all* network/http requests. +import * as t from "./api.js"; +import { Client } from "./client.js"; + +const e = (s: string) => encodeURIComponent(s); + +interface NetworkConfig { + fetchImpl?: typeof fetch, + verbose?: boolean, +} + +function getMethodColor(method: string): string { + // i love using the more obscure css colors + switch (method) { + case "GET": return "deepskyblue"; + case "POST": return "springgreen"; + case "PUT": return "lightsalmon"; + case "PATCH": return "sandybrown"; + case "DELETE": return "salmon"; + default: return "orchid"; + } +} + +export class Network { + constructor(private client: Client, private config: NetworkConfig) { + if (!config.fetchImpl) config.fetchImpl = globalThis.fetch; + } + + private async fetch(options: { + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", + path: string, + body?: BodyInit | object, + raw?: boolean, + extra?: RequestInit, + }) { + const url = `${this.client.config.baseUrl}${options.path}`; + const isJson = typeof options.body === "object" + && !ArrayBuffer.isView(options.body) + && !(options.body instanceof ReadableStream); + + if (this.config.verbose) { + console.log("%c%s%c %s", getMethodColor(options.method), options.method, "color: initial", options.path); + } + + const req = await this.config.fetchImpl!(url, { + method: options.method, + headers: { + "authorization": `Bearer ${this.client.config.token}`, + }, + body: (isJson ? JSON.stringify(options.body) : options.body) as BodyInit | undefined, + ...options.extra, + }); + if (!req.ok) throw new Error(`Request failed: ${await req.text()}`); + if (options.raw) return req.body; + return req.json(); + } + + public async sync(options: t.SyncRequest, signal: AbortSignal): Promise { + return this.fetch({ + method: "POST", + path: `/_matrix/client/unstable/org.matrix.msc3575/sync?timeout=${options.timeout}&pos=${options.pos}`, + body: options, + extra: { signal }, + }); + } + + public async fetchEvent(roomId: t.RoomId, eventId: t.EventId): Promise { + return this.fetch({ + method: "GET", + path: `/_matrix/client/v3/rooms/${e(roomId)}/event/${e(eventId)}`, + }); + } + + public async fetchContext(roomId: t.RoomId, eventId: t.EventId, limit = 50): Promise { + return this.fetch({ + method: "GET", + path: `/_matrix/client/v3/rooms/${e(roomId)}/context/${e(eventId)}?limit=${limit}`, + }); + } + + public async fetchMessages(opts: { roomId: t.RoomId, dir: "b" | "f", limit?: number, from: string, to?: string }): Promise { + let path = `/_matrix/client/v3/rooms/${e(opts.roomId)}/messages?limit=${opts.limit || 50}&from=${e(opts.from)}&dir=${e(opts.dir)}`; + if (opts.to) path += `&to=${e(opts.to)}`; + return this.fetch({ method: "GET", path }); + } + + public fetchRelations( + roomId: t.RoomId, + eventId: t.EventId, + opts: { + relType?: string, + eventType?: string, + limit?: string, + dir: "b" | "f", + from?: string, + to?: string, + }): Promise { + 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)}`; + if (opts.relType) path += `/${e(opts.relType)}`; + if (opts.eventType) path += `/${e(opts.eventType)}`; + path += `?limit=${opts.limit || 50}&dir=${opts.dir}`; + if (opts.from) path += `&from=${opts.from}`; + if (opts.to) path += `&from=${opts.to}`; + return this.fetch({ method: "GET", path }); + } + + public async fetchThreads(opts: { from?: string, limit?: number, roomIds?: Array, watching: boolean, include: Array }): Promise { + return this.fetch({ + method: "POST", + path: `/_matrix/client/v1/threads?limit=${opts.limit}${opts.from ? `&from=${e(opts.from)}`: ""}`, + body: { + watching: opts.watching, + room_ids: opts.roomIds, + include: opts.include, + } + }); + } + + public async fetchInbox(opts: { roomIds?: Array, from?: string, filter?: t.InboxFilter, limit?: number }): Promise { + return this.fetch({ + method: "POST", + path: `/_matrix/client/v1/inbox?limit=${opts.limit}${opts.from ? `&from=${e(opts.from)}`: ""}`, + body: { + filter: opts.filter, + room_ids: opts.roomIds, + } + }); + } + + public async sendEvent(roomId: t.RoomId, type: string, txnId: string, content: any): Promise { + return this.fetch({ + method: "PUT", + path: `/_matrix/client/v3/rooms/${e(roomId)}/send/${e(type)}/${e(txnId)}`, + body: content, + }); + } + + public async sendState(roomId: t.RoomId, type: string, stateKey: string, content: any): Promise { + return this.fetch({ + method: "PUT", + path: `/_matrix/client/v3/rooms/${e(roomId)}/state/${e(type)}/${e(stateKey)}`, + body: content, + }); + } +} diff --git a/src/room.ts b/src/room.ts new file mode 100644 index 0000000..3127253 --- /dev/null +++ b/src/room.ts @@ -0,0 +1,81 @@ +import EventEmitter from "events"; +import TypedEmitter from "typed-emitter"; +import { ApiEphemeralEvent, SyncResponseRoom, Unreads } from "./api.js"; +import { Client } from "./client.js"; +import { Event, StateEvent } from "./event.js"; +import { TimelineSet } from "./timeline.js"; +import { nanoid } from "nanoid"; + +type RoomEvents = { + // an event is appended to this room's live timeline + timeline: (event: Event) => void, + + // an ephemeral event was received + ephemeral: (event: ApiEphemeralEvent) => void, + + // this room's state updated + state: (event: StateEvent) => void, + + // notifications for this room were updated + notifications: (notifs: Unreads) => void, + + // this room's summary was updated + summary: (x: { invited_count: number, joined_count: number }) => void, + + // accountdata is updated + accountData: (type: string, content: string) => void, +}; + +export class Room extends (EventEmitter as unknown as new () => TypedEmitter) { + // The (possibly incomplete) state of this room + private state: Map> = new Map(); + + public timelines: TimelineSet; + + constructor( + public client: Client, + public id: string, + data: SyncResponseRoom, + ) { + super(); + this.timelines = new TimelineSet(this); + this.timelines.live.prevBatch = data.prev_batch || null; + this._merge(data); + } + + // should be private to only the library like pub(crate), but there's no way currently + _merge(data: SyncResponseRoom) { + for (const raw of data.required_state || []) { + const event = new StateEvent(this, raw); + const map = this.state.get(event.type); + if (map) { + map.set(event.stateKey, event); + } else { + this.state.set(event.type, new Map([[event.stateKey, event]])); + } + } + if (data.timeline?.length) { + const events = data.timeline.map(raw => new Event(this, raw)); + this.timelines._appendEvents(events); + } + } + + getState(type: string, stateKey: string = ""): StateEvent | null { + return this.state.get(type)?.get(stateKey) || null; + } + + getAllState(type: string): Array { + return [...this.state.get(type)?.values() ?? []]; + } + + // TODO: return event + async sendState(type: string, stateKey: string, content: any) { + // const { event_id } = await this.client.net.sendState(this.id, type, stateKey, content); + await this.client.net.sendState(this.id, type, stateKey, content); + } + + // TODO: return event + async sendEvent(type: string, content: any) { + await this.client.net.sendEvent(this.id, type, nanoid(), content); + } +} diff --git a/src/setup.ts b/src/setup.ts new file mode 100644 index 0000000..66c1ca9 --- /dev/null +++ b/src/setup.ts @@ -0,0 +1,4 @@ +// Used for initiating a client, handling well-known and authentication. + +class Setup { +} diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 0000000..04419c0 --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,121 @@ +// A sliding sync connection. One of these is active per client. +// It is part of `Client`, but split out for maintainability. + +import type { Client } from "./index.js"; +import { nanoid } from "nanoid"; +import { ListSubscription, RoomSubscription, SyncRequest } from "./api.js"; +import { Room } from "./room.js"; + +export class Connection { + private connId = nanoid(); + private controller = new AbortController(); + private pos: string = "0"; + private delta: string | undefined; + private query: SyncRequest = {}; + + constructor(private client: Client) {} + + // run sync once + async sync(timeout: number = 30000) { + const json = await this.client.net.sync({ + conn_id: this.connId, + pos: this.pos, + delta_token: this.delta || undefined, + timeout, + ...this.query, + }, this.controller.signal).catch((reason) => { + if (reason === "update query") return null; + throw reason; + }); + if (!json) return; + console.log(json) + + this.stripSticky(); + this.pos = json.pos; + this.delta = json.delta_token; + + const { rooms, lists } = this.client; + + for (const roomId in json.rooms) { + const data = json.rooms[roomId]; + if (rooms.has(roomId) && !data.initial) { + rooms.get(roomId)!._merge(data); + } else { + const room = new Room(this.client, roomId, data); + rooms.set(roomId, room); + this.client.emit("roomInit", room); + } + } + + for (const listId in json.lists) { + const list = lists.get(listId); + if (!list) continue; + + list.count = json.lists[listId].count; + + for (const op of json.lists[listId].ops) { + switch (op.op) { + case "SYNC": + list.rooms.splice(op.range[0], op.range[1] - op.range[0] + 1, ...op.room_ids.map(id => rooms.get(id)!)); + break; + // case "INVALIDATE": + // list.rooms.splice(op.range[0], op.range[1] - op.range[0] + 1); + // break; + default: + // the entire op system should probably be reworked + // if there's no sorting, i only need `sync` and `invalidate`? + throw new Error("unimplemented"); + } + } + } + + return json; + } + + private refresh() { + this.controller.abort("update query"); + this.controller = new AbortController(); + } + + private stripSticky() { + const { query } = this; + const { lists } = query; + + if (lists) { + for (const list in lists) { + lists[list] = { ranges: lists[list]!.ranges }; + } + } + + query.queries = {}; + query.rooms = {}; + } + + listSubscribe(name: string, subscription: ListSubscription) { + if (!this.query.lists) this.query.lists = {}; + this.query.lists[name] = subscription; + this.refresh(); + } + + listUnsubscribe(name: string) { + if (!this.query.lists) return; + delete this.query.lists[name]; + this.refresh(); + } + + roomSubscribe(roomId: string, subscription: RoomSubscription) { + if (!this.query.rooms) this.query.rooms = {}; + this.query.rooms[roomId] = subscription; + this.refresh(); + } + + roomUnsubscribe(roomId: string) { + if (!this.query.rooms) this.query.rooms = {}; + this.query.rooms[roomId] = null; + this.refresh(); + } + + abort(reason?: string) { + this.controller.abort(reason); + } +} diff --git a/src/thread.ts b/src/thread.ts new file mode 100644 index 0000000..6b06458 --- /dev/null +++ b/src/thread.ts @@ -0,0 +1,21 @@ +import EventEmitter from "events"; +import TypedEmitter from "typed-emitter"; +import { Unreads } from "./api.js"; +import { Room } from "./room.js"; +import { Event } from "./event.js"; + +type ThreadEvents = { + // an event is appended to this thread's live timeline + timeline: (event: Event) => void, + + // notifications for this thread were updated + notifications: (notifs: Unreads) => void, +}; + +export class Thread extends (EventEmitter as unknown as new () => TypedEmitter) { + public room: Room = this.baseEvent.room; + + constructor(public baseEvent: Event) { + super(); + } +} diff --git a/src/timeline.ts b/src/timeline.ts new file mode 100644 index 0000000..9be4667 --- /dev/null +++ b/src/timeline.ts @@ -0,0 +1,89 @@ +// Timelines are ordered sequences of events. + +// import TypedEventEmitter from "typed-emitter"; +import { EventId } from "./api.js"; +import { Room } from "./room.js"; +import { Event } from "./event.js"; +import { Thread } from "./thread.js"; + +interface Timeline { + isLive: boolean, + events: Array, +} + +export class RoomTimeline implements Timeline { + public events: Array = []; + public isLive: boolean = false; + + prevBatch: string | null = null; + nextBatch: string | null = null; + + constructor(public room: Room) { + + } + + // concat(other: RoomTimeline): RoomTimeline { + // if (this.events.length === 0) return other; + // if (other.events.length === 0) return this; + // const lastId = this.events.at(-1)!.id; + // const idx = other.events.findIndex((ev) => ev.id === lastId); + // } +} + +export class ThreadTimeline implements Timeline { + public events = []; + public isLive: boolean = false; + + prevBatch: string | null = null; + nextBatch: string | null = null; + + constructor(public thread: Thread) {} +} + +export class TimelineSet { + // Other timelines *may* be live, but this one is guaranteed to be live + public live: RoomTimeline; + private timelines: Set = new Set(); + // private eventIdToTimeline: Map = new Map(); + + constructor(public room: Room) { + this.live = new RoomTimeline(room); + this.live.isLive = true; + this.timelines.add(this.live); + } + + // Get a timeline for a thread. Becomes a live timeline if `atEnd = true`. + // public async forThread(eventId: EventId, atEnd: boolean): Promise { + // throw "todo"; + // // const thread = new Thread(); + // // const tl = new ThreadTimeline(thread); + // // return tl; + // } + + // Get a timeline for an event (context). + public async forEvent(eventId: EventId): Promise { + const context = await this.room.client.net.fetchContext(this.room.id, eventId); + const tl = new RoomTimeline(this.room); + tl.events = context.events_before + .reverse() + .concat([context.event]) + .concat(context.events_after) + .map(raw => new Event(this.room, raw)); + tl.prevBatch = context.start; + tl.nextBatch = context.end; + this.timelines.add(tl); + return tl; + } + + // Paginate a timeline for more events + // public paginate(timeline: Timeline, dir: "f" | "b", limit: number = 50) { + // throw "todo"; + // } + + _appendEvents(events: Array) { + // FIXME: merge timelines together + for (const timeline of this.timelines) { + if (timeline.isLive) timeline.events.push(...events); + } + } +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..0082b03 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1 @@ +// Various useful utilities diff --git a/tsconfig.json b/tsconfig.json index 3fb63b7..7ff2cb5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "skipLibCheck": true, "composite": true, "outDir": "dist/", @@ -20,7 +20,7 @@ "strictNullChecks": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, }, "include": ["src"], }