Merge pull request #109 from element-hq/midhun/multiroom/client-api

Allow modules to access a part of `MatrixClient` functionality
This commit is contained in:
R Midhun Suresh
2025-10-27 18:11:17 +05:30
committed by GitHub
8 changed files with 232 additions and 4 deletions
@@ -0,0 +1,46 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { Room } from "../models/Room";
import { Watchable } from "./watchable";
/**
* Modify account data stored on the homeserver.
* @public
*/
export interface AccountDataApi {
/**
* Returns a watchable with account data for this event type.
*/
get(eventType: string): Watchable<unknown>;
/**
* Set account data on the homeserver.
*/
set(eventType: string, content: unknown): Promise<void>;
/**
* Changes the content of this event to be empty.
*/
delete(eventType: string): Promise<void>;
}
/**
* Access some limited functionality from the SDK.
* @public
*/
export interface ClientApi {
/**
* Use this to modify account data on the homeserver.
*/
accountData: AccountDataApi;
/**
* Fetch room by id from SDK.
* @param id - Id of the room to get
* @returns Room object from SDK
*/
getRoom: (id: string) => Room | null;
}
@@ -17,6 +17,8 @@ import { AccountAuthApiExtension } from "./auth.ts";
import { ProfileApiExtension } from "./profile.ts";
import { ExtrasApi } from "./extras.ts";
import { BuiltinsApi } from "./builtins.ts";
import { StoresApi } from "./stores.ts";
import { ClientApi } from "./client.ts";
/**
* Module interface for modules to implement.
@@ -123,6 +125,16 @@ export interface Api
*/
readonly extras: ExtrasApi;
/**
* Allows modules to access a limited functionality of certain stores from Element Web.
*/
readonly stores: StoresApi;
/**
* Access some very specific functionality from the client.
*/
readonly client: ClientApi;
/**
* Create a ReactDOM root for rendering React components.
* Exposed to allow modules to avoid needing to bundle their own ReactDOM.
@@ -0,0 +1,36 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { Room } from "../models/Room";
import { Watchable } from "./watchable";
/**
* Provides some basic functionality of the Room List Store from element-web.
* @public
*/
export interface RoomListStoreApi {
/**
* Returns a watchable holding a flat list of sorted room.
*/
getRooms(): Watchable<Room[]>;
/**
* Returns a promise that resolves when RLS is ready.
*/
waitForReady(): Promise<void>;
}
/**
* Provides access to certain stores from element-web.
* @public
*/
export interface StoresApi {
/**
* Use this to access limited functionality of the RLS from element-web.
*/
roomListStore: RoomListStoreApi;
}
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { expect, test, vitest } from "vitest";
import { expect, test, vi, vitest } from "vitest";
import { Watchable } from "./watchable";
@@ -56,3 +56,44 @@ test("when value is an object, shallow comparison works", () => {
watchable.unwatch(listener); // Clean up after the test
});
test("onFirstWatch and onLastWatch are called when appropriate", () => {
const onFirstWatch = vi.fn();
const onLastWatch = vi.fn();
class CustomWatchable extends Watchable<number> {
protected onFirstWatch(): void {
onFirstWatch();
}
protected onLastWatch(): void {
onLastWatch();
}
}
const watchable = new CustomWatchable(10);
// No listeners yet, so expect no calls
expect(onFirstWatch).not.toHaveBeenCalled();
expect(onLastWatch).not.toHaveBeenCalled();
// Let's say that we have three listeners
const listeners = [vi.fn(), vi.fn(), vi.fn()];
// Let's add all of them via watch
for (const listener of listeners) {
watchable.watch(listener);
}
// Only expect onFirstWatch() to have been called once
expect(onFirstWatch).toHaveBeenCalledOnce();
// Let's remove all the listeners
for (const listener of listeners) {
watchable.unwatch(listener);
}
// Only expect onLastWatch to have been called once
expect(onLastWatch).toHaveBeenCalledOnce();
// Should call onFirstWatch again once we have more listeners
watchable.watch(vi.fn());
expect(onFirstWatch).toHaveBeenCalledTimes(2);
});
@@ -26,10 +26,14 @@ function isObject(value: unknown): value is object {
* @public
*/
export class Watchable<T> {
private readonly listeners = new Set<WatchFn<T>>();
protected readonly listeners = new Set<WatchFn<T>>();
public constructor(private currentValue: T) {}
/**
* The value stored in this watchable.
* Warning: Could potentially return stale data if you haven't called {@link Watchable#watch}.
*/
public get value(): T {
return this.currentValue;
}
@@ -50,12 +54,32 @@ export class Watchable<T> {
}
public watch(listener: (value: T) => void): void {
// Call onFirstWatch if there was no listener before.
if (this.listeners.size === 0) {
this.onFirstWatch();
}
this.listeners.add(listener);
}
public unwatch(listener: (value: T) => void): void {
this.listeners.delete(listener);
const hasDeleted = this.listeners.delete(listener);
// Call onLastWatch if every listener has been removed.
if (hasDeleted && this.listeners.size === 0) {
this.onLastWatch();
}
}
/**
* This is called when the number of listeners go from zero to one.
* Could be used to add external event listeners.
*/
protected onFirstWatch(): void {}
/**
* This is called when the number of listeners go from one to zero.
* Could be used to remove external event listeners.
*/
protected onLastWatch(): void {}
}
/**