An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(identity-wallet): add IdentityListHome component

- Creates new multi-identity home screen showing all claimed identities as cards
- Each card displays avatar, handle, truncated DID, and PDS endpoint
- Includes rotation key status badge (Root Key/Not Root/Unknown) in green/amber/gray
- Implements empty state when no identities exist with 'Add Identity' button
- Cards are tappable to navigate to identity detail view
- Includes refresh button to reload all identities
- Plus button navigates back to mode selector to add another identity
- Lazy-loads DID documents and device key IDs in parallel for each identity
- Reuses DIDAvatar component for deterministic avatar colors
- Follows HomeScreen styling patterns with card-based layout

authored by

Malpercio and committed by
Tangled
5c922e5e 68f26230

+404
+404
apps/identity-wallet/src/lib/components/home/IdentityListHome.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { listIdentities, getStoredDidDoc, getDeviceKeyId } from '$lib/ipc'; 4 + import DIDAvatar from './DIDAvatar.svelte'; 5 + 6 + let { 7 + onadd, 8 + onselect, 9 + }: { 10 + onadd: () => void; 11 + onselect: (did: string, didDoc: Record<string, unknown>) => void; 12 + } = $props(); 13 + 14 + interface IdentityCard { 15 + did: string; 16 + handle: string | null; 17 + pdsUrl: string | null; 18 + deviceKeyIsRoot: boolean | null; 19 + } 20 + 21 + let identities = $state<IdentityCard[]>([]); 22 + let didDocs = $state<Map<string, Record<string, unknown>>>(new Map()); 23 + let loading = $state(true); 24 + 25 + function truncateDid(did: string): string { 26 + const prefix = 'did:plc:'; 27 + if (!did.startsWith(prefix)) return did; 28 + const specific = did.slice(prefix.length); 29 + if (specific.length < 14) return did; 30 + return `${prefix}${specific.slice(0, 8)}…${specific.slice(-6)}`; 31 + } 32 + 33 + function extractHandle(didDoc: Record<string, unknown>): string | null { 34 + const alsoKnownAs = didDoc.alsoKnownAs; 35 + if (!Array.isArray(alsoKnownAs)) return null; 36 + for (const aka of alsoKnownAs) { 37 + if (typeof aka === 'string' && aka.startsWith('at://')) { 38 + return aka.slice(5); // Extract after "at://" 39 + } 40 + } 41 + return null; 42 + } 43 + 44 + function extractPds(didDoc: Record<string, unknown>): string | null { 45 + const service = didDoc.service; 46 + if (!Array.isArray(service)) return null; 47 + for (const svc of service) { 48 + if (typeof svc === 'object' && svc !== null) { 49 + const id = svc.id; 50 + const type = svc.type; 51 + const endpoint = svc.serviceEndpoint; 52 + if ((id === '#atproto_pds' || type === 'AtprotoPersonalDataServer') && typeof endpoint === 'string') { 53 + return endpoint; 54 + } 55 + } 56 + } 57 + return null; 58 + } 59 + 60 + function isDeviceKeyRoot( 61 + didDoc: Record<string, unknown>, 62 + deviceKeyId: string 63 + ): boolean | null { 64 + const verificationMethod = didDoc.verificationMethod; 65 + if (!Array.isArray(verificationMethod)) return null; 66 + 67 + let rotationKeys: string[] = []; 68 + for (const vm of verificationMethod) { 69 + if (typeof vm === 'object' && vm !== null) { 70 + const id = vm.id; 71 + const type = vm.type; 72 + if (id === '#rotation' && type === 'Multikey') { 73 + const publicKeyMultibase = vm.publicKeyMultibase; 74 + if (typeof publicKeyMultibase === 'string') { 75 + rotationKeys.push(publicKeyMultibase); 76 + } 77 + } 78 + } 79 + } 80 + 81 + if (rotationKeys.length === 0) return null; 82 + return rotationKeys[0] === deviceKeyId; 83 + } 84 + 85 + async function loadData() { 86 + loading = true; 87 + try { 88 + const dids = await listIdentities(); 89 + identities = []; 90 + didDocs.clear(); 91 + 92 + for (const did of dids) { 93 + try { 94 + const [docResult, keyIdResult] = await Promise.all([ 95 + getStoredDidDoc(did), 96 + getDeviceKeyId(did), 97 + ]); 98 + 99 + if (docResult) { 100 + didDocs.set(did, docResult); 101 + const handle = extractHandle(docResult); 102 + const pdsUrl = extractPds(docResult); 103 + const deviceKeyIsRoot = isDeviceKeyRoot(docResult, keyIdResult); 104 + 105 + identities.push({ 106 + did, 107 + handle, 108 + pdsUrl, 109 + deviceKeyIsRoot, 110 + }); 111 + } 112 + } catch (e) { 113 + console.error(`Failed to load identity ${did}:`, e); 114 + } 115 + } 116 + } catch (e) { 117 + console.error('Failed to load identities:', e); 118 + identities = []; 119 + didDocs.clear(); 120 + } finally { 121 + loading = false; 122 + } 123 + } 124 + 125 + onMount(() => { 126 + loadData(); 127 + }); 128 + 129 + function getBadgeInfo(deviceKeyIsRoot: boolean | null): { label: string; className: string } { 130 + if (deviceKeyIsRoot === true) { 131 + return { label: 'Root Key', className: 'badge--root' }; 132 + } else if (deviceKeyIsRoot === false) { 133 + return { label: 'Not Root', className: 'badge--not-root' }; 134 + } 135 + return { label: 'Unknown', className: 'badge--unknown' }; 136 + } 137 + </script> 138 + 139 + {#if loading} 140 + <div class="screen screen--center"> 141 + <div class="spinner" aria-label="Loading"></div> 142 + <p class="status-text">Loading…</p> 143 + </div> 144 + {:else} 145 + <div class="screen"> 146 + <div class="header"> 147 + <h1 class="title">Identity Wallet</h1> 148 + <button class="refresh-btn" onclick={loadData} aria-label="Refresh">↻</button> 149 + </div> 150 + 151 + {#if identities.length === 0} 152 + <div class="empty-state"> 153 + <p class="empty-text">No identities yet</p> 154 + <button class="add-btn" onclick={onadd}>+ Add Identity</button> 155 + </div> 156 + {:else} 157 + <div class="identity-cards"> 158 + {#each identities as card (card.did)} 159 + <button 160 + class="identity-card" 161 + onclick={() => onselect(card.did, didDocs.get(card.did)!)} 162 + > 163 + <div class="card-content"> 164 + <DIDAvatar did={card.did} handle={card.handle ?? 'Unknown'} /> 165 + <div class="identity-info"> 166 + <p class="handle">@{card.handle ?? 'Unknown handle'}</p> 167 + <p class="did">{truncateDid(card.did)}</p> 168 + {#if card.pdsUrl} 169 + <p class="pds">{card.pdsUrl}</p> 170 + {/if} 171 + </div> 172 + </div> 173 + <div class="card-badge"> 174 + {#if card.deviceKeyIsRoot !== null} 175 + <span 176 + class="badge" 177 + class:badge--root={card.deviceKeyIsRoot === true} 178 + class:badge--not-root={card.deviceKeyIsRoot === false} 179 + class:badge--unknown={card.deviceKeyIsRoot === null} 180 + > 181 + <span class="badge-dot"></span> 182 + {getBadgeInfo(card.deviceKeyIsRoot).label} 183 + </span> 184 + {/if} 185 + </div> 186 + </button> 187 + {/each} 188 + </div> 189 + 190 + <button class="add-btn" onclick={onadd}>+ Add Identity</button> 191 + {/if} 192 + </div> 193 + {/if} 194 + 195 + <style> 196 + .screen { 197 + display: flex; 198 + flex-direction: column; 199 + height: 100%; 200 + padding: 2rem 1.5rem; 201 + gap: 1.5rem; 202 + overflow-y: auto; 203 + } 204 + 205 + .screen--center { 206 + align-items: center; 207 + justify-content: center; 208 + gap: 1rem; 209 + } 210 + 211 + .spinner { 212 + width: 48px; 213 + height: 48px; 214 + border: 4px solid #e5e7eb; 215 + border-top-color: #007aff; 216 + border-radius: 50%; 217 + animation: spin 0.8s linear infinite; 218 + } 219 + 220 + @keyframes spin { 221 + to { transform: rotate(360deg); } 222 + } 223 + 224 + .status-text { 225 + font-size: 1rem; 226 + color: #6b7280; 227 + margin: 0; 228 + } 229 + 230 + .header { 231 + display: flex; 232 + align-items: center; 233 + justify-content: space-between; 234 + } 235 + 236 + .title { 237 + font-size: 1.4rem; 238 + font-weight: 700; 239 + margin: 0; 240 + color: #111827; 241 + } 242 + 243 + .refresh-btn { 244 + background: none; 245 + border: none; 246 + font-size: 1.4rem; 247 + color: #007aff; 248 + cursor: pointer; 249 + padding: 0.25rem; 250 + line-height: 1; 251 + } 252 + 253 + .identity-cards { 254 + display: flex; 255 + flex-direction: column; 256 + gap: 0.75rem; 257 + } 258 + 259 + .identity-card { 260 + background: #f9fafb; 261 + border: 1px solid #d1d5db; 262 + border-radius: 12px; 263 + padding: 1.25rem; 264 + display: flex; 265 + align-items: center; 266 + justify-content: space-between; 267 + gap: 1rem; 268 + cursor: pointer; 269 + border: none; 270 + width: 100%; 271 + text-align: left; 272 + transition: background 0.2s, border-color 0.2s; 273 + } 274 + 275 + .identity-card:active { 276 + background: #f3f4f6; 277 + border-color: #9ca3af; 278 + } 279 + 280 + .card-content { 281 + display: flex; 282 + align-items: center; 283 + gap: 1rem; 284 + min-width: 0; 285 + flex: 1; 286 + } 287 + 288 + .identity-info { 289 + display: flex; 290 + flex-direction: column; 291 + gap: 0.25rem; 292 + min-width: 0; 293 + } 294 + 295 + .handle { 296 + font-size: 1.1rem; 297 + font-weight: 600; 298 + color: #111827; 299 + margin: 0; 300 + white-space: nowrap; 301 + overflow: hidden; 302 + text-overflow: ellipsis; 303 + } 304 + 305 + .did { 306 + font-family: monospace; 307 + font-size: 0.8rem; 308 + color: #374151; 309 + margin: 0; 310 + white-space: nowrap; 311 + overflow: hidden; 312 + text-overflow: ellipsis; 313 + } 314 + 315 + .pds { 316 + font-size: 0.8rem; 317 + color: #6b7280; 318 + margin: 0; 319 + white-space: nowrap; 320 + overflow: hidden; 321 + text-overflow: ellipsis; 322 + } 323 + 324 + .card-badge { 325 + flex-shrink: 0; 326 + } 327 + 328 + .badge { 329 + display: flex; 330 + align-items: center; 331 + gap: 0.4rem; 332 + padding: 0.4rem 0.8rem; 333 + border-radius: 6px; 334 + font-size: 0.75rem; 335 + font-weight: 600; 336 + white-space: nowrap; 337 + } 338 + 339 + .badge-dot { 340 + width: 6px; 341 + height: 6px; 342 + border-radius: 50%; 343 + flex-shrink: 0; 344 + } 345 + 346 + .badge--root { 347 + background: #dcfce7; 348 + color: #166534; 349 + } 350 + 351 + .badge--root .badge-dot { 352 + background: #16a34a; 353 + } 354 + 355 + .badge--not-root { 356 + background: #fef3c7; 357 + color: #92400e; 358 + } 359 + 360 + .badge--not-root .badge-dot { 361 + background: #f59e0b; 362 + } 363 + 364 + .badge--unknown { 365 + background: #f3f4f6; 366 + color: #374151; 367 + } 368 + 369 + .badge--unknown .badge-dot { 370 + background: #9ca3af; 371 + } 372 + 373 + .empty-state { 374 + display: flex; 375 + flex-direction: column; 376 + align-items: center; 377 + justify-content: center; 378 + gap: 1.5rem; 379 + padding: 2rem 1rem; 380 + } 381 + 382 + .empty-text { 383 + font-size: 1rem; 384 + color: #6b7280; 385 + margin: 0; 386 + } 387 + 388 + .add-btn { 389 + width: 100%; 390 + padding: 0.9rem; 391 + background: #007aff; 392 + color: #fff; 393 + border: none; 394 + border-radius: 12px; 395 + font-size: 1rem; 396 + font-weight: 600; 397 + cursor: pointer; 398 + margin-top: auto; 399 + } 400 + 401 + .add-btn:active { 402 + background: #0051d5; 403 + } 404 + </style>