+5
README.md
+5
README.md
···
2
2
3
3
web extension to show which member(s) of a system was fronting when an ATProto record was created. it also implements showing fronter names on posts and other places, currently only for social-app (bsky.app & forks).
4
4
5
+
#### installing
6
+
7
+
- for firefox, download [here](https://dev.gaze.systems/x/at-fronter_firefox.xpi)
8
+
- for chrome, download [here](https://dev.gaze.systems/x/at-fronter_chrome.crx)
9
+
5
10
#### building
6
11
7
12
install dependencies with `pnpm i` and run `pnpm build` for chrome and `pnpm build:firefox` for firefox.
+1
env.d.ts
+1
env.d.ts
+2
-1
package.json
+2
-1
package.json
···
2
2
"name": "at-fronter",
3
3
"description": "view who was fronting when a record was made",
4
4
"private": true,
5
-
"version": "0.0.1",
5
+
"version": "0.0.8",
6
6
"type": "module",
7
7
"scripts": {
8
8
"dev": "wxt",
···
25
25
},
26
26
"dependencies": {
27
27
"@atcute/atproto": "^3.1.1",
28
+
"@atcute/bluesky": "^3.2.2",
28
29
"@atcute/client": "^4.0.3",
29
30
"@atcute/identity": "^1.0.3",
30
31
"@atcute/identity-resolver": "^1.1.3",
+11
pnpm-lock.yaml
+11
pnpm-lock.yaml
···
11
11
'@atcute/atproto':
12
12
specifier: ^3.1.1
13
13
version: 3.1.3
14
+
'@atcute/bluesky':
15
+
specifier: ^3.2.2
16
+
version: 3.2.2
14
17
'@atcute/client':
15
18
specifier: ^4.0.3
16
19
version: 4.0.3
···
66
69
67
70
'@atcute/atproto@3.1.3':
68
71
resolution: {integrity: sha512-+5u0l+8E7h6wZO7MM1HLXIPoUEbdwRtr28ZRTgsURp+Md9gkoBj9e5iMx/xM8F2Exfyb65J5RchW/WlF2mw/RQ==}
72
+
73
+
'@atcute/bluesky@3.2.2':
74
+
resolution: {integrity: sha512-L8RrMNeRLGvSHMq2KDIAGXrpuNGA87YOXpXHY1yhmovVCjQ5n55FrR6JoQaxhprdXdKKQiefxNwQQQybDrfgFQ==}
69
75
70
76
'@atcute/client@4.0.3':
71
77
resolution: {integrity: sha512-RIOZWFVLca/HiPAAUDqQPOdOreCxTbL5cb+WUf5yqQOKIu5yEAP3eksinmlLmgIrlr5qVOE7brazUUzaskFCfw==}
···
1935
1941
1936
1942
'@atcute/atproto@3.1.3':
1937
1943
dependencies:
1944
+
'@atcute/lexicons': 1.1.1
1945
+
1946
+
'@atcute/bluesky@3.2.2':
1947
+
dependencies:
1948
+
'@atcute/atproto': 3.1.3
1938
1949
'@atcute/lexicons': 1.1.1
1939
1950
1940
1951
'@atcute/client@4.0.3':
+366
-80
src/entrypoints/background.ts
+366
-80
src/entrypoints/background.ts
···
1
-
import { PersistentCache } from "@/lib/cache";
2
1
import { expect } from "@/lib/result";
3
2
import {
4
3
type Fronter,
5
4
fronterGetSocialAppHrefs,
6
-
fronterGetSocialAppHref,
7
5
getFronter,
8
6
getSpFronters,
9
-
memberUriString,
10
7
putFronter,
11
8
frontersCache,
12
9
parseSocialAppPostUrl,
13
10
displayNameCache,
14
11
deleteFronter,
12
+
getPkFronters,
13
+
FronterView,
14
+
docResolver,
15
15
} from "@/lib/utils";
16
16
import {
17
+
AppBskyFeedLike,
18
+
AppBskyFeedPost,
19
+
AppBskyFeedRepost,
20
+
AppBskyNotificationListNotifications,
21
+
} from "@atcute/bluesky";
22
+
import { feedViewPostSchema } from "@atcute/bluesky/types/app/feed/defs";
23
+
import { getAtprotoHandle } from "@atcute/identity";
24
+
import { is, parseResourceUri, ResourceUri } from "@atcute/lexicons";
25
+
import {
26
+
AtprotoDid,
27
+
Handle,
17
28
parseCanonicalResourceUri,
18
-
parseResourceUri,
19
-
ResourceUri,
20
-
} from "@atcute/lexicons";
29
+
} from "@atcute/lexicons/syntax";
21
30
22
31
export default defineBackground({
23
32
persistent: true,
···
81
90
) => {
82
91
if (!authToken) return;
83
92
const frontersArray = await storage.getItem<string[]>("sync:fronters");
84
-
let members: Parameters<typeof putFronter>["1"] = frontersArray ?? [];
93
+
let members: Parameters<typeof putFronter>["1"] =
94
+
frontersArray?.map((n) => ({ name: n, uri: undefined })) ?? [];
85
95
if (members.length === 0) {
86
-
const pkFronters = await storage.getItem<string[]>("sync:pk-fronter");
87
-
if (pkFronters) {
88
-
members = pkFronters.map((id) => ({ type: "pk", memberId: id }));
89
-
} else {
90
-
members = await getSpFronters();
91
-
}
96
+
members = await getPkFronters();
97
+
}
98
+
if (members.length === 0) {
99
+
members = await getSpFronters();
92
100
}
93
101
// dont write if no names is specified or no sp/pk fronters are fetched
94
102
if (members.length === 0) return;
95
-
const results = [];
103
+
const results: FronterView[] = [];
96
104
for (const result of items) {
97
105
const resp = await putFronter(result.uri, members, authToken);
98
106
if (resp.ok) {
99
107
const parsedUri = await cacheFronter(result.uri, resp.value);
100
108
results.push({
109
+
type:
110
+
parsedUri.collection === "app.bsky.feed.repost"
111
+
? "repost"
112
+
: parsedUri.collection === "app.bsky.feed.like"
113
+
? "like"
114
+
: "post",
101
115
rkey: parsedUri.rkey!,
102
116
...resp.value,
103
117
});
···
109
123
// hijack timeline fronter message because when a write is made it is either on the timeline
110
124
// or its a reply to a depth === 0 post on a threaded view, which is the same as a timeline post
111
125
browser.tabs.sendMessage(sender.tab?.id!, {
112
-
type: "TIMELINE_FRONTER",
113
-
results: new Map(
126
+
type: "APPLY_FRONTERS",
127
+
results: Object.fromEntries(
114
128
results.flatMap((fronter) =>
115
-
fronterGetSocialAppHrefs(fronter, fronter.rkey).map((href) => [
116
-
href,
117
-
fronter,
118
-
]),
129
+
fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]),
119
130
),
120
131
),
121
132
});
122
133
};
123
-
const handleTimeline = async (
124
-
feed: any[],
134
+
const handleNotifications = async (
135
+
items: any,
125
136
sender: globalThis.Browser.runtime.MessageSender,
126
137
) => {
127
-
const handlePost = async (post: any) => {
128
-
const cachedFronter = await frontersCache.get(post.uri);
129
-
if (cachedFronter === null) return;
130
-
const promise = cachedFronter
131
-
? Promise.resolve(cachedFronter)
132
-
: getFronter(post.uri).then(async (fronter) => {
133
-
if (!fronter.ok) {
134
-
await frontersCache.set(post.uri, null);
135
-
return;
136
-
}
137
-
return fronter.value;
138
-
});
139
-
return promise.then(async (fronter) => {
140
-
if (!fronter) return;
141
-
const parsedUri = await cacheFronter(post.uri, fronter);
142
-
return {
143
-
rkey: parsedUri.rkey!,
144
-
...fronter,
145
-
};
146
-
});
138
+
const fetchReply = async (
139
+
uri: ResourceUri,
140
+
): Promise<FronterView | undefined> => {
141
+
const cachedFronter = await frontersCache.get(uri);
142
+
const fronter =
143
+
(cachedFronter ?? null) ||
144
+
(await getFronter(uri).then((fronter) => {
145
+
if (!fronter.ok) {
146
+
frontersCache.set(uri, null);
147
+
return null;
148
+
}
149
+
return fronter.value;
150
+
}));
151
+
if (!fronter) return;
152
+
const parsedUri = await cacheFronter(uri, fronter);
153
+
return {
154
+
type: "post",
155
+
rkey: parsedUri.rkey!,
156
+
...fronter,
157
+
};
147
158
};
148
-
const allPromises = feed.flatMap((item) => {
149
-
const promises = [handlePost(item.post)];
150
-
if (item.reply?.parent) {
151
-
promises.push(handlePost(item.reply.parent));
159
+
const handleNotif = async (
160
+
item: AppBskyNotificationListNotifications.Notification,
161
+
): Promise<FronterView | undefined> => {
162
+
let postUrl: ResourceUri | null = null;
163
+
const fronterUrl: ResourceUri = item.uri;
164
+
if (
165
+
item.reason === "subscribed-post" ||
166
+
item.reason === "quote" ||
167
+
item.reason === "reply"
168
+
)
169
+
postUrl = item.uri;
170
+
if (item.reason === "repost" || item.reason === "repost-via-repost")
171
+
postUrl = (item.record as AppBskyFeedRepost.Main).subject.uri;
172
+
if (item.reason === "like" || item.reason === "like-via-repost")
173
+
postUrl = (item.record as AppBskyFeedLike.Main).subject.uri;
174
+
if (!postUrl) return;
175
+
const cachedFronter = await frontersCache.get(fronterUrl);
176
+
let fronter =
177
+
(cachedFronter ?? null) ||
178
+
(await getFronter(fronterUrl).then((fronter) => {
179
+
if (!fronter.ok) {
180
+
frontersCache.set(fronterUrl, null);
181
+
return null;
182
+
}
183
+
return fronter.value;
184
+
}));
185
+
if (!fronter) return;
186
+
if (item.reason === "reply")
187
+
fronter.replyTo = (
188
+
item.record as AppBskyFeedPost.Main
189
+
).reply?.parent.uri;
190
+
const parsedUri = await cacheFronter(fronterUrl, fronter);
191
+
const postParsedUri = expect(parseCanonicalResourceUri(postUrl));
192
+
let handle: Handle | undefined = undefined;
193
+
try {
194
+
handle =
195
+
getAtprotoHandle(
196
+
await docResolver.resolve(postParsedUri.repo as AtprotoDid),
197
+
) ?? undefined;
198
+
} catch (err) {
199
+
console.error(`failed to get handle for ${postParsedUri.repo}:`, err);
152
200
}
153
-
if (item.reply?.root) {
154
-
promises.push(handlePost(item.reply.root));
201
+
return {
202
+
type: "notification",
203
+
reason: item.reason,
204
+
rkey: parsedUri.rkey!,
205
+
subject: {
206
+
did: postParsedUri.repo as AtprotoDid,
207
+
rkey: postParsedUri.rkey,
208
+
handle,
209
+
},
210
+
...fronter,
211
+
};
212
+
};
213
+
const allPromises = [];
214
+
for (const item of items.notifications ?? []) {
215
+
if (!is(AppBskyNotificationListNotifications.notificationSchema, item))
216
+
continue;
217
+
console.log("Handling notification:", item);
218
+
allPromises.push(handleNotif(item));
219
+
if (item.reason === "reply" && item.record) {
220
+
const parentUri = (item.record as AppBskyFeedPost.Main).reply?.parent
221
+
.uri;
222
+
if (parentUri) allPromises.push(fetchReply(parentUri));
155
223
}
156
-
return promises;
224
+
}
225
+
const results = new Map(
226
+
(await Promise.allSettled(allPromises))
227
+
.filter((result) => result.status === "fulfilled")
228
+
.flatMap((result) => result.value ?? [])
229
+
.flatMap((fronter) =>
230
+
fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]),
231
+
),
232
+
);
233
+
if (results.size === 0) return;
234
+
browser.tabs.sendMessage(sender.tab?.id!, {
235
+
type: "APPLY_FRONTERS",
236
+
results: Object.fromEntries(results),
157
237
});
238
+
};
239
+
const handleTimeline = async (
240
+
feed: any[],
241
+
sender: globalThis.Browser.runtime.MessageSender,
242
+
) => {
243
+
const allPromises = feed.flatMap(
244
+
(item): Promise<FronterView | undefined>[] => {
245
+
if (!is(feedViewPostSchema, item)) return [];
246
+
const handleUri = async (
247
+
uri: ResourceUri,
248
+
type: "repost" | "post",
249
+
) => {
250
+
const cachedFronter = await frontersCache.get(uri);
251
+
if (cachedFronter === null) return;
252
+
const promise = cachedFronter
253
+
? Promise.resolve(cachedFronter)
254
+
: getFronter(uri).then(async (fronter) => {
255
+
if (!fronter.ok) {
256
+
await frontersCache.set(uri, null);
257
+
return;
258
+
}
259
+
return fronter.value;
260
+
});
261
+
return await promise.then(
262
+
async (fronter): Promise<FronterView | undefined> => {
263
+
if (!fronter) return;
264
+
if (type === "repost") {
265
+
const parsedPostUri = expect(
266
+
parseCanonicalResourceUri(item.post.uri),
267
+
);
268
+
fronter = {
269
+
subject: {
270
+
did: parsedPostUri.repo as AtprotoDid,
271
+
rkey: parsedPostUri.rkey,
272
+
handle:
273
+
item.post.author.handle === "handle.invalid"
274
+
? undefined
275
+
: item.post.author.handle,
276
+
},
277
+
...fronter,
278
+
};
279
+
} else if (
280
+
uri === item.post.uri &&
281
+
item.reply?.parent.$type === "app.bsky.feed.defs#postView"
282
+
) {
283
+
fronter = {
284
+
replyTo: item.reply?.parent.uri,
285
+
...fronter,
286
+
};
287
+
} else if (
288
+
uri === item.reply?.parent.uri &&
289
+
item.reply?.parent.$type === "app.bsky.feed.defs#postView"
290
+
) {
291
+
fronter = {
292
+
replyTo: (item.reply.parent.record as AppBskyFeedPost.Main)
293
+
.reply?.parent.uri,
294
+
...fronter,
295
+
};
296
+
}
297
+
const parsedUri = await cacheFronter(uri, fronter);
298
+
return {
299
+
type,
300
+
rkey: parsedUri.rkey!,
301
+
...fronter,
302
+
};
303
+
},
304
+
);
305
+
};
306
+
const promises: ReturnType<typeof handleUri>[] = [];
307
+
promises.push(handleUri(item.post.uri, "post"));
308
+
if (item.reply?.parent) {
309
+
promises.push(handleUri(item.reply.parent.uri, "post"));
310
+
if (item.reply?.parent.$type === "app.bsky.feed.defs#postView") {
311
+
const grandparentUri = (
312
+
item.reply.parent.record as AppBskyFeedPost.Main
313
+
).reply?.parent.uri;
314
+
if (grandparentUri)
315
+
promises.push(handleUri(grandparentUri, "post"));
316
+
}
317
+
}
318
+
if (item.reply?.root) {
319
+
promises.push(handleUri(item.reply.root.uri, "post"));
320
+
}
321
+
if (
322
+
item.reason &&
323
+
item.reason.$type === "app.bsky.feed.defs#reasonRepost" &&
324
+
item.reason.uri
325
+
) {
326
+
promises.push(handleUri(item.reason.uri, "repost"));
327
+
}
328
+
return promises;
329
+
},
330
+
);
158
331
const results = new Map(
159
332
(await Promise.allSettled(allPromises))
160
333
.filter((result) => result.status === "fulfilled")
161
334
.flatMap((result) => result.value ?? [])
162
335
.flatMap((fronter) =>
163
-
fronterGetSocialAppHrefs(fronter, fronter.rkey).map((href) => [
164
-
href,
165
-
fronter,
166
-
]),
336
+
fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]),
167
337
),
168
338
);
169
339
if (results.size === 0) return;
170
340
browser.tabs.sendMessage(sender.tab?.id!, {
171
-
type: "TIMELINE_FRONTER",
172
-
results,
341
+
type: "APPLY_FRONTERS",
342
+
results: Object.fromEntries(results),
173
343
});
174
344
// console.log("sent timeline fronters", results);
175
345
};
···
181
351
) => {
182
352
// check if this request was made for fetching replies
183
353
// if anchor is not the same as current document url, that is the case
184
-
// which means the depth of the returned posts are invalid to us, in the case of THREAD_FRONTER
185
-
// if so we will use TIMELINE_FRONTER to send it back to content script
354
+
// which means the depth of the returned posts are invalid to us
186
355
let isReplyThreadFetch = false;
187
356
const parsedDocumentUri = parseSocialAppPostUrl(documentUrl);
188
357
const anchorUri = new URL(requestUrl).searchParams.get("anchor");
···
210
379
}
211
380
return fronter.value;
212
381
});
213
-
return promise.then(async (fronter): Promise<any> => {
214
-
if (!fronter) return;
215
-
const parsedUri = await cacheFronter(item.uri, fronter);
216
-
if (isReplyThreadFetch)
382
+
return promise.then(
383
+
async (fronter): Promise<FronterView | undefined> => {
384
+
if (!fronter) return;
385
+
const parsedUri = await cacheFronter(item.uri, fronter);
386
+
if (isReplyThreadFetch)
387
+
return {
388
+
type: "thread_reply",
389
+
rkey: parsedUri.rkey!,
390
+
...fronter,
391
+
};
392
+
if (item.depth === 0) await setTabFronter(item.uri, fronter);
393
+
const displayName = item.value.post.author.displayName;
394
+
// cache display name for later use
395
+
if (fronter.handle)
396
+
await displayNameCache.set(fronter.handle, displayName);
397
+
await displayNameCache.set(fronter.did, displayName);
217
398
return {
399
+
type: "thread_post",
218
400
rkey: parsedUri.rkey!,
401
+
displayName,
402
+
depth: item.depth,
219
403
...fronter,
220
404
};
221
-
if (item.depth === 0) await setTabFronter(item.uri, fronter);
222
-
const displayName = item.value.post.author.displayName;
223
-
// cache display name for later use
224
-
if (fronter.handle)
225
-
await displayNameCache.set(fronter.handle, displayName);
226
-
await displayNameCache.set(fronter.did, displayName);
227
-
return {
228
-
rkey: parsedUri.rkey!,
229
-
displayName,
230
-
depth: item.depth,
231
-
...fronter,
232
-
};
233
-
});
405
+
},
406
+
);
234
407
});
235
408
});
236
409
const results = new Map(
···
238
411
.filter((result) => result.status === "fulfilled")
239
412
.flatMap((result) => result.value ?? [])
240
413
.flatMap((fronter) =>
241
-
fronterGetSocialAppHrefs(fronter, fronter.rkey, fronter.depth).map(
242
-
(href) => [href, fronter],
243
-
),
414
+
fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]),
244
415
),
245
416
);
246
417
if (results.size === 0) return;
247
418
browser.tabs.sendMessage(sender.tab?.id!, {
248
-
type: isReplyThreadFetch ? "TIMELINE_FRONTER" : "THREAD_FRONTER",
249
-
results,
419
+
type: "APPLY_FRONTERS",
420
+
results: Object.fromEntries(results),
250
421
});
251
422
// console.log("sent thread fronters", results);
252
423
};
424
+
const handleInteractions = async (
425
+
data: any,
426
+
sender: globalThis.Browser.runtime.MessageSender,
427
+
collection: string,
428
+
actors: { did: AtprotoDid; displayName: string }[],
429
+
) => {
430
+
const postUri = data.uri as ResourceUri;
431
+
const fetchInteractions = async (cursor?: string) => {
432
+
const resp = await fetch(
433
+
`https://constellation.microcosm.blue/links?target=${postUri}&collection=${collection}&path=.subject.uri&limit=100${cursor ? `&cursor=${cursor}` : ""}`,
434
+
);
435
+
if (!resp.ok) return;
436
+
const data = await resp.json();
437
+
return {
438
+
total: data.total as number,
439
+
records: data.linking_records.map(
440
+
(record: any) =>
441
+
`at://${record.did}/${record.collection}/${record.rkey}` as ResourceUri,
442
+
) as ResourceUri[],
443
+
cursor: data.cursor as string,
444
+
};
445
+
};
446
+
let interactions = await fetchInteractions();
447
+
if (!interactions) return;
448
+
let allRecords: (typeof interactions)["records"] = [];
449
+
while (allRecords.length < interactions.total) {
450
+
allRecords.push(...interactions.records);
451
+
if (!interactions.cursor) break;
452
+
interactions = await fetchInteractions(interactions.cursor);
453
+
if (!interactions) break;
454
+
}
455
+
456
+
const actorMap = new Map(
457
+
actors.map((actor) => [actor.did, actor.displayName]),
458
+
);
459
+
const allPromises = allRecords.map(
460
+
async (recordUri): Promise<FronterView | undefined> => {
461
+
const cachedFronter = await frontersCache.get(recordUri);
462
+
let fronter =
463
+
(cachedFronter ?? null) ||
464
+
(await getFronter(recordUri).then((fronter) => {
465
+
if (!fronter.ok) {
466
+
frontersCache.set(recordUri, null);
467
+
return null;
468
+
}
469
+
return fronter.value;
470
+
}));
471
+
if (!fronter) return;
472
+
const parsedUri = await cacheFronter(recordUri, fronter);
473
+
const displayName =
474
+
actorMap.get(fronter.did) ??
475
+
(await displayNameCache.get(fronter.did));
476
+
if (!displayName) return;
477
+
return {
478
+
type:
479
+
collection === "app.bsky.feed.repost"
480
+
? "post_repost_entry"
481
+
: "post_like_entry",
482
+
rkey: parsedUri.rkey!,
483
+
displayName,
484
+
...fronter,
485
+
};
486
+
},
487
+
);
488
+
489
+
const results = new Map(
490
+
(await Promise.allSettled(allPromises))
491
+
.filter((result) => result.status === "fulfilled")
492
+
.flatMap((result) => result.value ?? [])
493
+
.flatMap((fronter) =>
494
+
fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]),
495
+
),
496
+
);
497
+
if (results.size === 0) return;
498
+
browser.tabs.sendMessage(sender.tab?.id!, {
499
+
type: "APPLY_FRONTERS",
500
+
results: Object.fromEntries(results),
501
+
});
502
+
};
503
+
const handleReposts = async (
504
+
data: any,
505
+
sender: globalThis.Browser.runtime.MessageSender,
506
+
) =>
507
+
handleInteractions(
508
+
data,
509
+
sender,
510
+
"app.bsky.feed.repost",
511
+
data.repostedBy.map((by: any) => ({
512
+
did: by.did,
513
+
displayName: by.displayName,
514
+
})),
515
+
);
516
+
const handleLikes = async (
517
+
data: any,
518
+
sender: globalThis.Browser.runtime.MessageSender,
519
+
) =>
520
+
handleInteractions(
521
+
data,
522
+
sender,
523
+
"app.bsky.feed.like",
524
+
data.likes.map((by: any) => ({
525
+
did: by.actor.did,
526
+
displayName: by.actor.displayName,
527
+
})),
528
+
);
253
529
254
530
browser.runtime.onMessage.addListener(async (message, sender) => {
255
531
if (message.type !== "RESPONSE_CAPTURED") return;
···
269
545
sender,
270
546
);
271
547
break;
272
-
case "writeOne":
548
+
case "writeOne": {
273
549
await handleWrite(
274
550
[JSON.parse(message.data.body)],
275
551
message.data.authToken,
276
552
sender,
277
553
);
278
554
break;
555
+
}
279
556
case "posts":
280
557
await handleTimeline(
281
558
(JSON.parse(message.data.body) as any[]).map((post) => ({ post })),
···
287
564
break;
288
565
case "thread":
289
566
await handleThread(message, sender);
567
+
break;
568
+
case "notifications":
569
+
await handleNotifications(JSON.parse(message.data.body), sender);
570
+
break;
571
+
case "reposts":
572
+
await handleReposts(JSON.parse(message.data.body), sender);
573
+
break;
574
+
case "likes":
575
+
await handleLikes(JSON.parse(message.data.body), sender);
290
576
break;
291
577
}
292
578
});
+171
-34
src/entrypoints/content.ts
+171
-34
src/entrypoints/content.ts
···
1
-
import { decodeStorageKey } from "@/lib/cache";
2
1
import { expect } from "@/lib/result";
3
-
import {
4
-
Fronter,
5
-
fronterGetSocialAppHref,
6
-
fronterGetSocialAppHrefs,
7
-
parseSocialAppPostUrl,
8
-
} from "@/lib/utils";
9
-
import { parseResourceUri, ResourceUri } from "@atcute/lexicons";
2
+
import { FronterView, parseSocialAppPostUrl } from "@/lib/utils";
3
+
import { parseResourceUri } from "@atcute/lexicons";
10
4
11
5
const getAuthHeader = (headers: any): string | null => {
12
6
if (headers instanceof Headers) {
···
124
118
type: "posts",
125
119
body,
126
120
};
121
+
} else if (
122
+
response.url.includes("/xrpc/app.bsky.notification.listNotifications")
123
+
) {
124
+
detail = {
125
+
type: "notifications",
126
+
body,
127
+
};
128
+
} else if (response.url.includes("/xrpc/app.bsky.feed.getLikes")) {
129
+
detail = {
130
+
type: "likes",
131
+
body,
132
+
};
133
+
} else if (response.url.includes("/xrpc/app.bsky.feed.getRepostedBy")) {
134
+
detail = {
135
+
type: "reposts",
136
+
body,
137
+
};
127
138
}
128
139
if (detail) {
129
140
sendEvent(detail);
···
146
157
});
147
158
respEventSetup.then((name) => (respEventName = name));
148
159
149
-
const applyFronterName = (el: Element, fronters: Fronter["members"]) => {
150
-
if (el.hasAttribute("data-fronter")) return;
160
+
const applyFronterName = (
161
+
el: Element,
162
+
fronters: FronterView["members"],
163
+
) => {
164
+
if (el.hasAttribute("data-fronter")) return false;
151
165
const s = fronters.map((f) => f.name).join(", ");
152
166
el.textContent += ` [f: ${s}]`;
153
167
el.setAttribute("data-fronter", s);
168
+
return true;
154
169
};
155
170
const applyFrontersToPage = (
156
-
fronters: Map<string, any>,
171
+
fronters: Map<string, FronterView | null>,
157
172
pageChange: boolean,
158
173
) => {
159
174
// console.log("applyFrontersToPage", fronters);
···
164
179
);
165
180
for (const el of document.querySelectorAll("[data-fronter]")) {
166
181
const previousFronter = el.getAttribute("data-fronter")!;
167
-
// remove fronter text
168
-
el.textContent = el.textContent.replace(
169
-
` [f: ${previousFronter}]`,
170
-
"",
171
-
);
182
+
if (previousFronter !== "__set__") {
183
+
// remove fronter text
184
+
el.textContent = el.textContent.replace(
185
+
` [f: ${previousFronter}]`,
186
+
"",
187
+
);
188
+
}
172
189
el.removeAttribute("data-fronter");
173
190
}
174
191
}
175
192
console.log("applyFrontersToPage", match, fronters);
176
193
if (fronters.size === 0) return;
194
+
const applyFronterToElement = (el: Element, fronter: FronterView) => {
195
+
let displayNameElement: Element | null = null;
196
+
if (fronter.type === "repost") {
197
+
displayNameElement =
198
+
el.parentElement?.parentElement?.parentElement?.parentElement
199
+
?.parentElement?.firstElementChild?.nextElementSibling
200
+
?.firstElementChild?.nextElementSibling?.firstElementChild
201
+
?.firstElementChild?.nextElementSibling?.firstElementChild
202
+
?.firstElementChild?.firstElementChild?.firstElementChild ?? null;
203
+
const actorIdentifier = displayNameElement?.parentElement
204
+
?.getAttribute("href")
205
+
?.split("/")[2];
206
+
if (
207
+
fronter.did !== actorIdentifier &&
208
+
fronter.handle !== actorIdentifier
209
+
) {
210
+
return;
211
+
}
212
+
// sanity check
213
+
if (displayNameElement?.tagName !== "SPAN") {
214
+
console.log(
215
+
`invalid display element tag ${displayNameElement?.tagName}, expected span:`,
216
+
displayNameElement,
217
+
);
218
+
return;
219
+
}
220
+
} else if (
221
+
fronter.type === "post" ||
222
+
fronter.type === "thread_reply" ||
223
+
fronter.type === "thread_post" ||
224
+
(fronter.type === "notification" &&
225
+
(fronter.reason === "reply" || fronter.reason === "quote"))
226
+
) {
227
+
if (fronter.type === "thread_post" && fronter.depth === 0) {
228
+
if (match && match.rkey !== fronter.rkey) return;
229
+
if (el.ariaLabel !== fronter.displayName) return;
230
+
displayNameElement =
231
+
el.firstElementChild?.firstElementChild?.firstElementChild
232
+
?.firstElementChild?.firstElementChild ?? null;
233
+
// sanity check
234
+
if (displayNameElement?.tagName !== "DIV") {
235
+
console.log(
236
+
`invalid display element tag ${displayNameElement?.tagName}, expected a:`,
237
+
displayNameElement,
238
+
);
239
+
return;
240
+
}
241
+
} else {
242
+
displayNameElement =
243
+
el.parentElement?.firstElementChild?.firstElementChild
244
+
?.firstElementChild?.firstElementChild ?? null;
245
+
// sanity check
246
+
if (displayNameElement?.tagName !== "A") {
247
+
console.log(
248
+
`invalid display element tag ${displayNameElement?.tagName}, expected a:`,
249
+
displayNameElement,
250
+
);
251
+
return;
252
+
}
253
+
if (fronter.type === "post" && fronter.replyTo) {
254
+
const parsedReplyUri = expect(parseResourceUri(fronter.replyTo));
255
+
const replyFronter = fronters.get(
256
+
`/profile/${parsedReplyUri.repo}/post/${parsedReplyUri.rkey}`,
257
+
);
258
+
if (replyFronter && replyFronter.members?.length > 0) {
259
+
const replyDisplayNameElement =
260
+
el.parentElement?.parentElement?.parentElement
261
+
?.firstElementChild?.nextElementSibling?.firstElementChild
262
+
?.nextElementSibling?.firstElementChild?.firstElementChild
263
+
?.firstElementChild?.firstElementChild ?? null;
264
+
if (replyDisplayNameElement) {
265
+
applyFronterName(
266
+
replyDisplayNameElement,
267
+
replyFronter.members,
268
+
);
269
+
}
270
+
}
271
+
}
272
+
}
273
+
} else if (fronter.type === "notification") {
274
+
const multiOne =
275
+
el.firstElementChild?.nextElementSibling?.nextElementSibling
276
+
?.firstElementChild?.firstElementChild?.nextElementSibling
277
+
?.nextElementSibling?.firstElementChild?.firstElementChild
278
+
?.firstElementChild ?? null;
279
+
const singleOne =
280
+
el.firstElementChild?.nextElementSibling?.nextElementSibling
281
+
?.firstElementChild?.nextElementSibling?.nextElementSibling
282
+
?.firstElementChild?.firstElementChild?.firstElementChild ?? null;
283
+
displayNameElement = multiOne ?? singleOne ?? null;
284
+
if (displayNameElement?.tagName !== "A") {
285
+
console.log(
286
+
`invalid display element tag ${displayNameElement?.tagName}, expected a:`,
287
+
displayNameElement,
288
+
);
289
+
return;
290
+
}
291
+
const profileHref = displayNameElement?.getAttribute("href");
292
+
if (profileHref) {
293
+
const actorIdentifier = profileHref.split("/").slice(2)[0];
294
+
const isUser =
295
+
fronter.handle !== actorIdentifier &&
296
+
fronter.did !== actorIdentifier;
297
+
if (isUser) displayNameElement = null;
298
+
} else displayNameElement = null;
299
+
} else if (
300
+
fronter.type === "post_repost_entry" ||
301
+
fronter.type === "post_like_entry"
302
+
) {
303
+
// HACK: evil ass way to do this
304
+
if (el.ariaLabel !== `View ${fronter.displayName}'s profile`) return;
305
+
displayNameElement =
306
+
el.firstElementChild?.firstElementChild?.firstElementChild
307
+
?.nextElementSibling?.firstElementChild?.firstElementChild ??
308
+
null;
309
+
if (displayNameElement?.tagName !== "DIV") {
310
+
console.log(
311
+
`invalid display element tag ${displayNameElement?.tagName}, expected div:`,
312
+
displayNameElement,
313
+
);
314
+
return;
315
+
}
316
+
}
317
+
if (!displayNameElement) return;
318
+
return applyFronterName(displayNameElement, fronter.members);
319
+
};
177
320
for (const el of document.getElementsByTagName("a")) {
321
+
if (el.getAttribute("data-fronter")) continue;
178
322
const path = `/${el.href.split("/").slice(3).join("/")}`;
179
-
const fronter = fronters.get(path);
180
-
if (!fronter || fronter.members?.length === 0) continue;
181
-
if (el.hasAttribute("data-fronter")) continue;
182
-
const isFocusedPost = fronter.depth === 0;
183
-
if (isFocusedPost) if (match && match.rkey !== fronter.rkey) continue;
184
-
if (isFocusedPost) if (el.ariaLabel !== fronter.displayName) continue;
185
-
const displayNameElement = isFocusedPost
186
-
? (el.firstElementChild?.firstElementChild?.firstElementChild
187
-
?.firstElementChild?.firstElementChild ?? null)
188
-
: (el.parentElement?.firstElementChild?.firstElementChild
189
-
?.firstElementChild?.firstElementChild ?? null);
190
-
if (!displayNameElement) continue;
191
-
applyFronterName(displayNameElement, fronter.members);
323
+
const elFronters = [fronters.get(path), fronters.get(`${path}#repost`)];
324
+
for (const fronter of elFronters) {
325
+
if (!fronter || fronter.members?.length === 0) continue;
326
+
if (applyFronterToElement(el, fronter)) {
327
+
el.setAttribute("data-fronter", "__set__");
328
+
}
329
+
}
192
330
}
193
331
};
194
332
let postTabObserver: MutationObserver | null = null;
···
196
334
if (event.data.type !== "APPLY_CACHED_FRONTERS") return;
197
335
const applyFronters = () => {
198
336
console.log("applying cached fronters", event.data.fronters);
199
-
applyFrontersToPage(event.data.fronters, true);
337
+
applyFrontersToPage(new Map(Object.entries(event.data.fronters)), true);
200
338
};
201
339
// check if we are on profile so we can update fronters if the post tab is clicked on
202
340
const postTabElement = document.querySelector(
···
213
351
applyFronters();
214
352
});
215
353
window.addEventListener("message", (event) => {
216
-
if (!["TIMELINE_FRONTER", "THREAD_FRONTER"].includes(event.data.type))
217
-
return;
218
-
console.log(`received ${event.data.type} fronters`, event.data.results);
219
-
applyFrontersToPage(event.data.results, false);
354
+
if (event.data.type !== "APPLY_FRONTERS") return;
355
+
console.log(`received new fronters`, event.data.results);
356
+
applyFrontersToPage(new Map(Object.entries(event.data.results)), false);
220
357
});
221
358
},
222
359
});
+42
-21
src/entrypoints/isolated.content.ts
+42
-21
src/entrypoints/isolated.content.ts
···
3
3
import {
4
4
displayNameCache,
5
5
Fronter,
6
+
fronterGetSocialAppHref,
6
7
fronterGetSocialAppHrefs,
7
8
frontersCache,
9
+
FronterView,
8
10
parseSocialAppPostUrl,
9
11
} from "@/lib/utils";
10
12
import { parseResourceUri, ResourceUri } from "@atcute/lexicons";
···
39
41
data,
40
42
});
41
43
});
42
-
const messageTypes = ["TAB_FRONTER", "THREAD_FRONTER", "TIMELINE_FRONTER"];
44
+
const bgMessageTypes = ["APPLY_FRONTERS"];
43
45
browser.runtime.onMessage.addListener((message) => {
44
-
if (!messageTypes.includes(message.type)) return;
46
+
if (!bgMessageTypes.includes(message.type)) return;
45
47
window.postMessage(message);
46
48
});
47
49
const updateOnUrlChange = async () => {
48
50
const fronters = await frontersCache.getAll();
49
-
const updated = new Map<string, any>(
50
-
fronters.entries().flatMap(([storageKey, fronter]) => {
51
-
if (!fronter) return [];
52
-
const uri = decodeStorageKey(storageKey);
53
-
const rkey = expect(parseResourceUri(uri)).rkey!;
54
-
return fronterGetSocialAppHrefs(fronter, rkey).map((href) => [
55
-
href,
56
-
fronter,
57
-
]);
58
-
}),
59
-
);
51
+
const updated = new Map<string, FronterView | null>();
52
+
for (const [storageKey, fronter] of fronters.entries()) {
53
+
const uri = decodeStorageKey(storageKey);
54
+
const parsedUri = expect(parseResourceUri(uri));
55
+
if (!fronter) {
56
+
updated.set(
57
+
fronterGetSocialAppHref(parsedUri.repo, parsedUri.rkey!),
58
+
null,
59
+
);
60
+
continue;
61
+
}
62
+
const view: FronterView = {
63
+
type:
64
+
parsedUri.collection === "app.bsky.feed.repost" ? "repost" : "post",
65
+
rkey: parsedUri.rkey!,
66
+
...fronter,
67
+
};
68
+
for (const href of fronterGetSocialAppHrefs(view)) {
69
+
updated.set(href, view);
70
+
}
71
+
}
60
72
// add entry for current page
61
73
const match = parseSocialAppPostUrl(document.location.href);
62
74
if (match && !updated.has(`/profile/${match.actorIdentifier}`)) {
63
75
const maybeFronter = updated.get(
64
76
`/profile/${match.actorIdentifier}/post/${match.rkey}`,
65
77
);
66
-
if (maybeFronter)
67
-
updated.set(`/profile/${match.actorIdentifier}`, {
68
-
depth: 0,
69
-
displayName: await displayNameCache.get(match.actorIdentifier),
70
-
rkey: match.rkey,
71
-
...maybeFronter,
72
-
});
78
+
if (maybeFronter) {
79
+
const displayName = await displayNameCache.get(match.actorIdentifier);
80
+
if (displayName) {
81
+
const view: FronterView = {
82
+
...maybeFronter,
83
+
type: "thread_post",
84
+
depth: 0,
85
+
displayName,
86
+
rkey: match.rkey,
87
+
};
88
+
updated.set(`/profile/${maybeFronter.did}`, view);
89
+
if (maybeFronter.handle) {
90
+
updated.set(`/profile/${maybeFronter.handle}`, view);
91
+
}
92
+
}
93
+
}
73
94
}
74
95
window.postMessage({
75
96
type: "APPLY_CACHED_FRONTERS",
76
-
fronters: updated,
97
+
fronters: Object.fromEntries(updated),
77
98
});
78
99
// check for tab fronter for the current "post"
79
100
await checkFronter(document.location.href);
+26
-16
src/entrypoints/popup/App.svelte
+26
-16
src/entrypoints/popup/App.svelte
···
13
13
let queryError = $state("");
14
14
let isQuerying = $state(false);
15
15
let fronters = $state<string[]>([]);
16
-
let pkFronters = $state<string[]>([]);
16
+
let pkSystemId = $state<string>("");
17
17
let spToken = $state("");
18
18
let isFromCurrentTab = $state(false);
19
19
···
51
51
storage.setItem("sync:fronters", newFronters);
52
52
};
53
53
54
-
const updatePkFronters = (newPkFronters: string[]) => {
55
-
pkFronters = newPkFronters;
56
-
storage.setItem("sync:pk-fronter", newPkFronters);
54
+
const updatePkSystem = (event: any) => {
55
+
pkSystemId = (event.target as HTMLInputElement).value;
56
+
storage.setItem("sync:pk-system", pkSystemId);
57
57
};
58
58
59
59
const updateSpToken = (event: any) => {
···
80
80
fronters = frontersArray;
81
81
}
82
82
83
-
const pkFrontersArray =
84
-
await storage.getItem<string[]>("sync:pk-fronter");
85
-
if (pkFrontersArray && Array.isArray(pkFrontersArray)) {
86
-
pkFronters = pkFrontersArray;
83
+
const pkSystem = await storage.getItem<string>("sync:pk-system");
84
+
if (pkSystem) {
85
+
pkSystemId = pkSystem;
87
86
}
88
87
89
88
const token = await storage.getItem<string>("sync:sp_token");
···
232
231
</span>
233
232
</div>
234
233
</div>
235
-
<FronterList
236
-
bind:fronters={pkFronters}
237
-
onUpdate={updatePkFronters}
238
-
label="PK FRONTERS"
239
-
placeholder="enter_member_ids"
240
-
note="PluralKit member IDs, overrides SP fronters"
241
-
fetchNames={true}
242
-
/>
234
+
<div class="config-card">
235
+
<div class="config-row">
236
+
<span class="config-label">PK SYSTEM</span>
237
+
<input
238
+
type="password"
239
+
placeholder="enter_pk_system_id"
240
+
oninput={updatePkSystem}
241
+
bind:value={pkSystemId}
242
+
class="config-input"
243
+
class:has-value={pkSystemId}
244
+
/>
245
+
</div>
246
+
<div class="config-note">
247
+
<span class="note-text">
248
+
when set, pulls fronters from PluralKit (fronters
249
+
must be public)
250
+
</span>
251
+
</div>
252
+
</div>
243
253
<FronterList
244
254
bind:fronters
245
255
onUpdate={updateFronters}
+110
-23
src/lib/utils.ts
+110
-23
src/lib/utils.ts
···
27
27
} from "@atcute/identity-resolver";
28
28
import { getAtprotoHandle, getPdsEndpoint } from "@atcute/identity";
29
29
import { PersistentCache } from "./cache";
30
+
import { AppBskyNotificationListNotifications } from "@atcute/bluesky";
31
+
32
+
export type Subject = {
33
+
handle?: Handle;
34
+
did: AtprotoDid;
35
+
rkey: RecordKey;
36
+
};
30
37
31
38
export type Fronter = {
32
39
members: {
···
35
42
}[];
36
43
handle: Handle | null;
37
44
did: AtprotoDid;
45
+
subject?: Subject;
46
+
replyTo?: ResourceUri;
38
47
};
39
48
49
+
export type FronterView = Fronter & { rkey: RecordKey } & (
50
+
| {
51
+
type: "thread_reply";
52
+
}
53
+
| {
54
+
type: "thread_post";
55
+
displayName: string;
56
+
depth: number;
57
+
}
58
+
| {
59
+
type: "post";
60
+
}
61
+
| {
62
+
type: "like";
63
+
}
64
+
| {
65
+
type: "repost";
66
+
}
67
+
| {
68
+
type: "notification";
69
+
reason: InferOutput<AppBskyNotificationListNotifications.notificationSchema>["reason"];
70
+
}
71
+
| {
72
+
type: "post_repost_entry";
73
+
displayName: string;
74
+
}
75
+
| {
76
+
type: "post_like_entry";
77
+
displayName: string;
78
+
}
79
+
);
80
+
export type FronterType = FronterView["type"];
81
+
40
82
export const fronterSchema = v.record(
41
83
v.string(),
42
84
v.object({
···
54
96
55
97
export type MemberUri =
56
98
| { type: "at"; recordUri: ResourceUri }
57
-
| { type: "pk"; memberId: string }
99
+
| { type: "pk"; systemId: string; memberId: string }
58
100
| { type: "sp"; systemId: string; memberId: string };
59
101
60
102
export const parseMemberId = (memberId: GenericUri): MemberUri => {
···
62
104
switch (uri.protocol) {
63
105
case "pk:": {
64
106
const split = uri.pathname.split("/").slice(1);
65
-
return { type: "pk", memberId: split[0] };
107
+
return { type: "pk", systemId: split[0], memberId: split[1] };
66
108
}
67
109
case "sp:": {
68
110
const split = uri.pathname.split("/").slice(1);
···
142
184
}
143
185
};
144
186
145
-
export const getFronterNames = async (members: (string | MemberUri)[]) => {
187
+
export const getFronterNames = async (
188
+
members: { name?: string; uri?: MemberUri }[],
189
+
) => {
146
190
const promises = await Promise.allSettled(
147
191
members.map(async (m): Promise<Fronter["members"][0] | null> => {
148
-
if (typeof m === "string")
149
-
return Promise.resolve({ uri: undefined, name: m });
150
-
const name = await fetchMember(m);
151
-
return name ? { uri: m, name } : null;
192
+
if (!m.uri) return Promise.resolve({ uri: undefined, name: m.name! });
193
+
if (m.name) return Promise.resolve({ uri: m.uri, name: m.name });
194
+
const name = await fetchMember(m.uri);
195
+
return name ? { uri: m.uri, name } : null;
152
196
}),
153
197
);
154
198
return promises
···
156
200
.flatMap((p) => p.value ?? []);
157
201
};
158
202
159
-
const handleResolver = new CompositeHandleResolver({
203
+
export const handleResolver = new CompositeHandleResolver({
160
204
strategy: "race",
161
205
methods: {
162
206
dns: new DohJsonHandleResolver({
···
165
209
http: new WellKnownHandleResolver(),
166
210
},
167
211
});
168
-
const docResolver = new CompositeDidDocumentResolver({
212
+
export const docResolver = new CompositeDidDocumentResolver({
169
213
methods: {
170
214
plc: new PlcDidDocumentResolver(),
171
215
web: new WebDidDocumentResolver(),
···
244
288
245
289
export const putFronter = async (
246
290
subject: FronterSchema["subject"],
247
-
members: (string | MemberUri)[],
291
+
members: { name?: string; uri?: MemberUri }[],
248
292
authToken: string,
249
293
): Promise<Result<Fronter, string>> => {
250
294
const parsedRecordUri = parseResourceUri(subject);
···
315
359
return ok(true);
316
360
};
317
361
318
-
export const getSpFronters = async (): Promise<MemberUri[]> => {
362
+
export const getSpFronters = async (): Promise<
363
+
Parameters<typeof putFronter>["1"]
364
+
> => {
319
365
const spToken = await storage.getItem<string>("sync:sp_token");
320
366
if (!spToken) return [];
321
367
const resp = await fetch(`https://api.apparyllis.com/v1/fronters`, {
···
326
372
if (!resp.ok) return [];
327
373
const spFronters = (await resp.json()) as any[];
328
374
return spFronters.map((fronter) => ({
329
-
type: "sp",
330
-
memberId: fronter.content.member,
331
-
systemId: fronter.content.uid,
375
+
name: undefined,
376
+
uri: {
377
+
type: "sp",
378
+
memberId: fronter.content.member,
379
+
systemId: fronter.content.uid,
380
+
},
332
381
}));
333
382
};
334
383
335
-
export const fronterGetSocialAppHrefs = (
336
-
fronter: Fronter,
337
-
rkey: RecordKey,
338
-
depth?: number,
339
-
) => {
384
+
export const getPkFronters = async (): Promise<
385
+
Parameters<typeof putFronter>["1"]
386
+
> => {
387
+
const pkSystemId = await storage.getItem<string>("sync:pk-system");
388
+
if (!pkSystemId) return [];
389
+
const resp = await fetch(
390
+
`https://api.pluralkit.me/v2/systems/${pkSystemId}/fronters`,
391
+
);
392
+
if (!resp.ok) return [];
393
+
const pkFronters = await resp.json();
394
+
return (pkFronters.members as any[]).map((member) => ({
395
+
name: member.display_name ?? member.name,
396
+
uri: {
397
+
type: "pk",
398
+
memberId: member.id,
399
+
systemId: member.system,
400
+
},
401
+
}));
402
+
};
403
+
404
+
export const fronterGetSocialAppHrefs = (view: FronterView) => {
405
+
if (view.type === "repost" && view.subject) {
406
+
const subject = view.subject;
407
+
const handle = subject?.handle;
408
+
return [
409
+
handle ? [`${fronterGetSocialAppHref(handle, subject.rkey)}#repost`] : [],
410
+
`${fronterGetSocialAppHref(subject.did, subject.rkey)}#repost`,
411
+
].flat();
412
+
} else if (view.type === "notification" && view.subject) {
413
+
const subject = view.subject;
414
+
const handle = subject?.handle;
415
+
return [
416
+
handle ? [`${fronterGetSocialAppHref(handle, subject.rkey)}`] : [],
417
+
`${fronterGetSocialAppHref(subject.did, subject.rkey)}`,
418
+
].flat();
419
+
} else if (
420
+
view.type === "post_repost_entry" ||
421
+
view.type === "post_like_entry"
422
+
) {
423
+
return [
424
+
view.handle ? [`/profile/${view.handle}`] : [],
425
+
`/profile/${view.did}`,
426
+
].flat();
427
+
}
428
+
const depth = view.type === "thread_post" ? view.depth : undefined;
340
429
return [
341
-
fronter.handle
342
-
? [fronterGetSocialAppHref(fronter.handle, rkey, depth)]
343
-
: [],
344
-
fronterGetSocialAppHref(fronter.did, rkey, depth),
430
+
view.handle ? [fronterGetSocialAppHref(view.handle, view.rkey, depth)] : [],
431
+
fronterGetSocialAppHref(view.did, view.rkey, depth),
345
432
].flat();
346
433
};
347
434