You've already forked matrix-js-sdk
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 commite578d84464
. * Revert "Revert "allow alt event types in relations model"" This reverts commit515db7a8bc
. * 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:
246
spec/unit/models/poll.spec.ts
Normal file
246
spec/unit/models/poll.spec.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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(() => {
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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
175
src/models/poll.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
Reference in New Issue
Block a user