view who was fronting when a record was made

feat: add pk fronters, improve settings

ptr.pet 2da508cc b1eaddbe

verified
Changed files
+509 -83
src
+369
src/components/FronterList.svelte
···
+
<script lang="ts">
+
import { fetchMember, type MemberUri } from "@/lib/utils";
+
+
interface Props {
+
fronters: string[];
+
onUpdate: (fronters: string[]) => void;
+
label?: string;
+
placeholder?: string;
+
note?: string;
+
fetchNames?: boolean; // If true, treat as PK member IDs and fetch names
+
}
+
+
let {
+
fronters = $bindable([]),
+
onUpdate,
+
label = "FRONTERS",
+
placeholder = "enter_identifier",
+
note = "list of identifiers",
+
fetchNames = false,
+
}: Props = $props();
+
+
let inputValue = $state("");
+
let inputElement: HTMLInputElement;
+
let memberNames = $state<Map<string, string | null>>(new Map());
+
let memberErrors = $state<Map<string, string>>(new Map());
+
+
const fetchMemberName = async (memberId: string) => {
+
try {
+
const memberUri: MemberUri = { type: "pk", memberId };
+
const name = await fetchMember(memberUri);
+
if (name) {
+
memberNames.set(memberId, name);
+
memberErrors.delete(memberId);
+
} else {
+
memberNames.set(memberId, null);
+
memberErrors.set(memberId, "Member not found");
+
}
+
} catch (error) {
+
memberNames.set(memberId, null);
+
memberErrors.set(memberId, `Error: ${error}`);
+
}
+
// Trigger reactivity
+
memberNames = new Map(memberNames);
+
memberErrors = new Map(memberErrors);
+
};
+
+
const addFronter = (name: string) => {
+
const trimmedName = name.trim();
+
if (!trimmedName || fronters.includes(trimmedName)) return;
+
+
const updatedFronters = [...fronters, trimmedName];
+
fronters = updatedFronters;
+
onUpdate(updatedFronters);
+
inputValue = "";
+
+
// Fetch the member name if this is a PK fronter
+
if (fetchNames) {
+
fetchMemberName(trimmedName);
+
}
+
};
+
+
const removeFronter = (index: number) => {
+
const identifier = fronters[index];
+
const updatedFronters = fronters.filter((_, i) => i !== index);
+
fronters = updatedFronters;
+
onUpdate(updatedFronters);
+
+
// Clean up the member name cache if this is a PK fronter
+
if (fetchNames) {
+
memberNames.delete(identifier);
+
memberErrors.delete(identifier);
+
memberNames = new Map(memberNames);
+
memberErrors = new Map(memberErrors);
+
}
+
+
inputElement?.focus();
+
};
+
+
const handleKeyPress = (event: KeyboardEvent) => {
+
if (event.key === "Enter" || event.key === "," || event.key === " ") {
+
event.preventDefault();
+
addFronter(inputValue);
+
} else if (
+
event.key === "Backspace" &&
+
inputValue === "" &&
+
fronters.length > 0
+
) {
+
// Remove last tag when backspacing on empty input
+
removeFronter(fronters.length - 1);
+
}
+
};
+
+
const handleInput = (event: Event) => {
+
const target = event.target as HTMLInputElement;
+
const value = target.value;
+
+
// Check for comma or space at the end
+
if (value.endsWith(",") || value.endsWith(" ")) {
+
addFronter(value.slice(0, -1));
+
} else {
+
inputValue = value;
+
}
+
};
+
+
const focusInput = () => {
+
inputElement?.focus();
+
};
+
+
// Load existing member names on mount (only for PK fronters)
+
$effect(() => {
+
if (fetchNames) {
+
fronters.forEach((identifier) => {
+
if (
+
!memberNames.has(identifier) &&
+
!memberErrors.has(identifier)
+
) {
+
fetchMemberName(identifier);
+
}
+
});
+
}
+
});
+
+
// Helper function to get display text for a fronter
+
const getDisplayText = (identifier: string) => {
+
if (!fetchNames) return identifier;
+
return memberNames.get(identifier) || identifier;
+
};
+
+
// Helper function to check if we should show error/loading state
+
const getStatusInfo = (identifier: string) => {
+
if (!fetchNames) return null;
+
+
if (memberErrors.has(identifier)) {
+
return { type: "error", text: memberErrors.get(identifier) };
+
}
+
if (memberNames.get(identifier) === undefined) {
+
return { type: "loading", text: "loading..." };
+
}
+
return null;
+
};
+
</script>
+
+
<div class="config-card">
+
<div class="config-row">
+
<span class="config-label">{label}</span>
+
<div
+
class="tag-input-container"
+
onclick={focusInput}
+
onkeydown={(e) => e.key === "Enter" && focusInput()}
+
role="textbox"
+
tabindex="0"
+
>
+
<div class="tag-input-wrapper">
+
{#each fronters as identifier, index}
+
<div class="fronter-tag">
+
<div class="tag-content">
+
<span class="tag-text">
+
{getDisplayText(identifier)}
+
</span>
+
{#if getStatusInfo(identifier)}
+
{@const status = getStatusInfo(identifier)}
+
{#if status}
+
<span class="tag-{status.type}"
+
>{status.text}</span
+
>
+
{/if}
+
{/if}
+
</div>
+
<button
+
onclick={() => removeFronter(index)}
+
class="tag-remove"
+
title="Remove fronter"
+
>
+
×
+
</button>
+
</div>
+
{/each}
+
<input
+
bind:this={inputElement}
+
type="text"
+
placeholder={fronters.length === 0 ? placeholder : ""}
+
value={inputValue}
+
oninput={handleInput}
+
onkeydown={handleKeyPress}
+
class="tag-input"
+
/>
+
</div>
+
</div>
+
</div>
+
+
<div class="config-note">
+
<span class="note-text">{note}</span>
+
</div>
+
</div>
+
+
<style>
+
.config-card {
+
background: #0d0d0d;
+
border: 1px solid #2a2a2a;
+
border-left: 3px solid #444444;
+
padding: 10px;
+
display: flex;
+
flex-direction: column;
+
gap: 6px;
+
transition: border-left-color 0.2s ease;
+
}
+
+
.config-card:hover {
+
border-left-color: #555555;
+
}
+
+
.config-row {
+
display: flex;
+
align-items: center;
+
gap: 12px;
+
margin-bottom: 0;
+
}
+
+
.config-label {
+
font-size: 12px;
+
color: #cccccc;
+
letter-spacing: 1px;
+
font-weight: 700;
+
white-space: nowrap;
+
min-width: 90px;
+
}
+
+
.tag-input-container {
+
flex: 1;
+
background: #181818;
+
border: 1px solid #333333;
+
transition: border-color 0.2s ease;
+
cursor: text;
+
min-height: 42px;
+
display: flex;
+
align-items: center;
+
}
+
+
.tag-input-container:focus-within {
+
border-color: #666666;
+
}
+
+
.tag-input-container:focus-within:has(.fronter-tag) {
+
border-bottom-color: #00ff41;
+
}
+
+
.tag-input-wrapper {
+
display: flex;
+
flex-wrap: wrap;
+
align-items: center;
+
gap: 6px;
+
padding: 8px 12px;
+
width: 100%;
+
min-height: 26px;
+
}
+
+
.fronter-tag {
+
display: flex;
+
align-items: center;
+
background: #2a2a2a;
+
border: 1px solid #444444;
+
border-radius: 3px;
+
padding: 4px 6px;
+
gap: 6px;
+
font-size: 11px;
+
color: #ffffff;
+
font-weight: 600;
+
line-height: 1;
+
transition: all 0.15s ease;
+
animation: tagAppear 0.2s ease-out;
+
}
+
+
.fronter-tag:hover {
+
background: #333333;
+
border-color: #555555;
+
}
+
+
.tag-content {
+
display: flex;
+
flex-direction: column;
+
gap: 2px;
+
}
+
+
.tag-text {
+
white-space: nowrap;
+
letter-spacing: 0.5px;
+
}
+
+
.tag-error {
+
font-size: 9px;
+
color: #ff6666;
+
font-weight: 500;
+
letter-spacing: 0.3px;
+
}
+
+
.tag-loading {
+
font-size: 9px;
+
color: #888888;
+
font-weight: 500;
+
letter-spacing: 0.3px;
+
font-style: italic;
+
}
+
+
.tag-remove {
+
background: none;
+
border: none;
+
color: #888888;
+
font-size: 14px;
+
font-weight: 700;
+
cursor: pointer;
+
padding: 0;
+
line-height: 1;
+
transition: color 0.15s ease;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
width: 14px;
+
height: 14px;
+
margin-left: 2px;
+
}
+
+
.tag-remove:hover {
+
color: #ff4444;
+
}
+
+
.tag-input {
+
background: transparent;
+
border: none;
+
outline: none;
+
color: #ffffff;
+
font-family: inherit;
+
font-size: 12px;
+
font-weight: 500;
+
flex: 1;
+
min-width: 120px;
+
height: 26px;
+
}
+
+
.tag-input::placeholder {
+
color: #777777;
+
font-size: 12px;
+
}
+
+
.config-note {
+
padding: 0;
+
background: transparent;
+
border: none;
+
margin: 0;
+
}
+
+
.note-text {
+
font-size: 11px;
+
color: #bbbbbb;
+
line-height: 1.3;
+
font-weight: 500;
+
letter-spacing: 0.5px;
+
}
+
+
@keyframes tagAppear {
+
0% {
+
opacity: 0;
+
transform: scale(0.8);
+
}
+
100% {
+
opacity: 1;
+
transform: scale(1);
+
}
+
}
+
</style>
+12 -11
src/entrypoints/background.ts
···
authToken: string | null,
sender: globalThis.Browser.runtime.MessageSender,
) => {
-
const fronterName = await storage.getItem<string>("sync:fronter");
-
const spFronters = (await getSpFronters()).map((m) => memberUriString(m));
if (!authToken) return;
-
const fronter = {
-
names: fronterName?.split(",").map((name) => name.trim()) ?? [],
-
members: spFronters,
-
};
+
const frontersArray = await storage.getItem<string[]>("sync:fronters");
+
let members: Parameters<typeof putFronter>["1"] = frontersArray ?? [];
+
if (members.length === 0) {
+
const pkFronters = await storage.getItem<string[]>("sync:pk-fronter");
+
if (pkFronters) {
+
members = pkFronters.map((id) => ({ type: "pk", memberId: id }));
+
} else {
+
members = await getSpFronters();
+
}
+
}
// dont write if no names is specified or no sp/pk fronters are fetched
-
if (fronter.names.length === 0 && fronter.members.length === 0) return;
+
if (members.length === 0) return;
const results = [];
for (const result of items) {
-
const resp = await putFronter(
-
{ subject: result.uri, ...fronter },
-
authToken,
-
);
+
const resp = await putFronter(result.uri, members, authToken);
if (resp.ok) {
const parsedUri = cacheFronter(result.uri, resp.value);
results.push({
+4 -4
src/entrypoints/content.ts
···
});
respEventSetup.then((name) => (respEventName = name));
-
const applyFronterName = (el: Element, fronterNames: string[]) => {
+
const applyFronterName = (el: Element, fronters: Fronter["members"]) => {
if (el.getAttribute("data-fronter")) return;
-
const s = fronterNames.join(", ");
+
const s = fronters.map((f) => f.name).join(", ");
el.textContent += ` [f: ${s}]`;
el.setAttribute("data-fronter", s);
};
···
for (const el of document.getElementsByTagName("a")) {
const path = `/${el.href.split("/").slice(3).join("/")}`;
const fronter = fronters.get(path);
-
if (!fronter) continue;
+
if (!fronter || fronter.members.length === 0) continue;
const isFocusedPost = fronter.depth === 0;
if (isFocusedPost && match && match.rkey !== fronter.rkey) continue;
if (isFocusedPost && el.ariaLabel !== fronter.displayName) continue;
···
?.firstElementChild?.firstElementChild ?? null);
if (!displayNameElement) continue;
// console.log(path, fronter, displayNameElement);
-
applyFronterName(displayNameElement, fronter.names);
+
applyFronterName(displayNameElement, fronter.members);
}
};
let postTabObserver: MutationObserver | null = null;
+52 -30
src/entrypoints/popup/App.svelte
···
<script lang="ts">
import { expect } from "@/lib/result";
-
import { getFronter } from "@/lib/utils";
+
import { getFronter, getMemberPublicUri } from "@/lib/utils";
import { isResourceUri } from "@atcute/lexicons";
import type { ResourceUri } from "@atcute/lexicons/syntax";
+
import FronterList from "@/components/FronterList.svelte";
let recordAtUri = $state("");
let queryResult = $state("");
let isQuerying = $state(false);
-
let fronterName = $state("");
+
let fronters = $state<string[]>([]);
+
let pkFronters = $state<string[]>([]);
let spToken = $state("");
let isFromCurrentTab = $state(false);
-
const makeOutput = (fronter: any) => {
-
return `HANDLE: ${fronter.handle ?? "handle.invalid"}<br>FRONTER(S): ${fronter.names.join(", ")}`;
+
const makeOutput = (record: any) => {
+
const fronters = record.members
+
.map((f: any) => {
+
if (!f.uri) return f.name;
+
const publicUri = getMemberPublicUri(f.uri);
+
if (!publicUri) return f.name;
+
return `<a href="${publicUri}">${f.name}</a>`;
+
})
+
.join(", ");
+
return [
+
`HANDLE: ${record.handle ?? `handle.invalid (${record.did})`}`,
+
`FRONTER(S): ${fronters}`,
+
].join("<br>");
};
const queryRecord = async (recordUri: ResourceUri) => {
···
}
};
-
const updateFronter = (event: any) => {
-
fronterName = (event.target as HTMLInputElement).value;
-
storage.setItem("sync:fronter", fronterName);
+
const updateFronters = (newFronters: string[]) => {
+
fronters = newFronters;
+
storage.setItem("sync:fronters", newFronters);
+
};
+
+
const updatePkFronters = (newPkFronters: string[]) => {
+
pkFronters = newPkFronters;
+
storage.setItem("sync:pk-fronter", newPkFronters);
};
const updateSpToken = (event: any) => {
···
};
onMount(async () => {
-
const fronter = await storage.getItem<string>("sync:fronter");
-
if (fronter) {
-
fronterName = fronter;
+
const frontersArray = await storage.getItem<string[]>("sync:fronters");
+
if (frontersArray && Array.isArray(frontersArray)) {
+
fronters = frontersArray;
+
}
+
+
const pkFrontersArray =
+
await storage.getItem<string[]>("sync:pk-fronter");
+
if (pkFrontersArray && Array.isArray(pkFrontersArray)) {
+
pkFronters = pkFrontersArray;
}
const token = await storage.getItem<string>("sync:sp_token");
···
</div>
<div class="config-card">
<div class="config-row">
-
<span class="config-label">SP_TOKEN</span>
+
<span class="config-label">SP TOKEN</span>
<input
type="password"
placeholder="enter_simply_plural_token"
···
</div>
<div class="config-note">
<span class="note-text">
-
token requires only read permissions
-
</span>
-
</div>
-
</div>
-
<div class="config-card">
-
<div class="config-row">
-
<span class="config-label">FRONTER_NAME</span>
-
<input
-
type="text"
-
placeholder="enter_identifier"
-
oninput={updateFronter}
-
bind:value={fronterName}
-
class="config-input"
-
class:has-value={fronterName}
-
/>
-
</div>
-
<div class="config-note">
-
<span class="note-text">
-
overrides Simply Plural fronters when set
+
when set, pulls fronters from Simply Plural (token
+
only requires read permissions)
</span>
</div>
</div>
+
<FronterList
+
bind:fronters={pkFronters}
+
onUpdate={updatePkFronters}
+
label="PK FRONTERS"
+
placeholder="enter_member_ids"
+
note="PluralKit member IDs, overrides SP fronters"
+
fetchNames={true}
+
/>
+
<FronterList
+
bind:fronters
+
onUpdate={updateFronters}
+
label="FRONTERS"
+
placeholder="enter_fronter_names"
+
note="just names, overrides SP & PK fronters"
+
/>
</section>
</div>
+72 -38
src/lib/utils.ts
···
import { getAtprotoHandle, getPdsEndpoint } from "@atcute/identity";
export type Fronter = {
-
memberUris: MemberUri[];
-
names: string[];
+
members: {
+
uri?: MemberUri;
+
name: string;
+
}[];
handle: Handle | null;
did: AtprotoDid;
};
-
const fronterSchema = v.record(
+
export const fronterSchema = v.record(
v.string(),
v.object({
$type: v.literal("systems.gaze.atfronter.fronter"),
subject: v.resourceUriString(),
-
names: v.array(v.string()),
-
members: v.array(v.genericUriString()), // identifier(s) for pk or sp or etc. (maybe member record in the future?)
+
members: v.array(
+
v.object({
+
name: v.string(),
+
uri: v.optional(v.genericUriString()), // identifier(s) for pk or sp or etc. (maybe member record in the future?)
+
}),
+
),
}),
);
+
export type FronterSchema = InferOutput<typeof fronterSchema>;
-
type MemberUri =
+
export type MemberUri =
| { type: "at"; recordUri: ResourceUri }
-
| { type: "pk"; systemId: string; memberId: string }
+
| { type: "pk"; memberId: string }
| { type: "sp"; systemId: string; memberId: string };
export const parseMemberId = (memberId: GenericUri): MemberUri => {
···
switch (uri.protocol) {
case "pk:": {
const split = uri.pathname.split("/").slice(1);
-
return { type: "pk", systemId: split[0], memberId: split[1] };
+
return { type: "pk", memberId: split[0] };
}
case "sp:": {
const split = uri.pathname.split("/").slice(1);
···
export const memberUriString = (memberUri: MemberUri): GenericUri => {
switch (memberUri.type) {
case "pk": {
-
return `pk://api.pluralkit.com/${memberUri.systemId}/${memberUri.memberId}`;
+
return `pk://api.pluralkit.me/${memberUri.memberId}`;
}
case "sp": {
return `sp://api.apparyllis.com/${memberUri.systemId}/${memberUri.memberId}`;
···
}
}
};
+
export const getMemberPublicUri = (memberUri: MemberUri) => {
+
switch (memberUri.type) {
+
case "pk": {
+
return `https://dash.pluralkit.me/profile/m/${memberUri.memberId}`;
+
}
+
case "sp": {
+
return null;
+
}
+
case "at": {
+
return `https://pdsls.dev/${memberUri.recordUri}`;
+
}
+
}
+
};
let memberCache = new Map<string, any>();
export const fetchMember = async (
memberUri: MemberUri,
): Promise<string | undefined> => {
+
const s = memberUriString(memberUri);
+
const cached = memberCache.get(s);
switch (memberUri.type) {
case "sp": {
-
const s = memberUriString(memberUri);
-
const cached = memberCache.get(s);
if (cached) return cached.content.name;
const token = await storage.getItem<string>("sync:sp_token");
if (!token) return;
···
memberCache.set(s, member);
return member.content.name;
}
+
case "pk": {
+
if (cached) return cached.name;
+
const resp = await fetch(
+
`https://api.pluralkit.me/v2/members/${memberUri.memberId}`,
+
);
+
if (!resp.ok) return;
+
const member = await resp.json();
+
memberCache.set(s, member);
+
return member.name;
+
}
}
};
-
export const getFronterNames = async (
-
name: string[],
-
memberUris: MemberUri[],
-
) => {
-
let fronterNames = name;
-
if (memberUris.length > 0) {
-
fronterNames = (
-
await Promise.allSettled(memberUris.map((m) => fetchMember(m)))
-
)
-
.filter((p) => p.status === "fulfilled")
-
.flatMap((p) => p.value ?? []);
-
}
-
return fronterNames;
+
export const getFronterNames = async (members: (string | MemberUri)[]) => {
+
const promises = await Promise.allSettled(
+
members.map(async (m): Promise<Fronter["members"][0] | null> => {
+
if (typeof m === "string")
+
return Promise.resolve({ uri: undefined, name: m });
+
const name = await fetchMember(m);
+
return name ? { uri: m, name } : null;
+
}),
+
);
+
return promises
+
.filter((p) => p.status === "fulfilled")
+
.flatMap((p) => p.value ?? []);
};
const handleResolver = new CompositeHandleResolver({
···
const maybeTyped = safeParse(fronterSchema, maybeRecord.data.value);
if (!maybeTyped.ok) return err(maybeTyped.message);
-
let memberUris, fronterNames;
+
let members: Fronter["members"];
try {
-
memberUris = maybeTyped.value.members.map((m) => parseMemberId(m));
-
// fronterNames = await getFronterNames(maybeTyped.value.names, memberUris);
-
fronterNames = maybeTyped.value.names;
+
members = maybeTyped.value.members.map((m) => ({
+
name: m.name,
+
uri: m.uri ? parseMemberId(m.uri) : undefined,
+
}));
} catch (error) {
return err(`error fetching fronter names: ${error}`);
}
return ok({
-
memberUris,
-
names: fronterNames,
+
members,
handle,
did,
});
};
export const putFronter = async (
-
record: Omit<InferOutput<typeof fronterSchema>, "$type">,
+
subject: FronterSchema["subject"],
+
members: (string | MemberUri)[],
authToken: string,
): Promise<Result<Fronter, string>> => {
-
const parsedRecordUri = parseResourceUri(record.subject);
+
const parsedRecordUri = parseResourceUri(subject);
if (!parsedRecordUri.ok) return err(parsedRecordUri.error);
const { repo, collection, rkey } = parsedRecordUri.value;
···
// make client
const atpClient = await getAtpClient(did);
-
let memberUris, fronterNames;
+
let filteredMembers: Fronter["members"];
try {
-
memberUris = record.members.map((m) => parseMemberId(m));
-
fronterNames = await getFronterNames(record.names, memberUris);
+
filteredMembers = await getFronterNames(members);
} catch (error) {
return err(`error fetching fronter names: ${error}`);
}
···
repo: did,
collection: fronterSchema.object.shape.$type.expected,
rkey: `${collection}_${rkey}`,
-
record: { ...record, names: fronterNames },
+
record: {
+
subject,
+
members: filteredMembers.map((member) => ({
+
name: member.name,
+
uri: member.uri ? memberUriString(member.uri) : undefined,
+
})),
+
},
validate: false,
},
headers: { authorization: `Bearer ${authToken}` },
···
return ok({
did,
handle,
-
names: fronterNames,
-
memberUris,
+
members: filteredMembers,
});
};