From 33f3457858d916ac9be229b96357abdc865734b2 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 11 Jan 2022 19:48:34 -0700 Subject: [PATCH 1/4] Add polls support --- src/ExtensibleEvents.ts | 5 + src/events/PollEndEvent.ts | 90 +++++++ src/events/PollResponseEvent.ts | 140 ++++++++++ src/events/PollStartEvent.ts | 191 ++++++++++++++ src/events/poll_types.ts | 107 ++++++++ src/events/relationship_types.ts | 39 +++ src/index.ts | 6 + src/interpreters/modern/MPoll.ts | 44 ++++ src/types.ts | 21 ++ test/ExtensibleEvents.test.ts | 68 +++++ test/events/PollEndEvent.test.ts | 110 ++++++++ test/events/PollResponseEvent.test.ts | 277 ++++++++++++++++++++ test/events/PollStartEvent.test.ts | 339 +++++++++++++++++++++++++ test/interpreters/modern/MPoll.test.ts | 102 ++++++++ 14 files changed, 1539 insertions(+) create mode 100644 src/events/PollEndEvent.ts create mode 100644 src/events/PollResponseEvent.ts create mode 100644 src/events/PollStartEvent.ts create mode 100644 src/events/poll_types.ts create mode 100644 src/events/relationship_types.ts create mode 100644 src/interpreters/modern/MPoll.ts create mode 100644 test/events/PollEndEvent.test.ts create mode 100644 test/events/PollResponseEvent.test.ts create mode 100644 test/events/PollStartEvent.test.ts create mode 100644 test/interpreters/modern/MPoll.test.ts diff --git a/src/ExtensibleEvents.ts b/src/ExtensibleEvents.ts index 0d6615a..36567b5 100644 --- a/src/ExtensibleEvents.ts +++ b/src/ExtensibleEvents.ts @@ -23,6 +23,8 @@ import { InvalidEventError } from "./InvalidEventError"; import { LEGACY_M_ROOM_MESSAGE, parseMRoomMessage } from "./interpreters/legacy/MRoomMessage"; import { parseMMessage } from "./interpreters/modern/MMessage"; import { M_EMOTE, M_MESSAGE, M_NOTICE } from "./events/message_types"; +import { M_POLL_END, M_POLL_RESPONSE, M_POLL_START } from "./events/poll_types"; +import { parseMPoll } from "./interpreters/modern/MPoll"; export type EventInterpreter = (wireEvent: IPartialEvent) => Optional; @@ -41,6 +43,9 @@ export class ExtensibleEvents { [M_MESSAGE, parseMMessage], [M_EMOTE, parseMMessage], [M_NOTICE, parseMMessage], + [M_POLL_START, parseMPoll], + [M_POLL_RESPONSE, parseMPoll], + [M_POLL_END, parseMPoll], ]); private _unknownInterpretOrder: NamespacedValue[] = [ diff --git a/src/events/PollEndEvent.ts b/src/events/PollEndEvent.ts new file mode 100644 index 0000000..787053f --- /dev/null +++ b/src/events/PollEndEvent.ts @@ -0,0 +1,90 @@ +/* +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 { M_POLL_END_EVENT_CONTENT, M_POLL_END } from "./poll_types"; +import { IPartialEvent } from "../IPartialEvent"; +import { InvalidEventError } from "../InvalidEventError"; +import { PollStartEvent } from "./PollStartEvent"; +import { REFERENCE_RELATION } from "./relationship_types"; +import { MessageEvent } from "./MessageEvent"; +import { M_TEXT } from "./message_types"; + +/** + * 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 {IPartialEvent} 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 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 {string} pollEventId The poll start event ID. + * @param {string} message A closing message, typically revealing the top answer. + * @returns {PollStartEvent} 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/events/PollResponseEvent.ts b/src/events/PollResponseEvent.ts new file mode 100644 index 0000000..4d51f90 --- /dev/null +++ b/src/events/PollResponseEvent.ts @@ -0,0 +1,140 @@ +/* +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 { M_POLL_RESPONSE, M_POLL_RESPONSE_EVENT_CONTENT, M_POLL_RESPONSE_SUBTYPE } from "./poll_types"; +import { IPartialEvent } from "../IPartialEvent"; +import { InvalidEventError } from "../InvalidEventError"; +import { PollStartEvent } from "./PollStartEvent"; +import { REFERENCE_RELATION } from "./relationship_types"; + +/** + * Represents a poll response event. + */ +export class PollResponseEvent extends ExtensibleEvent { + private internalAnswerIds: string[]; + private internalSpoiled: boolean; + + /** + * 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 {IPartialEvent} 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 {PollStartEvent} poll The poll start event. + */ + public validateAgainst(poll: PollStartEvent) { + 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 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 {string} answers The user's answers. Should be valid from a poll's answer IDs. + * @param {string} pollEventId The poll start event ID. + * @returns {PollStartEvent} 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/events/PollStartEvent.ts b/src/events/PollStartEvent.ts new file mode 100644 index 0000000..0bb2370 --- /dev/null +++ b/src/events/PollStartEvent.ts @@ -0,0 +1,191 @@ +/* +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 { + POLL_ANSWER, + M_POLL_START_EVENT_CONTENT, + M_POLL_KIND_DISCLOSED, + M_POLL_KIND_UNDISCLOSED, + M_POLL_START, + M_POLL_START_SUBTYPE, + KNOWN_POLL_KIND, +} from "./poll_types"; +import { IPartialEvent } from "../IPartialEvent"; +import { MessageEvent } from "./MessageEvent"; +import { M_MESSAGE, M_TEXT } from "./message_types"; +import { InvalidEventError } from "../InvalidEventError"; +import { NamespacedValue } from "../NamespacedValue"; + +/** + * 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, + [M_MESSAGE.name]: this.renderings, + }, + }; + } + + /** + * Creates a new PollAnswerSubevent from ID and text. + * @param {string} id The answer ID (unique within the poll). + * @param {string} text The text. + * @returns {PollAnswerSubevent} 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: KNOWN_POLL_KIND; + + /** + * 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 {IPartialEvent} 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 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 {string} question The question to ask. + * @param {string} answers The answers. Should be unique within each other. + * @param {KNOWN_POLL_KIND|string} kind The kind of poll. + * @param {number} maxSelections The maximum number of selections. Must be 1 or higher. + * @returns {PollStartEvent} The representative poll start event. + */ + public static from(question: string, answers: string[], kind: KNOWN_POLL_KIND | 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, i) => ({id: `${i + 1}-${a}`, [M_TEXT.name]: a})), + }, + }, + }); + } +} diff --git a/src/events/poll_types.ts b/src/events/poll_types.ts new file mode 100644 index 0000000..b4ee2a0 --- /dev/null +++ b/src/events/poll_types.ts @@ -0,0 +1,107 @@ +/* +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, TSNamespace } from "../types"; +import { M_MESSAGE_EVENT_CONTENT } from "./message_types"; +import { REFERENCE_RELATION, RELATES_TO_RELATIONSHIP } from "./relationship_types"; + +/** + * Identifier for a disclosed poll. + */ +export const M_POLL_KIND_DISCLOSED = new UnstableValue("m.poll.disclosed", "org.matrix.msc3381.disclosed"); + +/** + * Identifier for an undisclosed poll. + */ +export const M_POLL_KIND_UNDISCLOSED = new UnstableValue("m.poll.undisclosed", "org.matrix.msc3381.undisclosed"); + +/** + * Any poll kind. + */ +export type POLL_KIND = TSNamespace | TSNamespace | string; + +/** + * Known poll kind namespaces. + */ +export type KNOWN_POLL_KIND = (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 M_POLL_START_SUBTYPE = { + question: M_MESSAGE_EVENT_CONTENT, + kind: POLL_KIND; + max_selections?: number; // default 1, always positive + answers: POLL_ANSWER[], +}; + +/** + * A poll answer. + */ +export type POLL_ANSWER = M_MESSAGE_EVENT_CONTENT & {id: string}; + +/** + * The event definition for an m.poll.start event (in content) + */ +export type M_POLL_START_EVENT = EitherAnd<{ [M_POLL_START.name]: M_POLL_START_SUBTYPE }, { [M_POLL_START.altName]: M_POLL_START_SUBTYPE }>; + +/** + * The content for an m.poll.start event + */ +export type M_POLL_START_EVENT_CONTENT = M_POLL_START_EVENT & M_MESSAGE_EVENT_CONTENT; + +/** + * 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 M_POLL_RESPONSE_SUBTYPE = { + answers: string[]; +}; + +/** + * The event definition for an m.poll.response event (in content) + */ +export type M_POLL_RESPONSE_EVENT = EitherAnd<{ [M_POLL_RESPONSE.name]: M_POLL_RESPONSE_SUBTYPE }, { [M_POLL_RESPONSE.altName]: M_POLL_RESPONSE_SUBTYPE }>; + +/** + * The content for an m.poll.response event + */ +export type M_POLL_RESPONSE_EVENT_CONTENT = M_POLL_RESPONSE_EVENT & RELATES_TO_RELATIONSHIP; + +/** + * 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 M_POLL_END_EVENT = EitherAnd<{ [M_POLL_END.name]: {} }, { [M_POLL_END.altName]: {} }>; + +/** + * The content for an m.poll.end event + */ +export type M_POLL_END_EVENT_CONTENT = M_POLL_END_EVENT & RELATES_TO_RELATIONSHIP & M_MESSAGE_EVENT_CONTENT; diff --git a/src/events/relationship_types.ts b/src/events/relationship_types.ts new file mode 100644 index 0000000..fa0c4fe --- /dev/null +++ b/src/events/relationship_types.ts @@ -0,0 +1,39 @@ +/* +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 { NamespacedValue } from "../NamespacedValue"; +import { DefaultNever, TSNamespace } from "../types"; + +/** + * The namespaced value for an m.reference relation + */ +export const REFERENCE_RELATION = new NamespacedValue("m.reference"); + +/** + * Represents any relation type + */ +export type ANY_RELATION = TSNamespace | string; + +/** + * An m.relates_to relationship + */ +export type RELATES_TO_RELATIONSHIP = { + "m.relates_to": { + // See https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 for array syntax + rel_type: [R] extends [never] ? ANY_RELATION : TSNamespace; + event_id: string; + } & DefaultNever; +}; diff --git a/src/index.ts b/src/index.ts index a901fdc..86996f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,10 +30,16 @@ export * from "./interpreters/legacy/MRoomMessage"; // Modern (or not-legacy) interpreters export * from "./interpreters/modern/MMessage"; +export * from "./interpreters/modern/MPoll"; // Event objects +export * from "./events/relationship_types"; export * from "./events/ExtensibleEvent"; export * from "./events/message_types"; export * from "./events/MessageEvent"; export * from "./events/EmoteEvent"; export * from "./events/NoticeEvent"; +export * from "./events/poll_types"; +export * from "./events/PollStartEvent"; +export * from "./events/PollResponseEvent"; +export * from "./events/PollEndEvent"; diff --git a/src/interpreters/modern/MPoll.ts b/src/interpreters/modern/MPoll.ts new file mode 100644 index 0000000..4e5ad31 --- /dev/null +++ b/src/interpreters/modern/MPoll.ts @@ -0,0 +1,44 @@ +/* +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 { IPartialEvent } from "../../IPartialEvent"; +import { Optional } from "../../types"; +import { + M_POLL_END, + M_POLL_END_EVENT_CONTENT, + M_POLL_RESPONSE, + M_POLL_RESPONSE_EVENT_CONTENT, + M_POLL_START, + M_POLL_START_EVENT_CONTENT, +} from "../../events/poll_types"; +import { PollStartEvent } from "../../events/PollStartEvent"; +import { PollResponseEvent } from "../../events/PollResponseEvent"; +import { PollEndEvent } from "../../events/PollEndEvent"; + +type PollContent = M_POLL_START_EVENT_CONTENT | M_POLL_RESPONSE_EVENT_CONTENT | M_POLL_END_EVENT_CONTENT; +type PollEvent = PollStartEvent | PollResponseEvent | PollEndEvent; + +export function parseMPoll(wireEvent: IPartialEvent): Optional { + if (M_POLL_START.matches(wireEvent.type)) { + return new PollStartEvent(wireEvent as IPartialEvent); + } else if (M_POLL_RESPONSE.matches(wireEvent.type)) { + return new PollResponseEvent(wireEvent as IPartialEvent); + } else if (M_POLL_END.matches(wireEvent.type)) { + return new PollEndEvent(wireEvent as IPartialEvent); + } + + return null; // not a poll event +} diff --git a/src/types.ts b/src/types.ts index 2c43ec0..a5c643a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { NamespacedValue } from "./NamespacedValue"; + /** * Represents an optional type: can either be T or a falsy value. */ @@ -41,3 +43,22 @@ export function isProvided(s: Optional): boolean { * Represents either just T1, just T2, or T1 and T2 mixed. */ export type EitherAnd = (T1 & T2) | T1 | T2; + +/** + * 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; diff --git a/test/ExtensibleEvents.test.ts b/test/ExtensibleEvents.test.ts index 36d44b8..18e747b 100644 --- a/test/ExtensibleEvents.test.ts +++ b/test/ExtensibleEvents.test.ts @@ -24,9 +24,20 @@ import { M_MESSAGE, M_MESSAGE_EVENT_CONTENT, M_NOTICE, + M_POLL_END, + M_POLL_END_EVENT_CONTENT, + M_POLL_KIND_DISCLOSED, + M_POLL_RESPONSE, + M_POLL_RESPONSE_EVENT_CONTENT, + M_POLL_START, + M_POLL_START_EVENT_CONTENT, M_TEXT, MessageEvent, NoticeEvent, + PollEndEvent, + PollResponseEvent, + PollStartEvent, + REFERENCE_RELATION, UnstableValue, } from "../src"; @@ -183,5 +194,62 @@ describe('ExtensibleEvents', () => { expect(message).toBeDefined(); expect(message instanceof NoticeEvent).toBe(true); }); + + it('should parse m.poll.start events', () => { + 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: 1, + answers: [ + {id: "one", [M_TEXT.name]: "ONE"}, + {id: "two", [M_TEXT.name]: "TWO"}, + {id: "thr", [M_TEXT.name]: "THR"}, + ], + }, + }, + }; + const poll = (new ExtensibleEvents()).parse(input); + expect(poll).toBeDefined(); + expect(poll instanceof PollStartEvent).toBe(true); + }); + + it('should parse m.poll.response events', () => { + 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 poll = (new ExtensibleEvents()).parse(input); + expect(poll).toBeDefined(); + expect(poll instanceof PollResponseEvent).toBe(true); + }); + + it('should parse m.poll.end events', () => { + const input: IPartialEvent = { + type: M_POLL_END.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_TEXT.name]: "FALLBACK Closure notice here", + [M_POLL_END.name]: { }, + }, + }; + const poll = (new ExtensibleEvents()).parse(input); + expect(poll).toBeDefined(); + expect(poll instanceof PollEndEvent).toBe(true); + }); }); }); diff --git a/test/events/PollEndEvent.test.ts b/test/events/PollEndEvent.test.ts new file mode 100644 index 0000000..fa72145 --- /dev/null +++ b/test/events/PollEndEvent.test.ts @@ -0,0 +1,110 @@ +/* +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_MESSAGE, + M_POLL_END, + M_POLL_END_EVENT_CONTENT, + M_TEXT, + PollEndEvent, + REFERENCE_RELATION, +} from "../../src"; + +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_MESSAGE.name]: expect.any(Array), // tested by MessageEvent tests + }); + }); + }); +}); diff --git a/test/events/PollResponseEvent.test.ts b/test/events/PollResponseEvent.test.ts new file mode 100644 index 0000000..cf6e692 --- /dev/null +++ b/test/events/PollResponseEvent.test.ts @@ -0,0 +1,277 @@ +/* +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_POLL_KIND_DISCLOSED, + M_POLL_RESPONSE, + M_POLL_RESPONSE_EVENT_CONTENT, + M_POLL_START, + M_TEXT, + PollResponseEvent, + PollStartEvent, + REFERENCE_RELATION, +} from "../../src"; + +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/test/events/PollStartEvent.test.ts b/test/events/PollStartEvent.test.ts new file mode 100644 index 0000000..43a653c --- /dev/null +++ b/test/events/PollStartEvent.test.ts @@ -0,0 +1,339 @@ +/* +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_MESSAGE, + M_POLL_KIND_DISCLOSED, + M_POLL_KIND_UNDISCLOSED, + M_POLL_START, + M_POLL_START_EVENT_CONTENT, + M_TEXT, + POLL_ANSWER, + PollAnswerSubevent, + PollStartEvent, +} from "../../src"; + +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_MESSAGE.name]: expect.any(Array), // 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.id === "1-A" && a.text === "A")).toBe(true); + expect(poll.answers.some(a => a.id === "2-B" && a.text === "B")).toBe(true); + expect(poll.answers.some(a => a.id === "3-C" && 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_MESSAGE.name]: expect.any(Array), // tested by MessageEvent tests + }, + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: [ + // M_MESSAGE tested by MessageEvent tests + {id: "1-A", [M_MESSAGE.name]: expect.any(Array)}, + {id: "2-B", [M_MESSAGE.name]: expect.any(Array)}, + {id: "3-C", [M_MESSAGE.name]: expect.any(Array)}, + ], + }, + }); + }); + + 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.id === "1-A" && a.text === "A")).toBe(true); + expect(poll.answers.some(a => a.id === "2-B" && a.text === "B")).toBe(true); + expect(poll.answers.some(a => a.id === "3-C" && 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_MESSAGE.name]: expect.any(Array), // tested by MessageEvent tests + }, + kind: "org.example.poll.kind", + max_selections: 2, + answers: [ + // M_MESSAGE tested by MessageEvent tests + {id: "1-A", [M_MESSAGE.name]: expect.any(Array)}, + {id: "2-B", [M_MESSAGE.name]: expect.any(Array)}, + {id: "3-C", [M_MESSAGE.name]: expect.any(Array)}, + ], + }, + }); + }); + }); +}); diff --git a/test/interpreters/modern/MPoll.test.ts b/test/interpreters/modern/MPoll.test.ts new file mode 100644 index 0000000..9bd0954 --- /dev/null +++ b/test/interpreters/modern/MPoll.test.ts @@ -0,0 +1,102 @@ +/* +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 { + IPartialEvent, + M_POLL_END, + M_POLL_END_EVENT_CONTENT, + M_POLL_KIND_DISCLOSED, + M_POLL_RESPONSE, + M_POLL_RESPONSE_EVENT_CONTENT, + M_POLL_START, + M_POLL_START_EVENT_CONTENT, + M_TEXT, + parseMPoll, + PollEndEvent, + PollResponseEvent, + PollStartEvent, + REFERENCE_RELATION, +} from "../../../src"; + +describe('parseMPoll', () => { + it('should return an unmodified PollStartEvent', () => { + 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 = parseMPoll(input) as PollStartEvent; + expect(poll instanceof PollStartEvent).toBe(true); + 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 return an unmodified PollResponseEvent', () => { + 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 = parseMPoll(input) as PollResponseEvent; + expect(response instanceof PollResponseEvent).toBe(true); + expect(response.spoiled).toBe(false); + expect(response.answerIds).toMatchObject(["one"]); + expect(response.pollEventId).toBe("$poll"); + }); + + it('should return an unmodified PollEndEvent', () => { + 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 = parseMPoll(input) as PollEndEvent; + expect(event instanceof PollEndEvent).toBe(true); + expect(event.pollEventId).toBe("$poll"); + expect(event.closingMessage.text).toBe("Poll closed"); + }); +}); From 71cd431048a9b70eb6cf842993bf80bb3f28bd25 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 11 Jan 2022 19:50:55 -0700 Subject: [PATCH 2/4] Appease the linter --- src/events/PollEndEvent.ts | 1 - src/events/poll_types.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/events/PollEndEvent.ts b/src/events/PollEndEvent.ts index 787053f..b9f502c 100644 --- a/src/events/PollEndEvent.ts +++ b/src/events/PollEndEvent.ts @@ -18,7 +18,6 @@ import { ExtensibleEvent } from "./ExtensibleEvent"; import { M_POLL_END_EVENT_CONTENT, M_POLL_END } from "./poll_types"; import { IPartialEvent } from "../IPartialEvent"; import { InvalidEventError } from "../InvalidEventError"; -import { PollStartEvent } from "./PollStartEvent"; import { REFERENCE_RELATION } from "./relationship_types"; import { MessageEvent } from "./MessageEvent"; import { M_TEXT } from "./message_types"; diff --git a/src/events/poll_types.ts b/src/events/poll_types.ts index b4ee2a0..c659d8b 100644 --- a/src/events/poll_types.ts +++ b/src/events/poll_types.ts @@ -48,10 +48,10 @@ export const M_POLL_START = new UnstableValue("m.poll.start", "org.matrix.msc338 * The m.poll.start type within event content */ export type M_POLL_START_SUBTYPE = { - question: M_MESSAGE_EVENT_CONTENT, + question: M_MESSAGE_EVENT_CONTENT; kind: POLL_KIND; max_selections?: number; // default 1, always positive - answers: POLL_ANSWER[], + answers: POLL_ANSWER[]; }; /** From 448dffefb0a656415abcc0502acf7aa58f3ac275 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 13 Jan 2022 15:00:21 -0700 Subject: [PATCH 3/4] Shorthand m.message to m.text when there's only plaintext present --- src/events/MessageEvent.ts | 20 +++++++++++++++++++- src/events/PollStartEvent.ts | 2 +- test/events/EmoteEvent.test.ts | 4 +--- test/events/MessageEvent.test.ts | 4 +--- test/events/NoticeEvent.test.ts | 4 +--- test/events/PollEndEvent.test.ts | 2 +- test/events/PollStartEvent.test.ts | 20 ++++++++++---------- 7 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/events/MessageEvent.ts b/src/events/MessageEvent.ts index d66a0bb..43b06e7 100644 --- a/src/events/MessageEvent.ts +++ b/src/events/MessageEvent.ts @@ -110,11 +110,29 @@ export class MessageEvent extends ExtensibleEvent { return M_NOTICE.matches(this.wireFormat.type) || isProvided(M_NOTICE.findIn(this.wireFormat.content)); } + protected serializeMMessageOnly(): M_MESSAGE_EVENT_CONTENT { + let messageRendering: M_MESSAGE_EVENT_CONTENT = { + [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: { - [M_MESSAGE.name]: this.renderings, + ...this.serializeMMessageOnly(), body: this.text, msgtype: "m.text", format: this.html ? "org.matrix.custom.html" : undefined, diff --git a/src/events/PollStartEvent.ts b/src/events/PollStartEvent.ts index 0bb2370..bfec4c8 100644 --- a/src/events/PollStartEvent.ts +++ b/src/events/PollStartEvent.ts @@ -56,7 +56,7 @@ export class PollAnswerSubevent extends MessageEvent { type: "org.matrix.sdk.poll.answer", content: { id: this.id, - [M_MESSAGE.name]: this.renderings, + ...this.serializeMMessageOnly(), }, }; } diff --git a/test/events/EmoteEvent.test.ts b/test/events/EmoteEvent.test.ts index f92bd6a..1033bda 100644 --- a/test/events/EmoteEvent.test.ts +++ b/test/events/EmoteEvent.test.ts @@ -222,9 +222,7 @@ describe('EmoteEvent', () => { const serialized = message.serialize(); expect(serialized.type).toBe("m.room.message"); expect(serialized.content).toMatchObject({ - [M_MESSAGE.name]: [ - {body: "Text here", mimetype: "text/plain"}, - ], + [M_TEXT.name]: "Text here", body: "Text here", msgtype: "m.emote", format: undefined, diff --git a/test/events/MessageEvent.test.ts b/test/events/MessageEvent.test.ts index fa6e8a0..16a230c 100644 --- a/test/events/MessageEvent.test.ts +++ b/test/events/MessageEvent.test.ts @@ -222,9 +222,7 @@ describe('MessageEvent', () => { const serialized = message.serialize(); expect(serialized.type).toBe("m.room.message"); expect(serialized.content).toMatchObject({ - [M_MESSAGE.name]: [ - {body: "Text here", mimetype: "text/plain"}, - ], + [M_TEXT.name]: "Text here", body: "Text here", msgtype: "m.text", format: undefined, diff --git a/test/events/NoticeEvent.test.ts b/test/events/NoticeEvent.test.ts index 8fdf24f..29c40be 100644 --- a/test/events/NoticeEvent.test.ts +++ b/test/events/NoticeEvent.test.ts @@ -222,9 +222,7 @@ describe('NoticeEvent', () => { const serialized = message.serialize(); expect(serialized.type).toBe("m.room.message"); expect(serialized.content).toMatchObject({ - [M_MESSAGE.name]: [ - {body: "Text here", mimetype: "text/plain"}, - ], + [M_TEXT.name]: "Text here", body: "Text here", msgtype: "m.notice", format: undefined, diff --git a/test/events/PollEndEvent.test.ts b/test/events/PollEndEvent.test.ts index fa72145..70a4fa7 100644 --- a/test/events/PollEndEvent.test.ts +++ b/test/events/PollEndEvent.test.ts @@ -103,7 +103,7 @@ describe('PollEndEvent', () => { event_id: "$poll", }, [M_POLL_END.name]: {}, - [M_MESSAGE.name]: expect.any(Array), // tested by MessageEvent tests + [M_TEXT.name]: expect.any(String), // tested by MessageEvent tests }); }); }); diff --git a/test/events/PollStartEvent.test.ts b/test/events/PollStartEvent.test.ts index 43a653c..c085b3f 100644 --- a/test/events/PollStartEvent.test.ts +++ b/test/events/PollStartEvent.test.ts @@ -77,7 +77,7 @@ describe('PollAnswerSubevent', () => { expect(serialized.type).toBe("org.matrix.sdk.poll.answer"); expect(serialized.content).toMatchObject({ id: "one", - [M_MESSAGE.name]: expect.any(Array), // tested by MessageEvent + [M_TEXT.name]: expect.any(String), // tested by MessageEvent }); }); }); @@ -291,15 +291,15 @@ describe('PollStartEvent', () => { [M_TEXT.name]: "Question here\n1. A\n2. B\n3. C", [M_POLL_START.name]: { question: { - [M_MESSAGE.name]: expect.any(Array), // tested by MessageEvent tests + [M_TEXT.name]: expect.any(String), // tested by MessageEvent tests }, kind: M_POLL_KIND_DISCLOSED.name, max_selections: 2, answers: [ - // M_MESSAGE tested by MessageEvent tests - {id: "1-A", [M_MESSAGE.name]: expect.any(Array)}, - {id: "2-B", [M_MESSAGE.name]: expect.any(Array)}, - {id: "3-C", [M_MESSAGE.name]: expect.any(Array)}, + // M_TEXT tested by MessageEvent tests + {id: "1-A", [M_TEXT.name]: expect.any(String)}, + {id: "2-B", [M_TEXT.name]: expect.any(String)}, + {id: "3-C", [M_TEXT.name]: expect.any(String)}, ], }, }); @@ -322,15 +322,15 @@ describe('PollStartEvent', () => { [M_TEXT.name]: "Question here\n1. A\n2. B\n3. C", [M_POLL_START.name]: { question: { - [M_MESSAGE.name]: expect.any(Array), // tested by MessageEvent tests + [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: "1-A", [M_MESSAGE.name]: expect.any(Array)}, - {id: "2-B", [M_MESSAGE.name]: expect.any(Array)}, - {id: "3-C", [M_MESSAGE.name]: expect.any(Array)}, + {id: "1-A", [M_TEXT.name]: expect.any(String)}, + {id: "2-B", [M_TEXT.name]: expect.any(String)}, + {id: "3-C", [M_TEXT.name]: expect.any(String)}, ], }, }); From 9e289c8e5ee72008632c0449415f07a0356f1175 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 13 Jan 2022 15:02:01 -0700 Subject: [PATCH 4/4] Clean up imports --- src/events/PollStartEvent.ts | 2 +- test/events/PollEndEvent.test.ts | 1 - test/events/PollStartEvent.test.ts | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/events/PollStartEvent.ts b/src/events/PollStartEvent.ts index bfec4c8..6f52089 100644 --- a/src/events/PollStartEvent.ts +++ b/src/events/PollStartEvent.ts @@ -26,7 +26,7 @@ import { } from "./poll_types"; import { IPartialEvent } from "../IPartialEvent"; import { MessageEvent } from "./MessageEvent"; -import { M_MESSAGE, M_TEXT } from "./message_types"; +import { M_TEXT } from "./message_types"; import { InvalidEventError } from "../InvalidEventError"; import { NamespacedValue } from "../NamespacedValue"; diff --git a/test/events/PollEndEvent.test.ts b/test/events/PollEndEvent.test.ts index 70a4fa7..869dd70 100644 --- a/test/events/PollEndEvent.test.ts +++ b/test/events/PollEndEvent.test.ts @@ -17,7 +17,6 @@ limitations under the License. import { InvalidEventError, IPartialEvent, - M_MESSAGE, M_POLL_END, M_POLL_END_EVENT_CONTENT, M_TEXT, diff --git a/test/events/PollStartEvent.test.ts b/test/events/PollStartEvent.test.ts index c085b3f..733854b 100644 --- a/test/events/PollStartEvent.test.ts +++ b/test/events/PollStartEvent.test.ts @@ -17,7 +17,6 @@ limitations under the License. import { InvalidEventError, IPartialEvent, - M_MESSAGE, M_POLL_KIND_DISCLOSED, M_POLL_KIND_UNDISCLOSED, M_POLL_START,