Initial infrastructure and code

This commit is contained in:
tezlm 2023-12-04 22:53:54 -08:00
parent f2350fc6b2
commit 6be08230c3
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
43 changed files with 1988 additions and 10 deletions

7
deno.json Normal file
View file

@ -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"
}
}

15
deno.lock Normal file
View file

@ -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"
}
}

0
dist/src/Connection.d.ts vendored Normal file
View file

1
dist/src/Connection.js vendored Normal file
View file

@ -0,0 +1 @@
"use strict";

178
dist/src/api.d.ts vendored Normal file
View file

@ -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<RoomId | null>;
types?: Array<string | null>;
purposes?: Array<string | null>;
is_invite_knock?: boolean;
};
};
export interface SyncRequest {
pos?: string;
timeout?: number;
txn_id?: string;
conn_id?: string;
delta_token?: string;
queries?: Record<string, null | {
required_state?: Array<[string, string]>;
timeline?: {
limit?: number;
types?: Array<string>;
not_types?: Array<string>;
};
ephemeral?: {
limit?: number;
types?: Array<string>;
include_old?: boolean;
};
}>;
lists?: Record<string, null | ListSubscription>;
rooms?: Record<RoomId, null | RoomSubscription>;
extensions?: {
"m.to_device"?: {
enabled: boolean;
limit?: number;
};
"m.e2ee"?: {
enabled: boolean;
};
"m.account_data"?: {
enabled: boolean;
lists: Array<string>;
rooms: Array<RoomId>;
};
"m.presence"?: {};
};
}
export interface SyncResponseRoom {
initial?: boolean;
required_state?: Array<ApiStateEvent>;
timeline?: Array<ApiEvent>;
ephemeral?: Array<ApiEphemeralEvent>;
prev_batch?: string;
joined_count?: number;
invited_count?: number;
unreads?: Unreads;
}
export interface SyncResponse {
pos: string;
txn_id?: string;
delta_token?: string;
lists?: Record<string, {
count: number;
ops: Array<{
op: "SYNC";
range: [number, number];
room_ids: Array<RoomId>;
} | {
op: "INSERT";
index: number;
room_id: RoomId;
} | {
op: "DELETE";
index: number;
room_ids: Array<RoomId>;
} | {
op: "INVALIDATE";
range: [number, number];
}>;
}>;
rooms?: Record<RoomId, SyncResponseRoom>;
extensions?: {
"m.to_device"?: {
enabled: boolean;
events: Array<ApiDeviceEvent>;
};
"m.account_data"?: {
enabled: boolean;
lists: Array<string>;
rooms: Array<RoomId>;
};
"m.e2ee"?: {
"device_one_time_keys_count": {
"signed_curve25519": number;
};
"device_lists": {
"changed": Array<UserId>;
"left": Array<UserId>;
};
"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<ApiEvent>;
events_after: Array<ApiEvent>;
state: Array<ApiStateEvent>;
}
export interface MessagesResponse {
chunk: Array<ApiEvent>;
start: string;
end: string;
state: Array<ApiStateEvent>;
}
export interface RelationsResponse {
chunk: Array<ApiEvent>;
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<ApiEvent>;
chunk?: Array<{
event: ApiEvent;
read: boolean;
}>;
}
export type IncludeThreads = "read" | "ignoring";
export interface ThreadsResponse {
chunk?: Array<Event>;
next_batch?: string;
}

2
dist/src/api.js vendored Normal file
View file

@ -0,0 +1,2 @@
// all the types for the api
export {};

53
dist/src/client.d.ts vendored Normal file
View file

@ -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<Room>;
};
declare const Client_base: new () => TypedEmitter<ClientEvents>;
export declare class Client extends Client_base {
config: ClientConfig;
state: ClientState;
net: Network;
conn: Connection;
lists: Map<string, RoomList>;
rooms: Map<string, Room>;
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 {};

85
dist/src/client.js vendored Normal file
View file

@ -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);
}
}

18
dist/src/event.d.ts vendored Normal file
View file

@ -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);
}

70
dist/src/event.js vendored Normal file
View file

@ -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;
}
}

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

@ -1 +1 @@
export declare function hello(name: string): void;
export { Client } from "./client.js";

4
dist/src/index.js vendored
View file

@ -1,3 +1 @@
export function hello(name) {
console.log(`hello ${name}!`);
}
export { Client } from "./client.js";

