polfan-server-js-client 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ export declare class RoomMessagesHistory {
6
6
  private tracker;
7
7
  private historyWindows;
8
8
  private traverseLock;
9
+ private timeLimitedHistory;
9
10
  constructor(room: Room, tracker: ChatStateTracker);
10
11
  /**
11
12
  * Returns a history window object for the given topic ID, allowing you to view message history.
@@ -20,14 +21,24 @@ export declare class RoomMessagesHistory {
20
21
  *
21
22
  * The window bindings are preserved; only windows that the application had
22
23
  * actually pulled to the latest page (state === LATEST) are refreshed, with
23
- * a single resetToLatest instead of a chain of catch-up requests. Windows
24
- * that were never pulled (LIVE) or belong to an ephemeral room are left
25
- * untouched so their in-memory context survives the reconnect.
24
+ * a single request instead of a chain of catch-up requests. Windows that
25
+ * were never pulled (LIVE) or belong to an ephemeral room are left untouched
26
+ * so their in-memory context survives the reconnect.
27
+ *
28
+ * How a refreshed window is rebuilt depends on the room history mode: rooms
29
+ * keeping the full history are simply reset to the latest page (it can
30
+ * always be traversed back on demand), while rooms with a time-limited
31
+ * history (MaxAge) load the messages missed during the downtime on top of
32
+ * the already loaded ones, with the messages returned in both deduplicated.
33
+ * The maxAge retention applies to what the server serves - so that users
34
+ * joining later do not see the older conversation - and never to what this
35
+ * client already has: messages the user witnessed stay in the window until
36
+ * they are pushed out by its own size limit.
26
37
  */
27
38
  resync(room: Room): Promise<void>;
28
39
  private handleRoomUpdated;
29
40
  private handleNewTopic;
30
41
  private handleTopicDeleted;
31
42
  private createHistoryWindowForTopic;
32
- private updateTraverseLock;
43
+ private updateHistoryMode;
33
44
  }
@@ -34,6 +34,7 @@ export declare abstract class TraversableRemoteCollection<ItemT, EventMapT exten
34
34
  fetchLimit: number;
35
35
  lastFetchCount: number;
36
36
  oldestId: string | null;
37
+ gaps: string[];
37
38
  };
38
39
  /**
39
40
  * Number of items to fetch per request.
@@ -62,12 +63,44 @@ export declare abstract class TraversableRemoteCollection<ItemT, EventMapT exten
62
63
  */
63
64
  set retainRatio(value: number);
64
65
  get hasLatest(): boolean;
66
+ /**
67
+ * IDs of the items the window could not stitch to the ones loaded before
68
+ * them: there is a gap in front of each of them, i.e. the item right above
69
+ * it in the window is not its real predecessor and an unknown number of
70
+ * items in between was never fetched.
71
+ *
72
+ * Such a gap appears when the collection is resynchronised after a
73
+ * reconnect (see resyncToLatest) and more items than a single page arrived
74
+ * while the connection was down. The markers are kept in the window order
75
+ * and disappear together with the items they point at.
76
+ */
77
+ get gaps(): readonly string[];
65
78
  get hasOldest(): boolean;
66
79
  abstract createMirror(): TraversableRemoteCollection<ItemT, EventMapT>;
67
80
  resetToLatest(force?: boolean): Promise<void>;
81
+ /**
82
+ * Refresh the window with the latest page, keeping the already loaded items
83
+ * instead of replacing them.
84
+ *
85
+ * This is the reconnect-friendly variant of resetToLatest: the items missed
86
+ * while the connection was down are pulled with a single request and merged
87
+ * on top of the loaded ones (items returned in both are deduplicated), so
88
+ * the context the application already had does not disappear. The window
89
+ * size limit is the only thing that pushes the oldest items out.
90
+ *
91
+ * An empty or partial page is not a reason to drop anything: it only means
92
+ * the collection has little (or nothing) left on the remote side, while the
93
+ * items loaded earlier are still valid. When the page is full and does not
94
+ * reach the loaded items, an unknown number of items in between was never
95
+ * fetched - both parts are still kept, and the seam between them is recorded
96
+ * in `gaps` so the application can show where the history is not continuous.
97
+ */
98
+ resyncToLatest(): Promise<void>;
68
99
  fetchPrevious(): Promise<void>;
69
100
  fetchNext(): Promise<void>;
