forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useQuery} from '@tanstack/react-query'
2
3import {resolvePdsServiceUrl} from '#/state/queries/resolve-identity'
4import {IS_WEB} from '#/env'
5
6const BSKY_PDS_HOSTNAMES = ['bsky.social', 'staging.bsky.dev']
7const BSKY_PDS_SUFFIX = '.bsky.network'
8const BRIDGY_FED_HOSTNAME = 'atproto.brid.gy'
9
10export function isBskyPdsUrl(url: string): boolean {
11 try {
12 const hostname = new URL(url).hostname
13 return (
14 BSKY_PDS_HOSTNAMES.includes(hostname) ||
15 hostname.endsWith(BSKY_PDS_SUFFIX)
16 )
17 } catch {
18 return false
19 }
20}
21
22export function isBridgedPdsUrl(url: string): boolean {
23 try {
24 return new URL(url).hostname === BRIDGY_FED_HOSTNAME
25 } catch {
26 return false
27 }
28}
29
30// Ordered from highest to lowest quality. The web path probes these via Image
31// (CORS-safe) and returns the first that successfully loads. The native path
32// uses these as a fallback chain when no <link> icon is found in the HTML.
33const ICON_CANDIDATE_PATHS = [
34 '/apple-touch-icon.png', // 180 脳 180, very common
35 '/apple-icon-180x180.png',
36 '/favicon-256x256.png',
37 '/favicon-96x96.png',
38 '/favicon-32x32.png',
39 '/favicon-16x16.png',
40 '/favicon.ico',
41]
42
43/** Returns the pixel size for a `sizes` attribute value like "180x180", or 0. */
44function parseSizeAttr(sizes: string | null | undefined): number {
45 if (!sizes) return 0
46 const match = sizes.match(/(\d+)x\d+/i)
47 return match ? parseInt(match[1], 10) : 0
48}
49
50/** Resolves an href found in a <link> tag to an absolute URL. */
51function resolveHref(href: string, origin: string): string {
52 if (href.startsWith('http')) return href
53 if (href.startsWith('//')) return `https:${href}`
54 if (href.startsWith('/')) return `${origin}${href}`
55 return `${origin}/${href}`
56}
57
58async function getFaviconUrl(pdsUrl: string): Promise<string | undefined> {
59 let origin = ''
60 try {
61 origin = new URL(pdsUrl).origin
62 } catch {
63 return undefined
64 }
65
66 if (IS_WEB) {
67 // fetch() is blocked by CORS for third-party origins on web.
68 // Probe candidate URLs in parallel using the Image constructor (CORS-safe).
69 // Return whichever high-quality candidate loads first, in priority order.
70 const results = await Promise.all(
71 ICON_CANDIDATE_PATHS.map(
72 path =>
73 new Promise<string | undefined>(resolve => {
74 const url = `${origin}${path}`
75 const img = new Image()
76 img.onload = () => resolve(url)
77 img.onerror = () => resolve(undefined)
78 img.src = url
79 }),
80 ),
81 )
82 // Return the first (highest-priority) candidate that loaded.
83 return results.find(Boolean)
84 }
85
86 // Native path: parse the page HTML for all <link rel="icon"> / <link
87 // rel="apple-touch-icon"> tags, pick the one with the largest declared size,
88 // then fall back to probing the candidate paths in order.
89 const htmlIconUrl = await fetch(origin, {headers: {Accept: 'text/html'}})
90 .then(async res => {
91 if (!res.ok) return undefined
92 const html = await res.text()
93
94 // Collect every <link> tag that looks like an icon.
95 const linkTagRe = /<link([^>]+)>/gi
96 let best: {url: string; size: number} | undefined
97
98 let tagMatch: RegExpExecArray | null
99 while ((tagMatch = linkTagRe.exec(html)) !== null) {
100 const attrs = tagMatch[1]
101 const relMatch = attrs.match(/rel=["']([^"']+)["']/i)
102 if (!relMatch) continue
103 const rel = relMatch[1].toLowerCase()
104 if (!rel.includes('icon')) continue
105
106 const hrefMatch =
107 attrs.match(/href=["']([^"']+)["']/i) ||
108 attrs.match(/href=([^\s>]+)/i)
109 if (!hrefMatch) continue
110
111 const sizesMatch = attrs.match(/sizes=["']([^"']+)["']/i)
112 const size = parseSizeAttr(sizesMatch?.[1])
113 const url = resolveHref(hrefMatch[1], origin)
114
115 // apple-touch-icon gets a size bonus so it beats a generic icon of the
116 // same declared dimensions.
117 const effectiveSize = rel.includes('apple-touch-icon') ? size + 1 : size
118
119 if (!best || effectiveSize > best.size) {
120 best = {url, size: effectiveSize}
121 }
122 }
123
124 return best?.url
125 })
126 .catch(() => undefined)
127
128 if (htmlIconUrl) return htmlIconUrl
129
130 // Fall back to probing known high-quality paths in order.
131 for (const path of ICON_CANDIDATE_PATHS) {
132 const url = `${origin}${path}`
133 const ok = await fetch(url, {method: 'HEAD'})
134 .then(res => res.ok)
135 .catch(() => false)
136 if (ok) return url
137 }
138
139 try {
140 const hostname = new URL(pdsUrl).hostname
141 return `https://favicon.im/${hostname}?throw-error-on-404=true`
142 } catch {
143 return undefined
144 }
145}
146
147export const RQKEY_ROOT = 'pds-label'
148export const RQKEY = (did: string) => [RQKEY_ROOT, did]
149
150export function usePdsLabelQuery(did: string | undefined) {
151 return useQuery({
152 queryKey: RQKEY(did ?? ''),
153 queryFn: async () => {
154 if (!did) return null
155 const pdsUrl = await resolvePdsServiceUrl(did as `did:${string}`)
156 const isBsky = isBskyPdsUrl(pdsUrl)
157 const isBridged = isBridgedPdsUrl(pdsUrl)
158 return {pdsUrl, isBsky, isBridged}
159 },
160 enabled: !!did,
161 staleTime: 1000 * 60 * 60, // 1 hour
162 })
163}
164
165export const RQKEY_FAVICON_ROOT = 'pds-favicon'
166export const RQKEY_FAVICON = (pdsUrl: string) => [RQKEY_FAVICON_ROOT, pdsUrl]
167
168export function usePdsFaviconQuery(pdsUrl: string | undefined) {
169 return useQuery({
170 queryKey: RQKEY_FAVICON(pdsUrl ?? ''),
171 queryFn: async () => {
172 if (!pdsUrl) return undefined
173 return await getFaviconUrl(pdsUrl)
174 },
175 enabled: !!pdsUrl,
176 staleTime: 1000 * 60 * 60, // 1 hour
177 })
178}