cozy-client 60.30.6 → 60.31.0

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.
@@ -0,0 +1,978 @@
1
+ "use strict";
2
+
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
4
+
5
+ Object.defineProperty(exports, "__esModule", {
6
+ value: true
7
+ });
8
+ exports.dismiss = exports.getActiveBanner = exports.getActiveBanners = exports.getVisibleBanners = exports.getCta = exports.getSurface = exports.getSeverity = exports.byPriority = exports.isInWindow = exports.isDismissed = exports.isSupported = exports.isTrusted = exports.buildBannersQuery = exports.BANNERS_QUERY_NAME = exports.KNOWN_SURFACES = exports.KNOWN_SEVERITIES = exports.SUPPORTED_DOCTYPE_VERSION = exports.BANNERS_DOCTYPE = void 0;
9
+
10
+ var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray"));
11
+
12
+ var _regenerator = _interopRequireDefault(require("@babel/runtime/regenerator"));
13
+
14
+ var _asyncToGenerator2 = _interopRequireDefault(require("@babel/runtime/helpers/asyncToGenerator"));
15
+
16
+ var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
17
+
18
+ var _isValid = _interopRequireDefault(require("date-fns/isValid"));
19
+
20
+ var _parseISO = _interopRequireDefault(require("date-fns/parseISO"));
21
+
22
+ var _dsl = require("../queries/dsl");
23
+
24
+ var _utils = require("./utils");
25
+
26
+ function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; }
27
+
28
+ function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { (0, _defineProperty2.default)(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; }
29
+
30
+ function _createForOfIteratorHelper(o, allowArrayLike) { var it; if (typeof Symbol === "undefined" || o[Symbol.iterator] == null) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = o[Symbol.iterator](); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it.return != null) it.return(); } finally { if (didErr) throw err; } } }; }
31
+
32
+ function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
33
+
34
+ function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; }
35
+
36
+ var BANNERS_DOCTYPE = 'io.cozy.banners';
37
+ /**
38
+ * The version of the io.cozy.banners shape this code was written against.
39
+ * A document declaring a higher cozyMetadata.doctypeVersion is skipped.
40
+ */
41
+
42
+ exports.BANNERS_DOCTYPE = BANNERS_DOCTYPE;
43
+ var SUPPORTED_DOCTYPE_VERSION = 1;
44
+ /**
45
+ * The severities the doctype defines. A value outside this list is rendered
46
+ * with DEFAULT_SEVERITY. Frozen because the fallbacks read it at call time, so
47
+ * a consumer mutating it would change what every other consumer renders.
48
+ *
49
+ * @type {readonly BannerSeverity[]}
50
+ */
51
+
52
+ exports.SUPPORTED_DOCTYPE_VERSION = SUPPORTED_DOCTYPE_VERSION;
53
+ var KNOWN_SEVERITIES = ['info', 'warning', 'error'];
54
+ exports.KNOWN_SEVERITIES = KNOWN_SEVERITIES;
55
+ Object.freeze(KNOWN_SEVERITIES);
56
+ /**
57
+ * The surfaces the doctype defines. A value outside this list is rendered on
58
+ * DEFAULT_SURFACE. Frozen for the same reason as KNOWN_SEVERITIES.
59
+ *
60
+ * @type {readonly BannerSurface[]}
61
+ */
62
+
63
+ var KNOWN_SURFACES = ['banner', 'modal'];
64
+ exports.KNOWN_SURFACES = KNOWN_SURFACES;
65
+ Object.freeze(KNOWN_SURFACES);
66
+ /** @type {BannerSeverity} */
67
+
68
+ var DEFAULT_SEVERITY = 'warning';
69
+ /** @type {BannerSurface} */
70
+
71
+ var DEFAULT_SURFACE = 'banner';
72
+ var STACK_APP = 'stack';
73
+ /**
74
+ * @typedef {object} BannerCta
75
+ * @property {string} label - The label, plain text, in the same language as the banner text
76
+ * @property {string} url - Absolute https URL the label points to
77
+ */
78
+
79
+ /**
80
+ * @typedef {'quota'|'billing'|'trial'|'account'|'system'} BannerCategory
81
+ */
82
+
83
+ /**
84
+ * @typedef {'info'|'warning'|'error'} BannerSeverity
85
+ */
86
+
87
+ /**
88
+ * @typedef {'banner'|'modal'} BannerSurface
89
+ */
90
+
91
+ /**
92
+ * @typedef {object} VisibleBannersOptions
93
+ * @property {Date} [now] - The current time, defaults to now
94
+ * @property {number} [supportedDoctypeVersion] - The highest version this client handles
95
+ */
96
+
97
+ /**
98
+ * @typedef {import("../CozyClient").default} BannerClient A CozyClient instance
99
+ */
100
+
101
+ /**
102
+ * @typedef {object} DismissOptions
103
+ * @property {string} [dismissedAt] - The timestamp to record, defaults to now
104
+ */
105
+
106
+ /**
107
+ * @typedef {object} Banner An io.cozy.banners document
108
+ * @property {string} [_id] - Identifier of the document, minted by CouchDB
109
+ * @property {string} [_rev] - Revision identifier of the document
110
+ * @property {string} bannerId - Identifier of the banner itself
111
+ * @property {BannerCategory} category - What the banner is about
112
+ * @property {BannerSeverity} severity - info, warning or error
113
+ * @property {BannerSurface} surface - banner or modal
114
+ * @property {string} text - The message, in the language given by lang
115
+ * @property {string} lang - BCP 47 tag of the language text is written in
116
+ * @property {BannerCta|null} [cta] - Optional call to action
117
+ * @property {boolean} dismissible - Whether the client offers a dismiss control
118
+ * @property {string|null} [dismissedAt] - When the user dismissed this banner
119
+ * @property {number} priority - Sort order, highest first
120
+ * @property {string} startsAt - Start of the validity window, inclusive
121
+ * @property {string|null} [endsAt] - End of the validity window, exclusive
122
+ * @property {object} [source] - What produced the document
123
+ * @property {object} [cozyMetadata] - Document lifecycle metadata
124
+ */
125
+
126
+ /**
127
+ * The name the banners query is stored under. It is namespaced rather than the
128
+ * bare doctype: `client.query` keys the store entry on this name but keys the
129
+ * in-flight promise on the definition, so an unrelated consumer naming its own
130
+ * `io.cozy.banners` query would make this one resolve nothing while that query
131
+ * is loading.
132
+ */
133
+
134
+ var BANNERS_QUERY_NAME = "".concat(BANNERS_DOCTYPE, "/all");
135
+ /**
136
+ * Query for the banners of the current instance.
137
+ *
138
+ * The query is named so every consumer shares one store entry rather than
139
+ * minting a new one per call, and the limit is lifted because the whole
140
+ * collection is read in full.
141
+ *
142
+ * @returns {import('../types').Query} A named query
143
+ */
144
+
145
+ exports.BANNERS_QUERY_NAME = BANNERS_QUERY_NAME;
146
+
147
+ var buildBannersQuery = function buildBannersQuery() {
148
+ return {
149
+ definition: (0, _dsl.Q)(BANNERS_DOCTYPE).UNSAFE_noLimit(),
150
+ options: {
151
+ as: BANNERS_QUERY_NAME
152
+ }
153
+ };
154
+ };
155
+ /**
156
+ * Documents are only trusted when the stack wrote them. Permissions scope by
157
+ * doctype and verb, never by field, so any application allowed to record a
158
+ * dismissal is also able to author a document that looks like a platform
159
+ * message.
160
+ *
161
+ * This is not a security boundary, and should not be read as one. The stack
162
+ * does not own this field: cozy-client keeps whatever `cozyMetadata` the caller
163
+ * passes (CozyClient.js, ensureCozyMetadata spreads it last), so an application
164
+ * can set `createdByApp: 'stack'` on a document it authored itself and pass
165
+ * this check. It catches an application writing under its own name, which is
166
+ * the ordinary mistake, not one deliberately impersonating the stack. Enforcing
167
+ * that needs the doctype to be stack write only on the server side.
168
+ *
169
+ * It also only speaks to who created the document. An application holding write
170
+ * permission can rewrite the fields of a stack authored one, which the platform
171
+ * cannot prevent, so a client escapes text and never interprets markup in it.
172
+ *
173
+ * @param {Banner} banner - The document to check
174
+ * @returns {boolean} True when the stack is the author
175
+ */
176
+
177
+
178
+ exports.buildBannersQuery = buildBannersQuery;
179
+
180
+ var isTrusted = function isTrusted(banner) {
181
+ return (0, _utils.getCreatedByApp)(banner) === STACK_APP;
182
+ };
183
+ /**
184
+ * cozy-stack writes numbers as strings in places, `cozyMetadata` declares
185
+ * `doctypeVersion` that way platform wide, so a value is parsed rather than
186
+ * compared as it comes. `Number()` alone is not enough: it maps null, "", false
187
+ * and [] to a finite 0, which would read as a real version or a real priority
188
+ * rather than as an unusable value, so only a number or a non blank string is
189
+ * parsed at all.
190
+ *
191
+ * @param {any} value - The value to read
192
+ * @param {number} fallback - What an absent or unusable value means
193
+ * @returns {number} The parsed number, or the fallback
194
+ */
195
+
196
+
197
+ exports.isTrusted = isTrusted;
198
+
199
+ var toNumber = function toNumber(value, fallback) {
200
+ var parsed = typeof value === 'number' || typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
201
+ return Number.isFinite(parsed) ? parsed : fallback;
202
+ };
203
+ /**
204
+ * @param {Banner} banner - The document to read
205
+ * @returns {number} The version, defaulting to 1 for a missing or unusable one
206
+ */
207
+
208
+
209
+ var doctypeVersion = function doctypeVersion(banner) {
210
+ var _banner$cozyMetadata;
211
+
212
+ return toNumber(banner === null || banner === void 0 ? void 0 : (_banner$cozyMetadata = banner.cozyMetadata) === null || _banner$cozyMetadata === void 0 ? void 0 : _banner$cozyMetadata.doctypeVersion, 1);
213
+ };
214
+ /**
215
+ * @param {Banner} banner - The document to check
216
+ * @param {number} [supportedVersion] - The highest version this client handles
217
+ * @returns {boolean} True when the shape is one this client can read
218
+ */
219
+
220
+
221
+ var isSupported = function isSupported(banner) {
222
+ var supportedVersion = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : SUPPORTED_DOCTYPE_VERSION;
223
+ return doctypeVersion(banner) <= supportedVersion;
224
+ };
225
+ /**
226
+ * A null value and an absent key mean the same thing for dismissedAt and
227
+ * endsAt, and `new Date(null)` is the Unix epoch rather than "no date", so
228
+ * every date is null checked before being parsed.
229
+ *
230
+ * Returns a timestamp rather than a Date so the comparisons stay primitive,
231
+ * and NaN when the value is present but unparseable, which the callers reject
232
+ * rather than silently treating as "no bound".
233
+ *
234
+ * This is the only date parser in the file. `Date.parse` is deliberately not
235
+ * used anywhere: it accepts implementation defined formats that `parseISO`
236
+ * rejects, and the two disagree by the local UTC offset on a date only value,
237
+ * so mixing them would give one document two different instants.
238
+ *
239
+ * @param {string|null|undefined} value - An ISO 8601 timestamp
240
+ * @returns {number|null} The timestamp, NaN when malformed, null when absent
241
+ */
242
+
243
+
244
+ exports.isSupported = isSupported;
245
+
246
+ var toTime = function toTime(value) {
247
+ if (value === null || value === undefined) return null;
248
+ var parsed = (0, _parseISO.default)(value);
249
+ return (0, _isValid.default)(parsed) ? parsed.getTime() : NaN;
250
+ };
251
+ /**
252
+ * @param {Banner} banner - The document to check
253
+ * @returns {boolean} True when the user dismissed it
254
+ */
255
+
256
+
257
+ var isDismissed = function isDismissed(banner) {
258
+ return (banner === null || banner === void 0 ? void 0 : banner.dismissedAt) != null;
259
+ };
260
+ /**
261
+ * The window is inclusive on startsAt and exclusive on endsAt, and an absent
262
+ * endsAt is open ended. A malformed bound hides the banner: a document whose
263
+ * window cannot be read is not one a client can decide to show.
264
+ *
265
+ * An absent startsAt hides it too. The doctype makes cta, dismissedAt and
266
+ * endsAt the only fields that may be missing, so a document with no lower bound
267
+ * is not open ended, it is unreadable. Treating it as "no bound" would make the
268
+ * missing field more permissive than a malformed one, and would let anything
269
+ * able to rewrite the document turn a scheduled banner into a permanent one by
270
+ * deleting both bounds.
271
+ *
272
+ * @param {Banner} banner - The document to check
273
+ * @param {Date} [now] - The current time
274
+ * @returns {boolean} True when the banner applies at that time
275
+ */
276
+
277
+
278
+ exports.isDismissed = isDismissed;
279
+
280
+ var isInWindow = function isInWindow(banner) {
281
+ var now = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : new Date();
282
+ var startsAt = toTime(banner === null || banner === void 0 ? void 0 : banner.startsAt);
283
+ var endsAt = toTime(banner === null || banner === void 0 ? void 0 : banner.endsAt);
284
+ if (startsAt === null || Number.isNaN(startsAt)) return false;
285
+ if (Number.isNaN(endsAt)) return false;
286
+ var at = now.getTime();
287
+ if (Number.isNaN(at)) return false;
288
+ if (at < startsAt) return false;
289
+ if (endsAt !== null && at >= endsAt) return false;
290
+ return true;
291
+ };
292
+
293
+ exports.isInWindow = isInWindow;
294
+
295
+ var priorityOf = function priorityOf(banner) {
296
+ return toNumber(banner === null || banner === void 0 ? void 0 : banner.priority, 0);
297
+ };
298
+ /**
299
+ * Highest priority first, then bannerId ascending so every client orders
300
+ * identically when priorities are equal.
301
+ *
302
+ * The tie break compares code units rather than calling localeCompare, whose
303
+ * result depends on the runtime's locale and ICU build. A missing or non
304
+ * numeric priority sorts as 0 instead of producing NaN, which would make the
305
+ * comparator intransitive and the sort order engine defined.
306
+ *
307
+ * Documents that lost their bannerId fall back on _id, as the deduplication
308
+ * does, so two of them do not compare equal and land in whatever order the
309
+ * database returned them in.
310
+ *
311
+ * @param {Banner} a - A banner
312
+ * @param {Banner} b - Another banner
313
+ * @returns {number} The comparison result
314
+ */
315
+
316
+
317
+ var byPriority = function byPriority(a, b) {
318
+ var _ref, _a$bannerId, _ref2, _b$bannerId;
319
+
320
+ var byRank = priorityOf(b) - priorityOf(a);
321
+ if (byRank !== 0) return byRank;
322
+ var idA = String((_ref = (_a$bannerId = a === null || a === void 0 ? void 0 : a.bannerId) !== null && _a$bannerId !== void 0 ? _a$bannerId : a === null || a === void 0 ? void 0 : a._id) !== null && _ref !== void 0 ? _ref : '');
323
+ var idB = String((_ref2 = (_b$bannerId = b === null || b === void 0 ? void 0 : b.bannerId) !== null && _b$bannerId !== void 0 ? _b$bannerId : b === null || b === void 0 ? void 0 : b._id) !== null && _ref2 !== void 0 ? _ref2 : '');
324
+ if (idA < idB) return -1;
325
+ if (idA > idB) return 1;
326
+ return 0;
327
+ };
328
+ /**
329
+ * @param {Banner} banner - The document to read
330
+ * @returns {BannerSeverity} The severity to render, falling back when unknown
331
+ */
332
+
333
+
334
+ exports.byPriority = byPriority;
335
+
336
+ var getSeverity = function getSeverity(banner) {
337
+ return KNOWN_SEVERITIES.includes(banner === null || banner === void 0 ? void 0 : banner.severity) ? banner.severity : DEFAULT_SEVERITY;
338
+ };
339
+ /**
340
+ * @param {Banner} banner - The document to read
341
+ * @returns {BannerSurface} The surface to render on, falling back when unknown
342
+ */
343
+
344
+
345
+ exports.getSeverity = getSeverity;
346
+
347
+ var getSurface = function getSurface(banner) {
348
+ return KNOWN_SURFACES.includes(banner === null || banner === void 0 ? void 0 : banner.surface) ? banner.surface : DEFAULT_SURFACE;
349
+ };
350
+ /**
351
+ * Whitespace plus the zero width characters `trim` leaves behind. A label made
352
+ * only of these renders as a button with nothing on it. They are stripped to
353
+ * decide whether the label is empty, never from the label itself, so a script
354
+ * that uses a zero width joiner between glyphs keeps it.
355
+ */
356
+
357
+
358
+ exports.getSurface = getSurface;
359
+ var BLANK = /[\s\u200B-\u200D\uFEFF]/g;
360
+ /**
361
+ * A call to action is only rendered when it carries a label and points at
362
+ * https, so a locally authored document cannot turn into a platform styled
363
+ * link on any scheme, and no banner renders a button the user cannot read.
364
+ * The scheme is matched case insensitively since URL schemes are.
365
+ *
366
+ * @param {Banner} banner - The document to read
367
+ * @returns {BannerCta|null} The call to action, or null when there is none to show
368
+ */
369
+
370
+ var getCta = function getCta(banner) {
371
+ var cta = banner === null || banner === void 0 ? void 0 : banner.cta;
372
+ if (!cta || typeof cta.url !== 'string') return null;
373
+
374
+ if (typeof cta.label !== 'string' || cta.label.replace(BLANK, '') === '') {
375
+ return null;
376
+ }
377
+
378
+ return isHttpsUrl(cta.url) ? cta : null;
379
+ };
380
+ /**
381
+ * A prefix check accepts "https://" and "https:example.org", which do not point
382
+ * where they look like they point, so the value is parsed instead. The parser
383
+ * rejects an https URL with no host outright, so reaching the scheme comparison
384
+ * already means there is one.
385
+ *
386
+ * @param {string} raw - The candidate URL
387
+ * @returns {boolean} True when it is an absolute https URL
388
+ */
389
+
390
+
391
+ exports.getCta = getCta;
392
+
393
+ var isHttpsUrl = function isHttpsUrl(raw) {
394
+ try {
395
+ return new URL(raw).protocol === 'https:';
396
+ } catch (error) {
397
+ return false;
398
+ }
399
+ };
400
+
401
+ var updatedAtOf = function updatedAtOf(banner) {
402
+ var _banner$cozyMetadata2;
403
+
404
+ var at = toTime(banner === null || banner === void 0 ? void 0 : (_banner$cozyMetadata2 = banner.cozyMetadata) === null || _banner$cozyMetadata2 === void 0 ? void 0 : _banner$cozyMetadata2.updatedAt);
405
+ return Number.isFinite(at) ? at : 0;
406
+ };
407
+ /**
408
+ * Orders two documents carrying the same bannerId, most relevant first. The
409
+ * highest supported doctypeVersion wins; equal versions fall back on the most
410
+ * recently written document and then on _id, so the winner never depends on
411
+ * the order the rows came back in.
412
+ *
413
+ * @param {Banner} candidate - The document being considered
414
+ * @param {Banner} current - The document currently held
415
+ * @returns {boolean} True when candidate should replace current
416
+ */
417
+
418
+
419
+ var supersedes = function supersedes(candidate, current) {
420
+ var _candidate$_id, _current$_id;
421
+
422
+ var byVersion = doctypeVersion(candidate) - doctypeVersion(current);
423
+ if (byVersion !== 0) return byVersion > 0;
424
+ var byUpdate = updatedAtOf(candidate) - updatedAtOf(current);
425
+ if (byUpdate !== 0) return byUpdate > 0;
426
+ return String((_candidate$_id = candidate === null || candidate === void 0 ? void 0 : candidate._id) !== null && _candidate$_id !== void 0 ? _candidate$_id : '') < String((_current$_id = current === null || current === void 0 ? void 0 : current._id) !== null && _current$_id !== void 0 ? _current$_id : '');
427
+ };
428
+ /**
429
+ * The identity two documents are grouped and dismissed under. bannerId and _id
430
+ * are kept in separate namespaces: sharing one would let a document whose _id
431
+ * happens to equal another document's bannerId dismiss or supersede it. A
432
+ * document carrying neither keys on itself, so unrelated ones do not collapse
433
+ * onto a single `undefined` key.
434
+ *
435
+ * @param {Banner} banner - The document to key
436
+ * @returns {string|Banner} The grouping key
437
+ */
438
+
439
+
440
+ var groupKey = function groupKey(banner) {
441
+ if ((banner === null || banner === void 0 ? void 0 : banner.bannerId) != null) return "bannerId:".concat(banner.bannerId);
442
+ if ((banner === null || banner === void 0 ? void 0 : banner._id) != null) return "_id:".concat(banner._id);
443
+ return banner;
444
+ };
445
+ /**
446
+ * Keeps the highest supported version of each bannerId. Without this the
447
+ * documents materialized on both sides of a version bump render twice.
448
+ *
449
+ * @param {Banner[]} banners - The documents to reduce
450
+ * @returns {Banner[]} One document per bannerId
451
+ */
452
+
453
+
454
+ var keepHighestVersion = function keepHighestVersion(banners) {
455
+ var best = new Map();
456
+
457
+ var _iterator = _createForOfIteratorHelper(banners),
458
+ _step;
459
+
460
+ try {
461
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
462
+ var banner = _step.value;
463
+ var key = groupKey(banner);
464
+ var current = best.get(key);
465
+
466
+ if (current === undefined || supersedes(banner, current)) {
467
+ best.set(key, banner);
468
+ }
469
+ }
470
+ } catch (err) {
471
+ _iterator.e(err);
472
+ } finally {
473
+ _iterator.f();
474
+ }
475
+
476
+ return Array.from(best.values());
477
+ };
478
+ /**
479
+ * The banners a client displays, in the order it displays them.
480
+ *
481
+ * A dismissal applies to the bannerId rather than to the single document
482
+ * carrying it, so a banner dismissed against one version stays hidden once the
483
+ * client moves to another. Deduplication runs last, on the documents that are
484
+ * actually renderable, so a newer version sitting outside its validity window
485
+ * never suppresses the older one that is still live.
486
+ *
487
+ * @param {Banner[]} banners - The documents read from the doctype
488
+ * @param {VisibleBannersOptions} [options] - Options
489
+ * @returns {Banner[]} The visible banners, ordered
490
+ */
491
+
492
+
493
+ var getVisibleBanners = function getVisibleBanners(banners) {
494
+ var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
495
+ var now = options.now instanceof Date && !Number.isNaN(options.now.getTime()) ? options.now : new Date(); // Validated rather than defaulted with ??, which only guards null and
496
+ // undefined: a NaN reaching isSupported makes every comparison false and
497
+ // hides every banner with no error.
498
+
499
+ var supportedVersion = toNumber(options.supportedDoctypeVersion, SUPPORTED_DOCTYPE_VERSION); // Only documents the stack wrote are read at all, dismissals included: a
500
+ // dismissal on a document an application authored must not hide a platform
501
+ // message.
502
+
503
+ var trusted = (banners !== null && banners !== void 0 ? banners : []).filter(isTrusted); // Collected before the version filter: a dismissal recorded by a client on a
504
+ // newer version of the document still applies to a client that only reads the
505
+ // older one, which would otherwise render a banner the user has closed.
506
+
507
+ var dismissed = new Set(trusted.filter(isDismissed).map(groupKey));
508
+ return keepHighestVersion(trusted.filter(function (banner) {
509
+ return isSupported(banner, supportedVersion) && !dismissed.has(groupKey(banner)) && isInWindow(banner, now);
510
+ })).sort(byPriority).map(resolve);
511
+ };
512
+ /**
513
+ * Applies every fallback the doctype defines, so a caller renders what it is
514
+ * given instead of reaching for the helpers itself: an unknown severity or
515
+ * surface is already replaced, and a call to action that is not an absolute
516
+ * https URL is already dropped.
517
+ *
518
+ * The doctype guarantees that a non dismissible banner on the modal surface
519
+ * carries a call to action, so the user is never left without a way out.
520
+ * Dropping an unusable one would break that guarantee and paint a blocking
521
+ * dialog with no dismiss control and no button, so the banner falls back to the
522
+ * surface that is never blocking.
523
+ *
524
+ * The document is returned unchanged when no fallback applied. The store relies
525
+ * on referential equality, and copying every banner on every read would make a
526
+ * memoized renderer repaint on every poll tick. An absent cta is not left
527
+ * absent though: the resolved shape always carries the key, so a caller reads
528
+ * one value rather than checking for both null and undefined.
529
+ *
530
+ * @param {Banner} banner - A visible banner
531
+ * @returns {Banner} The same banner, ready to render
532
+ */
533
+
534
+
535
+ exports.getVisibleBanners = getVisibleBanners;
536
+
537
+ var resolve = function resolve(banner) {
538
+ var severity = getSeverity(banner);
539
+ var cta = getCta(banner);
540
+ var surface = getSurface(banner) === 'modal' && cta === null && !(banner !== null && banner !== void 0 && banner.dismissible) ? DEFAULT_SURFACE : getSurface(banner);
541
+
542
+ if (severity === (banner === null || banner === void 0 ? void 0 : banner.severity) && surface === (banner === null || banner === void 0 ? void 0 : banner.surface) && cta === (banner === null || banner === void 0 ? void 0 : banner.cta)) {
543
+ return banner;
544
+ }
545
+
546
+ return _objectSpread(_objectSpread({}, banner), {}, {
547
+ severity: severity,
548
+ surface: surface,
549
+ cta: cta
550
+ });
551
+ };
552
+ /**
553
+ * A conflict reaches this code in two shapes: a FetchError from the stack,
554
+ * which carries the HTTP status, and a PouchDB conflict, which the pouch link
555
+ * rewraps into a plain Error that only keeps the message.
556
+ *
557
+ * @param {Error & {status?: number}} error - The error to classify
558
+ * @returns {boolean} True when the write lost a revision race
559
+ */
560
+
561
+
562
+ var isConflict = function isConflict(error) {
563
+ var _error$message;
564
+
565
+ return (error === null || error === void 0 ? void 0 : error.status) === 409 || /Document update conflict/.test((_error$message = error === null || error === void 0 ? void 0 : error.message) !== null && _error$message !== void 0 ? _error$message : '');
566
+ };
567
+ /**
568
+ * `client.query` does not always resolve a response. It resolves undefined when
569
+ * the client was built with an `onError` callback, which swallows the rejection,
570
+ * and when a query named the same as this one is already in flight with a
571
+ * different definition. Both are indistinguishable from an empty collection
572
+ * once the response is read, so they are rejected here instead: reporting a
573
+ * failed read as "there is nothing" hides a banner the user should see, and
574
+ * reports a dismissal that never happened as done.
575
+ *
576
+ * @param {object} response - The response of a query
577
+ * @returns {object} The same response
578
+ * @throws {Error} When the query resolved nothing
579
+ */
580
+
581
+
582
+ var requireResponse = function requireResponse(response) {
583
+ if (response === null || response === undefined) {
584
+ throw new Error("The ".concat(BANNERS_DOCTYPE, " query resolved nothing. Either the client swallowed the error through its onError option, or another query is registered under the name ").concat(BANNERS_QUERY_NAME, "."));
585
+ }
586
+
587
+ return response;
588
+ };
589
+ /**
590
+ * The links disagree on how a missing document comes back: the stack link
591
+ * resolves `{data: null}` while the pouch link resolves `{data: []}`, and an
592
+ * empty array is truthy. Both mean there is nothing to write to.
593
+ *
594
+ * @param {object} response - The response of a getById query
595
+ * @returns {Banner|null} The document, or null when there is none
596
+ */
597
+
598
+
599
+ var readOne = function readOne(response) {
600
+ var _requireResponse;
601
+
602
+ var data = (_requireResponse = requireResponse(response)) === null || _requireResponse === void 0 ? void 0 : _requireResponse.data;
603
+ var doc = Array.isArray(data) ? data[0] : data;
604
+ return doc !== null && doc !== void 0 && doc._id ? doc : null;
605
+ };
606
+ /**
607
+ * The banners to display, in the order to display them. This is the entry
608
+ * point: it queries the doctype and applies every rule the contract defines,
609
+ * so a caller does not compose the helpers itself.
610
+ *
611
+ * The whole collection is read rather than filtered by a Mango selector. The
612
+ * validity window, the dismissal and the version filter would each need their
613
+ * own index, and the doctype keeps the collection small on purpose: the stack
614
+ * deletes a banner when its condition clears.
615
+ *
616
+ * @param {BannerClient} client - A CozyClient instance
617
+ * @param {VisibleBannersOptions} [options] - Options
618
+ * @returns {Promise<Banner[]>} The banners to display, ready to render
619
+ * @throws {Error} When the doctype cannot be read, a missing permission included
620
+ */
621
+
622
+
623
+ var getActiveBanners = /*#__PURE__*/function () {
624
+ var _ref3 = (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee(client) {
625
+ var _requireResponse2;
626
+
627
+ var options,
628
+ _buildBannersQuery,
629
+ definition,
630
+ queryOptions,
631
+ data,
632
+ _args = arguments;
633
+
634
+ return _regenerator.default.wrap(function _callee$(_context) {
635
+ while (1) {
636
+ switch (_context.prev = _context.next) {
637
+ case 0:
638
+ options = _args.length > 1 && _args[1] !== undefined ? _args[1] : {};
639
+ _buildBannersQuery = buildBannersQuery(), definition = _buildBannersQuery.definition, queryOptions = _buildBannersQuery.options;
640
+ _context.t1 = requireResponse;
641
+ _context.next = 5;
642
+ return client.query(definition, queryOptions);
643
+
644
+ case 5:
645
+ _context.t2 = _context.sent;
646
+ _context.t3 = _requireResponse2 = (0, _context.t1)(_context.t2);
647
+ _context.t0 = _context.t3 === null;
648
+
649
+ if (_context.t0) {
650
+ _context.next = 10;
651
+ break;
652
+ }
653
+
654
+ _context.t0 = _requireResponse2 === void 0;
655
+
656
+ case 10:
657
+ if (!_context.t0) {
658
+ _context.next = 14;
659
+ break;
660
+ }
661
+
662
+ _context.t4 = void 0;
663
+ _context.next = 15;
664
+ break;
665
+
666
+ case 14:
667
+ _context.t4 = _requireResponse2.data;
668
+
669
+ case 15:
670
+ data = _context.t4;
671
+ return _context.abrupt("return", getVisibleBanners(Array.isArray(data) ? data : [], options));
672
+
673
+ case 17:
674
+ case "end":
675
+ return _context.stop();
676
+ }
677
+ }
678
+ }, _callee);
679
+ }));
680
+
681
+ return function getActiveBanners(_x) {
682
+ return _ref3.apply(this, arguments);
683
+ };
684
+ }();
685
+ /**
686
+ * The single banner to display, for a surface that shows one at a time.
687
+ *
688
+ * @param {BannerClient} client - A CozyClient instance
689
+ * @param {VisibleBannersOptions} [options] - Options
690
+ * @returns {Promise<Banner|null>} The highest priority banner, or null
691
+ * @throws {Error} When the doctype cannot be read, a missing permission included
692
+ */
693
+
694
+
695
+ exports.getActiveBanners = getActiveBanners;
696
+
697
+ var getActiveBanner = /*#__PURE__*/function () {
698
+ var _ref4 = (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee2(client) {
699
+ var options,
700
+ _yield$getActiveBanne,
701
+ _yield$getActiveBanne2,
702
+ first,
703
+ _args2 = arguments;
704
+
705
+ return _regenerator.default.wrap(function _callee2$(_context2) {
706
+ while (1) {
707
+ switch (_context2.prev = _context2.next) {
708
+ case 0:
709
+ options = _args2.length > 1 && _args2[1] !== undefined ? _args2[1] : {};
710
+ _context2.next = 3;
711
+ return getActiveBanners(client, options);
712
+
713
+ case 3:
714
+ _yield$getActiveBanne = _context2.sent;
715
+ _yield$getActiveBanne2 = (0, _slicedToArray2.default)(_yield$getActiveBanne, 1);
716
+ first = _yield$getActiveBanne2[0];
717
+ return _context2.abrupt("return", first !== null && first !== void 0 ? first : null);
718
+
719
+ case 7:
720
+ case "end":
721
+ return _context2.stop();
722
+ }
723
+ }
724
+ }, _callee2);
725
+ }));
726
+
727
+ return function getActiveBanner(_x2) {
728
+ return _ref4.apply(this, arguments);
729
+ };
730
+ }();
731
+ /**
732
+ * Records a dismissal. The field is idempotent, so a conflict is resolved by
733
+ * reading the document again and writing the value on the fresh revision.
734
+ *
735
+ * The dismissal is written to the document it is given. During a doctypeVersion
736
+ * migration the stack materializes the same bannerId under several versions,
737
+ * and the sibling documents are not reached from here.
738
+ *
739
+ * @param {BannerClient} client - A CozyClient instance
740
+ * @param {Banner & {_id: string}} banner - The banner to dismiss, as stored
741
+ * @param {DismissOptions} [options] - Options
742
+ * @returns {Promise<{data: Banner|null}>} The dismissed document, null when it is gone
743
+ */
744
+
745
+
746
+ exports.getActiveBanner = getActiveBanner;
747
+
748
+ var dismiss = /*#__PURE__*/function () {
749
+ var _ref5 = (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee3(client, banner) {
750
+ var _options$dismissedAt;
751
+
752
+ var options,
753
+ dismissedAt,
754
+ id,
755
+ siblings,
756
+ result,
757
+ _iterator2,
758
+ _step2,
759
+ sibling,
760
+ written,
761
+ _args3 = arguments;
762
+
763
+ return _regenerator.default.wrap(function _callee3$(_context3) {
764
+ while (1) {
765
+ switch (_context3.prev = _context3.next) {
766
+ case 0:
767
+ options = _args3.length > 2 && _args3[2] !== undefined ? _args3[2] : {};
768
+ dismissedAt = (_options$dismissedAt = options.dismissedAt) !== null && _options$dismissedAt !== void 0 ? _options$dismissedAt : new Date().toISOString();
769
+ id = banner === null || banner === void 0 ? void 0 : banner._id;
770
+
771
+ if (id) {
772
+ _context3.next = 5;
773
+ break;
774
+ }
775
+
776
+ throw new Error('A banner needs an _id to be dismissed');
777
+
778
+ case 5:
779
+ _context3.next = 7;
780
+ return findSiblings(client, banner);
781
+
782
+ case 7:
783
+ siblings = _context3.sent;
784
+ result = {
785
+ data: null
786
+ };
787
+ _iterator2 = _createForOfIteratorHelper(siblings);
788
+ _context3.prev = 10;
789
+
790
+ _iterator2.s();
791
+
792
+ case 12:
793
+ if ((_step2 = _iterator2.n()).done) {
794
+ _context3.next = 20;
795
+ break;
796
+ }
797
+
798
+ sibling = _step2.value;
799
+ _context3.next = 16;
800
+ return writeDismissal(client, sibling._id, dismissedAt);
801
+
802
+ case 16:
803
+ written = _context3.sent;
804
+ // The caller is answered with the document it asked about.
805
+ if (sibling._id === id) result = written;
806
+
807
+ case 18:
808
+ _context3.next = 12;
809
+ break;
810
+
811
+ case 20:
812
+ _context3.next = 25;
813
+ break;
814
+
815
+ case 22:
816
+ _context3.prev = 22;
817
+ _context3.t0 = _context3["catch"](10);
818
+
819
+ _iterator2.e(_context3.t0);
820
+
821
+ case 25:
822
+ _context3.prev = 25;
823
+
824
+ _iterator2.f();
825
+
826
+ return _context3.finish(25);
827
+
828
+ case 28:
829
+ return _context3.abrupt("return", result);
830
+
831
+ case 29:
832
+ case "end":
833
+ return _context3.stop();
834
+ }
835
+ }
836
+ }, _callee3, null, [[10, 22, 25, 28]]);
837
+ }));
838
+
839
+ return function dismiss(_x3, _x4) {
840
+ return _ref5.apply(this, arguments);
841
+ };
842
+ }();
843
+ /**
844
+ * Every stored document sharing the banner's identity, the one the caller holds
845
+ * included. The whole collection is read because the versions of a bannerId are
846
+ * not reachable by id, and the doctype keeps the collection small on purpose.
847
+ *
848
+ * @param {BannerClient} client - A CozyClient instance
849
+ * @param {Banner} banner - The banner being dismissed
850
+ * @returns {Promise<Banner[]>} The documents to write the dismissal to
851
+ */
852
+
853
+
854
+ exports.dismiss = dismiss;
855
+
856
+ var findSiblings = /*#__PURE__*/function () {
857
+ var _ref6 = (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee4(client, banner) {
858
+ var _buildBannersQuery2, definition, queryOptions, response, data, key, siblings;
859
+
860
+ return _regenerator.default.wrap(function _callee4$(_context4) {
861
+ while (1) {
862
+ switch (_context4.prev = _context4.next) {
863
+ case 0:
864
+ _buildBannersQuery2 = buildBannersQuery(), definition = _buildBannersQuery2.definition, queryOptions = _buildBannersQuery2.options;
865
+ _context4.t0 = requireResponse;
866
+ _context4.next = 4;
867
+ return client.query(definition, queryOptions);
868
+
869
+ case 4:
870
+ _context4.t1 = _context4.sent;
871
+ response = (0, _context4.t0)(_context4.t1);
872
+ data = Array.isArray(response.data) ? response.data : [];
873
+ key = groupKey(banner);
874
+ siblings = data.filter(function (doc) {
875
+ return (doc === null || doc === void 0 ? void 0 : doc._id) && groupKey(doc) === key;
876
+ }); // The document may have been deleted between the read and the click, in which
877
+ // case there is nothing left to write to.
878
+
879
+ return _context4.abrupt("return", siblings.length > 0 ? siblings : []);
880
+
881
+ case 10:
882
+ case "end":
883
+ return _context4.stop();
884
+ }
885
+ }
886
+ }, _callee4);
887
+ }));
888
+
889
+ return function findSiblings(_x5, _x6) {
890
+ return _ref6.apply(this, arguments);
891
+ };
892
+ }();
893
+ /** How many times the write is attempted before a conflict reaches the caller. */
894
+
895
+
896
+ var DISMISS_ATTEMPTS = 2;
897
+
898
+ var writeDismissal = /*#__PURE__*/function () {
899
+ var _ref7 = (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee5(client, id, dismissedAt) {
900
+ var attempt, stored;
901
+ return _regenerator.default.wrap(function _callee5$(_context5) {
902
+ while (1) {
903
+ switch (_context5.prev = _context5.next) {
904
+ case 0:
905
+ attempt = 1;
906
+
907
+ case 1:
908
+ if (!(attempt <= DISMISS_ATTEMPTS)) {
909
+ _context5.next = 24;
910
+ break;
911
+ }
912
+
913
+ _context5.t0 = readOne;
914
+ _context5.next = 5;
915
+ return client.query((0, _dsl.Q)(BANNERS_DOCTYPE).getById(id));
916
+
917
+ case 5:
918
+ _context5.t1 = _context5.sent;
919
+ stored = (0, _context5.t0)(_context5.t1);
920
+
921
+ if (stored) {
922
+ _context5.next = 9;
923
+ break;
924
+ }
925
+
926
+ return _context5.abrupt("return", {
927
+ data: null
928
+ });
929
+
930
+ case 9:
931
+ if (!isDismissed(stored)) {
932
+ _context5.next = 11;
933
+ break;
934
+ }
935
+
936
+ return _context5.abrupt("return", {
937
+ data: stored
938
+ });
939
+
940
+ case 11:
941
+ _context5.prev = 11;
942
+ _context5.next = 14;
943
+ return client.save(_objectSpread(_objectSpread({}, stored), {}, {
944
+ _type: BANNERS_DOCTYPE,
945
+ dismissedAt: dismissedAt
946
+ }));
947
+
948
+ case 14:
949
+ return _context5.abrupt("return", _context5.sent);
950
+
951
+ case 17:
952
+ _context5.prev = 17;
953
+ _context5.t2 = _context5["catch"](11);
954
+
955
+ if (!(!isConflict(_context5.t2) || attempt === DISMISS_ATTEMPTS)) {
956
+ _context5.next = 21;
957
+ break;
958
+ }
959
+
960
+ throw _context5.t2;
961
+
962
+ case 21:
963
+ attempt++;
964
+ _context5.next = 1;
965
+ break;
966
+
967
+ case 24:
968
+ case "end":
969
+ return _context5.stop();
970
+ }
971
+ }
972
+ }, _callee5, null, [[11, 17]]);
973
+ }));
974
+
975
+ return function writeDismissal(_x7, _x8, _x9) {
976
+ return _ref7.apply(this, arguments);
977
+ };
978
+ }();