46
dist/src/net.d.ts vendored Normal file
View file

@ -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<t.SyncResponse>;
fetchEvent(roomId: t.RoomId, eventId: t.EventId): Promise<t.ApiEvent>;
fetchContext(roomId: t.RoomId, eventId: t.EventId, limit?: number): Promise<t.ContextResponse>;
fetchMessages(opts: {
roomId: t.RoomId;
dir: "b" | "f";
limit?: number;
from: string;
to?: string;
}): Promise<t.MessagesResponse>;
fetchRelations(roomId: t.RoomId, eventId: t.EventId, opts: {
relType?: string;
eventType?: string;
limit?: string;
dir: "b" | "f";
from?: string;
to?: string;
}): Promise<t.RelationsResponse>;
fetchThreads(opts: {
from?: string;
limit?: number;
roomIds?: Array<t.RoomId>;
watching: boolean;
include: Array<t.IncludeThreads>;
}): Promise<t.ThreadsResponse>;
fetchInbox(opts: {
roomIds?: Array<t.RoomId>;
from?: string;
filter?: t.InboxFilter;
limit?: number;
}): Promise<t.InboxResponse>;
sendEvent(roomId: t.RoomId, type: string, txnId: string, content: any): Promise<t.SendResponse>;
sendState(roomId: t.RoomId, type: string, stateKey: string, content: any): Promise<t.SendResponse>;
}
export {};

128
dist/src/net.js vendored Normal file
View file

@ -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,
});
}
}

30
dist/src/room.d.ts vendored Normal file
View file

@ -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<RoomEvents>;
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<StateEvent>;
sendState(type: string, stateKey: string, content: any): Promise<void>;
sendEvent(type: string, content: any): Promise<void>;
}
export {};

69
dist/src/room.js vendored Normal file
View file

@ -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);
}
}

2
dist/src/setup.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare class Setup {
}

4
dist/src/setup.js vendored Normal file
View file

@ -0,0 +1,4 @@
"use strict";
// Used for initiating a client, handling well-known and authentication.
class Setup {
}

19
dist/src/sync.d.ts vendored Normal file
View file

@ -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<import("./api.js").SyncResponse | undefined>;
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;
}

139
dist/src/sync.js vendored Normal file
View file

@ -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);
}
}

15
dist/src/thread.d.ts vendored Normal file
View file

@ -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<ThreadEvents>;
export declare class Thread extends Thread_base {
baseEvent: Event;
room: Room;
constructor(baseEvent: Event);
}
export {};

18
dist/src/thread.js vendored Normal file
View file

@ -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
});
}
}

33
dist/src/timeline.d.ts vendored Normal file
View file

@ -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<Event>;
}
export declare class RoomTimeline implements Timeline {
room: Room;
events: Array<Event>;
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<RoomTimeline>;
_appendEvents(events: Array<Event>): void;
}
export {};

129
dist/src/timeline.js vendored Normal file
View file

@ -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<EventId, Timeline> = 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<ThreadTimeline> {
// 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);
}
}
}

0
dist/src/utils.d.ts vendored Normal file
View file

2
dist/src/utils.js vendored Normal file
View file

@ -0,0 +1,2 @@
"use strict";
// Various useful utilities

File diff suppressed because one or more lines are too long

22
docs/name.md Normal file
View file

@ -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";
}
```

34
docs/stream.md Normal file
View file

@ -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);
```

View file

@ -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"
}
}

View file

@ -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'}

200
src/api.ts Normal file
View file

