Initial infrastructure and code
This commit is contained in:
parent
f2350fc6b2
commit
6be08230c3
43 changed files with 1988 additions and 10 deletions
7
deno.json
Normal file
7
deno.json
Normal 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
15
deno.lock
Normal 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
0
dist/src/Connection.d.ts
vendored
Normal file
1
dist/src/Connection.js
vendored
Normal file
1
dist/src/Connection.js
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
"use strict";
|
178
dist/src/api.d.ts
vendored
Normal file
178
dist/src/api.d.ts
vendored
Normal 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
2
dist/src/api.js
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
// all the types for the api
|
||||
export {};
|
53
dist/src/client.d.ts
vendored
Normal file
53
dist/src/client.d.ts
vendored
Normal 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
85
dist/src/client.js
vendored
Normal 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
18
dist/src/event.d.ts
vendored
Normal 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
70
dist/src/event.js
vendored
Normal 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
2
dist/src/index.d.ts
vendored
|
@ -1 +1 @@
|
|||
export declare function hello(name: string): void;
|
||||
export { Client } from "./client.js";
|
||||
|
|
4
dist/src/index.js
vendored
4
dist/src/index.js
vendored
|
@ -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
46
dist/src/net.d.ts
vendored
Normal 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
128
dist/src/net.js
vendored
Normal 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
30
dist/src/room.d.ts
vendored
Normal 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
69
dist/src/room.js
vendored
Normal 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
2
dist/src/setup.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
declare class Setup {
|
||||
}
|
4
dist/src/setup.js
vendored
Normal file
4
dist/src/setup.js
vendored
Normal 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
19
dist/src/sync.d.ts
vendored
Normal 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
139
dist/src/sync.js
vendored
Normal 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
15
dist/src/thread.d.ts
vendored
Normal 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
18
dist/src/thread.js
vendored
Normal 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
33
dist/src/timeline.d.ts
vendored
Normal 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
129
dist/src/timeline.js
vendored
Normal 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
0
dist/src/utils.d.ts
vendored
Normal file
2
dist/src/utils.js
vendored
Normal file
2
dist/src/utils.js
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
"use strict";
|
||||
// Various useful utilities
|
2
dist/tsconfig.tsbuildinfo
vendored
2
dist/tsconfig.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
22
docs/name.md
Normal file
22
docs/name.md
Normal 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
34
docs/stream.md
Normal 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);
|
||||
```
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
200
src/api.ts
Normal 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
111
src/client.ts
Normal 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
34
src/event.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -1,3 +1 @@
|
|||
export function hello(name: string) {
|
||||
console.log(`hello ${name}!`);
|
||||
}
|
||||
export { Client } from "./client.js";
|
||||
|
|
146
src/net.ts
Normal file
146
src/net.ts
Normal 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
81
src/room.ts
Normal 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
4
src/setup.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
// Used for initiating a client, handling well-known and authentication.
|
||||
|
||||
class Setup {
|
||||
}
|
121
src/sync.ts
Normal file
121
src/sync.ts
Normal 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
21
src/thread.ts
Normal 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
89
src/timeline.ts
Normal 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
1
src/utils.ts
Normal file
|
@ -0,0 +1 @@
|
|||
// Various useful utilities
|
|
@ -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"],
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue