diff --git a/src/events/TopicEvent.ts b/src/events/TopicEvent.ts new file mode 100644 index 0000000..2e191ca --- /dev/null +++ b/src/events/TopicEvent.ts @@ -0,0 +1,104 @@ +/* +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 { ExtensibleEvent } from "./ExtensibleEvent"; +import { IPartialEvent } from "../IPartialEvent"; +import { isProvided, Optional } from "../types"; +import { InvalidEventError } from "../InvalidEventError"; +import { IMessageRendering } from "./message_types"; +import { EventType, isEventTypeSame } from "../utility/events"; +import { M_TOPIC, M_TOPIC_EVENT_CONTENT } from "./topic_types"; + +/** + * Represents a topic event. + */ +export class TopicEvent extends ExtensibleEvent { + /** + * The default text for the event. + */ + public readonly text: string; + + /** + * The default HTML for the event, if provided. + */ + public readonly html: Optional; + + /** + * All the different renderings of the topic. Note that this is the same + * format as an m.topic body but may contain elements not found directly + * in the event content: this is because this is interpreted based off the + * other information available in the event. + */ + public readonly renderings: IMessageRendering[]; + + /** + * Creates a new TopicEvent from a pure format. Note that the event is *not* + * parsed here: it will be treated as a literal m.topic primary typed event. + * @param {IPartialEvent} wireFormat The event. + */ + public constructor(wireFormat: IPartialEvent) { + super(wireFormat); + + const mtopic = M_TOPIC.findIn(this.wireContent); + if (isProvided(mtopic)) { + if (!Array.isArray(mtopic)) { + throw new InvalidEventError("m.topic contents must be an array"); + } + const text = mtopic.find(r => !isProvided(r.mimetype) || r.mimetype === "text/plain"); + const html = mtopic.find(r => r.mimetype === "text/html"); + + if (!text) throw new InvalidEventError("m.topic is missing a plain text representation"); + + this.text = text.body; + this.html = html?.body; + this.renderings = mtopic; + } else { + throw new InvalidEventError("Missing textual representation for event"); + } + } + + public isEquivalentTo(primaryEventType: EventType): boolean { + return isEventTypeSame(primaryEventType, M_TOPIC); + } + + public serialize(): IPartialEvent { + return { + type: "m.room.topic", + content: { + topic: this.text, + [M_TOPIC.name]: this.renderings, + }, + }; + } + + /** + * Creates a new TopicEvent from text and HTML. + * @param {string} text The text. + * @param {string} html Optional HTML. + * @returns {TopicEvent} The representative topic event. + */ + public static from(text: string, html?: string): TopicEvent { + return new TopicEvent({ + type: M_TOPIC.name, + content: { + [M_TOPIC.name]: [ + {body: text, mimetype: "text/plain"}, + {body: html, mimetype: "text/html"}, + ] + }, + }); + } +} diff --git a/src/events/topic_types.ts b/src/events/topic_types.ts new file mode 100644 index 0000000..8a0d0ed --- /dev/null +++ b/src/events/topic_types.ts @@ -0,0 +1,34 @@ +/* +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 { UnstableValue } from "../NamespacedValue"; +import { EitherAnd } from "../types"; +import { IMessageRendering } from "./message_types"; + +/** + * The namespaced value for m.topic + */ +export const M_TOPIC = new UnstableValue("m.topic", "org.matrix.msc3381.topic"); + +/** + * The event definition for an m.topic event (in content) + */ +export type M_TOPIC_EVENT = EitherAnd<{ [M_TOPIC.name]: IMessageRendering[] }, { [M_TOPIC.altName]: IMessageRendering[] }>; + +/** + * The content for an m.topic event + */ +export type M_TOPIC_EVENT_CONTENT = M_TOPIC_EVENT; diff --git a/src/index.ts b/src/index.ts index d588c1f..0296e02 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,3 +44,5 @@ export * from "./events/poll_types"; export * from "./events/PollStartEvent"; export * from "./events/PollResponseEvent"; export * from "./events/PollEndEvent"; +export * from "./events/topic_types"; +export * from "./events/TopicEvent"; diff --git a/test/events/TopicEvent.test.ts b/test/events/TopicEvent.test.ts new file mode 100644 index 0000000..552e0a9 --- /dev/null +++ b/test/events/TopicEvent.test.ts @@ -0,0 +1,101 @@ +/* +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 { + InvalidEventError, + IPartialEvent, + M_TOPIC, + M_TOPIC_EVENT_CONTENT, + TopicEvent +} from "../../src"; + +describe('TopicEvent', () => { + it('should parse m.topic', () => { + const input: IPartialEvent = { + type: "org.example.topic-like", + content: { + [M_TOPIC.name]: [ + {body: "Text here", mimetype: "text/plain"}, + {body: "HTML here", mimetype: "text/html"}, + {body: "MD here", mimetype: "text/markdown"}, + ], + }, + }; + const topic = new TopicEvent(input); + expect(topic.text).toBe("Text here"); + expect(topic.html).toBe("HTML here"); + expect(topic.renderings.length).toBe(3); + expect(topic.renderings.some(r => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true); + expect(topic.renderings.some(r => r.mimetype === "text/html" && r.body === "HTML here")).toBe(true); + expect(topic.renderings.some(r => r.mimetype === "text/markdown" && r.body === "MD here")).toBe(true); + }); + + it('should fail to parse missing text', () => { + const input: IPartialEvent = { + type: "org.example.topic-like", + content: { + hello: "world", + } as any, // force invalid type + }; + expect(() => new TopicEvent(input)) + .toThrow(new InvalidEventError("Missing textual representation for event")); + }); + + it('should fail to parse missing plain text in m.topic', () => { + const input: IPartialEvent = { + type: "org.example.topic-like", + content: { + [M_TOPIC.name]: [ + {body: "HTML here", mimetype: "text/html"}, + ], + }, + }; + expect(() => new TopicEvent(input)) + .toThrow(new InvalidEventError("m.topic is missing a plain text representation")); + }); + + it('should fail to parse non-array m.topic', () => { + const input: IPartialEvent = { + type: "org.example.topic-like", + content: { + [M_TOPIC.name]: "invalid", + } as any, // force invalid type + }; + expect(() => new TopicEvent(input)) + .toThrow(new InvalidEventError("m.topic contents must be an array")); + }); + + describe('from & serialize', () => { + it('should serialize to a legacy fallback', () => { + const topic = TopicEvent.from("Text here", "HTML here"); + expect(topic.text).toBe("Text here"); + expect(topic.html).toBe("HTML here"); + expect(topic.renderings.length).toBe(2); + expect(topic.renderings.some(r => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true); + expect(topic.renderings.some(r => r.mimetype === "text/html" && r.body === "HTML here")).toBe(true); + + const serialized = topic.serialize(); + expect(serialized.type).toBe("m.room.topic"); + expect(serialized.content).toMatchObject({ + [M_TOPIC.name]: [ + {body: "Text here", mimetype: "text/plain"}, + {body: "HTML here", mimetype: "text/html"}, + ], + topic: "Text here", + }); + }); + }); +});