Add Module Composer API (#33284)
* Spec composer API * Add composer api implementation * Tests * Copyright * update sigs * cleanup * a snap * cleanup * linting * Tidy up * Adjust
This commit is contained in:
@@ -1293,23 +1293,30 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case Action.ComposerInsert: {
|
case Action.ComposerInsert: {
|
||||||
if (payload.composerType) break;
|
const composerInsertPayload = payload as ComposerInsertPayload;
|
||||||
|
if (composerInsertPayload.composerType) break;
|
||||||
|
|
||||||
let timelineRenderingType: TimelineRenderingType = payload.timelineRenderingType;
|
let timelineRenderingType: TimelineRenderingType | undefined;
|
||||||
// ThreadView handles Action.ComposerInsert itself due to it having its own editState
|
// ThreadView handles Action.ComposerInsert itself due to it having its own editState
|
||||||
if (timelineRenderingType === TimelineRenderingType.Thread) break;
|
if (composerInsertPayload.timelineRenderingType === TimelineRenderingType.Thread) break;
|
||||||
if (
|
if (
|
||||||
this.state.timelineRenderingType === TimelineRenderingType.Search &&
|
this.state.timelineRenderingType === TimelineRenderingType.Search &&
|
||||||
payload.timelineRenderingType === TimelineRenderingType.Search
|
composerInsertPayload.timelineRenderingType === TimelineRenderingType.Search
|
||||||
) {
|
) {
|
||||||
// we don't have the composer rendered in this state, so bring it back first
|
// we don't have the composer rendered in this state, so bring it back first
|
||||||
await this.onCancelSearchClick();
|
await this.onCancelSearchClick();
|
||||||
timelineRenderingType = TimelineRenderingType.Room;
|
timelineRenderingType = TimelineRenderingType.Room;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the dispatchee didn't request a timeline rendering type, use the current one.
|
||||||
|
timelineRenderingType =
|
||||||
|
timelineRenderingType ??
|
||||||
|
composerInsertPayload.timelineRenderingType ??
|
||||||
|
this.state.timelineRenderingType;
|
||||||
|
|
||||||
// re-dispatch to the correct composer
|
// re-dispatch to the correct composer
|
||||||
defaultDispatcher.dispatch<ComposerInsertPayload>({
|
defaultDispatcher.dispatch<ComposerInsertPayload>({
|
||||||
...(payload as ComposerInsertPayload),
|
...composerInsertPayload,
|
||||||
timelineRenderingType,
|
timelineRenderingType,
|
||||||
composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send,
|
composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -167,12 +167,14 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
|||||||
}
|
}
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case Action.ComposerInsert: {
|
case Action.ComposerInsert: {
|
||||||
if (payload.composerType) break;
|
const insertPayload = payload as ComposerInsertPayload;
|
||||||
if (payload.timelineRenderingType !== TimelineRenderingType.Thread) break;
|
if (insertPayload.composerType) break;
|
||||||
|
if (insertPayload.timelineRenderingType !== TimelineRenderingType.Thread) break;
|
||||||
|
|
||||||
// re-dispatch to the correct composer
|
// re-dispatch to the correct composer
|
||||||
dis.dispatch<ComposerInsertPayload>({
|
dis.dispatch<ComposerInsertPayload>({
|
||||||
...(payload as ComposerInsertPayload),
|
...insertPayload,
|
||||||
|
timelineRenderingType: TimelineRenderingType.Thread,
|
||||||
composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send,
|
composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export enum ComposerType {
|
|||||||
|
|
||||||
interface IBaseComposerInsertPayload extends ActionPayload {
|
interface IBaseComposerInsertPayload extends ActionPayload {
|
||||||
action: Action.ComposerInsert;
|
action: Action.ComposerInsert;
|
||||||
timelineRenderingType: TimelineRenderingType;
|
timelineRenderingType?: TimelineRenderingType; // undefined if this should just use the current in-focus type.
|
||||||
composerType?: ComposerType; // falsy if should be re-dispatched to the correct composer
|
composerType?: ComposerType; // falsy if should be re-dispatched to the correct composer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { StoresApi } from "./StoresApi.ts";
|
|||||||
import { WidgetLifecycleApi } from "./WidgetLifecycleApi.ts";
|
import { WidgetLifecycleApi } from "./WidgetLifecycleApi.ts";
|
||||||
import { WidgetApi } from "./WidgetApi.ts";
|
import { WidgetApi } from "./WidgetApi.ts";
|
||||||
import { CustomisationsApi } from "./customisationsApi.ts";
|
import { CustomisationsApi } from "./customisationsApi.ts";
|
||||||
|
import { ComposerApi } from "./ComposerApi.ts";
|
||||||
|
import defaultDispatcher from "../dispatcher/dispatcher.ts";
|
||||||
|
|
||||||
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
|
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
|
||||||
let used = false;
|
let used = false;
|
||||||
@@ -94,6 +96,7 @@ export class ModuleApi implements Api {
|
|||||||
public readonly rootNode = document.getElementById("matrixchat")!;
|
public readonly rootNode = document.getElementById("matrixchat")!;
|
||||||
public readonly client = new ClientApi();
|
public readonly client = new ClientApi();
|
||||||
public readonly stores = new StoresApi();
|
public readonly stores = new StoresApi();
|
||||||
|
public readonly composer = new ComposerApi(defaultDispatcher);
|
||||||
|
|
||||||
public createRoot(element: Element): Root {
|
public createRoot(element: Element): Root {
|
||||||
return createRoot(element);
|
return createRoot(element);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type ComposerApi as ModuleComposerApi } from "@element-hq/element-web-module-api";
|
||||||
|
|
||||||
|
import type { MatrixDispatcher } from "../dispatcher/dispatcher";
|
||||||
|
import { Action } from "../dispatcher/actions";
|
||||||
|
import type { ComposerInsertPayload } from "../dispatcher/payloads/ComposerInsertPayload";
|
||||||
|
|
||||||
|
export class ComposerApi implements ModuleComposerApi {
|
||||||
|
public constructor(private readonly dispatcher: MatrixDispatcher) {}
|
||||||
|
|
||||||
|
public insertPlaintextIntoComposer(plaintext: string): void {
|
||||||
|
this.dispatcher.dispatch({
|
||||||
|
action: Action.ComposerInsert,
|
||||||
|
text: plaintext,
|
||||||
|
} satisfies ComposerInsertPayload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ import ErrorDialog from "../../../../src/components/views/dialogs/ErrorDialog.ts
|
|||||||
import * as pinnedEventHooks from "../../../../src/hooks/usePinnedEvents";
|
import * as pinnedEventHooks from "../../../../src/hooks/usePinnedEvents";
|
||||||
import { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
|
import { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
|
||||||
import { ModuleApi } from "../../../../src/modules/Api";
|
import { ModuleApi } from "../../../../src/modules/Api";
|
||||||
|
import { type ComposerInsertPayload, ComposerType } from "../../../../src/dispatcher/payloads/ComposerInsertPayload.ts";
|
||||||
|
|
||||||
// Used by group calls
|
// Used by group calls
|
||||||
jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
|
jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
|
||||||
@@ -1075,6 +1076,80 @@ describe("RoomView", () => {
|
|||||||
expect(onRoomViewUpdateMock).toHaveBeenCalledWith(true);
|
expect(onRoomViewUpdateMock).toHaveBeenCalledWith(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("handles Action.ComposerInsert", () => {
|
||||||
|
it("redispatches an empty composerType, timelineRenderingType with the current state", async () => {
|
||||||
|
await mountRoomView();
|
||||||
|
const promise = untilDispatch((payload) => {
|
||||||
|
try {
|
||||||
|
expect(payload).toEqual({
|
||||||
|
action: Action.ComposerInsert,
|
||||||
|
text: "Hello world",
|
||||||
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
|
composerType: ComposerType.Send,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, defaultDispatcher);
|
||||||
|
defaultDispatcher.dispatch({
|
||||||
|
action: Action.ComposerInsert,
|
||||||
|
text: "Hello world",
|
||||||
|
} satisfies ComposerInsertPayload);
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
it("redispatches an empty composerType with the current state", async () => {
|
||||||
|
await mountRoomView();
|
||||||
|
const promise = untilDispatch((payload) => {
|
||||||
|
try {
|
||||||
|
expect(payload).toEqual({
|
||||||
|
action: Action.ComposerInsert,
|
||||||
|
text: "Hello world",
|
||||||
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
|
composerType: ComposerType.Send,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, defaultDispatcher);
|
||||||
|
defaultDispatcher.dispatch({
|
||||||
|
action: Action.ComposerInsert,
|
||||||
|
text: "Hello world",
|
||||||
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
|
} satisfies ComposerInsertPayload);
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
it("ignores payloads with a timelineRenderingType != TimelineRenderingType.Thread", async () => {
|
||||||
|
await mountRoomView();
|
||||||
|
const promise = untilDispatch(
|
||||||
|
(payload) => {
|
||||||
|
try {
|
||||||
|
expect(payload).toStrictEqual({
|
||||||
|
action: Action.ComposerInsert,
|
||||||
|
text: "Hello world",
|
||||||
|
timelineRenderingType: TimelineRenderingType.Thread,
|
||||||
|
composerType: ComposerType.Send,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
defaultDispatcher,
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
defaultDispatcher.dispatch({
|
||||||
|
action: Action.ComposerInsert,
|
||||||
|
text: "Hello world",
|
||||||
|
composerType: ComposerType.Send,
|
||||||
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
|
viaTest: true,
|
||||||
|
} satisfies ComposerInsertPayload);
|
||||||
|
await expect(promise).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("when there is a RoomView", () => {
|
describe("when there is a RoomView", () => {
|
||||||
const widget1Id = "widget1";
|
const widget1Id = "widget1";
|
||||||
const widget2Id = "widget2";
|
const widget2Id = "widget2";
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ import { getRoomContext } from "../../../test-utils/room";
|
|||||||
import { mkMessage, stubClient } from "../../../test-utils/test-utils";
|
import { mkMessage, stubClient } from "../../../test-utils/test-utils";
|
||||||
import { mkThread } from "../../../test-utils/threads";
|
import { mkThread } from "../../../test-utils/threads";
|
||||||
import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx";
|
import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx";
|
||||||
|
import { untilDispatch } from "../../../test-utils/utilities.ts";
|
||||||
|
import { TimelineRenderingType } from "../../../../src/contexts/RoomContext.ts";
|
||||||
|
import { type ComposerInsertPayload, ComposerType } from "../../../../src/dispatcher/payloads/ComposerInsertPayload.ts";
|
||||||
|
|
||||||
describe("ThreadView", () => {
|
describe("ThreadView", () => {
|
||||||
const ROOM_ID = "!roomId:example.org";
|
const ROOM_ID = "!roomId:example.org";
|
||||||
@@ -209,4 +212,87 @@ describe("ThreadView", () => {
|
|||||||
metricsTrigger: undefined,
|
metricsTrigger: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("handles Action.ComposerInsert", () => {
|
||||||
|
it("redispatches a payload of timelineRenderingType=Thread", async () => {
|
||||||
|
await getComponent();
|
||||||
|
const promise = untilDispatch((payload) => {
|
||||||
|
try {
|
||||||
|
expect(payload).toEqual({
|
||||||
|
action: Action.ComposerInsert,
|
||||||
|
text: "Hello world",
|
||||||
|
timelineRenderingType: TimelineRenderingType.Thread,
|
||||||
|
composerType: ComposerType.Send,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, dispatcher);
|
||||||
|
dispatcher.dispatch({
|
||||||
|
action: Action.ComposerInsert,
|
||||||
|
text: "Hello world",
|
||||||
|
timelineRenderingType: TimelineRenderingType.Thread,
|
||||||
|
} satisfies ComposerInsertPayload);
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
it("ignores payloads with a composerType", async () => {
|
||||||
|
await getComponent();
|
||||||
|
const promise = untilDispatch(
|
||||||
|
(payload) => {
|
||||||
|
try {
|
||||||
|
expect(payload).toStrictEqual({
|
||||||
|
action: Action.ComposerInsert,
|
||||||
|
text: "Hello world",
|
||||||
|
timelineRenderingType: TimelineRenderingType.Thread,
|
||||||
|
composerType: ComposerType.Send,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
dispatcher,
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
dispatcher.dispatch({
|
||||||
|
action: Action.ComposerInsert,
|
||||||
|
text: "Hello world",
|
||||||
|
composerType: ComposerType.Send,
|
||||||
|
timelineRenderingType: TimelineRenderingType.Thread,
|
||||||
|
// Ensure we don't accidentally pick up this emit by strictly checking above.
|
||||||
|
viaTest: true,
|
||||||
|
} satisfies ComposerInsertPayload);
|
||||||
|
await expect(promise).rejects.toThrow();
|
||||||
|
});
|
||||||
|
it("ignores payloads with a timelineRenderingType != TimelineRenderingType.Thread", async () => {
|
||||||
|
await getComponent();
|
||||||
|
const promise = untilDispatch(
|
||||||
|
(payload) => {
|
||||||
|
try {
|
||||||
|
expect(payload).toStrictEqual({
|
||||||
|
action: Action.ComposerInsert,
|
||||||
|
text: "Hello world",
|
||||||
|
timelineRenderingType: TimelineRenderingType.Thread,
|
||||||
|
composerType: ComposerType.Send,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
dispatcher,
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
dispatcher.dispatch({
|
||||||
|
action: Action.ComposerInsert,
|
||||||
|
text: "Hello world",
|
||||||
|
composerType: ComposerType.Send,
|
||||||
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
|
// Ensure we don't accidentally pick up this emit by strictly checking above.
|
||||||
|
viaTest: true,
|
||||||
|
} satisfies ComposerInsertPayload);
|
||||||
|
await expect(promise).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-1
@@ -283,8 +283,10 @@ describe("EditWysiwygComposer", () => {
|
|||||||
// It adds the composerType fields where the value refers if the composer is in editing or not
|
// It adds the composerType fields where the value refers if the composer is in editing or not
|
||||||
// The listeners in the RTE ignore the message if the composerType is missing in the payload
|
// The listeners in the RTE ignore the message if the composerType is missing in the payload
|
||||||
const dispatcherRef = defaultDispatcher.register((payload: ActionPayload) => {
|
const dispatcherRef = defaultDispatcher.register((payload: ActionPayload) => {
|
||||||
|
const insertPayload = payload as ComposerInsertPayload;
|
||||||
defaultDispatcher.dispatch<ComposerInsertPayload>({
|
defaultDispatcher.dispatch<ComposerInsertPayload>({
|
||||||
...(payload as ComposerInsertPayload),
|
...insertPayload,
|
||||||
|
timelineRenderingType: insertPayload.timelineRenderingType!,
|
||||||
composerType: ComposerType.Edit,
|
composerType: ComposerType.Edit,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-2
@@ -48,11 +48,13 @@ describe("SendWysiwygComposer", () => {
|
|||||||
const registerId = defaultDispatcher.register((payload) => {
|
const registerId = defaultDispatcher.register((payload) => {
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case Action.ComposerInsert: {
|
case Action.ComposerInsert: {
|
||||||
if (payload.composerType) break;
|
const insertPayload = payload as ComposerInsertPayload;
|
||||||
|
if (insertPayload.composerType) break;
|
||||||
|
|
||||||
// re-dispatch to the correct composer
|
// re-dispatch to the correct composer
|
||||||
defaultDispatcher.dispatch<ComposerInsertPayload>({
|
defaultDispatcher.dispatch<ComposerInsertPayload>({
|
||||||
...(payload as ComposerInsertPayload),
|
...insertPayload,
|
||||||
|
timelineRenderingType: insertPayload.timelineRenderingType!,
|
||||||
composerType: ComposerType.Send,
|
composerType: ComposerType.Send,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Action } from "../../../src/dispatcher/actions";
|
||||||
|
import type { MatrixDispatcher } from "../../../src/dispatcher/dispatcher";
|
||||||
|
import { ComposerApi } from "../../../src/modules/ComposerApi";
|
||||||
|
|
||||||
|
describe("ComposerApi", () => {
|
||||||
|
it("should be able to insert text via insertTextIntoComposer()", () => {
|
||||||
|
const dispatcher = {
|
||||||
|
dispatch: jest.fn(),
|
||||||
|
} as unknown as MatrixDispatcher;
|
||||||
|
const api = new ComposerApi(dispatcher);
|
||||||
|
api.insertPlaintextIntoComposer("Hello world");
|
||||||
|
expect(dispatcher.dispatch).toHaveBeenCalledWith({
|
||||||
|
action: Action.ComposerInsert,
|
||||||
|
text: "Hello world",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API to interact with the message composer.
|
||||||
|
* @alpha Likely to change
|
||||||
|
*/
|
||||||
|
export interface ComposerApi {
|
||||||
|
/**
|
||||||
|
* Insert plaintext into the current composer.
|
||||||
|
* @param plaintext - The plain text to insert
|
||||||
|
* @returns Returns immediately, does not await action.
|
||||||
|
* @alpha Likely to change
|
||||||
|
*/
|
||||||
|
insertPlaintextIntoComposer(plaintext: string): void;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { type ClientApi } from "./client.ts";
|
|||||||
import { type WidgetLifecycleApi } from "./widget-lifecycle.ts";
|
import { type WidgetLifecycleApi } from "./widget-lifecycle.ts";
|
||||||
import { type WidgetApi } from "./widget.ts";
|
import { type WidgetApi } from "./widget.ts";
|
||||||
import { type CustomisationsApi } from "./customisations.ts";
|
import { type CustomisationsApi } from "./customisations.ts";
|
||||||
|
import { type ComposerApi } from "./composer.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module interface for modules to implement.
|
* Module interface for modules to implement.
|
||||||
@@ -159,6 +160,12 @@ export interface Api
|
|||||||
*/
|
*/
|
||||||
readonly customisations: CustomisationsApi;
|
readonly customisations: CustomisationsApi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows modules to customise the message composer.
|
||||||
|
* @alpha Subject to change.
|
||||||
|
*/
|
||||||
|
readonly composer: ComposerApi;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a ReactDOM root for rendering React components.
|
* Create a ReactDOM root for rendering React components.
|
||||||
* Exposed to allow modules to avoid needing to bundle their own ReactDOM.
|
* Exposed to allow modules to avoid needing to bundle their own ReactDOM.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type { Config, ConfigApi } from "./api/config";
|
|||||||
export type { I18nApi, Variables, Translations, SubstitutionValue, Tags } from "./api/i18n";
|
export type { I18nApi, Variables, Translations, SubstitutionValue, Tags } from "./api/i18n";
|
||||||
export type * from "./models/event";
|
export type * from "./models/event";
|
||||||
export type * from "./models/Room";
|
export type * from "./models/Room";
|
||||||
|
export type * from "./api/composer";
|
||||||
export type * from "./api/custom-components";
|
export type * from "./api/custom-components";
|
||||||
export type * from "./api/extras";
|
export type * from "./api/extras";
|
||||||
export type * from "./api/legacy-modules";
|
export type * from "./api/legacy-modules";
|
||||||
|
|||||||
Reference in New Issue
Block a user