From bc787846884b95b573a55c3deb76f7892de17df2 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 13 Jan 2023 10:02:27 -0700 Subject: [PATCH] Extract v1 extensible events polls types out of the events-sdk (#3062) * Extract v1 extensible events polls out of events-sdk * Appease tsdoc? * Appease naming standards * Bring the tests over too --- spec/unit/content-helpers.spec.ts | 3 +- .../ExtensibleEvent.spec.ts | 41 +++ .../extensible_events_v1/MessageEvent.spec.ts | 156 ++++++++ .../extensible_events_v1/PollEndEvent.spec.ts | 107 ++++++ .../PollResponseEvent.spec.ts | 277 ++++++++++++++ .../PollStartEvent.spec.ts | 337 ++++++++++++++++++ .../extensible_events_v1/utilities.spec.ts | 87 +++++ spec/unit/location.spec.ts | 8 +- spec/unit/models/beacon.spec.ts | 3 +- spec/unit/relations.spec.ts | 3 +- src/@types/beacon.ts | 5 +- src/@types/extensible_events.ts | 138 ++++++- src/@types/location.ts | 4 +- src/@types/polls.ts | 119 +++++++ src/@types/topic.ts | 3 +- src/content-helpers.ts | 9 +- src/extensible_events_v1/ExtensibleEvent.ts | 58 +++ src/extensible_events_v1/InvalidEventError.ts | 24 ++ src/extensible_events_v1/MessageEvent.ts | 145 ++++++++ src/extensible_events_v1/PollEndEvent.ts | 97 +++++ src/extensible_events_v1/PollResponseEvent.ts | 143 ++++++++ src/extensible_events_v1/PollStartEvent.ts | 207 +++++++++++ src/extensible_events_v1/utilities.ts | 35 ++ 23 files changed, 1984 insertions(+), 25 deletions(-) create mode 100644 spec/unit/extensible_events_v1/ExtensibleEvent.spec.ts create mode 100644 spec/unit/extensible_events_v1/MessageEvent.spec.ts create mode 100644 spec/unit/extensible_events_v1/PollEndEvent.spec.ts create mode 100644 spec/unit/extensible_events_v1/PollResponseEvent.spec.ts create mode 100644 spec/unit/extensible_events_v1/PollStartEvent.spec.ts create mode 100644 spec/unit/extensible_events_v1/utilities.spec.ts create mode 100644 src/@types/polls.ts create mode 100644 src/extensible_events_v1/ExtensibleEvent.ts create mode 100644 src/extensible_events_v1/InvalidEventError.ts create mode 100644 src/extensible_events_v1/MessageEvent.ts create mode 100644 src/extensible_events_v1/PollEndEvent.ts create mode 100644 src/extensible_events_v1/PollResponseEvent.ts create mode 100644 src/extensible_events_v1/PollStartEvent.ts create mode 100644 src/extensible_events_v1/utilities.ts diff --git a/spec/unit/content-helpers.spec.ts b/spec/unit/content-helpers.spec.ts index 9a5ddb143..2188cbcca 100644 --- a/spec/unit/content-helpers.spec.ts +++ b/spec/unit/content-helpers.spec.ts @@ -14,8 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { REFERENCE_RELATION } from "matrix-events-sdk"; - import { LocationAssetType, M_ASSET, M_LOCATION, M_TIMESTAMP } from "../../src/@types/location"; import { M_TOPIC } from "../../src/@types/topic"; import { @@ -25,6 +23,7 @@ import { parseBeaconContent, parseTopicContent, } from "../../src/content-helpers"; +import { REFERENCE_RELATION } from "../../src/@types/extensible_events"; describe("Beacon content helpers", () => { describe("makeBeaconInfoContent()", () => { diff --git a/spec/unit/extensible_events_v1/ExtensibleEvent.spec.ts b/spec/unit/extensible_events_v1/ExtensibleEvent.spec.ts new file mode 100644 index 000000000..2a0839efa --- /dev/null +++ b/spec/unit/extensible_events_v1/ExtensibleEvent.spec.ts @@ -0,0 +1,41 @@ +/* +Copyright 2022 - 2023 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 { ExtensibleEventType, IPartialEvent } from "../../../src/@types/extensible_events"; +import { ExtensibleEvent } from "../../../src/extensible_events_v1/ExtensibleEvent"; + +class MockEvent extends ExtensibleEvent { + public constructor(wireEvent: IPartialEvent) { + super(wireEvent); + } + + public serialize(): IPartialEvent { + throw new Error("Not implemented for tests"); + } + + public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean { + throw new Error("Not implemented for tests"); + } +} + +describe("ExtensibleEvent", () => { + it("should expose the wire event directly", () => { + const input: IPartialEvent = { type: "org.example.custom", content: { hello: "world" } }; + const event = new MockEvent(input); + expect(event.wireFormat).toBe(input); + expect(event.wireContent).toBe(input.content); + }); +}); diff --git a/spec/unit/extensible_events_v1/MessageEvent.spec.ts b/spec/unit/extensible_events_v1/MessageEvent.spec.ts new file mode 100644 index 000000000..cb41f1de9 --- /dev/null +++ b/spec/unit/extensible_events_v1/MessageEvent.spec.ts @@ -0,0 +1,156 @@ +/* +Copyright 2022 - 2023 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 { + ExtensibleAnyMessageEventContent, + IPartialEvent, + M_HTML, + M_MESSAGE, + M_TEXT, +} from "../../../src/@types/extensible_events"; +import { MessageEvent } from "../../../src/extensible_events_v1/MessageEvent"; +import { InvalidEventError } from "../../../src/extensible_events_v1/InvalidEventError"; + +describe("MessageEvent", () => { + it("should parse m.text", () => { + const input: IPartialEvent = { + type: "org.example.message-like", + content: { + [M_TEXT.name]: "Text here", + }, + }; + const message = new MessageEvent(input); + expect(message.text).toBe("Text here"); + expect(message.html).toBeFalsy(); + expect(message.renderings.length).toBe(1); + expect(message.renderings.some((r) => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true); + }); + + it("should parse m.html", () => { + const input: IPartialEvent = { + type: "org.example.message-like", + content: { + [M_TEXT.name]: "Text here", + [M_HTML.name]: "HTML here", + }, + }; + const message = new MessageEvent(input); + expect(message.text).toBe("Text here"); + expect(message.html).toBe("HTML here"); + expect(message.renderings.length).toBe(2); + expect(message.renderings.some((r) => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true); + expect(message.renderings.some((r) => r.mimetype === "text/html" && r.body === "HTML here")).toBe(true); + }); + + it("should parse m.message", () => { + const input: IPartialEvent = { + type: "org.example.message-like", + content: { + [M_MESSAGE.name]: [ + { body: "Text here", mimetype: "text/plain" }, + { body: "HTML here", mimetype: "text/html" }, + { body: "MD here", mimetype: "text/markdown" }, + ], + + // These should be ignored + [M_TEXT.name]: "WRONG Text here", + [M_HTML.name]: "WRONG HTML here", + }, + }; + const message = new MessageEvent(input); + expect(message.text).toBe("Text here"); + expect(message.html).toBe("HTML here"); + expect(message.renderings.length).toBe(3); + expect(message.renderings.some((r) => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true); + expect(message.renderings.some((r) => r.mimetype === "text/html" && r.body === "HTML here")).toBe(true); + expect(message.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.message-like", + content: { + hello: "world", + } as any, // force invalid type + }; + expect(() => new MessageEvent(input)).toThrow( + new InvalidEventError("Missing textual representation for event"), + ); + }); + + it("should fail to parse missing plain text in m.message", () => { + const input: IPartialEvent = { + type: "org.example.message-like", + content: { + [M_MESSAGE.name]: [{ body: "HTML here", mimetype: "text/html" }], + }, + }; + expect(() => new MessageEvent(input)).toThrow( + new InvalidEventError("m.message is missing a plain text representation"), + ); + }); + + it("should fail to parse non-array m.message", () => { + const input: IPartialEvent = { + type: "org.example.message-like", + content: { + [M_MESSAGE.name]: "invalid", + } as any, // force invalid type + }; + expect(() => new MessageEvent(input)).toThrow(new InvalidEventError("m.message contents must be an array")); + }); + + describe("from & serialize", () => { + it("should serialize to a legacy fallback", () => { + const message = MessageEvent.from("Text here", "HTML here"); + expect(message.text).toBe("Text here"); + expect(message.html).toBe("HTML here"); + expect(message.renderings.length).toBe(2); + expect(message.renderings.some((r) => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true); + expect(message.renderings.some((r) => r.mimetype === "text/html" && r.body === "HTML here")).toBe(true); + + const serialized = message.serialize(); + expect(serialized.type).toBe("m.room.message"); + expect(serialized.content).toMatchObject({ + [M_MESSAGE.name]: [ + { body: "Text here", mimetype: "text/plain" }, + { body: "HTML here", mimetype: "text/html" }, + ], + body: "Text here", + msgtype: "m.text", + format: "org.matrix.custom.html", + formatted_body: "HTML here", + }); + }); + + it("should serialize non-html content to a legacy fallback", () => { + const message = MessageEvent.from("Text here"); + expect(message.text).toBe("Text here"); + expect(message.renderings.length).toBe(1); + expect(message.renderings.some((r) => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true); + + const serialized = message.serialize(); + expect(serialized.type).toBe("m.room.message"); + expect(serialized.content).toMatchObject({ + [M_TEXT.name]: "Text here", + body: "Text here", + msgtype: "m.text", + format: undefined, + formatted_body: undefined, + }); + }); + }); +}); diff --git a/spec/unit/extensible_events_v1/PollEndEvent.spec.ts b/spec/unit/extensible_events_v1/PollEndEvent.spec.ts new file mode 100644 index 000000000..349e3fc58 --- /dev/null +++ b/spec/unit/extensible_events_v1/PollEndEvent.spec.ts @@ -0,0 +1,107 @@ +/* +Copyright 2022 - 2023 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 { PollEndEventContent, M_POLL_END } from "../../../src/@types/polls"; +import { IPartialEvent, REFERENCE_RELATION, M_TEXT } from "../../../src/@types/extensible_events"; +import { PollEndEvent } from "../../../src/extensible_events_v1/PollEndEvent"; +import { InvalidEventError } from "../../../src/extensible_events_v1/InvalidEventError"; + +describe("PollEndEvent", () => { + // Note: throughout these tests we don't really bother testing that + // MessageEvent is doing its job. It has its own tests to worry about. + + it("should parse a poll closure", () => { + const input: IPartialEvent = { + type: M_POLL_END.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_END.name]: {}, + [M_TEXT.name]: "Poll closed", + }, + }; + const event = new PollEndEvent(input); + expect(event.pollEventId).toBe("$poll"); + expect(event.closingMessage.text).toBe("Poll closed"); + }); + + it("should fail to parse a missing relationship", () => { + const input: IPartialEvent = { + type: M_POLL_END.name, + content: { + [M_POLL_END.name]: {}, + [M_TEXT.name]: "Poll closed", + } as any, // force invalid type + }; + expect(() => new PollEndEvent(input)).toThrow( + new InvalidEventError("Relationship must be a reference to an event"), + ); + }); + + it("should fail to parse a missing relationship event ID", () => { + const input: IPartialEvent = { + type: M_POLL_END.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + }, + [M_POLL_END.name]: {}, + [M_TEXT.name]: "Poll closed", + } as any, // force invalid type + }; + expect(() => new PollEndEvent(input)).toThrow( + new InvalidEventError("Relationship must be a reference to an event"), + ); + }); + + it("should fail to parse an improper relationship", () => { + const input: IPartialEvent = { + type: M_POLL_END.name, + content: { + "m.relates_to": { + rel_type: "org.example.not-relationship", + event_id: "$poll", + }, + [M_POLL_END.name]: {}, + [M_TEXT.name]: "Poll closed", + } as any, // force invalid type + }; + expect(() => new PollEndEvent(input)).toThrow( + new InvalidEventError("Relationship must be a reference to an event"), + ); + }); + + describe("from & serialize", () => { + it("should serialize to a poll end event", () => { + const event = PollEndEvent.from("$poll", "Poll closed"); + expect(event.pollEventId).toBe("$poll"); + expect(event.closingMessage.text).toBe("Poll closed"); + + const serialized = event.serialize(); + expect(M_POLL_END.matches(serialized.type)).toBe(true); + expect(serialized.content).toMatchObject({ + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_END.name]: {}, + [M_TEXT.name]: expect.any(String), // tested by MessageEvent tests + }); + }); + }); +}); diff --git a/spec/unit/extensible_events_v1/PollResponseEvent.spec.ts b/spec/unit/extensible_events_v1/PollResponseEvent.spec.ts new file mode 100644 index 000000000..49e900407 --- /dev/null +++ b/spec/unit/extensible_events_v1/PollResponseEvent.spec.ts @@ -0,0 +1,277 @@ +/* +Copyright 2022 - 2023 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 { M_TEXT, IPartialEvent, REFERENCE_RELATION } from "../../../src/@types/extensible_events"; +import { + M_POLL_START, + M_POLL_KIND_DISCLOSED, + PollResponseEventContent, + M_POLL_RESPONSE, +} from "../../../src/@types/polls"; +import { PollStartEvent } from "../../../src/extensible_events_v1/PollStartEvent"; +import { InvalidEventError } from "../../../src/extensible_events_v1/InvalidEventError"; +import { PollResponseEvent } from "../../../src/extensible_events_v1/PollResponseEvent"; + +const SAMPLE_POLL = new PollStartEvent({ + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: [ + { id: "one", [M_TEXT.name]: "ONE" }, + { id: "two", [M_TEXT.name]: "TWO" }, + { id: "thr", [M_TEXT.name]: "THR" }, + ], + }, + }, +}); + +describe("PollResponseEvent", () => { + it("should parse a poll response", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: ["one"], + }, + }, + }; + const response = new PollResponseEvent(input); + expect(response.spoiled).toBe(false); + expect(response.answerIds).toMatchObject(["one"]); + expect(response.pollEventId).toBe("$poll"); + }); + + it("should fail to parse a missing relationship", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + [M_POLL_RESPONSE.name]: { + answers: ["one"], + }, + } as any, // force invalid type + }; + expect(() => new PollResponseEvent(input)).toThrow( + new InvalidEventError("Relationship must be a reference to an event"), + ); + }); + + it("should fail to parse a missing relationship event ID", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + }, + [M_POLL_RESPONSE.name]: { + answers: ["one"], + }, + } as any, // force invalid type + }; + expect(() => new PollResponseEvent(input)).toThrow( + new InvalidEventError("Relationship must be a reference to an event"), + ); + }); + + it("should fail to parse an improper relationship", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: "org.example.not-relationship", + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: ["one"], + }, + } as any, // force invalid type + }; + expect(() => new PollResponseEvent(input)).toThrow( + new InvalidEventError("Relationship must be a reference to an event"), + ); + }); + + describe("validateAgainst", () => { + it("should spoil the vote when no answers", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: {}, + } as any, // force invalid type + }; + const response = new PollResponseEvent(input); + expect(response.spoiled).toBe(true); + + response.validateAgainst(SAMPLE_POLL); + expect(response.spoiled).toBe(true); + }); + + it("should spoil the vote when answers are empty", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: [], + }, + }, + }; + const response = new PollResponseEvent(input); + expect(response.spoiled).toBe(true); + + response.validateAgainst(SAMPLE_POLL); + expect(response.spoiled).toBe(true); + }); + + it("should spoil the vote when answers are empty", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: [], + }, + }, + }; + const response = new PollResponseEvent(input); + expect(response.spoiled).toBe(true); + + response.validateAgainst(SAMPLE_POLL); + expect(response.spoiled).toBe(true); + }); + + it("should spoil the vote when answers are not strings", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: [1, 2, 3], + }, + } as any, // force invalid type + }; + const response = new PollResponseEvent(input); + expect(response.spoiled).toBe(true); + + response.validateAgainst(SAMPLE_POLL); + expect(response.spoiled).toBe(true); + }); + + describe("consumer usage", () => { + it("should spoil the vote when invalid answers are given", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: ["A", "B", "C"], + }, + }, + }; + const response = new PollResponseEvent(input); + expect(response.spoiled).toBe(false); // it won't know better + + response.validateAgainst(SAMPLE_POLL); + expect(response.spoiled).toBe(true); + }); + + it("should truncate answers to the poll max selections", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: ["one", "two", "thr"], + }, + }, + }; + const response = new PollResponseEvent(input); + expect(response.spoiled).toBe(false); // it won't know better + expect(response.answerIds).toMatchObject(["one", "two", "thr"]); + + response.validateAgainst(SAMPLE_POLL); + expect(response.spoiled).toBe(false); + expect(response.answerIds).toMatchObject(["one", "two"]); + }); + }); + }); + + describe("from & serialize", () => { + it("should serialize to a poll response event", () => { + const response = PollResponseEvent.from(["A", "B", "C"], "$poll"); + expect(response.spoiled).toBe(false); + expect(response.answerIds).toMatchObject(["A", "B", "C"]); + expect(response.pollEventId).toBe("$poll"); + + const serialized = response.serialize(); + expect(M_POLL_RESPONSE.matches(serialized.type)).toBe(true); + expect(serialized.content).toMatchObject({ + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: ["A", "B", "C"], + }, + }); + }); + + it("should serialize a spoiled vote", () => { + const response = PollResponseEvent.from([], "$poll"); + expect(response.spoiled).toBe(true); + expect(response.answerIds).toMatchObject([]); + expect(response.pollEventId).toBe("$poll"); + + const serialized = response.serialize(); + expect(M_POLL_RESPONSE.matches(serialized.type)).toBe(true); + expect(serialized.content).toMatchObject({ + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: undefined, + }, + }); + }); + }); +}); diff --git a/spec/unit/extensible_events_v1/PollStartEvent.spec.ts b/spec/unit/extensible_events_v1/PollStartEvent.spec.ts new file mode 100644 index 000000000..93612069b --- /dev/null +++ b/spec/unit/extensible_events_v1/PollStartEvent.spec.ts @@ -0,0 +1,337 @@ +/* +Copyright 2022 - 2023 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 { M_TEXT, IPartialEvent } from "../../../src/@types/extensible_events"; +import { + M_POLL_START, + M_POLL_KIND_DISCLOSED, + PollAnswer, + PollStartEventContent, + M_POLL_KIND_UNDISCLOSED, +} from "../../../src/@types/polls"; +import { PollStartEvent, PollAnswerSubevent } from "../../../src/extensible_events_v1/PollStartEvent"; +import { InvalidEventError } from "../../../src/extensible_events_v1/InvalidEventError"; + +describe("PollAnswerSubevent", () => { + // Note: throughout these tests we don't really bother testing that + // MessageEvent is doing its job. It has its own tests to worry about. + + it("should parse an answer representation", () => { + const input: IPartialEvent = { + type: "org.matrix.sdk.poll.answer", + content: { + id: "one", + [M_TEXT.name]: "ONE", + }, + }; + const answer = new PollAnswerSubevent(input); + expect(answer.id).toBe("one"); + expect(answer.text).toBe("ONE"); + }); + + it("should fail to parse answers without an ID", () => { + const input: IPartialEvent = { + type: "org.matrix.sdk.poll.answer", + content: { + [M_TEXT.name]: "ONE", + } as any, // force invalid type + }; + expect(() => new PollAnswerSubevent(input)).toThrow( + new InvalidEventError("Answer ID must be a non-empty string"), + ); + }); + + it("should fail to parse answers without text", () => { + const input: IPartialEvent = { + type: "org.matrix.sdk.poll.answer", + content: { + id: "one", + } as any, // force invalid type + }; + expect(() => new PollAnswerSubevent(input)).toThrow(); // we don't check message - that'll be MessageEvent's problem + }); + + describe("from & serialize", () => { + it("should serialize to a placeholder representation", () => { + const answer = PollAnswerSubevent.from("one", "ONE"); + expect(answer.id).toBe("one"); + expect(answer.text).toBe("ONE"); + + const serialized = answer.serialize(); + expect(serialized.type).toBe("org.matrix.sdk.poll.answer"); + expect(serialized.content).toMatchObject({ + id: "one", + [M_TEXT.name]: expect.any(String), // tested by MessageEvent + }); + }); + }); +}); + +describe("PollStartEvent", () => { + // Note: throughout these tests we don't really bother testing that + // MessageEvent is doing its job. It has its own tests to worry about. + + it("should parse a poll", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: [ + { id: "one", [M_TEXT.name]: "ONE" }, + { id: "two", [M_TEXT.name]: "TWO" }, + { id: "thr", [M_TEXT.name]: "THR" }, + ], + }, + }, + }; + const poll = new PollStartEvent(input); + expect(poll.question).toBeDefined(); + expect(poll.question.text).toBe("Question here"); + expect(poll.kind).toBe(M_POLL_KIND_DISCLOSED); + expect(M_POLL_KIND_DISCLOSED.matches(poll.rawKind)).toBe(true); + expect(poll.maxSelections).toBe(2); + expect(poll.answers.length).toBe(3); + expect(poll.answers.some((a) => a.id === "one" && a.text === "ONE")).toBe(true); + expect(poll.answers.some((a) => a.id === "two" && a.text === "TWO")).toBe(true); + expect(poll.answers.some((a) => a.id === "thr" && a.text === "THR")).toBe(true); + }); + + it("should fail to parse a missing question", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: [ + { id: "one", [M_TEXT.name]: "ONE" }, + { id: "two", [M_TEXT.name]: "TWO" }, + { id: "thr", [M_TEXT.name]: "THR" }, + ], + }, + } as any, // force invalid type + }; + expect(() => new PollStartEvent(input)).toThrow(new InvalidEventError("A question is required")); + }); + + it("should fail to parse non-array answers", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: "one", + } as any, // force invalid type + }, + }; + expect(() => new PollStartEvent(input)).toThrow(new InvalidEventError("Poll answers must be an array")); + }); + + it("should fail to parse invalid answers", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: [{ id: "one" }, { [M_TEXT.name]: "TWO" }], + } as any, // force invalid type + }, + }; + expect(() => new PollStartEvent(input)).toThrow(); // error tested by PollAnswerSubevent tests + }); + + it("should fail to parse lack of answers", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: [], + } as any, // force invalid type + }, + }; + expect(() => new PollStartEvent(input)).toThrow(new InvalidEventError("No answers available")); + }); + + it("should truncate answers at 20", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: [ + { id: "01", [M_TEXT.name]: "A" }, + { id: "02", [M_TEXT.name]: "B" }, + { id: "03", [M_TEXT.name]: "C" }, + { id: "04", [M_TEXT.name]: "D" }, + { id: "05", [M_TEXT.name]: "E" }, + { id: "06", [M_TEXT.name]: "F" }, + { id: "07", [M_TEXT.name]: "G" }, + { id: "08", [M_TEXT.name]: "H" }, + { id: "09", [M_TEXT.name]: "I" }, + { id: "10", [M_TEXT.name]: "J" }, + { id: "11", [M_TEXT.name]: "K" }, + { id: "12", [M_TEXT.name]: "L" }, + { id: "13", [M_TEXT.name]: "M" }, + { id: "14", [M_TEXT.name]: "N" }, + { id: "15", [M_TEXT.name]: "O" }, + { id: "16", [M_TEXT.name]: "P" }, + { id: "17", [M_TEXT.name]: "Q" }, + { id: "18", [M_TEXT.name]: "R" }, + { id: "19", [M_TEXT.name]: "S" }, + { id: "20", [M_TEXT.name]: "T" }, + { id: "FAIL", [M_TEXT.name]: "U" }, + ], + }, + }, + }; + const poll = new PollStartEvent(input); + expect(poll.answers.length).toBe(20); + expect(poll.answers.some((a) => a.id === "FAIL")).toBe(false); + }); + + it("should infer a kind from unknown kinds", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + kind: "org.example.custom.poll.kind", + max_selections: 2, + answers: [ + { id: "01", [M_TEXT.name]: "A" }, + { id: "02", [M_TEXT.name]: "B" }, + { id: "03", [M_TEXT.name]: "C" }, + ], + }, + }, + }; + const poll = new PollStartEvent(input); + expect(poll.kind).toBe(M_POLL_KIND_UNDISCLOSED); + expect(poll.rawKind).toBe("org.example.custom.poll.kind"); + }); + + it("should infer a kind from missing kinds", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + max_selections: 2, + answers: [ + { id: "01", [M_TEXT.name]: "A" }, + { id: "02", [M_TEXT.name]: "B" }, + { id: "03", [M_TEXT.name]: "C" }, + ], + } as any, // force invalid type + }, + }; + const poll = new PollStartEvent(input); + expect(poll.kind).toBe(M_POLL_KIND_UNDISCLOSED); + expect(poll.rawKind).toBeFalsy(); + }); + + describe("from & serialize", () => { + it("should serialize to a poll start event", () => { + const poll = PollStartEvent.from("Question here", ["A", "B", "C"], M_POLL_KIND_DISCLOSED, 2); + expect(poll.question.text).toBe("Question here"); + expect(poll.kind).toBe(M_POLL_KIND_DISCLOSED); + expect(M_POLL_KIND_DISCLOSED.matches(poll.rawKind)).toBe(true); + expect(poll.maxSelections).toBe(2); + expect(poll.answers.length).toBe(3); + expect(poll.answers.some((a) => a.text === "A")).toBe(true); + expect(poll.answers.some((a) => a.text === "B")).toBe(true); + expect(poll.answers.some((a) => a.text === "C")).toBe(true); + + // Ids are non-empty and unique + expect(poll.answers[0].id).toHaveLength(16); + expect(poll.answers[1].id).toHaveLength(16); + expect(poll.answers[2].id).toHaveLength(16); + expect(poll.answers[0].id).not.toEqual(poll.answers[1].id); + expect(poll.answers[0].id).not.toEqual(poll.answers[2].id); + expect(poll.answers[1].id).not.toEqual(poll.answers[2].id); + + const serialized = poll.serialize(); + expect(M_POLL_START.matches(serialized.type)).toBe(true); + expect(serialized.content).toMatchObject({ + [M_TEXT.name]: "Question here\n1. A\n2. B\n3. C", + [M_POLL_START.name]: { + question: { + [M_TEXT.name]: expect.any(String), // tested by MessageEvent tests + }, + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: [ + // M_TEXT tested by MessageEvent tests + { id: expect.any(String), [M_TEXT.name]: expect.any(String) }, + { id: expect.any(String), [M_TEXT.name]: expect.any(String) }, + { id: expect.any(String), [M_TEXT.name]: expect.any(String) }, + ], + }, + }); + }); + + it("should serialize to a custom kind poll start event", () => { + const poll = PollStartEvent.from("Question here", ["A", "B", "C"], "org.example.poll.kind", 2); + expect(poll.question.text).toBe("Question here"); + expect(poll.kind).toBe(M_POLL_KIND_UNDISCLOSED); + expect(poll.rawKind).toBe("org.example.poll.kind"); + expect(poll.maxSelections).toBe(2); + expect(poll.answers.length).toBe(3); + expect(poll.answers.some((a) => a.text === "A")).toBe(true); + expect(poll.answers.some((a) => a.text === "B")).toBe(true); + expect(poll.answers.some((a) => a.text === "C")).toBe(true); + + const serialized = poll.serialize(); + expect(M_POLL_START.matches(serialized.type)).toBe(true); + expect(serialized.content).toMatchObject({ + [M_TEXT.name]: "Question here\n1. A\n2. B\n3. C", + [M_POLL_START.name]: { + question: { + [M_TEXT.name]: expect.any(String), // tested by MessageEvent tests + }, + kind: "org.example.poll.kind", + max_selections: 2, + answers: [ + // M_MESSAGE tested by MessageEvent tests + { id: expect.any(String), [M_TEXT.name]: expect.any(String) }, + { id: expect.any(String), [M_TEXT.name]: expect.any(String) }, + { id: expect.any(String), [M_TEXT.name]: expect.any(String) }, + ], + }, + }); + }); + }); +}); diff --git a/spec/unit/extensible_events_v1/utilities.spec.ts b/spec/unit/extensible_events_v1/utilities.spec.ts new file mode 100644 index 000000000..9fd3636de --- /dev/null +++ b/spec/unit/extensible_events_v1/utilities.spec.ts @@ -0,0 +1,87 @@ +/* +Copyright 2022 - 2023 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 { NamespacedValue } from "matrix-events-sdk"; + +import { isEventTypeSame } from "../../../src/@types/extensible_events"; + +describe("isEventTypeSame", () => { + it("should match string and string", () => { + const a = "org.example.message-like"; + const b = "org.example.different"; + + expect(isEventTypeSame(a, b)).toBe(false); + expect(isEventTypeSame(b, a)).toBe(false); + + expect(isEventTypeSame(a, a)).toBe(true); + expect(isEventTypeSame(b, b)).toBe(true); + }); + + it("should match string and namespace", () => { + const a = "org.example.message-like"; + const b = new NamespacedValue("org.example.stable", "org.example.unstable"); + + expect(isEventTypeSame(a, b)).toBe(false); + expect(isEventTypeSame(b, a)).toBe(false); + + expect(isEventTypeSame(a, a)).toBe(true); + expect(isEventTypeSame(b, b)).toBe(true); + expect(isEventTypeSame(b.name, b)).toBe(true); + expect(isEventTypeSame(b.altName, b)).toBe(true); + expect(isEventTypeSame(b, b.name)).toBe(true); + expect(isEventTypeSame(b, b.altName)).toBe(true); + }); + + it("should match namespace and namespace", () => { + const a = new NamespacedValue("org.example.stable1", "org.example.unstable1"); + const b = new NamespacedValue("org.example.stable2", "org.example.unstable2"); + + expect(isEventTypeSame(a, b)).toBe(false); + expect(isEventTypeSame(b, a)).toBe(false); + + expect(isEventTypeSame(a, a)).toBe(true); + expect(isEventTypeSame(a.name, a)).toBe(true); + expect(isEventTypeSame(a.altName, a)).toBe(true); + expect(isEventTypeSame(a, a.name)).toBe(true); + expect(isEventTypeSame(a, a.altName)).toBe(true); + + expect(isEventTypeSame(b, b)).toBe(true); + expect(isEventTypeSame(b.name, b)).toBe(true); + expect(isEventTypeSame(b.altName, b)).toBe(true); + expect(isEventTypeSame(b, b.name)).toBe(true); + expect(isEventTypeSame(b, b.altName)).toBe(true); + }); + + it("should match namespaces of different pointers", () => { + const a = new NamespacedValue("org.example.stable", "org.example.unstable"); + const b = new NamespacedValue("org.example.stable", "org.example.unstable"); + + expect(isEventTypeSame(a, b)).toBe(true); + expect(isEventTypeSame(b, a)).toBe(true); + + expect(isEventTypeSame(a, a)).toBe(true); + expect(isEventTypeSame(a.name, a)).toBe(true); + expect(isEventTypeSame(a.altName, a)).toBe(true); + expect(isEventTypeSame(a, a.name)).toBe(true); + expect(isEventTypeSame(a, a.altName)).toBe(true); + + expect(isEventTypeSame(b, b)).toBe(true); + expect(isEventTypeSame(b.name, b)).toBe(true); + expect(isEventTypeSame(b.altName, b)).toBe(true); + expect(isEventTypeSame(b, b.name)).toBe(true); + expect(isEventTypeSame(b, b.altName)).toBe(true); + }); +}); diff --git a/spec/unit/location.spec.ts b/spec/unit/location.spec.ts index 953d3b611..ff24d6638 100644 --- a/spec/unit/location.spec.ts +++ b/spec/unit/location.spec.ts @@ -22,7 +22,7 @@ import { M_TIMESTAMP, LocationEventWireContent, } from "../../src/@types/location"; -import { TEXT_NODE_TYPE } from "../../src/@types/extensible_events"; +import { M_TEXT } from "../../src/@types/extensible_events"; import { MsgType } from "../../src/@types/event"; describe("Location", function () { @@ -32,7 +32,7 @@ describe("Location", function () { geo_uri: "geo:-36.24484561954707,175.46884959563613;u=10", [M_LOCATION.name]: { uri: "geo:-36.24484561954707,175.46884959563613;u=10", description: null }, [M_ASSET.name]: { type: "m.self" }, - [TEXT_NODE_TYPE.name]: "Location geo:-36.24484561954707,175.46884959563613;u=10 at 2022-03-09T11:01:52.443Z", + [M_TEXT.name]: "Location geo:-36.24484561954707,175.46884959563613;u=10 at 2022-03-09T11:01:52.443Z", [M_TIMESTAMP.name]: 1646823712443, } as any; @@ -59,7 +59,7 @@ describe("Location", function () { description: undefined, }); expect(M_ASSET.findIn(loc)).toEqual({ type: LocationAssetType.Self }); - expect(TEXT_NODE_TYPE.findIn(loc)).toEqual("User Location geo:foo at 1970-01-02T13:17:15.435Z"); + expect(M_TEXT.findIn(loc)).toEqual("User Location geo:foo at 1970-01-02T13:17:15.435Z"); expect(M_TIMESTAMP.findIn(loc)).toEqual(134235435); }); @@ -74,7 +74,7 @@ describe("Location", function () { description: "desc", }); expect(M_ASSET.findIn(loc)).toEqual({ type: LocationAssetType.Pin }); - expect(TEXT_NODE_TYPE.findIn(loc)).toEqual('Location "desc" geo:bar at 1970-01-02T13:17:15.436Z'); + expect(M_TEXT.findIn(loc)).toEqual('Location "desc" geo:bar at 1970-01-02T13:17:15.436Z'); expect(M_TIMESTAMP.findIn(loc)).toEqual(134235436); }); diff --git a/spec/unit/models/beacon.spec.ts b/spec/unit/models/beacon.spec.ts index 620e4a8fd..b3042cd6a 100644 --- a/spec/unit/models/beacon.spec.ts +++ b/spec/unit/models/beacon.spec.ts @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { REFERENCE_RELATION } from "matrix-events-sdk"; - +import { REFERENCE_RELATION } from "../../../src/@types/extensible_events"; import { MatrixEvent } from "../../../src"; import { M_BEACON_INFO } from "../../../src/@types/beacon"; import { isTimestampInDuration, Beacon, BeaconEvent } from "../../../src/models/beacon"; diff --git a/spec/unit/relations.spec.ts b/spec/unit/relations.spec.ts index cf4997c28..985259940 100644 --- a/spec/unit/relations.spec.ts +++ b/spec/unit/relations.spec.ts @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { M_POLL_START } from "matrix-events-sdk"; - +import { M_POLL_START } from "../../src/@types/polls"; import { EventTimelineSet } from "../../src/models/event-timeline-set"; import { MatrixEvent, MatrixEventEvent } from "../../src/models/event"; import { Room } from "../../src/models/room"; diff --git a/src/@types/beacon.ts b/src/@types/beacon.ts index 4f2b257a7..e6bfb8ff9 100644 --- a/src/@types/beacon.ts +++ b/src/@types/beacon.ts @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { RELATES_TO_RELATIONSHIP, REFERENCE_RELATION } from "matrix-events-sdk"; - +import { RelatesToRelationship, REFERENCE_RELATION } from "./extensible_events"; import { UnstableValue } from "../NamespacedValue"; import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location"; @@ -138,4 +137,4 @@ export type MBeaconEventContent = MLocationEvent & // timestamp when location was taken MTimestampEvent & // relates to a beacon_info event - RELATES_TO_RELATIONSHIP; + RelatesToRelationship; diff --git a/src/@types/extensible_events.ts b/src/@types/extensible_events.ts index 51e9d3c3c..db9ea1806 100644 --- a/src/@types/extensible_events.ts +++ b/src/@types/extensible_events.ts @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2023 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. @@ -14,8 +14,138 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Types for MSC1767: Extensible events in Matrix +import { EitherAnd, NamespacedValue, Optional, UnstableValue } from "matrix-events-sdk"; -import { UnstableValue } from "../NamespacedValue"; +import { isProvided } from "../extensible_events_v1/utilities"; -export const TEXT_NODE_TYPE = new UnstableValue("m.text", "org.matrix.msc1767.text"); +// Types and utilities for MSC1767: Extensible events (version 1) in Matrix + +/** + * Represents the stable and unstable values of a given namespace. + */ +export type TSNamespace = N extends NamespacedValue + ? TSNamespaceValue | TSNamespaceValue + : never; + +/** + * Represents a namespaced value, if the value is a string. Used to extract provided types + * from a TSNamespace (in cases where only stable *or* unstable is provided). + */ +export type TSNamespaceValue = V extends string ? V : never; + +/** + * Creates a type which is V when T is `never`, otherwise T. + */ +// See https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 for details on the array syntax. +export type DefaultNever = [T] extends [never] ? V : T; + +/** + * The namespaced value for m.message + */ +export const M_MESSAGE = new UnstableValue("m.message", "org.matrix.msc1767.message"); + +/** + * An m.message event rendering + */ +export interface IMessageRendering { + body: string; + mimetype?: string; +} + +/** + * The content for an m.message event + */ +export type ExtensibleMessageEventContent = EitherAnd< + { [M_MESSAGE.name]: IMessageRendering[] }, + { [M_MESSAGE.altName]: IMessageRendering[] } +>; + +/** + * The namespaced value for m.text + */ +export const M_TEXT = new UnstableValue("m.text", "org.matrix.msc1767.text"); + +/** + * The content for an m.text event + */ +export type TextEventContent = EitherAnd<{ [M_TEXT.name]: string }, { [M_TEXT.altName]: string }>; + +/** + * The namespaced value for m.html + */ +export const M_HTML = new UnstableValue("m.html", "org.matrix.msc1767.html"); + +/** + * The content for an m.html event + */ +export type HtmlEventContent = EitherAnd<{ [M_HTML.name]: string }, { [M_HTML.altName]: string }>; + +/** + * The content for an m.message, m.text, or m.html event + */ +export type ExtensibleAnyMessageEventContent = ExtensibleMessageEventContent | TextEventContent | HtmlEventContent; + +/** + * The namespaced value for an m.reference relation + */ +export const REFERENCE_RELATION = new NamespacedValue("m.reference"); + +/** + * Represents any relation type + */ +export type AnyRelation = TSNamespace | string; + +/** + * An m.relates_to relationship + */ +export type RelatesToRelationship = { + "m.relates_to": { + // See https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 for array syntax + rel_type: [R] extends [never] ? AnyRelation : TSNamespace; + event_id: string; + } & DefaultNever; +}; + +/** + * Partial types for a Matrix Event. + */ +export interface IPartialEvent { + type: string; + content: TContent; +} + +/** + * Represents a potentially namespaced event type. + */ +export type ExtensibleEventType = NamespacedValue | string; + +/** + * Determines if two event types are the same, including namespaces. + * @param given - The given event type. This will be compared + * against the expected type. + * @param expected - The expected event type. + * @returns True if the given type matches the expected type. + */ +export function isEventTypeSame( + given: Optional, + expected: Optional, +): boolean { + if (typeof given === "string") { + if (typeof expected === "string") { + return expected === given; + } else { + return (expected as NamespacedValue).matches(given as string); + } + } else { + if (typeof expected === "string") { + return (given as NamespacedValue).matches(expected as string); + } else { + const expectedNs = expected as NamespacedValue; + const givenNs = given as NamespacedValue; + return ( + expectedNs.matches(givenNs.name) || + (isProvided(givenNs.altName) && expectedNs.matches(givenNs.altName!)) + ); + } + } +} diff --git a/src/@types/location.ts b/src/@types/location.ts index 023557b77..d1a826fd8 100644 --- a/src/@types/location.ts +++ b/src/@types/location.ts @@ -18,7 +18,7 @@ limitations under the License. import { EitherAnd } from "matrix-events-sdk"; import { UnstableValue } from "../NamespacedValue"; -import { TEXT_NODE_TYPE } from "./extensible_events"; +import { M_TEXT } from "./extensible_events"; export enum LocationAssetType { Self = "m.self", @@ -50,7 +50,7 @@ export type MLocationEvent = EitherAnd< { [M_LOCATION.altName]: MLocationContent } >; -export type MTextEvent = EitherAnd<{ [TEXT_NODE_TYPE.name]: string }, { [TEXT_NODE_TYPE.altName]: string }>; +export type MTextEvent = EitherAnd<{ [M_TEXT.name]: string }, { [M_TEXT.altName]: string }>; /* From the spec at: * https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md diff --git a/src/@types/polls.ts b/src/@types/polls.ts new file mode 100644 index 000000000..3b06f932a --- /dev/null +++ b/src/@types/polls.ts @@ -0,0 +1,119 @@ +/* +Copyright 2022 - 2023 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, UnstableValue } from "matrix-events-sdk"; + +import { + ExtensibleAnyMessageEventContent, + REFERENCE_RELATION, + RelatesToRelationship, + TSNamespace, +} from "./extensible_events"; + +/** + * Identifier for a disclosed poll. + */ +export const M_POLL_KIND_DISCLOSED = new UnstableValue("m.poll.disclosed", "org.matrix.msc3381.poll.disclosed"); + +/** + * Identifier for an undisclosed poll. + */ +export const M_POLL_KIND_UNDISCLOSED = new UnstableValue("m.poll.undisclosed", "org.matrix.msc3381.poll.undisclosed"); + +/** + * Any poll kind. + */ +export type PollKind = TSNamespace | TSNamespace | string; + +/** + * Known poll kind namespaces. + */ +export type KnownPollKind = typeof M_POLL_KIND_DISCLOSED | typeof M_POLL_KIND_UNDISCLOSED; + +/** + * The namespaced value for m.poll.start + */ +export const M_POLL_START = new UnstableValue("m.poll.start", "org.matrix.msc3381.poll.start"); + +/** + * The m.poll.start type within event content + */ +export type PollStartSubtype = { + question: ExtensibleAnyMessageEventContent; + kind: PollKind; + max_selections?: number; // default 1, always positive + answers: PollAnswer[]; +}; + +/** + * A poll answer. + */ +export type PollAnswer = ExtensibleAnyMessageEventContent & { id: string }; + +/** + * The event definition for an m.poll.start event (in content) + */ +export type PollStartEvent = EitherAnd< + { [M_POLL_START.name]: PollStartSubtype }, + { [M_POLL_START.altName]: PollStartSubtype } +>; + +/** + * The content for an m.poll.start event + */ +export type PollStartEventContent = PollStartEvent & ExtensibleAnyMessageEventContent; + +/** + * The namespaced value for m.poll.response + */ +export const M_POLL_RESPONSE = new UnstableValue("m.poll.response", "org.matrix.msc3381.poll.response"); + +/** + * The m.poll.response type within event content + */ +export type PollResponseSubtype = { + answers: string[]; +}; + +/** + * The event definition for an m.poll.response event (in content) + */ +export type PollResponseEvent = EitherAnd< + { [M_POLL_RESPONSE.name]: PollResponseSubtype }, + { [M_POLL_RESPONSE.altName]: PollResponseSubtype } +>; + +/** + * The content for an m.poll.response event + */ +export type PollResponseEventContent = PollResponseEvent & RelatesToRelationship; + +/** + * The namespaced value for m.poll.end + */ +export const M_POLL_END = new UnstableValue("m.poll.end", "org.matrix.msc3381.poll.end"); + +/** + * The event definition for an m.poll.end event (in content) + */ +export type PollEndEvent = EitherAnd<{ [M_POLL_END.name]: {} }, { [M_POLL_END.altName]: {} }>; + +/** + * The content for an m.poll.end event + */ +export type PollEndEventContent = PollEndEvent & + RelatesToRelationship & + ExtensibleAnyMessageEventContent; diff --git a/src/@types/topic.ts b/src/@types/topic.ts index 5b66e07c4..04d146406 100644 --- a/src/@types/topic.ts +++ b/src/@types/topic.ts @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EitherAnd, IMessageRendering } from "matrix-events-sdk"; +import { EitherAnd } from "matrix-events-sdk"; import { UnstableValue } from "../NamespacedValue"; +import { IMessageRendering } from "./extensible_events"; /** * Extensible topic event type based on MSC3765 diff --git a/src/content-helpers.ts b/src/content-helpers.ts index 03bacbb9f..88bcd90ef 100644 --- a/src/content-helpers.ts +++ b/src/content-helpers.ts @@ -14,11 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { isProvided, REFERENCE_RELATION } from "matrix-events-sdk"; - import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent } from "./@types/beacon"; import { MsgType } from "./@types/event"; -import { TEXT_NODE_TYPE } from "./@types/extensible_events"; +import { M_TEXT, REFERENCE_RELATION } from "./@types/extensible_events"; +import { isProvided } from "./extensible_events_v1/utilities"; import { M_ASSET, LocationAssetType, @@ -160,7 +159,7 @@ export const makeLocationContent = ( [M_ASSET.name]: { type: assetType || LocationAssetType.Self, }, - [TEXT_NODE_TYPE.name]: defaultedText, + [M_TEXT.name]: defaultedText, ...timestampEvent, } as LegacyLocationEventContent & MLocationEventContent; }; @@ -173,7 +172,7 @@ export const parseLocationEvent = (wireEventContent: LocationEventWireContent): const location = M_LOCATION.findIn(wireEventContent); const asset = M_ASSET.findIn(wireEventContent); const timestamp = M_TIMESTAMP.findIn(wireEventContent); - const text = TEXT_NODE_TYPE.findIn(wireEventContent); + const text = M_TEXT.findIn(wireEventContent); const geoUri = location?.uri ?? wireEventContent?.geo_uri; const description = location?.description; diff --git a/src/extensible_events_v1/ExtensibleEvent.ts b/src/extensible_events_v1/ExtensibleEvent.ts new file mode 100644 index 000000000..049659251 --- /dev/null +++ b/src/extensible_events_v1/ExtensibleEvent.ts @@ -0,0 +1,58 @@ +/* +Copyright 2021 - 2023 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 { ExtensibleEventType, IPartialEvent } from "../@types/extensible_events"; + +/** + * Represents an Extensible Event in Matrix. + */ +export abstract class ExtensibleEvent { + protected constructor(public readonly wireFormat: IPartialEvent) {} + + /** + * Shortcut to wireFormat.content + */ + public get wireContent(): TContent { + return this.wireFormat.content; + } + + /** + * Serializes the event into a format which can be used to send the + * event to the room. + * @returns The serialized event. + */ + public abstract serialize(): IPartialEvent; + + /** + * Determines if this event is equivalent to the provided event type. + * This is recommended over `instanceof` checks due to issues in the JS + * runtime (and layering of dependencies in some projects). + * + * Implementations should pass this check off to their super classes + * if their own checks fail. Some primary implementations do not extend + * fallback classes given they support the primary type first. Thus, + * those classes may return false if asked about their fallback + * representation. + * + * Note that this only checks primary event types: legacy events, like + * m.room.message, should/will fail this check. + * @param primaryEventType - The (potentially namespaced) event + * type. + * @returns True if this event *could* be represented as the + * given type. + */ + public abstract isEquivalentTo(primaryEventType: ExtensibleEventType): boolean; +} diff --git a/src/extensible_events_v1/InvalidEventError.ts b/src/extensible_events_v1/InvalidEventError.ts new file mode 100644 index 000000000..12e59ad62 --- /dev/null +++ b/src/extensible_events_v1/InvalidEventError.ts @@ -0,0 +1,24 @@ +/* +Copyright 2022 - 2023 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. +*/ + +/** + * Thrown when an event is unforgivably unparsable. + */ +export class InvalidEventError extends Error { + public constructor(message: string) { + super(message); + } +} diff --git a/src/extensible_events_v1/MessageEvent.ts b/src/extensible_events_v1/MessageEvent.ts new file mode 100644 index 000000000..3d049f458 --- /dev/null +++ b/src/extensible_events_v1/MessageEvent.ts @@ -0,0 +1,145 @@ +/* +Copyright 2022 - 2023 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 { Optional } from "matrix-events-sdk"; + +import { ExtensibleEvent } from "./ExtensibleEvent"; +import { + ExtensibleEventType, + IMessageRendering, + IPartialEvent, + isEventTypeSame, + M_HTML, + M_MESSAGE, + ExtensibleAnyMessageEventContent, + M_TEXT, +} from "../@types/extensible_events"; +import { isOptionalAString, isProvided } from "./utilities"; +import { InvalidEventError } from "./InvalidEventError"; + +/** + * Represents a message event. Message events are the simplest form of event with + * just text (optionally of different mimetypes, like HTML). + * + * Message events can additionally be an Emote or Notice, though typically those + * are represented as EmoteEvent and NoticeEvent respectively. + */ +export class MessageEvent 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 message. Note that this is the same + * format as an m.message 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 MessageEvent from a pure format. Note that the event is + * *not* parsed here: it will be treated as a literal m.message primary + * typed event. + * @param wireFormat - The event. + */ + public constructor(wireFormat: IPartialEvent) { + super(wireFormat); + + const mmessage = M_MESSAGE.findIn(this.wireContent); + const mtext = M_TEXT.findIn(this.wireContent); + const mhtml = M_HTML.findIn(this.wireContent); + if (isProvided(mmessage)) { + if (!Array.isArray(mmessage)) { + throw new InvalidEventError("m.message contents must be an array"); + } + const text = mmessage.find((r) => !isProvided(r.mimetype) || r.mimetype === "text/plain"); + const html = mmessage.find((r) => r.mimetype === "text/html"); + + if (!text) throw new InvalidEventError("m.message is missing a plain text representation"); + + this.text = text.body; + this.html = html?.body; + this.renderings = mmessage; + } else if (isOptionalAString(mtext)) { + this.text = mtext; + this.html = mhtml; + this.renderings = [{ body: mtext, mimetype: "text/plain" }]; + if (this.html) { + this.renderings.push({ body: this.html, mimetype: "text/html" }); + } + } else { + throw new InvalidEventError("Missing textual representation for event"); + } + } + + public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean { + return isEventTypeSame(primaryEventType, M_MESSAGE); + } + + protected serializeMMessageOnly(): ExtensibleAnyMessageEventContent { + let messageRendering: ExtensibleAnyMessageEventContent = { + [M_MESSAGE.name]: this.renderings, + }; + + // Use the shorthand if it's just a simple text event + if (this.renderings.length === 1) { + const mime = this.renderings[0].mimetype; + if (mime === undefined || mime === "text/plain") { + messageRendering = { + [M_TEXT.name]: this.renderings[0].body, + }; + } + } + + return messageRendering; + } + + public serialize(): IPartialEvent { + return { + type: "m.room.message", + content: { + ...this.serializeMMessageOnly(), + body: this.text, + msgtype: "m.text", + format: this.html ? "org.matrix.custom.html" : undefined, + formatted_body: this.html ?? undefined, + }, + }; + } + + /** + * Creates a new MessageEvent from text and HTML. + * @param text - The text. + * @param html - Optional HTML. + * @returns The representative message event. + */ + public static from(text: string, html?: string): MessageEvent { + return new MessageEvent({ + type: M_MESSAGE.name, + content: { + [M_TEXT.name]: text, + [M_HTML.name]: html, + }, + }); + } +} diff --git a/src/extensible_events_v1/PollEndEvent.ts b/src/extensible_events_v1/PollEndEvent.ts new file mode 100644 index 000000000..243f1906a --- /dev/null +++ b/src/extensible_events_v1/PollEndEvent.ts @@ -0,0 +1,97 @@ +/* +Copyright 2022 - 2023 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 { + ExtensibleEventType, + IPartialEvent, + isEventTypeSame, + M_TEXT, + REFERENCE_RELATION, +} from "../@types/extensible_events"; +import { M_POLL_END, PollEndEventContent } from "../@types/polls"; +import { ExtensibleEvent } from "./ExtensibleEvent"; +import { InvalidEventError } from "./InvalidEventError"; +import { MessageEvent } from "./MessageEvent"; + +/** + * Represents a poll end/closure event. + */ +export class PollEndEvent extends ExtensibleEvent { + /** + * The poll start event ID referenced by the response. + */ + public readonly pollEventId: string; + + /** + * The closing message for the event. + */ + public readonly closingMessage: MessageEvent; + + /** + * Creates a new PollEndEvent from a pure format. Note that the event is *not* + * parsed here: it will be treated as a literal m.poll.response primary typed event. + * @param wireFormat - The event. + */ + public constructor(wireFormat: IPartialEvent) { + super(wireFormat); + + const rel = this.wireContent["m.relates_to"]; + if (!REFERENCE_RELATION.matches(rel?.rel_type) || typeof rel?.event_id !== "string") { + throw new InvalidEventError("Relationship must be a reference to an event"); + } + + this.pollEventId = rel.event_id; + this.closingMessage = new MessageEvent(this.wireFormat); + } + + public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean { + return isEventTypeSame(primaryEventType, M_POLL_END); + } + + public serialize(): IPartialEvent { + return { + type: M_POLL_END.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: this.pollEventId, + }, + [M_POLL_END.name]: {}, + ...this.closingMessage.serialize().content, + }, + }; + } + + /** + * Creates a new PollEndEvent from a poll event ID. + * @param pollEventId - The poll start event ID. + * @param message - A closing message, typically revealing the top answer. + * @returns The representative poll closure event. + */ + public static from(pollEventId: string, message: string): PollEndEvent { + return new PollEndEvent({ + type: M_POLL_END.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: pollEventId, + }, + [M_POLL_END.name]: {}, + [M_TEXT.name]: message, + }, + }); + } +} diff --git a/src/extensible_events_v1/PollResponseEvent.ts b/src/extensible_events_v1/PollResponseEvent.ts new file mode 100644 index 000000000..a61fc2e7c --- /dev/null +++ b/src/extensible_events_v1/PollResponseEvent.ts @@ -0,0 +1,143 @@ +/* +Copyright 2022 - 2023 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 { M_POLL_RESPONSE, PollResponseEventContent, PollResponseSubtype } from "../@types/polls"; +import { ExtensibleEventType, IPartialEvent, isEventTypeSame, REFERENCE_RELATION } from "../@types/extensible_events"; +import { InvalidEventError } from "./InvalidEventError"; +import { PollStartEvent } from "./PollStartEvent"; + +/** + * Represents a poll response event. + */ +export class PollResponseEvent extends ExtensibleEvent { + private internalAnswerIds: string[] = []; + private internalSpoiled = false; + + /** + * The provided answers for the poll. Note that this may be falsy/unpredictable if + * the `spoiled` property is true. + */ + public get answerIds(): string[] { + return this.internalAnswerIds; + } + + /** + * The poll start event ID referenced by the response. + */ + public readonly pollEventId: string; + + /** + * Whether the vote is spoiled. + */ + public get spoiled(): boolean { + return this.internalSpoiled; + } + + /** + * Creates a new PollResponseEvent from a pure format. Note that the event is *not* + * parsed here: it will be treated as a literal m.poll.response primary typed event. + * + * To validate the response against a poll, call `validateAgainst` after creation. + * @param wireFormat - The event. + */ + public constructor(wireFormat: IPartialEvent) { + super(wireFormat); + + const rel = this.wireContent["m.relates_to"]; + if (!REFERENCE_RELATION.matches(rel?.rel_type) || typeof rel?.event_id !== "string") { + throw new InvalidEventError("Relationship must be a reference to an event"); + } + + this.pollEventId = rel.event_id; + this.validateAgainst(null); + } + + /** + * Validates the poll response using the poll start event as a frame of reference. This + * is used to determine if the vote is spoiled, whether the answers are valid, etc. + * @param poll - The poll start event. + */ + public validateAgainst(poll: PollStartEvent | null): void { + const response = M_POLL_RESPONSE.findIn(this.wireContent); + if (!Array.isArray(response?.answers)) { + this.internalSpoiled = true; + this.internalAnswerIds = []; + return; + } + + let answers = response?.answers ?? []; + if (answers.some((a) => typeof a !== "string") || answers.length === 0) { + this.internalSpoiled = true; + this.internalAnswerIds = []; + return; + } + + if (poll) { + if (answers.some((a) => !poll.answers.some((pa) => pa.id === a))) { + this.internalSpoiled = true; + this.internalAnswerIds = []; + return; + } + + answers = answers.slice(0, poll.maxSelections); + } + + this.internalAnswerIds = answers; + this.internalSpoiled = false; + } + + public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean { + return isEventTypeSame(primaryEventType, M_POLL_RESPONSE); + } + + public serialize(): IPartialEvent { + return { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: this.pollEventId, + }, + [M_POLL_RESPONSE.name]: { + answers: this.spoiled ? undefined : this.answerIds, + }, + }, + }; + } + + /** + * Creates a new PollResponseEvent from a set of answers. To spoil the vote, pass an empty + * answers array. + * @param answers - The user's answers. Should be valid from a poll's answer IDs. + * @param pollEventId - The poll start event ID. + * @returns The representative poll response event. + */ + public static from(answers: string[], pollEventId: string): PollResponseEvent { + return new PollResponseEvent({ + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: pollEventId, + }, + [M_POLL_RESPONSE.name]: { + answers: answers, + }, + }, + }); + } +} diff --git a/src/extensible_events_v1/PollStartEvent.ts b/src/extensible_events_v1/PollStartEvent.ts new file mode 100644 index 000000000..8584bf9e1 --- /dev/null +++ b/src/extensible_events_v1/PollStartEvent.ts @@ -0,0 +1,207 @@ +/* +Copyright 2022 - 2023 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 { NamespacedValue } from "matrix-events-sdk"; + +import { MessageEvent } from "./MessageEvent"; +import { ExtensibleEventType, IPartialEvent, isEventTypeSame, M_TEXT } from "../@types/extensible_events"; +import { + KnownPollKind, + M_POLL_KIND_DISCLOSED, + M_POLL_KIND_UNDISCLOSED, + M_POLL_START, + PollStartEventContent, + PollStartSubtype, + PollAnswer, +} from "../@types/polls"; +import { InvalidEventError } from "./InvalidEventError"; +import { ExtensibleEvent } from "./ExtensibleEvent"; + +/** + * Represents a poll answer. Note that this is represented as a subtype and is + * not registered as a parsable event - it is implied for usage exclusively + * within the PollStartEvent parsing. + */ +export class PollAnswerSubevent extends MessageEvent { + /** + * The answer ID. + */ + public readonly id: string; + + public constructor(wireFormat: IPartialEvent) { + super(wireFormat); + + const id = wireFormat.content.id; + if (!id || typeof id !== "string") { + throw new InvalidEventError("Answer ID must be a non-empty string"); + } + this.id = id; + } + + public serialize(): IPartialEvent { + return { + type: "org.matrix.sdk.poll.answer", + content: { + id: this.id, + ...this.serializeMMessageOnly(), + }, + }; + } + + /** + * Creates a new PollAnswerSubevent from ID and text. + * @param id - The answer ID (unique within the poll). + * @param text - The text. + * @returns The representative answer. + */ + public static from(id: string, text: string): PollAnswerSubevent { + return new PollAnswerSubevent({ + type: "org.matrix.sdk.poll.answer", + content: { + id: id, + [M_TEXT.name]: text, + }, + }); + } +} + +/** + * Represents a poll start event. + */ +export class PollStartEvent extends ExtensibleEvent { + /** + * The question being asked, as a MessageEvent node. + */ + public readonly question: MessageEvent; + + /** + * The interpreted kind of poll. Note that this will infer a value that is known to the + * SDK rather than verbatim - this means unknown types will be represented as undisclosed + * polls. + * + * To get the raw kind, use rawKind. + */ + public readonly kind: KnownPollKind; + + /** + * The true kind as provided by the event sender. Might not be valid. + */ + public readonly rawKind: string; + + /** + * The maximum number of selections a user is allowed to make. + */ + public readonly maxSelections: number; + + /** + * The possible answers for the poll. + */ + public readonly answers: PollAnswerSubevent[]; + + /** + * Creates a new PollStartEvent from a pure format. Note that the event is *not* + * parsed here: it will be treated as a literal m.poll.start primary typed event. + * @param wireFormat - The event. + */ + public constructor(wireFormat: IPartialEvent) { + super(wireFormat); + + const poll = M_POLL_START.findIn(this.wireContent); + + if (!poll?.question) { + throw new InvalidEventError("A question is required"); + } + + this.question = new MessageEvent({ type: "org.matrix.sdk.poll.question", content: poll.question }); + + this.rawKind = poll.kind; + if (M_POLL_KIND_DISCLOSED.matches(this.rawKind)) { + this.kind = M_POLL_KIND_DISCLOSED; + } else { + this.kind = M_POLL_KIND_UNDISCLOSED; // default & assumed value + } + + this.maxSelections = + Number.isFinite(poll.max_selections) && poll.max_selections! > 0 ? poll.max_selections! : 1; + + if (!Array.isArray(poll.answers)) { + throw new InvalidEventError("Poll answers must be an array"); + } + const answers = poll.answers.slice(0, 20).map( + (a) => + new PollAnswerSubevent({ + type: "org.matrix.sdk.poll.answer", + content: a, + }), + ); + if (answers.length <= 0) { + throw new InvalidEventError("No answers available"); + } + this.answers = answers; + } + + public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean { + return isEventTypeSame(primaryEventType, M_POLL_START); + } + + public serialize(): IPartialEvent { + return { + type: M_POLL_START.name, + content: { + [M_POLL_START.name]: { + question: this.question.serialize().content, + kind: this.rawKind, + max_selections: this.maxSelections, + answers: this.answers.map((a) => a.serialize().content), + }, + [M_TEXT.name]: `${this.question.text}\n${this.answers.map((a, i) => `${i + 1}. ${a.text}`).join("\n")}`, + }, + }; + } + + /** + * Creates a new PollStartEvent from question, answers, and metadata. + * @param question - The question to ask. + * @param answers - The answers. Should be unique within each other. + * @param kind - The kind of poll. + * @param maxSelections - The maximum number of selections. Must be 1 or higher. + * @returns The representative poll start event. + */ + public static from( + question: string, + answers: string[], + kind: KnownPollKind | string, + maxSelections = 1, + ): PollStartEvent { + return new PollStartEvent({ + type: M_POLL_START.name, + content: { + [M_TEXT.name]: question, // unused by parsing + [M_POLL_START.name]: { + question: { [M_TEXT.name]: question }, + kind: kind instanceof NamespacedValue ? kind.name : kind, + max_selections: maxSelections, + answers: answers.map((a) => ({ id: makeId(), [M_TEXT.name]: a })), + }, + }, + }); + } +} + +const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +function makeId(): string { + return [...Array(16)].map(() => LETTERS.charAt(Math.floor(Math.random() * LETTERS.length))).join(""); +} diff --git a/src/extensible_events_v1/utilities.ts b/src/extensible_events_v1/utilities.ts new file mode 100644 index 000000000..0660442ec --- /dev/null +++ b/src/extensible_events_v1/utilities.ts @@ -0,0 +1,35 @@ +/* +Copyright 2021 - 2023 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 { Optional } from "matrix-events-sdk"; + +/** + * Determines if the given optional was provided a value. + * @param s - The optional to test. + * @returns True if the value is defined. + */ +export function isProvided(s: Optional): boolean { + return s !== null && s !== undefined; +} + +/** + * Determines if the given optional string is a defined string. + * @param s - The input string. + * @returns True if the input is a defined string. + */ +export function isOptionalAString(s: Optional): s is string { + return isProvided(s) && typeof s === "string"; +}