70
101
  jumpTo(id: string): Promise<void>;
102
+ delete(...ids: string[]): void;
103
+ deleteAll(): void;
71
104
  protected abstract fetchLatestItems(): Promise<ItemT[]>;
72
105
  protected abstract fetchItemsBefore(): Promise<ItemT[] | null>;
73
106
  protected abstract fetchItemsAfter(): Promise<ItemT[] | null>;
@@ -76,6 +109,20 @@ export declare abstract class TraversableRemoteCollection<ItemT, EventMapT exten
76
109
  protected refreshFetchedState(): Promise<void>;
77
110
  protected addItems(newItems: ItemT[], to: 'head' | 'tail'): void;
78
111
  protected emitChangeWithDiff(itemChanged: boolean, originalState: WindowState): void;
112
+ /**
113
+ * Record that the history is not continuous in front of the given item.
114
+ */
115
+ protected markGapBefore(id: string): void;
116
+ /**
117
+ * Forget the gap in front of the given item - the items before it are known
118
+ * to be its real predecessors now.
119
+ */
120
+ protected clearGapBefore(id: string): void;
121
+ /**
122
+ * Forget the gap markers pointing at items that are no longer in the window
123
+ * (trimmed, deleted or replaced), so `gaps` never refers to nothing.
124
+ */
125
+ protected dropDanglingGaps(): void;
79
126
  /**
80
127
  * Return array with messages trimmed using High/Low Watermark strategy.
81
128
  */
@@ -100,6 +147,7 @@ export declare class TopicHistoryWindow extends TraversableRemoteCollection<Mess
100
147
  get isTraverseLocked(): boolean;
101
148
  setTraverseLock(lock: boolean): Promise<void>;
102
149
  resetToLatest(force?: boolean): Promise<void>;
150
+ resyncToLatest(): Promise<void>;
103
151
  fetchNext(): Promise<void>;
104
152
  fetchPrevious(): Promise<void>;
105
153
  jumpTo(id: string): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polfan-server-js-client",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "JavaScript client library for handling communication with Polfan chat server.",
5
5
  "author": "Jarosław Żak",
6
6
  "license": "MIT",
