1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-31 15:24:23 +03:00

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 e578d84464.

* Revert "Revert "allow alt event types in relations model""

This reverts commit 515db7a8bc.

* 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
This commit is contained in:
Kerry
2023-01-26 15:07:55 +13:00
committed by GitHub
parent cb61345780
commit ef51ee28fd
7 changed files with 561 additions and 13 deletions

View File

@ -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<IEvent>, 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]);
});
});
});

View File

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 { 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 * as utils from "../test-utils/test-utils";
import { emitPromise } from "../test-utils/test-utils"; import { emitPromise } from "../test-utils/test-utils";
@ -37,6 +38,7 @@ import {
MatrixEvent, MatrixEvent,
MatrixEventEvent, MatrixEventEvent,
PendingEventOrdering, PendingEventOrdering,
PollEvent,
RelationType, RelationType,
RoomEvent, RoomEvent,
RoomMember, 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", () => { describe("findPredecessorRoomId", () => {
let client: MatrixClient | null = null; let client: MatrixClient | null = null;
beforeEach(() => { beforeEach(() => {

View File

@ -5426,7 +5426,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents); const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents);
this.processBeaconEvents(room, timelineEvents); this.processAggregatedTimelineEvents(room, timelineEvents);
room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline()); room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
this.processThreadEvents(room, threadedEvents, true); this.processThreadEvents(room, threadedEvents, true);
@ -5541,7 +5541,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start); timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start);
// The target event is not in a thread but process the contextual events, so we can show any threads around it. // The target event is not in a thread but process the contextual events, so we can show any threads around it.
this.processThreadEvents(timelineSet.room, threadedEvents, true); this.processThreadEvents(timelineSet.room, threadedEvents, true);
this.processBeaconEvents(timelineSet.room, timelineEvents); this.processAggregatedTimelineEvents(timelineSet.room, timelineEvents);
// There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring // There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring
// timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up // timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up
@ -5636,7 +5636,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} }
timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward); timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward);
timeline.setPaginationToken(resNewer.next_batch ?? null, Direction.Forward); timeline.setPaginationToken(resNewer.next_batch ?? null, Direction.Forward);
this.processBeaconEvents(timelineSet.room, events); this.processAggregatedTimelineEvents(timelineSet.room, events);
// There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring // There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring
// timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up // timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up
@ -5693,7 +5693,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} }
timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward); timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward);
timeline.setPaginationToken(null, Direction.Forward); timeline.setPaginationToken(null, Direction.Forward);
this.processBeaconEvents(timelineSet.room, events); this.processAggregatedTimelineEvents(timelineSet.room, events);
return timeline; return timeline;
} }
@ -5946,7 +5946,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// in the notification timeline set // in the notification timeline set
const timelineSet = eventTimeline.getTimelineSet(); const timelineSet = eventTimeline.getTimelineSet();
timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token); timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
this.processBeaconEvents(timelineSet.room, matrixEvents); this.processAggregatedTimelineEvents(timelineSet.room, matrixEvents);
// if we've hit the end of the timeline, we need to stop trying to // if we've hit the end of the timeline, we need to stop trying to
// paginate. We need to keep the 'forwards' token though, to make sure // paginate. We need to keep the 'forwards' token though, to make sure
@ -5988,7 +5988,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const timelineSet = eventTimeline.getTimelineSet(); const timelineSet = eventTimeline.getTimelineSet();
timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token); timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
this.processBeaconEvents(room, matrixEvents); this.processAggregatedTimelineEvents(room, matrixEvents);
this.processThreadRoots(room, matrixEvents, backwards); this.processThreadRoots(room, matrixEvents, backwards);
// if we've hit the end of the timeline, we need to stop trying to // if we've hit the end of the timeline, we need to stop trying to
@ -6035,7 +6035,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const originalEvent = await this.fetchRoomEvent(eventTimeline.getRoomId() ?? "", thread.id); const originalEvent = await this.fetchRoomEvent(eventTimeline.getRoomId() ?? "", thread.id);
timelineSet.addEventsToTimeline([mapper(originalEvent)], true, eventTimeline, null); timelineSet.addEventsToTimeline([mapper(originalEvent)], true, eventTimeline, null);
} }
this.processBeaconEvents(timelineSet.room, matrixEvents); this.processAggregatedTimelineEvents(timelineSet.room, matrixEvents);
// if we've hit the end of the timeline, we need to stop trying to // if we've hit the end of the timeline, we need to stop trying to
// paginate. We need to keep the 'forwards' token though, to make sure // paginate. We need to keep the 'forwards' token though, to make sure
@ -6073,7 +6073,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const timelineSet = eventTimeline.getTimelineSet(); const timelineSet = eventTimeline.getTimelineSet();
const [timelineEvents] = room.partitionThreadedEvents(matrixEvents); const [timelineEvents] = room.partitionThreadedEvents(matrixEvents);
timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token); timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
this.processBeaconEvents(room, timelineEvents); this.processAggregatedTimelineEvents(room, timelineEvents);
this.processThreadRoots( this.processThreadRoots(
room, room,
timelineEvents.filter((it) => it.getServerAggregatedRelation(THREAD_RELATION_TYPE.name)), timelineEvents.filter((it) => it.getServerAggregatedRelation(THREAD_RELATION_TYPE.name)),
@ -9360,10 +9360,22 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} }
public processBeaconEvents(room?: Room, events?: MatrixEvent[]): void { public processBeaconEvents(room?: Room, events?: MatrixEvent[]): void {
this.processAggregatedTimelineEvents(room, events);
}
/**
* Calls aggregation functions for event types that are aggregated
* Polls and location beacons
* @param room - room the events belong to
* @param events - timeline events to be processed
* @returns
*/
public processAggregatedTimelineEvents(room?: Room, events?: MatrixEvent[]): void {
if (!events?.length) return; if (!events?.length) return;
if (!room) return; if (!room) return;
room.currentState.processBeaconEvents(events, this); room.currentState.processBeaconEvents(events, this);
room.processPollEvents(events);
} }
/** /**

View File

@ -34,6 +34,7 @@ export * from "./models/event";
export * from "./models/room"; export * from "./models/room";
export * from "./models/event-timeline"; export * from "./models/event-timeline";
export * from "./models/event-timeline-set"; export * from "./models/event-timeline-set";
export * from "./models/poll";
export * from "./models/room-member"; export * from "./models/room-member";
export * from "./models/room-state"; export * from "./models/room-state";
export * from "./models/user"; export * from "./models/user";

175
src/models/poll.ts Normal file
View File

@ -0,0 +1,175 @@
/*
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 { M_POLL_END, M_POLL_RESPONSE, PollStartEvent } from "../@types/polls";
import { MatrixClient } from "../client";
import { MatrixEvent } from "./event";
import { Relations } from "./relations";
import { TypedEventEmitter } from "./typed-event-emitter";
export enum PollEvent {
New = "Poll.new",
End = "Poll.end",
Update = "Poll.update",
Responses = "Poll.Responses",
Destroy = "Poll.Destroy",
}
export type PollEventHandlerMap = {
[PollEvent.Update]: (event: MatrixEvent, poll: Poll) => 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<Exclude<PollEvent, PollEvent.New>, PollEventHandlerMap> {
public readonly roomId: string;
public readonly pollEvent: PollStartEvent;
private fetchingResponsesPromise: null | Promise<void> = 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<Relations> {
// 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<void> {
// 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);
}
}

View File

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -122,7 +122,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
* *
* @param event - The relation event to remove. * @param event - The relation event to remove.
*/ */
private async removeEvent(event: MatrixEvent): Promise<void> { public async removeEvent(event: MatrixEvent): Promise<void> {
if (!this.relations.has(event)) { if (!this.relations.has(event)) {
return; return;
} }

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { Optional } from "matrix-events-sdk"; import { M_POLL_START, Optional } from "matrix-events-sdk";
import { import {
EventTimelineSet, EventTimelineSet,
@ -65,6 +65,7 @@ import { IStateEventWithRoomId } from "../@types/search";
import { RelationsContainer } from "./relations-container"; import { RelationsContainer } from "./relations-container";
import { ReadReceipt, synthesizeReceipt } from "./read-receipt"; import { ReadReceipt, synthesizeReceipt } from "./read-receipt";
import { Feature, ServerSupport } from "../feature"; import { Feature, ServerSupport } from "../feature";
import { Poll, PollEvent } from "./poll";
// These constants are used as sane defaults when the homeserver doesn't support // 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 // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@ -162,7 +163,8 @@ export type RoomEmittedEvents =
| BeaconEvent.New | BeaconEvent.New
| BeaconEvent.Update | BeaconEvent.Update
| BeaconEvent.Destroy | BeaconEvent.Destroy
| BeaconEvent.LivenessChange; | BeaconEvent.LivenessChange
| PollEvent.New;
export type RoomEventHandlerMap = { export type RoomEventHandlerMap = {
/** /**
@ -289,6 +291,11 @@ export type RoomEventHandlerMap = {
[RoomEvent.UnreadNotifications]: (unreadNotifications?: NotificationCount, threadId?: string) => void; [RoomEvent.UnreadNotifications]: (unreadNotifications?: NotificationCount, threadId?: string) => void;
[RoomEvent.TimelineRefresh]: (room: Room, eventTimelineSet: EventTimelineSet) => void; [RoomEvent.TimelineRefresh]: (room: Room, eventTimelineSet: EventTimelineSet) => void;
[ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => 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<ThreadHandlerMap, ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete> & } & Pick<ThreadHandlerMap, ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete> &
EventTimelineSetHandlerMap & EventTimelineSetHandlerMap &
Pick<MatrixEventHandlerMap, MatrixEventEvent.BeforeRedaction> & Pick<MatrixEventHandlerMap, MatrixEventEvent.BeforeRedaction> &
@ -317,6 +324,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
*/ */
private unthreadedReceipts = new Map<string, Receipt>(); private unthreadedReceipts = new Map<string, Receipt>();
private readonly timelineSets: EventTimelineSet[]; private readonly timelineSets: EventTimelineSet[];
public readonly polls: Map<string, Poll> = new Map<string, Poll>();
public readonly threadsTimelineSets: EventTimelineSet[] = []; public readonly threadsTimelineSets: EventTimelineSet[] = [];
// any filtered timeline sets we're maintaining for this room // any filtered timeline sets we're maintaining for this room
private readonly filteredTimelineSets: Record<string, EventTimelineSet> = {}; // filter_id: timelineSet private readonly filteredTimelineSets: Record<string, EventTimelineSet> = {}; // filter_id: timelineSet
@ -1890,6 +1898,38 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
this.threadsReady = true; this.threadsReady = true;
} }
public async processPollEvents(events: MatrixEvent[]): Promise<void> {
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 * Fetch a single page of threadlist messages for the specific thread filter
* @internal * @internal