tangled
alpha
login
or
join now
whey.party
/
red-dwarf
82
fork
atom
an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
82
fork
atom
overview
issues
25
pulls
pipelines
useModeration and author badges
whey.party
1 month ago
36d82759
3e16834d
+928
-99
12 changed files
expand all
collapse all
unified
split
src
api
moderation.ts
components
ModerationBatcher.tsx
ModerationInitializer.tsx
UniversalPostRenderer.tsx
hooks
useLabelInfo.ts
useModeration.ts
routes
__root.tsx
moderation.tsx
profile.$did
index.tsx
state
moderationAtoms.ts
types
moderation.ts
utils
useQuery.ts
+35
src/api/moderation.ts
···
1
1
+
import type { QueryLabelsResponse } from "~/types/moderation";
2
2
+
3
3
+
export const fetchLabelsBatch = async (
4
4
+
serviceUrl: string,
5
5
+
uris: string[],
6
6
+
): Promise<QueryLabelsResponse> => {
7
7
+
const url = new URL(`${serviceUrl}/xrpc/com.atproto.label.queryLabels`);
8
8
+
uris.forEach((uri) => url.searchParams.append("uriPatterns", uri));
9
9
+
10
10
+
// 1. Setup Timeout (5 seconds)
11
11
+
const controller = new AbortController();
12
12
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
13
13
+
14
14
+
try {
15
15
+
const response = await fetch(url.toString(), {
16
16
+
signal: controller.signal,
17
17
+
});
18
18
+
19
19
+
if (!response.ok) {
20
20
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
21
21
+
}
22
22
+
23
23
+
const data = await response.json();
24
24
+
return data as QueryLabelsResponse;
25
25
+
} catch (error: any) {
26
26
+
if (error.name === 'AbortError') {
27
27
+
console.error(`[fetchLabelsBatch] Timeout querying ${serviceUrl}`);
28
28
+
} else {
29
29
+
console.error(`[fetchLabelsBatch] Error querying ${serviceUrl}:`, error);
30
30
+
}
31
31
+
throw error;
32
32
+
} finally {
33
33
+
clearTimeout(timeoutId);
34
34
+
}
35
35
+
};
+166
src/components/ModerationBatcher.tsx
···
1
1
+
import { useAtom, useAtomValue } from "jotai";
2
2
+
import { useEffect, useRef } from "react";
3
3
+
4
4
+
import { fetchLabelsBatch } from "~/api/moderation";
5
5
+
import {
6
6
+
CACHE_TIMEOUT_MS,
7
7
+
labelerConfigAtom,
8
8
+
moderationCacheAtom,
9
9
+
pendingUriQueueAtom,
10
10
+
processingUriSetAtom,
11
11
+
} from "~/state/moderationAtoms";
12
12
+
13
13
+
const BATCH_CHUNK_SIZE = 25;
14
14
+
15
15
+
export const ModerationBatcher = () => {
16
16
+
const [queue, setQueue] = useAtom(pendingUriQueueAtom);
17
17
+
const [processingSet, setProcessingSet] = useAtom(processingUriSetAtom);
18
18
+
const [cache, setCache] = useAtom(moderationCacheAtom);
19
19
+
const labelers = useAtomValue(labelerConfigAtom);
20
20
+
21
21
+
const stateRef = useRef({ queue, processingSet, cache, labelers });
22
22
+
useEffect(() => {
23
23
+
stateRef.current = { queue, processingSet, cache, labelers };
24
24
+
}, [queue, processingSet, cache, labelers]);
25
25
+
26
26
+
useEffect(() => {
27
27
+
const interval = setInterval(async () => {
28
28
+
const {
29
29
+
queue: currentQueue,
30
30
+
processingSet: currentProcessing,
31
31
+
cache: currentCache,
32
32
+
labelers: currentLabelers,
33
33
+
} = stateRef.current;
34
34
+
35
35
+
if (currentQueue.size === 0 || currentLabelers.length === 0) return;
36
36
+
37
37
+
const now = Date.now();
38
38
+
39
39
+
// 1. Identify stale items
40
40
+
const batchUris = Array.from(currentQueue).filter((uri) => {
41
41
+
const entry = currentCache.get(uri);
42
42
+
const isStale = entry ? now - entry.timestamp > CACHE_TIMEOUT_MS : true;
43
43
+
return !currentProcessing.has(uri) && isStale;
44
44
+
});
45
45
+
46
46
+
if (batchUris.length === 0) return;
47
47
+
48
48
+
console.log(`[Batcher] Processing ${batchUris.length} URIs...`);
49
49
+
50
50
+
// 2. Lock items
51
51
+
setProcessingSet((prev) => {
52
52
+
const next = new Set(prev);
53
53
+
batchUris.forEach((u) => next.add(u));
54
54
+
return next;
55
55
+
});
56
56
+
setQueue((prev) => {
57
57
+
const next = new Set(prev);
58
58
+
batchUris.forEach((u) => next.delete(u));
59
59
+
return next;
60
60
+
});
61
61
+
62
62
+
// 3. Process chunks
63
63
+
const chunks = [];
64
64
+
for (let i = 0; i < batchUris.length; i += BATCH_CHUNK_SIZE) {
65
65
+
chunks.push(batchUris.slice(i, i + BATCH_CHUNK_SIZE));
66
66
+
}
67
67
+
68
68
+
for (const chunk of chunks) {
69
69
+
try {
70
70
+
const results = await Promise.allSettled(
71
71
+
currentLabelers.map((l) => fetchLabelsBatch(l.url, chunk)),
72
72
+
);
73
73
+
74
74
+
setCache((prevCache) => {
75
75
+
const nextCache = new Map(prevCache);
76
76
+
const updateTime = Date.now();
77
77
+
78
78
+
// A. Initialize requested URIs (to remove loading state)
79
79
+
chunk.forEach((uri) => {
80
80
+
if (!nextCache.has(uri) || nextCache.get(uri)!.timestamp < updateTime) {
81
81
+
nextCache.set(uri, { labels: [], timestamp: updateTime });
82
82
+
}
83
83
+
});
84
84
+
85
85
+
// B. Process Results
86
86
+
results.forEach((res, index) => {
87
87
+
if (res.status === "fulfilled") {
88
88
+
const labeler = currentLabelers[index];
89
89
+
const rawLabels = res.value.labels || [];
90
90
+
91
91
+
// --- REDUCTION LOGIC START ---
92
92
+
93
93
+
// 1. Group by URI
94
94
+
const labelsByUri = new Map<string, typeof rawLabels>();
95
95
+
rawLabels.forEach((l) => {
96
96
+
if (!labelsByUri.has(l.uri)) labelsByUri.set(l.uri, []);
97
97
+
labelsByUri.get(l.uri)!.push(l);
98
98
+
});
99
99
+
100
100
+
// 2. Process each URI's history
101
101
+
labelsByUri.forEach((labels, uri) => {
102
102
+
// Only process if this URI is actually in our cache/interest
103
103
+
if (!nextCache.has(uri)) return;
104
104
+
const cacheEntry = nextCache.get(uri)!;
105
105
+
106
106
+
// 3. Find latest state per (Source + Value)
107
107
+
// Key: "did:plc:xyz::porn" -> Latest Label Object
108
108
+
const latestState = new Map<string, typeof rawLabels[0]>();
109
109
+
110
110
+
labels.forEach((l) => {
111
111
+
const key = `${l.src}::${l.val}`;
112
112
+
const existing = latestState.get(key);
113
113
+
114
114
+
const currentCts = new Date(l.cts).getTime();
115
115
+
const existingCts = existing ? new Date(existing.cts).getTime() : 0;
116
116
+
117
117
+
if (!existing || currentCts > existingCts) {
118
118
+
latestState.set(key, l);
119
119
+
}
120
120
+
});
121
121
+
122
122
+
// 4. Push only active (non-negated) labels
123
123
+
for (const activeLabel of latestState.values()) {
124
124
+
if (activeLabel.neg) continue; // Skip deleted labels
125
125
+
126
126
+
// Resolve preference from the Labeler Config (our subscription)
127
127
+
// Note: We attribute the label to the 'labeler.did' (the service we subscribed to)
128
128
+
// even if the signer (src) is different, because prefs are attached to the service.
129
129
+
const resolvedPref =
130
130
+
labeler.supportedLabels?.[activeLabel.val] || "ignore";
131
131
+
132
132
+
cacheEntry.labels.push({
133
133
+
sourceDid: labeler.did,
134
134
+
val: activeLabel.val,
135
135
+
cts: activeLabel.cts,
136
136
+
preference: resolvedPref,
137
137
+
});
138
138
+
}
139
139
+
});
140
140
+
// --- REDUCTION LOGIC END ---
141
141
+
142
142
+
} else {
143
143
+
console.error(`[Batcher] Labeler ${currentLabelers[index].url} failed:`, res.reason);
144
144
+
}
145
145
+
});
146
146
+
147
147
+
return nextCache;
148
148
+
});
149
149
+
} catch (e) {
150
150
+
console.error("[Batcher] Chunk failed", e);
151
151
+
}
152
152
+
}
153
153
+
154
154
+
// 5. Release Lock
155
155
+
setProcessingSet((prev) => {
156
156
+
const next = new Set(prev);
157
157
+
batchUris.forEach((u) => next.delete(u));
158
158
+
return next;
159
159
+
});
160
160
+
}, 2000);
161
161
+
162
162
+
return () => clearInterval(interval);
163
163
+
}, []);
164
164
+
165
165
+
return null;
166
166
+
};
+164
src/components/ModerationInitializer.tsx
···
1
1
+
import { useQueries } from "@tanstack/react-query";
2
2
+
import { useSetAtom } from "jotai";
3
3
+
import { useEffect } from "react";
4
4
+
5
5
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
6
+
import { labelerConfigAtom } from "~/state/moderationAtoms";
7
7
+
import type { LabelerDefinition, LabelPreference, LabelValueDefinition } from "~/types/moderation";
8
8
+
import { useQueryIdentity } from "~/utils/useQuery";
9
9
+
import { useQueryPreferences } from "~/utils/useQuery";
10
10
+
11
11
+
// Manual DID document resolution
12
12
+
const fetchDidDocument = async (did: string): Promise<any> => {
13
13
+
if (did.startsWith("did:plc:")) {
14
14
+
// For PLC DIDs, fetch from plc.directory
15
15
+
const response = await fetch(
16
16
+
`https://plc.directory/${encodeURIComponent(did)}`,
17
17
+
);
18
18
+
if (!response.ok)
19
19
+
throw new Error(`Failed to fetch PLC DID document for ${did}`);
20
20
+
return response.json();
21
21
+
} else if (did.startsWith("did:web:")) {
22
22
+
// For web DIDs, fetch from well-known
23
23
+
const handle = did.replace("did:web:", "");
24
24
+
const url = `https://${handle}/.well-known/did.json`;
25
25
+
const response = await fetch(url);
26
26
+
if (!response.ok)
27
27
+
throw new Error(
28
28
+
`Failed to fetch web DID document for ${did} (CORS or not found)`,
29
29
+
);
30
30
+
return response.json();
31
31
+
} else {
32
32
+
throw new Error(`Unsupported DID type: ${did}`);
33
33
+
}
34
34
+
};
35
35
+
36
36
+
export const ModerationInitializer = () => {
37
37
+
const { agent } = useAuth();
38
38
+
const setLabelerConfig = useSetAtom(labelerConfigAtom);
39
39
+
40
40
+
// 1. Get User Identity to get PDS URL
41
41
+
const { data: identity } = useQueryIdentity(agent?.did);
42
42
+
43
43
+
// 2. Get User Preferences (Global: "porn" -> "hide")
44
44
+
const { data: prefs } = useQueryPreferences({
45
45
+
agent: agent ?? undefined,
46
46
+
pdsUrl: identity?.pds,
47
47
+
});
48
48
+
49
49
+
// 3. Identify Labeler DIDs from prefs
50
50
+
const labelerDids =
51
51
+
prefs?.preferences
52
52
+
?.find((pref: any) => pref.$type === "app.bsky.actor.defs#labelersPref")
53
53
+
?.labelers?.map((l: any) => l.did) ?? [];
54
54
+
55
55
+
// 4. Parallel fetch all Labeler DID Documents and Service Records
56
56
+
const labelerDidDocQueries = useQueries({
57
57
+
queries: labelerDids.map((did: string) => ({
58
58
+
queryKey: ["labelerDidDoc", did],
59
59
+
queryFn: () => fetchDidDocument(did),
60
60
+
staleTime: 5 * 60 * 1000, // 5 minutes
61
61
+
retry: 1, // Only retry once for DID docs
62
62
+
})),
63
63
+
});
64
64
+
65
65
+
const labelerServiceQueries = useQueries({
66
66
+
queries: labelerDids.map((did: string) => ({
67
67
+
queryKey: ["labelerService", did],
68
68
+
queryFn: async () => {
69
69
+
if (!identity?.pds) throw new Error("No PDS URL");
70
70
+
const response = await fetch(
71
71
+
`${identity.pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent("app.bsky.labeler.service")}&rkey=self`,
72
72
+
);
73
73
+
if (!response.ok) throw new Error("Failed to fetch labeler service");
74
74
+
return response.json();
75
75
+
},
76
76
+
enabled: !!identity?.pds && !!agent,
77
77
+
staleTime: 5 * 60 * 1000, // 5 minutes
78
78
+
})),
79
79
+
});
80
80
+
81
81
+
useEffect(() => {
82
82
+
if (
83
83
+
!prefs ||
84
84
+
labelerDidDocQueries.some((q) => q.isLoading) ||
85
85
+
labelerDidDocQueries.some((q) => q.isFetching) ||
86
86
+
labelerServiceQueries.some((q) => q.isLoading) ||
87
87
+
labelerServiceQueries.some((q) => q.isFetching)
88
88
+
)
89
89
+
return;
90
90
+
91
91
+
// Extract content label preferences
92
92
+
const contentLabelPrefs =
93
93
+
prefs.preferences?.filter(
94
94
+
(pref: any) => pref.$type === "app.bsky.actor.defs#contentLabelPref",
95
95
+
) ?? [];
96
96
+
97
97
+
const globalPrefs: Record<string, LabelPreference> = {};
98
98
+
contentLabelPrefs.forEach((pref: any) => {
99
99
+
globalPrefs[pref.label] = pref.visibility as LabelPreference;
100
100
+
});
101
101
+
102
102
+
const definitions: LabelerDefinition[] = labelerDids
103
103
+
.map((did: string, index: number) => {
104
104
+
const didDocQuery = labelerDidDocQueries[index];
105
105
+
const serviceQuery = labelerServiceQueries[index];
106
106
+
107
107
+
if (!didDocQuery.data || !serviceQuery.data) return null;
108
108
+
109
109
+
// Extract service endpoint from DID document
110
110
+
const didDoc = didDocQuery.data as any;
111
111
+
const atprotoLabelerService = didDoc?.service?.find(
112
112
+
(s: any) => s.id === "#atproto_labeler",
113
113
+
);
114
114
+
115
115
+
const record = (serviceQuery.data as any).value; // The raw ATProto record
116
116
+
117
117
+
// 1. Create the Metadata Map
118
118
+
const labelDefs: Record<string, LabelValueDefinition> = {};
119
119
+
120
120
+
if (record.policies.labelValueDefinitions) {
121
121
+
record.policies.labelValueDefinitions.forEach((def: any) => {
122
122
+
labelDefs[def.identifier] = {
123
123
+
identifier: def.identifier,
124
124
+
severity: def.severity,
125
125
+
blurs: def.blurs,
126
126
+
adultOnly: def.adultOnly,
127
127
+
defaultSetting: def.defaultSetting,
128
128
+
locales: def.locales || [] // <--- Capture the locales array
129
129
+
};
130
130
+
});
131
131
+
}
132
132
+
133
133
+
// RESOLUTION LOGIC:
134
134
+
// Map record.policies.labelValueDefinitions to a lookup map.
135
135
+
// Priority: User Global Pref > Labeler Default > 'ignore'
136
136
+
const supportedLabels: Record<string, LabelPreference> = {};
137
137
+
138
138
+
record.policies?.labelValues?.forEach((val: string) => {
139
139
+
// Does user have a global override for this string?
140
140
+
const globalPref = globalPrefs[val];
141
141
+
// Or use labeler default
142
142
+
const defaultPref =
143
143
+
record.policies?.labelValueDefinitions?.find(
144
144
+
(d: any) => d.identifier === val,
145
145
+
)?.defaultSetting || "ignore";
146
146
+
147
147
+
supportedLabels[val] = (globalPref || defaultPref) as LabelPreference;
148
148
+
});
149
149
+
150
150
+
return {
151
151
+
did: did,
152
152
+
url: atprotoLabelerService?.serviceEndpoint || record.serviceEndpoint,
153
153
+
isDefault: false, // logic to determine if this is a default Bluesky labeler
154
154
+
supportedLabels,
155
155
+
labelDefs,
156
156
+
};
157
157
+
})
158
158
+
.filter(Boolean) as LabelerDefinition[];
159
159
+
160
160
+
setLabelerConfig(definitions);
161
161
+
}, [prefs, labelerDidDocQueries, labelerServiceQueries, setLabelerConfig, identity?.pds, labelerDids]);
162
162
+
163
163
+
return null; // Headless component
164
164
+
};
+113
-28
src/components/UniversalPostRenderer.tsx
···
16
16
import { useEffect, useState } from "react";
17
17
18
18
import defaultpfp from "~/../public/defaultpfp.png";
19
19
+
import { useLabelInfo } from "~/hooks/useLabelInfo";
20
20
+
import { useModeration } from "~/hooks/useModeration";
19
21
import { useAuth } from "~/providers/UnifiedAuthProvider";
20
22
import { renderSnack } from "~/routes/__root";
23
23
+
//import { ModerationInner } from "~/routes/moderation";
21
24
import {
22
25
FollowButton,
23
26
Mutual,
24
27
} from "~/routes/profile.$did";
25
28
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
29
29
+
import type { ContentLabel } from "~/types/moderation";
26
30
import {
27
31
composerAtom,
28
32
constellationURLAtom,
···
133
137
setReplies(
134
138
links
135
139
? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]
136
136
-
?.records || 0
140
140
+
?.records || 0
137
141
: null,
138
142
);
139
143
}, [links]);
···
168
172
169
173
const replyAturis = repliesData
170
174
? repliesData.pages.flatMap((page) =>
171
171
-
page
172
172
-
? page.linking_records.map((record) => {
173
173
-
const aturi = `at://${record.did}/${record.collection}/${record.rkey}`;
174
174
-
return aturi;
175
175
-
})
176
176
-
: [],
177
177
-
)
175
175
+
page
176
176
+
? page.linking_records.map((record) => {
177
177
+
const aturi = `at://${record.did}/${record.collection}/${record.rkey}`;
178
178
+
return aturi;
179
179
+
})
180
180
+
: [],
181
181
+
)
178
182
: [];
179
183
180
184
const { oldestOpsReply, oldestOpsReplyElseNewestNonOpsReply } = (() => {
···
390
394
const isQuotewithImages =
391
395
isquotewithmedia &&
392
396
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
393
393
-
"app.bsky.embed.images";
397
397
+
"app.bsky.embed.images";
394
398
const isQuotewithVideo =
395
399
isquotewithmedia &&
396
400
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
397
397
-
"app.bsky.embed.video";
401
401
+
"app.bsky.embed.video";
398
402
399
403
const hasMedia =
400
404
hasEmbed &&
···
573
577
maxReplies?: number;
574
578
constellationLinks?: any;
575
579
}) {
580
580
+
const { isLoading: authorModLoading, labels: authorLabels } = useModeration(
581
581
+
post.author.did,
582
582
+
);
583
583
+
const hideAuthorLabels = authorLabels.filter(
584
584
+
label => label.preference === 'hide'
585
585
+
);
586
586
+
const warnAuthorLabels = authorLabels.filter(
587
587
+
label => label.preference === 'warn'
588
588
+
);
589
589
+
590
590
+
576
591
const parsed = new AtUri(post.uri);
577
592
const navigate = useNavigate();
578
593
const [hasRetweeted, setHasRetweeted] = useState<boolean>(
···
631
646
632
647
const tags = unfediwafrnTags
633
648
? unfediwafrnTags
634
634
-
.split("\n")
635
635
-
.map((t) => t.trim())
636
636
-
.filter(Boolean)
649
649
+
.split("\n")
650
650
+
.map((t) => t.trim())
651
651
+
.filter(Boolean)
637
652
: undefined;
638
653
639
654
const links = tags
640
655
? tags
641
641
-
.map((tag) => {
642
642
-
const encoded = encodeURIComponent(tag);
643
643
-
return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`;
644
644
-
})
645
645
-
.join("<br>")
656
656
+
.map((tag) => {
657
657
+
const encoded = encodeURIComponent(tag);
658
658
+
return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`;
659
659
+
})
660
660
+
.join("<br>")
646
661
: "";
647
662
648
663
const unfediwafrn = unfediwafrnPartial
···
654
669
(showWafrnText ? unfediwafrn : undefined);
655
670
656
671
const isMainItem = false;
657
657
-
const setMainItem = (any: any) => {};
672
672
+
const setMainItem = (any: any) => { };
673
673
+
674
674
+
if (hideAuthorLabels.length > 0 ) {
675
675
+
return null
676
676
+
}
658
677
659
678
return (
660
679
<div ref={ref} style={style} data-index={dataIndexPropPass}>
···
666
685
: setMainItem
667
686
? onPostClick
668
687
? (e) => {
669
669
-
setMainItem({ post: post });
670
670
-
onPostClick(e);
671
671
-
}
688
688
+
setMainItem({ post: post });
689
689
+
onPostClick(e);
690
690
+
}
672
691
: () => {
673
673
-
setMainItem({ post: post });
674
674
-
}
692
692
+
setMainItem({ post: post });
693
693
+
}
675
694
: undefined
676
695
}
677
696
style={{
···
897
916
</span>
898
917
</div>
899
918
</div>
919
919
+
{/* <ModerationInner subject={post.author.did} /> */}
920
920
+
{authorModLoading ?
921
921
+
(
922
922
+
<div className="flex flex-wrap flex-row gap-1 my-1">
923
923
+
<div
924
924
+
className="text-xs bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded-full flex flex-row items-center gap-1"
925
925
+
>
926
926
+
{/* <img
927
927
+
src={resolvedpfp || defaultpfp}
928
928
+
alt="avatar"
929
929
+
className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
930
930
+
style={{
931
931
+
width: 12,
932
932
+
height: 12,
933
933
+
}}
934
934
+
/> */}
935
935
+
<span className="font-medium">loading badges...</span>
936
936
+
</div>
937
937
+
</div>
938
938
+
)
939
939
+
:
940
940
+
(
941
941
+
<div className="flex flex-wrap flex-row gap-1 my-1">
942
942
+
{warnAuthorLabels.map((label, index) => (
943
943
+
<SmallAuthorLabelBadge label={label} key={label.cts + label.sourceDid + label.val} />
944
944
+
))}
945
945
+
</div>
946
946
+
)
947
947
+
}
900
948
{!!feedviewpostreplyhandle && (
901
949
<div
902
950
style={{
···
919
967
<IconMdiReply /> Reply to @{feedviewpostreplyhandle}
920
968
</div>
921
969
)}
970
970
+
{/* <ModerationInner subject={post.uri} /> */}
922
971
<div
923
972
style={{
924
973
fontSize: 16,
···
1084
1133
try {
1085
1134
await navigator.clipboard.writeText(
1086
1135
"https://bsky.app" +
1087
1087
-
"/profile/" +
1088
1088
-
post.author.handle +
1089
1089
-
"/post/" +
1090
1090
-
post.uri.split("/").pop(),
1136
1136
+
"/profile/" +
1137
1137
+
post.author.handle +
1138
1138
+
"/post/" +
1139
1139
+
post.uri.split("/").pop(),
1091
1140
);
1092
1141
renderSnack({
1093
1142
title: "Copied to clipboard!",
···
1136
1185
Feed = "Feed",
1137
1186
FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia",
1138
1187
}
1188
1188
+
1189
1189
+
1190
1190
+
export function SmallAuthorLabelBadge({ label, large }: { label: ContentLabel, large?: boolean }) {
1191
1191
+
/*
1192
1192
+
-{" "}
1193
1193
+
{label.preference} (from {label.sourceDid})
1194
1194
+
*/
1195
1195
+
const { getLabelInfo } = useLabelInfo();
1196
1196
+
const info = getLabelInfo(label.sourceDid, label.val);
1197
1197
+
1198
1198
+
const [imgcdn] = useAtom(imgCDNAtom);
1199
1199
+
1200
1200
+
1201
1201
+
const { data: opProfile } = useQueryProfile(
1202
1202
+
`at://${label.sourceDid}/app.bsky.actor.profile/self`,
1203
1203
+
);
1204
1204
+
1205
1205
+
const resolvedpfp = getAvatarUrl(opProfile, label.sourceDid, imgcdn)
1206
1206
+
1207
1207
+
return (
1208
1208
+
<div
1209
1209
+
className={`text-xs bg-gray-100 dark:bg-gray-800 ${large ? "px-2 py-1" : "px-1 py-0.5"} rounded-full flex flex-row items-center gap-1`}
1210
1210
+
>
1211
1211
+
<img
1212
1212
+
src={resolvedpfp || defaultpfp}
1213
1213
+
alt="avatar"
1214
1214
+
className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
1215
1215
+
style={{
1216
1216
+
width: 12,
1217
1217
+
height: 12,
1218
1218
+
}}
1219
1219
+
/>
1220
1220
+
<span className="font-medium">{info.name || label.val}</span>
1221
1221
+
</div>
1222
1222
+
)
1223
1223
+
}
+43
src/hooks/useLabelInfo.ts
···
1
1
+
import { useAtomValue } from "jotai";
2
2
+
import { useCallback } from "react";
3
3
+
4
4
+
import { labelerConfigAtom } from "~/state/moderationAtoms";
5
5
+
6
6
+
export const useLabelInfo = () => {
7
7
+
const labelers = useAtomValue(labelerConfigAtom);
8
8
+
9
9
+
const getLabelInfo = useCallback((sourceDid: string, val: string) => {
10
10
+
// 1. Find the labeler config
11
11
+
const labeler = labelers.find((l) => l.did === sourceDid);
12
12
+
13
13
+
// Fallback if labeler or definition is missing
14
14
+
const fallback = {
15
15
+
name: val,
16
16
+
description: "",
17
17
+
isAdult: false
18
18
+
};
19
19
+
20
20
+
if (!labeler) return fallback;
21
21
+
22
22
+
// 2. Look up the definition
23
23
+
const def = labeler.labelDefs[val];
24
24
+
if (!def) return fallback;
25
25
+
26
26
+
// 3. Resolve Locale (Match browser lang -> 'en' -> first available)
27
27
+
// You can replace 'en' with a proper i18n atom if you have one
28
28
+
const userLang = "en";
29
29
+
const locale = def.locales.find((l) => l.lang === userLang)
30
30
+
|| def.locales.find((l) => l.lang === "en")
31
31
+
|| def.locales[0];
32
32
+
33
33
+
return {
34
34
+
name: locale?.name || val,
35
35
+
description: locale?.description || "",
36
36
+
isAdult: def.adultOnly,
37
37
+
severity: def.severity,
38
38
+
blurs: def.blurs
39
39
+
};
40
40
+
}, [labelers]);
41
41
+
42
42
+
return { getLabelInfo };
43
43
+
};
+51
src/hooks/useModeration.ts
···
1
1
+
import { useAtom, useSetAtom } from "jotai";
2
2
+
import { selectAtom } from "jotai/utils";
3
3
+
import { useEffect, useMemo } from "react";
4
4
+
5
5
+
import {
6
6
+
CACHE_TIMEOUT_MS,
7
7
+
moderationCacheAtom,
8
8
+
pendingUriQueueAtom,
9
9
+
processingUriSetAtom,
10
10
+
} from "~/state/moderationAtoms";
11
11
+
12
12
+
export const useModeration = (uri: string) => {
13
13
+
const setQueue = useSetAtom(pendingUriQueueAtom);
14
14
+
15
15
+
// 1. Select ONLY this URI's cache entry
16
16
+
const entryAtom = useMemo(
17
17
+
() => selectAtom(moderationCacheAtom, (cache) => cache.get(uri)),
18
18
+
[uri],
19
19
+
);
20
20
+
const [cachedEntry] = useAtom(entryAtom);
21
21
+
22
22
+
// 2. Select ONLY this URI's processing state
23
23
+
const isProcessingAtom = useMemo(
24
24
+
() => selectAtom(processingUriSetAtom, (set) => set.has(uri)),
25
25
+
[uri],
26
26
+
);
27
27
+
const [isProcessing] = useAtom(isProcessingAtom);
28
28
+
29
29
+
const now = Date.now();
30
30
+
const exists = cachedEntry !== undefined;
31
31
+
const isStale = exists && now - cachedEntry.timestamp > CACHE_TIMEOUT_MS;
32
32
+
33
33
+
useEffect(() => {
34
34
+
// Stop if we have valid data or are currently working on it
35
35
+
if ((exists && !isStale) || isProcessing) return;
36
36
+
37
37
+
// Queue it
38
38
+
setQueue((prev) => {
39
39
+
if (prev.has(uri)) return prev;
40
40
+
const next = new Set(prev);
41
41
+
next.add(uri);
42
42
+
return next;
43
43
+
});
44
44
+
}, [uri, exists, isStale, isProcessing, setQueue]);
45
45
+
46
46
+
return {
47
47
+
// Show loading ONLY if we have absolutely no data (first load)
48
48
+
isLoading: !exists,
49
49
+
labels: cachedEntry?.labels || [],
50
50
+
};
51
51
+
};
+5
-2
src/routes/__root.tsx
···
23
23
import { Import } from "~/components/Import";
24
24
import Login from "~/components/Login";
25
25
import Logo from "~/components/LogoSvg";
26
26
+
import { ModerationBatcher } from "~/components/ModerationBatcher";
27
27
+
import { ModerationInitializer } from "~/components/ModerationInitializer";
26
28
import { NotFound } from "~/components/NotFound";
27
29
import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider";
28
30
import { PollMutationQueueProvider } from "~/providers/PollMutationQueueProvider";
···
85
87
<UnifiedAuthProvider>
86
88
<LikeMutationQueueProvider>
87
89
<PollMutationQueueProvider>
90
90
+
<ModerationInitializer />
91
91
+
<ModerationBatcher />
88
92
<RootDocument>
89
93
<KeepAliveProvider>
90
94
<AppToaster />
···
207
211
const location = useLocation();
208
212
const navigate = useNavigate();
209
213
const { agent } = useAuth();
214
214
+
const isNotifications = location.pathname.startsWith("/notifications");
210
215
const authed = !!agent?.did;
211
211
-
const isHome = location.pathname === "/";
212
212
-
const isNotifications = location.pathname.startsWith("/notifications");
213
216
const isProfile =
214
217
agent &&
215
218
(location.pathname === `/profile/${agent?.did}` ||
+63
-12
src/routes/moderation.tsx
···
13
13
import { Switch } from "radix-ui";
14
14
15
15
import { Header } from "~/components/Header";
16
16
+
import { useModeration } from "~/hooks/useModeration";
16
17
import { useAuth } from "~/providers/UnifiedAuthProvider";
17
18
import { quickAuthAtom } from "~/utils/atoms";
18
19
import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery";
···
28
29
function RouteComponent() {
29
30
const { agent } = useAuth();
30
31
31
31
-
const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
32
32
+
const [quickAuth] = useAtom(quickAuthAtom);
32
33
const isAuthRestoring = quickAuth ? status === "loading" : false;
33
34
34
35
const identityresultmaybe = useQueryIdentity(
35
35
-
!isAuthRestoring ? agent?.did : undefined
36
36
+
!isAuthRestoring ? agent?.did : undefined,
36
37
);
37
38
const identity = identityresultmaybe?.data;
38
39
···
43
44
const rawprefs = prefsresultmaybe?.data?.preferences as
44
45
| ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"]
45
46
| undefined;
46
46
-
47
47
-
//console.log(JSON.stringify(prefs, null, 2))
48
47
49
48
const parsedPref = parsePreferences(rawprefs);
50
49
···
96
95
<Switch.Root
97
96
id={`switch-${"hardcoded"}`}
98
97
checked={parsedPref?.adultContentEnabled}
99
99
-
onCheckedChange={(v) => {
98
98
+
onCheckedChange={() => {
100
99
renderSnack({
101
100
title: "Sorry... Modifying preferences is not implemented yet",
102
101
description: "You can use another app to change preferences",
···
108
107
<Switch.Thumb className="m3switch thumb " />
109
108
</Switch.Root>
110
109
</div>
110
110
+
111
111
+
<TestModeration subject="did:plc:q7suwaz53ztc4mbiqyygbn43" />
112
112
+
<TestModeration subject="did:plc:fpruhuo22xkm5o7ttr2ktxdo" />
113
113
+
<TestModeration subject="did:plc:6ayddqghxhciedbaofoxkcbs" />
114
114
+
<TestModeration subject="did:plc:za2ezszbzyqer7eylvtgapd5" />
115
115
+
<TestModeration subject="did:plc:ia76kvnndjutgedggx2ibrem" />
116
116
+
<TestModeration subject="did:plc:w2wbinubagmo4hlxx2ik5rrp" />
111
117
<div className="">
112
118
{Object.entries(parsedPref?.contentLabelPrefs ?? {}).map(
113
119
([label, visibility]) => (
···
133
139
value={visibility as "ignore" | "warn" | "hide"}
134
140
/>
135
141
</div>
136
136
-
)
142
142
+
),
137
143
)}
138
144
</div>
139
145
</div>
···
174
180
});
175
181
onChange?.(opt);
176
182
}}
177
177
-
className={`flex-1 px-3 py-1.5 rounded-full transition-colors ${
178
178
-
isActive
179
179
-
? "bg-gray-400 dark:bg-gray-600 text-white"
180
180
-
: "text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700"
181
181
-
}`}
183
183
+
className={`flex-1 px-3 py-1.5 rounded-full transition-colors ${isActive
184
184
+
? "bg-gray-400 dark:bg-gray-600 text-white"
185
185
+
: "text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700"
186
186
+
}`}
182
187
>
183
188
{" "}
184
189
{opt.charAt(0).toUpperCase() + opt.slice(1)}
···
206
211
}
207
212
208
213
export function parsePreferences(
209
209
-
prefs?: PrefItem[]
214
214
+
prefs?: PrefItem[],
210
215
): NormalizedPreferences | undefined {
211
216
if (!prefs) return undefined;
212
217
const normalized: NormalizedPreferences = {
···
267
272
268
273
return normalized;
269
274
}
275
275
+
276
276
+
277
277
+
export function TestModeration({ subject }: { subject: string }) {
278
278
+
return (
279
279
+
<>
280
280
+
{/* Test the moderation system */}
281
281
+
<div className="px-4 py-2 border-b">
282
282
+
<div className="flex flex-col">
283
283
+
<span className="text-md font-medium">Moderation System Test</span>
284
284
+
<span className="text-sm text-gray-500 dark:text-gray-400">
285
285
+
Testing useModeration hook with example content
286
286
+
</span>
287
287
+
<ModerationInner subject={subject} />
288
288
+
</div>
289
289
+
</div>
290
290
+
</>
291
291
+
)
292
292
+
293
293
+
}
294
294
+
295
295
+
export function ModerationInner({ subject }: { subject: string }) {
296
296
+
const { isLoading: moderationLoading, labels: testLabels } = useModeration(
297
297
+
subject,
298
298
+
);
299
299
+
300
300
+
return (<>{moderationLoading ? (
301
301
+
<span className="text-sm text-blue-500">
302
302
+
Loading moderation data...
303
303
+
</span>
304
304
+
) : (
305
305
+
<div className="mt-2">
306
306
+
<span className="text-sm">
307
307
+
Found {testLabels.length} labels for {subject}
308
308
+
</span>
309
309
+
{testLabels.map((label, index) => (
310
310
+
<div
311
311
+
key={index}
312
312
+
className="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded mt-1"
313
313
+
>
314
314
+
<span className="font-medium">{label.val}</span> -{" "}
315
315
+
{label.preference} (from {label.sourceDid})
316
316
+
</div>
317
317
+
))}
318
318
+
</div>
319
319
+
)}</>)
320
320
+
}
+59
-17
src/routes/profile.$did/index.tsx
···
13
13
useReusableTabScrollRestore,
14
14
} from "~/components/ReusableTabRoute";
15
15
import {
16
16
+
SmallAuthorLabelBadge,
16
17
UniversalPostRendererATURILoader,
17
18
} from "~/components/UniversalPostRenderer";
18
19
import { renderTextWithFacets } from "~/components/UtilityFunctions";
20
20
+
import { useModeration } from "~/hooks/useModeration";
19
21
import { useAuth } from "~/providers/UnifiedAuthProvider";
20
22
import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms";
21
23
import {
···
53
55
error: identityError,
54
56
} = useQueryIdentity(did);
55
57
58
58
+
59
59
+
const { isLoading: authorModLoading, labels: authorLabels } = useModeration(
60
60
+
did,
61
61
+
);
62
62
+
const hideAuthorLabels = authorLabels.filter(
63
63
+
label => label.preference === 'hide'
64
64
+
);
65
65
+
const warnAuthorLabels = authorLabels.filter(
66
66
+
label => label.preference === 'warn'
67
67
+
);
68
68
+
56
69
// i was gonna check the did doc but useQueryIdentity doesnt return that info (slingshot minidoc)
57
70
// so instead we should query the labeler profile
58
71
···
100
113
const resultwhateversure = useQueryConstellationLinksCountDistinctDids(
101
114
resolvedDid
102
115
? {
103
103
-
method: "/links/count/distinct-dids",
104
104
-
collection: "app.bsky.graph.follow",
105
105
-
target: resolvedDid,
106
106
-
path: ".subject",
107
107
-
}
116
116
+
method: "/links/count/distinct-dids",
117
117
+
collection: "app.bsky.graph.follow",
118
118
+
target: resolvedDid,
119
119
+
path: ".subject",
120
120
+
}
108
121
: undefined
109
122
);
110
123
···
221
234
<RichTextRenderer key={did} description={description} />
222
235
</div>
223
236
)}
237
237
+
{/* <ModerationInner subject={post.author.did} /> */}
238
238
+
{authorModLoading ?
239
239
+
(
240
240
+
<div className="flex flex-wrap flex-row gap-1">
241
241
+
<div
242
242
+
className="text-xs bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded-full flex flex-row items-center gap-1"
243
243
+
>
244
244
+
{/* <img
245
245
+
src={resolvedpfp || defaultpfp}
246
246
+
alt="avatar"
247
247
+
className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
248
248
+
style={{
249
249
+
width: 12,
250
250
+
height: 12,
251
251
+
}}
252
252
+
/> */}
253
253
+
<span className="font-medium">loading badges...</span>
254
254
+
</div>
255
255
+
</div>
256
256
+
)
257
257
+
:
258
258
+
(
259
259
+
<div className="flex flex-wrap flex-row gap-1">
260
260
+
{warnAuthorLabels.map((label, index) => (
261
261
+
<SmallAuthorLabelBadge label={label} key={label.cts + label.sourceDid + label.val} large />
262
262
+
))}
263
263
+
</div>
264
264
+
)
265
265
+
}
224
266
</div>
225
267
</div>
226
268
···
231
273
tabs={{
232
274
...(isLabeler
233
275
? {
234
234
-
Labels: <LabelsTab did={did} labelerRecord={labelerRecord} />,
235
235
-
}
276
276
+
Labels: <LabelsTab did={did} labelerRecord={labelerRecord} />,
277
277
+
}
236
278
: {}),
237
279
...{
238
280
Posts: <PostsTab did={did} />,
···
696
738
// @ts-expect-error overloads sucks
697
739
!listmode
698
740
? {
699
699
-
target: feed.uri,
700
700
-
method: "/links/count",
701
701
-
collection: "app.bsky.feed.like",
702
702
-
path: ".subject.uri",
703
703
-
}
741
741
+
target: feed.uri,
742
742
+
method: "/links/count",
743
743
+
collection: "app.bsky.feed.like",
744
744
+
path: ".subject.uri",
745
745
+
}
704
746
: undefined
705
747
);
706
748
···
1044
1086
const theyFollowYouRes = useGetOneToOneState(
1045
1087
agent?.did
1046
1088
? {
1047
1047
-
target: agent?.did,
1048
1048
-
user: identity?.did ?? targetdidorhandle,
1049
1049
-
collection: "app.bsky.graph.follow",
1050
1050
-
path: ".subject",
1051
1051
-
}
1089
1089
+
target: agent?.did,
1090
1090
+
user: identity?.did ?? targetdidorhandle,
1091
1091
+
collection: "app.bsky.graph.follow",
1092
1092
+
path: ".subject",
1093
1093
+
}
1052
1094
: undefined
1053
1095
);
1054
1096
+92
src/state/moderationAtoms.ts
···
1
1
+
import { atom } from "jotai";
2
2
+
import { atomWithStorage } from "jotai/utils";
3
3
+
4
4
+
import type { ContentLabel, LabelerDefinition } from "~/types/moderation";
5
5
+
6
6
+
// --- Configuration ---
7
7
+
export const CACHE_TIMEOUT_MS = 3600000; // 1 Hour
8
8
+
const MAX_CACHE_ENTRIES = 2000; // Limit to prevent localStorage quota issues
9
9
+
const STORAGE_KEY = "moderation-cache-v1";
10
10
+
11
11
+
// --- Types ---
12
12
+
type CacheEntry = { labels: ContentLabel[]; timestamp: number };
13
13
+
type CacheMap = Map<string, CacheEntry>;
14
14
+
15
15
+
// --- Custom Storage Implementation ---
16
16
+
// We cannot use createJSONStorage because it fails to serialize Maps.
17
17
+
// We must write the storage logic manually.
18
18
+
const mapStorage = {
19
19
+
getItem: (key: string, initialValue: CacheMap): CacheMap => {
20
20
+
if (typeof window === "undefined" || !window.localStorage) {
21
21
+
return initialValue;
22
22
+
}
23
23
+
24
24
+
try {
25
25
+
const item = localStorage.getItem(key);
26
26
+
if (!item) return initialValue;
27
27
+
28
28
+
const parsed = JSON.parse(item);
29
29
+
30
30
+
// Ensure it is an array (Map serialization format)
31
31
+
if (!Array.isArray(parsed)) return initialValue;
32
32
+
33
33
+
const now = Date.now();
34
34
+
const map = new Map<string, CacheEntry>();
35
35
+
36
36
+
parsed.forEach(([uri, data]) => {
37
37
+
// 1. STALENESS CHECK (On Load)
38
38
+
// Only load if younger than timeout
39
39
+
if (data && now - data.timestamp < CACHE_TIMEOUT_MS) {
40
40
+
map.set(uri, data);
41
41
+
}
42
42
+
});
43
43
+
44
44
+
console.log(`[Cache] Hydrated ${map.size} valid entries.`);
45
45
+
return map;
46
46
+
} catch (error) {
47
47
+
console.error("[Cache] Failed to load:", error);
48
48
+
return initialValue;
49
49
+
}
50
50
+
},
51
51
+
52
52
+
setItem: (key: string, value: CacheMap) => {
53
53
+
if (typeof window === "undefined" || !window.localStorage) return;
54
54
+
55
55
+
try {
56
56
+
let entries = Array.from(value.entries());
57
57
+
58
58
+
// 2. SAFETY CAP (On Save)
59
59
+
// If we have too many entries, keep only the newest ones
60
60
+
if (entries.length > MAX_CACHE_ENTRIES) {
61
61
+
// Sort by timestamp descending (newest first)
62
62
+
entries.sort((a, b) => b[1].timestamp - a[1].timestamp);
63
63
+
// Keep top N
64
64
+
entries = entries.slice(0, MAX_CACHE_ENTRIES);
65
65
+
}
66
66
+
67
67
+
// Convert Map -> Array -> JSON String
68
68
+
localStorage.setItem(key, JSON.stringify(entries));
69
69
+
} catch (error) {
70
70
+
console.error("[Cache] Failed to save:", error);
71
71
+
}
72
72
+
},
73
73
+
74
74
+
removeItem: (key: string) => {
75
75
+
if (typeof window !== "undefined" && window.localStorage) {
76
76
+
localStorage.removeItem(key);
77
77
+
}
78
78
+
},
79
79
+
};
80
80
+
81
81
+
// --- Atoms ---
82
82
+
83
83
+
export const labelerConfigAtom = atom<LabelerDefinition[]>([]);
84
84
+
85
85
+
export const moderationCacheAtom = atomWithStorage<CacheMap>(
86
86
+
STORAGE_KEY,
87
87
+
new Map(),
88
88
+
mapStorage // <--- Pass our custom object here
89
89
+
);
90
90
+
91
91
+
export const pendingUriQueueAtom = atom<Set<string>>(new Set<string>());
92
92
+
export const processingUriSetAtom = atom<Set<string>>(new Set<string>());
+61
src/types/moderation.ts
···
1
1
+
// AT Protocol moderation types
2
2
+
3
3
+
export type LabelPreference = "ignore" | "warn" | "hide";
4
4
+
5
5
+
export interface LabelerDefinition {
6
6
+
did: string;
7
7
+
url: string;
8
8
+
isDefault: boolean;
9
9
+
supportedLabels: Record<string, LabelPreference>;
10
10
+
// The lookup map for UI strings
11
11
+
labelDefs: Record<string, LabelValueDefinition>;
12
12
+
}
13
13
+
14
14
+
export interface LabelValueDefinition {
15
15
+
identifier: string;
16
16
+
severity: 'inform' | 'alert' | 'none';
17
17
+
blurs: 'content' | 'media' | 'none';
18
18
+
adultOnly: boolean;
19
19
+
defaultSetting?: LabelPreference;
20
20
+
locales: Array<{
21
21
+
lang: string;
22
22
+
name: string;
23
23
+
description: string;
24
24
+
}>;
25
25
+
}
26
26
+
27
27
+
export interface ContentLabel {
28
28
+
sourceDid: string; // Who said it?
29
29
+
val: string; // What is the label?
30
30
+
cts: string; // Timestamp
31
31
+
preference: LabelPreference; // Resolved preference for this specific label
32
32
+
}
33
33
+
34
34
+
// Type for the labeler service record response
35
35
+
export interface LabelerServiceRecord {
36
36
+
did: string;
37
37
+
serviceEndpoint: string;
38
38
+
policies: {
39
39
+
labelValues: string[];
40
40
+
labelValueDefinitions?: Array<{
41
41
+
identifier: string;
42
42
+
defaultSetting: LabelPreference;
43
43
+
}>;
44
44
+
};
45
45
+
}
46
46
+
47
47
+
// Type for queryLabels response (matches ATProto API)
48
48
+
export interface QueryLabelsResponse {
49
49
+
cursor?: string;
50
50
+
labels: Array<{
51
51
+
ver?: number;
52
52
+
src: string; // DID
53
53
+
uri: string; // AT URI
54
54
+
cid?: string; // CID
55
55
+
val: string; // Label value
56
56
+
neg?: boolean; // Negation label
57
57
+
cts: string; // Created timestamp
58
58
+
exp?: string; // Expiry timestamp
59
59
+
sig?: Uint8Array; // Signature
60
60
+
}>;
61
61
+
}
+76
-40
src/utils/useQuery.ts
···
15
15
16
16
export function constructIdentityQuery(
17
17
didorhandle?: string,
18
18
-
slingshoturl?: string
18
18
+
slingshoturl?: string,
19
19
) {
20
20
return queryOptions({
21
21
queryKey: ["identity", didorhandle],
22
22
queryFn: async () => {
23
23
if (!didorhandle) return undefined as undefined;
24
24
const res = await fetch(
25
25
-
`https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
25
25
+
`https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`,
26
26
);
27
27
if (!res.ok) throw new Error("Failed to fetch post");
28
28
try {
···
71
71
queryFn: async () => {
72
72
if (!uri) return undefined as undefined;
73
73
const res = await fetch(
74
74
-
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
74
74
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`,
75
75
);
76
76
let data: any;
77
77
try {
···
135
135
queryFn: async () => {
136
136
if (!uri) return undefined as undefined;
137
137
const res = await fetch(
138
138
-
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
138
138
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`,
139
139
);
140
140
let data: any;
141
141
try {
···
269
269
const cursor = query.cursor;
270
270
const dids = query?.dids;
271
271
const res = await fetch(
272
272
-
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}`
272
272
+
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}`,
273
273
);
274
274
if (!res.ok) throw new Error("Failed to fetch post");
275
275
try {
···
308
308
const [constellationurl] = useAtom(constellationURLAtom);
309
309
const queryres = useQuery(
310
310
constructConstellationQuery(
311
311
-
query && { constellation: constellationurl, ...query }
312
312
-
)
311
311
+
query && { constellation: constellationurl, ...query },
312
312
+
),
313
313
) as unknown as UseQueryResult<linksCountResponse, Error>;
314
314
if (!query) {
315
315
return undefined as undefined;
···
389
389
const [constellationurl] = useAtom(constellationURLAtom);
390
390
return useQuery(
391
391
constructConstellationQuery(
392
392
-
query && { constellation: constellationurl, ...query }
393
393
-
)
392
392
+
query && { constellation: constellationurl, ...query },
393
393
+
),
394
394
);
395
395
}
396
396
···
446
446
// Authenticated flow
447
447
if (!agent || !pdsUrl || !feedServiceDid) {
448
448
throw new Error(
449
449
-
"Missing required info for authenticated feed fetch."
449
449
+
"Missing required info for authenticated feed fetch.",
450
450
);
451
451
}
452
452
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
···
481
481
feedServiceDid?: string;
482
482
}) {
483
483
return useQuery(constructFeedSkeletonQuery(options));
484
484
+
}
485
485
+
486
486
+
export function constructRecordQuery(
487
487
+
did?: string,
488
488
+
collection?: string,
489
489
+
rkey?: string,
490
490
+
pdsUrl?: string,
491
491
+
) {
492
492
+
return queryOptions({
493
493
+
queryKey: ["record", did, collection, rkey],
494
494
+
queryFn: async () => {
495
495
+
if (!did || !collection || !rkey || !pdsUrl)
496
496
+
return undefined as undefined;
497
497
+
const url = `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`;
498
498
+
const res = await fetch(url);
499
499
+
if (!res.ok) throw new Error("Failed to fetch record");
500
500
+
try {
501
501
+
return (await res.json()) as {
502
502
+
uri: string;
503
503
+
cid: string;
504
504
+
value: any;
505
505
+
};
506
506
+
} catch (_e) {
507
507
+
return undefined;
508
508
+
}
509
509
+
},
510
510
+
staleTime: 5 * 60 * 1000, // 5 minutes
511
511
+
gcTime: 5 * 60 * 1000,
512
512
+
});
513
513
+
}
514
514
+
515
515
+
export function useQueryRecord(
516
516
+
did?: string,
517
517
+
collection?: string,
518
518
+
rkey?: string,
519
519
+
pdsUrl?: string,
520
520
+
) {
521
521
+
return useQuery(constructRecordQuery(did, collection, rkey, pdsUrl));
484
522
}
485
523
486
524
export function constructPreferencesQuery(
487
525
agent?: ATPAPI.Agent | undefined,
488
488
-
pdsUrl?: string | undefined
526
526
+
pdsUrl?: string | undefined,
489
527
) {
490
528
return queryOptions({
491
529
queryKey: ["preferences", agent?.did],
···
511
549
queryFn: async () => {
512
550
if (!uri) return undefined as undefined;
513
551
const res = await fetch(
514
514
-
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
552
552
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`,
515
553
);
516
554
let data: any;
517
555
try {
···
590
628
export function constructAuthorFeedQuery(
591
629
did: string,
592
630
pdsUrl: string,
593
593
-
collection: string = "app.bsky.feed.post"
631
631
+
collection: string = "app.bsky.feed.post",
594
632
) {
595
633
return queryOptions({
596
634
queryKey: ["authorFeed", did, collection],
···
613
651
export function useInfiniteQueryAuthorFeed(
614
652
did: string | undefined,
615
653
pdsUrl: string | undefined,
616
616
-
collection?: string
654
654
+
collection?: string,
617
655
) {
618
656
const { queryKey, queryFn } = constructAuthorFeedQuery(
619
657
did!,
620
658
pdsUrl!,
621
621
-
collection
659
659
+
collection,
622
660
);
623
661
624
662
return useInfiniteQuery({
···
655
693
if (isAuthed && !unauthedfeedurl) {
656
694
if (!agent || !pdsUrl || !feedServiceDid) {
657
695
throw new Error(
658
658
-
"Missing required info for authenticated feed fetch."
696
696
+
"Missing required info for authenticated feed fetch.",
659
697
);
660
698
}
661
699
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
···
748
786
collection ? `&collection=${encodeURIComponent(collection)}` : ""
749
787
}${path ? `&path=${encodeURIComponent(path)}` : ""}${
750
788
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""
751
751
-
}`
789
789
+
}`,
752
790
);
753
791
754
792
if (!res.ok) throw new Error("Failed to fetch");
···
774
812
agent: agent || undefined,
775
813
isAuthed: status === "signedIn",
776
814
pdsUrl: identity?.pds,
777
777
-
feedServiceDid: "did:web:"+lycanurl,
778
778
-
})
815
815
+
feedServiceDid: "did:web:" + lycanurl,
816
816
+
}),
779
817
);
780
818
}
781
819
···
802
840
});
803
841
if (!res.ok)
804
842
throw new Error(
805
805
-
`Authenticated lycan status fetch failed: ${res.statusText}`
843
843
+
`Authenticated lycan status fetch failed: ${res.statusText}`,
806
844
);
807
845
return (await res.json()) as statuschek;
808
846
}
···
816
854
error?: "MethodNotImplemented";
817
855
message?: "Method Not Implemented";
818
856
status?: "finished" | "in_progress";
819
819
-
position?: string,
820
820
-
progress?: number,
821
821
-
857
857
+
position?: string;
858
858
+
progress?: number;
822
859
};
823
860
824
861
//{"status":"in_progress","position":"2025-08-30T06:53:18Z","progress":0.0878319661441268}
825
862
type importtype = {
826
826
-
message?: "Import has already started" | "Import has been scheduled"
827
827
-
}
863
863
+
message?: "Import has already started" | "Import has been scheduled";
864
864
+
};
828
865
829
866
export function constructLycanRequestIndexQuery(options: {
830
867
agent?: ATPAPI.Agent;
···
849
886
});
850
887
if (!res.ok)
851
888
throw new Error(
852
852
-
`Authenticated lycan status fetch failed: ${res.statusText}`
889
889
+
`Authenticated lycan status fetch failed: ${res.statusText}`,
853
890
);
854
854
-
return await res.json() as importtype;
891
891
+
return (await res.json()) as importtype;
855
892
}
856
893
return undefined;
857
894
},
···
864
901
cursor?: string;
865
902
};
866
903
867
867
-
868
868
-
export function useInfiniteQueryLycanSearch(options: { query: string, type: "likes" | "pins" | "reposts" | "quotes"}) {
869
869
-
870
870
-
904
904
+
export function useInfiniteQueryLycanSearch(options: {
905
905
+
query: string;
906
906
+
type: "likes" | "pins" | "reposts" | "quotes";
907
907
+
}) {
871
908
const [lycanurl] = useAtom(lycanURLAtom);
872
909
const { agent, status } = useAuth();
873
910
const { data: identity } = useQueryIdentity(agent?.did);
874
911
875
912
const { queryKey, queryFn } = constructLycanSearchQuery({
876
876
-
agent: agent || undefined,
877
877
-
isAuthed: status === "signedIn",
878
878
-
pdsUrl: identity?.pds,
879
879
-
feedServiceDid: "did:web:"+lycanurl,
880
880
-
query: options.query,
881
881
-
type: options.type,
882
882
-
})
913
913
+
agent: agent || undefined,
914
914
+
isAuthed: status === "signedIn",
915
915
+
pdsUrl: identity?.pds,
916
916
+
feedServiceDid: "did:web:" + lycanurl,
917
917
+
query: options.query,
918
918
+
type: options.type,
919
919
+
});
883
920
884
921
return {
885
922
...useInfiniteQuery({
···
900
937
queryKey: queryKey,
901
938
};
902
939
}
903
903
-
904
940
905
941
export function constructLycanSearchQuery(options: {
906
942
agent?: ATPAPI.Agent;
···
929
965
});
930
966
if (!res.ok)
931
967
throw new Error(
932
932
-
`Authenticated lycan status fetch failed: ${res.statusText}`
968
968
+
`Authenticated lycan status fetch failed: ${res.statusText}`,
933
969
);
934
970
return (await res.json()) as LycanSearchPage;
935
971
}