@gitkraken/core-gitlens 0.24.0 → 0.25.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.
- package/CHANGELOG.md +27 -1
- package/README.md +23 -15
- package/dist/git/errors.d.ts +6 -5
- package/dist/git/errors.js +7 -6
- package/dist/git/errors.js.map +1 -1
- package/dist/git/features.d.ts +1 -1
- package/dist/git/features.js +1 -1
- package/dist/git/features.js.map +1 -1
- package/dist/git/models/remoteProvider.d.ts +4 -1
- package/dist/git/models/remoteProvider.js.map +1 -1
- package/dist/git/providers/provider.d.ts +4 -2
- package/dist/git/utils/branch.utils.d.ts +4 -1
- package/dist/git/utils/branch.utils.js +5 -2
- package/dist/git/utils/branch.utils.js.map +1 -1
- package/dist/git/utils/mergeConflicts.utils.js +2 -2
- package/dist/git/utils/mergeConflicts.utils.js.map +1 -1
- package/dist/git-cli/cliGitProvider.d.ts +14 -3
- package/dist/git-cli/cliGitProvider.js +71 -34
- package/dist/git-cli/cliGitProvider.js.map +1 -1
- package/dist/git-cli/exec/git.d.ts +7 -1
- package/dist/git-cli/exec/git.js +19 -4
- package/dist/git-cli/exec/git.js.map +1 -1
- package/dist/git-cli/providers/branchRemote.js +2 -5
- package/dist/git-cli/providers/branchRemote.js.map +1 -1
- package/dist/git-cli/providers/branches.js +4 -4
- package/dist/git-cli/providers/branches.js.map +1 -1
- package/dist/git-cli/providers/config.js +4 -1
- package/dist/git-cli/providers/config.js.map +1 -1
- package/dist/plus/ai/errors.d.ts +4 -1
- package/dist/plus/ai/errors.js +4 -0
- package/dist/plus/ai/errors.js.map +1 -1
- package/dist/plus/ai/providers/mistralProvider.js +1 -1
- package/dist/plus/ai/providers/mistralProvider.js.map +1 -1
- package/dist/plus/ai/providers/openAICompatibleProviderBase.js +1 -0
- package/dist/plus/ai/providers/openAICompatibleProviderBase.js.map +1 -1
- package/dist/plus/git-github/api/github.js +12 -13
- package/dist/plus/git-github/api/github.js.map +1 -1
- package/dist/plus/git-github/api/token.d.ts +2 -0
- package/dist/plus/integrations/authentication/cloudIntegrationService.d.ts +1 -1
- package/dist/plus/integrations/authentication/cloudIntegrationService.js +6 -1
- package/dist/plus/integrations/authentication/cloudIntegrationService.js.map +1 -1
- package/dist/plus/integrations/authentication/configuredIntegrationService.js +2 -2
- package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js +18 -5
- package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js.map +1 -1
- package/dist/plus/integrations/authentication/integrationAuthenticationService.d.ts +3 -1
- package/dist/plus/integrations/authentication/integrationAuthenticationService.js +4 -0
- package/dist/plus/integrations/authentication/integrationAuthenticationService.js.map +1 -1
- package/dist/plus/integrations/authentication/models.d.ts +2 -0
- package/dist/plus/integrations/authentication/models.js +2 -1
- package/dist/plus/integrations/authentication/models.js.map +1 -1
- package/dist/plus/integrations/authentication/observedAuthenticationProvider.js +1 -1
- package/dist/plus/integrations/authentication/rejectedTokenTracker.d.ts +5 -4
- package/dist/plus/integrations/authentication/rejectedTokenTracker.js +5 -4
- package/dist/plus/integrations/authentication/rejectedTokenTracker.js.map +1 -1
- package/dist/plus/integrations/index.d.ts +2 -2
- package/dist/plus/integrations/index.js +1 -1
- package/dist/plus/integrations/index.js.map +1 -1
- package/dist/plus/integrations/integrationService.d.ts +6 -0
- package/dist/plus/integrations/integrationService.js +3 -3
- package/dist/plus/integrations/integrationService.js.map +1 -1
- package/dist/plus/integrations/lite.js +1 -1
- package/dist/plus/integrations/manager.d.ts +51 -3
- package/dist/plus/integrations/models/gitHostIntegration.d.ts +14 -0
- package/dist/plus/integrations/models/gitHostIntegration.js +50 -41
- package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
- package/dist/plus/integrations/models/integration.d.ts +27 -5
- package/dist/plus/integrations/models/integration.js +57 -15
- package/dist/plus/integrations/models/integration.js.map +1 -1
- package/dist/plus/integrations/models/issueReads.d.ts +6 -0
- package/dist/plus/integrations/models/issuesIntegration.js +11 -11
- package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
- package/dist/plus/integrations/providers/azure/azure.js +1 -1
- package/dist/plus/integrations/providers/azureDevOps.js +1 -1
- package/dist/plus/integrations/providers/azureDevOpsServer.js +1 -1
- package/dist/plus/integrations/providers/bitbucket/bitbucket.js +1 -1
- package/dist/plus/integrations/providers/bitbucket/bitbucket.js.map +1 -1
- package/dist/plus/integrations/providers/bitbucket-server.js +1 -1
- package/dist/plus/integrations/providers/bitbucket.js +1 -1
- package/dist/plus/integrations/providers/github.js +1 -1
- package/dist/plus/integrations/providers/gitlab/gitlab.js +2 -2
- package/dist/plus/integrations/providers/gitlab/gitlab.js.map +1 -1
- package/dist/plus/integrations/providers/gitlab.js +1 -1
- package/dist/plus/integrations/providers/jira-server.js +2 -2
- package/dist/plus/integrations/providers/jira.js +1 -1
- package/dist/plus/integrations/providers/linear.js +2 -2
- package/dist/plus/integrations/providers/linear.js.map +1 -1
- package/dist/plus/integrations/providers/models.js +2 -1
- package/dist/plus/integrations/providers/models.js.map +1 -1
- package/dist/plus/integrations/providers/providerErrors.js +9 -0
- package/dist/plus/integrations/providers/providerErrors.js.map +1 -1
- package/dist/plus/integrations/providers/providersApi.js +1 -1
- package/dist/plus/integrations/providers/trello.js +1 -1
- package/dist/plus/integrations/providers/utils.js +1 -1
- package/dist/plus/integrations/reads/counts.js +1 -1
- package/dist/plus/integrations/reads/cursors.js +1 -1
- package/dist/plus/integrations/reads/drains.d.ts +5 -4
- package/dist/plus/integrations/reads/drains.js +28 -8
- package/dist/plus/integrations/reads/drains.js.map +1 -1
- package/dist/plus/integrations/reads/hierarchy.js +1 -1
- package/dist/plus/integrations/reads/issueBatch.d.ts +1 -1
- package/dist/plus/integrations/reads/issueBatch.js.map +1 -1
- package/dist/plus/integrations/reads/issueTracker.js +1 -1
- package/dist/plus/integrations/reads/issues.d.ts +2 -0
- package/dist/plus/integrations/reads/issues.js +5 -3
- package/dist/plus/integrations/reads/issues.js.map +1 -1
- package/dist/plus/integrations/reads/paging.d.ts +5 -6
- package/dist/plus/integrations/reads/paging.js +5 -6
- package/dist/plus/integrations/reads/paging.js.map +1 -1
- package/dist/plus/integrations/reads/pullRequests.d.ts +4 -0
- package/dist/plus/integrations/reads/pullRequests.js +4 -2
- package/dist/plus/integrations/reads/pullRequests.js.map +1 -1
- package/dist/plus/integrations/reads/resolveRepository.js +1 -1
- package/dist/plus/integrations/reads/sweeps.js +42 -10
- package/dist/plus/integrations/reads/sweeps.js.map +1 -1
- package/dist/plus/integrations/results.d.ts +23 -3
- package/dist/plus/integrations/results.js.map +1 -1
- package/dist/plus/notifications/api.d.ts +20 -0
- package/dist/plus/notifications/api.js +89 -0
- package/dist/plus/notifications/api.js.map +1 -0
- package/dist/plus/notifications/context.d.ts +28 -0
- package/dist/plus/notifications/context.js +0 -0
- package/dist/plus/notifications/errors.d.ts +13 -0
- package/dist/plus/notifications/errors.js +22 -0
- package/dist/plus/notifications/errors.js.map +1 -0
- package/dist/plus/notifications/models.d.ts +94 -0
- package/dist/plus/notifications/models.js +0 -0
- package/dist/plus/notifications/notificationsService.d.ts +115 -0
- package/dist/plus/notifications/notificationsService.js +381 -0
- package/dist/plus/notifications/notificationsService.js.map +1 -0
- package/dist/plus/notifications/parse.d.ts +12 -0
- package/dist/plus/notifications/parse.js +152 -0
- package/dist/plus/notifications/parse.js.map +1 -0
- package/dist/plus/notifications/presentation.d.ts +28 -0
- package/dist/plus/notifications/presentation.js +55 -0
- package/dist/plus/notifications/presentation.js.map +1 -0
- package/dist/utils/env/node/exec.d.ts +10 -1
- package/dist/utils/env/node/exec.js +39 -2
- package/dist/utils/env/node/exec.js.map +1 -1
- package/docs/integrations.md +21 -2
- package/docs/notifications.md +239 -0
- package/package.json +21 -1
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { Emitter } from "../../utils/event.js";
|
|
2
|
+
import { NotificationsApiError } from "./errors.js";
|
|
3
|
+
import { isToastEligible } from "./presentation.js";
|
|
4
|
+
import { NotificationsApi } from "./api.js";
|
|
5
|
+
//#region ../plus/notifications/src/notificationsService.ts
|
|
6
|
+
/**
|
|
7
|
+
* How far before the newest previously-held notification's `createdAt` a notification may fall and still count as
|
|
8
|
+
* newly arrived in {@link NotificationsService.refresh}'s result. Both sides are server timestamps; the margin only
|
|
9
|
+
* covers concurrent inserts committing out of `created_at` order on the server.
|
|
10
|
+
*/
|
|
11
|
+
const newToastableSkewMargin = 3e5;
|
|
12
|
+
/** At most this many pages are re-fetched by a refresh; anything paged in beyond them is dropped until loaded again */
|
|
13
|
+
const maxRefreshPages = 5;
|
|
14
|
+
/**
|
|
15
|
+
* Holds the user's notifications for one product and keeps them in sync with `notifications-service-v2`.
|
|
16
|
+
* Read/unread/delete and settings changes apply optimistically and roll back if the request fails (a 404 on
|
|
17
|
+
* read/unread/delete means the notifications are already gone, so it isn't a failure). The service never polls on
|
|
18
|
+
* its own: the host decides when to {@link refresh}, and each refresh reports which notices newly arrived and
|
|
19
|
+
* should toast; how (and whether) to show them stays the host's call.
|
|
20
|
+
*/
|
|
21
|
+
var NotificationsService = class {
|
|
22
|
+
context;
|
|
23
|
+
_onDidChange = new Emitter();
|
|
24
|
+
get onDidChange() {
|
|
25
|
+
return this._onDidChange.event;
|
|
26
|
+
}
|
|
27
|
+
api;
|
|
28
|
+
_items = [];
|
|
29
|
+
_hasMore = false;
|
|
30
|
+
/** The last page loaded, 1-based; 0 before the first load. */
|
|
31
|
+
_page = 0;
|
|
32
|
+
_status = "idle";
|
|
33
|
+
_lastSyncedAt;
|
|
34
|
+
_settings;
|
|
35
|
+
/** The settings the server last acknowledged (fetched, or successfully saved) */
|
|
36
|
+
_confirmedSettings;
|
|
37
|
+
_settingsPost;
|
|
38
|
+
_settingsFollowUp;
|
|
39
|
+
_refreshing;
|
|
40
|
+
_loadingMore;
|
|
41
|
+
/**
|
|
42
|
+
* Actions in flight (or settled while a refresh that started before them was still fetching), by id. A refresh
|
|
43
|
+
* re-applies them so data fetched before the server saw an action doesn't undo it.
|
|
44
|
+
*/
|
|
45
|
+
_overrides = /* @__PURE__ */ new Map();
|
|
46
|
+
/** For items a refresh re-applied an override to: the override's output → which override and the server's item */
|
|
47
|
+
_overridden = /* @__PURE__ */ new WeakMap();
|
|
48
|
+
constructor(context) {
|
|
49
|
+
this.context = context;
|
|
50
|
+
this.api = new NotificationsApi(context);
|
|
51
|
+
}
|
|
52
|
+
dispose() {
|
|
53
|
+
this._onDidChange.dispose();
|
|
54
|
+
}
|
|
55
|
+
[Symbol.dispose]() {
|
|
56
|
+
this.dispose();
|
|
57
|
+
}
|
|
58
|
+
get unreadCount() {
|
|
59
|
+
return this._items.reduce((count, n) => n.readAt == null ? count + 1 : count, 0);
|
|
60
|
+
}
|
|
61
|
+
getState() {
|
|
62
|
+
return {
|
|
63
|
+
items: this._items,
|
|
64
|
+
hasMore: this._hasMore,
|
|
65
|
+
status: this._status,
|
|
66
|
+
lastSyncedAt: this._lastSyncedAt,
|
|
67
|
+
settings: this._settings,
|
|
68
|
+
unreadCount: this.unreadCount
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Re-fetches every page already loaded (up to {@link maxRefreshPages}), so read state, deletions and expiry
|
|
73
|
+
* from elsewhere reach them without collapsing a list the user paged through. Expired items are pruned and
|
|
74
|
+
* local actions still in flight are kept. Never throws: on failure the cached items stay and the status
|
|
75
|
+
* becomes `error`.
|
|
76
|
+
*
|
|
77
|
+
* Resolves to the notices to toast: the unread, toast-eligible ones that newly arrived with this refresh,
|
|
78
|
+
* newest first. Empty when the refresh failed, when nothing had synced before it (no backfill: the first load
|
|
79
|
+
* for an account is the existing inbox, not news; a restored snapshot with a `lastSyncedAt` does count as
|
|
80
|
+
* synced, so what arrived while the host was closed still toasts), or when nothing is new. New means its id
|
|
81
|
+
* wasn't held before the refresh AND it was created after the newest of those held, less
|
|
82
|
+
* {@link newToastableSkewMargin}. Only server timestamps are compared, so the client's clock can't hide a
|
|
83
|
+
* notice. The id check keeps a notice already seen from toasting again; the time check keeps an old one that
|
|
84
|
+
* only now shifts into the first page (after a deletion, or a page boundary moving) from toasting as if it
|
|
85
|
+
* were new. With nothing held before there is no cutoff.
|
|
86
|
+
*
|
|
87
|
+
* Concurrent calls share one request and resolve to the same list, so the result is for ONE caller to act on
|
|
88
|
+
* once (the host's refresh loop); other callers should ignore it.
|
|
89
|
+
*/
|
|
90
|
+
refresh() {
|
|
91
|
+
this._refreshing ??= this.refreshCore().finally(() => {
|
|
92
|
+
this._refreshing = void 0;
|
|
93
|
+
});
|
|
94
|
+
return this._refreshing;
|
|
95
|
+
}
|
|
96
|
+
async refreshCore() {
|
|
97
|
+
this.setStatus("loading");
|
|
98
|
+
await this._loadingMore?.catch(() => void 0);
|
|
99
|
+
const previous = this._items;
|
|
100
|
+
const previouslySynced = this._lastSyncedAt != null;
|
|
101
|
+
let arrived = [];
|
|
102
|
+
try {
|
|
103
|
+
const pages = Math.min(Math.max(this._page, 1), maxRefreshPages);
|
|
104
|
+
const items = [];
|
|
105
|
+
const seen = /* @__PURE__ */ new Set();
|
|
106
|
+
let page = 0;
|
|
107
|
+
let hasMore = true;
|
|
108
|
+
while (hasMore && page < pages) {
|
|
109
|
+
page++;
|
|
110
|
+
const next = await this.api.list(page);
|
|
111
|
+
for (const n of next.items) if (!seen.has(n.id)) {
|
|
112
|
+
seen.add(n.id);
|
|
113
|
+
items.push(n);
|
|
114
|
+
}
|
|
115
|
+
hasMore = next.hasMore;
|
|
116
|
+
}
|
|
117
|
+
this._items = sortNewestFirst(this.pruneExpired(this.applyOverrides(items)));
|
|
118
|
+
this._hasMore = hasMore;
|
|
119
|
+
this._page = page;
|
|
120
|
+
this._lastSyncedAt = this.now();
|
|
121
|
+
this._status = "ready";
|
|
122
|
+
if (previouslySynced) arrived = this.newToastableSince(previous);
|
|
123
|
+
} catch (ex) {
|
|
124
|
+
this.context.log?.error?.(ex, "Unable to refresh notifications");
|
|
125
|
+
this._status = "error";
|
|
126
|
+
}
|
|
127
|
+
this.fireChange();
|
|
128
|
+
return arrived;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Loads the next page, after any refresh in flight (which may change which page is next). Throws on failure,
|
|
132
|
+
* leaving the loaded items as they were. Concurrent calls share one request.
|
|
133
|
+
*/
|
|
134
|
+
loadMore() {
|
|
135
|
+
this._loadingMore ??= this.loadMoreCore().finally(() => {
|
|
136
|
+
this._loadingMore = void 0;
|
|
137
|
+
});
|
|
138
|
+
return this._loadingMore;
|
|
139
|
+
}
|
|
140
|
+
async loadMoreCore() {
|
|
141
|
+
if (this._refreshing != null) await this._refreshing;
|
|
142
|
+
if (!this._hasMore) return;
|
|
143
|
+
const page = this._page + 1;
|
|
144
|
+
const next = await this.api.list(page);
|
|
145
|
+
const ids = new Set(this._items.map((n) => n.id));
|
|
146
|
+
const added = this.applyOverrides(next.items.filter((n) => !ids.has(n.id)));
|
|
147
|
+
this._items = sortNewestFirst(this.pruneExpired([...this._items, ...added]));
|
|
148
|
+
this._page = page;
|
|
149
|
+
this._hasMore = next.hasMore;
|
|
150
|
+
this.fireChange();
|
|
151
|
+
}
|
|
152
|
+
markRead(ids) {
|
|
153
|
+
const readAt = this.now();
|
|
154
|
+
return this.updateOptimistically(ids, (n) => n.readAt == null ? {
|
|
155
|
+
...n,
|
|
156
|
+
readAt
|
|
157
|
+
} : n, () => this.api.markRead(ids));
|
|
158
|
+
}
|
|
159
|
+
markUnread(ids) {
|
|
160
|
+
return this.updateOptimistically(ids, (n) => {
|
|
161
|
+
if (n.readAt == null) return n;
|
|
162
|
+
const { readAt: _, ...unread } = n;
|
|
163
|
+
return unread;
|
|
164
|
+
}, () => this.api.markUnread(ids));
|
|
165
|
+
}
|
|
166
|
+
async delete(ids) {
|
|
167
|
+
if (!ids.length) return;
|
|
168
|
+
const deleting = new Set(ids);
|
|
169
|
+
const removed = this._items.filter((n) => deleting.has(n.id));
|
|
170
|
+
this._items = this._items.filter((n) => !deleting.has(n.id));
|
|
171
|
+
const release = this.trackOverrides(ids, "deleted");
|
|
172
|
+
this.fireChange();
|
|
173
|
+
try {
|
|
174
|
+
await this.api.delete(ids);
|
|
175
|
+
release(true);
|
|
176
|
+
} catch (ex) {
|
|
177
|
+
if (NotificationsApiError.is(ex) && ex.status === 404) {
|
|
178
|
+
release(true);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
release(false);
|
|
182
|
+
const present = new Set(this._items.map((n) => n.id));
|
|
183
|
+
this._items = sortNewestFirst([...this._items, ...removed.filter((n) => !present.has(n.id))]);
|
|
184
|
+
this.fireChange();
|
|
185
|
+
throw ex;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Marks every loaded unread notification read. */
|
|
189
|
+
markAllRead() {
|
|
190
|
+
return this.markRead(this._items.filter((n) => n.readAt == null).map((n) => n.id));
|
|
191
|
+
}
|
|
192
|
+
/** Deletes every loaded read notification. */
|
|
193
|
+
deleteAllRead() {
|
|
194
|
+
return this.delete(this._items.filter((n) => n.readAt != null).map((n) => n.id));
|
|
195
|
+
}
|
|
196
|
+
/** Fetches the user's notification settings (and caches them in the state). */
|
|
197
|
+
async getSettings() {
|
|
198
|
+
const settings = await this.api.getSettings();
|
|
199
|
+
this._settings = settings;
|
|
200
|
+
this._confirmedSettings = settings;
|
|
201
|
+
this.fireChange();
|
|
202
|
+
return settings;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Applies `changes` optimistically, then saves. The server stores the whole settings object from each request,
|
|
206
|
+
* so POSTs must not overlap (they could land out of order and leave the older state): at most one is in flight,
|
|
207
|
+
* and calls made meanwhile share ONE follow-up POST that sends the latest state. Each call settles with the
|
|
208
|
+
* outcome of the POST that carried its change.
|
|
209
|
+
*/
|
|
210
|
+
async updateSettings(changes) {
|
|
211
|
+
let current = this._settings;
|
|
212
|
+
if (current == null) {
|
|
213
|
+
const fetched = await this.api.getSettings();
|
|
214
|
+
this._settings ??= fetched;
|
|
215
|
+
this._confirmedSettings ??= fetched;
|
|
216
|
+
current = this._settings;
|
|
217
|
+
}
|
|
218
|
+
this._settings = {
|
|
219
|
+
...current,
|
|
220
|
+
...changes
|
|
221
|
+
};
|
|
222
|
+
this.fireChange();
|
|
223
|
+
if (this._settingsFollowUp != null) return this._settingsFollowUp;
|
|
224
|
+
if (this._settingsPost == null) return this.trackSettingsPost(this.postSettings());
|
|
225
|
+
const follow = this._settingsPost.catch(() => void 0).then(() => {
|
|
226
|
+
this._settingsFollowUp = void 0;
|
|
227
|
+
return this.postSettings();
|
|
228
|
+
});
|
|
229
|
+
this._settingsFollowUp = follow;
|
|
230
|
+
return this.trackSettingsPost(follow);
|
|
231
|
+
}
|
|
232
|
+
trackSettingsPost(post) {
|
|
233
|
+
this._settingsPost = post;
|
|
234
|
+
const clear = () => {
|
|
235
|
+
if (this._settingsPost === post) this._settingsPost = void 0;
|
|
236
|
+
};
|
|
237
|
+
post.then(clear, clear);
|
|
238
|
+
return post;
|
|
239
|
+
}
|
|
240
|
+
/** Sends the latest optimistic settings; the confirmed state is what was sent, not the server's echo. */
|
|
241
|
+
async postSettings() {
|
|
242
|
+
const settings = this._settings;
|
|
243
|
+
try {
|
|
244
|
+
await this.api.updateSettings(settings);
|
|
245
|
+
} catch (ex) {
|
|
246
|
+
if (this._settings === settings) {
|
|
247
|
+
this._settings = this._confirmedSettings;
|
|
248
|
+
this.fireChange();
|
|
249
|
+
}
|
|
250
|
+
throw ex;
|
|
251
|
+
}
|
|
252
|
+
this._confirmedSettings = settings;
|
|
253
|
+
return settings;
|
|
254
|
+
}
|
|
255
|
+
/** The held notices that are new relative to `previous`, newest first; see {@link refresh} for the rules */
|
|
256
|
+
newToastableSince(previous) {
|
|
257
|
+
const knownIds = /* @__PURE__ */ new Set();
|
|
258
|
+
let newest = Number.NEGATIVE_INFINITY;
|
|
259
|
+
for (const n of previous) {
|
|
260
|
+
knownIds.add(n.id);
|
|
261
|
+
if (n.createdAt > newest) newest = n.createdAt;
|
|
262
|
+
}
|
|
263
|
+
const cutoff = newest - newToastableSkewMargin;
|
|
264
|
+
return this._items.filter((n) => !knownIds.has(n.id) && n.createdAt > cutoff && n.readAt == null && isToastEligible(n));
|
|
265
|
+
}
|
|
266
|
+
toSnapshot() {
|
|
267
|
+
return {
|
|
268
|
+
items: this._items,
|
|
269
|
+
hasMore: this._hasMore,
|
|
270
|
+
page: this._page,
|
|
271
|
+
lastSyncedAt: this._lastSyncedAt,
|
|
272
|
+
settings: this._settings
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/** Seeds the state from a snapshot the host persisted; expired items are pruned. */
|
|
276
|
+
restore(snapshot) {
|
|
277
|
+
const items = Array.isArray(snapshot.items) ? snapshot.items.filter((n) => typeof n?.id === "string" && typeof n.createdAt === "number") : [];
|
|
278
|
+
this._items = sortNewestFirst(this.pruneExpired(items));
|
|
279
|
+
this._hasMore = snapshot.hasMore === true;
|
|
280
|
+
this._page = typeof snapshot.page === "number" ? snapshot.page : items.length ? 1 : 0;
|
|
281
|
+
this._lastSyncedAt = snapshot.lastSyncedAt;
|
|
282
|
+
this._settings = snapshot.settings;
|
|
283
|
+
this._confirmedSettings = snapshot.settings;
|
|
284
|
+
this.fireChange();
|
|
285
|
+
}
|
|
286
|
+
/** On a 404 (the notifications are already gone) the targets are removed instead of rolled back. */
|
|
287
|
+
async updateOptimistically(ids, update, request) {
|
|
288
|
+
if (!ids.length) return;
|
|
289
|
+
const targets = new Set(ids);
|
|
290
|
+
const previous = /* @__PURE__ */ new Map();
|
|
291
|
+
const updated = /* @__PURE__ */ new Map();
|
|
292
|
+
this._items = this._items.map((n) => {
|
|
293
|
+
if (!targets.has(n.id)) return n;
|
|
294
|
+
const next = update(n);
|
|
295
|
+
if (next !== n) {
|
|
296
|
+
previous.set(n.id, n);
|
|
297
|
+
updated.set(n.id, next);
|
|
298
|
+
}
|
|
299
|
+
return next;
|
|
300
|
+
});
|
|
301
|
+
const release = this.trackOverrides([...updated.keys()], update);
|
|
302
|
+
this.fireChange();
|
|
303
|
+
try {
|
|
304
|
+
await request();
|
|
305
|
+
release(true);
|
|
306
|
+
} catch (ex) {
|
|
307
|
+
if (NotificationsApiError.is(ex) && ex.status === 404) {
|
|
308
|
+
release(false);
|
|
309
|
+
this._items = this._items.filter((n) => !targets.has(n.id));
|
|
310
|
+
this.trackOverrides(ids, "deleted")(true);
|
|
311
|
+
this.fireChange();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
release(false);
|
|
315
|
+
this._items = this._items.map((n) => {
|
|
316
|
+
if (updated.get(n.id) === n) return previous.get(n.id) ?? n;
|
|
317
|
+
const overridden = this._overridden.get(n);
|
|
318
|
+
return overridden?.override === update ? overridden.server : n;
|
|
319
|
+
});
|
|
320
|
+
this.fireChange();
|
|
321
|
+
throw ex;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Records `override` for `ids` until the action settles; on success, until any refresh or Load More in flight
|
|
326
|
+
* (either may have fetched before the server applied the action) has landed too. Returns the release for when
|
|
327
|
+
* it settles.
|
|
328
|
+
*/
|
|
329
|
+
trackOverrides(ids, override) {
|
|
330
|
+
for (const id of ids) this._overrides.set(id, override);
|
|
331
|
+
return (succeeded) => {
|
|
332
|
+
const clear = () => {
|
|
333
|
+
for (const id of ids) if (this._overrides.get(id) === override) this._overrides.delete(id);
|
|
334
|
+
};
|
|
335
|
+
const inFlight = [this._refreshing, this._loadingMore].filter((p) => p != null);
|
|
336
|
+
if (succeeded && inFlight.length) Promise.allSettled(inFlight).then(clear);
|
|
337
|
+
else clear();
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
applyOverrides(items) {
|
|
341
|
+
if (!this._overrides.size) return items;
|
|
342
|
+
const result = [];
|
|
343
|
+
for (const n of items) {
|
|
344
|
+
const override = this._overrides.get(n.id);
|
|
345
|
+
if (override === "deleted") continue;
|
|
346
|
+
if (override == null) {
|
|
347
|
+
result.push(n);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const output = override(n);
|
|
351
|
+
if (output !== n) this._overridden.set(output, {
|
|
352
|
+
override,
|
|
353
|
+
server: n
|
|
354
|
+
});
|
|
355
|
+
result.push(output);
|
|
356
|
+
}
|
|
357
|
+
return result;
|
|
358
|
+
}
|
|
359
|
+
pruneExpired(items) {
|
|
360
|
+
const now = this.now();
|
|
361
|
+
return items.filter((n) => n.expiresAt == null || n.expiresAt > now);
|
|
362
|
+
}
|
|
363
|
+
setStatus(status) {
|
|
364
|
+
if (this._status === status) return;
|
|
365
|
+
this._status = status;
|
|
366
|
+
this.fireChange();
|
|
367
|
+
}
|
|
368
|
+
fireChange() {
|
|
369
|
+
this._onDidChange.fire(void 0);
|
|
370
|
+
}
|
|
371
|
+
now() {
|
|
372
|
+
return this.context.now?.() ?? Date.now();
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
function sortNewestFirst(items) {
|
|
376
|
+
return items.sort((a, b) => b.createdAt - a.createdAt);
|
|
377
|
+
}
|
|
378
|
+
//#endregion
|
|
379
|
+
export { NotificationsService, newToastableSkewMargin };
|
|
380
|
+
|
|
381
|
+
//# sourceMappingURL=notificationsService.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notificationsService.js","names":[],"sources":["../../../../plus/notifications/src/notificationsService.ts"],"sourcesContent":["import type { UnifiedDisposable } from '@gitlens/utils/disposable.js';\nimport type { Event } from '@gitlens/utils/event.js';\nimport { Emitter } from '@gitlens/utils/event.js';\nimport { NotificationsApi } from './api.js';\nimport type { NotificationsContext } from './context.js';\nimport { NotificationsApiError } from './errors.js';\nimport type {\n\tGkNotification,\n\tNotificationSettings,\n\tNotificationsSnapshot,\n\tNotificationsState,\n\tNotificationsStatus,\n} from './models.js';\nimport { isToastEligible } from './presentation.js';\n\n/**\n * How far before the newest previously-held notification's `createdAt` a notification may fall and still count as\n * newly arrived in {@link NotificationsService.refresh}'s result. Both sides are server timestamps; the margin only\n * covers concurrent inserts committing out of `created_at` order on the server.\n */\nexport const newToastableSkewMargin = 5 * 60 * 1000; // 5 minutes\n\n/** At most this many pages are re-fetched by a refresh; anything paged in beyond them is dropped until loaded again */\nconst maxRefreshPages = 5;\n\n/** A change an action made locally that a refresh must keep until the server is known to reflect it */\ntype Override = ((n: GkNotification) => GkNotification) | 'deleted';\n\n/**\n * Holds the user's notifications for one product and keeps them in sync with `notifications-service-v2`.\n * Read/unread/delete and settings changes apply optimistically and roll back if the request fails (a 404 on\n * read/unread/delete means the notifications are already gone, so it isn't a failure). The service never polls on\n * its own: the host decides when to {@link refresh}, and each refresh reports which notices newly arrived and\n * should toast; how (and whether) to show them stays the host's call.\n */\nexport class NotificationsService implements UnifiedDisposable {\n\tprivate readonly _onDidChange = new Emitter<void>();\n\tget onDidChange(): Event<void> {\n\t\treturn this._onDidChange.event;\n\t}\n\n\tprivate readonly api: NotificationsApi;\n\tprivate _items: GkNotification[] = [];\n\tprivate _hasMore = false;\n\t/** The last page loaded, 1-based; 0 before the first load. */\n\tprivate _page = 0;\n\tprivate _status: NotificationsStatus = 'idle';\n\tprivate _lastSyncedAt: number | undefined;\n\tprivate _settings: NotificationSettings | undefined;\n\t/** The settings the server last acknowledged (fetched, or successfully saved) */\n\tprivate _confirmedSettings: NotificationSettings | undefined;\n\tprivate _settingsPost: Promise<NotificationSettings> | undefined;\n\tprivate _settingsFollowUp: Promise<NotificationSettings> | undefined;\n\tprivate _refreshing: Promise<GkNotification[]> | undefined;\n\tprivate _loadingMore: Promise<void> | undefined;\n\t/**\n\t * Actions in flight (or settled while a refresh that started before them was still fetching), by id. A refresh\n\t * re-applies them so data fetched before the server saw an action doesn't undo it.\n\t */\n\tprivate readonly _overrides = new Map<string, Override>();\n\t/** For items a refresh re-applied an override to: the override's output → which override and the server's item */\n\tprivate readonly _overridden = new WeakMap<GkNotification, { override: Override; server: GkNotification }>();\n\n\tconstructor(private readonly context: NotificationsContext) {\n\t\tthis.api = new NotificationsApi(context);\n\t}\n\n\tdispose(): void {\n\t\tthis._onDidChange.dispose();\n\t}\n\n\t[Symbol.dispose](): void {\n\t\tthis.dispose();\n\t}\n\n\tget unreadCount(): number {\n\t\treturn this._items.reduce((count, n) => (n.readAt == null ? count + 1 : count), 0);\n\t}\n\n\tgetState(): NotificationsState {\n\t\treturn {\n\t\t\titems: this._items,\n\t\t\thasMore: this._hasMore,\n\t\t\tstatus: this._status,\n\t\t\tlastSyncedAt: this._lastSyncedAt,\n\t\t\tsettings: this._settings,\n\t\t\tunreadCount: this.unreadCount,\n\t\t};\n\t}\n\n\t/**\n\t * Re-fetches every page already loaded (up to {@link maxRefreshPages}), so read state, deletions and expiry\n\t * from elsewhere reach them without collapsing a list the user paged through. Expired items are pruned and\n\t * local actions still in flight are kept. Never throws: on failure the cached items stay and the status\n\t * becomes `error`.\n\t *\n\t * Resolves to the notices to toast: the unread, toast-eligible ones that newly arrived with this refresh,\n\t * newest first. Empty when the refresh failed, when nothing had synced before it (no backfill: the first load\n\t * for an account is the existing inbox, not news; a restored snapshot with a `lastSyncedAt` does count as\n\t * synced, so what arrived while the host was closed still toasts), or when nothing is new. New means its id\n\t * wasn't held before the refresh AND it was created after the newest of those held, less\n\t * {@link newToastableSkewMargin}. Only server timestamps are compared, so the client's clock can't hide a\n\t * notice. The id check keeps a notice already seen from toasting again; the time check keeps an old one that\n\t * only now shifts into the first page (after a deletion, or a page boundary moving) from toasting as if it\n\t * were new. With nothing held before there is no cutoff.\n\t *\n\t * Concurrent calls share one request and resolve to the same list, so the result is for ONE caller to act on\n\t * once (the host's refresh loop); other callers should ignore it.\n\t */\n\trefresh(): Promise<GkNotification[]> {\n\t\tthis._refreshing ??= this.refreshCore().finally(() => {\n\t\t\tthis._refreshing = undefined;\n\t\t});\n\t\treturn this._refreshing;\n\t}\n\n\tprivate async refreshCore(): Promise<GkNotification[]> {\n\t\tthis.setStatus('loading');\n\t\t// A Load More in flight would otherwise land after this read the page count, and be dropped by the commit.\n\t\t// (No deadlock: a running `loadMoreCore` is already past its own wait on `_refreshing`.)\n\t\tawait this._loadingMore?.catch(() => undefined);\n\n\t\t// What was held before this refresh, to tell what newly arrived with it\n\t\tconst previous = this._items;\n\t\tconst previouslySynced = this._lastSyncedAt != null;\n\n\t\tlet arrived: GkNotification[] = [];\n\t\ttry {\n\t\t\t// Re-fetch every page already loaded (capped), so read state, deletions and expiry on the server reach\n\t\t\t// them too, without collapsing a list the user has paged through\n\t\t\tconst pages = Math.min(Math.max(this._page, 1), maxRefreshPages);\n\t\t\tconst items: GkNotification[] = [];\n\t\t\tconst seen = new Set<string>();\n\t\t\t// Counted locally: the loop must end even if the server's own page numbering doesn't advance\n\t\t\tlet page = 0;\n\t\t\tlet hasMore = true;\n\t\t\twhile (hasMore && page < pages) {\n\t\t\t\tpage++;\n\t\t\t\tconst next = await this.api.list(page);\n\t\t\t\tfor (const n of next.items) {\n\t\t\t\t\tif (!seen.has(n.id)) {\n\t\t\t\t\t\tseen.add(n.id);\n\t\t\t\t\t\titems.push(n);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thasMore = next.hasMore;\n\t\t\t}\n\n\t\t\tthis._items = sortNewestFirst(this.pruneExpired(this.applyOverrides(items)));\n\t\t\tthis._hasMore = hasMore;\n\t\t\tthis._page = page;\n\t\t\tthis._lastSyncedAt = this.now();\n\t\t\tthis._status = 'ready';\n\t\t\tif (previouslySynced) {\n\t\t\t\tarrived = this.newToastableSince(previous);\n\t\t\t}\n\t\t} catch (ex) {\n\t\t\tthis.context.log?.error?.(ex, 'Unable to refresh notifications');\n\t\t\tthis._status = 'error';\n\t\t}\n\n\t\tthis.fireChange();\n\t\treturn arrived;\n\t}\n\n\t/**\n\t * Loads the next page, after any refresh in flight (which may change which page is next). Throws on failure,\n\t * leaving the loaded items as they were. Concurrent calls share one request.\n\t */\n\tloadMore(): Promise<void> {\n\t\tthis._loadingMore ??= this.loadMoreCore().finally(() => {\n\t\t\tthis._loadingMore = undefined;\n\t\t});\n\t\treturn this._loadingMore;\n\t}\n\n\tprivate async loadMoreCore(): Promise<void> {\n\t\tif (this._refreshing != null) {\n\t\t\tawait this._refreshing;\n\t\t}\n\t\tif (!this._hasMore) return;\n\n\t\tconst page = this._page + 1;\n\t\tconst next = await this.api.list(page);\n\n\t\tconst ids = new Set(this._items.map(n => n.id));\n\t\tconst added = this.applyOverrides(next.items.filter(n => !ids.has(n.id)));\n\t\tthis._items = sortNewestFirst(this.pruneExpired([...this._items, ...added]));\n\t\tthis._page = page;\n\t\tthis._hasMore = next.hasMore;\n\t\tthis.fireChange();\n\t}\n\n\tmarkRead(ids: readonly string[]): Promise<void> {\n\t\tconst readAt = this.now();\n\t\treturn this.updateOptimistically(\n\t\t\tids,\n\t\t\tn => (n.readAt == null ? { ...n, readAt: readAt } : n),\n\t\t\t() => this.api.markRead(ids),\n\t\t);\n\t}\n\n\tmarkUnread(ids: readonly string[]): Promise<void> {\n\t\treturn this.updateOptimistically(\n\t\t\tids,\n\t\t\tn => {\n\t\t\t\tif (n.readAt == null) return n;\n\n\t\t\t\tconst { readAt: _, ...unread } = n;\n\t\t\t\treturn unread;\n\t\t\t},\n\t\t\t() => this.api.markUnread(ids),\n\t\t);\n\t}\n\n\tasync delete(ids: readonly string[]): Promise<void> {\n\t\tif (!ids.length) return;\n\n\t\tconst deleting = new Set(ids);\n\t\tconst removed = this._items.filter(n => deleting.has(n.id));\n\t\tthis._items = this._items.filter(n => !deleting.has(n.id));\n\t\tconst release = this.trackOverrides(ids, 'deleted');\n\t\tthis.fireChange();\n\n\t\ttry {\n\t\t\tawait this.api.delete(ids);\n\t\t\trelease(true);\n\t\t} catch (ex) {\n\t\t\t// The server drops ids it can no longer find and only answers 404 when none remain: already deleted\n\t\t\tif (NotificationsApiError.is(ex) && ex.status === 404) {\n\t\t\t\trelease(true);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\trelease(false);\n\t\t\t// Put back what was removed, without resurrecting anything a concurrent refresh already returned\n\t\t\tconst present = new Set(this._items.map(n => n.id));\n\t\t\tthis._items = sortNewestFirst([...this._items, ...removed.filter(n => !present.has(n.id))]);\n\t\t\tthis.fireChange();\n\t\t\tthrow ex;\n\t\t}\n\t}\n\n\t/** Marks every loaded unread notification read. */\n\tmarkAllRead(): Promise<void> {\n\t\treturn this.markRead(this._items.filter(n => n.readAt == null).map(n => n.id));\n\t}\n\n\t/** Deletes every loaded read notification. */\n\tdeleteAllRead(): Promise<void> {\n\t\treturn this.delete(this._items.filter(n => n.readAt != null).map(n => n.id));\n\t}\n\n\t/** Fetches the user's notification settings (and caches them in the state). */\n\tasync getSettings(): Promise<NotificationSettings> {\n\t\tconst settings = await this.api.getSettings();\n\t\tthis._settings = settings;\n\t\tthis._confirmedSettings = settings;\n\t\tthis.fireChange();\n\t\treturn settings;\n\t}\n\n\t/**\n\t * Applies `changes` optimistically, then saves. The server stores the whole settings object from each request,\n\t * so POSTs must not overlap (they could land out of order and leave the older state): at most one is in flight,\n\t * and calls made meanwhile share ONE follow-up POST that sends the latest state. Each call settles with the\n\t * outcome of the POST that carried its change.\n\t */\n\tasync updateSettings(changes: Partial<NotificationSettings>): Promise<NotificationSettings> {\n\t\tlet current = this._settings;\n\t\tif (current == null) {\n\t\t\tconst fetched = await this.api.getSettings();\n\t\t\t// A concurrent call may have populated it while fetching\n\t\t\tthis._settings ??= fetched;\n\t\t\tthis._confirmedSettings ??= fetched;\n\t\t\tcurrent = this._settings;\n\t\t}\n\n\t\tthis._settings = { ...current, ...changes };\n\t\tthis.fireChange();\n\n\t\tif (this._settingsFollowUp != null) return this._settingsFollowUp;\n\n\t\tif (this._settingsPost == null) return this.trackSettingsPost(this.postSettings());\n\n\t\tconst follow = this._settingsPost\n\t\t\t.catch(() => undefined)\n\t\t\t.then(() => {\n\t\t\t\t// Starting now: later calls need a new follow-up\n\t\t\t\tthis._settingsFollowUp = undefined;\n\t\t\t\treturn this.postSettings();\n\t\t\t});\n\t\tthis._settingsFollowUp = follow;\n\t\treturn this.trackSettingsPost(follow);\n\t}\n\n\tprivate trackSettingsPost(post: Promise<NotificationSettings>): Promise<NotificationSettings> {\n\t\tthis._settingsPost = post;\n\t\tconst clear = (): void => {\n\t\t\tif (this._settingsPost === post) {\n\t\t\t\tthis._settingsPost = undefined;\n\t\t\t}\n\t\t};\n\t\tpost.then(clear, clear);\n\t\treturn post;\n\t}\n\n\t/** Sends the latest optimistic settings; the confirmed state is what was sent, not the server's echo. */\n\tprivate async postSettings(): Promise<NotificationSettings> {\n\t\tconst settings = this._settings!;\n\t\ttry {\n\t\t\tawait this.api.updateSettings(settings);\n\t\t} catch (ex) {\n\t\t\t// A newer change will be carried by the follow-up POST, so only roll back when there is none\n\t\t\tif (this._settings === settings) {\n\t\t\t\tthis._settings = this._confirmedSettings;\n\t\t\t\tthis.fireChange();\n\t\t\t}\n\t\t\tthrow ex;\n\t\t}\n\n\t\tthis._confirmedSettings = settings;\n\t\treturn settings;\n\t}\n\n\t/** The held notices that are new relative to `previous`, newest first; see {@link refresh} for the rules */\n\tprivate newToastableSince(previous: readonly GkNotification[]): GkNotification[] {\n\t\tconst knownIds = new Set<string>();\n\t\tlet newest = Number.NEGATIVE_INFINITY;\n\t\tfor (const n of previous) {\n\t\t\tknownIds.add(n.id);\n\t\t\tif (n.createdAt > newest) {\n\t\t\t\tnewest = n.createdAt;\n\t\t\t}\n\t\t}\n\n\t\tconst cutoff = newest - newToastableSkewMargin;\n\t\treturn this._items.filter(\n\t\t\tn => !knownIds.has(n.id) && n.createdAt > cutoff && n.readAt == null && isToastEligible(n),\n\t\t);\n\t}\n\n\ttoSnapshot(): NotificationsSnapshot {\n\t\treturn {\n\t\t\titems: this._items,\n\t\t\thasMore: this._hasMore,\n\t\t\tpage: this._page,\n\t\t\tlastSyncedAt: this._lastSyncedAt,\n\t\t\tsettings: this._settings,\n\t\t};\n\t}\n\n\t/** Seeds the state from a snapshot the host persisted; expired items are pruned. */\n\trestore(snapshot: NotificationsSnapshot): void {\n\t\tconst items = Array.isArray(snapshot.items)\n\t\t\t? snapshot.items.filter(n => typeof n?.id === 'string' && typeof n.createdAt === 'number')\n\t\t\t: [];\n\n\t\tthis._items = sortNewestFirst(this.pruneExpired(items));\n\t\tthis._hasMore = snapshot.hasMore === true;\n\t\tthis._page = typeof snapshot.page === 'number' ? snapshot.page : items.length ? 1 : 0;\n\t\tthis._lastSyncedAt = snapshot.lastSyncedAt;\n\t\tthis._settings = snapshot.settings;\n\t\tthis._confirmedSettings = snapshot.settings;\n\t\tthis.fireChange();\n\t}\n\n\t/** On a 404 (the notifications are already gone) the targets are removed instead of rolled back. */\n\tprivate async updateOptimistically(\n\t\tids: readonly string[],\n\t\tupdate: (n: GkNotification) => GkNotification,\n\t\trequest: () => Promise<void>,\n\t): Promise<void> {\n\t\tif (!ids.length) return;\n\n\t\tconst targets = new Set(ids);\n\t\tconst previous = new Map<string, GkNotification>();\n\t\tconst updated = new Map<string, GkNotification>();\n\t\tthis._items = this._items.map(n => {\n\t\t\tif (!targets.has(n.id)) return n;\n\n\t\t\tconst next = update(n);\n\t\t\tif (next !== n) {\n\t\t\t\tprevious.set(n.id, n);\n\t\t\t\tupdated.set(n.id, next);\n\t\t\t}\n\t\t\treturn next;\n\t\t});\n\t\tconst release = this.trackOverrides([...updated.keys()], update);\n\t\tthis.fireChange();\n\n\t\ttry {\n\t\t\tawait request();\n\t\t\trelease(true);\n\t\t} catch (ex) {\n\t\t\t// The server drops ids it can no longer find and only answers 404 when none remain: they're gone, and a\n\t\t\t// refresh already in flight (fetched before they were deleted) must not bring them back\n\t\t\tif (NotificationsApiError.is(ex) && ex.status === 404) {\n\t\t\t\trelease(false);\n\t\t\t\tthis._items = this._items.filter(n => !targets.has(n.id));\n\t\t\t\tthis.trackOverrides(ids, 'deleted')(true);\n\t\t\t\tthis.fireChange();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\trelease(false);\n\t\t\t// Only roll back items still in the state this call (or a refresh re-applying it) left them in, so a\n\t\t\t// later action or refresh on them wins\n\t\t\tthis._items = this._items.map(n => {\n\t\t\t\tif (updated.get(n.id) === n) return previous.get(n.id) ?? n;\n\n\t\t\t\t// A refresh landed meanwhile and re-applied this action to its data: fall back to the server's item\n\t\t\t\tconst overridden = this._overridden.get(n);\n\t\t\t\treturn overridden?.override === update ? overridden.server : n;\n\t\t\t});\n\t\t\tthis.fireChange();\n\t\t\tthrow ex;\n\t\t}\n\t}\n\n\t/**\n\t * Records `override` for `ids` until the action settles; on success, until any refresh or Load More in flight\n\t * (either may have fetched before the server applied the action) has landed too. Returns the release for when\n\t * it settles.\n\t */\n\tprivate trackOverrides(ids: readonly string[], override: Override): (succeeded: boolean) => void {\n\t\tfor (const id of ids) {\n\t\t\tthis._overrides.set(id, override);\n\t\t}\n\n\t\treturn succeeded => {\n\t\t\tconst clear = () => {\n\t\t\t\tfor (const id of ids) {\n\t\t\t\t\t// A later action on the same id owns it now\n\t\t\t\t\tif (this._overrides.get(id) === override) {\n\t\t\t\t\t\tthis._overrides.delete(id);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tconst inFlight = [this._refreshing, this._loadingMore].filter(p => p != null);\n\t\t\tif (succeeded && inFlight.length) {\n\t\t\t\t// `allSettled`: a failed Load More rejects, and must still clear (without an unhandled rejection)\n\t\t\t\tvoid Promise.allSettled(inFlight).then(clear);\n\t\t\t} else {\n\t\t\t\tclear();\n\t\t\t}\n\t\t};\n\t}\n\n\tprivate applyOverrides(items: GkNotification[]): GkNotification[] {\n\t\tif (!this._overrides.size) return items;\n\n\t\tconst result: GkNotification[] = [];\n\t\tfor (const n of items) {\n\t\t\tconst override = this._overrides.get(n.id);\n\t\t\tif (override === 'deleted') continue;\n\n\t\t\tif (override == null) {\n\t\t\t\tresult.push(n);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst output = override(n);\n\t\t\tif (output !== n) {\n\t\t\t\tthis._overridden.set(output, { override: override, server: n });\n\t\t\t}\n\t\t\tresult.push(output);\n\t\t}\n\t\treturn result;\n\t}\n\n\tprivate pruneExpired(items: GkNotification[]): GkNotification[] {\n\t\tconst now = this.now();\n\t\treturn items.filter(n => n.expiresAt == null || n.expiresAt > now);\n\t}\n\n\tprivate setStatus(status: NotificationsStatus): void {\n\t\tif (this._status === status) return;\n\n\t\tthis._status = status;\n\t\tthis.fireChange();\n\t}\n\n\tprivate fireChange(): void {\n\t\tthis._onDidChange.fire(undefined);\n\t}\n\n\tprivate now(): number {\n\t\treturn this.context.now?.() ?? Date.now();\n\t}\n}\n\nfunction sortNewestFirst(items: GkNotification[]): GkNotification[] {\n\treturn items.sort((a, b) => b.createdAt - a.createdAt);\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { NotificationsLogger } from "./context.js";
|
|
2
|
+
import { GkNotification } from "./models.js";
|
|
3
|
+
//#region ../plus/notifications/src/parse.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Validates a notification from the server. Returns `undefined` (and logs why, without its content) when it
|
|
6
|
+
* can't be shown: a missing identity, a malformed payload for its event, an unknown
|
|
7
|
+
* event with no `fallback`. A missing `title` is not a reason to drop one.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseNotification(raw: unknown, log?: NotificationsLogger): GkNotification | undefined;
|
|
10
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
11
|
+
//#endregion
|
|
12
|
+
//# sourceMappingURL=parse.d.ts.map
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { isSafeExternalUrl, toPlainText } from "./presentation.js";
|
|
2
|
+
//#region ../plus/notifications/src/parse.ts
|
|
3
|
+
const contentTypes = /* @__PURE__ */ new Set([
|
|
4
|
+
"feature",
|
|
5
|
+
"help",
|
|
6
|
+
"marketing",
|
|
7
|
+
"system",
|
|
8
|
+
"security"
|
|
9
|
+
]);
|
|
10
|
+
const knownEvents = /* @__PURE__ */ new Set([
|
|
11
|
+
"general",
|
|
12
|
+
"callToAction",
|
|
13
|
+
"projectShared",
|
|
14
|
+
"nudge"
|
|
15
|
+
]);
|
|
16
|
+
/**
|
|
17
|
+
* Validates a notification from the server. Returns `undefined` (and logs why, without its content) when it
|
|
18
|
+
* can't be shown: a missing identity, a malformed payload for its event, an unknown
|
|
19
|
+
* event with no `fallback`. A missing `title` is not a reason to drop one.
|
|
20
|
+
*/
|
|
21
|
+
function parseNotification(raw, log) {
|
|
22
|
+
if (!isRecord(raw)) {
|
|
23
|
+
log?.warn?.("Dropped a notification: not an object");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const id = nonEmptyString(raw.notificationId);
|
|
27
|
+
if (id == null) {
|
|
28
|
+
log?.warn?.("Dropped a notification: missing notificationId");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const result = toNotification(id, raw);
|
|
32
|
+
if (typeof result === "string") {
|
|
33
|
+
log?.warn?.(`Dropped notification ${id}: ${result}`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
/** The validated notification, or why it was dropped. */
|
|
39
|
+
function toNotification(id, raw) {
|
|
40
|
+
const contentType = typeof raw.contentType === "string" && contentTypes.has(raw.contentType) ? raw.contentType : "feature";
|
|
41
|
+
const createdAt = parseTimestamp(raw.createdAt);
|
|
42
|
+
if (createdAt == null) return "invalid createdAt";
|
|
43
|
+
const rawEvent = typeof raw.event === "string" ? raw.event : "";
|
|
44
|
+
let event;
|
|
45
|
+
let content;
|
|
46
|
+
let needsNewerVersion = false;
|
|
47
|
+
if (knownEvents.has(rawEvent)) {
|
|
48
|
+
event = rawEvent;
|
|
49
|
+
content = parsePayload(event, isRecord(raw.payload) ? raw.payload : void 0);
|
|
50
|
+
if (content == null) return `invalid '${event}' payload`;
|
|
51
|
+
} else {
|
|
52
|
+
const fallback = nonEmptyString(raw.fallback);
|
|
53
|
+
if (fallback == null) return `unknown event '${rawEvent}' with no fallback`;
|
|
54
|
+
event = "unknown";
|
|
55
|
+
content = { body: fallback };
|
|
56
|
+
needsNewerVersion = true;
|
|
57
|
+
}
|
|
58
|
+
const headline = nonEmptyString(raw.title)?.trim();
|
|
59
|
+
const notification = {
|
|
60
|
+
id,
|
|
61
|
+
event,
|
|
62
|
+
contentType,
|
|
63
|
+
body: content.body,
|
|
64
|
+
createdAt
|
|
65
|
+
};
|
|
66
|
+
if (headline) notification.headline = headline;
|
|
67
|
+
const desktopMessage = isRecord(raw.payload) ? nonEmptyString(raw.payload.desktopMessage)?.trim() : void 0;
|
|
68
|
+
const fallbackText = nonEmptyString(raw.fallback);
|
|
69
|
+
const summary = desktopMessage || (fallbackText != null ? toPlainText(fallbackText) : void 0);
|
|
70
|
+
if (summary) notification.summary = summary;
|
|
71
|
+
if (content.cta != null) notification.cta = content.cta;
|
|
72
|
+
if (content.projectShared != null) notification.projectShared = content.projectShared;
|
|
73
|
+
const readAt = parseTimestamp(raw.readAt);
|
|
74
|
+
if (readAt != null) notification.readAt = readAt;
|
|
75
|
+
const expiresAt = parseTimestamp(raw.expiresAt);
|
|
76
|
+
if (expiresAt != null) notification.expiresAt = expiresAt;
|
|
77
|
+
if (needsNewerVersion) notification.needsNewerVersion = true;
|
|
78
|
+
return notification;
|
|
79
|
+
}
|
|
80
|
+
function parsePayload(event, payload) {
|
|
81
|
+
if (payload == null) return void 0;
|
|
82
|
+
switch (event) {
|
|
83
|
+
case "general": {
|
|
84
|
+
const markdown = nonEmptyString(payload.markdown);
|
|
85
|
+
return markdown != null ? { body: markdown } : void 0;
|
|
86
|
+
}
|
|
87
|
+
case "callToAction": {
|
|
88
|
+
const markdown = nonEmptyString(payload.markdown);
|
|
89
|
+
const cta = isRecord(payload.callToAction) ? payload.callToAction : void 0;
|
|
90
|
+
const text = nonEmptyString(cta?.text)?.trim();
|
|
91
|
+
const url = nonEmptyString(cta?.link)?.trim();
|
|
92
|
+
if (markdown == null || !text || !url || !isSafeExternalUrl(url)) return void 0;
|
|
93
|
+
return {
|
|
94
|
+
body: markdown,
|
|
95
|
+
cta: {
|
|
96
|
+
text,
|
|
97
|
+
url
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
case "projectShared": {
|
|
102
|
+
const projectShared = parseProjectShared(payload);
|
|
103
|
+
return projectShared != null ? {
|
|
104
|
+
body: void 0,
|
|
105
|
+
projectShared
|
|
106
|
+
} : void 0;
|
|
107
|
+
}
|
|
108
|
+
case "nudge": {
|
|
109
|
+
const message = nonEmptyString((isRecord(payload.metadata) ? payload.metadata : void 0)?.message);
|
|
110
|
+
return message != null ? { body: message } : void 0;
|
|
111
|
+
}
|
|
112
|
+
default: return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function parseProjectShared(payload) {
|
|
116
|
+
const organization = nameOf(payload.organization);
|
|
117
|
+
const owner = nameOf(payload.owner);
|
|
118
|
+
const project = nameOf(payload.project);
|
|
119
|
+
if (organization == null || owner == null || project == null) return void 0;
|
|
120
|
+
const teams = [];
|
|
121
|
+
if (Array.isArray(payload.teams)) for (const team of payload.teams) {
|
|
122
|
+
const name = nameOf(team);
|
|
123
|
+
if (name != null) teams.push(name);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
organization,
|
|
127
|
+
owner,
|
|
128
|
+
project,
|
|
129
|
+
teams
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function nameOf(value) {
|
|
133
|
+
return isRecord(value) ? nonEmptyString(value.name)?.trim() : void 0;
|
|
134
|
+
}
|
|
135
|
+
function isRecord(value) {
|
|
136
|
+
return typeof value === "object" && value != null && !Array.isArray(value);
|
|
137
|
+
}
|
|
138
|
+
/** The string itself when it has non-whitespace content, otherwise `undefined`. */
|
|
139
|
+
function nonEmptyString(value) {
|
|
140
|
+
return typeof value === "string" && value.trim().length > 0 ? value : void 0;
|
|
141
|
+
}
|
|
142
|
+
/** An ISO timestamp (or epoch ms) as epoch ms; `undefined` when absent or unparseable. */
|
|
143
|
+
function parseTimestamp(value) {
|
|
144
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
145
|
+
if (typeof value !== "string" || !value) return void 0;
|
|
146
|
+
const ms = Date.parse(value);
|
|
147
|
+
return Number.isNaN(ms) ? void 0 : ms;
|
|
148
|
+
}
|
|
149
|
+
//#endregion
|
|
150
|
+
export { isRecord, parseNotification };
|
|
151
|
+
|
|
152
|
+
//# sourceMappingURL=parse.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse.js","names":[],"sources":["../../../../plus/notifications/src/parse.ts"],"sourcesContent":["import type { NotificationsLogger } from './context.js';\nimport type { GkNotification, NotificationContentType, NotificationEvent, ProjectSharedDetails } from './models.js';\nimport { isSafeExternalUrl, toPlainText } from './presentation.js';\n\nconst contentTypes: ReadonlySet<string> = new Set<NotificationContentType>([\n\t'feature',\n\t'help',\n\t'marketing',\n\t'system',\n\t'security',\n]);\nconst knownEvents: ReadonlySet<string> = new Set<NotificationEvent>([\n\t'general',\n\t'callToAction',\n\t'projectShared',\n\t'nudge',\n]);\n\ntype Content = Pick<GkNotification, 'body' | 'cta' | 'projectShared'>;\n\n/**\n * Validates a notification from the server. Returns `undefined` (and logs why, without its content) when it\n * can't be shown: a missing identity, a malformed payload for its event, an unknown\n * event with no `fallback`. A missing `title` is not a reason to drop one.\n */\nexport function parseNotification(raw: unknown, log?: NotificationsLogger): GkNotification | undefined {\n\tif (!isRecord(raw)) {\n\t\tlog?.warn?.('Dropped a notification: not an object');\n\t\treturn undefined;\n\t}\n\n\tconst id = nonEmptyString(raw.notificationId);\n\tif (id == null) {\n\t\tlog?.warn?.('Dropped a notification: missing notificationId');\n\t\treturn undefined;\n\t}\n\n\tconst result = toNotification(id, raw);\n\tif (typeof result === 'string') {\n\t\tlog?.warn?.(`Dropped notification ${id}: ${result}`);\n\t\treturn undefined;\n\t}\n\n\treturn result;\n}\n\n/** The validated notification, or why it was dropped. */\nfunction toNotification(id: string, raw: Record<string, unknown>): GkNotification | string {\n\t// An unrecognized content type (e.g. one the server adds later) is shown like `feature`: no chip, no toast\n\tconst contentType: NotificationContentType =\n\t\ttypeof raw.contentType === 'string' && contentTypes.has(raw.contentType)\n\t\t\t? (raw.contentType as NotificationContentType)\n\t\t\t: 'feature';\n\n\tconst createdAt = parseTimestamp(raw.createdAt);\n\tif (createdAt == null) return 'invalid createdAt';\n\n\tconst rawEvent = typeof raw.event === 'string' ? raw.event : '';\n\n\tlet event: NotificationEvent;\n\tlet content: Content | undefined;\n\tlet needsNewerVersion = false;\n\tif (knownEvents.has(rawEvent)) {\n\t\tevent = rawEvent as NotificationEvent;\n\t\tcontent = parsePayload(event, isRecord(raw.payload) ? raw.payload : undefined);\n\t\tif (content == null) return `invalid '${event}' payload`;\n\t} else {\n\t\tconst fallback = nonEmptyString(raw.fallback);\n\t\tif (fallback == null) return `unknown event '${rawEvent}' with no fallback`;\n\n\t\tevent = 'unknown';\n\t\tcontent = { body: fallback };\n\t\tneedsNewerVersion = true;\n\t}\n\n\t// Never the server's `name`: it's an internal identifier, not display text\n\tconst headline = nonEmptyString(raw.title)?.trim();\n\n\tconst notification: GkNotification = {\n\t\tid: id,\n\t\tevent: event,\n\t\tcontentType: contentType,\n\t\tbody: content.body,\n\t\tcreatedAt: createdAt,\n\t};\n\tif (headline) {\n\t\tnotification.headline = headline;\n\t}\n\tconst desktopMessage = isRecord(raw.payload) ? nonEmptyString(raw.payload.desktopMessage)?.trim() : undefined;\n\t// `fallback` is markdown (Desktop renders it as such), but a header is one line of plain text\n\tconst fallbackText = nonEmptyString(raw.fallback);\n\tconst summary = desktopMessage || (fallbackText != null ? toPlainText(fallbackText) : undefined);\n\tif (summary) {\n\t\tnotification.summary = summary;\n\t}\n\tif (content.cta != null) {\n\t\tnotification.cta = content.cta;\n\t}\n\tif (content.projectShared != null) {\n\t\tnotification.projectShared = content.projectShared;\n\t}\n\n\tconst readAt = parseTimestamp(raw.readAt);\n\tif (readAt != null) {\n\t\tnotification.readAt = readAt;\n\t}\n\n\tconst expiresAt = parseTimestamp(raw.expiresAt);\n\tif (expiresAt != null) {\n\t\tnotification.expiresAt = expiresAt;\n\t}\n\n\tif (needsNewerVersion) {\n\t\tnotification.needsNewerVersion = true;\n\t}\n\n\treturn notification;\n}\n\nfunction parsePayload(event: NotificationEvent, payload: Record<string, unknown> | undefined): Content | undefined {\n\tif (payload == null) return undefined;\n\n\tswitch (event) {\n\t\tcase 'general': {\n\t\t\tconst markdown = nonEmptyString(payload.markdown);\n\t\t\treturn markdown != null ? { body: markdown } : undefined;\n\t\t}\n\t\tcase 'callToAction': {\n\t\t\tconst markdown = nonEmptyString(payload.markdown);\n\t\t\tconst cta = isRecord(payload.callToAction) ? payload.callToAction : undefined;\n\t\t\tconst text = nonEmptyString(cta?.text)?.trim();\n\t\t\tconst url = nonEmptyString(cta?.link)?.trim();\n\t\t\tif (markdown == null || !text || !url || !isSafeExternalUrl(url)) return undefined;\n\n\t\t\treturn { body: markdown, cta: { text: text, url: url } };\n\t\t}\n\t\tcase 'projectShared': {\n\t\t\tconst projectShared = parseProjectShared(payload);\n\t\t\treturn projectShared != null ? { body: undefined, projectShared: projectShared } : undefined;\n\t\t}\n\t\tcase 'nudge': {\n\t\t\tconst metadata = isRecord(payload.metadata) ? payload.metadata : undefined;\n\t\t\tconst message = nonEmptyString(metadata?.message);\n\t\t\treturn message != null ? { body: message } : undefined;\n\t\t}\n\t\tdefault:\n\t\t\treturn undefined;\n\t}\n}\n\nfunction parseProjectShared(payload: Record<string, unknown>): ProjectSharedDetails | undefined {\n\tconst organization = nameOf(payload.organization);\n\tconst owner = nameOf(payload.owner);\n\tconst project = nameOf(payload.project);\n\tif (organization == null || owner == null || project == null) return undefined;\n\n\tconst teams: string[] = [];\n\tif (Array.isArray(payload.teams)) {\n\t\tfor (const team of payload.teams) {\n\t\t\tconst name = nameOf(team);\n\t\t\tif (name != null) {\n\t\t\t\tteams.push(name);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn { organization: organization, owner: owner, project: project, teams: teams };\n}\n\nfunction nameOf(value: unknown): string | undefined {\n\treturn isRecord(value) ? nonEmptyString(value.name)?.trim() : undefined;\n}\n\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === 'object' && value != null && !Array.isArray(value);\n}\n\n/** The string itself when it has non-whitespace content, otherwise `undefined`. */\nfunction nonEmptyString(value: unknown): string | undefined {\n\treturn typeof value === 'string' && value.trim().length > 0 ? value : undefined;\n}\n\n/** An ISO timestamp (or epoch ms) as epoch ms; `undefined` when absent or unparseable. */\nfunction parseTimestamp(value: unknown): number | undefined {\n\tif (typeof value === 'number') return Number.isFinite(value) ? value : undefined;\n\tif (typeof value !== 'string' || !value) return undefined;\n\n\tconst ms = Date.parse(value);\n\treturn Number.isNaN(ms) ? undefined : ms;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { GkNotification, NotificationContentType } from "./models.js";
|
|
2
|
+
//#region ../plus/notifications/src/presentation.d.ts
|
|
3
|
+
export type NotificationCategoryChip = 'help' | 'offer' | 'security' | 'system';
|
|
4
|
+
/** The category chip shown on a notification card; `feature` notifications get none. */
|
|
5
|
+
export declare function getCategoryChip(contentType: NotificationContentType): NotificationCategoryChip | undefined;
|
|
6
|
+
/** Only security and system notices may interrupt with a toast; everything else arrives quietly. */
|
|
7
|
+
export declare function isToastEligible(notification: Pick<GkNotification, 'contentType'>): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Server-supplied links may only open `https:` URLs (never `command:`, `javascript:`, `file:`, ...). Deliberately
|
|
10
|
+
* stricter than the inline-link policy for untrusted markdown (`getUntrustedLinkHref`, which also allows `http:`
|
|
11
|
+
* and `mailto:`): a call-to-action is a prominent, single-click action.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isSafeExternalUrl(url: string): boolean;
|
|
14
|
+
export declare function formatUnreadCount(count: number): string;
|
|
15
|
+
/**
|
|
16
|
+
* The body to show under a notification's headline. Service-generated notices often open their body with the
|
|
17
|
+
* headline itself (`jane@acme.dev has requested to join _Acme_. …`), so a first paragraph that only repeats the
|
|
18
|
+
* headline (ignoring markdown emphasis/code markers, whitespace and a trailing period) is dropped. Only the
|
|
19
|
+
* first paragraph is ever dropped; `undefined` when nothing remains.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getDisplayBody(headline: string | undefined, body: string | undefined): string | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* A best-effort conversion of markdown to a single line of plain text, for headers that cannot render markdown.
|
|
24
|
+
* Not a parser: it handles the common inline and line-start constructs and leaves anything exotic as-is.
|
|
25
|
+
*/
|
|
26
|
+
export declare function toPlainText(markdown: string): string;
|
|
27
|
+
//#endregion
|
|
28
|
+
//# sourceMappingURL=presentation.d.ts.map
|