From 4e8affafcc9198b5377d6b5fb87d692e095979c3 Mon Sep 17 00:00:00 2001 From: Kerry Date: Thu, 2 Feb 2023 09:44:40 +1300 Subject: [PATCH] Poll model - page /relations results (#3073) * 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 * page poll relation results * validate poll end event senders * reformatted copyright * undo more comment reformatting * test paging * use correct pollstartevent type * emit after updating _isFetchingResponses state * make rootEvent public readonly * fix poll end validation logic to allow poll creator to end poll regardless of redaction --- spec/unit/models/poll.spec.ts | 151 ++++++++++++++++++++++++++++++---- src/models/poll.ts | 68 +++++++++++---- 2 files changed, 187 insertions(+), 32 deletions(-) diff --git a/spec/unit/models/poll.spec.ts b/spec/unit/models/poll.spec.ts index c6bc39d53..6b00e4a5d 100644 --- a/spec/unit/models/poll.spec.ts +++ b/spec/unit/models/poll.spec.ts @@ -46,7 +46,7 @@ describe("Poll", () => { jest.clearAllMocks(); jest.setSystemTime(now); - mockClient.relations.mockResolvedValue({ events: [] }); + mockClient.relations.mockReset().mockResolvedValue({ events: [] }); maySendRedactionForEventSpy.mockClear().mockReturnValue(true); }); @@ -95,8 +95,17 @@ describe("Poll", () => { it("calls relations api and emits", async () => { const poll = new Poll(basePollStartEvent, mockClient, room); const emitSpy = jest.spyOn(poll, "emit"); - const responses = await poll.getResponses(); - expect(mockClient.relations).toHaveBeenCalledWith(roomId, basePollStartEvent.getId(), "m.reference"); + const fetchResponsePromise = poll.getResponses(); + expect(poll.isFetchingResponses).toBe(true); + const responses = await fetchResponsePromise; + expect(poll.isFetchingResponses).toBe(false); + expect(mockClient.relations).toHaveBeenCalledWith( + roomId, + basePollStartEvent.getId(), + "m.reference", + undefined, + { from: undefined }, + ); expect(emitSpy).toHaveBeenCalledWith(PollEvent.Responses, responses); }); @@ -133,6 +142,48 @@ describe("Poll", () => { expect(responses.getRelations()).toEqual([stableResponseEvent, unstableResponseEvent]); }); + describe("with multiple pages of relations", () => { + const makeResponses = (count = 1, timestamp = now): MatrixEvent[] => + new Array(count) + .fill("x") + .map((_x, index) => + makeRelatedEvent( + { type: M_POLL_RESPONSE.stable!, sender: "@bob@server.org" }, + timestamp + index, + ), + ); + + it("page relations responses", async () => { + const responseEvents = makeResponses(6); + mockClient.relations + .mockResolvedValueOnce({ + events: responseEvents.slice(0, 2), + nextBatch: "test-next-1", + }) + .mockResolvedValueOnce({ + events: responseEvents.slice(2, 4), + nextBatch: "test-next-2", + }) + .mockResolvedValueOnce({ + events: responseEvents.slice(4), + }); + + const poll = new Poll(basePollStartEvent, mockClient, room); + jest.spyOn(poll, "emit"); + const responses = await poll.getResponses(); + + expect(mockClient.relations.mock.calls).toEqual([ + [roomId, basePollStartEvent.getId(), "m.reference", undefined, { from: undefined }], + [roomId, basePollStartEvent.getId(), "m.reference", undefined, { from: "test-next-1" }], + [roomId, basePollStartEvent.getId(), "m.reference", undefined, { from: "test-next-2" }], + ]); + + expect(poll.emit).toHaveBeenCalledTimes(3); + expect(poll.isFetchingResponses).toBeFalsy(); + expect(responses.getRelations().length).toEqual(6); + }); + }); + describe("with poll end event", () => { const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@bob@server.org" }); const unstablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.unstable!, sender: "@bob@server.org" }); @@ -156,17 +207,6 @@ describe("Poll", () => { expect(poll.emit).toHaveBeenCalledWith(PollEvent.End); }); - it("does not set poll end event when sent by a user without redaction rights", async () => { - const poll = new Poll(basePollStartEvent, mockClient, room); - maySendRedactionForEventSpy.mockReturnValue(false); - jest.spyOn(poll, "emit"); - await poll.getResponses(); - - expect(maySendRedactionForEventSpy).toHaveBeenCalledWith(basePollStartEvent, "@bob@server.org"); - expect(poll.isEnded).toBe(false); - expect(poll.emit).not.toHaveBeenCalledWith(PollEvent.End); - }); - it("sets poll end event when endevent sender also created the poll, but does not have redaction rights", async () => { const pollStartEvent = new MatrixEvent({ ...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(), @@ -316,6 +356,89 @@ describe("Poll", () => { expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]); }); + it("does not set poll end event when sent by invalid user", async () => { + maySendRedactionForEventSpy.mockReturnValue(false); + const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@charlie:server.org" }); + const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000); + mockClient.relations.mockResolvedValue({ + events: [responseEventAfterEnd], + }); + const poll = new Poll(basePollStartEvent, mockClient, room); + await poll.getResponses(); + jest.spyOn(poll, "emit"); + + poll.onNewRelation(stablePollEndEvent); + + // didn't end, didn't refilter responses + expect(poll.emit).not.toHaveBeenCalled(); + expect(poll.isEnded).toBeFalsy(); + expect(maySendRedactionForEventSpy).toHaveBeenCalledWith(basePollStartEvent, "@charlie:server.org"); + }); + + it("does not set poll end event when an earlier end event already exists", async () => { + const earlierPollEndEvent = makeRelatedEvent( + { type: M_POLL_END.stable!, sender: "@valid:server.org" }, + now, + ); + const laterPollEndEvent = makeRelatedEvent( + { type: M_POLL_END.stable!, sender: "@valid:server.org" }, + now + 2000, + ); + + const poll = new Poll(basePollStartEvent, mockClient, room); + await poll.getResponses(); + + poll.onNewRelation(earlierPollEndEvent); + + // first end event set correctly + expect(poll.isEnded).toBeTruthy(); + + // reset spy count + jest.spyOn(poll, "emit").mockClear(); + + poll.onNewRelation(laterPollEndEvent); + // didn't set new end event, didn't refilter responses + expect(poll.emit).not.toHaveBeenCalled(); + expect(poll.isEnded).toBeTruthy(); + }); + + it("replaces poll end event and refilters when an older end event already exists", async () => { + const earlierPollEndEvent = makeRelatedEvent( + { type: M_POLL_END.stable!, sender: "@valid:server.org" }, + now, + ); + const laterPollEndEvent = makeRelatedEvent( + { type: M_POLL_END.stable!, sender: "@valid:server.org" }, + now + 2000, + ); + 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, laterPollEndEvent], + }); + + const poll = new Poll(basePollStartEvent, mockClient, room); + const responses = await poll.getResponses(); + + // all responses have a timestamp < laterPollEndEvent + expect(responses.getRelations().length).toEqual(3); + // first end event set correctly + expect(poll.isEnded).toBeTruthy(); + + // reset spy count + jest.spyOn(poll, "emit").mockClear(); + + // add a valid end event with earlier timestamp + poll.onNewRelation(earlierPollEndEvent); + + // emitted new end event + expect(poll.emit).toHaveBeenCalledWith(PollEvent.End); + // filtered responses and emitted + expect(poll.emit).toHaveBeenCalledWith(PollEvent.Responses, responses); + expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]); + }); + it("sets poll end event and refilters responses based on timestamp", async () => { const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: userId }); const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000); diff --git a/src/models/poll.ts b/src/models/poll.ts index c51c39168..7c5d245f7 100644 --- a/src/models/poll.ts +++ b/src/models/poll.ts @@ -14,8 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { M_POLL_END, M_POLL_RESPONSE, PollStartEvent } from "../@types/polls"; +import { M_POLL_END, M_POLL_RESPONSE } from "../@types/polls"; import { MatrixClient } from "../client"; +import { PollStartEvent } from "../extensible_events_v1/PollStartEvent"; import { MatrixEvent } from "./event"; import { Relations } from "./relations"; import { Room } from "./room"; @@ -61,11 +62,12 @@ const filterResponseRelations = ( export class Poll extends TypedEventEmitter, PollEventHandlerMap> { public readonly roomId: string; public readonly pollEvent: PollStartEvent; - private fetchingResponsesPromise: null | Promise = null; + private _isFetchingResponses = false; + private relationsNextBatch: string | undefined; private responses: null | Relations = null; private endEvent: MatrixEvent | undefined; - public constructor(private rootEvent: MatrixEvent, private matrixClient: MatrixClient, private room: Room) { + public constructor(public readonly rootEvent: MatrixEvent, private matrixClient: MatrixClient, private room: Room) { super(); if (!this.rootEvent.getRoomId() || !this.rootEvent.getId()) { throw new Error("Invalid poll start event."); @@ -82,16 +84,23 @@ export class Poll extends TypedEventEmitter, P return !!this.endEvent; } + public get isFetchingResponses(): boolean { + return this._isFetchingResponses; + } + public async getResponses(): Promise { - // if we have already fetched the responses + // if we have already fetched some responses // just return them if (this.responses) { return this.responses; } - if (!this.fetchingResponsesPromise) { - this.fetchingResponsesPromise = this.fetchResponses(); + + // if there is no fetching in progress + // start fetching + if (!this.isFetchingResponses) { + await this.fetchResponses(); } - await this.fetchingResponsesPromise; + // return whatever responses we got from the first page return this.responses!; } @@ -124,21 +133,34 @@ export class Poll extends TypedEventEmitter, P } private async fetchResponses(): Promise { + this._isFetchingResponses = true; + // 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"); + const allRelations = await this.matrixClient.relations( + this.roomId, + this.rootEvent.getId()!, + "m.reference", + undefined, + { + from: this.relationsNextBatch || undefined, + }, + ); - // @TODO(kerrya) paging results + const responses = + this.responses || + new Relations("m.reference", M_POLL_RESPONSE.name, this.matrixClient, [M_POLL_RESPONSE.altName!]); - 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())); + if (this.validateEndEvent(pollEndEvent)) { + this.endEvent = pollEndEvent; + this.refilterResponsesOnEnd(); + this.emit(PollEvent.End); + } - const potentialEndEvent = allRelations.events.find((event) => M_POLL_END.matches(event.getType())); - const pollEndEvent = this.validateEndEvent(potentialEndEvent) ? potentialEndEvent : undefined; - const pollCloseTimestamp = pollEndEvent?.getTs() || Number.MAX_SAFE_INTEGER; + const pollCloseTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER; const { responseEvents } = filterResponseRelations(allRelations.events, pollCloseTimestamp); @@ -146,11 +168,21 @@ export class Poll extends TypedEventEmitter, P responses.addEvent(event); }); + this.relationsNextBatch = allRelations.nextBatch ?? undefined; this.responses = responses; - this.endEvent = pollEndEvent; - if (this.endEvent) { - this.emit(PollEvent.End); + + // while there are more pages of relations + // fetch them + if (this.relationsNextBatch) { + // don't await + // we want to return the first page as soon as possible + this.fetchResponses(); + } else { + // no more pages + this._isFetchingResponses = false; } + + // emit after updating _isFetchingResponses state this.emit(PollEvent.Responses, this.responses); }