Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add polls support #1

Merged
merged 4 commits into from
Jan 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/ExtensibleEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TContentIn = object, TEvent extends ExtensibleEvent = ExtensibleEvent>
= (wireEvent: IPartialEvent<TContentIn>) => Optional<TEvent>;
Expand All @@ -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<string, string>[] = [
Expand Down
20 changes: 19 additions & 1 deletion src/events/MessageEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,29 @@ export class MessageEvent extends ExtensibleEvent<M_MESSAGE_EVENT_CONTENT> {
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<object> {
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,
Expand Down
89 changes: 89 additions & 0 deletions src/events/PollEndEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
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 { 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<M_POLL_END_EVENT_CONTENT> {
/**
* 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<M_POLL_END_EVENT_CONTENT>} wireFormat The event.
*/
public constructor(wireFormat: IPartialEvent<M_POLL_END_EVENT_CONTENT>) {
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<object> {
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,
},
});
}
}
140 changes: 140 additions & 0 deletions src/events/PollResponseEvent.ts
Original file line number Diff line number Diff line change
@@ -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<M_POLL_RESPONSE_EVENT_CONTENT> {
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<M_POLL_RESPONSE_EVENT_CONTENT>} wireFormat The event.
*/
public constructor(wireFormat: IPartialEvent<M_POLL_RESPONSE_EVENT_CONTENT>) {
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<M_POLL_RESPONSE_SUBTYPE>(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<object> {
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,
},
},
});
}
}
Loading