Highly ambitious ATProtocol AppView service and sdks
1import { useState } from "react";
2import { graphql, useLazyLoadQuery } from "react-relay";
3import { FormControl } from "./FormControl.tsx";
4import { Input } from "./Input.tsx";
5import { Button } from "./Button.tsx";
6import { LexiconDependencyConfirmationDialog } from "./LexiconDependencyConfirmationDialog.tsx";
7import { resolveDependencies } from "../utils/lexiconDependencies.ts";
8import type { PublishedLexiconsListQuery } from "../__generated__/PublishedLexiconsListQuery.graphql.ts";
9
10interface PublishedLexicon {
11 uri: string;
12 nsid: string;
13 description?: string;
14 defs: unknown;
15 fullData: unknown;
16}
17
18interface LexiconWithData {
19 nsid: string;
20 data: unknown;
21}
22
23interface PublishedLexiconsListProps {
24 existingNsids: string[];
25 onSelect: (lexicons: LexiconWithData[]) => void;
26 onBack: () => void;
27 onCancel: () => void;
28}
29
30const PUBLISHED_LEXICONS_SLICE_URI = "at://did:plc:dzmqinfp7efnofbqg5npjmth/network.slices.slice/3m3fsrppc3p2h";
31
32export function PublishedLexiconsList({
33 existingNsids,
34 onSelect,
35 onBack,
36 onCancel,
37}: PublishedLexiconsListProps) {
38 const [searchQuery, setSearchQuery] = useState("");
39 const [showDepsDialog, setShowDepsDialog] = useState(false);
40 const [selectedLexicon, setSelectedLexicon] = useState<LexiconWithData | null>(null);
41 const [resolvedDeps, setResolvedDeps] = useState<LexiconWithData[]>([]);
42
43 const data = useLazyLoadQuery<PublishedLexiconsListQuery>(
44 graphql`
45 query PublishedLexiconsListQuery(
46 $sliceUri: String!
47 $where: SliceRecordsWhereInput
48 ) {
49 sliceRecords(sliceUri: $sliceUri, first: 1000, where: $where) {
50 edges {
51 node {
52 uri
53 collection
54 value
55 }
56 }
57 }
58 }
59 `,
60 {
61 sliceUri: PUBLISHED_LEXICONS_SLICE_URI,
62 where: {
63 collection: { eq: "com.atproto.lexicon.schema" },
64 },
65 },
66 {
67 fetchPolicy: "store-and-network",
68 }
69 );
70
71 // Parse and filter published lexicons
72 const publishedLexicons = data.sliceRecords.edges
73 .map((edge) => {
74 try {
75 const lexiconData = JSON.parse(edge.node.value);
76 const nsid = lexiconData.id || lexiconData.nsid;
77 const defs = lexiconData.defs || lexiconData.definitions;
78
79 if (!nsid || !defs) return null;
80
81 return {
82 uri: edge.node.uri,
83 nsid,
84 description: lexiconData.description,
85 defs,
86 fullData: lexiconData,
87 } as PublishedLexicon;
88 } catch {
89 return null;
90 }
91 })
92 .filter((lex): lex is PublishedLexicon => lex !== null);
93
94 // Filter by search query
95 const filteredLexicons = publishedLexicons.filter((lex) => {
96 if (!searchQuery) return true;
97 const query = searchQuery.toLowerCase();
98 return (
99 lex.nsid.toLowerCase().includes(query) ||
100 lex.description?.toLowerCase().includes(query)
101 );
102 });
103
104 // Check if lexicon already exists in slice
105 const isAlreadyAdded = (nsid: string) => existingNsids.includes(nsid);
106
107 // Handle lexicon selection with dependency resolution
108 const handleLexiconClick = (lexicon: PublishedLexicon) => {
109 if (isAlreadyAdded(lexicon.nsid)) return;
110
111 // Convert to LexiconWithData format
112 const mainLexicon: LexiconWithData = {
113 nsid: lexicon.nsid,
114 data: lexicon.fullData,
115 };
116
117 // Convert all published lexicons to LexiconWithData format
118 const allLexicons: LexiconWithData[] = publishedLexicons.map(lex => ({
119 nsid: lex.nsid,
120 data: lex.fullData,
121 }));
122
123 // Resolve dependencies
124 const dependencies = resolveDependencies(mainLexicon, allLexicons, existingNsids);
125
126 // If there are dependencies, show confirmation dialog
127 if (dependencies.length > 0) {
128 setSelectedLexicon(mainLexicon);
129 setResolvedDeps(dependencies);
130 setShowDepsDialog(true);
131 } else {
132 // No dependencies, add directly
133 onSelect([mainLexicon]);
134 }
135 };
136
137 // Handle confirmation dialog confirmation
138 const handleConfirmDeps = () => {
139 if (selectedLexicon) {
140 onSelect([selectedLexicon, ...resolvedDeps]);
141 }
142 setShowDepsDialog(false);
143 setSelectedLexicon(null);
144 setResolvedDeps([]);
145 };
146
147 // Handle confirmation dialog cancellation
148 const handleCancelDeps = () => {
149 setShowDepsDialog(false);
150 setSelectedLexicon(null);
151 setResolvedDeps([]);
152 };
153
154 return (
155 <div className="space-y-4">
156 <FormControl label="Search Lexicons" htmlFor="search">
157 <Input
158 id="search"
159 type="text"
160 value={searchQuery}
161 onChange={(e) => setSearchQuery(e.target.value)}
162 placeholder="Filter by NSID or description..."
163 />
164 </FormControl>
165
166 <div className="h-96 overflow-y-auto">
167 {filteredLexicons.length === 0 ? (
168 <div className="text-center py-8 text-sm text-zinc-500">
169 {searchQuery ? "No lexicons match your search" : "No published lexicons found"}
170 </div>
171 ) : (
172 filteredLexicons.map((lexicon) => {
173 const alreadyAdded = isAlreadyAdded(lexicon.nsid);
174 const parts = lexicon.nsid.split(".");
175 const authority = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : parts[0];
176 const rest = parts.length >= 2 ? parts.slice(2).join(".") : "";
177
178 // Check if this is a record type lexicon
179 let isRecordType = false;
180 try {
181 const defs = lexicon.defs as Record<string, { type?: string }> | undefined;
182 isRecordType = defs?.main?.type === "record";
183 } catch {
184 // ignore
185 }
186
187 // Split the rest into middle and last part if it's a record type
188 let middle = rest;
189 let lastPart = "";
190 if (isRecordType && rest) {
191 const restParts = rest.split(".");
192 if (restParts.length > 1) {
193 lastPart = restParts[restParts.length - 1];
194 middle = restParts.slice(0, -1).join(".");
195 } else {
196 lastPart = rest;
197 middle = "";
198 }
199 }
200
201 return (
202 <button
203 key={lexicon.uri}
204 type="button"
205 onClick={() => handleLexiconClick(lexicon)}
206 disabled={alreadyAdded}
207 className={`w-full text-left py-1 rounded group transition-colors ${
208 alreadyAdded
209 ? "opacity-50 cursor-not-allowed"
210 : "hover:bg-zinc-900/50 cursor-pointer"
211 }`}
212 >
213 <div className="flex items-center gap-2">
214 <span className="text-sm font-medium font-mono">
215 <span className="text-zinc-200">{authority}</span>
216 {isRecordType ? (
217 <>
218 {middle && <span className="text-zinc-400">.{middle}</span>}
219 {lastPart && (
220 <>
221 <span className="text-zinc-400">.</span>
222 <span className="text-cyan-400">{lastPart}</span>
223 </>
224 )}
225 </>
226 ) : (
227 rest && <span className="text-zinc-400">.{rest}</span>
228 )}
229 </span>
230 {alreadyAdded && (
231 <span className="text-xs text-zinc-600">
232 (added)
233 </span>
234 )}
235 {lexicon.description && (
236 <span className="text-xs text-zinc-600 truncate">
237 {lexicon.description}
238 </span>
239 )}
240 </div>
241 </button>
242 );
243 })
244 )}
245 </div>
246
247 <div className="flex justify-between gap-3 pt-4">
248 <Button type="button" variant="default" onClick={onBack}>
249 Back
250 </Button>
251 <Button type="button" variant="default" onClick={onCancel}>
252 Cancel
253 </Button>
254 </div>
255
256 {/* Dependency confirmation dialog */}
257 <LexiconDependencyConfirmationDialog
258 open={showDepsDialog}
259 mainLexiconNsid={selectedLexicon?.nsid || ""}
260 dependencies={resolvedDeps.map(dep => dep.nsid)}
261 onConfirm={handleConfirmDeps}
262 onCancel={handleCancelDeps}
263 />
264 </div>
265 );
266}