+1
.gitignore
+1
.gitignore
+19
LICENSE
+19
LICENSE
···
1
+
Copyright (c) 2025 dusk
2
+
3
+
Permission is hereby granted, free of charge, to any person obtaining a
4
+
copy of this software and associated documentation files (the "Software"),
5
+
to deal in the Software without restriction, including without limitation
6
+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
7
+
and/or sell copies of the Software, and to permit persons to whom the
8
+
Software is furnished to do so, subject to the following conditions:
9
+
10
+
The above copyright notice and this permission notice shall be included
11
+
in all copies or substantial portions of the Software.
12
+
13
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
14
+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
19
+
IN THE SOFTWARE.
+12
README.md
+12
README.md
···
1
+
## at fronter
2
+
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
+
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
+
10
+
#### building
11
+
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.0",
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':
+369
src/components/FronterList.svelte
+369
src/components/FronterList.svelte
···
1
+
<script lang="ts">
2
+
import { fetchMember, type MemberUri } from "@/lib/utils";
3
+
4
+
interface Props {
5
+
fronters: string[];
6
+
onUpdate: (fronters: string[]) => void;
7
+
label?: string;
8
+
placeholder?: string;
9
+
note?: string;
10
+
fetchNames?: boolean; // If true, treat as PK member IDs and fetch names
11
+
}
12
+
13
+
let {
14
+
fronters = $bindable([]),
15
+
onUpdate,
16
+
label = "FRONTERS",
17
+
placeholder = "enter_identifier",
18
+
note = "list of identifiers",
19
+
fetchNames = false,
20
+
}: Props = $props();
21
+
22
+
let inputValue = $state("");
23
+
let inputElement: HTMLInputElement;
24
+
let memberNames = $state<Map<string, string | null>>(new Map());
25
+
let memberErrors = $state<Map<string, string>>(new Map());
26
+
27
+
const fetchMemberName = async (memberId: string) => {
28
+
try {
29
+
const memberUri: MemberUri = { type: "pk", memberId };
30
+
const name = await fetchMember(memberUri);
31
+
if (name) {
32
+
memberNames.set(memberId, name);
33
+
memberErrors.delete(memberId);
34
+
} else {
35
+
memberNames.set(memberId, null);
36
+
memberErrors.set(memberId, "Member not found");
37
+
}
38
+
} catch (error) {
39
+
memberNames.set(memberId, null);
40
+
memberErrors.set(memberId, `Error: ${error}`);
41
+
}
42
+
// Trigger reactivity
43
+
memberNames = new Map(memberNames);
44
+
memberErrors = new Map(memberErrors);
45
+
};
46
+
47
+
const addFronter = (name: string) => {
48
+
const trimmedName = name.trim();
49
+
if (!trimmedName || fronters.includes(trimmedName)) return;
50
+
51
+
const updatedFronters = [...fronters, trimmedName];
52
+
fronters = updatedFronters;
53
+
onUpdate(updatedFronters);
54
+
inputValue = "";
55
+
56
+
// Fetch the member name if this is a PK fronter
57
+
if (fetchNames) {
58
+
fetchMemberName(trimmedName);
59
+
}
60
+
};
61
+
62
+
const removeFronter = (index: number) => {
63
+
const identifier = fronters[index];
64
+
const updatedFronters = fronters.filter((_, i) => i !== index);
65
+
fronters = updatedFronters;
66
+
onUpdate(updatedFronters);
67
+
68
+
// Clean up the member name cache if this is a PK fronter
69
+
if (fetchNames) {
70
+
memberNames.delete(identifier);
71
+
memberErrors.delete(identifier);
72
+
memberNames = new Map(memberNames);
73
+
memberErrors = new Map(memberErrors);
74
+
}
75
+
76
+
inputElement?.focus();
77
+
};
78
+
79
+
const handleKeyPress = (event: KeyboardEvent) => {
80
+
if (event.key === "Enter" || event.key === "," || event.key === " ") {
81
+
event.preventDefault();
82
+
addFronter(inputValue);
83
+
} else if (
84
+
event.key === "Backspace" &&
85
+
inputValue === "" &&
86
+
fronters.length > 0
87
+
) {
88
+
// Remove last tag when backspacing on empty input
89
+
removeFronter(fronters.length - 1);
90
+
}
91
+
};
92
+
93
+
const handleInput = (event: Event) => {
94
+
const target = event.target as HTMLInputElement;
95
+
const value = target.value;
96
+
97
+
// Check for comma or space at the end
98
+
if (value.endsWith(",") || value.endsWith(" ")) {
99
+
addFronter(value.slice(0, -1));
100
+
} else {
101
+
inputValue = value;
102
+
}
103
+
};
104
+
105
+
const focusInput = () => {
106
+
inputElement?.focus();
107
+
};
108
+
109
+
// Load existing member names on mount (only for PK fronters)
110
+
$effect(() => {
111
+
if (fetchNames) {
112
+
fronters.forEach((identifier) => {
113
+
if (
114
+
!memberNames.has(identifier) &&
115
+
!memberErrors.has(identifier)
116
+
) {
117
+
fetchMemberName(identifier);
118
+
}
119
+
});
120
+
}
121
+
});
122
+
123
+
// Helper function to get display text for a fronter
124
+
const getDisplayText = (identifier: string) => {
125
+
if (!fetchNames) return identifier;
126
+
return memberNames.get(identifier) || identifier;
127
+
};
128
+
129
+
// Helper function to check if we should show error/loading state
130
+
const getStatusInfo = (identifier: string) => {
131
+
if (!fetchNames) return null;
132
+
133
+
if (memberErrors.has(identifier)) {
134
+
return { type: "error", text: memberErrors.get(identifier) };
135
+
}
136
+
if (memberNames.get(identifier) === undefined) {
137
+
return { type: "loading", text: "loading..." };
138
+
}
139
+
return null;
140
+
};
141
+
</script>
142
+
143
+
<div class="config-card">
144
+
<div class="config-row">
145
+
<span class="config-label">{label}</span>
146
+
<div
147
+
class="tag-input-container"
148
+
onclick={focusInput}
149
+
onkeydown={(e) => e.key === "Enter" && focusInput()}
150
+
role="textbox"
151
+
tabindex="0"
152
+
>
153
+
<div class="tag-input-wrapper">
154
+
{#each fronters as identifier, index}
155
+
<div class="fronter-tag">
156
+
<div class="tag-content">
157
+
<span class="tag-text">
158
+
{getDisplayText(identifier)}
159
+
</span>
160
+
{#if getStatusInfo(identifier)}
161
+
{@const status = getStatusInfo(identifier)}
162
+
{#if status}
163
+
<span class="tag-{status.type}"
164
+
>{status.text}</span
165
+
>
166
+
{/if}
167
+
{/if}
168
+
</div>
169
+
<button
170
+
onclick={() => removeFronter(index)}
171
+
class="tag-remove"
172
+
title="Remove fronter"
173
+
>
174
+
ร
175
+
</button>
176
+
</div>
177
+
{/each}
178
+
<input
179
+
bind:this={inputElement}
180
+
type="text"
181
+
placeholder={fronters.length === 0 ? placeholder : ""}
182
+
value={inputValue}
183
+
oninput={handleInput}
184
+
onkeydown={handleKeyPress}
185
+
class="tag-input"
186
+
/>
187
+
</div>
188
+
</div>
189
+
</div>
190
+
191
+
<div class="config-note">
192
+
<span class="note-text">{note}</span>
193
+
</div>
194
+
</div>
195
+
196
+
<style>
197
+
.config-card {
198
+
background: #0d0d0d;
199
+
border: 1px solid #2a2a2a;
200
+
border-left: 3px solid #444444;
201
+
padding: 10px;
202
+
display: flex;
203
+
flex-direction: column;
204
+
gap: 6px;
205
+
transition: border-left-color 0.2s ease;
206
+
}
207
+
208
+
.config-card:hover {
209
+
border-left-color: #555555;
210
+
}
211
+
212
+
.config-row {
213
+
display: flex;
214
+
align-items: center;
215
+
gap: 12px;
216
+
margin-bottom: 0;
217
+
}
218
+
219
+
.config-label {
220
+
font-size: 12px;
221
+
color: #cccccc;
222
+
letter-spacing: 1px;
223
+
font-weight: 700;
224
+
white-space: nowrap;
225
+
min-width: 90px;
226
+
}
227
+
228
+
.tag-input-container {
229
+
flex: 1;
230
+
background: #181818;
231
+
border: 1px solid #333333;
232
+
transition: border-color 0.2s ease;
233
+
cursor: text;
234
+
min-height: 42px;
235
+
display: flex;
236
+
align-items: center;
237
+
}
238
+
239
+
.tag-input-container:focus-within {
240
+
border-color: #666666;
241
+
}
242
+
243
+
.tag-input-container:focus-within:has(.fronter-tag) {
244
+
border-bottom-color: #00ff41;
245
+
}
246
+
247
+
.tag-input-wrapper {
248
+
display: flex;
249
+
flex-wrap: wrap;
250
+
align-items: center;
251
+
gap: 6px;
252
+
padding: 8px 12px;
253
+
width: 100%;
254
+
min-height: 26px;
255
+
}
256
+
257
+
.fronter-tag {
258
+
display: flex;
259
+
align-items: center;
260
+
background: #2a2a2a;
261
+
border: 1px solid #444444;
262
+
border-radius: 3px;
263
+
padding: 4px 6px;
264
+
gap: 6px;
265
+
font-size: 11px;
266
+
color: #ffffff;
267
+
font-weight: 600;
268
+
line-height: 1;
269
+
transition: all 0.15s ease;
270
+
animation: tagAppear 0.2s ease-out;
271
+
}
272
+
273
+
.fronter-tag:hover {
274
+
background: #333333;
275
+
border-color: #555555;
276
+
}
277
+
278
+
.tag-content {
279
+
display: flex;
280
+
flex-direction: column;
281
+
gap: 2px;
282
+
}
283
+
284
+
.tag-text {
285
+
white-space: nowrap;
286
+
letter-spacing: 0.5px;
287
+
}
288
+
289
+
.tag-error {
290
+
font-size: 9px;
291
+
color: #ff6666;
292
+
font-weight: 500;
293
+
letter-spacing: 0.3px;
294
+
}
295
+
296
+
.tag-loading {
297
+
font-size: 9px;
298
+
color: #888888;
299
+
font-weight: 500;
300
+
letter-spacing: 0.3px;
301
+
font-style: italic;
302
+
}
303
+
304
+
.tag-remove {
305
+
background: none;
306
+
border: none;
307
+
color: #888888;
308
+
font-size: 14px;
309
+
font-weight: 700;
310
+
cursor: pointer;
311
+
padding: 0;
312
+
line-height: 1;
313
+
transition: color 0.15s ease;
314
+
display: flex;
315
+
align-items: center;
316
+
justify-content: center;
317
+
width: 14px;
318
+
height: 14px;
319
+
margin-left: 2px;
320
+
}
321
+
322
+
.tag-remove:hover {
323
+
color: #ff4444;
324
+
}
325
+
326
+
.tag-input {
327
+
background: transparent;
328
+
border: none;
329
+
outline: none;
330
+
color: #ffffff;
331
+
font-family: inherit;
332
+
font-size: 12px;
333
+
font-weight: 500;
334
+
flex: 1;
335
+
min-width: 120px;
336
+
height: 26px;
337
+
}
338
+
339
+
.tag-input::placeholder {
340
+
color: #777777;
341
+
font-size: 12px;
342
+
}
343
+
344
+
.config-note {
345
+
padding: 0;
346
+
background: transparent;
347
+
border: none;
348
+
margin: 0;
349
+
}
350
+
351
+
.note-text {
352
+
font-size: 11px;
353
+
color: #bbbbbb;
354
+
line-height: 1.3;
355
+
font-weight: 500;
356
+
letter-spacing: 0.5px;
357
+
}
358
+
359
+
@keyframes tagAppear {
360
+
0% {
361
+
opacity: 0;
362
+
transform: scale(0.8);
363
+
}
364
+
100% {
365
+
opacity: 1;
366
+
transform: scale(1);
367
+
}
368
+
}
369
+
</style>
+467
-90
src/entrypoints/background.ts
+467
-90
src/entrypoints/background.ts
···
1
1
import { expect } from "@/lib/result";
2
2
import {
3
-
Fronter,
4
-
fronterGetSocialAppHref,
3
+
type Fronter,
4
+
fronterGetSocialAppHrefs,
5
5
getFronter,
6
+
getSpFronters,
6
7
putFronter,
8
+
frontersCache,
9
+
parseSocialAppPostUrl,
10
+
displayNameCache,
11
+
deleteFronter,
12
+
getPkFronters,
13
+
FronterView,
14
+
docResolver,
7
15
} from "@/lib/utils";
8
-
import { parseResourceUri, ResourceUri } from "@atcute/lexicons";
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,
28
+
parseCanonicalResourceUri,
29
+
} from "@atcute/lexicons/syntax";
9
30
10
31
export default defineBackground({
11
32
persistent: true,
12
33
main: () => {
13
34
console.log("setting up background script");
14
35
15
-
let fronters = new Map<ResourceUri, Fronter | null>();
16
-
const cacheFronter = (uri: ResourceUri, fronter: Fronter) => {
36
+
const cacheFronter = async (uri: ResourceUri, fronter: Fronter) => {
17
37
const parsedUri = expect(parseResourceUri(uri));
18
-
fronters.set(uri, fronter);
19
-
fronters.set(
38
+
await frontersCache.set(
20
39
`at://${fronter.did}/${parsedUri.collection!}/${parsedUri.rkey!}`,
21
40
fronter,
22
41
);
23
-
fronters.set(
42
+
await frontersCache.set(
24
43
`at://${fronter.handle}/${parsedUri.collection!}/${parsedUri.rkey!}`,
25
44
fronter,
26
45
);
···
48
67
browser.tabs.onUpdated.addListener(deleteOld);
49
68
};
50
69
70
+
const handleDelete = async (
71
+
data: any,
72
+
authToken: string | null,
73
+
sender: globalThis.Browser.runtime.MessageSender,
74
+
) => {
75
+
if (!authToken) return;
76
+
const deleted = await deleteFronter(
77
+
data.repo,
78
+
data.collection,
79
+
data.rkey,
80
+
authToken,
81
+
);
82
+
if (!deleted.ok) {
83
+
console.error("failed to delete fronter:", deleted.error);
84
+
}
85
+
};
51
86
const handleWrite = async (
52
-
{ data: { body, authToken } }: any,
87
+
items: any[],
88
+
authToken: string | null,
53
89
sender: globalThis.Browser.runtime.MessageSender,
54
90
) => {
55
-
const fronter = await storage.getItem<string>("sync:fronter");
56
-
if (!fronter) return;
57
91
if (!authToken) return;
58
-
const data: any = JSON.parse(body);
59
-
// console.log("will put fronter", fronter, "for records", data.results);
60
-
const results = [];
61
-
for (const result of data.results) {
62
-
const resp = await putFronter(result.uri, fronter, authToken);
92
+
const frontersArray = await storage.getItem<string[]>("sync:fronters");
93
+
let members: Parameters<typeof putFronter>["1"] =
94
+
frontersArray?.map((n) => ({ name: n, uri: undefined })) ?? [];
95
+
if (members.length === 0) {
96
+
members = await getPkFronters();
97
+
}
98
+
if (members.length === 0) {
99
+
members = await getSpFronters();
100
+
}
101
+
// dont write if no names is specified or no sp/pk fronters are fetched
102
+
if (members.length === 0) return;
103
+
const results: FronterView[] = [];
104
+
for (const result of items) {
105
+
const resp = await putFronter(result.uri, members, authToken);
63
106
if (resp.ok) {
64
-
const parsedUri = cacheFronter(result.uri, resp.value);
107
+
const parsedUri = await cacheFronter(result.uri, resp.value);
65
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",
66
115
rkey: parsedUri.rkey!,
67
116
...resp.value,
68
117
});
118
+
} else {
119
+
console.error(`fronter write: ${resp.error}`);
69
120
}
70
121
}
122
+
if (results.length === 0) return;
123
+
// hijack timeline fronter message because when a write is made it is either on the timeline
124
+
// or its a reply to a depth === 0 post on a threaded view, which is the same as a timeline post
71
125
browser.tabs.sendMessage(sender.tab?.id!, {
72
-
type: "TIMELINE_FRONTER",
73
-
results: new Map(
74
-
results.map((fronter) => [
75
-
fronterGetSocialAppHref(fronter, fronter.rkey),
76
-
fronter,
77
-
]),
126
+
type: "APPLY_FRONTERS",
127
+
results: Object.fromEntries(
128
+
results.flatMap((fronter) =>
129
+
fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]),
130
+
),
78
131
),
79
132
});
80
133
};
81
-
const handleTimeline = async (
82
-
feed: any[],
134
+
const handleNotifications = async (
135
+
items: any,
83
136
sender: globalThis.Browser.runtime.MessageSender,
84
137
) => {
85
-
const handlePost = async (post: any) => {
86
-
const cachedFronter = fronters.get(post.uri);
87
-
if (cachedFronter === null) return;
88
-
const promise = cachedFronter
89
-
? Promise.resolve(cachedFronter)
90
-
: getFronter(post.uri).then(async (fronter) => {
91
-
if (!fronter.ok) {
92
-
fronters.set(post.uri, null);
93
-
return;
94
-
}
95
-
return fronter.value;
96
-
});
97
-
return promise.then((fronter) => {
98
-
if (!fronter) return;
99
-
const parsedUri = cacheFronter(post.uri, fronter);
100
-
return {
101
-
rkey: parsedUri.rkey!,
102
-
...fronter,
103
-
};
104
-
});
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
+
};
105
158
};
106
-
const allPromises = feed.flatMap((item) => {
107
-
const promises = [handlePost(item.post)];
108
-
if (item.reply?.parent) {
109
-
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);
110
200
}
111
-
if (item.reply?.root) {
112
-
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));
113
223
}
114
-
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),
115
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
+
);
116
331
const results = new Map(
117
332
(await Promise.allSettled(allPromises))
118
333
.filter((result) => result.status === "fulfilled")
119
334
.flatMap((result) => result.value ?? [])
120
-
.map((fronter) => [
121
-
fronterGetSocialAppHref(fronter, fronter.rkey),
122
-
fronter,
123
-
]),
335
+
.flatMap((fronter) =>
336
+
fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]),
337
+
),
124
338
);
339
+
if (results.size === 0) return;
125
340
browser.tabs.sendMessage(sender.tab?.id!, {
126
-
type: "TIMELINE_FRONTER",
127
-
results,
341
+
type: "APPLY_FRONTERS",
342
+
results: Object.fromEntries(results),
128
343
});
129
344
// console.log("sent timeline fronters", results);
130
345
};
131
346
const handleThread = async (
132
-
{ data: { body } }: any,
347
+
{
348
+
data: { body, requestUrl, documentUrl },
349
+
}: { data: { body: string; requestUrl: string; documentUrl: string } },
133
350
sender: globalThis.Browser.runtime.MessageSender,
134
351
) => {
352
+
// check if this request was made for fetching replies
353
+
// if anchor is not the same as current document url, that is the case
354
+
// which means the depth of the returned posts are invalid to us
355
+
let isReplyThreadFetch = false;
356
+
const parsedDocumentUri = parseSocialAppPostUrl(documentUrl);
357
+
const anchorUri = new URL(requestUrl).searchParams.get("anchor");
358
+
// console.log(
359
+
// "parsedDocumentUri",
360
+
// parsedDocumentUri,
361
+
// "anchorUri",
362
+
// anchorUri,
363
+
// );
364
+
if (parsedDocumentUri && anchorUri) {
365
+
const parsedAnchorUri = expect(parseResourceUri(anchorUri));
366
+
isReplyThreadFetch = parsedDocumentUri.rkey !== parsedAnchorUri.rkey;
367
+
}
368
+
// console.log("isReplyThreadFetch", isReplyThreadFetch);
135
369
const data: any = JSON.parse(body);
136
370
const promises = (data.thread as any[]).flatMap((item) => {
137
-
const cachedFronter = fronters.get(item.uri);
138
-
if (cachedFronter === null) return [];
139
-
const promise = cachedFronter
140
-
? Promise.resolve(cachedFronter)
141
-
: getFronter(item.uri).then(async (fronter) => {
371
+
return frontersCache.get(item.uri).then(async (cachedFronter) => {
372
+
if (cachedFronter === null) return [];
373
+
const promise = cachedFronter
374
+
? Promise.resolve(cachedFronter)
375
+
: getFronter(item.uri).then(async (fronter) => {
376
+
if (!fronter.ok) {
377
+
await frontersCache.set(item.uri, null);
378
+
return;
379
+
}
380
+
return fronter.value;
381
+
});
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);
398
+
return {
399
+
type: "thread_post",
400
+
rkey: parsedUri.rkey!,
401
+
displayName,
402
+
depth: item.depth,
403
+
...fronter,
404
+
};
405
+
},
406
+
);
407
+
});
408
+
});
409
+
const results = new Map(
410
+
(await Promise.allSettled(promises))
411
+
.filter((result) => result.status === "fulfilled")
412
+
.flatMap((result) => result.value ?? [])
413
+
.flatMap((fronter) =>
414
+
fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]),
415
+
),
416
+
);
417
+
if (results.size === 0) return;
418
+
browser.tabs.sendMessage(sender.tab?.id!, {
419
+
type: "APPLY_FRONTERS",
420
+
results: Object.fromEntries(results),
421
+
});
422
+
// console.log("sent thread fronters", results);
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) => {
142
465
if (!fronter.ok) {
143
-
fronters.set(item.uri, null);
144
-
return;
466
+
frontersCache.set(recordUri, null);
467
+
return null;
145
468
}
146
469
return fronter.value;
147
-
});
148
-
return promise.then(async (fronter) => {
470
+
}));
149
471
if (!fronter) return;
150
-
const parsedUri = cacheFronter(item.uri, fronter);
151
-
if (item.depth === 0) await setTabFronter(item.uri, fronter);
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;
152
477
return {
478
+
type:
479
+
collection === "app.bsky.feed.repost"
480
+
? "post_repost_entry"
481
+
: "post_like_entry",
153
482
rkey: parsedUri.rkey!,
154
-
displayName: item.value.post.author.displayName,
155
-
depth: item.depth,
483
+
displayName,
156
484
...fronter,
157
485
};
158
-
});
159
-
});
486
+
},
487
+
);
488
+
160
489
const results = new Map(
161
-
(await Promise.allSettled(promises))
490
+
(await Promise.allSettled(allPromises))
162
491
.filter((result) => result.status === "fulfilled")
163
492
.flatMap((result) => result.value ?? [])
164
-
.map((fronter) => [
165
-
fronterGetSocialAppHref(fronter, fronter.rkey, fronter.depth),
166
-
fronter,
167
-
]),
493
+
.flatMap((fronter) =>
494
+
fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]),
495
+
),
168
496
);
497
+
if (results.size === 0) return;
169
498
browser.tabs.sendMessage(sender.tab?.id!, {
170
-
type: "THREAD_FRONTER",
171
-
results,
499
+
type: "APPLY_FRONTERS",
500
+
results: Object.fromEntries(results),
172
501
});
173
-
// console.log("sent thread fronters", results);
174
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
+
);
175
529
176
530
browser.runtime.onMessage.addListener(async (message, sender) => {
177
531
if (message.type !== "RESPONSE_CAPTURED") return;
178
-
// console.log("handling response event", message);
532
+
console.log("handling response", message.data);
179
533
switch (message.data.type as string) {
534
+
case "delete":
535
+
await handleDelete(
536
+
JSON.parse(message.data.body),
537
+
message.data.authToken,
538
+
sender,
539
+
);
540
+
break;
180
541
case "write":
181
-
await handleWrite(message, sender);
542
+
await handleWrite(
543
+
JSON.parse(message.data.body).results,
544
+
message.data.authToken,
545
+
sender,
546
+
);
547
+
break;
548
+
case "writeOne": {
549
+
await handleWrite(
550
+
[JSON.parse(message.data.body)],
551
+
message.data.authToken,
552
+
sender,
553
+
);
182
554
break;
555
+
}
183
556
case "posts":
184
-
const posts = JSON.parse(message.data.body) as any[];
185
557
await handleTimeline(
186
-
posts.map((post) => ({ post })),
558
+
(JSON.parse(message.data.body) as any[]).map((post) => ({ post })),
187
559
sender,
188
560
);
189
561
break;
···
193
565
case "thread":
194
566
await handleThread(message, sender);
195
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);
576
+
break;
196
577
}
197
-
browser.tabs.sendMessage(sender.tab?.id!, {
198
-
type: "CACHED_FRONTERS",
199
-
fronters,
200
-
});
201
578
});
202
579
browser.runtime.onMessage.addListener(async (message, sender) => {
203
580
if (message.type !== "TAB_FRONTER") return;
+256
-35
src/entrypoints/content.ts
+256
-35
src/entrypoints/content.ts
···
1
1
import { expect } from "@/lib/result";
2
-
import { Fronter, fronterGetSocialAppHref } from "@/lib/utils";
3
-
import { parseResourceUri, ResourceUri } from "@atcute/lexicons";
2
+
import { FronterView, parseSocialAppPostUrl } from "@/lib/utils";
3
+
import { parseResourceUri } from "@atcute/lexicons";
4
4
5
5
const getAuthHeader = (headers: any): string | null => {
6
6
if (headers instanceof Headers) {
···
22
22
const overriddenFetch = async (
23
23
...args: [input: RequestInfo | URL, init?: RequestInit]
24
24
) => {
25
+
const getRequestBody = async () => {
26
+
if (args[0] instanceof Request) {
27
+
if (args[0].bodyUsed) return null;
28
+
try {
29
+
const clone = args[0].clone();
30
+
return await clone.text();
31
+
} catch {
32
+
return null;
33
+
}
34
+
} else if (args[1]?.body) {
35
+
return typeof args[1].body === "string"
36
+
? args[1].body
37
+
: JSON.stringify(args[1].body);
38
+
}
39
+
return null;
40
+
};
41
+
const requestBody = await getRequestBody();
25
42
const response = await originalFetch.apply(this, args);
26
43
27
44
if (respEventName === null) return response;
···
38
55
}),
39
56
);
40
57
};
41
-
42
-
let detail: any;
43
-
if (response.url.includes("/xrpc/com.atproto.repo.applyWrites")) {
58
+
const getAuthToken = () => {
44
59
let authHeader: string | null = null;
45
60
if (typeof args[0] === "string") {
46
61
if (args[1]?.headers) {
···
49
64
} else if (args[0] instanceof Request) {
50
65
authHeader = getAuthHeader(args[0].headers);
51
66
}
67
+
return authHeader?.split(" ")[1] || null;
68
+
};
69
+
const getRequestUrl = () => {
70
+
let url: string | null = null;
71
+
if (args[0] instanceof Request) {
72
+
url = args[0].url;
73
+
} else {
74
+
url = args[0].toString();
75
+
}
76
+
return decodeURI(url);
77
+
};
52
78
79
+
let detail: any = undefined;
80
+
if (response.url.includes("/xrpc/com.atproto.repo.applyWrites")) {
53
81
detail = {
54
82
type: "write",
55
83
body,
56
-
authToken: authHeader?.split(" ")[1] || null,
84
+
authToken: getAuthToken(),
85
+
};
86
+
} else if (response.url.includes("/xrpc/com.atproto.repo.deleteRecord")) {
87
+
detail = {
88
+
type: "delete",
89
+
body: requestBody,
90
+
authToken: getAuthToken(),
91
+
};
92
+
} else if (response.url.includes("/xrpc/com.atproto.repo.createRecord")) {
93
+
detail = {
94
+
type: "writeOne",
95
+
body,
96
+
authToken: getAuthToken(),
57
97
};
58
98
} else if (
59
99
response.url.includes("/xrpc/app.bsky.feed.getAuthorFeed") ||
···
70
110
detail = {
71
111
type: "thread",
72
112
body,
113
+
requestUrl: getRequestUrl(),
114
+
documentUrl: document.location.href,
73
115
};
74
116
} else if (response.url.includes("/xrpc/app.bsky.feed.getPosts")) {
75
117
detail = {
76
118
type: "posts",
77
119
body,
78
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
+
};
79
138
}
80
-
sendEvent(detail);
139
+
if (detail) {
140
+
sendEvent(detail);
141
+
}
81
142
82
143
return response;
83
144
};
···
96
157
});
97
158
respEventSetup.then((name) => (respEventName = name));
98
159
99
-
const applyFronterName = (el: Element, fronterName: string) => {
100
-
if (el.getAttribute("data-fronter")) return;
101
-
el.textContent += ` [f: ${fronterName}]`;
102
-
el.setAttribute("data-fronter", fronterName);
160
+
const applyFronterName = (
161
+
el: Element,
162
+
fronters: FronterView["members"],
163
+
) => {
164
+
if (el.hasAttribute("data-fronter")) return false;
165
+
const s = fronters.map((f) => f.name).join(", ");
166
+
el.textContent += ` [f: ${s}]`;
167
+
el.setAttribute("data-fronter", s);
168
+
return true;
103
169
};
104
-
const applyFrontersToPage = (fronters: Map<string, any>) => {
170
+
const applyFrontersToPage = (
171
+
fronters: Map<string, FronterView | null>,
172
+
pageChange: boolean,
173
+
) => {
174
+
// console.log("applyFrontersToPage", fronters);
175
+
const match = parseSocialAppPostUrl(document.URL);
176
+
if (pageChange) {
177
+
console.log(
178
+
"page change so clearing all elements with data-fronter attribute",
179
+
);
180
+
for (const el of document.querySelectorAll("[data-fronter]")) {
181
+
const previousFronter = el.getAttribute("data-fronter")!;
182
+
if (previousFronter !== "__set__") {
183
+
// remove fronter text
184
+
el.textContent = el.textContent.replace(
185
+
` [f: ${previousFronter}]`,
186
+
"",
187
+
);
188
+
}
189
+
el.removeAttribute("data-fronter");
190
+
}
191
+
}
192
+
console.log("applyFrontersToPage", match, fronters);
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
+
};
105
320
for (const el of document.getElementsByTagName("a")) {
321
+
if (el.getAttribute("data-fronter")) continue;
106
322
const path = `/${el.href.split("/").slice(3).join("/")}`;
107
-
const fronter = fronters.get(path);
108
-
if (!fronter) continue;
109
-
const isFocusedPost = fronter.depth === 0;
110
-
if (isFocusedPost && el.ariaLabel !== fronter.displayName) continue;
111
-
const displayNameElement = isFocusedPost
112
-
? (el.firstElementChild?.firstElementChild?.firstElementChild
113
-
?.firstElementChild?.firstElementChild ?? null)
114
-
: (el.parentElement?.firstElementChild?.firstElementChild
115
-
?.firstElementChild?.firstElementChild ?? null);
116
-
if (!displayNameElement) continue;
117
-
applyFronterName(displayNameElement, fronter.fronterName);
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
+
}
118
330
}
119
331
};
332
+
let postTabObserver: MutationObserver | null = null;
120
333
window.addEventListener("message", (event) => {
121
-
if (event.data.type !== "CACHED_FRONTERS") return;
122
-
const fronters = event.data.fronters as Map<string, Fronter | null>;
123
-
const updated = new Map(
124
-
fronters.entries().flatMap(([uri, fronter]) => {
125
-
if (!fronter) return [];
126
-
const rkey = expect(parseResourceUri(uri)).rkey!;
127
-
return [[fronterGetSocialAppHref(fronter, rkey), fronter]];
128
-
}),
334
+
if (event.data.type !== "APPLY_CACHED_FRONTERS") return;
335
+
const applyFronters = () => {
336
+
console.log("applying cached fronters", event.data.fronters);
337
+
applyFrontersToPage(new Map(Object.entries(event.data.fronters)), true);
338
+
};
339
+
// check if we are on profile so we can update fronters if the post tab is clicked on
340
+
const postTabElement = document.querySelector(
341
+
'[data-testid="profilePager-Posts"]',
129
342
);
130
-
applyFrontersToPage(updated);
343
+
if (postTabElement) {
344
+
postTabObserver = new MutationObserver(applyFronters);
345
+
postTabObserver.observe(postTabElement, { attributes: true });
346
+
} else if (postTabObserver) {
347
+
postTabObserver.disconnect();
348
+
postTabObserver = null;
349
+
}
350
+
// update fronters on page
351
+
applyFronters();
131
352
});
132
353
window.addEventListener("message", (event) => {
133
-
if (!["TIMELINE_FRONTER", "THREAD_FRONTER"].includes(event.data.type))
134
-
return;
135
-
applyFrontersToPage(event.data.results as Map<string, any>);
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);
136
357
});
137
358
},
138
359
});
+76
-47
src/entrypoints/isolated.content.ts
+76
-47
src/entrypoints/isolated.content.ts
···
1
-
import { Fronter } from "@/lib/utils";
2
-
import { ResourceUri } from "@atcute/lexicons";
1
+
import { decodeStorageKey } from "@/lib/cache";
2
+
import { expect } from "@/lib/result";
3
+
import {
4
+
displayNameCache,
5
+
Fronter,
6
+
fronterGetSocialAppHref,
7
+
fronterGetSocialAppHrefs,
8
+
frontersCache,
9
+
FronterView,
10
+
parseSocialAppPostUrl,
11
+
} from "@/lib/utils";
12
+
import { parseResourceUri, ResourceUri } from "@atcute/lexicons";
3
13
4
14
export default defineContentScript({
5
15
matches: ["<all_urls>"],
6
16
runAt: "document_start",
7
17
world: "ISOLATED",
8
18
main: (ctx) => {
9
-
let fronters = new Map<ResourceUri, Fronter | null>();
10
-
11
-
const checkFronter = (url: string) => {
19
+
const checkFronter = async (url: string) => {
12
20
// match https://*/profile/<actor_identifier>/post/<rkey> regex with named params to extract actor_identifier and rkey
13
-
const match = url.match(
14
-
/https:\/\/[^/]+\/profile\/([^/]+)\/post\/([^/]+)/,
15
-
);
21
+
const match = parseSocialAppPostUrl(url);
16
22
if (!match) return false;
17
-
const [website, actorIdentifier, rkey] = match;
18
23
const recordUri =
19
-
`at://${actorIdentifier}/app.bsky.feed.post/${rkey}` as ResourceUri;
20
-
const fronter = fronters.get(recordUri);
24
+
`at://${match.actorIdentifier}/app.bsky.feed.post/${match.rkey}` as ResourceUri;
25
+
const fronter = await frontersCache.get(recordUri);
21
26
if (!fronter) return false;
22
27
browser.runtime.sendMessage({
23
28
type: "TAB_FRONTER",
···
30
35
const respEventName = Math.random().toString(36).slice(2);
31
36
window.addEventListener(`${respEventName}-isolated`, async (event) => {
32
37
const data = (event as any).detail;
33
-
// console.log("passing response event to bg", data);
34
-
await browser.runtime
35
-
.sendMessage({
36
-
type: "RESPONSE_CAPTURED",
37
-
data,
38
-
})
39
-
.catch(() => {
40
-
console.log("background script not ready");
41
-
});
38
+
// console.log("passing response event to bg", event);
39
+
await browser.runtime.sendMessage({
40
+
type: "RESPONSE_CAPTURED",
41
+
data,
42
+
});
42
43
});
43
-
const messageTypes = [
44
-
"TAB_FRONTER",
45
-
"THREAD_FRONTER",
46
-
"TIMELINE_FRONTER",
47
-
"CACHED_FRONTERS",
48
-
];
44
+
const bgMessageTypes = ["APPLY_FRONTERS"];
49
45
browser.runtime.onMessage.addListener((message) => {
50
-
if (!messageTypes.includes(message.type)) return;
51
-
if (message.type === "CACHED_FRONTERS") {
52
-
fronters = message.fronters;
53
-
}
46
+
if (!bgMessageTypes.includes(message.type)) return;
54
47
window.postMessage(message);
55
48
});
56
-
let postTabObserver: MutationObserver | null = null;
57
-
ctx.addEventListener(window, "wxt:locationchange", (event) => {
58
-
window.postMessage({ type: "CACHED_FRONTERS", fronters });
59
-
// check if we are on profile so we can update fronters if the post tab is clicked on
60
-
const postTabElement = document.querySelector(
61
-
'[data-testid="profilePager-Posts"]',
62
-
);
63
-
if (postTabElement) {
64
-
postTabObserver = new MutationObserver(() => {
65
-
window.postMessage({ type: "CACHED_FRONTERS", fronters });
66
-
});
67
-
postTabObserver.observe(postTabElement, { attributes: true });
68
-
} else if (postTabObserver) {
69
-
postTabObserver.disconnect();
70
-
postTabObserver = null;
49
+
const updateOnUrlChange = async () => {
50
+
const fronters = await frontersCache.getAll();
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
+
}
72
+
// add entry for current page
73
+
const match = parseSocialAppPostUrl(document.location.href);
74
+
if (match && !updated.has(`/profile/${match.actorIdentifier}`)) {
75
+
const maybeFronter = updated.get(
76
+
`/profile/${match.actorIdentifier}/post/${match.rkey}`,
77
+
);
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
+
}
71
94
}
95
+
window.postMessage({
96
+
type: "APPLY_CACHED_FRONTERS",
97
+
fronters: Object.fromEntries(updated),
98
+
});
72
99
// check for tab fronter for the current "post"
73
-
checkFronter(event.newUrl.toString());
74
-
});
100
+
await checkFronter(document.location.href);
101
+
};
102
+
window.addEventListener("popstate", updateOnUrlChange);
103
+
ctx.addEventListener(window, "wxt:locationchange", updateOnUrlChange);
75
104
76
105
// setup response "channel"
77
106
document.dispatchEvent(
+279
-130
src/entrypoints/popup/App.svelte
+279
-130
src/entrypoints/popup/App.svelte
···
1
1
<script lang="ts">
2
2
import { expect } from "@/lib/result";
3
-
import { getFronter } from "@/lib/utils";
3
+
import { getFronter, getMemberPublicUri } from "@/lib/utils";
4
4
import { isResourceUri } from "@atcute/lexicons";
5
-
import type {
6
-
AtprotoDid,
7
-
Handle,
8
-
ResourceUri,
9
-
} from "@atcute/lexicons/syntax";
5
+
import type { ResourceUri } from "@atcute/lexicons/syntax";
6
+
import FronterList from "@/components/FronterList.svelte";
10
7
11
8
let recordAtUri = $state("");
12
-
let queryResult = $state("");
9
+
let queryResult = $state<{
10
+
handle: string;
11
+
fronters: { name: string; uri?: string }[];
12
+
} | null>(null);
13
+
let queryError = $state("");
13
14
let isQuerying = $state(false);
14
-
let fronterName = $state("");
15
+
let fronters = $state<string[]>([]);
16
+
let pkSystemId = $state<string>("");
17
+
let spToken = $state("");
18
+
let isFromCurrentTab = $state(false);
15
19
16
-
const makeOutput = (fronterName: string, handle: Handle | null) => {
17
-
return `HANDLE: ${handle ?? "handle.invalid"}\nFRONTER: ${fronterName}`;
20
+
const makeOutput = (record: any) => {
21
+
const fronters = record.members.map((f: any) => ({
22
+
name: f.name,
23
+
uri: f.uri ? getMemberPublicUri(f.uri) : undefined,
24
+
}));
25
+
return {
26
+
handle: record.handle ?? `handle.invalid (${record.did})`,
27
+
fronters,
28
+
};
18
29
};
19
30
20
31
const queryRecord = async (recordUri: ResourceUri) => {
21
32
if (!recordAtUri.trim()) return;
22
33
23
34
isQuerying = true;
24
-
queryResult = "";
35
+
queryResult = null;
25
36
26
37
try {
27
38
if (!isResourceUri(recordUri)) throw "INVALID_RESOURCE_URI";
28
39
const result = expect(await getFronter(recordUri));
29
-
queryResult =
30
-
makeOutput(result.fronterName, result.handle) ||
31
-
"NO_FRONTER_FOUND";
40
+
queryResult = makeOutput(result);
32
41
} catch (error) {
33
-
queryResult = `ERROR: ${error}`;
42
+
queryResult = null;
43
+
queryError = `ERROR: ${error}`;
34
44
} finally {
35
45
isQuerying = false;
36
46
}
37
47
};
38
48
39
-
const updateFronter = (event: any) => {
40
-
fronterName = (event.target as HTMLInputElement).value;
41
-
storage.setItem("sync:fronter", fronterName);
49
+
const updateFronters = (newFronters: string[]) => {
50
+
fronters = newFronters;
51
+
storage.setItem("sync:fronters", newFronters);
52
+
};
53
+
54
+
const updatePkSystem = (event: any) => {
55
+
pkSystemId = (event.target as HTMLInputElement).value;
56
+
storage.setItem("sync:pk-system", pkSystemId);
57
+
};
58
+
59
+
const updateSpToken = (event: any) => {
60
+
spToken = (event.target as HTMLInputElement).value;
61
+
storage.setItem("sync:sp_token", spToken);
42
62
};
43
63
44
64
const handleKeyPress = (event: KeyboardEvent) => {
···
48
68
};
49
69
50
70
const clearResult = () => {
51
-
queryResult = "";
71
+
queryResult = null;
72
+
queryError = "";
52
73
recordAtUri = "";
74
+
isFromCurrentTab = false;
53
75
};
54
76
55
77
onMount(async () => {
56
-
const fronter = await storage.getItem<string>("sync:fronter");
57
-
if (fronter) {
58
-
fronterName = fronter;
78
+
const frontersArray = await storage.getItem<string[]>("sync:fronters");
79
+
if (frontersArray && Array.isArray(frontersArray)) {
80
+
fronters = frontersArray;
81
+
}
82
+
83
+
const pkSystem = await storage.getItem<string>("sync:pk-system");
84
+
if (pkSystem) {
85
+
pkSystemId = pkSystem;
86
+
}
87
+
88
+
const token = await storage.getItem<string>("sync:sp_token");
89
+
if (token) {
90
+
spToken = token;
59
91
}
60
92
61
93
const tabs = await browser.tabs.query({
62
94
active: true,
63
95
currentWindow: true,
64
96
});
65
-
const tabFronter = await storage.getItem<{
66
-
fronterName: string;
67
-
recordUri: ResourceUri;
68
-
handle: Handle | null;
69
-
did: AtprotoDid;
70
-
}>(`local:tab-${tabs[0].id!}-fronter`);
97
+
const tabFronter = await storage.getItem<any>(
98
+
`local:tab-${tabs[0].id!}-fronter`,
99
+
);
71
100
if (tabFronter) {
72
-
queryResult = makeOutput(tabFronter.fronterName, tabFronter.handle);
101
+
queryResult = makeOutput(tabFronter);
73
102
recordAtUri = tabFronter.recordUri;
103
+
isFromCurrentTab = true;
74
104
}
75
105
});
76
106
</script>
77
107
78
108
<main>
79
109
<div class="container">
80
-
<header class="header">
81
-
<div class="title">AT_FRONTER</div>
82
-
</header>
83
-
84
110
<div class="content">
85
111
<section class="query-panel">
86
112
<div class="panel-header">
···
104
130
class="exec-button"
105
131
disabled={isQuerying || !recordAtUri.trim()}
106
132
>
107
-
<span class="button-text"
108
-
>{isQuerying ? "EXECUTING" : "EXEC"}</span
109
-
>
133
+
<span class="button-text">EXEC</span>
110
134
<div class="button-accent"></div>
111
135
</button>
112
136
</div>
···
114
138
115
139
<div class="output-container">
116
140
<div class="output-header">
117
-
<span>OUTPUT</span>
141
+
<div class="output-header-left">
142
+
<span>OUTPUT</span>
143
+
{#if isFromCurrentTab}
144
+
<div class="tab-indicator">
145
+
<span class="tab-indicator-text"
146
+
>FROM_CURRENT_TAB</span
147
+
>
148
+
<div class="tab-indicator-accent"></div>
149
+
</div>
150
+
{/if}
151
+
</div>
118
152
<div class="clear-button-container">
119
-
{#if queryResult && !isQuerying}
153
+
{#if (queryResult || queryError) && !isQuerying}
120
154
<button
121
155
class="clear-button"
122
156
onclick={clearResult}
···
135
169
>
136
170
<div class="loading-bar"></div>
137
171
</div>
172
+
{:else if queryError}
173
+
<div class="result-text error">
174
+
{queryError}
175
+
</div>
138
176
{:else if queryResult}
139
-
<div
140
-
class="result-text"
141
-
class:error={queryResult.startsWith(
142
-
"ERROR:",
143
-
)}
144
-
>
145
-
{queryResult}
177
+
<div class="result-text">
178
+
<div>HANDLE: {queryResult.handle}</div>
179
+
<div>
180
+
FRONTER(S):
181
+
{#each queryResult.fronters as fronter, i}
182
+
{#if fronter.uri}
183
+
<a
184
+
href={fronter.uri}
185
+
class="fronter-link"
186
+
>{fronter.name}</a
187
+
>
188
+
{:else}
189
+
{fronter.name +
190
+
(i <
191
+
queryResult.fronters
192
+
.length -
193
+
1
194
+
? ", "
195
+
: "")}
196
+
{/if}
197
+
{/each}
198
+
</div>
146
199
</div>
147
200
{:else}
148
201
<div class="placeholder-text">
···
159
212
<span class="panel-title">CONFIGURATION</span>
160
213
<div class="panel-accent"></div>
161
214
</div>
162
-
163
-
<div class="config-row">
164
-
<span class="config-label">FRONTER_NAME</span>
165
-
<div class="config-input-wrapper">
215
+
<div class="config-card">
216
+
<div class="config-row">
217
+
<span class="config-label">SP TOKEN</span>
166
218
<input
167
-
type="text"
168
-
placeholder="enter_identifier"
169
-
oninput={updateFronter}
170
-
bind:value={fronterName}
219
+
type="password"
220
+
placeholder="enter_simply_plural_token"
221
+
oninput={updateSpToken}
222
+
bind:value={spToken}
171
223
class="config-input"
172
-
class:has-value={fronterName}
224
+
class:has-value={spToken}
173
225
/>
174
226
</div>
227
+
<div class="config-note">
228
+
<span class="note-text">
229
+
when set, pulls fronters from Simply Plural (token
230
+
only requires read permissions)
231
+
</span>
232
+
</div>
175
233
</div>
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>
253
+
<FronterList
254
+
bind:fronters
255
+
onUpdate={updateFronters}
256
+
label="FRONTERS"
257
+
placeholder="enter_fronter_names"
258
+
note="just names, overrides SP & PK fronters"
259
+
/>
176
260
</section>
177
261
</div>
178
262
179
263
<footer class="footer">
180
-
<span
181
-
>SOURCE ON <a
182
-
href="https://tangled.sh/did:plc:dfl62fgb7wtjj3fcbb72naae/at-fronter"
183
-
>TANGLED</a
184
-
></span
264
+
<span class="title">AT_FRONTER</span>
265
+
<span class="footer-separator">โข</span>
266
+
<span class="footer-source">SOURCE ON </span>
267
+
<a
268
+
href="https://tangled.sh/did:plc:dfl62fgb7wtjj3fcbb72naae/at-fronter"
269
+
class="footer-link">TANGLED</a
185
270
>
186
271
</footer>
187
272
</div>
···
209
294
background: linear-gradient(180deg, #000000 0%, #0a0a0a 100%);
210
295
}
211
296
212
-
.header {
213
-
display: flex;
214
-
align-items: center;
215
-
justify-content: center;
216
-
padding: 20px 20px;
217
-
background: #000000;
218
-
border-bottom: 1px solid #333333;
219
-
position: relative;
220
-
}
221
-
222
-
.header::after {
223
-
content: "";
224
-
position: absolute;
225
-
bottom: 0;
226
-
left: 0;
227
-
width: 100%;
228
-
height: 1px;
229
-
background: linear-gradient(90deg, transparent, #555555, transparent);
230
-
}
231
-
232
297
.title {
233
-
font-size: 18px;
234
-
font-weight: 800;
235
-
letter-spacing: 3px;
236
-
color: #ffffff;
298
+
font-size: 10px;
299
+
font-weight: 700;
300
+
letter-spacing: 2px;
301
+
color: #999999;
302
+
line-height: 1;
303
+
vertical-align: baseline;
237
304
}
238
305
239
306
.content {
240
307
flex: 1;
241
308
display: flex;
242
309
flex-direction: column;
243
-
gap: 24px;
244
-
padding: 24px 20px;
310
+
gap: 20px;
311
+
padding: 18px 16px;
245
312
overflow-y: auto;
246
313
}
247
314
···
254
321
.config-panel {
255
322
display: flex;
256
323
flex-direction: column;
257
-
gap: 16px;
324
+
gap: 12px;
325
+
}
326
+
327
+
.config-card {
328
+
background: #0d0d0d;
329
+
border: 1px solid #2a2a2a;
330
+
border-left: 3px solid #444444;
331
+
padding: 10px;
332
+
display: flex;
333
+
flex-direction: column;
334
+
gap: 6px;
335
+
transition: border-left-color 0.2s ease;
336
+
}
337
+
338
+
.config-card:hover {
339
+
border-left-color: #555555;
340
+
}
341
+
342
+
.config-note {
343
+
padding: 0;
344
+
background: transparent;
345
+
border: none;
346
+
margin: 0;
347
+
}
348
+
349
+
.note-text {
350
+
font-size: 11px;
351
+
color: #bbbbbb;
352
+
line-height: 1.3;
353
+
font-weight: 500;
354
+
letter-spacing: 0.5px;
258
355
}
259
356
260
357
.panel-header {
···
294
391
295
392
.record-input {
296
393
flex: 1;
297
-
padding: 16px 18px;
394
+
padding: 12px 14px;
298
395
background: transparent;
299
396
border: none;
300
397
outline: none;
···
315
412
316
413
.exec-button {
317
414
position: relative;
318
-
padding: 16px 28px;
415
+
padding: 8px 10px;
319
416
background: #2a2a2a;
320
417
border: none;
321
418
border-left: 1px solid #444444;
···
380
477
min-height: 32px;
381
478
}
382
479
480
+
.output-header-left {
481
+
display: flex;
482
+
align-items: center;
483
+
gap: 12px;
484
+
}
485
+
486
+
.tab-indicator {
487
+
display: flex;
488
+
align-items: center;
489
+
gap: 6px;
490
+
padding: 4px 8px;
491
+
background: #1a1a1a;
492
+
border: 1px solid #333333;
493
+
position: relative;
494
+
overflow: hidden;
495
+
}
496
+
497
+
.tab-indicator-text {
498
+
font-size: 9px;
499
+
color: #00ff41;
500
+
font-weight: 700;
501
+
letter-spacing: 1px;
502
+
position: relative;
503
+
z-index: 1;
504
+
}
505
+
506
+
.tab-indicator-accent {
507
+
position: absolute;
508
+
left: 0;
509
+
bottom: 0;
510
+
width: 100%;
511
+
height: 1px;
512
+
background: #00ff41;
513
+
animation: pulse 2s ease-in-out infinite;
514
+
}
515
+
383
516
.clear-button-container {
384
517
width: 60px;
385
518
display: flex;
···
419
552
}
420
553
421
554
.output-content {
422
-
padding: 18px;
555
+
padding: 14px;
423
556
height: 100%;
424
557
display: flex;
425
558
align-items: center;
···
469
602
color: #ff4444;
470
603
}
471
604
605
+
.fronter-link {
606
+
color: #00ff41;
607
+
text-decoration: none;
608
+
font-weight: 700;
609
+
transition: all 0.2s ease;
610
+
position: relative;
611
+
border-bottom: 1px solid transparent;
612
+
}
613
+
614
+
.fronter-link:hover {
615
+
color: #33ff66;
616
+
border-bottom-color: #00ff41;
617
+
}
618
+
619
+
.fronter-link:active {
620
+
color: #ffffff;
621
+
}
622
+
472
623
.placeholder-text {
473
624
color: #888888;
474
625
font-size: 12px;
···
479
630
480
631
.config-row {
481
632
display: flex;
482
-
flex-direction: column;
483
-
gap: 8px;
633
+
align-items: center;
634
+
gap: 12px;
635
+
margin-bottom: 0;
484
636
}
485
637
486
638
.config-label {
487
-
font-size: 11px;
488
-
color: #aaaaaa;
489
-
letter-spacing: 1.5px;
639
+
font-size: 12px;
640
+
color: #cccccc;
641
+
letter-spacing: 1px;
490
642
font-weight: 700;
491
-
}
492
-
493
-
.config-input-wrapper {
494
-
display: flex;
495
-
align-items: center;
643
+
white-space: nowrap;
644
+
min-width: 90px;
496
645
}
497
646
498
647
.config-input {
499
648
flex: 1;
500
-
padding: 14px 18px;
649
+
padding: 10px 12px;
501
650
background: #181818;
502
651
border: 1px solid #333333;
503
652
color: #ffffff;
504
653
font-family: inherit;
505
-
font-size: 13px;
654
+
font-size: 12px;
506
655
font-weight: 500;
507
656
transition: all 0.2s ease;
508
657
position: relative;
···
524
673
525
674
.footer {
526
675
display: flex;
527
-
align-items: center;
676
+
align-items: baseline;
528
677
justify-content: center;
529
-
padding: 16px 20px;
678
+
gap: 8px;
679
+
padding: 12px 16px;
530
680
background: #000000;
531
-
border-top: 1px solid #333333;
532
-
font-size: 10px;
533
-
color: #888888;
534
-
font-weight: 600;
535
-
letter-spacing: 1px;
681
+
border-top: 1px solid #222222;
682
+
font-size: 9px;
683
+
color: #666666;
684
+
font-weight: 500;
685
+
letter-spacing: 0.5px;
686
+
line-height: 1;
536
687
position: relative;
537
688
}
538
689
···
543
694
left: 0;
544
695
width: 100%;
545
696
height: 1px;
546
-
background: linear-gradient(90deg, transparent, #555555, transparent);
697
+
background: linear-gradient(90deg, transparent, #333333, transparent);
547
698
}
548
699
549
-
.footer a {
550
-
color: #aaaaaa;
700
+
.footer-separator {
701
+
color: #444444;
702
+
font-weight: 400;
703
+
line-height: 1;
704
+
vertical-align: baseline;
705
+
}
706
+
707
+
.footer-source {
708
+
color: #777777;
709
+
line-height: 1;
710
+
vertical-align: baseline;
711
+
}
712
+
713
+
.footer-link {
714
+
color: #999999;
551
715
text-decoration: none;
552
716
font-weight: 700;
553
717
transition: color 0.2s ease;
718
+
line-height: 1;
719
+
vertical-align: baseline;
554
720
}
555
721
556
-
.footer a:hover {
557
-
color: #ffffff;
722
+
.footer-link:hover {
723
+
color: #cccccc;
558
724
}
559
725
560
726
/* Animations */
···
575
741
100% {
576
742
left: 100%;
577
743
}
578
-
}
579
-
580
-
/* Scrollbar */
581
-
.content::-webkit-scrollbar {
582
-
width: 2px;
583
-
}
584
-
585
-
.content::-webkit-scrollbar-track {
586
-
background: #000000;
587
-
}
588
-
589
-
.content::-webkit-scrollbar-thumb {
590
-
background: #333333;
591
-
}
592
-
593
-
.content::-webkit-scrollbar-thumb:hover {
594
-
background: #555555;
595
744
}
596
745
</style>
+68
-3
src/entrypoints/popup/app.css
+68
-3
src/entrypoints/popup/app.css
···
87
87
color: #ffffff;
88
88
}
89
89
90
+
/* Cross-browser scrollbar styling */
91
+
92
+
/* Standard scrollbar properties (Firefox, Chrome 121+, Edge 121+) */
93
+
* {
94
+
scrollbar-width: thin;
95
+
scrollbar-color: #333333 #0a0a0a;
96
+
}
97
+
98
+
/* Content areas get even thinner scrollbars */
99
+
.content,
100
+
.output-content,
101
+
textarea,
102
+
input {
103
+
scrollbar-width: thin;
104
+
scrollbar-color: #2a2a2a #000000;
105
+
}
106
+
107
+
/* Webkit scrollbar styling for older browsers and better customization */
90
108
/* Global scrollbar styling */
91
109
::-webkit-scrollbar {
92
-
width: 2px;
93
-
height: 2px;
110
+
width: 8px;
111
+
height: 8px;
94
112
}
95
113
96
114
::-webkit-scrollbar-track {
97
-
background: #000000;
115
+
background: #0a0a0a;
116
+
border-radius: 0;
98
117
}
99
118
100
119
::-webkit-scrollbar-thumb {
101
120
background: #333333;
121
+
border-radius: 0;
102
122
border: none;
123
+
transition: background 0.2s ease;
103
124
}
104
125
105
126
::-webkit-scrollbar-thumb:hover {
106
127
background: #555555;
107
128
}
108
129
130
+
::-webkit-scrollbar-thumb:active {
131
+
background: #666666;
132
+
}
133
+
109
134
::-webkit-scrollbar-corner {
135
+
background: #0a0a0a;
136
+
}
137
+
138
+
/* Scrollbar for specific containers */
139
+
.content::-webkit-scrollbar,
140
+
.output-content::-webkit-scrollbar,
141
+
textarea::-webkit-scrollbar,
142
+
input::-webkit-scrollbar {
143
+
width: 6px;
144
+
height: 6px;
145
+
}
146
+
147
+
.content::-webkit-scrollbar-track,
148
+
.output-content::-webkit-scrollbar-track,
149
+
textarea::-webkit-scrollbar-track,
150
+
input::-webkit-scrollbar-track {
110
151
background: #000000;
152
+
}
153
+
154
+
.content::-webkit-scrollbar-thumb,
155
+
.output-content::-webkit-scrollbar-thumb,
156
+
textarea::-webkit-scrollbar-thumb,
157
+
input::-webkit-scrollbar-thumb {
158
+
background: #2a2a2a;
159
+
border-radius: 0;
160
+
border: none;
161
+
transition: background 0.15s ease;
162
+
}
163
+
164
+
.content::-webkit-scrollbar-thumb:hover,
165
+
.output-content::-webkit-scrollbar-thumb:hover,
166
+
textarea::-webkit-scrollbar-thumb:hover,
167
+
input::-webkit-scrollbar-thumb:hover {
168
+
background: #444444;
169
+
}
170
+
171
+
.content::-webkit-scrollbar-thumb:active,
172
+
.output-content::-webkit-scrollbar-thumb:active,
173
+
textarea::-webkit-scrollbar-thumb:active,
174
+
input::-webkit-scrollbar-thumb:active {
175
+
background: #555555;
111
176
}
112
177
113
178
/* Animations */
+110
src/lib/cache.ts
+110
src/lib/cache.ts
···
1
+
interface CachedItem<T> {
2
+
data: T;
3
+
timestamp: number;
4
+
}
5
+
6
+
export const decodeStorageKey = (storageKey: string) =>
7
+
atob(storageKey.split("_")[1]);
8
+
9
+
export class PersistentCache<T = any> {
10
+
private readonly keyPrefix: string;
11
+
private readonly expiryHours: number;
12
+
private readonly keysSetKey: `local:${string}`;
13
+
14
+
constructor(keyPrefix: string, expiryHours: number) {
15
+
this.keyPrefix = keyPrefix;
16
+
this.expiryHours = expiryHours;
17
+
this.keysSetKey = `local:${keyPrefix}_keys`;
18
+
}
19
+
20
+
private getCacheKey(key: string): `local:${string}` {
21
+
const safeKey = btoa(key);
22
+
return `local:${this.keyPrefix}_${safeKey}`;
23
+
}
24
+
25
+
private async getStoredKeys(): Promise<Set<string>> {
26
+
const keys = await storage.getItem<string[]>(this.keysSetKey);
27
+
return new Set(keys || []);
28
+
}
29
+
30
+
private async addKeyToSet(key: string): Promise<void> {
31
+
const keys = await this.getStoredKeys();
32
+
keys.add(key);
33
+
await storage.setItem(this.keysSetKey, Array.from(keys));
34
+
}
35
+
36
+
private async removeKeyFromSet(...key: string[]): Promise<void> {
37
+
const keys = await this.getStoredKeys();
38
+
for (const k of key) keys.delete(k);
39
+
await storage.setItem(this.keysSetKey, Array.from(keys));
40
+
}
41
+
42
+
async get(key: string): Promise<T | undefined> {
43
+
const cacheKey = this.getCacheKey(key);
44
+
const cached = await storage.getItem<CachedItem<T>>(cacheKey);
45
+
46
+
if (!cached) return undefined;
47
+
48
+
const now = Date.now();
49
+
const expiryTime = cached.timestamp + this.expiryHours * 60 * 60 * 1000;
50
+
51
+
if (this.expiryHours > 0 && now > expiryTime) {
52
+
await storage.removeItem(cacheKey);
53
+
return undefined;
54
+
}
55
+
56
+
return cached.data;
57
+
}
58
+
59
+
async set(key: string, value: T): Promise<void> {
60
+
const cacheKey = this.getCacheKey(key);
61
+
const cachedItem: CachedItem<T> = {
62
+
data: value,
63
+
timestamp: Date.now(),
64
+
};
65
+
await storage.setItem(cacheKey, cachedItem);
66
+
await this.addKeyToSet(key);
67
+
}
68
+
69
+
async remove(key: string): Promise<void> {
70
+
const cacheKey = this.getCacheKey(key);
71
+
await storage.removeItem(cacheKey);
72
+
await this.removeKeyFromSet(key);
73
+
}
74
+
75
+
async getAll(): Promise<Map<string, T>> {
76
+
const keys = await this.getStoredKeys();
77
+
78
+
if (keys.size === 0) {
79
+
return new Map();
80
+
}
81
+
82
+
const cacheKeys = Array.from(keys).map((key) => this.getCacheKey(key));
83
+
const items = await storage.getItems(cacheKeys);
84
+
85
+
const result = new Map<string, T>();
86
+
const now = Date.now();
87
+
const keysToRemove: string[] = [];
88
+
89
+
for (const { key, value } of items) {
90
+
const expiryTime = value.timestamp + this.expiryHours * 60 * 60 * 1000;
91
+
92
+
if (this.expiryHours > 0 && now > expiryTime) {
93
+
keysToRemove.push(key);
94
+
} else {
95
+
result.set(key, value.data);
96
+
}
97
+
}
98
+
99
+
// Clean up expired or missing items
100
+
if (keysToRemove.length > 0) {
101
+
const expiredCacheKeys = keysToRemove.map((key) => this.getCacheKey(key));
102
+
await Promise.all([
103
+
storage.removeItems(expiredCacheKeys),
104
+
this.removeKeyFromSet(...keysToRemove),
105
+
]);
106
+
}
107
+
108
+
return result;
109
+
}
110
+
}
+2
-2
src/lib/result.ts
+2
-2
src/lib/result.ts
+314
-22
src/lib/utils.ts
+314
-22
src/lib/utils.ts
···
7
7
import {
8
8
ActorIdentifier,
9
9
Did,
10
+
GenericUri,
10
11
Handle,
11
12
isHandle,
13
+
Nsid,
12
14
RecordKey,
13
15
type AtprotoDid,
14
16
type ResourceUri,
···
24
26
WellKnownHandleResolver,
25
27
} from "@atcute/identity-resolver";
26
28
import { getAtprotoHandle, getPdsEndpoint } from "@atcute/identity";
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
+
};
27
37
28
38
export type Fronter = {
29
-
fronterName: string;
39
+
members: {
40
+
uri?: MemberUri;
41
+
name: string;
42
+
}[];
30
43
handle: Handle | null;
31
44
did: AtprotoDid;
45
+
subject?: Subject;
46
+
replyTo?: ResourceUri;
32
47
};
33
48
34
-
export const fronterGetSocialAppHref = (
35
-
fronter: Fronter,
36
-
rkey: RecordKey,
37
-
depth?: number,
38
-
) => {
39
-
return depth === 0
40
-
? `/profile/${fronter.handle ?? fronter.did}`
41
-
: `/profile/${fronter.handle ?? fronter.did}/post/${rkey}`;
42
-
};
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"];
43
81
44
-
const fronterSchema = v.record(
82
+
export const fronterSchema = v.record(
45
83
v.string(),
46
84
v.object({
47
85
$type: v.literal("systems.gaze.atfronter.fronter"),
48
-
name: v.string(),
49
86
subject: v.resourceUriString(),
87
+
members: v.array(
88
+
v.object({
89
+
name: v.string(),
90
+
uri: v.optional(v.genericUriString()), // identifier(s) for pk or sp or etc. (maybe member record in the future?)
91
+
}),
92
+
),
50
93
}),
51
94
);
95
+
export type FronterSchema = InferOutput<typeof fronterSchema>;
52
96
53
-
const handleResolver = new CompositeHandleResolver({
97
+
export type MemberUri =
98
+
| { type: "at"; recordUri: ResourceUri }
99
+
| { type: "pk"; systemId: string; memberId: string }
100
+
| { type: "sp"; systemId: string; memberId: string };
101
+
102
+
export const parseMemberId = (memberId: GenericUri): MemberUri => {
103
+
const uri = new URL(memberId);
104
+
switch (uri.protocol) {
105
+
case "pk:": {
106
+
const split = uri.pathname.split("/").slice(1);
107
+
return { type: "pk", systemId: split[0], memberId: split[1] };
108
+
}
109
+
case "sp:": {
110
+
const split = uri.pathname.split("/").slice(1);
111
+
return { type: "sp", systemId: split[0], memberId: split[1] };
112
+
}
113
+
case "at:": {
114
+
return { type: "at", recordUri: memberId as ResourceUri };
115
+
}
116
+
default: {
117
+
throw new Error(`Invalid member ID: ${memberId}`);
118
+
}
119
+
}
120
+
};
121
+
export const memberUriString = (memberUri: MemberUri): GenericUri => {
122
+
switch (memberUri.type) {
123
+
case "pk": {
124
+
return `pk://api.pluralkit.me/${memberUri.memberId}`;
125
+
}
126
+
case "sp": {
127
+
return `sp://api.apparyllis.com/${memberUri.systemId}/${memberUri.memberId}`;
128
+
}
129
+
case "at": {
130
+
return memberUri.recordUri;
131
+
}
132
+
}
133
+
};
134
+
export const getMemberPublicUri = (memberUri: MemberUri) => {
135
+
switch (memberUri.type) {
136
+
case "pk": {
137
+
return `https://dash.pluralkit.me/profile/m/${memberUri.memberId}`;
138
+
}
139
+
case "sp": {
140
+
return null;
141
+
}
142
+
case "at": {
143
+
return `https://pdsls.dev/${memberUri.recordUri}`;
144
+
}
145
+
}
146
+
};
147
+
148
+
// Member cache instance
149
+
const memberCache = new PersistentCache("member_cache", 24);
150
+
151
+
export const fetchMember = async (
152
+
memberUri: MemberUri,
153
+
): Promise<string | undefined> => {
154
+
const s = memberUriString(memberUri);
155
+
const cached = await memberCache.get(s);
156
+
switch (memberUri.type) {
157
+
case "sp": {
158
+
if (cached) return cached.content.name;
159
+
const token = await storage.getItem<string>("sync:sp_token");
160
+
if (!token) return;
161
+
const resp = await fetch(
162
+
`https://api.apparyllis.com/v1/member/${memberUri.systemId}/${memberUri.memberId}`,
163
+
{
164
+
headers: {
165
+
authorization: token,
166
+
},
167
+
},
168
+
);
169
+
if (!resp.ok) return;
170
+
const member = await resp.json();
171
+
await memberCache.set(s, member);
172
+
return member.content.name;
173
+
}
174
+
case "pk": {
175
+
if (cached) return cached.name;
176
+
const resp = await fetch(
177
+
`https://api.pluralkit.me/v2/members/${memberUri.memberId}`,
178
+
);
179
+
if (!resp.ok) return;
180
+
const member = await resp.json();
181
+
await memberCache.set(s, member);
182
+
return member.name;
183
+
}
184
+
}
185
+
};
186
+
187
+
export const getFronterNames = async (
188
+
members: { name?: string; uri?: MemberUri }[],
189
+
) => {
190
+
const promises = await Promise.allSettled(
191
+
members.map(async (m): Promise<Fronter["members"][0] | 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;
196
+
}),
197
+
);
198
+
return promises
199
+
.filter((p) => p.status === "fulfilled")
200
+
.flatMap((p) => p.value ?? []);
201
+
};
202
+
203
+
export const handleResolver = new CompositeHandleResolver({
54
204
strategy: "race",
55
205
methods: {
56
206
dns: new DohJsonHandleResolver({
···
59
209
http: new WellKnownHandleResolver(),
60
210
},
61
211
});
62
-
const docResolver = new CompositeDidDocumentResolver({
212
+
export const docResolver = new CompositeDidDocumentResolver({
63
213
methods: {
64
214
plc: new PlcDidDocumentResolver(),
65
215
web: new WebDidDocumentResolver(),
···
87
237
return new AtpClient({ handler });
88
238
};
89
239
240
+
export const frontersCache = new PersistentCache<Fronter | null>(
241
+
"cachedFronters",
242
+
24,
243
+
);
244
+
90
245
export const getFronter = async <Uri extends ResourceUri>(
91
246
recordUri: Uri,
92
247
): Promise<Result<Fronter, string>> => {
···
114
269
const maybeTyped = safeParse(fronterSchema, maybeRecord.data.value);
115
270
if (!maybeTyped.ok) return err(maybeTyped.message);
116
271
272
+
let members: Fronter["members"];
273
+
try {
274
+
members = maybeTyped.value.members.map((m) => ({
275
+
name: m.name,
276
+
uri: m.uri ? parseMemberId(m.uri) : undefined,
277
+
}));
278
+
} catch (error) {
279
+
return err(`error fetching fronter names: ${error}`);
280
+
}
281
+
117
282
return ok({
118
-
fronterName: maybeTyped.value.name,
283
+
members,
119
284
handle,
120
285
did,
121
286
});
122
287
};
123
288
124
-
export const putFronter = async <Uri extends ResourceUri>(
125
-
recordUri: Uri,
126
-
name: string,
289
+
export const putFronter = async (
290
+
subject: FronterSchema["subject"],
291
+
members: { name?: string; uri?: MemberUri }[],
127
292
authToken: string,
128
293
): Promise<Result<Fronter, string>> => {
129
-
const parsedRecordUri = parseResourceUri(recordUri);
294
+
const parsedRecordUri = parseResourceUri(subject);
130
295
if (!parsedRecordUri.ok) return err(parsedRecordUri.error);
131
296
const { repo, collection, rkey } = parsedRecordUri.value;
132
297
···
135
300
136
301
// make client
137
302
const atpClient = await getAtpClient(did);
303
+
304
+
let filteredMembers: Fronter["members"];
305
+
try {
306
+
filteredMembers = await getFronterNames(members);
307
+
} catch (error) {
308
+
return err(`error fetching fronter names: ${error}`);
309
+
}
138
310
139
311
// put
140
312
let maybeRecord = await atpClient.post("com.atproto.repo.putRecord", {
···
143
315
collection: fronterSchema.object.shape.$type.expected,
144
316
rkey: `${collection}_${rkey}`,
145
317
record: {
146
-
name,
147
-
subject: `at://${did}/${collection}/${rkey}`,
318
+
subject,
319
+
members: filteredMembers.map((member) => ({
320
+
name: member.name,
321
+
uri: member.uri ? memberUriString(member.uri) : undefined,
322
+
})),
148
323
},
149
324
validate: false,
150
325
},
···
156
331
return ok({
157
332
did,
158
333
handle,
159
-
fronterName: name,
334
+
members: filteredMembers,
160
335
});
161
336
};
337
+
338
+
export const deleteFronter = async (
339
+
did: AtprotoDid,
340
+
collection: Nsid,
341
+
rkey: RecordKey,
342
+
authToken: string,
343
+
): Promise<Result<boolean, string>> => {
344
+
// make client
345
+
const atpClient = await getAtpClient(did);
346
+
347
+
// delete
348
+
let maybeRecord = await atpClient.post("com.atproto.repo.deleteRecord", {
349
+
input: {
350
+
repo: did,
351
+
collection: fronterSchema.object.shape.$type.expected,
352
+
rkey: `${collection}_${rkey}`,
353
+
},
354
+
headers: { authorization: `Bearer ${authToken}` },
355
+
});
356
+
if (!maybeRecord.ok)
357
+
return err(maybeRecord.data.message ?? maybeRecord.data.error);
358
+
359
+
return ok(true);
360
+
};
361
+
362
+
export const getSpFronters = async (): Promise<
363
+
Parameters<typeof putFronter>["1"]
364
+
> => {
365
+
const spToken = await storage.getItem<string>("sync:sp_token");
366
+
if (!spToken) return [];
367
+
const resp = await fetch(`https://api.apparyllis.com/v1/fronters`, {
368
+
headers: {
369
+
authorization: spToken,
370
+
},
371
+
});
372
+
if (!resp.ok) return [];
373
+
const spFronters = (await resp.json()) as any[];
374
+
return spFronters.map((fronter) => ({
375
+
name: undefined,
376
+
uri: {
377
+
type: "sp",
378
+
memberId: fronter.content.member,
379
+
systemId: fronter.content.uid,
380
+
},
381
+
}));
382
+
};
383
+
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;
429
+
return [
430
+
view.handle ? [fronterGetSocialAppHref(view.handle, view.rkey, depth)] : [],
431
+
fronterGetSocialAppHref(view.did, view.rkey, depth),
432
+
].flat();
433
+
};
434
+
435
+
export const fronterGetSocialAppHref = (
436
+
repo: string,
437
+
rkey: RecordKey,
438
+
depth?: number,
439
+
) => {
440
+
return depth === 0 ? `/profile/${repo}` : `/profile/${repo}/post/${rkey}`;
441
+
};
442
+
443
+
export const parseSocialAppPostUrl = (url: string) => {
444
+
const match = url.match(/https:\/\/[^/]+\/profile\/([^/]+)\/post\/([^/]+)/);
445
+
if (!match) return;
446
+
const [website, actorIdentifier, rkey] = match;
447
+
return { actorIdentifier, rkey };
448
+
};
449
+
450
+
export const displayNameCache = new PersistentCache<string>(
451
+
"displayNameCache",
452
+
1,
453
+
);