diff --git a/spec/integ/matrix-client-syncing.spec.js b/spec/integ/matrix-client-syncing.spec.js
index 6adb35a50c0..8703ef29584 100644
--- a/spec/integ/matrix-client-syncing.spec.js
+++ b/spec/integ/matrix-client-syncing.spec.js
@@ -1,5 +1,6 @@
import { MatrixEvent } from "../../src/models/event";
import { EventTimeline } from "../../src/models/event-timeline";
+import { EventType } from "../../src/@types/event";
import * as utils from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
@@ -461,6 +462,211 @@ describe("MatrixClient syncing", function() {
xit("should update the room topic", function() {
});
+
+ fdescribe("onMarkerStateEvent", () => {
+ it('no marker means the timeline does not need a refresh (check a sane default)', async () => {
+ const syncData = {
+ next_batch: "batch_token",
+ rooms: {
+ join: {},
+ },
+ };
+ syncData.rooms.join[roomOne] = {
+ timeline: {
+ events: [
+ utils.mkMessage({
+ room: roomOne, user: otherUserId, msg: "hello",
+ }),
+ ],
+ prev_batch: "pagTok",
+ },
+ state: {
+ events: [
+ utils.mkEvent({
+ type: "m.room.create", room: roomOne, user: otherUserId,
+ content: {
+ creator: otherUserId,
+ },
+ }),
+ ],
+ },
+ };
+
+ httpBackend.when("GET", "/sync").respond(200, syncData);
+
+ client.startClient();
+ await Promise.all([
+ httpBackend.flushAllExpected(),
+ awaitSyncEvent(),
+ ]);
+
+ const room = client.getRoom(roomOne);
+ expect(room.getTimelineNeedsRefresh()).toEqual(false);
+ });
+
+ fit('when this is our first sync for the room, there is no timeline to refresh', async () => {
+ const syncData = {
+ next_batch: "batch_token",
+ rooms: {
+ join: {},
+ },
+ };
+ syncData.rooms.join[roomOne] = {
+ timeline: {
+ events: [
+ utils.mkEvent({
+ type: EventType.Marker, room: roomOne, user: otherUserId,
+ skey: "",
+ content: {
+ "m.insertion_id": "$abc",
+ },
+ }),
+ ],
+ prev_batch: "pagTok",
+ },
+ state: {
+ events: [
+ utils.mkEvent({
+ type: "m.room.create", room: roomOne, user: otherUserId,
+ content: {
+ creator: otherUserId,
+ },
+ }),
+ ],
+ },
+ };
+
+ httpBackend.when("GET", "/sync").respond(200, syncData);
+
+ client.startClient();
+ await Promise.all([
+ httpBackend.flushAllExpected(),
+ awaitSyncEvent(),
+ ]);
+
+ const room = client.getRoom(roomOne);
+ expect(room.getTimelineNeedsRefresh()).toEqual(false);
+ });
+
+ it('a new marker event should mark the timeline as needing a refresh', async () => {
+ const syncData = {
+ next_batch: "batch_token",
+ rooms: {
+ join: {},
+ },
+ };
+ syncData.rooms.join[roomOne] = {
+ timeline: {
+ events: [
+ utils.mkMessage({
+ room: roomOne, user: otherUserId, msg: "hello",
+ }),
+ ],
+ prev_batch: "pagTok",
+ },
+ state: {
+ events: [
+ utils.mkEvent({
+ type: "m.room.create", room: roomOne, user: otherUserId,
+ content: {
+ creator: otherUserId,
+ },
+ }),
+ ],
+ },
+ };
+
+ const nextSyncData = {
+ next_batch: "batch_token",
+ rooms: {
+ join: {},
+ },
+ };
+ nextSyncData.rooms.join[roomOne] = {
+ timeline: {
+ events: [
+ utils.mkEvent({
+ type: EventType.Marker, room: roomOne, user: otherUserId,
+ skey: "",
+ content: {
+ "m.insertion_id": "$abc",
+ },
+ }),
+ ],
+ prev_batch: "pagTok",
+ },
+ };
+
+ const markerEventId = nextSyncData.rooms.join[roomOne].timeline.events[0].event_id;
+
+ let emitCount = 0;
+ client.on("Room.historyImportedWithinTimeline", function(markerEvent, room) {
+ expect(markerEvent.getId()).toEqual(markerEventId);
+ expect(room.roomId).toEqual(roomOne);
+ emitCount += 1;
+ });
+
+ httpBackend.when("GET", "/sync").respond(200, syncData);
+ httpBackend.when("GET", "/sync").respond(200, nextSyncData);
+
+ client.startClient();
+ await Promise.all([
+ httpBackend.flushAllExpected(),
+ awaitSyncEvent(2),
+ ]);
+
+ const room = client.getRoom(roomOne);
+ expect(room.getTimelineNeedsRefresh()).toEqual(true);
+ // Make sure "Room.historyImportedWithinTimeline" was emitted
+ expect(emitCount).toEqual(1);
+ expect(room.getLastMarkerEventIdProcessed()).toEqual(markerEventId);
+ });
+
+ it('marker event sent far back in the scroll back but since our last sync will cause the timeline to refresh', async () => {
+ const syncData = {
+ next_batch: "batch_token",
+ rooms: {
+ join: {},
+ },
+ };
+ syncData.rooms.join[roomOne] = {
+ timeline: {
+ events: [
+ // TODO: Update this scenario to match test title
+ utils.mkEvent({
+ type: EventType.Marker, room: roomOne, user: otherUserId,
+ skey: "",
+ content: {
+ "m.insertion_id": "$abc",
+ },
+ }),
+ ],
+ prev_batch: "pagTok",
+ },
+ state: {
+ events: [
+ utils.mkEvent({
+ type: "m.room.create", room: roomOne, user: otherUserId,
+ content: {
+ creator: otherUserId,
+ },
+ }),
+ ],
+ },
+ };
+
+ httpBackend.when("GET", "/sync").respond(200, syncData);
+
+ client.startClient();
+ await Promise.all([
+ httpBackend.flushAllExpected(),
+ awaitSyncEvent(),
+ ]);
+
+ const room = client.getRoom(roomOne);
+ expect(room.getTimelineNeedsRefresh()).toEqual(true);
+ });
+ });
});
describe("timeline", function() {
diff --git a/spec/unit/event-timeline.spec.js b/spec/unit/event-timeline.spec.js
index c9311d0e387..d32287e3e14 100644
--- a/spec/unit/event-timeline.spec.js
+++ b/spec/unit/event-timeline.spec.js
@@ -49,10 +49,14 @@ describe("EventTimeline", function() {
];
timeline.initialiseState(events);
expect(timeline.startState.setStateEvents).toHaveBeenCalledWith(
- events,
+ events, {
+ fromInitialState: true,
+ },
);
expect(timeline.endState.setStateEvents).toHaveBeenCalledWith(
- events,
+ events, {
+ fromInitialState: true,
+ },
);
});
@@ -73,7 +77,7 @@ describe("EventTimeline", function() {
expect(function() {
timeline.initialiseState(state);
}).not.toThrow();
- timeline.addEvent(event, false);
+ timeline.addEvent(event, { toStartOfTimeline: false });
expect(function() {
timeline.initialiseState(state);
}).toThrow();
@@ -149,9 +153,9 @@ describe("EventTimeline", function() {
];
it("should be able to add events to the end", function() {
- timeline.addEvent(events[0], false);
+ timeline.addEvent(events[0], { toStartOfTimeline: false });
const initialIndex = timeline.getBaseIndex();
- timeline.addEvent(events[1], false);
+ timeline.addEvent(events[1], { toStartOfTimeline: false });
expect(timeline.getBaseIndex()).toEqual(initialIndex);
expect(timeline.getEvents().length).toEqual(2);
expect(timeline.getEvents()[0]).toEqual(events[0]);
@@ -159,9 +163,9 @@ describe("EventTimeline", function() {
});
it("should be able to add events to the start", function() {
- timeline.addEvent(events[0], true);
+ timeline.addEvent(events[0], { toStartOfTimeline: true });
const initialIndex = timeline.getBaseIndex();
- timeline.addEvent(events[1], true);
+ timeline.addEvent(events[1], { toStartOfTimeline: true });
expect(timeline.getBaseIndex()).toEqual(initialIndex + 1);
expect(timeline.getEvents().length).toEqual(2);
expect(timeline.getEvents()[0]).toEqual(events[1]);
@@ -203,9 +207,9 @@ describe("EventTimeline", function() {
content: { name: "Old Room Name" },
});
- timeline.addEvent(newEv, false);
+ timeline.addEvent(newEv, { toStartOfTimeline: false });
expect(newEv.sender).toEqual(sentinel);
- timeline.addEvent(oldEv, true);
+ timeline.addEvent(oldEv, { toStartOfTimeline: true });
expect(oldEv.sender).toEqual(oldSentinel);
});
@@ -242,9 +246,9 @@ describe("EventTimeline", function() {
const oldEv = utils.mkMembership({
room: roomId, mship: "ban", user: userB, skey: userA, event: true,
});
- timeline.addEvent(newEv, false);
+ timeline.addEvent(newEv, { toStartOfTimeline: false });
expect(newEv.target).toEqual(sentinel);
- timeline.addEvent(oldEv, true);
+ timeline.addEvent(oldEv, { toStartOfTimeline: true });
expect(oldEv.target).toEqual(oldSentinel);
});
@@ -262,13 +266,17 @@ describe("EventTimeline", function() {
}),
];
- timeline.addEvent(events[0], false);
- timeline.addEvent(events[1], false);
+ timeline.addEvent(events[0], { toStartOfTimeline: false });
+ timeline.addEvent(events[1], { toStartOfTimeline: false });
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
- toHaveBeenCalledWith([events[0]]);
+ toHaveBeenCalledWith([events[0]], {
+ fromInitialState: undefined,
+ });
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
- toHaveBeenCalledWith([events[1]]);
+ toHaveBeenCalledWith([events[1]], {
+ fromInitialState: undefined,
+ });
expect(events[0].forwardLooking).toBe(true);
expect(events[1].forwardLooking).toBe(true);
@@ -291,13 +299,17 @@ describe("EventTimeline", function() {
}),
];
- timeline.addEvent(events[0], true);
- timeline.addEvent(events[1], true);
+ timeline.addEvent(events[0], { toStartOfTimeline: true });
+ timeline.addEvent(events[1], { toStartOfTimeline: true });
expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents).
- toHaveBeenCalledWith([events[0]]);
+ toHaveBeenCalledWith([events[0]], {
+ fromInitialState: undefined,
+ });
expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents).
- toHaveBeenCalledWith([events[1]]);
+ toHaveBeenCalledWith([events[1]], {
+ fromInitialState: undefined,
+ });
expect(events[0].forwardLooking).toBe(false);
expect(events[1].forwardLooking).toBe(false);
@@ -324,8 +336,8 @@ describe("EventTimeline", function() {
];
it("should remove events", function() {
- timeline.addEvent(events[0], false);
- timeline.addEvent(events[1], false);
+ timeline.addEvent(events[0], { toStartOfTimeline: false });
+ timeline.addEvent(events[1], { toStartOfTimeline: false });
expect(timeline.getEvents().length).toEqual(2);
let ev = timeline.removeEvent(events[0].getId());
@@ -338,9 +350,9 @@ describe("EventTimeline", function() {
});
it("should update baseIndex", function() {
- timeline.addEvent(events[0], false);
- timeline.addEvent(events[1], true);
- timeline.addEvent(events[2], false);
+ timeline.addEvent(events[0], { toStartOfTimeline: false });
+ timeline.addEvent(events[1], { toStartOfTimeline: true });
+ timeline.addEvent(events[2], { toStartOfTimeline: false });
expect(timeline.getEvents().length).toEqual(3);
expect(timeline.getBaseIndex()).toEqual(1);
@@ -358,11 +370,11 @@ describe("EventTimeline", function() {
// further addEvent(ev, false) calls made the index increase.
it("should not make baseIndex assplode when removing the last event",
function() {
- timeline.addEvent(events[0], true);
+ timeline.addEvent(events[0], { toStartOfTimeline: true });
timeline.removeEvent(events[0].getId());
const initialIndex = timeline.getBaseIndex();
- timeline.addEvent(events[1], false);
- timeline.addEvent(events[2], false);
+ timeline.addEvent(events[1], { toStartOfTimeline: false });
+ timeline.addEvent(events[2], { toStartOfTimeline: false });
expect(timeline.getBaseIndex()).toEqual(initialIndex);
expect(timeline.getEvents().length).toEqual(2);
});
diff --git a/spec/unit/room-state.spec.js b/spec/unit/room-state.spec.js
index 261f7572d91..cefa3c5fe59 100644
--- a/spec/unit/room-state.spec.js
+++ b/spec/unit/room-state.spec.js
@@ -3,6 +3,7 @@ import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon";
import { filterEmitCallsByEventType } from "../test-utils/emitter";
import { RoomState, RoomStateEvent } from "../../src/models/room-state";
import { BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon";
+import { EventType } from "../../src/@types/event";
describe("RoomState", function() {
const roomId = "!foo:bar";
@@ -252,6 +253,29 @@ describe("RoomState", function() {
);
});
+ it("should emit 'RoomStateEvent.Marker' for each marker event", function() {
+ const events = [
+ utils.mkEvent({
+ event: true,
+ type: EventType.Marker,
+ room: roomId,
+ user: userA,
+ skey: "",
+ content: {
+ "m.insertion_id": "$abc",
+ },
+ }),
+ ];
+ let emitCount = 0;
+ state.on("RoomStateEvent.Marker", function(markerEvent, setStateOptions) {
+ expect(markerEvent).toEqual(events[emitCount]);
+ expect(setStateOptions).toEqual({ fromInitialState: true });
+ emitCount += 1;
+ });
+ state.setStateEvents(events, { fromInitialState: true });
+ expect(emitCount).toEqual(1);
+ });
+
describe('beacon events', () => {
it('adds new beacon info events to state and emits', () => {
const beaconEvent = makeBeaconInfoEvent(userA, roomId);
diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts
index 85f4c21d572..bb7b3887ea4 100644
--- a/spec/unit/room.spec.ts
+++ b/spec/unit/room.spec.ts
@@ -209,7 +209,9 @@ describe("Room", function() {
it("should throw if duplicateStrategy isn't 'replace' or 'ignore'", function() {
expect(function() {
- room.addLiveEvents(events, "foo");
+ room.addLiveEvents(events, {
+ duplicateStrategy: "foo",
+ });
}).toThrow();
});
@@ -221,7 +223,9 @@ describe("Room", function() {
dupe.event.event_id = events[0].getId();
room.addLiveEvents(events);
expect(room.timeline[0]).toEqual(events[0]);
- room.addLiveEvents([dupe], DuplicateStrategy.Replace);
+ room.addLiveEvents([dupe], {
+ duplicateStrategy: DuplicateStrategy.Replace,
+ });
expect(room.timeline[0]).toEqual(dupe);
});
@@ -233,7 +237,9 @@ describe("Room", function() {
dupe.event.event_id = events[0].getId();
room.addLiveEvents(events);
expect(room.timeline[0]).toEqual(events[0]);
- room.addLiveEvents([dupe], "ignore");
+ room.addLiveEvents([dupe], {
+ duplicateStrategy: "ignore",
+ });
expect(room.timeline[0]).toEqual(events[0]);
});
@@ -266,9 +272,11 @@ describe("Room", function() {
room.addLiveEvents(events);
expect(room.currentState.setStateEvents).toHaveBeenCalledWith(
[events[0]],
+ { fromInitialState: false },
);
expect(room.currentState.setStateEvents).toHaveBeenCalledWith(
[events[1]],
+ { fromInitialState: false },
);
expect(events[0].forwardLooking).toBe(true);
expect(events[1].forwardLooking).toBe(true);
@@ -470,9 +478,11 @@ describe("Room", function() {
room.addEventsToTimeline(events, true, room.getLiveTimeline());
expect(room.oldState.setStateEvents).toHaveBeenCalledWith(
[events[0]],
+ { fromInitialState: false },
);
expect(room.oldState.setStateEvents).toHaveBeenCalledWith(
[events[1]],
+ { fromInitialState: false },
);
expect(events[0].forwardLooking).toBe(false);
expect(events[1].forwardLooking).toBe(false);
diff --git a/spec/unit/timeline-window.spec.js b/spec/unit/timeline-window.spec.js
index c9466412c83..a0d61207301 100644
--- a/spec/unit/timeline-window.spec.js
+++ b/spec/unit/timeline-window.spec.js
@@ -35,13 +35,15 @@ function createTimeline(numEvents, baseIndex) {
return timeline;
}
-function addEventsToTimeline(timeline, numEvents, atStart) {
+function addEventsToTimeline(timeline, numEvents, toStartOfTimeline) {
for (let i = 0; i < numEvents; i++) {
timeline.addEvent(
utils.mkMessage({
room: ROOM_ID, user: USER_ID,
event: true,
- }), atStart,
+ }), {
+ toStartOfTimeline,
+ },
);
}
}
diff --git a/src/@types/event.ts b/src/@types/event.ts
index e5eac34f948..95fbb66943c 100644
--- a/src/@types/event.ts
+++ b/src/@types/event.ts
@@ -87,6 +87,8 @@ export enum EventType {
RoomKeyRequest = "m.room_key_request",
ForwardedRoomKey = "m.forwarded_room_key",
Dummy = "m.dummy",
+
+ Marker = "org.matrix.msc2716.marker", // MSC2716
}
export enum RelationType {
diff --git a/src/client.ts b/src/client.ts
index bc55145e557..5b26cd3c95b 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -790,6 +790,7 @@ type RoomEvents = RoomEvent.Name
| RoomEvent.Receipt
| RoomEvent.Tags
| RoomEvent.LocalEchoUpdated
+ | RoomEvent.historyImportedWithinTimeline
| RoomEvent.AccountData
| RoomEvent.MyMembership
| RoomEvent.Timeline
@@ -799,6 +800,7 @@ type RoomStateEvents = RoomStateEvent.Events
| RoomStateEvent.Members
| RoomStateEvent.NewMember
| RoomStateEvent.Update
+ | RoomStateEvent.Marker
;
type CryptoEvents = CryptoEvent.KeySignatureUploadFailure
diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts
index 13ea8c458f2..9973dc08738 100644
--- a/src/models/event-timeline-set.ts
+++ b/src/models/event-timeline-set.ts
@@ -56,6 +56,40 @@ export interface IRoomTimelineData {
liveEvent?: boolean;
}
+export interface IAddLiveEventOptions {
+ /** Applies to events in the timeline only. If this is 'replace' then if a
+ * duplicate is encountered, the event passed to this function will replace
+ * the existing event in the timeline. If this is not specified, or is
+ * 'ignore', then the event passed to this function will be ignored
+ * entirely, preserving the existing event in the timeline. Events are
+ * identical based on their event ID only. */
+ duplicateStrategy?: DuplicateStrategy;
+ /** Whether the sync response came from cache */
+ fromCache?: boolean;
+ /** The state events to reconcile metadata from */
+ roomState?: RoomState;
+ /** Whether the state is part of the first state snapshot we're seeing in
+ * the room. This could be happen in a variety of cases:
+ * 1. From the initial sync
+ * 2. It's the first state we're seeing after joining the room
+ * 3. Or whether it's coming from `syncFromCache` */
+ fromInitialState?: boolean;
+}
+
+export interface IAddEventToTimelineOptions {
+ toStartOfTimeline: boolean;
+ /** Whether the sync response came from cache */
+ fromCache?: boolean;
+ /** The state events to reconcile metadata from */
+ roomState?: RoomState;
+ /** Whether the state is part of the first state snapshot we're seeing in
+ * the room. This could be happen in a variety of cases:
+ * 1. From the initial sync
+ * 2. It's the first state we're seeing after joining the room
+ * 3. Or whether it's coming from `syncFromCache` */
+ fromInitialState?: boolean;
+}
+
type EmittedEvents = RoomEvent.Timeline | RoomEvent.TimelineReset;
export type EventTimelineSetHandlerMap = {
@@ -431,7 +465,9 @@ export class EventTimelineSet extends TypedEventEmitter { return ev.getType(); }));
if (this.events.length > 0) {
throw new Error("Cannot initialise state after events are added");
}
@@ -152,8 +167,11 @@ export class EventTimeline {
Object.freeze(e);
}
- this.startState.setStateEvents(stateEvents);
- this.endState.setStateEvents(stateEvents);
+ const setStateOptions: ISetStateOptions = {
+ fromInitialState: true,
+ };
+ this.startState.setStateEvents(stateEvents, setStateOptions);
+ this.endState.setStateEvents(stateEvents, setStateOptions);
}
/**
@@ -345,24 +363,33 @@ export class EventTimeline {
* Add a new event to the timeline, and update the state
*
* @param {MatrixEvent} event new event
- * @param {boolean} atStart true to insert new event at the start
+ * @param {IAddEventOptions} options addEvent options
*/
- public addEvent(event: MatrixEvent, atStart: boolean, stateContext?: RoomState): void {
- if (!stateContext) {
- stateContext = atStart ? this.startState : this.endState;
+ public addEvent(
+ event: MatrixEvent,
+ {
+ toStartOfTimeline,
+ roomState,
+ fromInitialState,
+ }: IAddEventOptions,
+ ): void {
+ if (!roomState) {
+ roomState = toStartOfTimeline ? this.startState : this.endState;
}
const timelineSet = this.getTimelineSet();
if (timelineSet.room) {
- EventTimeline.setEventMetadata(event, stateContext, atStart);
+ EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline);
// modify state but only on unfiltered timelineSets
if (
event.isState() &&
timelineSet.room.getUnfilteredTimelineSet() === timelineSet
) {
- stateContext.setStateEvents([event]);
+ roomState.setStateEvents([event], {
+ fromInitialState: fromInitialState,
+ });
// it is possible that the act of setting the state event means we
// can set more metadata (specifically sender/target props), so try
// it again if the prop wasn't previously set. It may also mean that
@@ -373,22 +400,22 @@ export class EventTimeline {
// back in time, else we'll set the .sender value for BEFORE the given
// member event, whereas we want to set the .sender value for the ACTUAL
// member event itself.
- if (!event.sender || (event.getType() === "m.room.member" && !atStart)) {
- EventTimeline.setEventMetadata(event, stateContext, atStart);
+ if (!event.sender || (event.getType() === "m.room.member" && !toStartOfTimeline)) {
+ EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline);
}
}
}
let insertIndex;
- if (atStart) {
+ if (toStartOfTimeline) {
insertIndex = 0;
} else {
insertIndex = this.events.length;
}
this.events.splice(insertIndex, 0, event); // insert element
- if (atStart) {
+ if (toStartOfTimeline) {
this.baseIndex++;
}
}
diff --git a/src/models/room-state.ts b/src/models/room-state.ts
index 5719034397c..248c7428d31 100644
--- a/src/models/room-state.ts
+++ b/src/models/room-state.ts
@@ -39,12 +39,24 @@ enum OobStatus {
Finished,
}
+export interface ISetStateOptions {
+ /** Whether the sync response came from cache */
+ fromCache?: boolean;
+ /** Whether the state is part of the first state snapshot we're seeing in
+ * the room. This could be happen in a variety of cases:
+ * 1. From the initial sync
+ * 2. It's the first state we're seeing after joining the room
+ * 3. Or whether it's coming from `syncFromCache` */
+ fromInitialState?: boolean;
+}
+
export enum RoomStateEvent {
Events = "RoomState.events",
Members = "RoomState.members",
NewMember = "RoomState.newMember",
Update = "RoomState.update", // signals batches of updates without specificity
BeaconLiveness = "RoomState.BeaconLiveness",
+ Marker = "RoomState.org.matrix.msc2716.marker",
}
export type RoomStateEventHandlerMap = {
@@ -54,6 +66,7 @@ export type RoomStateEventHandlerMap = {
[RoomStateEvent.Update]: (state: RoomState) => void;
[RoomStateEvent.BeaconLiveness]: (state: RoomState, hasLiveBeacons: boolean) => void;
[BeaconEvent.New]: (event: MatrixEvent, beacon: Beacon) => void;
+ [RoomStateEvent.Marker]: (event: MatrixEvent, setStateOptions: ISetStateOptions) => void;
};
type EmittedEvents = RoomStateEvent | BeaconEvent;
@@ -312,16 +325,24 @@ export class RoomState extends TypedEventEmitter
}
/**
- * Add an array of one or more state MatrixEvents, overwriting
- * any existing state with the same {type, stateKey} tuple. Will fire
- * "RoomState.events" for every event added. May fire "RoomState.members"
- * if there are m.room.member
events.
+ * Add an array of one or more state MatrixEvents, overwriting any existing
+ * state with the same {type, stateKey} tuple. Will fire "RoomState.events"
+ * for every event added. May fire "RoomState.members" if there are
+ * m.room.member
events. May fire "RoomStateEvent.Marker" if there are
+ * EventType.Marker
events.
* @param {MatrixEvent[]} stateEvents a list of state events for this room.
+ * @param {boolean} fromInitialState whether the stateEvents are from the first
+ * sync in the room or a sync we already know about (syncFromCache)
* @fires module:client~MatrixClient#event:"RoomState.members"
* @fires module:client~MatrixClient#event:"RoomState.newMember"
* @fires module:client~MatrixClient#event:"RoomState.events"
+ * @fires module:client~MatrixClient#event:"RoomStateEvent.Marker"
*/
- public setStateEvents(stateEvents: MatrixEvent[]) {
+ public setStateEvents(stateEvents: MatrixEvent[], setStateOptions?: ISetStateOptions) {
+ Error.stackTraceLimit = Infinity;
+ console.log(`setStateEvents fromInitialState=${setStateOptions && setStateOptions.fromInitialState} stateEvents:\n`, stateEvents.map((ev) => {
+ return `\t${ev.getType()} ${ev.getId()}`;
+ }).join('\n'), new Error().stack);
this.updateModifiedTime();
// update the core event dict
@@ -401,6 +422,8 @@ export class RoomState extends TypedEventEmitter
// assume all our sentinels are now out-of-date
this.sentinels = {};
+ } else if (event.getType() === EventType.Marker) {
+ this.emit(RoomStateEvent.Marker, event, setStateOptions);
}
});
diff --git a/src/models/room.ts b/src/models/room.ts
index af3dd4758b8..e6f566c1533 100644
--- a/src/models/room.ts
+++ b/src/models/room.ts
@@ -48,6 +48,7 @@ import {
} from "./thread";
import { Method } from "../http-api";
import { TypedEventEmitter } from "./typed-event-emitter";
+import { IAddLiveEventOptions } from "./event-timeline-set";
// 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
@@ -164,6 +165,7 @@ export enum RoomEvent {
LocalEchoUpdated = "Room.localEchoUpdated",
Timeline = "Room.timeline",
TimelineReset = "Room.timelineReset",
+ historyImportedWithinTimeline = "Room.historyImportedWithinTimeline",
}
type EmittedEvents = RoomEvent
@@ -172,6 +174,7 @@ type EmittedEvents = RoomEvent
| ThreadEvent.NewReply
| RoomEvent.Timeline
| RoomEvent.TimelineReset
+ | RoomEvent.historyImportedWithinTimeline
| MatrixEventEvent.BeforeRedaction;
export type RoomEventHandlerMap = {
@@ -188,6 +191,10 @@ export type RoomEventHandlerMap = {
oldEventId?: string,
oldStatus?: EventStatus,
) => void;
+ [RoomEvent.historyImportedWithinTimeline]: (
+ markerEvent: MatrixEvent,
+ room: Room,
+ ) => void;
[ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void;
} & ThreadHandlerMap & MatrixEventHandlerMap;
@@ -205,6 +212,8 @@ export class Room extends TypedEventEmitter
public readonly threadsTimelineSets: EventTimelineSet[] = [];
// any filtered timeline sets we're maintaining for this room
private readonly filteredTimelineSets: Record = {}; // filter_id: timelineSet
+ private timelineNeedsRefresh = false;
+ private lastMarkerEventIdProcessed: string = null;
private readonly pendingEventList?: MatrixEvent[];
// read by megolm via getter; boolean value - null indicates "use global value"
private blacklistUnverifiedDevices: boolean = null;
@@ -442,6 +451,19 @@ export class Room extends TypedEventEmitter
return Promise.allSettled(decryptionPromises) as unknown as Promise;
}
+ /**
+ * Gets the creator of the room
+ * @returns {string} The creator of the room, or null if it could not be determined
+ */
+ public getRoomCreator(): string | null {
+ const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, "");
+ if (!createEvent) {
+ return null;
+ }
+ const roomCreator = createEvent.getContent()['creator'];
+ return roomCreator;
+ }
+
/**
* Gets the version of the room
* @returns {string} The version of the room, or null if it could not be determined
@@ -1007,6 +1029,43 @@ export class Room extends TypedEventEmitter
return this.getUnfilteredTimelineSet().addTimeline();
}
+ /**
+ * Whether the timeline needs to be refreshed in order to pull in new
+ * historical messages that were imported.
+ * @param {Boolean} value The value to set
+ */
+ public setTimelineNeedsRefresh(value: boolean): void {
+ this.timelineNeedsRefresh = value;
+ }
+
+ /**
+ * Whether the timeline needs to be refreshed in order to pull in new
+ * historical messages that were imported.
+ * @return {Boolean} .
+ */
+ public getTimelineNeedsRefresh(): boolean {
+ return this.timelineNeedsRefresh;
+ }
+
+ /**
+ * Get the last marker event ID proccessed
+ *
+ * @return {String} the last marker event ID proccessed or null if none have
+ * been processed
+ */
+ public getLastMarkerEventIdProcessed(): string | null {
+ return this.lastMarkerEventIdProcessed;
+ }
+
+ /**
+ * Set the last marker event ID proccessed
+ *
+ * @param {String} eventId The marker event ID to set
+ */
+ public setLastMarkerEventIdProcessed(eventId: string): void {
+ this.lastMarkerEventIdProcessed = eventId;
+ }
+
/**
* Get an event which is stored in our unfiltered timeline set, or in a thread
*
@@ -1463,7 +1522,9 @@ export class Room extends TypedEventEmitter
return event.getSender() === this.client.getUserId();
});
if (filterType !== ThreadFilterType.My || currentUserParticipated) {
- timelineSet.getLiveTimeline().addEvent(thread.rootEvent, false);
+ timelineSet.getLiveTimeline().addEvent(thread.rootEvent, {
+ toStartOfTimeline: false,
+ });
}
});
}
@@ -1510,22 +1571,20 @@ export class Room extends TypedEventEmitter
let latestMyThreadsRootEvent: MatrixEvent;
const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
for (const rootEvent of threadRoots) {
- this.threadsTimelineSets[0].addLiveEvent(
- rootEvent,
- DuplicateStrategy.Ignore,
- false,
+ this.threadsTimelineSets[0].addLiveEvent(rootEvent, {
+ duplicateStrategy: DuplicateStrategy.Ignore,
+ fromCache: false,
roomState,
- );
+ });
const threadRelationship = rootEvent
.getServerAggregatedRelation(RelationType.Thread);
if (threadRelationship.current_user_participated) {
- this.threadsTimelineSets[1].addLiveEvent(
- rootEvent,
- DuplicateStrategy.Ignore,
- false,
+ this.threadsTimelineSets[1].addLiveEvent(rootEvent, {
+ duplicateStrategy: DuplicateStrategy.Ignore,
+ fromCache: false,
roomState,
- );
+ });
latestMyThreadsRootEvent = rootEvent;
}
@@ -1756,7 +1815,7 @@ export class Room extends TypedEventEmitter
timelineSet.addEventToTimeline(
thread.rootEvent,
timelineSet.getLiveTimeline(),
- toStartOfTimeline,
+ { toStartOfTimeline },
);
}
}
@@ -1839,15 +1898,14 @@ export class Room extends TypedEventEmitter
* "Room.timeline".
*
* @param {MatrixEvent} event Event to be added
- * @param {string?} duplicateStrategy 'ignore' or 'replace'
- * @param {boolean} fromCache whether the sync response came from cache
+ * @param {IAddLiveEventOptions} options addLiveEvent options
* @fires module:client~MatrixClient#event:"Room.timeline"
* @private
*/
- private addLiveEvent(event: MatrixEvent, duplicateStrategy: DuplicateStrategy, fromCache = false): void {
+ private addLiveEvent(event: MatrixEvent, addLiveEventOptions: IAddLiveEventOptions = {}): void {
// add to our timeline sets
for (let i = 0; i < this.timelineSets.length; i++) {
- this.timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache);
+ this.timelineSets[i].addLiveEvent(event, addLiveEventOptions);
}
// synthesize and inject implicit read receipts
@@ -1933,11 +1991,15 @@ export class Room extends TypedEventEmitter
if (timelineSet.getFilter()) {
if (timelineSet.getFilter().filterRoomTimeline([event]).length) {
timelineSet.addEventToTimeline(event,
- timelineSet.getLiveTimeline(), false);
+ timelineSet.getLiveTimeline(), {
+ toStartOfTimeline: false,
+ });
}
} else {
timelineSet.addEventToTimeline(event,
- timelineSet.getLiveTimeline(), false);
+ timelineSet.getLiveTimeline(), {
+ toStartOfTimeline: false,
+ });
}
}
}
@@ -2174,18 +2236,11 @@ export class Room extends TypedEventEmitter
* they will go to the end of the timeline.
*
* @param {MatrixEvent[]} events A list of events to add.
- *
- * @param {string} duplicateStrategy Optional. Applies to events in the
- * timeline only. If this is 'replace' then if a duplicate is encountered, the
- * event passed to this function will replace the existing event in the
- * timeline. If this is not specified, or is 'ignore', then the event passed to
- * this function will be ignored entirely, preserving the existing event in the
- * timeline. Events are identical based on their event ID only.
- *
- * @param {boolean} fromCache whether the sync response came from cache
+ * @param {IAddLiveEventOptions} options addLiveEvent options
* @throws If duplicateStrategy
is not falsey, 'replace' or 'ignore'.
*/
- public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache = false): void {
+ public addLiveEvents(events: MatrixEvent[], addLiveEventOptions: IAddLiveEventOptions = {}): void {
+ const { duplicateStrategy } = addLiveEventOptions;
if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) {
throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'");
}
@@ -2226,7 +2281,7 @@ export class Room extends TypedEventEmitter
}
if (shouldLiveInRoom) {
- this.addLiveEvent(events[i], duplicateStrategy, fromCache);
+ this.addLiveEvent(events[i], addLiveEventOptions);
}
}
diff --git a/src/models/thread.ts b/src/models/thread.ts
index 14a036b3046..60c1d013b85 100644
--- a/src/models/thread.ts
+++ b/src/models/thread.ts
@@ -172,9 +172,11 @@ export class Thread extends TypedEventEmitter {
this.timelineSet.addEventToTimeline(
event,
this.liveTimeline,
- toStartOfTimeline,
- false,
- this.roomState,
+ {
+ toStartOfTimeline,
+ fromCache: false,
+ roomState: this.roomState,
+ },
);
}
}
diff --git a/src/sync.ts b/src/sync.ts
index 30df2cb7e6c..df3b6582061 100644
--- a/src/sync.ts
+++ b/src/sync.ts
@@ -54,6 +54,7 @@ import { IPushRules } from "./@types/PushRules";
import { RoomStateEvent } from "./models/room-state";
import { RoomMemberEvent } from "./models/room-member";
import { BeaconEvent } from "./models/beacon";
+import { ISetStateOptions } from "./models/room-state";
const DEBUG = true;
@@ -77,6 +78,13 @@ export enum SyncState {
Reconnecting = "RECONNECTING",
}
+// Room versions where "insertion", "batch", and "marker" events are controlled
+// by power-levels. MSC2716 is supported in existing room versions but they
+// should only have special meaning when the room creator sends them.
+const MSC2716_ROOM_VERSIONS = [
+ 'org.matrix.msc2716v3',
+];
+
function getFilterName(userId: string, suffix?: string): string {
// scope this on the user ID because people may login on many accounts
// and they all need to be stored!
@@ -202,6 +210,7 @@ export class SyncApi {
RoomEvent.Receipt,
RoomEvent.Tags,
RoomEvent.LocalEchoUpdated,
+ RoomEvent.historyImportedWithinTimeline,
RoomEvent.AccountData,
RoomEvent.MyMembership,
RoomEvent.Timeline,
@@ -240,6 +249,96 @@ export class SyncApi {
RoomMemberEvent.Membership,
]);
});
+
+ room.currentState.on(RoomStateEvent.Marker, (markerEvent, setStateOptions) => {
+ this.onMarkerStateEvent(room, markerEvent, setStateOptions);
+ });
+ }
+
+ /** When we see the marker state change in the room, we know there is some
+ * new historical messages imported by MSC2716 `/batch_send` somewhere in
+ * the room and we need to throw away the timeline to make sure the
+ * historical messages are shown when we paginate `/messages` again.
+ * @param {Room} room The room where the marker event was sent
+ * @param {MatrixEvent} markerEvent The new marker event
+ * @param {ISetStateOptions} setStateOptions When `fromInitialState` is set
+ * as `true`, the given marker event will be ignored
+ */
+ private onMarkerStateEvent(
+ room: Room,
+ markerEvent: MatrixEvent,
+ { fromInitialState }: ISetStateOptions = {},
+ ): void {
+ // We don't want to refresh the timeline:
+ // 1. If it's persons first time syncing the room, they won't have
+ // any old events cached to refresh. This could be from initial
+ // sync or just the first time syncing the room since joining.
+ // 2. If we're re-hydrating from `syncFromCache` because we already
+ // processed any marker event state that was in the cache
+ if (fromInitialState) {
+ logger.debug(
+ `MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} ` +
+ `because it's from initial state.`,
+ );
+ return;
+ }
+
+ const isValidMsc2716Event =
+ // Check whether the room version directly supports MSC2716, in
+ // which case, "marker" events are already auth'ed by
+ // power_levels
+ MSC2716_ROOM_VERSIONS.includes(room.getVersion()) ||
+ // MSC2716 is also supported in all existing room versions but
+ // special meaning should only be given to "insertion", "batch",
+ // and "marker" events when they come from the room creator
+ markerEvent.getSender() === room.getRoomCreator();
+
+ if (!isValidMsc2716Event) {
+ logger.debug(
+ `MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} because ` +
+ `MSC2716 is not supported in the room version or for any room version, the marker wasn't sent ` +
+ `by the room creator.`,
+ );
+ }
+
+ // Don't process a marker event multiple times; we only need to
+ // throw the timeline away once, when we see a marker. All of the
+ // historical content will be in the `/messsages` responses from
+ // here on out.
+ const markerAlreadyProcessed = markerEvent.getId() === room.getLastMarkerEventIdProcessed();
+ if (markerAlreadyProcessed) {
+ logger.debug(
+ `MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} because ` +
+ `it has already been processed.`,
+ );
+ }
+
+ // It would be nice if we could also specifically tell whether the
+ // historical messages actually affected the locally cached client
+ // timeline or not. The problem is we can't see the prev_events of
+ // the base insertion event that the marker was pointing to because
+ // prev_events aren't available in the client API's. In most cases,
+ // the history won't be in people's locally cached timelines in the
+ // client, so we don't need to bother everyone about refreshing
+ // their timeline. This works for a v1 though and there are use
+ // cases like initially bootstrapping your bridged room where people
+ // are likely to encounter the historical messages affecting their
+ // current timeline (think someone signing up for Beeper and
+ // importing their Whatsapp history).
+ if (
+ isValidMsc2716Event &&
+ !markerAlreadyProcessed
+ ) {
+ // Saw new marker event, let's let the clients know they should
+ // refresh the timeline.
+ logger.debug(
+ `MarkerState: Timeline needs to be refreshed because ` +
+ `a new markerEventId=${markerEvent.getId()} was sent in roomId=${room.roomId}`,
+ );
+ room.setTimelineNeedsRefresh(true);
+ room.emit(RoomEvent.historyImportedWithinTimeline, markerEvent, room);
+ room.setLastMarkerEventIdProcessed(markerEvent.getId());
+ }
}
/**
@@ -251,6 +350,7 @@ export class SyncApi {
room.currentState.removeAllListeners(RoomStateEvent.Events);
room.currentState.removeAllListeners(RoomStateEvent.Members);
room.currentState.removeAllListeners(RoomStateEvent.NewMember);
+ room.currentState.removeAllListeners(RoomStateEvent.Marker);
}
/**
@@ -1635,7 +1735,11 @@ export class SyncApi {
// if the timeline has any state events in it.
// This also needs to be done before running push rules on the events as they need
// to be decorated with sender etc.
- room.addLiveEvents(timelineEventList || [], null, fromCache);
+
+ room.addLiveEvents(timelineEventList || [], {
+ fromCache,
+ fromInitialState: timelineWasEmpty || fromCache
+ });
this.client.processBeaconEvents(room, timelineEventList);
}