tangled
alpha
login
or
join now
ptr.pet
/
at-fronter
6
fork
atom
view who was fronting when a record was made
6
fork
atom
overview
issues
pulls
pipelines
feat: add sp integration
ptr.pet
6 months ago
f60168fc
32e798dd
verified
This commit was signed with the committer's
known signature
.
ptr.pet
SSH Key Fingerprint:
SHA256:Abmvag+juovVufZTxyWY8KcVgrznxvBjQpJesv071Aw=
+210
-58
5 changed files
expand all
collapse all
unified
split
src
entrypoints
background.ts
content.ts
isolated.content.ts
popup
App.svelte
lib
utils.ts
+26
-11
src/entrypoints/background.ts
···
1
import { expect } from "@/lib/result";
2
import {
3
-
Fronter,
4
fronterGetSocialAppHref,
5
getFronter,
0
0
6
putFronter,
7
} from "@/lib/utils";
8
import { parseResourceUri, ResourceUri } from "@atcute/lexicons";
···
15
let fronters = new Map<ResourceUri, Fronter | null>();
16
const cacheFronter = (uri: ResourceUri, fronter: Fronter) => {
17
const parsedUri = expect(parseResourceUri(uri));
18
-
fronters.set(uri, fronter);
19
fronters.set(
20
`at://${fronter.did}/${parsedUri.collection!}/${parsedUri.rkey!}`,
21
fronter,
···
49
};
50
51
const handleWrite = async (
52
-
{ data: { body, authToken } }: any,
0
53
sender: globalThis.Browser.runtime.MessageSender,
54
) => {
55
const fronter = await storage.getItem<string>("sync:fronter");
56
-
if (!fronter) return;
57
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);
0
0
0
63
if (resp.ok) {
64
const parsedUri = cacheFronter(result.uri, resp.value);
65
results.push({
66
rkey: parsedUri.rkey!,
67
...resp.value,
68
});
0
0
69
}
70
}
71
browser.tabs.sendMessage(sender.tab?.id!, {
···
178
// console.log("handling response event", message);
179
switch (message.data.type as string) {
180
case "write":
181
-
await handleWrite(message, sender);
0
0
0
0
0
0
0
0
0
0
0
182
break;
183
case "posts":
184
-
const posts = JSON.parse(message.data.body) as any[];
185
await handleTimeline(
186
-
posts.map((post) => ({ post })),
187
sender,
188
);
189
break;
···
1
import { expect } from "@/lib/result";
2
import {
3
+
type Fronter,
4
fronterGetSocialAppHref,
5
getFronter,
6
+
getSpFronters,
7
+
memberUriString,
8
putFronter,
9
} from "@/lib/utils";
10
import { parseResourceUri, ResourceUri } from "@atcute/lexicons";
···
17
let fronters = new Map<ResourceUri, Fronter | null>();
18
const cacheFronter = (uri: ResourceUri, fronter: Fronter) => {
19
const parsedUri = expect(parseResourceUri(uri));
0
20
fronters.set(
21
`at://${fronter.did}/${parsedUri.collection!}/${parsedUri.rkey!}`,
22
fronter,
···
50
};
51
52
const handleWrite = async (
53
+
items: any[],
54
+
authToken: string | null,
55
sender: globalThis.Browser.runtime.MessageSender,
56
) => {
57
const fronter = await storage.getItem<string>("sync:fronter");
58
+
const spFronters = (await getSpFronters()).map((m) => memberUriString(m));
59
if (!authToken) return;
0
0
60
const results = [];
61
+
for (const result of items) {
62
+
const resp = await putFronter(
63
+
{ name: fronter ?? "", subject: result.uri, member: spFronters },
64
+
authToken,
65
+
);
66
if (resp.ok) {
67
const parsedUri = cacheFronter(result.uri, resp.value);
68
results.push({
69
rkey: parsedUri.rkey!,
70
...resp.value,
71
});
72
+
} else {
73
+
console.error(`fronter write: ${resp.error}`);
74
}
75
}
76
browser.tabs.sendMessage(sender.tab?.id!, {
···
183
// console.log("handling response event", message);
184
switch (message.data.type as string) {
185
case "write":
186
+
await handleWrite(
187
+
JSON.parse(message.data.body).results,
188
+
message.data.authToken,
189
+
sender,
190
+
);
191
+
break;
192
+
case "writeOne":
193
+
await handleWrite(
194
+
[JSON.parse(message.data.body)],
195
+
message.data.authToken,
196
+
sender,
197
+
);
198
break;
199
case "posts":
0
200
await handleTimeline(
201
+
(JSON.parse(message.data.body) as any[]).map((post) => ({ post })),
202
sender,
203
);
204
break;
+20
-9
src/entrypoints/content.ts
···
38
}),
39
);
40
};
41
-
42
-
let detail: any;
43
-
if (response.url.includes("/xrpc/com.atproto.repo.applyWrites")) {
44
let authHeader: string | null = null;
45
if (typeof args[0] === "string") {
46
if (args[1]?.headers) {
···
49
} else if (args[0] instanceof Request) {
50
authHeader = getAuthHeader(args[0].headers);
51
}
0
0
52
0
0
53
detail = {
54
type: "write",
55
body,
56
-
authToken: authHeader?.split(" ")[1] || null,
0
0
0
0
0
0
57
};
58
} else if (
59
response.url.includes("/xrpc/app.bsky.feed.getAuthorFeed") ||
···
77
body,
78
};
79
}
80
-
sendEvent(detail);
0
0
81
82
return response;
83
};
···
96
});
97
respEventSetup.then((name) => (respEventName = name));
98
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);
0
103
};
104
const applyFrontersToPage = (fronters: Map<string, any>) => {
105
for (const el of document.getElementsByTagName("a")) {
···
114
: (el.parentElement?.firstElementChild?.firstElementChild
115
?.firstElementChild?.firstElementChild ?? null);
116
if (!displayNameElement) continue;
117
-
applyFronterName(displayNameElement, fronter.fronterName);
118
}
119
};
120
window.addEventListener("message", (event) => {
···
38
}),
39
);
40
};
41
+
const getAuthToken = () => {
0
0
42
let authHeader: string | null = null;
43
if (typeof args[0] === "string") {
44
if (args[1]?.headers) {
···
47
} else if (args[0] instanceof Request) {
48
authHeader = getAuthHeader(args[0].headers);
49
}
50
+
return authHeader?.split(" ")[1] || null;
51
+
};
52
53
+
let detail: any = undefined;
54
+
if (response.url.includes("/xrpc/com.atproto.repo.applyWrites")) {
55
detail = {
56
type: "write",
57
body,
58
+
authToken: getAuthToken(),
59
+
};
60
+
} else if (response.url.includes("/xrpc/com.atproto.repo.createRecord")) {
61
+
detail = {
62
+
type: "writeOne",
63
+
body,
64
+
authToken: getAuthToken(),
65
};
66
} else if (
67
response.url.includes("/xrpc/app.bsky.feed.getAuthorFeed") ||
···
85
body,
86
};
87
}
88
+
if (detail) {
89
+
sendEvent(detail);
90
+
}
91
92
return response;
93
};
···
106
});
107
respEventSetup.then((name) => (respEventName = name));
108
109
+
const applyFronterName = (el: Element, fronterNames: string[]) => {
110
if (el.getAttribute("data-fronter")) return;
111
+
const s = fronterNames.join(", ");
112
+
el.textContent += ` [f: ${s}]`;
113
+
el.setAttribute("data-fronter", s);
114
};
115
const applyFrontersToPage = (fronters: Map<string, any>) => {
116
for (const el of document.getElementsByTagName("a")) {
···
125
: (el.parentElement?.firstElementChild?.firstElementChild
126
?.firstElementChild?.firstElementChild ?? null);
127
if (!displayNameElement) continue;
128
+
applyFronterName(displayNameElement, fronter.names);
129
}
130
};
131
window.addEventListener("message", (event) => {
+5
-9
src/entrypoints/isolated.content.ts
···
30
const respEventName = Math.random().toString(36).slice(2);
31
window.addEventListener(`${respEventName}-isolated`, async (event) => {
32
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
-
});
42
});
43
const messageTypes = [
44
"TAB_FRONTER",
···
30
const respEventName = Math.random().toString(36).slice(2);
31
window.addEventListener(`${respEventName}-isolated`, async (event) => {
32
const data = (event as any).detail;
33
+
// console.log("passing response event to bg", event);
34
+
await browser.runtime.sendMessage({
35
+
type: "RESPONSE_CAPTURED",
36
+
data,
37
+
});
0
0
0
0
38
});
39
const messageTypes = [
40
"TAB_FRONTER",
+34
-18
src/entrypoints/popup/App.svelte
···
2
import { expect } from "@/lib/result";
3
import { getFronter } from "@/lib/utils";
4
import { isResourceUri } from "@atcute/lexicons";
5
-
import type {
6
-
AtprotoDid,
7
-
Handle,
8
-
ResourceUri,
9
-
} from "@atcute/lexicons/syntax";
10
11
let recordAtUri = $state("");
12
let queryResult = $state("");
13
let isQuerying = $state(false);
14
let fronterName = $state("");
0
15
16
-
const makeOutput = (fronterName: string, handle: Handle | null) => {
17
-
return `HANDLE: ${handle ?? "handle.invalid"}\nFRONTER: ${fronterName}`;
18
};
19
20
const queryRecord = async (recordUri: ResourceUri) => {
···
26
try {
27
if (!isResourceUri(recordUri)) throw "INVALID_RESOURCE_URI";
28
const result = expect(await getFronter(recordUri));
29
-
queryResult =
30
-
makeOutput(result.fronterName, result.handle) ||
31
-
"NO_FRONTER_FOUND";
32
} catch (error) {
33
queryResult = `ERROR: ${error}`;
34
} finally {
···
39
const updateFronter = (event: any) => {
40
fronterName = (event.target as HTMLInputElement).value;
41
storage.setItem("sync:fronter", fronterName);
0
0
0
0
0
42
};
43
44
const handleKeyPress = (event: KeyboardEvent) => {
···
58
fronterName = fronter;
59
}
60
0
0
0
0
0
61
const tabs = await browser.tabs.query({
62
active: true,
63
currentWindow: true,
64
});
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`);
71
if (tabFronter) {
72
-
queryResult = makeOutput(tabFronter.fronterName, tabFronter.handle);
73
recordAtUri = tabFronter.recordUri;
74
}
75
});
···
142
"ERROR:",
143
)}
144
>
145
-
{queryResult}
146
</div>
147
{:else}
148
<div class="placeholder-text">
···
170
bind:value={fronterName}
171
class="config-input"
172
class:has-value={fronterName}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
173
/>
174
</div>
175
</div>
···
2
import { expect } from "@/lib/result";
3
import { getFronter } from "@/lib/utils";
4
import { isResourceUri } from "@atcute/lexicons";
5
+
import type { ResourceUri } from "@atcute/lexicons/syntax";
0
0
0
0
6
7
let recordAtUri = $state("");
8
let queryResult = $state("");
9
let isQuerying = $state(false);
10
let fronterName = $state("");
11
+
let spToken = $state("");
12
13
+
const makeOutput = (fronter: any) => {
14
+
return `HANDLE: ${fronter.handle ?? "handle.invalid"}<br>FRONTER: ${fronter.fronterName}`;
15
};
16
17
const queryRecord = async (recordUri: ResourceUri) => {
···
23
try {
24
if (!isResourceUri(recordUri)) throw "INVALID_RESOURCE_URI";
25
const result = expect(await getFronter(recordUri));
26
+
queryResult = makeOutput(result) || "NO_FRONTER_FOUND";
0
0
27
} catch (error) {
28
queryResult = `ERROR: ${error}`;
29
} finally {
···
34
const updateFronter = (event: any) => {
35
fronterName = (event.target as HTMLInputElement).value;
36
storage.setItem("sync:fronter", fronterName);
37
+
};
38
+
39
+
const updateSpToken = (event: any) => {
40
+
spToken = (event.target as HTMLInputElement).value;
41
+
storage.setItem("sync:sp_token", spToken);
42
};
43
44
const handleKeyPress = (event: KeyboardEvent) => {
···
58
fronterName = fronter;
59
}
60
61
+
const token = await storage.getItem<string>("sync:sp_token");
62
+
if (token) {
63
+
spToken = token;
64
+
}
65
+
66
const tabs = await browser.tabs.query({
67
active: true,
68
currentWindow: true,
69
});
70
+
const tabFronter = await storage.getItem<any>(
71
+
`local:tab-${tabs[0].id!}-fronter`,
72
+
);
0
0
0
73
if (tabFronter) {
74
+
queryResult = makeOutput(tabFronter);
75
recordAtUri = tabFronter.recordUri;
76
}
77
});
···
144
"ERROR:",
145
)}
146
>
147
+
{@html queryResult}
148
</div>
149
{:else}
150
<div class="placeholder-text">
···
172
bind:value={fronterName}
173
class="config-input"
174
class:has-value={fronterName}
175
+
/>
176
+
</div>
177
+
</div>
178
+
179
+
<div class="config-row">
180
+
<span class="config-label">SP_TOKEN</span>
181
+
<div class="config-input-wrapper">
182
+
<input
183
+
type="password"
184
+
placeholder="enter_simply_plural_token"
185
+
oninput={updateSpToken}
186
+
bind:value={spToken}
187
+
class="config-input"
188
+
class:has-value={spToken}
189
/>
190
</div>
191
</div>
+125
-11
src/lib/utils.ts
···
7
import {
8
ActorIdentifier,
9
Did,
0
10
Handle,
11
isHandle,
12
RecordKey,
···
26
import { getAtprotoHandle, getPdsEndpoint } from "@atcute/identity";
27
28
export type Fronter = {
29
-
fronterName: string;
0
30
handle: Handle | null;
31
did: AtprotoDid;
32
};
···
47
$type: v.literal("systems.gaze.atfronter.fronter"),
48
name: v.string(),
49
subject: v.resourceUriString(),
0
50
}),
51
);
52
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
53
const handleResolver = new CompositeHandleResolver({
54
strategy: "race",
55
methods: {
···
114
const maybeTyped = safeParse(fronterSchema, maybeRecord.data.value);
115
if (!maybeTyped.ok) return err(maybeTyped.message);
116
0
0
0
0
0
0
0
0
117
return ok({
118
-
fronterName: maybeTyped.value.name,
0
119
handle,
120
did,
121
});
122
};
123
124
-
export const putFronter = async <Uri extends ResourceUri>(
125
-
recordUri: Uri,
126
-
name: string,
127
authToken: string,
128
): Promise<Result<Fronter, string>> => {
129
-
const parsedRecordUri = parseResourceUri(recordUri);
130
if (!parsedRecordUri.ok) return err(parsedRecordUri.error);
131
const { repo, collection, rkey } = parsedRecordUri.value;
132
···
142
repo: did,
143
collection: fronterSchema.object.shape.$type.expected,
144
rkey: `${collection}_${rkey}`,
145
-
record: {
146
-
name,
147
-
subject: `at://${did}/${collection}/${rkey}`,
148
-
},
149
validate: false,
150
},
151
headers: { authorization: `Bearer ${authToken}` },
···
153
if (!maybeRecord.ok)
154
return err(maybeRecord.data.message ?? maybeRecord.data.error);
155
0
0
0
0
0
0
0
0
156
return ok({
157
did,
158
handle,
159
-
fronterName: name,
0
160
});
161
};
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
7
import {
8
ActorIdentifier,
9
Did,
10
+
GenericUri,
11
Handle,
12
isHandle,
13
RecordKey,
···
27
import { getAtprotoHandle, getPdsEndpoint } from "@atcute/identity";
28
29
export type Fronter = {
30
+
memberUris: MemberUri[];
31
+
names: string[];
32
handle: Handle | null;
33
did: AtprotoDid;
34
};
···
49
$type: v.literal("systems.gaze.atfronter.fronter"),
50
name: v.string(),
51
subject: v.resourceUriString(),
52
+
member: v.array(v.genericUriString()), // identifier(s) for pk or sp or etc. (maybe member record in the future?)
53
}),
54
);
55
56
+
type MemberUri =
57
+
| { type: "at"; recordUri: ResourceUri }
58
+
| { type: "pk"; systemId: string; memberId: string }
59
+
| { type: "sp"; systemId: string; memberId: string };
60
+
61
+
export const parseMemberId = (memberId: GenericUri): MemberUri => {
62
+
const uri = new URL(memberId);
63
+
switch (uri.protocol) {
64
+
case "pk:": {
65
+
const split = uri.pathname.split("/").slice(1);
66
+
return { type: "pk", systemId: split[0], memberId: split[1] };
67
+
}
68
+
case "sp:": {
69
+
const split = uri.pathname.split("/").slice(1);
70
+
return { type: "sp", systemId: split[0], memberId: split[1] };
71
+
}
72
+
case "at:": {
73
+
return { type: "at", recordUri: memberId as ResourceUri };
74
+
}
75
+
default: {
76
+
throw new Error(`Invalid member ID: ${memberId}`);
77
+
}
78
+
}
79
+
};
80
+
export const memberUriString = (memberUri: MemberUri): GenericUri => {
81
+
switch (memberUri.type) {
82
+
case "pk": {
83
+
return `pk://api.pluralkit.com/${memberUri.systemId}/${memberUri.memberId}`;
84
+
}
85
+
case "sp": {
86
+
return `sp://api.apparyllis.com/${memberUri.systemId}/${memberUri.memberId}`;
87
+
}
88
+
case "at": {
89
+
return memberUri.recordUri;
90
+
}
91
+
}
92
+
};
93
+
94
+
let memberCache = new Map<string, any>();
95
+
export const fetchMember = async (
96
+
memberUri: MemberUri,
97
+
): Promise<string | undefined> => {
98
+
switch (memberUri.type) {
99
+
case "sp": {
100
+
const s = memberUriString(memberUri);
101
+
const cached = memberCache.get(s);
102
+
if (cached) return cached.content.name;
103
+
const token = await storage.getItem<string>("sync:sp_token");
104
+
if (!token) return;
105
+
const resp = await fetch(
106
+
`https://api.apparyllis.com/v1/member/${memberUri.systemId}/${memberUri.memberId}`,
107
+
{
108
+
headers: {
109
+
authorization: token,
110
+
},
111
+
},
112
+
);
113
+
if (!resp.ok) return;
114
+
const member = await resp.json();
115
+
memberCache.set(s, member);
116
+
return member.content.name;
117
+
}
118
+
}
119
+
};
120
+
121
+
export const getFronterNames = async (
122
+
name: string,
123
+
memberUris: MemberUri[],
124
+
) => {
125
+
let fronterNames = [name];
126
+
if (memberUris.length > 0) {
127
+
fronterNames = (
128
+
await Promise.allSettled(memberUris.map((m) => fetchMember(m)))
129
+
)
130
+
.filter((p) => p.status === "fulfilled")
131
+
.flatMap((p) => p.value ?? []);
132
+
}
133
+
return fronterNames;
134
+
};
135
+
136
const handleResolver = new CompositeHandleResolver({
137
strategy: "race",
138
methods: {
···
197
const maybeTyped = safeParse(fronterSchema, maybeRecord.data.value);
198
if (!maybeTyped.ok) return err(maybeTyped.message);
199
200
+
let memberUris, fronterNames;
201
+
try {
202
+
memberUris = maybeTyped.value.member.map((m) => parseMemberId(m));
203
+
fronterNames = await getFronterNames(maybeTyped.value.name, memberUris);
204
+
} catch (error) {
205
+
return err(`error fetching fronter names: ${error}`);
206
+
}
207
+
208
return ok({
209
+
memberUris,
210
+
names: fronterNames,
211
handle,
212
did,
213
});
214
};
215
216
+
export const putFronter = async (
217
+
record: Omit<InferOutput<typeof fronterSchema>, "$type">,
0
218
authToken: string,
219
): Promise<Result<Fronter, string>> => {
220
+
const parsedRecordUri = parseResourceUri(record.subject);
221
if (!parsedRecordUri.ok) return err(parsedRecordUri.error);
222
const { repo, collection, rkey } = parsedRecordUri.value;
223
···
233
repo: did,
234
collection: fronterSchema.object.shape.$type.expected,
235
rkey: `${collection}_${rkey}`,
236
+
record,
0
0
0
237
validate: false,
238
},
239
headers: { authorization: `Bearer ${authToken}` },
···
241
if (!maybeRecord.ok)
242
return err(maybeRecord.data.message ?? maybeRecord.data.error);
243
244
+
let memberUris, fronterNames;
245
+
try {
246
+
memberUris = record.member.map((m) => parseMemberId(m));
247
+
fronterNames = await getFronterNames(record.name, memberUris);
248
+
} catch (error) {
249
+
return err(`error fetching fronter names: ${error}`);
250
+
}
251
+
252
return ok({
253
did,
254
handle,
255
+
names: fronterNames,
256
+
memberUris,
257
});
258
};
259
+
260
+
export const getSpFronters = async (): Promise<MemberUri[]> => {
261
+
const spToken = await storage.getItem<string>("sync:sp_token");
262
+
if (!spToken) return [];
263
+
const resp = await fetch(`https://api.apparyllis.com/v1/fronters`, {
264
+
headers: {
265
+
authorization: spToken,
266
+
},
267
+
});
268
+
if (!resp.ok) return [];
269
+
const spFronters = (await resp.json()) as any[];
270
+
return spFronters.map((fronter) => ({
271
+
type: "sp",
272
+
memberId: fronter.content.member,
273
+
systemId: fronter.content.uid,
274
+
}));
275
+
};