@@ -6,6 +6,7 @@ import {TopicHistoryWindow, WindowState} from "./TopicHistoryWindow";
6
6
  export class RoomMessagesHistory {
7
7
  private historyWindows = new IndexedCollection<string, TopicHistoryWindow>();
8
8
  private traverseLock: boolean = false;
9
+ private timeLimitedHistory: boolean = false;
9
10
 
10
11
  public constructor(
11
12
  private room: Room,
@@ -15,7 +16,7 @@ export class RoomMessagesHistory {
15
16
  this.tracker.client.on('NewTopic', ev => this.handleNewTopic(ev));
16
17
  this.tracker.client.on('TopicDeleted', ev => this.handleTopicDeleted(ev));
17
18
 
18
- this.updateTraverseLock(this.room);
19
+ this.updateHistoryMode(this.room);
19
20
 
20
21
  if (this.room.defaultTopic) {
21
22
  this.createHistoryWindowForTopic(this.room.defaultTopic);
@@ -46,13 +47,23 @@ export class RoomMessagesHistory {
46
47
  *
47
48
  * The window bindings are preserved; only windows that the application had
48
49
  * actually pulled to the latest page (state === LATEST) are refreshed, with
49
- * a single resetToLatest instead of a chain of catch-up requests. Windows
50
- * that were never pulled (LIVE) or belong to an ephemeral room are left
51
- * untouched so their in-memory context survives the reconnect.
50
+ * a single request instead of a chain of catch-up requests. Windows that
51
+ * were never pulled (LIVE) or belong to an ephemeral room are left untouched
52
+ * so their in-memory context survives the reconnect.
53
+ *
54
+ * How a refreshed window is rebuilt depends on the room history mode: rooms
55
+ * keeping the full history are simply reset to the latest page (it can
56
+ * always be traversed back on demand), while rooms with a time-limited
57
+ * history (MaxAge) load the messages missed during the downtime on top of
58
+ * the already loaded ones, with the messages returned in both deduplicated.
59
+ * The maxAge retention applies to what the server serves - so that users
60
+ * joining later do not see the older conversation - and never to what this
61
+ * client already has: messages the user witnessed stay in the window until
62
+ * they are pushed out by its own size limit.
52
63
  */
53
64
  public async resync(room: Room): Promise<void> {
54
65
  this.room = room;
55
- this.updateTraverseLock(room);
66
+ this.updateHistoryMode(room);
56
67
 
57
68
  if (this.room.defaultTopic) {
58
69
  this.createHistoryWindowForTopic(this.room.defaultTopic);
@@ -68,7 +79,13 @@ export class RoomMessagesHistory {
68
79
  continue;
69
80
  }
70
81
 
71
- if (window.state === WindowState.LATEST) {
82
+ if (window.state !== WindowState.LATEST) {
83
+ continue;
84
+ }
85
+
86
+ if (this.timeLimitedHistory) {
87
+ await window.resyncToLatest();
88
+ } else {
72
89
  await window.resetToLatest(true);
73
90
  }
74
91
  } catch (_e) {
@@ -83,7 +100,7 @@ export class RoomMessagesHistory {
83
100
  if (this.room.id === ev.room.id) {
84
101
  this.room = ev.room;
85
102
 
86
- this.updateTraverseLock(ev.room);
103
+ this.updateHistoryMode(ev.room);
87
104
 
88
105
  if (ev.room.defaultTopic) {
89
106
  this.createHistoryWindowForTopic(ev.room.defaultTopic);
@@ -134,7 +151,8 @@ export class RoomMessagesHistory {
134
151
  }
135
152
  }
136
153
 
137
- private updateTraverseLock(room: Room): void {
138
- this.traverseLock = room.history.mode === 'Ephemeral';
154
+ private updateHistoryMode(room: Room): void {
155
+ this.traverseLock = room.history?.mode === 'Ephemeral';
156
+ this.timeLimitedHistory = room.history?.mode === 'MaxAge';
139
157
  }
140
158
  }
@@ -45,6 +45,7 @@ export abstract class TraversableRemoteCollection<
45
45
  fetchLimit: number,
46
46
  lastFetchCount: number,
47
47
  oldestId: string | null,
48
+ gaps: string[],
48
49
  } = {
49
50
  current: WindowState.LIVE,
50
51
  ongoing: undefined,
@@ -53,6 +54,7 @@ export abstract class TraversableRemoteCollection<
53
54
  fetchLimit: 50,
54
55
  lastFetchCount: 0,
55
56
  oldestId: null,
57
+ gaps: [],
56
58
  };
57
59
 
58
60
  /**
@@ -103,6 +105,21 @@ export abstract class TraversableRemoteCollection<
103
105
  return [WindowState.LATEST, WindowState.LIVE].includes(this.state);
104
106
  }
105
107
 
108
+ /**
109
+ * IDs of the items the window could not stitch to the ones loaded before
110
+ * them: there is a gap in front of each of them, i.e. the item right above
111
+ * it in the window is not its real predecessor and an unknown number of
112
+ * items in between was never fetched.
113
+ *
114
+ * Such a gap appears when the collection is resynchronised after a
115
+ * reconnect (see resyncToLatest) and more items than a single page arrived
116
+ * while the connection was down. The markers are kept in the window order
117
+ * and disappear together with the items they point at.
118
+ */
119
+ public get gaps(): readonly string[] {
120
+ return [...this.internalState.gaps];
121
+ }
122
+
106
123
  public get hasOldest(): boolean {
107
124
  return this.state === WindowState.OLDEST
108
125
  || this.state === WindowState.LATEST && this.length < this.fetchLimit
@@ -128,11 +145,68 @@ export abstract class TraversableRemoteCollection<
128
145
  }
129
146
 
130
147
  this._items.deleteAll(); // Directly call deleteAll to prevent event emit.
148
+ this.internalState.gaps = []; // Whole content is replaced, old markers mean nothing.
131
149
  this.addItems(result, 'tail');
132
150
  this.internalState.current = WindowState.LATEST;
133
151
  this.emitChangeWithDiff(true, originalState);
134
152
  }
135
153
 
154
+ /**
155
+ * Refresh the window with the latest page, keeping the already loaded items
156
+ * instead of replacing them.
157
+ *
158
+ * This is the reconnect-friendly variant of resetToLatest: the items missed
159
+ * while the connection was down are pulled with a single request and merged
160
+ * on top of the loaded ones (items returned in both are deduplicated), so
161
+ * the context the application already had does not disappear. The window
162
+ * size limit is the only thing that pushes the oldest items out.
163
+ *
164
+ * An empty or partial page is not a reason to drop anything: it only means
165
+ * the collection has little (or nothing) left on the remote side, while the
166
+ * items loaded earlier are still valid. When the page is full and does not
167
+ * reach the loaded items, an unknown number of items in between was never
168
+ * fetched - both parts are still kept, and the seam between them is recorded
169
+ * in `gaps` so the application can show where the history is not continuous.
170
+ */
171
+ public async resyncToLatest(): Promise<void> {
172
+ if (this.internalState.ongoing) {
173
+ return;
174
+ }
175
+
176
+ let result;
177
+ const originalState = this.state;
178
+ this.internalState.ongoing = WindowState.LATEST;
179
+
180
+ try {
181
+ result = await this.fetchLatestItems();
182
+ this.internalState.lastFetchCount = result.length;
183
+ } finally {
184
+ this.internalState.ongoing = undefined;
185
+ }
186
+
187
+ const loaded = this.items;
188
+ const fetchedIds = new Set(result.map(item => this.getId(item)));
189
+ // Items present in the page are taken from it - the server copy is the
190
+ // up-to-date one.
191
+ const retained = loaded.filter(item => ! fetchedIds.has(this.getId(item)));
192
+
193
+ // Nothing can be missing in between when there is nothing to stitch,
194
+ // when the page is everything the remote side has (it is shorter than
195
+ // the requested limit), or when the page reaches the newest loaded item.
196
+ const isContinuous = ! retained.length
197
+ || result.length < this.internalState.fetchLimit
198
+ || fetchedIds.has(this.getId(loaded[loaded.length - 1]));
199
+
200
+ if (! isContinuous) {
201
+ this.markGapBefore(this.getId(result[0]));
202
+ }
203
+
204
+ this._items.deleteAll(); // Directly call deleteAll to prevent event emit.
205
+ this.addItems([...retained, ...result], 'tail');
206
+ this.internalState.current = WindowState.LATEST;
207
+ this.emitChangeWithDiff(true, originalState);
208
+ }
209
+
136
210
  public async fetchPrevious(): Promise<void> {
137
211
  if (this.internalState.ongoing || this.hasOldest) {
138
212
  return;
@@ -140,6 +214,7 @@ export abstract class TraversableRemoteCollection<
140
214
 
141
215
  let result;
142
216
  const originalState = this.state;
217
+ const firstItem = this.getAt(0);
143
218
  this.internalState.ongoing = WindowState.PAST;
144
219
 
145
220
  try {
@@ -154,7 +229,6 @@ export abstract class TraversableRemoteCollection<
154
229
  }
155
230
 
156
231
  if (! result.length) {
157
- const firstItem = this.getAt(0);
158
232
  this.internalState.oldestId = firstItem ? this.getId(firstItem) : null;
159
233
 
160
234
  await this.refreshFetchedState();
@@ -168,6 +242,13 @@ export abstract class TraversableRemoteCollection<
168
242
  return;
169
243
  }
170
244
 
245
+ if (firstItem) {
246
+ // The fetch asked for the items right before the one that was first,
247
+ // so whatever came back is its real predecessor: a gap marked in
248
+ // front of it (it used to be the top of the window) is closed now.
249
+ this.clearGapBefore(this.getId(firstItem));
250
+ }
251
+
171
252
  this.addItems(result, 'head');
172
253
  await this.refreshFetchedState();
173
254
  this.emitChangeWithDiff(true, originalState);
@@ -217,6 +298,7 @@ export abstract class TraversableRemoteCollection<
217
298
 
218
299
  if (result) {
219
300
  this._items.deleteAll(); // Directly call deleteAll to prevent event emit.
301
+ this.internalState.gaps = []; // Whole content is replaced, old markers mean nothing.
220
302
  this.addItems(result, 'tail');
221
303
  await this.refreshFetchedState();
222
304
  }
@@ -227,6 +309,16 @@ export abstract class TraversableRemoteCollection<
227
309
  this.emitChangeWithDiff(!!result, originalState);
228
310
  }
229
311
 
312
+ public delete(...ids: string[]): void {
313
+ super.delete(...ids);
314
+ this.dropDanglingGaps();
315
+ }
316
+
317
+ public deleteAll(): void {
318
+ super.deleteAll();
319
+ this.internalState.gaps = [];
320
+ }
321
+
230
322
  protected abstract fetchLatestItems(): Promise<ItemT[]>;
231
323
 
232
324
  protected abstract fetchItemsBefore(): Promise<ItemT[] | null>;
@@ -255,6 +347,8 @@ export abstract class TraversableRemoteCollection<
255
347
  // Directly calls to prevent event emit.
256
348
  this._items.deleteAll();
257
349
  this._items.set(...(result.map(item => [this.getId(item), item] as [string, ItemT])));
350
+
351
+ this.dropDanglingGaps();
258
352
  }
259
353
 
260
354
  protected emitChangeWithDiff(itemChanged: boolean, originalState: WindowState): void {
@@ -263,6 +357,37 @@ export abstract class TraversableRemoteCollection<
263
357
  }
264
358
  }
265
359
 
360
+ /**
361
+ * Record that the history is not continuous in front of the given item.
362
+ */
363
+ protected markGapBefore(id: string): void {
364
+ if (! this.internalState.gaps.includes(id)) {
365
+ this.internalState.gaps = [...this.internalState.gaps, id];
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Forget the gap in front of the given item - the items before it are known
371
+ * to be its real predecessors now.
372
+ */
373
+ protected clearGapBefore(id: string): void {
374
+ if (this.internalState.gaps.includes(id)) {
375
+ this.internalState.gaps = this.internalState.gaps.filter(gapId => gapId !== id);
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Forget the gap markers pointing at items that are no longer in the window
381
+ * (trimmed, deleted or replaced), so `gaps` never refers to nothing.
382
+ */
383
+ protected dropDanglingGaps(): void {
384
+ if (! this.internalState.gaps.length) {
385
+ return;
386
+ }
387
+
388
+ this.internalState.gaps = this.internalState.gaps.filter(id => this.has(id));
389
+ }
390
+
266
391
  /**
267
392
  * Return array with messages trimmed using High/Low Watermark strategy.
268
393
  */
@@ -347,6 +472,13 @@ export class TopicHistoryWindow extends TraversableRemoteCollection<
347
472
  return super.resetToLatest(force);
348
473
  }
349
474
 
475
+ public async resyncToLatest(): Promise<void> {
476
+ if (this.internalState.traverseLock) {
477
+ return;
478
+ }
479
+ return super.resyncToLatest();
480
+ }
481
+
350
482
  public async fetchNext(): Promise<void> {
351
483
  if (this.internalState.traverseLock) {
352
484
  return;
@@ -209,6 +209,137 @@ test('history window - reset to latest', async () => {
209
209
  [7,8,9].forEach(id => expect(window.items.map(item => item.id)).toContain(id));
210
210
  });
211
211
 
212
+ test('history window - resync to latest merges the new page into loaded items', async () => {
213
+ const window = new TestableHistoryWindow();
214
+ window.limit = 10;
215
+ window.fetchLimit = 3;
216
+
217
+ await window.resetToLatest(); // [7,8,9]
218
+ await window.fetchPrevious(); // [4,5,6,7,8,9]
219
+
220
+ await window.resyncToLatest(); // fetched [7,8,9] overlaps -> nothing is lost
221
+
222
+ expect(window.state).toEqual(WindowState.LATEST);
223
+ expect(window.items.map(item => item.id)).toEqual([4, 5, 6, 7, 8, 9]);
224
+ });
225
+
226
+ test('history window - resync to latest trims the merged items to the limit', async () => {
227
+ const window = new TestableHistoryWindow();
228
+ window.limit = 4;
229
+ window.fetchLimit = 3;
230
+
231
+ await window.resetToLatest(); // [7,8,9]
232
+ await window.fetchPrevious(); // [4,5,6,7] (trimmed to the limit)
233
+
234
+ // [4,5,6,7] merged with the page [7,8,9] -> [4,5,6,7,8,9], trimmed again.
235
+ await window.resyncToLatest();
236
+
237
+ expect(window.state).toEqual(WindowState.LATEST);
238
+ expect(window.items.map(item => item.id)).toEqual([6, 7, 8, 9]);
239
+ });
240
+
241
+ test('history window - resync to latest marks the gap instead of dropping items', async () => {
242
+ const window = new TestableHistoryWindow();
243
+ window.limit = 10;
244
+ window.fetchLimit = 3;
245
+
246
+ await window.jumpTo('1'); // [0,1,2]
247
+
248
+ expect(window.gaps).toEqual([]);
249
+
250
+ // A full page ([7,8,9]) that does not reach the newest loaded item (2):
251
+ // items 3..6 were never fetched, so the seam in front of 7 is marked.
252
+ await window.resyncToLatest();
253
+
254
+ expect(window.state).toEqual(WindowState.LATEST);
255
+ expect(window.items.map(item => item.id)).toEqual([0, 1, 2, 7, 8, 9]);
256
+ expect(window.gaps).toEqual([7]);
257
+ });
258
+
259
+ test('history window - gap marker disappears with the item it points at', async () => {
260
+ const window = new TestableHistoryWindow();
261
+ window.limit = 10;
262
+ window.fetchLimit = 3;
263
+
264
+ await window.jumpTo('1'); // [0,1,2]
265
+ await window.resyncToLatest(); // [0,1,2] | [7,8,9]
266
+
267
+ expect(window.gaps).toEqual([7]);
268
+
269
+ window.delete(7);
270
+
271
+ expect(window.gaps).toEqual([]);
272
+ });
273
+
274
+ test('history window - gap marker is closed by fetching what is before it', async () => {
275
+ const window = new TestableHistoryWindow();
276
+ window.limit = 10;
277
+ window.fetchLimit = 3;
278
+
279
+ await window.jumpTo('1'); // [0,1,2]
280
+ await window.resyncToLatest(); // [0,1,2] | [7,8,9]
281
+ window.delete(0, 1, 2); // the items above the gap are gone, 7 is the top one
282
+
283
+ expect(window.gaps).toEqual([7]);
284
+
285
+ // Whatever comes back was fetched as the direct predecessor of 7, so the
286
+ // history is continuous again.
287
+ await window.fetchPrevious(); // [4,5,6,7,8,9]
288
+
289
+ expect(window.items.map(item => item.id)).toEqual([4, 5, 6, 7, 8, 9]);
290
+ expect(window.gaps).toEqual([]);
291
+ });
292
+
293
+ test('history window - gap markers are cleared when the whole window is replaced', async () => {
294
+ const window = new TestableHistoryWindow();
295
+ window.limit = 10;
296
+ window.fetchLimit = 3;
297
+
298
+ await window.jumpTo('1'); // [0,1,2]
299
+ await window.resyncToLatest(); // [0,1,2] | [7,8,9]
300
+
301
+ expect(window.gaps).toEqual([7]);
302
+
303
+ await window.resetToLatest(true); // [7,8,9] - a continuous window again
304
+
305
+ expect(window.items.map(item => item.id)).toEqual([7, 8, 9]);
306
+ expect(window.gaps).toEqual([]);
307
+ });
308
+
309
+ test('history window - gap marker is forgotten when trimmed out of the window', async () => {
310
+ const window = new TestableHistoryWindow();
311
+ window.limit = 10;
312
+ window.fetchLimit = 3;
313
+
314
+ await window.jumpTo('1'); // [0,1,2]
315
+ await window.resyncToLatest(); // [0,1,2] | [7,8,9]
316
+
317
+ expect(window.gaps).toEqual([7]);
318
+
319
+ // The window shrinks and the trimming pushes item 7 (and everything above
320
+ // it) out, so the marker has nothing left to point at.
321
+ window.limit = 2;
322
+ await window.resyncToLatest();
323
+
324
+ expect(window.items.map(item => item.id)).toEqual([8, 9]);
325
+ expect(window.gaps).toEqual([]);
326
+ });
327
+
328
+ test('history window - resync to latest keeps loaded items when the page is not full', async () => {
329
+ const window = new TestableHistoryWindow();
330
+ window.limit = 10;
331
+ window.fetchLimit = 5; // The fetch returns 3 items only - all there is.
332
+
333
+ await window.jumpTo('1'); // [0,1,2,3]
334
+
335
+ // Nothing is missing in between: the page is everything the remote side has,
336
+ // so the loaded items are kept even though the page does not reach them.
337
+ await window.resyncToLatest();
338
+
339
+ expect(window.state).toEqual(WindowState.LATEST);
340
+ expect(window.items.map(item => item.id)).toEqual([0, 1, 2, 3, 7, 8, 9]);
341
+ });
342
+
212
343
  test('history window - jump to message', async () => {
213
344
  const window = new TestableHistoryWindow();
214
345
  window.limit = 5;