Highly ambitious ATProtocol AppView service and sdks
at main 266 lines 8.5 kB view raw
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}