Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

fix: pds badge (a bit)

+328 -65
+24 -42
src/components/PdsDialog.tsx
··· 10 10 import {Trans} from '@lingui/react/macro' 11 11 12 12 import { 13 - getPdsFallbackFaviconUrl, 13 + getPdsFallbackFaviconUrls, 14 14 isBridgedPdsUrl, 15 15 isBskyPdsUrl, 16 16 } from '#/state/queries/pds-label.util' ··· 247 247 function FaviconBadgeIcon({ 248 248 size, 249 249 borderRadius, 250 - faviconUrl, 251 - fallbackFaviconUrl, 250 + faviconUrls, 252 251 }: { 253 252 size: number 254 253 borderRadius: number 255 - faviconUrl: string 256 - fallbackFaviconUrl?: string 254 + faviconUrls: string[] 257 255 }) { 258 - const getInitialUrl = () => { 259 - if (!failedFaviconUrls.has(faviconUrl)) return faviconUrl 260 - if (fallbackFaviconUrl && !failedFaviconUrls.has(fallbackFaviconUrl)) { 261 - return fallbackFaviconUrl 262 - } 263 - return undefined 264 - } 265 - const [currentUrl, setCurrentUrl] = useState<string | undefined>( 266 - getInitialUrl, 267 - ) 256 + const t = useTheme() 257 + const getNextUrl = (currentUrl?: string) => 258 + faviconUrls.find( 259 + url => url !== currentUrl && url && !failedFaviconUrls.has(url), 260 + ) 261 + const [currentUrl, setCurrentUrl] = useState<string | undefined>(getNextUrl) 268 262 const [imageLoaded, setImageLoaded] = useState(false) 269 263 270 264 if (!currentUrl) { ··· 279 273 width: size, 280 274 height: size, 281 275 borderRadius, 276 + backgroundColor: t.atoms.bg_contrast_100.backgroundColor, 282 277 }, 283 278 ]}> 284 - <DbBadgeIcon size={size} borderRadius={borderRadius} /> 279 + {!imageLoaded ? ( 280 + <DbBadgeIcon size={size} borderRadius={borderRadius} /> 281 + ) : null} 285 282 <Image 286 283 key={currentUrl} 287 284 source={{uri: currentUrl}} ··· 301 298 failedFaviconUrls.add(currentUrl) 302 299 setImageLoaded(false) 303 300 304 - if ( 305 - fallbackFaviconUrl && 306 - currentUrl !== fallbackFaviconUrl && 307 - !failedFaviconUrls.has(fallbackFaviconUrl) 308 - ) { 309 - setCurrentUrl(fallbackFaviconUrl) 310 - return 311 - } 312 - 313 - setCurrentUrl(undefined) 301 + setCurrentUrl(getNextUrl(currentUrl)) 314 302 }} 315 303 /> 316 304 </View> ··· 335 323 const r = borderRadius ?? size / 5 336 324 if (isBsky) return <BskyBadgeSVG size={size} /> 337 325 if (isBridged) return <FediverseBadgeSVG size={size} /> 338 - const fallbackFaviconUrl = pdsUrl 339 - ? getPdsFallbackFaviconUrl(pdsUrl) 340 - : undefined 341 - if (faviconUrl) 342 - return ( 343 - <FaviconBadgeIcon 344 - key={`${faviconUrl}|${fallbackFaviconUrl ?? ''}`} 345 - size={size} 346 - borderRadius={r} 347 - faviconUrl={faviconUrl} 348 - fallbackFaviconUrl={fallbackFaviconUrl} 349 - /> 350 - ) 351 - if (fallbackFaviconUrl) 326 + const faviconCandidates = Array.from( 327 + new Set( 328 + [faviconUrl, ...(pdsUrl ? getPdsFallbackFaviconUrls(pdsUrl) : [])].filter( 329 + Boolean, 330 + ) as string[], 331 + ), 332 + ) 333 + if (faviconCandidates.length > 0) 352 334 return ( 353 335 <FaviconBadgeIcon 354 - key={fallbackFaviconUrl} 336 + key={faviconCandidates.join('|')} 355 337 size={size} 356 338 borderRadius={r} 357 - faviconUrl={fallbackFaviconUrl} 339 + faviconUrls={faviconCandidates} 358 340 /> 359 341 ) 360 342 return <DbBadgeIcon size={size} borderRadius={r} />
+38
src/lib/atproto/did.ts
··· 1 + import {type Did} from '@atproto/api' 2 + 3 + export function getDidDocumentUrl( 4 + did: Did, 5 + plcDirectory: string, 6 + ): string | undefined { 7 + if (did.startsWith('did:plc:')) { 8 + return `${plcDirectory}/${did}` 9 + } 10 + 11 + if (!did.startsWith('did:web:')) { 12 + return undefined 13 + } 14 + 15 + const msid = did.slice('did:web:'.length) 16 + if (!msid) { 17 + return undefined 18 + } 19 + 20 + const [hostEnc, ...pathSegments] = msid.split(':') 21 + if (!hostEnc) { 22 + return undefined 23 + } 24 + 25 + const host = hostEnc.replace(/%3A/gi, ':') 26 + const protocol = 27 + host.startsWith('localhost') && 28 + (host.length === 'localhost'.length || 29 + host.charAt('localhost'.length) === ':') 30 + ? 'http' 31 + : 'https' 32 + const path = 33 + pathSegments.length > 0 34 + ? `/${pathSegments.join('/')}/did.json` 35 + : '/.well-known/did.json' 36 + 37 + return `${protocol}://${host}${path}` 38 + }
+1
src/screens/Settings/RunesSettings.tsx
··· 441 441 const presets = [ 442 442 'https://twenty-icons.com/(pds)', 443 443 'https://favicon.im/(pds)?larger=true&throw-error-on-404=true', 444 + 'https://favicon.blueat.net/(pds)?larger=true&throw-error-on-404=true', 444 445 ] 445 446 446 447 return (
+100
src/state/queries/__tests__/resolve-identity-test.ts
··· 1 + import {beforeEach, describe, expect, it, jest} from '@jest/globals' 2 + 3 + const mockResolveDid: jest.MockedFunction< 4 + ( 5 + params: {did: string}, 6 + options?: {signal?: AbortSignal}, 7 + ) => Promise<{ 8 + data: { 9 + didDoc: Record<string, unknown> 10 + } 11 + }> 12 + > = jest.fn() 13 + const mockDispose: jest.MockedFunction<() => void> = jest.fn() 14 + 15 + jest.mock('#/state/session/agent', () => ({ 16 + createPublicAgent() { 17 + return { 18 + com: { 19 + atproto: { 20 + identity: { 21 + resolveDid: mockResolveDid, 22 + }, 23 + }, 24 + }, 25 + dispose: mockDispose, 26 + } 27 + }, 28 + })) 29 + 30 + jest.mock('../direct-fetch-record', () => ({ 31 + LRU: class<K, V> { 32 + private map = new Map<K, V>() 33 + 34 + async getOrTryInsertWith(key: K, factory: () => Promise<V>) { 35 + if (this.map.has(key)) { 36 + return this.map.get(key) 37 + } 38 + const value = await factory() 39 + this.map.set(key, value) 40 + return value 41 + } 42 + }, 43 + })) 44 + 45 + import {resolveDidDocument} from '../resolve-identity' 46 + 47 + describe('query DID resolution', () => { 48 + beforeEach(() => { 49 + mockResolveDid.mockReset() 50 + mockDispose.mockReset() 51 + jest.restoreAllMocks() 52 + }) 53 + 54 + it('resolves did:web paths using the correct did.json URL', async () => { 55 + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ 56 + ok: true, 57 + json: () => 58 + Promise.resolve({ 59 + id: 'did:web:alice.example:users:bob', 60 + service: [], 61 + }), 62 + } as Response) 63 + 64 + await expect( 65 + resolveDidDocument('did:web:alice.example:users:bob'), 66 + ).resolves.toEqual({ 67 + id: 'did:web:alice.example:users:bob', 68 + service: [], 69 + }) 70 + 71 + expect(fetchSpy).toHaveBeenCalledWith( 72 + 'https://alice.example/users/bob/did.json', 73 + { 74 + headers: { 75 + accept: 'application/did+ld+json, application/json', 76 + }, 77 + }, 78 + ) 79 + }) 80 + 81 + it('falls back to appview DID resolution when direct did:web fetching fails', async () => { 82 + jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('CORS')) 83 + mockResolveDid.mockResolvedValueOnce({ 84 + data: { 85 + didDoc: { 86 + id: 'did:web:alice.example', 87 + service: [], 88 + }, 89 + }, 90 + }) 91 + 92 + await expect(resolveDidDocument('did:web:alice.example')).resolves.toEqual({ 93 + id: 'did:web:alice.example', 94 + service: [], 95 + }) 96 + 97 + expect(mockResolveDid).toHaveBeenCalledWith({did: 'did:web:alice.example'}) 98 + expect(mockDispose).toHaveBeenCalled() 99 + }) 100 + })
+10 -2
src/state/queries/pds-label.util.ts
··· 1 1 const BSKY_PDS_HOSTNAMES = ['bsky.social', 'staging.bsky.dev'] 2 2 const BSKY_PDS_SUFFIX = '.bsky.network' 3 3 const BRIDGY_FED_HOSTNAME = 'atproto.brid.gy' 4 + const PDS_FAVICON_CANDIDATE_PATHS = ['/favicon.ico'] 4 5 5 6 export function isBskyPdsUrl(url: string): boolean { 6 7 try { ··· 35 36 } 36 37 37 38 export function getPdsFallbackFaviconUrl(pdsUrl: string): string | undefined { 39 + return getPdsFallbackFaviconUrls(pdsUrl)[0] 40 + } 41 + 42 + export function getPdsFallbackFaviconUrls(pdsUrl: string): string[] { 38 43 try { 39 - return new URL('/favicon.ico', pdsUrl).toString() 44 + const origin = new URL(pdsUrl).origin 45 + return PDS_FAVICON_CANDIDATE_PATHS.map(path => 46 + new URL(path, origin).toString(), 47 + ) 40 48 } catch { 41 - return undefined 49 + return [] 42 50 } 43 51 }
+35 -8
src/state/queries/resolve-identity.ts
··· 1 1 import {type Did, isDid} from '@atproto/api' 2 2 import {useQuery} from '@tanstack/react-query' 3 3 4 + import {getDidDocumentUrl} from '#/lib/atproto/did' 4 5 import {readPlcDirectory} from '#/state/preferences/plc-directory' 6 + import {createPublicAgent} from '#/state/session/agent' 5 7 import {STALE} from '.' 6 8 import {LRU} from './direct-fetch-record' 7 9 const RQKEY_ROOT = 'resolve-identity' ··· 31 33 32 34 const serviceCache = new LRU<string, DidDocument>() 33 35 36 + async function resolveDidDocumentUsingAppView(did: Did) { 37 + const agent = createPublicAgent() 38 + try { 39 + const res = await agent.com.atproto.identity.resolveDid({did}) 40 + return res.data.didDoc as DidDocument 41 + } finally { 42 + agent.dispose() 43 + } 44 + } 45 + 34 46 export async function resolveDidDocument(did: Did) { 35 - const cacheKey = did.startsWith('did:plc:') 36 - ? `${readPlcDirectory()}|${did}` 37 - : did 47 + const plcDirectory = readPlcDirectory() 48 + const cacheKey = did.startsWith('did:plc:') ? `${plcDirectory}|${did}` : did 38 49 39 50 return await serviceCache.getOrTryInsertWith(cacheKey, async () => { 40 - const docUrl = did.startsWith('did:plc:') 41 - ? `${readPlcDirectory()}/${did}` 42 - : `https://${did.substring(8)}/.well-known/did.json` 51 + const docUrl = getDidDocumentUrl(did, plcDirectory) 52 + if (!docUrl) { 53 + throw new Error(`Unsupported DID method for ${did}`) 54 + } 55 + 56 + try { 57 + const res = await fetch(docUrl, { 58 + headers: { 59 + accept: 'application/did+ld+json, application/json', 60 + }, 61 + }) 62 + if (!res.ok) { 63 + throw new Error(`Failed to resolve DID document for ${did}`) 64 + } 43 65 44 - // TODO: we should probably validate this... 45 - return await (await fetch(docUrl)).json() 66 + return (await res.json()) as DidDocument 67 + } catch (err) { 68 + if (!did.startsWith('did:web:')) { 69 + throw err 70 + } 71 + return await resolveDidDocumentUsingAppView(did) 72 + } 46 73 }) 47 74 } 48 75
+91
src/state/session/__tests__/identity-resolver-test.ts
··· 10 10 } 11 11 }> 12 12 > = jest.fn() 13 + const mockResolveDid: jest.MockedFunction< 14 + ( 15 + params: {did: string}, 16 + options?: {signal?: AbortSignal}, 17 + ) => Promise<{ 18 + data: { 19 + didDoc: Record<string, unknown> 20 + } 21 + }> 22 + > = jest.fn() 13 23 const mockDispose: jest.MockedFunction<() => void> = jest.fn() 14 24 15 25 jest.mock('../agent', () => ({ 16 26 createPublicAgent() { 17 27 return { 28 + com: { 29 + atproto: { 30 + identity: { 31 + resolveDid: mockResolveDid, 32 + }, 33 + }, 34 + }, 18 35 resolveHandle: mockResolveHandle, 19 36 dispose: mockDispose, 20 37 } ··· 30 47 describe('appview identity resolver', () => { 31 48 beforeEach(() => { 32 49 mockResolveHandle.mockReset() 50 + mockResolveDid.mockReset() 33 51 mockDispose.mockReset() 34 52 jest.restoreAllMocks() 35 53 }) ··· 168 186 ], 169 187 }, 170 188 }) 189 + }) 190 + 191 + it('resolves did:web path identities using the correct DID document URL', async () => { 192 + mockResolveHandle.mockResolvedValueOnce({ 193 + data: { 194 + did: 'did:web:alice.example:users:bob', 195 + }, 196 + }) 197 + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ 198 + ok: true, 199 + json: () => 200 + Promise.resolve({ 201 + id: 'did:web:alice.example:users:bob', 202 + alsoKnownAs: ['at://alice.example'], 203 + service: [ 204 + { 205 + id: '#atproto_pds', 206 + type: 'AtprotoPersonalDataServer', 207 + serviceEndpoint: 'https://pds.alice.example', 208 + }, 209 + ], 210 + }), 211 + } as Response) 212 + 213 + const resolver = createIdentityResolver() 214 + const identity = await resolver.resolve('did:web:alice.example:users:bob') 215 + 216 + expect(fetchSpy).toHaveBeenCalledWith( 217 + 'https://alice.example/users/bob/did.json', 218 + { 219 + headers: { 220 + accept: 'application/did+ld+json, application/json', 221 + }, 222 + signal: undefined, 223 + }, 224 + ) 225 + expect(identity.did).toBe('did:web:alice.example:users:bob') 226 + expect(identity.handle).toBe('alice.example') 227 + }) 228 + 229 + it('falls back to appview DID resolution when direct did:web fetching fails', async () => { 230 + jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('CORS')) 231 + mockResolveDid.mockResolvedValueOnce({ 232 + data: { 233 + didDoc: { 234 + id: 'did:web:alice.example', 235 + alsoKnownAs: ['at://alice.example'], 236 + service: [ 237 + { 238 + id: '#atproto_pds', 239 + type: 'AtprotoPersonalDataServer', 240 + serviceEndpoint: 'https://pds.alice.example', 241 + }, 242 + ], 243 + }, 244 + }, 245 + }) 246 + mockResolveHandle.mockResolvedValueOnce({ 247 + data: { 248 + did: 'did:web:alice.example', 249 + }, 250 + }) 251 + 252 + const resolver = createIdentityResolver() 253 + const identity = await resolver.resolve('did:web:alice.example') 254 + 255 + expect(mockResolveDid).toHaveBeenCalledWith( 256 + {did: 'did:web:alice.example'}, 257 + {signal: undefined}, 258 + ) 259 + expect(mockDispose).toHaveBeenCalled() 260 + expect(identity.did).toBe('did:web:alice.example') 261 + expect(identity.handle).toBe('alice.example') 171 262 }) 172 263 173 264 it('extracts the pds service url from resolved identity info', () => {
+29 -13
src/state/session/identity-resolver.ts
··· 4 4 type IdentityResolver, 5 5 } from '@atproto-labs/identity-resolver' 6 6 7 + import {getDidDocumentUrl} from '#/lib/atproto/did' 7 8 import {DOH_ENDPOINT} from '#/lib/constants' 8 9 import {readPlcDirectory} from '#/state/preferences/plc-directory' 9 10 import {createPublicAgent} from './agent' ··· 166 167 did: AtprotoDid, 167 168 signal?: AbortSignal, 168 169 ): Promise<DidDocument> { 169 - const docUrl = did.startsWith('did:plc:') 170 - ? `${readPlcDirectory()}/${did}` 171 - : `https://${did.substring(8)}/.well-known/did.json` 170 + const docUrl = getDidDocumentUrl(did, readPlcDirectory()) 171 + if (!docUrl) { 172 + throw new Error(`Unsupported DID method for ${did}`) 173 + } 174 + 175 + try { 176 + const res = await fetch(docUrl, { 177 + headers: { 178 + accept: 'application/did+ld+json, application/json', 179 + }, 180 + signal, 181 + }) 172 182 173 - const res = await fetch(docUrl, { 174 - headers: { 175 - accept: 'application/did+ld+json, application/json', 176 - }, 177 - signal, 178 - }) 183 + if (!res.ok) { 184 + throw new Error(`Failed to resolve DID document for ${did}`) 185 + } 186 + 187 + return (await res.json()) as DidDocument 188 + } catch (err) { 189 + if (!did.startsWith('did:web:')) { 190 + throw err 191 + } 179 192 180 - if (!res.ok) { 181 - throw new Error(`Failed to resolve DID document for ${did}`) 193 + const agent = createPublicAgent() 194 + try { 195 + const res = await agent.com.atproto.identity.resolveDid({did}, {signal}) 196 + return res.data.didDoc as DidDocument 197 + } finally { 198 + agent.dispose() 199 + } 182 200 } 183 - 184 - return (await res.json()) as DidDocument 185 201 } 186 202 187 203 async function getValidatedHandleFromDidDocument(