diff --git a/spec/unit/content-helpers.spec.ts b/spec/unit/content-helpers.spec.ts index 71b7344ed6a..ba431d9d586 100644 --- a/spec/unit/content-helpers.spec.ts +++ b/spec/unit/content-helpers.spec.ts @@ -17,7 +17,13 @@ limitations under the License. import { REFERENCE_RELATION } from "matrix-events-sdk"; import { LocationAssetType, M_ASSET, M_LOCATION, M_TIMESTAMP } from "../../src/@types/location"; -import { makeBeaconContent, makeBeaconInfoContent } from "../../src/content-helpers"; +import { M_TOPIC } from "../../src/@types/topic"; +import { + makeBeaconContent, + makeBeaconInfoContent, + makeTopicContent, + parseTopicContent, +} from "../../src/content-helpers"; describe('Beacon content helpers', () => { describe('makeBeaconInfoContent()', () => { @@ -122,3 +128,68 @@ describe('Beacon content helpers', () => { }); }); }); + +describe('Topic content helpers', () => { + describe('makeTopicContent()', () => { + it('creates fully defined event content without html', () => { + expect(makeTopicContent("pizza")).toEqual({ + topic: "pizza", + [M_TOPIC.name]: [{ + body: "pizza", + mimetype: "text/plain", + }], + }); + }); + + it('creates fully defined event content with html', () => { + expect(makeTopicContent("pizza", "pizza")).toEqual({ + topic: "pizza", + [M_TOPIC.name]: [{ + body: "pizza", + mimetype: "text/plain", + }, { + body: "pizza", + mimetype: "text/html", + }], + }); + }); + }); + + describe('parseTopicContent()', () => { + it('parses event content with plain text topic without mimetype', () => { + expect(parseTopicContent({ + topic: "pizza", + [M_TOPIC.name]: [{ + body: "pizza", + }], + })).toEqual({ + text: "pizza", + }); + }); + + it('parses event content with plain text topic', () => { + expect(parseTopicContent({ + topic: "pizza", + [M_TOPIC.name]: [{ + body: "pizza", + mimetype: "text/plain", + }], + })).toEqual({ + text: "pizza", + }); + }); + + it('parses event content with html topic', () => { + expect(parseTopicContent({ + topic: "pizza", + [M_TOPIC.name]: [{ + body: "pizza", + mimetype: "text/html", + }], + })).toEqual({ + text: "pizza", + html: "pizza", + }); + }); + }); +}); diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index d1562d766cc..489aa167950 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -33,7 +33,7 @@ import { ReceiptType } from "../../src/@types/read_receipts"; import * as testUtils from "../test-utils/test-utils"; import { makeBeaconInfoContent } from "../../src/content-helpers"; import { M_BEACON_INFO } from "../../src/@types/beacon"; -import { Room } from "../../src"; +import { ContentHelpers, Room } from "../../src"; import { makeBeaconEvent } from "../test-utils/beacon"; jest.useFakeTimers(); @@ -1104,6 +1104,41 @@ describe("MatrixClient", function() { }); }); + describe("setRoomTopic", () => { + const roomId = "!foofoofoofoofoofoo:matrix.org"; + const createSendStateEventMock = (topic: string, htmlTopic?: string) => { + return jest.fn() + .mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { + expect(roomId).toEqual(roomId); + expect(eventType).toEqual(EventType.RoomTopic); + expect(content).toMatchObject(ContentHelpers.makeTopicContent(topic, htmlTopic)); + expect(stateKey).toBeUndefined(); + return Promise.resolve(); + }); + }; + + it("is called with plain text topic and sends state event", async () => { + const sendStateEvent = createSendStateEventMock("pizza"); + client.sendStateEvent = sendStateEvent; + await client.setRoomTopic(roomId, "pizza"); + expect(sendStateEvent).toHaveBeenCalledTimes(1); + }); + + it("is called with plain text topic and callback and sends state event", async () => { + const sendStateEvent = createSendStateEventMock("pizza"); + client.sendStateEvent = sendStateEvent; + await client.setRoomTopic(roomId, "pizza", () => {}); + expect(sendStateEvent).toHaveBeenCalledTimes(1); + }); + + it("is called with plain text and HTML topic and sends state event", async () => { + const sendStateEvent = createSendStateEventMock("pizza", "pizza"); + client.sendStateEvent = sendStateEvent; + await client.setRoomTopic(roomId, "pizza", "pizza"); + expect(sendStateEvent).toHaveBeenCalledTimes(1); + }); + }); + describe("setPassword", () => { const auth = { session: 'abcdef', type: 'foo' }; const newPassword = 'newpassword'; diff --git a/src/@types/topic.ts b/src/@types/topic.ts new file mode 100644 index 00000000000..0d2708b2e50 --- /dev/null +++ b/src/@types/topic.ts @@ -0,0 +1,62 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EitherAnd, IMessageRendering } from "matrix-events-sdk"; + +import { UnstableValue } from "../NamespacedValue"; + +/** + * Extensible topic event type based on MSC3765 + * https://github.com/matrix-org/matrix-spec-proposals/pull/3765 + */ + +/** + * Eg + * { + * "type": "m.room.topic, + * "state_key": "", + * "content": { + * "topic": "All about **pizza**", + * "m.topic": [{ + * "body": "All about **pizza**", + * "mimetype": "text/plain", + * }, { + * "body": "All about pizza", + * "mimetype": "text/html", + * }], + * } + * } + */ + +/** + * The event type for an m.topic event (in content) + */ +export const M_TOPIC = new UnstableValue("m.topic", "org.matrix.msc3765.topic"); + +/** + * The event content for an m.topic event (in content) + */ +export type MTopicContent = IMessageRendering[]; + +/** + * The event definition for an m.topic event (in content) + */ +export type MTopicEvent = EitherAnd<{ [M_TOPIC.name]: MTopicContent }, { [M_TOPIC.altName]: MTopicContent }>; + +/** + * The event content for an m.room.topic event + */ +export type MRoomTopicEventContent = { topic: string } & MTopicEvent; diff --git a/src/client.ts b/src/client.ts index bb7b9e96e6a..5187a13b0d6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3557,12 +3557,31 @@ export class MatrixClient extends TypedEventEmitter { - return this.sendStateEvent(roomId, EventType.RoomTopic, { topic: topic }, undefined, callback); + public setRoomTopic( + roomId: string, + topic: string, + htmlTopic?: string, + ): Promise; + public setRoomTopic( + roomId: string, + topic: string, + callback?: Callback, + ): Promise; + public setRoomTopic( + roomId: string, + topic: string, + htmlTopicOrCallback?: string | Callback, + ): Promise { + const isCallback = typeof htmlTopicOrCallback === 'function'; + const htmlTopic = isCallback ? undefined : htmlTopicOrCallback; + const callback = isCallback ? htmlTopicOrCallback : undefined; + const content = ContentHelpers.makeTopicContent(topic, htmlTopic); + return this.sendStateEvent(roomId, EventType.RoomTopic, content, undefined, callback); } /** diff --git a/src/content-helpers.ts b/src/content-helpers.ts index 383b9b34396..8c813b7aad6 100644 --- a/src/content-helpers.ts +++ b/src/content-helpers.ts @@ -16,7 +16,7 @@ limitations under the License. /** @module ContentHelpers */ -import { REFERENCE_RELATION } from "matrix-events-sdk"; +import { isProvided, REFERENCE_RELATION } from "matrix-events-sdk"; import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent } from "./@types/beacon"; import { MsgType } from "./@types/event"; @@ -32,6 +32,7 @@ import { MAssetContent, LegacyLocationEventContent, } from "./@types/location"; +import { MRoomTopicEventContent, MTopicContent, M_TOPIC } from "./@types/topic"; /** * Generates the content for a HTML Message event @@ -190,6 +191,34 @@ export const parseLocationEvent = (wireEventContent: LocationEventWireContent): return makeLocationContent(fallbackText, geoUri, timestamp, description, assetType); }; +/** + * Topic event helpers + */ +export type MakeTopicContent = ( + topic: string, + htmlTopic?: string, +) => MRoomTopicEventContent; + +export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => { + const renderings = [{ body: topic, mimetype: "text/plain" }]; + if (isProvided(htmlTopic)) { + renderings.push({ body: htmlTopic, mimetype: "text/html" }); + } + return { topic, [M_TOPIC.name]: renderings }; +}; + +export type TopicState = { + text: string; + html?: string; +}; + +export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => { + const mtopic = M_TOPIC.findIn(content); + const text = mtopic?.find(r => !isProvided(r.mimetype) || r.mimetype === "text/plain")?.body ?? content.topic; + const html = mtopic?.find(r => r.mimetype === "text/html")?.body; + return { text, html }; +}; + /** * Beacon event helpers */