browse the protocol like its 2008 ibex.desertthunder.dev
ubuntu atproto svelte
7

Configure Feed

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

feat: add Identity Inspector

Owais Jamil (Jun 16, 2026, 10:02 PM -0500) 032065b6 1d3f90d8

+1167 -73
+1 -1
TODO.md
··· 94 94 95 95 - [x] Add canonical `/repos/:did/...` routing. 96 96 - [ ] Deepen Nautilus and gedit with collection controls plus record JSON/schema/info tabs. 97 - - [ ] Add Identity Inspector. 97 + - [x] Add Identity Inspector. 98 98 - [ ] Add System Monitor with Jetstream first. 99 99 - [ ] Add Network Map. 100 100 - [ ] Add Archive Manager.
+119
src/lib/atproto/identity-inspector.test.ts
··· 1 + /** Move to test/inspector.test.ts */ 2 + import { describe, expect, it } from 'vitest'; 3 + import { 4 + findHandleAlias, 5 + findPdsEndpoint, 6 + inspectIdentity, 7 + normalizeHandle, 8 + resolveDidDocumentUrl, 9 + validateHandle 10 + } from './identity-inspector'; 11 + import type { DidDocument } from './types'; 12 + 13 + describe('identity inspector helpers', () => { 14 + it('resolves supported DID document URLs', () => { 15 + expect(resolveDidDocumentUrl('did:plc:xg2vq45muivyy3xwatcehspu')).toBe( 16 + 'https://plc.directory/did%3Aplc%3Axg2vq45muivyy3xwatcehspu' 17 + ); 18 + expect(resolveDidDocumentUrl('did:web:example.com:user')).toBe('https://example.com/user/.well-known/did.json'); 19 + }); 20 + 21 + it('normalizes handle aliases and PDS services', () => { 22 + expect(normalizeHandle('@DesertThunder.Dev')).toBe('desertthunder.dev'); 23 + expect(findHandleAlias(['at://desertthunder.dev'])).toBe('desertthunder.dev'); 24 + expect( 25 + findPdsEndpoint([ 26 + { id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com' } 27 + ]) 28 + ).toBe('https://pds.example.com'); 29 + }); 30 + 31 + it('classifies handle validation responses', async () => { 32 + const client = { 33 + get: async (name: string, _init?: unknown) => { 34 + if (name === 'com.atproto.identity.resolveHandle') { 35 + return { ok: true, data: { did: 'did:plc:example' } }; 36 + } 37 + 38 + throw new Error('Unexpected XRPC call'); 39 + } 40 + }; 41 + const fetcher = async (input: RequestInfo | URL) => { 42 + const url = String(input); 43 + 44 + if (url.includes('/.well-known/atproto-did')) { 45 + return new Response('did:plc:other'); 46 + } 47 + 48 + return Response.json({ Answer: [{ data: '"did=did:plc:example"' }] }); 49 + }; 50 + 51 + const validation = await validateHandle('desertthunder.dev', 'did:plc:example', client, fetcher); 52 + 53 + expect(validation.resolveHandle.status).toBe('valid'); 54 + expect(validation.wellKnown.status).toBe('mismatch'); 55 + expect(validation.dnsTxt.status).toBe('valid'); 56 + }); 57 + 58 + it('builds an inspection model from atcute DID resolution and PLC audit', async () => { 59 + const didDocument: DidDocument = { 60 + id: 'did:plc:example', 61 + alsoKnownAs: ['at://desertthunder.dev'], 62 + verificationMethod: [ 63 + { 64 + id: 'did:plc:example#atproto', 65 + type: 'Multikey', 66 + controller: 'did:plc:example', 67 + publicKeyMultibase: 'zExample' 68 + } 69 + ], 70 + service: [{ id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com' }] 71 + }; 72 + const client = { 73 + get: async (name: string, _init?: unknown) => { 74 + if (name === 'com.atproto.identity.resolveDid') { 75 + return { ok: true, data: { didDoc: didDocument } }; 76 + } 77 + 78 + if (name === 'com.atproto.identity.resolveHandle') { 79 + return { ok: true, data: { did: 'did:plc:example' } }; 80 + } 81 + 82 + throw new Error('Unexpected XRPC call'); 83 + } 84 + }; 85 + const fetcher = async (input: RequestInfo | URL) => { 86 + const url = String(input); 87 + 88 + if (url.endsWith('/log/audit')) { 89 + return Response.json([ 90 + { 91 + cid: 'bafyold', 92 + nullified: false, 93 + createdAt: '2024-01-01T00:00:00.000Z', 94 + operation: { rotationKeys: ['did:key:old'] } 95 + }, 96 + { 97 + cid: 'bafynew', 98 + nullified: false, 99 + createdAt: '2024-02-01T00:00:00.000Z', 100 + operation: { rotationKeys: ['did:key:new'] } 101 + } 102 + ]); 103 + } 104 + 105 + if (url.includes('/.well-known/atproto-did')) { 106 + return new Response('did:plc:example'); 107 + } 108 + 109 + return Response.json({ Answer: [] }); 110 + }; 111 + 112 + const inspection = await inspectIdentity('did:plc:example', null, { client, fetcher }); 113 + 114 + expect(inspection.handle).toBe('desertthunder.dev'); 115 + expect(inspection.pds).toBe('https://pds.example.com'); 116 + expect(inspection.rotationKeys).toEqual(['did:key:new']); 117 + expect(inspection.handleValidation?.wellKnown.status).toBe('valid'); 118 + }); 119 + });
+270
src/lib/atproto/identity-inspector.ts
··· 1 + /** TODO: this should be identity/inspector.ts */ 2 + import { Client, simpleFetchHandler } from '@atcute/client'; 3 + import type { Did, Handle } from '@atcute/lexicons/syntax'; 4 + import type {} from '@atcute/atproto'; 5 + import type { DidDocument, DidService, DidVerificationMethod } from './types'; 6 + 7 + export type IdentityValidationStatus = 'valid' | 'mismatch' | 'unavailable' | 'unsupported'; 8 + 9 + export type HandleValidation = { 10 + handle: string; 11 + resolveHandle: IdentityCheck; 12 + wellKnown: IdentityCheck; 13 + dnsTxt: IdentityCheck; 14 + }; 15 + 16 + export type IdentityCheck = { status: IdentityValidationStatus; label: string; detail: string; value: string | null }; 17 + 18 + export type PlcAuditEntry = { 19 + cid: string; 20 + createdAt: string; 21 + nullified: boolean; 22 + operation: { 23 + alsoKnownAs?: string[]; 24 + rotationKeys?: string[]; 25 + verificationMethods?: Record<string, string>; 26 + services?: Record<string, { type?: string; endpoint?: string }>; 27 + }; 28 + }; 29 + 30 + export type IdentityInspection = { 31 + did: string; 32 + handle: string | null; 33 + didDocument: DidDocument; 34 + aliases: string[]; 35 + services: DidService[]; 36 + verificationMethods: DidVerificationMethod[]; 37 + rotationKeys: string[]; 38 + pds: string | null; 39 + didDocumentUrl: string; 40 + plcAuditUrl: string | null; 41 + handleValidation: HandleValidation | null; 42 + rawJson: string; 43 + }; 44 + 45 + type IdentityClient = { get: (name: string, init?: unknown) => Promise<unknown> }; 46 + 47 + export type ResolveDidDocumentOptions = { client?: IdentityClient; fetcher?: typeof fetch }; 48 + 49 + type InspectIdentityOptions = ResolveDidDocumentOptions; 50 + 51 + const publicApi = new Client({ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) }); 52 + const publicIdentityApi = publicApi as unknown as IdentityClient; 53 + 54 + export async function inspectIdentity( 55 + did: string, 56 + fallbackHandle: string | null = null, 57 + options: InspectIdentityOptions = {} 58 + ): Promise<IdentityInspection> { 59 + if (!did.startsWith('did:')) { 60 + throw new Error('Identity Inspector requires a DID.'); 61 + } 62 + 63 + const client = options.client ?? publicIdentityApi; 64 + const fetcher = options.fetcher ?? fetch; 65 + const didDocumentUrl = resolveDidDocumentUrl(did); 66 + const didDocument = await resolveDidDocument(did, { client, fetcher }); 67 + const aliases = didDocument.alsoKnownAs ?? []; 68 + const handle = findHandleAlias(aliases) ?? normalizeHandle(fallbackHandle); 69 + const services = didDocument.service ?? []; 70 + const verificationMethods = didDocument.verificationMethod ?? []; 71 + const pds = findPdsEndpoint(services); 72 + const audit = did.startsWith('did:plc:') ? await fetchPlcAudit(did, fetcher).catch(() => []) : []; 73 + const latestOperation = audit.at(-1)?.operation; 74 + const rotationKeys = latestOperation?.rotationKeys ?? []; 75 + 76 + return { 77 + did, 78 + handle, 79 + didDocument, 80 + aliases, 81 + services, 82 + verificationMethods, 83 + rotationKeys, 84 + pds, 85 + didDocumentUrl, 86 + plcAuditUrl: did.startsWith('did:plc:') ? `${didDocumentUrl}/log/audit` : null, 87 + handleValidation: handle ? await validateHandle(handle, did, client, fetcher) : null, 88 + rawJson: JSON.stringify(didDocument, null, 2) 89 + }; 90 + } 91 + 92 + export async function validateHandle( 93 + handle: string, 94 + did: string, 95 + client: IdentityClient = publicIdentityApi, 96 + fetcher: typeof fetch = fetch 97 + ): Promise<HandleValidation> { 98 + const normalizedHandle = normalizeHandle(handle); 99 + 100 + if (!normalizedHandle) { 101 + throw new Error('Handle is empty.'); 102 + } 103 + 104 + const [resolveHandle, wellKnown, dnsTxt] = await Promise.all([ 105 + validateResolveHandle(normalizedHandle, did, client), 106 + validateWellKnown(normalizedHandle, did, fetcher), 107 + validateDnsTxt(normalizedHandle, did, fetcher) 108 + ]); 109 + 110 + return { handle: normalizedHandle, resolveHandle, wellKnown, dnsTxt }; 111 + } 112 + 113 + export function resolveDidDocumentUrl(did: string) { 114 + if (did.startsWith('did:plc:')) { 115 + return `https://plc.directory/${encodeURIComponent(did)}`; 116 + } 117 + 118 + if (did.startsWith('did:web:')) { 119 + const domain = did.slice('did:web:'.length).replaceAll(':', '/'); 120 + return `https://${domain}/.well-known/did.json`; 121 + } 122 + 123 + throw new Error(`Unsupported DID method: ${did}`); 124 + } 125 + 126 + export function findPdsEndpoint(services: DidService[]) { 127 + const pdsService = services.find( 128 + (service) => service.id === '#atproto_pds' || service.type === 'AtprotoPersonalDataServer' 129 + ); 130 + const endpoint = pdsService?.serviceEndpoint; 131 + 132 + return typeof endpoint === 'string' ? endpoint : null; 133 + } 134 + 135 + export function findHandleAlias(aliases: string[]) { 136 + const alias = aliases.find((value) => value.startsWith('at://')); 137 + return alias ? normalizeHandle(alias.slice('at://'.length)) : null; 138 + } 139 + 140 + export function normalizeHandle(handle: string | null | undefined) { 141 + const normalized = handle?.trim().replace(/^@/, '').toLowerCase(); 142 + return normalized || null; 143 + } 144 + 145 + export async function resolveDidDocument(did: string, options: ResolveDidDocumentOptions = {}): Promise<DidDocument> { 146 + const client = options.client ?? publicIdentityApi; 147 + const fetcher = options.fetcher ?? fetch; 148 + 149 + try { 150 + const result = unwrapXrpcData( 151 + await client.get('com.atproto.identity.resolveDid', { params: { did: did as Did } }) 152 + ) as { didDoc: unknown }; 153 + return result.didDoc as DidDocument; 154 + } catch { 155 + const response = await fetcher(resolveDidDocumentUrl(did)); 156 + 157 + if (!response.ok) { 158 + throw new Error(`Could not fetch DID document (${response.status}).`); 159 + } 160 + 161 + return (await response.json()) as DidDocument; 162 + } 163 + } 164 + 165 + async function validateResolveHandle(handle: string, did: string, client: IdentityClient): Promise<IdentityCheck> { 166 + try { 167 + const result = unwrapXrpcData( 168 + await client.get('com.atproto.identity.resolveHandle', { params: { handle: handle as Handle } }) 169 + ) as { did: string }; 170 + const status = result.did === did ? 'valid' : 'mismatch'; 171 + 172 + return { 173 + status, 174 + label: 'XRPC resolveHandle', 175 + detail: 176 + status === 'valid' 177 + ? 'Public ATProto resolver matches this DID.' 178 + : 'Public ATProto resolver returned a different DID.', 179 + value: result.did 180 + }; 181 + } catch { 182 + return unavailableCheck('XRPC resolveHandle', 'Could not resolve this handle through public.api.bsky.app.'); 183 + } 184 + } 185 + 186 + async function validateWellKnown(handle: string, did: string, fetcher: typeof fetch): Promise<IdentityCheck> { 187 + try { 188 + const response = await fetcher(`https://${handle}/.well-known/atproto-did`); 189 + 190 + if (!response.ok) { 191 + return unavailableCheck('.well-known', `HTTP ${response.status} from the handle well-known endpoint.`); 192 + } 193 + 194 + const value = (await response.text()).trim(); 195 + const status = value === did ? 'valid' : 'mismatch'; 196 + 197 + return { 198 + status, 199 + label: '.well-known', 200 + detail: 201 + status === 'valid' 202 + ? 'Handle well-known file asserts this DID.' 203 + : 'Endpoint responded, but did not assert this DID.', 204 + value: value.slice(0, 160) 205 + }; 206 + } catch { 207 + return unavailableCheck('.well-known', 'Could not fetch the handle well-known endpoint.'); 208 + } 209 + } 210 + 211 + async function validateDnsTxt(handle: string, did: string, fetcher: typeof fetch): Promise<IdentityCheck> { 212 + try { 213 + const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(`_atproto.${handle}`)}&type=TXT`; 214 + const response = await fetcher(url, { headers: { accept: 'application/dns-json' } }); 215 + 216 + if (!response.ok) { 217 + return unavailableCheck('DNS TXT', `DNS-over-HTTPS returned HTTP ${response.status}.`); 218 + } 219 + 220 + const data = (await response.json()) as { Answer?: Array<{ data?: string }> }; 221 + const values = data.Answer?.map((answer) => answer.data ?? '').map(stripDnsTxtQuotes) ?? []; 222 + const assertion = values.find((value) => value.startsWith('did=')); 223 + 224 + if (!assertion) { 225 + return unavailableCheck('DNS TXT', 'No _atproto TXT DID assertion was found.'); 226 + } 227 + 228 + const value = assertion.slice('did='.length); 229 + const status = value === did ? 'valid' : 'mismatch'; 230 + 231 + return { 232 + status, 233 + label: 'DNS TXT', 234 + detail: status === 'valid' ? 'DNS TXT record asserts this DID.' : 'DNS TXT record asserts a different DID.', 235 + value 236 + }; 237 + } catch { 238 + return unavailableCheck('DNS TXT', 'Could not query DNS-over-HTTPS for this handle.'); 239 + } 240 + } 241 + 242 + async function fetchPlcAudit(did: string, fetcher: typeof fetch): Promise<PlcAuditEntry[]> { 243 + const response = await fetcher(`${resolveDidDocumentUrl(did)}/log/audit`); 244 + 245 + if (!response.ok) { 246 + return []; 247 + } 248 + 249 + return (await response.json()) as PlcAuditEntry[]; 250 + } 251 + 252 + function unavailableCheck(label: string, detail: string): IdentityCheck { 253 + return { status: 'unavailable', label, detail, value: null }; 254 + } 255 + 256 + function stripDnsTxtQuotes(value: string) { 257 + return value.replace(/^"|"$/g, '').replaceAll('" "', ''); 258 + } 259 + 260 + function unwrapXrpcData(response: unknown) { 261 + if (typeof response !== 'object' || response === null) { 262 + throw new Error('Invalid XRPC response.'); 263 + } 264 + 265 + if (!('ok' in response) || response.ok !== true || !('data' in response)) { 266 + throw new Error('XRPC request failed.'); 267 + } 268 + 269 + return response.data; 270 + }
+4 -29
src/lib/atproto/identity.ts
··· 3 3 import type { Handle } from '@atcute/lexicons/syntax'; 4 4 import type {} from '@atcute/atproto'; 5 5 import type {} from '@atcute/bluesky'; 6 - import type { AccountIdentity, ActorTypeaheadResult, DidDocument } from './types'; 7 - 8 - export type { AccountIdentity, ActorTypeaheadResult } from './types'; 6 + import { findPdsEndpoint, resolveDidDocument } from './identity-inspector'; 7 + import type { AccountIdentity, ActorTypeaheadResult } from './types'; 9 8 10 9 export const defaultIdentity = { handle: 'desertthunder.dev', did: 'did:plc:xg2vq45muivyy3xwatcehspu' }; 11 10 ··· 76 75 } 77 76 78 77 async function resolvePds(did: string): Promise<string | null> { 79 - const response = await fetch(resolveDidDocumentUrl(did)); 80 - 81 - if (!response.ok) { 82 - return null; 83 - } 84 - 85 - const document = (await response.json()) as DidDocument; 86 - const pdsService = document.service?.find( 87 - (service) => service.id === '#atproto_pds' || service.type === 'AtprotoPersonalDataServer' 88 - ); 89 - const endpoint = pdsService?.serviceEndpoint; 90 - 91 - return typeof endpoint === 'string' ? endpoint : null; 92 - } 93 - 94 - function resolveDidDocumentUrl(did: string) { 95 - if (did.startsWith('did:plc:')) { 96 - return `https://plc.directory/${encodeURIComponent(did)}`; 97 - } 98 - 99 - if (did.startsWith('did:web:')) { 100 - const domain = did.slice('did:web:'.length).replaceAll(':', '/'); 101 - return `https://${domain}/.well-known/did.json`; 102 - } 103 - 104 - throw new Error(`Unsupported DID method: ${did}`); 78 + const document = await resolveDidDocument(did); 79 + return findPdsEndpoint(document.service ?? []); 105 80 }
+6 -1
src/lib/atproto/routes.test.ts
··· 1 + /** Move to test/routes.test.ts */ 1 2 import { describe, expect, it } from 'vitest'; 2 - import { collectionPath, recordPath, repoPath } from './routes'; 3 + import { collectionPath, identityPath, recordPath, repoPath } from './routes'; 3 4 4 5 describe('ATProto route helpers', () => { 5 6 it('builds canonical repo routes', () => { ··· 10 11 expect(collectionPath({ did: 'did:plc:abc123', collection: 'app.bsky.feed.post' })).toBe( 11 12 '/repos/did:plc:abc123/collections/app.bsky.feed.post' 12 13 ); 14 + }); 15 + 16 + it('builds canonical identity inspector routes', () => { 17 + expect(identityPath('did:plc:abc123')).toBe('/repos/did:plc:abc123/identity'); 13 18 }); 14 19 15 20 it('encodes record keys in canonical record routes', () => {
+4
src/lib/atproto/routes.ts
··· 6 6 return `/repos/${did}`; 7 7 } 8 8 9 + export function identityPath(did: string) { 10 + return `${repoPath(did)}/identity`; 11 + } 12 + 9 13 export function collectionPath({ did, collection }: CollectionRouteParams) { 10 14 return `${repoPath(did)}/collections/${collection}`; 11 15 }
+18 -1
src/lib/atproto/types.ts
··· 10 10 export type ActorTypeaheadResult = Pick<AccountIdentity, 'handle' | 'did' | 'displayName' | 'avatar'>; 11 11 12 12 export type DidDocument = { 13 - service?: Array<{ id?: string; type?: string; serviceEndpoint?: string | string[] | Record<string, unknown> }>; 13 + '@context'?: unknown; 14 + id?: string; 15 + alsoKnownAs?: string[]; 16 + verificationMethod?: DidVerificationMethod[]; 17 + service?: DidService[]; 18 + }; 19 + 20 + export type DidService = { 21 + id?: string; 22 + type?: string; 23 + serviceEndpoint?: string | string[] | Record<string, unknown>; 24 + }; 25 + 26 + export type DidVerificationMethod = { 27 + id?: string; 28 + type?: string; 29 + controller?: string; 30 + publicKeyMultibase?: string; 14 31 }; 15 32 16 33 export type CollectionSummary = { name: string; icon: string; appLabel: string | null; loadedCount: number | null };
+63 -30
src/lib/components/CollectionBrowser.svelte
··· 3 3 import { onMount } from 'svelte'; 4 4 import { accountSetup } from '$lib/atproto/setup.svelte'; 5 5 import { repoBrowser } from '$lib/atproto/repo.svelte'; 6 - import { recordPath } from '$lib/atproto/routes'; 6 + import { identityPath, recordPath } from '$lib/atproto/routes'; 7 7 import type { CollectionSummary, RepoRecordSummary } from '$lib/atproto/types'; 8 8 import { windowManager } from '$lib/window-manager.svelte'; 9 9 import { SvelteMap } from 'svelte/reactivity'; ··· 57 57 } 58 58 } 59 59 60 + function openIdentityInspector() { 61 + const identity = accountSetup.identity; 62 + if (!identity) return; 63 + 64 + void goto(identityPath(identity.did), { keepFocus: true, noScroll: true }); 65 + } 66 + 60 67 function groupCollections(collections: CollectionSummary[]) { 61 - const groups = new SvelteMap< 62 - string, 63 - { namespace: string; label: string; icon: string; collections: CollectionSummary[] } 64 - >(); 68 + type M = { namespace: string; label: string; icon: string; collections: CollectionSummary[] }; 69 + const groups = new SvelteMap<string, M>(); 65 70 66 71 for (const collection of collections) { 67 72 const namespace = namespaceForCollection(collection.name); ··· 153 158 {/if} 154 159 </p> 155 160 </div> 156 - <form 157 - class="search-box" 158 - role="search" 159 - onsubmit={(event) => { 160 - event.preventDefault(); 161 - searchRecords(); 162 - }}> 163 - <label for="record-search">Search</label> 164 - <div> 165 - <input 166 - id="record-search" 167 - bind:value={searchQuery} 168 - placeholder="Search records" 169 - disabled={!repoBrowser.selectedCollection || repoBrowser.isLoadingRecords || repoBrowser.isSearching} /> 170 - <button type="submit" disabled={!searchQuery.trim() || repoBrowser.isSearching}>Search</button> 171 - {#if repoBrowser.searchQuery} 172 - <button type="button" class="clear-search" onclick={clearSearch}>Clear</button> 173 - {/if} 174 - </div> 175 - </form> 161 + <div class="summary-controls"> 162 + <form 163 + class="search-box" 164 + role="search" 165 + onsubmit={(event) => { 166 + event.preventDefault(); 167 + searchRecords(); 168 + }}> 169 + <label for="record-search">Search</label> 170 + <div> 171 + <input 172 + id="record-search" 173 + bind:value={searchQuery} 174 + placeholder="Search records" 175 + disabled={!repoBrowser.selectedCollection || repoBrowser.isLoadingRecords || repoBrowser.isSearching} /> 176 + <button type="submit" disabled={!searchQuery.trim() || repoBrowser.isSearching}>Search</button> 177 + {#if repoBrowser.searchQuery} 178 + <button type="button" class="clear-search" onclick={clearSearch}>Clear</button> 179 + {/if} 180 + </div> 181 + </form> 182 + <button 183 + class="identity-launcher" 184 + type="button" 185 + disabled={!accountSetup.identity} 186 + onclick={openIdentityInspector}> 187 + <img src="/icons/humanity/apps/identity-inspector.svg" alt="" width="22" height="22" /> 188 + <span>Identity</span> 189 + </button> 190 + </div> 176 191 </div> 177 192 178 193 <div class="record-list"> ··· 188 203 <p class="message">{repoBrowser.isSearching ? 'Searching cached records…' : 'Loading records…'}</p> 189 204 {:else if repoBrowser.records.length === 0} 190 205 <p class="message"> 191 - {repoBrowser.searchQuery 192 - ? 'No cached records matched that search.' 193 - : 'No public records found for this collection.'} 206 + {#if repoBrowser.searchQuery} 207 + No cached records matched that search. 208 + {:else} 209 + No public records found for this collection. 210 + {/if} 194 211 </p> 195 212 {:else} 196 213 {#each repoBrowser.records as record (record.uri)} ··· 378 395 border-bottom: 1px solid #b29366; 379 396 } 380 397 398 + .summary-controls, 381 399 .search-box { 382 400 display: grid; 383 401 gap: var(--space-1); ··· 410 428 font-size: var(--text-1); 411 429 } 412 430 413 - .search-box button { 431 + .search-box button, 432 + .identity-launcher { 414 433 padding: 0.35rem 0.55rem; 415 434 color: #2c180d; 416 435 background: linear-gradient(#fff8e8, #d3b17d); ··· 422 441 font-weight: 700; 423 442 } 424 443 444 + .identity-launcher { 445 + display: inline-flex; 446 + align-items: center; 447 + justify-self: end; 448 + gap: var(--space-1); 449 + min-height: 1.9rem; 450 + cursor: default; 451 + } 452 + 453 + .identity-launcher:active { 454 + scale: 0.96; 455 + } 456 + 425 457 .search-box button:disabled, 426 - .search-box input:disabled { 458 + .search-box input:disabled, 459 + .identity-launcher:disabled { 427 460 opacity: 0.62; 428 461 } 429 462
+599
src/lib/components/IdentityInspector.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { accountSetup } from '$lib/atproto/setup.svelte'; 4 + import { inspectIdentity, type IdentityCheck, type IdentityInspection } from '$lib/atproto/identity-inspector'; 5 + import { errorMessage } from '$lib/utils/errors'; 6 + 7 + let inspection = $state<IdentityInspection | null>(null); 8 + let selectedSection = $state<'overview' | 'aliases' | 'services' | 'keys' | 'raw'>('overview'); 9 + let isLoading = $state(false); 10 + let error = $state<string | null>(null); 11 + let copiedValue = $state<string | null>(null); 12 + 13 + const identity = $derived(accountSetup.identity); 14 + const keyrings = $derived.by(() => { 15 + if (!inspection) return []; 16 + 17 + return [ 18 + { id: 'overview', label: 'Identity', icon: '/icons/humanity/status/network-wireless-encrypted.svg', count: 1 }, 19 + { id: 'aliases', label: 'Handle aliases', icon: '/icons/humanity/places/user-home.svg', count: inspection.aliases.length }, 20 + { 21 + id: 'services', 22 + label: 'Services', 23 + icon: '/icons/humanity/apps/internet-feed-reader.svg', 24 + count: inspection.services.length 25 + }, 26 + { 27 + id: 'keys', 28 + label: 'Verification keys', 29 + icon: '/icons/humanity/status/network-wireless-encrypted.svg', 30 + count: inspection.verificationMethods.length + inspection.rotationKeys.length 31 + }, 32 + { id: 'raw', label: 'DID document', icon: '/icons/humanity/mimes/text-x-generic.svg', count: 1 } 33 + ] as const; 34 + }); 35 + 36 + onMount(() => { 37 + void loadInspection(); 38 + }); 39 + 40 + async function loadInspection() { 41 + if (!identity) { 42 + error = 'No repository identity is configured.'; 43 + return; 44 + } 45 + 46 + isLoading = true; 47 + error = null; 48 + 49 + try { 50 + inspection = await inspectIdentity(identity.did, identity.handle); 51 + } catch (unknownError) { 52 + error = errorMessage(unknownError, 'Could not inspect that identity.'); 53 + } finally { 54 + isLoading = false; 55 + } 56 + } 57 + 58 + async function copyValue(value: string | null | undefined) { 59 + if (!value) return; 60 + 61 + try { 62 + await navigator.clipboard.writeText(value); 63 + copiedValue = value; 64 + window.setTimeout(() => { 65 + if (copiedValue === value) copiedValue = null; 66 + }, 1600); 67 + } catch { 68 + copiedValue = null; 69 + } 70 + } 71 + 72 + function statusLabel(status: IdentityCheck['status']) { 73 + if (status === 'valid') return 'Trusted'; 74 + if (status === 'mismatch') return 'Mismatch'; 75 + if (status === 'unsupported') return 'Unsupported'; 76 + return 'Unavailable'; 77 + } 78 + </script> 79 + 80 + <section class="identity-inspector" aria-label="Identity Inspector"> 81 + <aside class="keyring-sidebar" aria-label="Identity keyrings"> 82 + <header> 83 + <img src="/icons/humanity/apps/identity-inspector.svg" alt="" width="36" height="36" /> 84 + <div> 85 + <h2>Passwords and Encryption Keys</h2> 86 + <p>{identity ? `@${identity.handle}` : 'No identity loaded'}</p> 87 + </div> 88 + </header> 89 + 90 + <nav aria-label="Identity sections"> 91 + {#if keyrings.length === 0} 92 + <p class="sidebar-empty">Loading keyrings...</p> 93 + {:else} 94 + {#each keyrings as keyring (keyring.id)} 95 + <button 96 + type="button" 97 + class:active={selectedSection === keyring.id} 98 + aria-current={selectedSection === keyring.id ? 'page' : undefined} 99 + onclick={() => (selectedSection = keyring.id)}> 100 + <img src={keyring.icon} alt="" width="24" height="24" /> 101 + <span>{keyring.label}</span> 102 + <small>{keyring.count}</small> 103 + </button> 104 + {/each} 105 + {/if} 106 + </nav> 107 + </aside> 108 + 109 + <div class="detail-pane"> 110 + <div class="keyring-toolbar" aria-label="Identity actions"> 111 + <button type="button" onclick={loadInspection} disabled={isLoading}> 112 + <img src="/icons/humanity/actions/mail-send-receive.svg" alt="" width="18" height="18" /> 113 + <span>{isLoading ? 'Refreshing' : 'Refresh'}</span> 114 + </button> 115 + <button type="button" onclick={() => copyValue(inspection?.did)} disabled={!inspection}> 116 + <img src="/icons/humanity/mimes/text-x-generic.svg" alt="" width="18" height="18" /> 117 + <span>Copy DID</span> 118 + </button> 119 + {#if inspection?.pds} 120 + <button type="button" onclick={() => window.open(inspection?.pds ?? '', '_blank', 'noopener,noreferrer')}> 121 + <img src="/icons/humanity/apps/web-browser.svg" alt="" width="18" height="18" /> 122 + <span>Open PDS</span> 123 + </button> 124 + {/if} 125 + </div> 126 + 127 + {#if error} 128 + <div class="inspector-message error"> 129 + <img src="/icons/humanity/status/network-wireless-encrypted.svg" alt="" width="32" height="32" /> 130 + <p>{error}</p> 131 + </div> 132 + {:else if isLoading || !inspection} 133 + <div class="inspector-message"> 134 + <img src="/icons/humanity/status/network-wireless-encrypted.svg" alt="" width="32" height="32" /> 135 + <p>Unlocking public identity material...</p> 136 + </div> 137 + {:else} 138 + <header class="identity-heading"> 139 + <img src={identity?.avatar ?? '/icons/humanity/status/network-wireless-encrypted.svg'} alt="" width="48" height="48" /> 140 + <div> 141 + <p>Default public keyring</p> 142 + <h2>{inspection.handle ? `@${inspection.handle}` : inspection.did}</h2> 143 + <span>{inspection.did}</span> 144 + </div> 145 + </header> 146 + 147 + {#if selectedSection === 'overview'} 148 + <section class="property-section" aria-label="Identity overview"> 149 + <h3>Identity Properties</h3> 150 + <div class="property-grid"> 151 + {@render PropertyRow('DID', inspection.did, copyValue, copiedValue === inspection.did)} 152 + {@render PropertyRow( 153 + 'Handle', 154 + inspection.handle ?? 'No handle alias', 155 + copyValue, 156 + copiedValue === inspection.handle 157 + )} 158 + {@render PropertyRow('PDS', inspection.pds ?? 'Not advertised', copyValue, copiedValue === inspection.pds)} 159 + {@render PropertyRow( 160 + 'DID document', 161 + inspection.didDocumentUrl, 162 + copyValue, 163 + copiedValue === inspection.didDocumentUrl 164 + )} 165 + </div> 166 + 167 + {#if inspection.handleValidation} 168 + <div class="validation-list" aria-label="Handle validation"> 169 + {#each [inspection.handleValidation.resolveHandle, inspection.handleValidation.wellKnown, inspection.handleValidation.dnsTxt] as check (check.label)} 170 + <div class="validation-row" class:mismatch={check.status === 'mismatch'} class:valid={check.status === 'valid'}> 171 + <img 172 + src={check.status === 'valid' 173 + ? '/icons/humanity/status/network-wireless-encrypted.svg' 174 + : check.status === 'mismatch' 175 + ? '/icons/humanity/status/audio-volume-medium.svg' 176 + : '/icons/humanity/apps/identity-inspector.svg'} 177 + alt="" 178 + width="24" 179 + height="24" /> 180 + <div> 181 + <strong>{check.label}</strong> 182 + <p>{check.detail}</p> 183 + {#if check.value} 184 + <code>{check.value}</code> 185 + {/if} 186 + </div> 187 + <span>{statusLabel(check.status)}</span> 188 + </div> 189 + {/each} 190 + </div> 191 + {/if} 192 + </section> 193 + {:else if selectedSection === 'aliases'} 194 + <section class="property-section" aria-label="Handle aliases"> 195 + <h3>Aliases</h3> 196 + <div class="list-box"> 197 + {#each inspection.aliases as alias (alias)} 198 + {@render PropertyRow('alsoKnownAs', alias, copyValue, copiedValue === alias)} 199 + {:else} 200 + <p class="empty-row">No aliases are listed in the DID document.</p> 201 + {/each} 202 + </div> 203 + </section> 204 + {:else if selectedSection === 'services'} 205 + <section class="property-section" aria-label="Services"> 206 + <h3>Services</h3> 207 + <div class="list-box"> 208 + {#each inspection.services as service, index (`${service.id}-${index}`)} 209 + <div class="object-row"> 210 + <img src="/icons/humanity/apps/internet-feed-reader.svg" alt="" width="28" height="28" /> 211 + <div> 212 + <strong>{service.id ?? 'Unnamed service'}</strong> 213 + <p>{service.type ?? 'Unknown service type'}</p> 214 + <code>{typeof service.serviceEndpoint === 'string' ? service.serviceEndpoint : JSON.stringify(service.serviceEndpoint)}</code> 215 + </div> 216 + </div> 217 + {:else} 218 + <p class="empty-row">No services are listed in the DID document.</p> 219 + {/each} 220 + </div> 221 + </section> 222 + {:else if selectedSection === 'keys'} 223 + <section class="property-section" aria-label="Keys"> 224 + <h3>Verification Methods</h3> 225 + <div class="list-box"> 226 + {#each inspection.verificationMethods as method, index (`${method.id}-${index}`)} 227 + <div class="object-row"> 228 + <img src="/icons/humanity/status/network-wireless-encrypted.svg" alt="" width="28" height="28" /> 229 + <div> 230 + <strong>{method.id ?? 'Unnamed verification method'}</strong> 231 + <p>{method.type ?? 'Unknown key type'}</p> 232 + <code>{method.publicKeyMultibase ?? method.controller ?? 'No public key material'}</code> 233 + </div> 234 + </div> 235 + {:else} 236 + <p class="empty-row">No verification methods are listed in the DID document.</p> 237 + {/each} 238 + </div> 239 + 240 + <h3>Rotation Keys</h3> 241 + <div class="list-box"> 242 + {#each inspection.rotationKeys as key (key)} 243 + {@render PropertyRow('PLC rotation key', key, copyValue, copiedValue === key)} 244 + {:else} 245 + <p class="empty-row">No PLC rotation keys were available.</p> 246 + {/each} 247 + </div> 248 + </section> 249 + {:else} 250 + <section class="property-section raw-section" aria-label="Raw DID document"> 251 + <h3>Raw DID Document</h3> 252 + <pre>{inspection.rawJson}</pre> 253 + </section> 254 + {/if} 255 + {/if} 256 + </div> 257 + </section> 258 + 259 + {#snippet PropertyRow(label: string, value: string, oncopy: (value: string) => unknown, copied = false)} 260 + <div class="property-row"> 261 + <dt>{label}</dt> 262 + <dd> 263 + <code>{value}</code> 264 + <button type="button" onclick={() => oncopy(value)}> 265 + <img src="/icons/humanity/mimes/text-x-generic.svg" alt="" width="16" height="16" /> 266 + <span>{copied ? 'Copied' : 'Copy'}</span> 267 + </button> 268 + </dd> 269 + </div> 270 + {/snippet} 271 + 272 + <style> 273 + .identity-inspector { 274 + display: grid; 275 + grid-template-columns: minmax(13rem, 16rem) minmax(0, 1fr); 276 + height: 100%; 277 + overflow: hidden; 278 + color: #2f2218; 279 + background: #f7f0e3; 280 + border: 1px solid #8d7557; 281 + box-shadow: 0 1px 0 rgb(255 255 255 / 0.7) inset; 282 + } 283 + 284 + .keyring-sidebar { 285 + display: grid; 286 + grid-template-rows: auto minmax(0, 1fr); 287 + min-height: 0; 288 + background: linear-gradient(90deg, #c9b38f, #e7d8bf); 289 + border-right: 1px solid #8f704d; 290 + } 291 + 292 + .keyring-sidebar header { 293 + display: flex; 294 + gap: var(--space-2); 295 + align-items: center; 296 + padding: var(--space-3); 297 + border-bottom: 1px solid #9a7b57; 298 + box-shadow: 0 1px 0 rgb(255 255 255 / 0.5); 299 + } 300 + 301 + .keyring-sidebar h2 { 302 + font-size: var(--text-2); 303 + line-height: var(--leading-tight); 304 + text-wrap: balance; 305 + } 306 + 307 + .keyring-sidebar p, 308 + .sidebar-empty, 309 + .identity-heading p, 310 + .identity-heading span, 311 + .validation-row p, 312 + .object-row p, 313 + .empty-row { 314 + color: #68513a; 315 + font-size: var(--text-1); 316 + } 317 + 318 + .keyring-sidebar nav { 319 + display: grid; 320 + align-content: start; 321 + gap: 1px; 322 + min-height: 0; 323 + padding: var(--space-2); 324 + overflow: auto; 325 + } 326 + 327 + .keyring-sidebar button { 328 + display: grid; 329 + grid-template-columns: 1.5rem minmax(0, 1fr) auto; 330 + gap: var(--space-2); 331 + align-items: center; 332 + min-height: 2.35rem; 333 + padding: var(--space-1) var(--space-2); 334 + text-align: left; 335 + border: 1px solid transparent; 336 + border-radius: var(--radius-1); 337 + cursor: default; 338 + } 339 + 340 + .keyring-sidebar button:hover, 341 + .keyring-sidebar button.active { 342 + background: linear-gradient(#f8edda, #cdb38e); 343 + border-color: #fff8ea #987755 #765533 #f0dcc0; 344 + box-shadow: 0 1px 0 rgb(255 255 255 / 0.72) inset; 345 + } 346 + 347 + .keyring-sidebar button span { 348 + overflow: hidden; 349 + text-overflow: ellipsis; 350 + white-space: nowrap; 351 + } 352 + 353 + .keyring-sidebar small { 354 + min-width: 1.4rem; 355 + padding: 0 0.3rem; 356 + color: #3b2919; 357 + text-align: center; 358 + background: #efe1c8; 359 + border: 1px solid #a58a63; 360 + border-radius: 999px; 361 + box-shadow: var(--shadow-sunken); 362 + font-variant-numeric: tabular-nums; 363 + } 364 + 365 + .detail-pane { 366 + display: grid; 367 + grid-template-rows: auto auto minmax(0, 1fr); 368 + min-width: 0; 369 + min-height: 0; 370 + background: #fffaf0; 371 + } 372 + 373 + .keyring-toolbar { 374 + display: flex; 375 + gap: var(--space-1); 376 + align-items: center; 377 + min-height: 2.35rem; 378 + padding: var(--space-1) var(--space-2); 379 + background: linear-gradient(#f7ead5, #cfb48f); 380 + border-bottom: 1px solid #9a7b57; 381 + box-shadow: 0 1px 0 rgb(255 255 255 / 0.62) inset; 382 + } 383 + 384 + .keyring-toolbar button, 385 + .property-row button { 386 + display: inline-flex; 387 + gap: var(--space-1); 388 + align-items: center; 389 + min-height: 1.75rem; 390 + padding: 0 var(--space-2); 391 + color: #2f2218; 392 + text-decoration: none; 393 + background: linear-gradient(#fff8e8, #d7bd99); 394 + border: 1px solid #a1845f; 395 + border-radius: var(--radius-1); 396 + box-shadow: 0 1px 0 rgb(255 255 255 / 0.65) inset; 397 + cursor: default; 398 + transition-property: filter, scale; 399 + transition-duration: 120ms; 400 + } 401 + 402 + .keyring-toolbar button:active, 403 + .property-row button:active { 404 + scale: 0.96; 405 + } 406 + 407 + .keyring-toolbar button:disabled { 408 + opacity: 0.58; 409 + } 410 + 411 + .inspector-message { 412 + display: flex; 413 + gap: var(--space-2); 414 + align-items: center; 415 + margin: var(--space-4); 416 + padding: var(--space-3); 417 + background: #f2e5ca; 418 + border: 1px solid #b99a70; 419 + border-radius: var(--radius-1); 420 + } 421 + 422 + .inspector-message.error { 423 + color: #5b160f; 424 + background: #f3d5ca; 425 + border-color: #b46b55; 426 + } 427 + 428 + .identity-heading { 429 + display: flex; 430 + gap: var(--space-3); 431 + align-items: center; 432 + min-width: 0; 433 + padding: var(--space-3) var(--space-4); 434 + background: #f8efdf; 435 + border-bottom: 1px solid #d6c2a5; 436 + } 437 + 438 + .identity-heading img { 439 + width: 3rem; 440 + height: 3rem; 441 + object-fit: cover; 442 + border: 1px solid rgb(68 45 22 / 0.28); 443 + border-radius: var(--radius-1); 444 + } 445 + 446 + .identity-heading h2 { 447 + font-size: var(--text-5); 448 + line-height: var(--leading-tight); 449 + text-wrap: balance; 450 + } 451 + 452 + .identity-heading span { 453 + display: block; 454 + overflow-wrap: anywhere; 455 + font-family: var(--font-mono); 456 + } 457 + 458 + .property-section { 459 + min-height: 0; 460 + padding: var(--space-4); 461 + overflow: auto; 462 + } 463 + 464 + .property-section h3 { 465 + margin-bottom: var(--space-2); 466 + font-size: var(--text-3); 467 + line-height: var(--leading-tight); 468 + } 469 + 470 + .property-section h3:not(:first-child) { 471 + margin-top: var(--space-4); 472 + } 473 + 474 + .property-grid, 475 + .list-box, 476 + .validation-list { 477 + display: grid; 478 + gap: var(--space-2); 479 + } 480 + 481 + .property-row, 482 + .object-row, 483 + .validation-row { 484 + background: #fff8ed; 485 + border: 1px solid #d0b895; 486 + border-radius: var(--radius-1); 487 + box-shadow: 0 1px 0 rgb(255 255 255 / 0.72) inset; 488 + } 489 + 490 + .property-row { 491 + display: grid; 492 + grid-template-columns: minmax(7rem, 10rem) minmax(0, 1fr); 493 + } 494 + 495 + .property-row dt { 496 + padding: var(--space-2); 497 + color: #5c442f; 498 + background: #ebdbc1; 499 + border-right: 1px solid #d0b895; 500 + font-weight: 700; 501 + } 502 + 503 + .property-row dd { 504 + display: grid; 505 + grid-template-columns: minmax(0, 1fr) auto; 506 + gap: var(--space-2); 507 + align-items: center; 508 + min-width: 0; 509 + padding: var(--space-1) var(--space-2); 510 + } 511 + 512 + code, 513 + pre { 514 + font-family: var(--font-mono); 515 + font-size: var(--text-1); 516 + } 517 + 518 + code { 519 + overflow-wrap: anywhere; 520 + } 521 + 522 + .validation-row, 523 + .object-row { 524 + display: grid; 525 + grid-template-columns: 2rem minmax(0, 1fr) auto; 526 + gap: var(--space-2); 527 + align-items: start; 528 + padding: var(--space-2); 529 + } 530 + 531 + .object-row { 532 + grid-template-columns: 2rem minmax(0, 1fr); 533 + } 534 + 535 + .validation-row.valid { 536 + border-color: #8ca05f; 537 + } 538 + 539 + .validation-row.mismatch { 540 + border-color: #b27a4c; 541 + } 542 + 543 + .validation-row span { 544 + padding: 0.12rem var(--space-2); 545 + color: #3d2a1a; 546 + background: #ead8b9; 547 + border: 1px solid #b89a71; 548 + border-radius: 999px; 549 + font-size: var(--text-1); 550 + font-weight: 700; 551 + } 552 + 553 + .raw-section pre { 554 + min-height: 20rem; 555 + margin: 0; 556 + padding: var(--space-3); 557 + overflow: auto; 558 + color: #28190f; 559 + background: #fff8ed; 560 + border: 1px solid #c6ad89; 561 + border-radius: var(--radius-1); 562 + box-shadow: var(--shadow-sunken); 563 + white-space: pre-wrap; 564 + overflow-wrap: anywhere; 565 + } 566 + 567 + .empty-row, 568 + .sidebar-empty { 569 + padding: var(--space-2); 570 + } 571 + 572 + @media (max-width: 700px) { 573 + .identity-inspector { 574 + grid-template-columns: 1fr; 575 + grid-template-rows: auto minmax(0, 1fr); 576 + } 577 + 578 + .keyring-sidebar { 579 + grid-template-rows: auto auto; 580 + border-right: 0; 581 + border-bottom: 1px solid #8f704d; 582 + } 583 + 584 + .keyring-sidebar nav { 585 + grid-auto-flow: column; 586 + grid-auto-columns: minmax(10rem, 1fr); 587 + overflow-x: auto; 588 + } 589 + 590 + .property-row { 591 + grid-template-columns: 1fr; 592 + } 593 + 594 + .property-row dt { 595 + border-right: 0; 596 + border-bottom: 1px solid #d0b895; 597 + } 598 + } 599 + </style>
+13 -2
src/lib/window-manager.svelte.ts
··· 1 - export type WindowId = 'main' | 'about-computer' | 'gedit' | 'document-viewer'; 1 + /* Split up this file into windows/types.ts & windows/manager.svelte.ts */ 2 + 3 + export type WindowId = 'main' | 'about-computer' | 'gedit' | 'document-viewer' | 'identity-inspector'; 2 4 3 5 export type ManagedWindow = { 4 6 id: WindowId; ··· 47 49 isMinimized: false, 48 50 isMaximized: false, 49 51 zIndex: 4 52 + }, 53 + { 54 + id: 'identity-inspector', 55 + title: 'Identity Inspector', 56 + icon: '/icons/humanity/apps/identity-inspector.svg', 57 + isOpen: false, 58 + isMinimized: false, 59 + isMaximized: false, 60 + zIndex: 5 50 61 } 51 62 ]); 52 63 53 - private nextZIndex = 4; 64 + private nextZIndex = 5; 54 65 55 66 get openWindows() { 56 67 return this.windows.filter((window) => window.isOpen);
+66 -9
src/routes/+layout.svelte
··· 1 + <!-- FIXME: this is becoming a "god" component, not an orchestrator 2 + like it should --> 1 3 <script lang="ts"> 2 4 import { onMount } from 'svelte'; 3 5 import { dev } from '$app/environment'; ··· 17 19 import DocumentViewer from '$lib/components/DocumentViewer.svelte'; 18 20 import Gedit from '$lib/components/Gedit.svelte'; 19 21 import GnomePanel from '$lib/components/GnomePanel.svelte'; 22 + import IdentityInspector from '$lib/components/IdentityInspector.svelte'; 20 23 import LockScreen from '$lib/components/LockScreen.svelte'; 21 24 import NativeWindow from '$lib/components/NativeWindow.svelte'; 22 25 import NotImplementedDialog from '$lib/components/NotImplementedDialog.svelte'; ··· 53 56 const aboutWindow = $derived(windowManager.getWindow('about-computer')); 54 57 const geditWindow = $derived(windowManager.getWindow('gedit')); 55 58 const documentViewerWindow = $derived(windowManager.getWindow('document-viewer')); 59 + const identityInspectorWindow = $derived(windowManager.getWindow('identity-inspector')); 56 60 const shortcuts = $derived([ 57 61 { 58 62 label: 'ibex Home', ··· 73 77 } 74 78 }, 75 79 { 80 + label: 'Identity Inspector', 81 + icon: '/icons/humanity/apps/identity-inspector.svg', 82 + selected: identityInspectorWindow?.isOpen && !identityInspectorWindow.isMinimized, 83 + onactivate: () => { 84 + if (accountSetup.identity) { 85 + void goto(resolve(`/repos/${accountSetup.identity.did}/identity`), { keepFocus: true, noScroll: true }); 86 + return; 87 + } 88 + 89 + windowManager.restore('main'); 90 + void goto(resolve('/browse')); 91 + } 92 + }, 93 + { 76 94 label: 'Computer', 77 95 icon: '/icons/humanity/devices/computer.svg', 78 96 selected: showAboutComputer, ··· 115 133 const route = repoRouteFromParams(); 116 134 if (bootStatus !== 'ready' || !route) return; 117 135 118 - const routeKey = [route.did, route.collection, route.rkey].filter(Boolean).join('/'); 136 + const routeKey = [route.did, route.app, route.collection, route.rkey].filter(Boolean).join('/'); 119 137 if (handledRepoRoute === routeKey) return; 120 138 121 139 handledRepoRoute = routeKey; ··· 177 195 accountSetup.load(); 178 196 } 179 197 180 - async function openRepoRoute(route: { did: string; collection?: string; rkey?: string }) { 198 + async function openRepoRoute(route: { did: string; app?: string; collection?: string; rkey?: string }) { 181 199 try { 182 200 const { hydratePublicIdentity } = await import('$lib/atproto/identity'); 183 201 const identity = await hydratePublicIdentity(route.did); 184 202 185 203 accountSetup.save(identity); 186 204 windowManager.restore('main'); 205 + 206 + if (route.app === 'identity') { 207 + await repoBrowser.load(identity); 208 + windowManager.setTitle('identity-inspector', `${identity.handle} - Identity Inspector`); 209 + windowManager.open('identity-inspector'); 210 + return; 211 + } 187 212 188 213 if (route.collection && route.rkey) { 189 214 await repoBrowser.openRecordRoute(identity, route.collection, route.rkey); ··· 207 232 if (!page.route.id?.startsWith('/repos/[did]')) return null; 208 233 209 234 const { did, collection, rkey } = page.params; 210 - if (!did) return null; 211 235 212 - return { did, collection, rkey }; 236 + if (!did) return null; 237 + return { did, app: page.route.id === '/repos/[did]/identity' ? 'identity' : undefined, collection, rkey }; 213 238 } 214 239 215 240 async function waitForMinimumBootTime(startedAt: number) { ··· 332 357 </div> 333 358 {/if} 334 359 360 + {#if identityInspectorWindow?.isOpen && !identityInspectorWindow.isMinimized} 361 + <div 362 + class="identity-inspector-window" 363 + class:maximized={identityInspectorWindow.isMaximized} 364 + style:z-index={identityInspectorWindow.zIndex}> 365 + <AppWindow 366 + windowId="identity-inspector" 367 + title={identityInspectorWindow.title} 368 + icon="/icons/humanity/apps/identity-inspector.svg" 369 + address={accountSetup.identity ? `/repos/${accountSetup.identity.did}/identity` : undefined} 370 + maximized={identityInspectorWindow.isMaximized} 371 + onfocus={() => windowManager.focus('identity-inspector')} 372 + onminimize={() => windowManager.minimize('identity-inspector')} 373 + onmaximize={() => windowManager.toggleMaximize('identity-inspector')} 374 + onclose={() => windowManager.close('identity-inspector')}> 375 + <IdentityInspector /> 376 + </AppWindow> 377 + </div> 378 + {/if} 379 + 335 380 {#if showStickyNote} 336 381 <aside class="sticky-note" aria-label="Design note"> 337 382 <button type="button" aria-label="Close note" onclick={() => (showStickyNote = false)}>×</button> ··· 392 437 .primary-window.maximized, 393 438 .about-window.maximized, 394 439 .document-viewer-window.maximized, 395 - .gedit-window.maximized { 440 + .gedit-window.maximized, 441 + .identity-inspector-window.maximized { 396 442 position: fixed; 397 443 top: 1.75rem; 398 444 right: 0; ··· 405 451 406 452 .about-window, 407 453 .document-viewer-window, 408 - .gedit-window { 454 + .gedit-window, 455 + .identity-inspector-window { 409 456 position: absolute; 410 457 z-index: 3; 411 458 } ··· 429 476 left: min(20rem, 24vw); 430 477 width: min(44rem, calc(100vw - 2rem)); 431 478 height: min(34rem, calc(100vh - 5rem)); 479 + } 480 + 481 + .identity-inspector-window { 482 + top: min(4.75rem, 9vh); 483 + left: min(13rem, 16vw); 484 + width: min(56rem, calc(100vw - 2rem)); 485 + height: min(39rem, calc(100vh - 5rem)); 432 486 } 433 487 434 488 .about-window :global(.app-window), 435 489 .document-viewer-window :global(.app-window), 436 - .gedit-window :global(.app-window) { 490 + .gedit-window :global(.app-window), 491 + .identity-inspector-window :global(.app-window) { 437 492 height: 100%; 438 493 } 439 494 ··· 481 536 } 482 537 483 538 .about-window, 484 - .gedit-window { 539 + .gedit-window, 540 + .identity-inspector-window { 485 541 left: auto; 486 542 right: var(--space-3); 487 543 } ··· 506 562 } 507 563 508 564 .about-window, 509 - .gedit-window { 565 + .gedit-window, 566 + .identity-inspector-window { 510 567 top: var(--space-3); 511 568 right: var(--space-3); 512 569 left: var(--space-3);
+4
src/routes/repos/[did]/identity/+page.svelte
··· 1 + <!-- TODO: we should use this page properly, such that identity manager 2 + can direct link to it 3 + --> 4 + <script lang="ts"></script>