@ -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<RoomId | null>,
types?: Array<string | null>,
purposes?: Array<string | null>,
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<string, null | {
required_state?: Array<[string, string]>,
timeline?: {
limit?: number,
types?: Array<string>,
not_types?: Array<string>,
// 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<string>,
include_old?: boolean, // include temporarily persisted events replaced with the same key
},
}>
lists?: Record<string, null | ListSubscription>,
rooms?: Record<RoomId, null | RoomSubscription>,
extensions?: {
"m.to_device"?: {
enabled: boolean,
limit?: number,
},
"m.e2ee"?: {
enabled: boolean,
},
"m.account_data"?: {
enabled: boolean,
lists: Array<string>,
rooms: Array<RoomId>,
},
"m.presence"?: {
},
}
}
export interface SyncResponseRoom {
initial?: boolean,
required_state?: Array<ApiStateEvent>,
timeline?: Array<ApiEvent>,
ephemeral?: Array<ApiEphemeralEvent>,
prev_batch?: string,
joined_count?: number,
invited_count?: number,
unreads?: Unreads,
}
export interface SyncResponse {
pos: string,
txn_id?: string,
delta_token?: string,
lists?: Record<string, {
count: number,
ops: Array<
// i may rework this api to be less awful
{ op: "SYNC", range: [number, number], room_ids: Array<RoomId> } |
{ op: "INSERT", index: number, room_id: RoomId } |
{ op: "DELETE", index: number, room_ids: Array<RoomId> } |
{ op: "INVALIDATE", range: [number, number] }
>
}>,
rooms?: Record<RoomId, SyncResponseRoom>,
extensions?: {
"m.to_device"?: {
enabled: boolean,
events: Array<ApiDeviceEvent>,
},
"m.account_data"?: {
enabled: boolean,
lists: Array<string>,
rooms: Array<RoomId>,
},
"m.e2ee"?: {
"device_one_time_keys_count": {
"signed_curve25519": number,
},
"device_lists": {
"changed": Array<UserId>,
"left": Array<UserId>,
},
"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<ApiEvent>, // in reverse chronological order!
events_after: Array<ApiEvent>,
state: Array<ApiStateEvent>,
}
export interface MessagesResponse {
chunk: Array<ApiEvent>, // in reverse chronological order, if dir=b!
start: string,
end: string,
state: Array<ApiStateEvent>,
}
export interface RelationsResponse {
chunk: Array<ApiEvent>,
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<ApiEvent>,
chunk?: Array<{
event: ApiEvent,
read: boolean,
}>,
}
export type IncludeThreads = "read" | "ignoring";
export interface ThreadsResponse {
chunk?: Array<Event>,
next_batch?: string,
}

111
src/client.ts Normal file
View file

@ -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<Room>,
};
export class Client extends (EventEmitter as unknown as new () => TypedEmitter<ClientEvents>) {
// these shouldn't be public, but ah typescript
state: ClientState = { state: "stop" };
net: Network;
conn: Connection;
public lists: Map<string, RoomList> = new Map();
public rooms: Map<string, Room> = 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);
}
}

34
src/event.ts Normal file
View file

@ -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;
}
}

View file

@ -1,3 +1 @@
export function hello(name: string) {
console.log(`hello ${name}!`);
}
export { Client } from "./client.js";

146
src/net.ts Normal file
View file

@ -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<t.SyncResponse> {
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<t.ApiEvent> {
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<t.ContextResponse> {
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<t.MessagesResponse> {
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<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)}`;
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<t.RoomId>, watching: boolean, include: Array<t.IncludeThreads> }): Promise<t.ThreadsResponse> {
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<t.RoomId>, from?: string, filter?: t.InboxFilter, limit?: number }): Promise<t.InboxResponse> {
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<t.SendResponse> {
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<t.SendResponse> {
return this.fetch({
method: "PUT",
path: `/_matrix/client/v3/rooms/${e(roomId)}/state/${e(type)}/${e(stateKey)}`,
body: content,
});
}
}

81
src/room.ts Normal file
View file

@ -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<RoomEvents>) {
// The (possibly incomplete) state of this room
private state: Map<string, Map<string, StateEvent>> = 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<StateEvent> {
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);
}
}

4
src/setup.ts Normal file
View file

@ -0,0 +1,4 @@
// Used for initiating a client, handling well-known and authentication.
class Setup {
}

121
src/sync.ts Normal file
View file

@ -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);
}
}

21
src/thread.ts Normal file
View file

@ -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<ThreadEvents>) {
public room: Room = this.baseEvent.room;
constructor(public baseEvent: Event) {
super();
}
}

89
src/timeline.ts Normal file
View file

@ -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<Event>,
}
export class RoomTimeline implements Timeline {
public events: Array<Event> = [];
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<Timeline> = new Set();
// private eventIdToTimeline: Map<EventId, Timeline> = 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<ThreadTimeline> {
// 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<RoomTimeline> {
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<Event>) {
// FIXME: merge timelines together
for (const timeline of this.timelines) {
if (timeline.isLive) timeline.events.push(...events);
}
}
}

1
src/utils.ts Normal file
View file

@ -0,0 +1 @@
// Various useful utilities

View file

@ -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"],
}