From ef51ee28fda9a9d7e5e98580823ca177ce4fe8d6 Mon Sep 17 00:00:00 2001 From: Kerry Date: Thu, 26 Jan 2023 15:07:55 +1300 Subject: [PATCH] Poll model (#3036) * first cut poll model * process incoming poll relations * allow alt event types in relations model * allow alt event types in relations model * remove unneccesary checks on remove relation * comment * Revert "allow alt event types in relations model" This reverts commit e578d84464403d4a15ee8a7cf3ac643f4fb86d69. * Revert "Revert "allow alt event types in relations model"" This reverts commit 515db7a8bc2df5a1c619a37c86e17ccbe287ba7a. * basic handling for new poll relations * tests * test room.processPollEvents * join processBeaconEvents and poll events in client * tidy and set 23 copyrights * use rooms instance of matrixClient * tidy * more copyright * simplify processPollEvent code * throw when poll start event has no roomId * updates for events-sdk move * more type changes for events-sdk changes * comment --- spec/unit/models/poll.spec.ts | 246 ++++++++++++++++++++++++++++++++++ spec/unit/room.spec.ts | 76 ++++++++++- src/client.ts | 28 ++-- src/matrix.ts | 1 + src/models/poll.ts | 175 ++++++++++++++++++++++++ src/models/relations.ts | 4 +- src/models/room.ts | 44 +++++- 7 files changed, 561 insertions(+), 13 deletions(-) create mode 100644 spec/unit/models/poll.spec.ts create mode 100644 src/models/poll.ts diff --git a/spec/unit/models/poll.spec.ts b/spec/unit/models/poll.spec.ts new file mode 100644 index 000000000..feb0c27ff --- /dev/null +++ b/spec/unit/models/poll.spec.ts @@ -0,0 +1,246 @@ +/* +Copyright 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 { IEvent, MatrixEvent, PollEvent } from "../../../src"; +import { REFERENCE_RELATION } from "../../../src/@types/extensible_events"; +import { M_POLL_END, M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE } from "../../../src/@types/polls"; +import { PollStartEvent } from "../../../src/extensible_events_v1/PollStartEvent"; +import { Poll } from "../../../src/models/poll"; +import { getMockClientWithEventEmitter } from "../../test-utils/client"; + +jest.useFakeTimers(); + +describe("Poll", () => { + const mockClient = getMockClientWithEventEmitter({ + relations: jest.fn(), + }); + const roomId = "!room:server"; + // 14.03.2022 16:15 + const now = 1647270879403; + + const basePollStartEvent = new MatrixEvent({ + ...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(), + room_id: roomId, + }); + basePollStartEvent.event.event_id = "$12345"; + + beforeEach(() => { + jest.clearAllMocks(); + jest.setSystemTime(now); + + mockClient.relations.mockResolvedValue({ events: [] }); + }); + + let eventId = 1; + const makeRelatedEvent = (eventProps: Partial, timestamp = now): MatrixEvent => { + const event = new MatrixEvent({ + ...eventProps, + content: { + ...(eventProps.content || {}), + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: basePollStartEvent.getId(), + }, + }, + }); + event.event.origin_server_ts = timestamp; + event.event.event_id = `${eventId++}`; + return event; + }; + + it("initialises with root event", () => { + const poll = new Poll(basePollStartEvent, mockClient); + expect(poll.roomId).toEqual(roomId); + expect(poll.pollId).toEqual(basePollStartEvent.getId()); + expect(poll.pollEvent).toEqual(basePollStartEvent.unstableExtensibleEvent); + expect(poll.isEnded).toBe(false); + }); + + it("throws when poll start has no room id", () => { + const pollStartEvent = new MatrixEvent( + PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(), + ); + expect(() => new Poll(pollStartEvent, mockClient)).toThrowError("Invalid poll start event."); + }); + + it("throws when poll start has no event id", () => { + const pollStartEvent = new MatrixEvent({ + ...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(), + room_id: roomId, + }); + expect(() => new Poll(pollStartEvent, mockClient)).toThrowError("Invalid poll start event."); + }); + + describe("fetching responses", () => { + it("calls relations api and emits", async () => { + const poll = new Poll(basePollStartEvent, mockClient); + const emitSpy = jest.spyOn(poll, "emit"); + const responses = await poll.getResponses(); + expect(mockClient.relations).toHaveBeenCalledWith(roomId, basePollStartEvent.getId(), "m.reference"); + expect(emitSpy).toHaveBeenCalledWith(PollEvent.Responses, responses); + }); + + it("returns existing responses object after initial fetch", async () => { + const poll = new Poll(basePollStartEvent, mockClient); + const responses = await poll.getResponses(); + const responses2 = await poll.getResponses(); + // only fetched relations once + expect(mockClient.relations).toHaveBeenCalledTimes(1); + // strictly equal + expect(responses).toBe(responses2); + }); + + it("waits for existing relations request to finish when getting responses", async () => { + const poll = new Poll(basePollStartEvent, mockClient); + const firstResponsePromise = poll.getResponses(); + const secondResponsePromise = poll.getResponses(); + await firstResponsePromise; + expect(firstResponsePromise).toEqual(secondResponsePromise); + await secondResponsePromise; + expect(mockClient.relations).toHaveBeenCalledTimes(1); + }); + + it("filters relations for relevent response events", async () => { + const replyEvent = new MatrixEvent({ type: "m.room.message" }); + const stableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.stable! }); + const unstableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.unstable }); + + mockClient.relations.mockResolvedValue({ + events: [replyEvent, stableResponseEvent, unstableResponseEvent], + }); + const poll = new Poll(basePollStartEvent, mockClient); + const responses = await poll.getResponses(); + expect(responses.getRelations()).toEqual([stableResponseEvent, unstableResponseEvent]); + }); + + describe("with poll end event", () => { + const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable! }); + const unstablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.unstable! }); + const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000); + const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now); + const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000); + + beforeEach(() => { + mockClient.relations.mockResolvedValue({ + events: [responseEventAfterEnd, responseEventAtEnd, responseEventBeforeEnd, stablePollEndEvent], + }); + }); + + it("sets poll end event with stable event type", async () => { + const poll = new Poll(basePollStartEvent, mockClient); + jest.spyOn(poll, "emit"); + await poll.getResponses(); + + expect(poll.isEnded).toBe(true); + expect(poll.emit).toHaveBeenCalledWith(PollEvent.End); + }); + + it("sets poll end event with unstable event type", async () => { + mockClient.relations.mockResolvedValue({ + events: [unstablePollEndEvent], + }); + const poll = new Poll(basePollStartEvent, mockClient); + jest.spyOn(poll, "emit"); + await poll.getResponses(); + + expect(poll.isEnded).toBe(true); + expect(poll.emit).toHaveBeenCalledWith(PollEvent.End); + }); + + it("filters out responses that were sent after poll end", async () => { + const poll = new Poll(basePollStartEvent, mockClient); + const responses = await poll.getResponses(); + + // just response type events + // and response with ts after poll end event is excluded + expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]); + }); + }); + }); + + describe("onNewRelation()", () => { + it("discards response if poll responses have not been initialised", () => { + const poll = new Poll(basePollStartEvent, mockClient); + jest.spyOn(poll, "emit"); + const responseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now); + + poll.onNewRelation(responseEvent); + + // did not add response -> no emit + expect(poll.emit).not.toHaveBeenCalled(); + }); + + it("sets poll end event when responses are not initialised", () => { + const poll = new Poll(basePollStartEvent, mockClient); + jest.spyOn(poll, "emit"); + const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable! }); + + poll.onNewRelation(stablePollEndEvent); + + expect(poll.emit).toHaveBeenCalledWith(PollEvent.End); + }); + + it("sets poll end event and refilters responses based on timestamp", async () => { + const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable! }); + const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000); + const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now); + const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000); + mockClient.relations.mockResolvedValue({ + events: [responseEventAfterEnd, responseEventAtEnd, responseEventBeforeEnd], + }); + const poll = new Poll(basePollStartEvent, mockClient); + const responses = await poll.getResponses(); + jest.spyOn(poll, "emit"); + + expect(responses.getRelations().length).toEqual(3); + poll.onNewRelation(stablePollEndEvent); + + expect(poll.emit).toHaveBeenCalledWith(PollEvent.End); + expect(poll.emit).toHaveBeenCalledWith(PollEvent.Responses, responses); + expect(responses.getRelations().length).toEqual(2); + // after end timestamp event is removed + expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]); + }); + + it("filters out irrelevant relations", async () => { + const poll = new Poll(basePollStartEvent, mockClient); + // init responses + const responses = await poll.getResponses(); + jest.spyOn(poll, "emit"); + const replyEvent = new MatrixEvent({ type: "m.room.message" }); + + poll.onNewRelation(replyEvent); + + // did not add response -> no emit + expect(poll.emit).not.toHaveBeenCalled(); + expect(responses.getRelations().length).toEqual(0); + }); + + it("adds poll response relations to responses", async () => { + const poll = new Poll(basePollStartEvent, mockClient); + // init responses + const responses = await poll.getResponses(); + jest.spyOn(poll, "emit"); + const responseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now); + + poll.onNewRelation(responseEvent); + + // did not add response -> no emit + expect(poll.emit).toHaveBeenCalledWith(PollEvent.Responses, responses); + expect(responses.getRelations()).toEqual([responseEvent]); + }); + }); +}); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 38fc2cdc4..705199ddb 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +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. @@ -19,6 +19,7 @@ limitations under the License. */ import { mocked } from "jest-mock"; +import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, PollStartEvent } from "matrix-events-sdk"; import * as utils from "../test-utils/test-utils"; import { emitPromise } from "../test-utils/test-utils"; @@ -37,6 +38,7 @@ import { MatrixEvent, MatrixEventEvent, PendingEventOrdering, + PollEvent, RelationType, RoomEvent, RoomMember, @@ -3228,6 +3230,78 @@ describe("Room", function () { }); }); + describe("processPollEvents()", () => { + let room: Room; + let client: MatrixClient; + + beforeEach(() => { + client = getMockClientWithEventEmitter({ + decryptEventIfNeeded: jest.fn(), + }); + room = new Room(roomId, client, userA); + jest.spyOn(room, "emit").mockClear(); + }); + + const makePollStart = (id: string): MatrixEvent => { + const event = new MatrixEvent({ + ...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(), + room_id: roomId, + }); + event.event.event_id = id; + return event; + }; + + it("adds poll models to room state for a poll start event ", async () => { + const pollStartEvent = makePollStart("1"); + const events = [pollStartEvent]; + + await room.processPollEvents(events); + expect(client.decryptEventIfNeeded).toHaveBeenCalledWith(pollStartEvent); + const pollInstance = room.polls.get(pollStartEvent.getId()!); + expect(pollInstance).toBeTruthy(); + + expect(room.emit).toHaveBeenCalledWith(PollEvent.New, pollInstance); + }); + + it("adds related events to poll models", async () => { + const pollStartEvent = makePollStart("1"); + const pollStartEvent2 = makePollStart("2"); + const events = [pollStartEvent, pollStartEvent2]; + const pollResponseEvent = new MatrixEvent({ + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: RelationType.Reference, + event_id: pollStartEvent.getId(), + }, + }, + }); + const messageEvent = new MatrixEvent({ + type: "m.room.messsage", + content: { + text: "hello", + }, + }); + + // init poll + await room.processPollEvents(events); + + const poll = room.polls.get(pollStartEvent.getId()!)!; + const poll2 = room.polls.get(pollStartEvent2.getId()!)!; + jest.spyOn(poll, "onNewRelation"); + jest.spyOn(poll2, "onNewRelation"); + + await room.processPollEvents([pollResponseEvent, messageEvent]); + + // only called for relevant event + expect(poll.onNewRelation).toHaveBeenCalledTimes(1); + expect(poll.onNewRelation).toHaveBeenCalledWith(pollResponseEvent); + + // only called on poll with relation + expect(poll2.onNewRelation).not.toHaveBeenCalled(); + }); + }); + describe("findPredecessorRoomId", () => { let client: MatrixClient | null = null; beforeEach(() => { diff --git a/src/client.ts b/src/client.ts index ae2afe9ca..869737e74 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5426,7 +5426,7 @@ export class MatrixClient extends TypedEventEmitter it.getServerAggregatedRelation(THREAD_RELATION_TYPE.name)), @@ -9360,10 +9360,22 @@ export class MatrixClient extends TypedEventEmitter void; + [PollEvent.Destroy]: (pollIdentifier: string) => void; + [PollEvent.End]: () => void; + [PollEvent.Responses]: (responses: Relations) => void; +}; + +const filterResponseRelations = ( + relationEvents: MatrixEvent[], + pollEndTimestamp: number, +): { + responseEvents: MatrixEvent[]; +} => { + const responseEvents = relationEvents.filter((event) => { + if (event.isDecryptionFailure()) { + // @TODO(kerrya) PSG-1023 track and return these + return; + } + return ( + M_POLL_RESPONSE.matches(event.getType()) && + // From MSC3381: + // "Votes sent on or before the end event's timestamp are valid votes" + event.getTs() <= pollEndTimestamp + ); + }); + + return { responseEvents }; +}; + +export class Poll extends TypedEventEmitter, PollEventHandlerMap> { + public readonly roomId: string; + public readonly pollEvent: PollStartEvent; + private fetchingResponsesPromise: null | Promise = null; + private responses: null | Relations = null; + private endEvent: MatrixEvent | undefined; + + public constructor(private rootEvent: MatrixEvent, private matrixClient: MatrixClient) { + super(); + if (!this.rootEvent.getRoomId() || !this.rootEvent.getId()) { + throw new Error("Invalid poll start event."); + } + this.roomId = this.rootEvent.getRoomId()!; + // @TODO(kerrya) proper way to do this? + this.pollEvent = this.rootEvent.unstableExtensibleEvent as unknown as PollStartEvent; + } + + public get pollId(): string { + return this.rootEvent.getId()!; + } + + public get isEnded(): boolean { + return !!this.endEvent; + } + + public async getResponses(): Promise { + // if we have already fetched the responses + // just return them + if (this.responses) { + return this.responses; + } + if (!this.fetchingResponsesPromise) { + this.fetchingResponsesPromise = this.fetchResponses(); + } + await this.fetchingResponsesPromise; + return this.responses!; + } + + /** + * + * @param event - event with a relation to the rootEvent + * @returns void + */ + public onNewRelation(event: MatrixEvent): void { + if (M_POLL_END.matches(event.getType())) { + this.endEvent = event; + this.refilterResponsesOnEnd(); + this.emit(PollEvent.End); + } + + // wait for poll responses to be initialised + if (!this.responses) { + return; + } + + const pollEndTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER; + const { responseEvents } = filterResponseRelations([event], pollEndTimestamp); + + if (responseEvents.length) { + responseEvents.forEach((event) => { + this.responses!.addEvent(event); + }); + this.emit(PollEvent.Responses, this.responses); + } + } + + private async fetchResponses(): Promise { + // we want: + // - stable and unstable M_POLL_RESPONSE + // - stable and unstable M_POLL_END + // so make one api call and filter by event type client side + const allRelations = await this.matrixClient.relations(this.roomId, this.rootEvent.getId()!, "m.reference"); + + // @TODO(kerrya) paging results + + const responses = new Relations("m.reference", M_POLL_RESPONSE.name, this.matrixClient, [ + M_POLL_RESPONSE.altName!, + ]); + + const pollEndEvent = allRelations.events.find((event) => M_POLL_END.matches(event.getType())); + const pollCloseTimestamp = pollEndEvent?.getTs() || Number.MAX_SAFE_INTEGER; + + const { responseEvents } = filterResponseRelations(allRelations.events, pollCloseTimestamp); + + responseEvents.forEach((event) => { + responses.addEvent(event); + }); + + this.responses = responses; + this.endEvent = pollEndEvent; + if (this.endEvent) { + this.emit(PollEvent.End); + } + this.emit(PollEvent.Responses, this.responses); + } + + /** + * Only responses made before the poll ended are valid + * Refilter after an end event is recieved + * To ensure responses are valid + */ + private refilterResponsesOnEnd(): void { + if (!this.responses) { + return; + } + + const pollEndTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER; + this.responses.getRelations().forEach((event) => { + if (event.getTs() > pollEndTimestamp) { + this.responses?.removeEvent(event); + } + }); + + this.emit(PollEvent.Responses, this.responses); + } +} diff --git a/src/models/relations.ts b/src/models/relations.ts index 069bb0a0c..d2b637cc3 100644 --- a/src/models/relations.ts +++ b/src/models/relations.ts @@ -1,5 +1,5 @@ /* -Copyright 2019, 2021 The Matrix.org Foundation C.I.C. +Copyright 2019, 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. @@ -122,7 +122,7 @@ export class Relations extends TypedEventEmitter { + public async removeEvent(event: MatrixEvent): Promise { if (!this.relations.has(event)) { return; } diff --git a/src/models/room.ts b/src/models/room.ts index e1202c523..003dc59df 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Optional } from "matrix-events-sdk"; +import { M_POLL_START, Optional } from "matrix-events-sdk"; import { EventTimelineSet, @@ -65,6 +65,7 @@ import { IStateEventWithRoomId } from "../@types/search"; import { RelationsContainer } from "./relations-container"; import { ReadReceipt, synthesizeReceipt } from "./read-receipt"; import { Feature, ServerSupport } from "../feature"; +import { Poll, PollEvent } from "./poll"; // These constants are used as sane defaults when the homeserver doesn't support // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be @@ -162,7 +163,8 @@ export type RoomEmittedEvents = | BeaconEvent.New | BeaconEvent.Update | BeaconEvent.Destroy - | BeaconEvent.LivenessChange; + | BeaconEvent.LivenessChange + | PollEvent.New; export type RoomEventHandlerMap = { /** @@ -289,6 +291,11 @@ export type RoomEventHandlerMap = { [RoomEvent.UnreadNotifications]: (unreadNotifications?: NotificationCount, threadId?: string) => void; [RoomEvent.TimelineRefresh]: (room: Room, eventTimelineSet: EventTimelineSet) => void; [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; + /** + * Fires when a new poll instance is added to the room state + * @param poll - the new poll + */ + [PollEvent.New]: (poll: Poll) => void; } & Pick & EventTimelineSetHandlerMap & Pick & @@ -317,6 +324,7 @@ export class Room extends ReadReceipt { */ private unthreadedReceipts = new Map(); private readonly timelineSets: EventTimelineSet[]; + public readonly polls: Map = new Map(); public readonly threadsTimelineSets: EventTimelineSet[] = []; // any filtered timeline sets we're maintaining for this room private readonly filteredTimelineSets: Record = {}; // filter_id: timelineSet @@ -1890,6 +1898,38 @@ export class Room extends ReadReceipt { this.threadsReady = true; } + public async processPollEvents(events: MatrixEvent[]): Promise { + const processPollStartEvent = (event: MatrixEvent): void => { + if (!M_POLL_START.matches(event.getType())) return; + try { + const poll = new Poll(event, this.client); + this.polls.set(event.getId()!, poll); + this.emit(PollEvent.New, poll); + } catch {} + // poll creation can fail for malformed poll start events + }; + + const processPollRelationEvent = (event: MatrixEvent): void => { + const relationEventId = event.relationEventId; + if (relationEventId && this.polls.has(relationEventId)) { + const poll = this.polls.get(relationEventId); + poll?.onNewRelation(event); + } + }; + + const processPollEvent = (event: MatrixEvent): void => { + processPollStartEvent(event); + processPollRelationEvent(event); + }; + + for (const event of events) { + try { + await this.client.decryptEventIfNeeded(event); + processPollEvent(event); + } catch {} + } + } + /** * Fetch a single page of threadlist messages for the specific thread filter * @internal