this repo has no description
10
fork

Configure Feed

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

feat(registry): bsky client picker, dropped support/handle fields, prefill avatar

Profile schema simplifications and Explore UX improvements based on the
fact that a registry profile is, by construction, owned by the signed-in
account: the user's handle (which is both their Bluesky handle and their
Atmosphere handle — they're the same identity) is implicit, so there's
no reason to ask for it in the form.

- Lexicon: drop `supportUrl`, `bskyHandle`, `atmosphereHandle`. Add
`bskyClient` enum (bluesky | blacksky | anisota | deer | witchsky).
- DB: remove the dropped columns from the base schema; ship an idempotent
additive ALTER to add the new `bsky_client` column to existing tables
(catches "duplicate column" so it's safe to re-run).
- New lib/bsky-clients.ts is the single source of truth for the client
list (id, display name, domain, profile URL builder, favicon helper).
- CreateProfileForm:
- Drop the support/contact, Bluesky-handle, and Atmosphere-handle
inputs. Show a read-only "Signed in as @handle" row instead.
- Add a radio-card client picker styled to match the native modal
referenced by the user (favicon, name, domain, selection ring).
- Avatar preview now falls back to a server-passed `initialAvatarUrl`
(the Bluesky PDS getBlob URL) when no registry record exists yet,
so the prefilled icon actually renders instead of showing an empty
placeholder.
- /explore/[handle] (ProfileLinks): replace the generic link grid with
two prominent buttons — "Open on <client>" (using the project's
selected client + that client's favicon) and Website. The Bluesky
button is the visual primary action; Website is secondary.
- routes/explore/manage.tsx: compute the PDS getBlob URL for the
prefill case and pass it to the form.
- i18n: add handleLabel, bskyClientLabel/Hint, openOn; drop unused
support/bskyHandle/atmosphereHandle copy.
- API + indexer: serialise/deserialise `bskyClient` end-to-end.

Made-with: Cursor

+481 -207
+204 -21
assets/styles.css
··· 2081 2081 color: rgba(255, 255, 255, 0.85); 2082 2082 } 2083 2083 2084 - .profile-links { 2084 + /* ---- Profile detail call-to-action buttons (Bluesky + Website) ---- */ 2085 + .profile-actions { 2085 2086 margin-top: 1.5rem; 2086 2087 display: grid; 2087 - gap: 0.6rem; 2088 - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); 2088 + gap: 0.7rem; 2089 + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); 2089 2090 } 2090 - .profile-link { 2091 - padding: 0.85rem 1rem; 2092 - border-radius: 14px; 2091 + .profile-action { 2092 + display: flex; 2093 + align-items: center; 2094 + gap: 0.85rem; 2095 + padding: 0.9rem 1.05rem; 2096 + border-radius: 16px; 2093 2097 background: rgba(255, 255, 255, 0.55); 2094 2098 border: 1px solid rgba(255, 255, 255, 0.55); 2095 2099 text-decoration: none; 2096 2100 color: inherit; 2101 + transition: 2102 + background 0.15s ease, 2103 + border-color 0.15s ease, 2104 + transform 0.15s ease; 2105 + } 2106 + .profile-action:hover { 2107 + background: rgba(255, 255, 255, 0.85); 2108 + transform: translateY(-1px); 2109 + } 2110 + .profile-action--primary { 2111 + background: linear-gradient( 2112 + 135deg, 2113 + rgba(42, 90, 168, 0.95), 2114 + rgba(60, 124, 220, 0.95) 2115 + ); 2116 + color: #ffffff; 2117 + border-color: rgba(255, 255, 255, 0.18); 2118 + } 2119 + .profile-action--primary:hover { 2120 + background: linear-gradient( 2121 + 135deg, 2122 + rgba(42, 90, 168, 1), 2123 + rgba(72, 138, 232, 1) 2124 + ); 2125 + } 2126 + .profile-action-icon { 2127 + width: 28px; 2128 + height: 28px; 2129 + border-radius: 8px; 2130 + flex-shrink: 0; 2131 + background: #ffffff; 2132 + object-fit: contain; 2133 + padding: 2px; 2134 + } 2135 + .profile-action-icon--glyph { 2136 + background: rgba(18, 26, 47, 0.08); 2137 + color: rgba(18, 26, 47, 0.7); 2138 + display: inline-flex; 2139 + align-items: center; 2140 + justify-content: center; 2141 + font-size: 1rem; 2142 + padding: 0; 2143 + } 2144 + .profile-action-label { 2097 2145 display: flex; 2098 2146 flex-direction: column; 2099 - gap: 0.15rem; 2100 - transition: background 0.15s ease, transform 0.15s ease; 2147 + gap: 0.1rem; 2148 + min-width: 0; 2101 2149 } 2102 - .profile-link:hover { 2103 - background: rgba(255, 255, 255, 0.78); 2104 - transform: translateY(-1px); 2150 + .profile-action-title { 2151 + font-weight: 600; 2152 + font-size: 0.95rem; 2105 2153 } 2106 - .profile-link-label { 2154 + .profile-action-sub { 2107 2155 font-family: "IBM Plex Mono", monospace; 2108 2156 font-size: 0.75rem; 2109 - text-transform: uppercase; 2110 - letter-spacing: 0.04em; 2111 - color: rgba(18, 26, 47, 0.55); 2112 - } 2113 - .profile-link-value { 2114 - font-size: 0.95rem; 2157 + letter-spacing: 0.02em; 2158 + opacity: 0.75; 2115 2159 word-break: break-word; 2116 2160 } 2117 - .dark-phase .profile-link { 2161 + .dark-phase .profile-action { 2118 2162 background: rgba(255, 255, 255, 0.1); 2119 2163 border-color: rgba(255, 255, 255, 0.18); 2120 2164 } 2121 - .dark-phase .profile-link-label { 2122 - color: rgba(255, 255, 255, 0.55); 2165 + .dark-phase .profile-action:hover { 2166 + background: rgba(255, 255, 255, 0.16); 2167 + } 2168 + .dark-phase .profile-action--primary { 2169 + border-color: rgba(255, 255, 255, 0.24); 2170 + } 2171 + .dark-phase .profile-action-icon--glyph { 2172 + background: rgba(255, 255, 255, 0.12); 2173 + color: rgba(255, 255, 255, 0.85); 2123 2174 } 2124 2175 2125 2176 .profile-footer { ··· 2193 2244 display: flex; 2194 2245 flex-direction: column; 2195 2246 gap: 1.1rem; 2247 + } 2248 + 2249 + /* Read-only "Signed in as @handle" row */ 2250 + .profile-form-handle-row { 2251 + display: flex; 2252 + align-items: baseline; 2253 + gap: 0.5rem; 2254 + flex-wrap: wrap; 2255 + padding: 0.6rem 0.85rem; 2256 + border-radius: 12px; 2257 + background: rgba(18, 26, 47, 0.04); 2258 + border: 1px solid rgba(18, 26, 47, 0.06); 2259 + } 2260 + .profile-form-handle-value { 2261 + font-family: "IBM Plex Mono", monospace; 2262 + font-size: 0.95rem; 2263 + font-weight: 600; 2264 + color: rgba(18, 26, 47, 0.9); 2265 + } 2266 + .dark-phase .profile-form-handle-row { 2267 + background: rgba(255, 255, 255, 0.06); 2268 + border-color: rgba(255, 255, 255, 0.1); 2269 + } 2270 + .dark-phase .profile-form-handle-value { 2271 + color: rgba(255, 255, 255, 0.95); 2272 + } 2273 + 2274 + /* ---- Bluesky client picker (radio-card list, mimics native modal) ---- */ 2275 + .bsky-client-list { 2276 + display: flex; 2277 + flex-direction: column; 2278 + gap: 0.4rem; 2279 + margin-top: 0.4rem; 2280 + padding: 0.4rem; 2281 + border-radius: 16px; 2282 + background: rgba(18, 26, 47, 0.04); 2283 + border: 1px solid rgba(18, 26, 47, 0.06); 2284 + } 2285 + .dark-phase .bsky-client-list { 2286 + background: rgba(255, 255, 255, 0.04); 2287 + border-color: rgba(255, 255, 255, 0.08); 2288 + } 2289 + .bsky-client-row { 2290 + display: flex; 2291 + align-items: center; 2292 + gap: 0.85rem; 2293 + padding: 0.65rem 0.85rem; 2294 + border-radius: 12px; 2295 + background: transparent; 2296 + border: 1px solid transparent; 2297 + cursor: pointer; 2298 + transition: background 0.12s ease, border-color 0.12s ease; 2299 + } 2300 + .bsky-client-row:hover { 2301 + background: rgba(255, 255, 255, 0.5); 2302 + } 2303 + .bsky-client-row.is-selected { 2304 + background: rgba(42, 90, 168, 0.08); 2305 + border-color: rgba(42, 90, 168, 0.35); 2306 + } 2307 + .dark-phase .bsky-client-row:hover { 2308 + background: rgba(255, 255, 255, 0.06); 2309 + } 2310 + .dark-phase .bsky-client-row.is-selected { 2311 + background: rgba(120, 170, 255, 0.12); 2312 + border-color: rgba(120, 170, 255, 0.5); 2313 + } 2314 + .bsky-client-row > input[type="radio"] { 2315 + position: absolute; 2316 + opacity: 0; 2317 + pointer-events: none; 2318 + } 2319 + .bsky-client-icon { 2320 + width: 36px; 2321 + height: 36px; 2322 + border-radius: 10px; 2323 + flex-shrink: 0; 2324 + background: #ffffff; 2325 + object-fit: contain; 2326 + padding: 4px; 2327 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); 2328 + } 2329 + .bsky-client-meta { 2330 + display: flex; 2331 + flex-direction: column; 2332 + flex: 1; 2333 + min-width: 0; 2334 + } 2335 + .bsky-client-name { 2336 + font-weight: 600; 2337 + font-size: 0.95rem; 2338 + color: rgba(18, 26, 47, 0.95); 2339 + } 2340 + .bsky-client-row.is-selected .bsky-client-name { 2341 + color: rgba(42, 90, 168, 1); 2342 + } 2343 + .bsky-client-domain { 2344 + font-family: "IBM Plex Mono", monospace; 2345 + font-size: 0.78rem; 2346 + color: rgba(18, 26, 47, 0.55); 2347 + } 2348 + .dark-phase .bsky-client-name { 2349 + color: rgba(255, 255, 255, 0.95); 2350 + } 2351 + .dark-phase .bsky-client-row.is-selected .bsky-client-name { 2352 + color: rgba(160, 200, 255, 1); 2353 + } 2354 + .dark-phase .bsky-client-domain { 2355 + color: rgba(255, 255, 255, 0.55); 2356 + } 2357 + .bsky-client-radio { 2358 + width: 18px; 2359 + height: 18px; 2360 + border-radius: 50%; 2361 + border: 2px solid rgba(18, 26, 47, 0.25); 2362 + background: transparent; 2363 + flex-shrink: 0; 2364 + position: relative; 2365 + transition: border-color 0.12s ease, background 0.12s ease; 2366 + } 2367 + .bsky-client-row.is-selected .bsky-client-radio { 2368 + border-color: rgba(42, 90, 168, 1); 2369 + background: rgba(42, 90, 168, 1); 2370 + box-shadow: inset 0 0 0 3px #ffffff; 2371 + } 2372 + .dark-phase .bsky-client-radio { 2373 + border-color: rgba(255, 255, 255, 0.35); 2374 + } 2375 + .dark-phase .bsky-client-row.is-selected .bsky-client-radio { 2376 + border-color: rgba(160, 200, 255, 1); 2377 + background: rgba(160, 200, 255, 1); 2378 + box-shadow: inset 0 0 0 3px rgba(20, 26, 50, 1); 2196 2379 } 2197 2380 .profile-form-field { 2198 2381 display: flex;
+40 -53
components/explore/ProfileLinks.tsx
··· 1 1 import type { ProfileRow } from "../../lib/registry.ts"; 2 + import { bskyClientFaviconUrl, getBskyClient } from "../../lib/bsky-clients.ts"; 2 3 import { useT } from "../../i18n/mod.ts"; 3 4 4 5 interface Props { 5 6 profile: ProfileRow; 6 7 } 7 8 8 - interface LinkRow { 9 - href: string; 10 - label: string; 11 - value: string; 12 - external?: boolean; 13 - } 14 - 15 9 function trimUrlForDisplay(url: string): string { 16 10 try { 17 11 const u = new URL(url); ··· 23 17 24 18 export default function ProfileLinks({ profile }: Props) { 25 19 const t = useT().explore.detail; 26 - const links: LinkRow[] = []; 27 - if (profile.website) { 28 - links.push({ 29 - href: profile.website, 30 - label: t.website, 31 - value: trimUrlForDisplay(profile.website), 32 - external: true, 33 - }); 34 - } 35 - if (profile.supportUrl) { 36 - links.push({ 37 - href: profile.supportUrl, 38 - label: t.support, 39 - value: trimUrlForDisplay(profile.supportUrl), 40 - external: true, 41 - }); 42 - } 43 - if (profile.bskyHandle) { 44 - links.push({ 45 - href: `https://bsky.app/profile/${ 46 - encodeURIComponent(profile.bskyHandle) 47 - }`, 48 - label: t.bsky, 49 - value: `@${profile.bskyHandle}`, 50 - external: true, 51 - }); 52 - } 53 - if (profile.atmosphereHandle) { 54 - links.push({ 55 - href: `https://${profile.atmosphereHandle}`, 56 - label: t.atmosphere, 57 - value: profile.atmosphereHandle, 58 - external: true, 59 - }); 60 - } 61 - if (links.length === 0) return null; 20 + const client = getBskyClient(profile.bskyClient); 21 + const bskyHref = client.profileUrl(profile.handle); 22 + 62 23 return ( 63 - <div class="profile-links"> 64 - {links.map((l) => ( 24 + <div class="profile-actions"> 25 + <a 26 + class="profile-action profile-action--primary" 27 + href={bskyHref} 28 + target="_blank" 29 + rel="noopener noreferrer" 30 + > 31 + <img 32 + src={bskyClientFaviconUrl(client.domain)} 33 + alt="" 34 + class="profile-action-icon" 35 + loading="lazy" 36 + decoding="async" 37 + /> 38 + <span class="profile-action-label"> 39 + <span class="profile-action-title"> 40 + {t.openOn} {client.name} 41 + </span> 42 + <span class="profile-action-sub">{client.domain}</span> 43 + </span> 44 + </a> 45 + {profile.website && ( 65 46 <a 66 - key={`${l.label}-${l.href}`} 67 - href={l.href} 68 - target={l.external ? "_blank" : undefined} 69 - rel={l.external ? "noopener noreferrer" : undefined} 70 - class="profile-link" 47 + class="profile-action" 48 + href={profile.website} 49 + target="_blank" 50 + rel="noopener noreferrer" 71 51 > 72 - <span class="profile-link-label">{l.label}</span> 73 - <span class="profile-link-value">{l.value}</span> 52 + <span class="profile-action-icon profile-action-icon--glyph"> 53 + 54 + </span> 55 + <span class="profile-action-label"> 56 + <span class="profile-action-title">{t.website}</span> 57 + <span class="profile-action-sub"> 58 + {trimUrlForDisplay(profile.website)} 59 + </span> 60 + </span> 74 61 </a> 75 - ))} 62 + )} 76 63 </div> 77 64 ); 78 65 }
+5 -9
i18n/messages/en.tsx
··· 331 331 "Powered by you — every entry is created and signed by the project's own Atmosphere account.", 332 332 detail: { 333 333 website: "Website", 334 - support: "Support", 335 - bsky: "Bluesky", 336 - atmosphere: "Atmosphere profile", 334 + openOn: "Open on", 337 335 lastUpdated: "Last updated", 338 336 hostedOn: "Hosted on", 339 337 editProfile: "Edit this profile", ··· 375 373 376 374 forms: { 377 375 profile: { 376 + handleLabel: "Signed in as", 378 377 nameLabel: "Project name", 379 378 namePlaceholder: "e.g. Bluesky", 380 379 descriptionLabel: "Short description", ··· 386 385 subcategoriesHint: "For apps. Pick up to a few.", 387 386 websiteLabel: "Website", 388 387 websitePlaceholder: "https://yourproject.com", 389 - supportUrlLabel: "Support / contact URL", 390 - supportUrlPlaceholder: "https://yourproject.com/support", 391 - bskyHandleLabel: "Bluesky handle (optional)", 392 - bskyHandlePlaceholder: "yourproject.bsky.social", 393 - atmosphereHandleLabel: "Atmosphere handle (optional)", 394 - atmosphereHandlePlaceholder: "yourproject.com", 388 + bskyClientLabel: "Bluesky client", 389 + bskyClientHint: 390 + "Pick which client opens when visitors click the Bluesky button on your profile. Your handle works on all of them.", 395 391 tagsLabel: "Tags (optional)", 396 392 tagsHint: "Comma-separated, up to 10. Helps people find you in search.", 397 393 tagsPlaceholder: "open-source, indie, federated",
+61 -52
islands/CreateProfileForm.tsx
··· 5 5 CATEGORIES, 6 6 type Category, 7 7 } from "../lib/lexicons.ts"; 8 + import { 9 + BSKY_CLIENTS, 10 + bskyClientFaviconUrl, 11 + DEFAULT_BSKY_CLIENT_ID, 12 + } from "../lib/bsky-clients.ts"; 8 13 import { useT } from "../i18n/mod.ts"; 9 14 10 15 interface ExistingProfile { ··· 13 18 category: string; 14 19 subcategories: string[]; 15 20 website: string | null; 16 - supportUrl: string | null; 17 - bskyHandle: string | null; 18 - atmosphereHandle: string | null; 21 + bskyClient: string | null; 19 22 tags: string[]; 20 23 avatar: { ref: string; mime: string } | null; 21 24 } ··· 24 27 did: string; 25 28 handle: string; 26 29 initial: ExistingProfile | null; 30 + /** Direct image URL to show in the avatar slot before any registry record 31 + * exists (e.g. the user's PDS-hosted Bluesky avatar). */ 32 + initialAvatarUrl?: string | null; 27 33 } 28 34 29 35 interface BlobRefShape { ··· 47 53 return btoa(binary); 48 54 } 49 55 50 - export default function CreateProfileForm({ did, handle, initial }: Props) { 56 + export default function CreateProfileForm( 57 + { did, handle, initial, initialAvatarUrl }: Props, 58 + ) { 51 59 const t = useT(); 52 60 const tForm = t.forms.profile; 53 61 ··· 56 64 const category = useSignal<string>(initial?.category ?? "app"); 57 65 const subcategories = useSignal<string[]>(initial?.subcategories ?? []); 58 66 const website = useSignal(initial?.website ?? ""); 59 - const supportUrl = useSignal(initial?.supportUrl ?? ""); 60 - const bskyHandle = useSignal(initial?.bskyHandle ?? handle); 61 - const atmosphereHandle = useSignal(initial?.atmosphereHandle ?? handle); 67 + const bskyClient = useSignal<string>( 68 + initial?.bskyClient ?? DEFAULT_BSKY_CLIENT_ID, 69 + ); 62 70 const tagsText = useSignal((initial?.tags ?? []).join(", ")); 63 71 const avatarKeep = useSignal<BlobRefShape | null>(null); 72 + /** Preview URL precedence: locally-picked file blob > existing registry 73 + * record (cached proxy) > prefill source (Bluesky PDS getBlob) > none. */ 64 74 const avatarPreview = useSignal<string | null>( 65 - initial?.avatar ? `/api/registry/avatar/${encodeURIComponent(did)}` : null, 75 + initial?.avatar 76 + ? `/api/registry/avatar/${encodeURIComponent(did)}` 77 + : (initialAvatarUrl ?? null), 66 78 ); 67 79 const avatarFile = useSignal<File | null>(null); 68 80 const avatarRemoved = useSignal(false); ··· 73 85 null, 74 86 ); 75 87 76 - // Pull existing avatar BlobRef so we can echo it back unchanged. 77 88 useEffect(() => { 78 89 if (!initial?.avatar) return; 79 90 avatarKeep.value = { ··· 131 142 category: category.value, 132 143 subcategories: subcategories.value, 133 144 website: website.value.trim() || undefined, 134 - supportUrl: supportUrl.value.trim() || undefined, 135 - bskyHandle: bskyHandle.value.trim() || undefined, 136 - atmosphereHandle: atmosphereHandle.value.trim() || undefined, 145 + bskyClient: bskyClient.value || undefined, 137 146 tags, 138 147 }; 139 148 if (avatarFile.value) { ··· 224 233 </div> 225 234 226 235 <div class="profile-form-fields"> 236 + <div class="profile-form-handle-row"> 237 + <span class="profile-form-label">{tForm.handleLabel}</span> 238 + <span class="profile-form-handle-value">@{handle}</span> 239 + </div> 240 + 227 241 <label class="profile-form-field"> 228 242 <span class="profile-form-label"> 229 243 {tForm.nameLabel} <em class="profile-form-required">*</em> ··· 316 330 /> 317 331 </label> 318 332 319 - <label class="profile-form-field"> 320 - <span class="profile-form-label">{tForm.supportUrlLabel}</span> 321 - <input 322 - type="url" 323 - placeholder={tForm.supportUrlPlaceholder} 324 - value={supportUrl.value} 325 - onInput={(e) => 326 - supportUrl.value = (e.currentTarget as HTMLInputElement).value} 327 - class="profile-form-input" 328 - /> 329 - </label> 330 - 331 - <div class="profile-form-row-2"> 332 - <label class="profile-form-field"> 333 - <span class="profile-form-label">{tForm.bskyHandleLabel}</span> 334 - <input 335 - type="text" 336 - placeholder={tForm.bskyHandlePlaceholder} 337 - value={bskyHandle.value} 338 - onInput={(e) => 339 - bskyHandle.value = 340 - (e.currentTarget as HTMLInputElement).value} 341 - class="profile-form-input" 342 - /> 343 - </label> 344 - <label class="profile-form-field"> 345 - <span class="profile-form-label"> 346 - {tForm.atmosphereHandleLabel} 347 - </span> 348 - <input 349 - type="text" 350 - placeholder={tForm.atmosphereHandlePlaceholder} 351 - value={atmosphereHandle.value} 352 - onInput={(e) => 353 - atmosphereHandle.value = 354 - (e.currentTarget as HTMLInputElement).value} 355 - class="profile-form-input" 356 - /> 357 - </label> 358 - </div> 333 + <fieldset class="profile-form-field"> 334 + <legend class="profile-form-label">{tForm.bskyClientLabel}</legend> 335 + <p class="profile-form-hint">{tForm.bskyClientHint}</p> 336 + <div class="bsky-client-list"> 337 + {BSKY_CLIENTS.map((c) => { 338 + const selected = bskyClient.value === c.id; 339 + return ( 340 + <label 341 + key={c.id} 342 + class={`bsky-client-row ${selected ? "is-selected" : ""}`} 343 + > 344 + <input 345 + type="radio" 346 + name="bskyClient" 347 + value={c.id} 348 + checked={selected} 349 + onChange={() => bskyClient.value = c.id} 350 + /> 351 + <img 352 + src={bskyClientFaviconUrl(c.domain)} 353 + alt="" 354 + class="bsky-client-icon" 355 + loading="lazy" 356 + decoding="async" 357 + /> 358 + <span class="bsky-client-meta"> 359 + <span class="bsky-client-name">{c.name}</span> 360 + <span class="bsky-client-domain">{c.domain}</span> 361 + </span> 362 + <span class="bsky-client-radio" aria-hidden="true" /> 363 + </label> 364 + ); 365 + })} 366 + </div> 367 + </fieldset> 359 368 360 369 <label class="profile-form-field"> 361 370 <span class="profile-form-label">{tForm.tagsLabel}</span>
+9 -11
lexicons/com/atmosphereaccount/registry/profile.json
··· 54 54 "format": "uri", 55 55 "maxLength": 256 56 56 }, 57 - "supportUrl": { 58 - "type": "string", 59 - "format": "uri", 60 - "maxLength": 256 61 - }, 62 - "bskyHandle": { 63 - "type": "string", 64 - "format": "handle" 65 - }, 66 - "atmosphereHandle": { 57 + "bskyClient": { 67 58 "type": "string", 68 - "format": "handle" 59 + "knownValues": [ 60 + "bluesky", 61 + "blacksky", 62 + "anisota", 63 + "deer", 64 + "witchsky" 65 + ], 66 + "description": "Preferred Bluesky-compatible client to open when visitors click the project's Bluesky link. Identity (handle) is the same across all clients." 69 67 }, 70 68 "tags": { 71 69 "type": "array",
+82
lib/bsky-clients.ts
··· 1 + /** 2 + * Curated list of Bluesky-compatible clients a registry profile can opt 3 + * into. Each entry is the *frontend* a project wants visitors to land on 4 + * when clicking the "Bluesky" button on its detail page — they're all 5 + * regular atproto/Bluesky web clients with the same `/profile/{handle}` 6 + * URL convention. 7 + * 8 + * Single source of truth used by: 9 + * - the create / manage form picker (islands/CreateProfileForm.tsx) 10 + * - the public profile detail page (components/explore/ProfileLinks.tsx) 11 + * - lexicon validation (lib/lexicons.ts) 12 + * 13 + * Keep IDs short and stable (they're written to PDS records). To add a 14 + * new client, append here and it shows up everywhere automatically. 15 + */ 16 + 17 + export interface BskyClient { 18 + /** Stable lexicon-safe id (lowercase, persisted on PDS records). */ 19 + id: string; 20 + /** Display name shown to users. */ 21 + name: string; 22 + /** Bare hostname (used to derive favicon + profile URL). */ 23 + domain: string; 24 + /** Returns the profile URL for a given handle on this client. */ 25 + profileUrl: (handle: string) => string; 26 + } 27 + 28 + const profileUrlAt = (host: string) => (handle: string): string => 29 + `https://${host}/profile/${encodeURIComponent(handle)}`; 30 + 31 + export const BSKY_CLIENTS: BskyClient[] = [ 32 + { 33 + id: "bluesky", 34 + name: "Bluesky", 35 + domain: "bsky.app", 36 + profileUrl: profileUrlAt("bsky.app"), 37 + }, 38 + { 39 + id: "blacksky", 40 + name: "Blacksky", 41 + domain: "blacksky.community", 42 + profileUrl: profileUrlAt("blacksky.community"), 43 + }, 44 + { 45 + id: "anisota", 46 + name: "Anisota", 47 + domain: "anisota.net", 48 + profileUrl: profileUrlAt("anisota.net"), 49 + }, 50 + { 51 + id: "deer", 52 + name: "Deer Social", 53 + domain: "deer.social", 54 + profileUrl: profileUrlAt("deer.social"), 55 + }, 56 + { 57 + id: "witchsky", 58 + name: "Witchsky", 59 + domain: "witchsky.app", 60 + profileUrl: profileUrlAt("witchsky.app"), 61 + }, 62 + ]; 63 + 64 + export const BSKY_CLIENT_IDS = BSKY_CLIENTS.map((c) => c.id); 65 + export type BskyClientId = typeof BSKY_CLIENT_IDS[number]; 66 + export const DEFAULT_BSKY_CLIENT_ID = "bluesky"; 67 + 68 + export function getBskyClient(id: string | null | undefined): BskyClient { 69 + return BSKY_CLIENTS.find((c) => c.id === id) ?? BSKY_CLIENTS[0]; 70 + } 71 + 72 + /** 73 + * URL for the client's favicon. We route through Google's S2 favicon 74 + * service so we never depend on each domain serving CORS-friendly 75 + * `/favicon.ico` and so we get a consistent rendered size. Cached 76 + * aggressively by Google's CDN. 77 + */ 78 + export function bskyClientFaviconUrl(domain: string, size = 64): string { 79 + return `https://www.google.com/s2/favicons?sz=${size}&domain=${ 80 + encodeURIComponent(domain) 81 + }`; 82 + }
+33 -3
lib/db.ts
··· 73 73 category TEXT NOT NULL, 74 74 subcategories TEXT NOT NULL DEFAULT '[]', 75 75 website TEXT, 76 - support_url TEXT, 77 - bsky_handle TEXT, 78 - atmosphere_handle TEXT, 76 + bsky_client TEXT, 79 77 tags TEXT NOT NULL DEFAULT '[]', 80 78 avatar_cid TEXT, 81 79 avatar_mime TEXT, ··· 139 137 )`, 140 138 ]; 141 139 140 + /** 141 + * Additive migrations applied after the base schema. SQLite has no 142 + * `ADD COLUMN IF NOT EXISTS`, so we attempt the ALTER and swallow the 143 + * "duplicate column" error. Drop columns are no-ops in SQLite (the 144 + * unused legacy columns simply stay around, holding NULLs). 145 + */ 146 + async function applyAdditiveMigrations( 147 + c: { execute: (s: string) => Promise<unknown> }, 148 + ): Promise<void> { 149 + const additiveColumns: Array<{ table: string; column: string; ddl: string }> = 150 + [ 151 + { 152 + table: "profile", 153 + column: "bsky_client", 154 + ddl: "ALTER TABLE profile ADD COLUMN bsky_client TEXT", 155 + }, 156 + ]; 157 + for (const m of additiveColumns) { 158 + try { 159 + await c.execute(m.ddl); 160 + } catch (err) { 161 + const msg = err instanceof Error ? err.message : String(err); 162 + if (/duplicate column|already exists/i.test(msg)) continue; 163 + console.warn( 164 + `[db] additive migration failed (${m.table}.${m.column}):`, 165 + msg, 166 + ); 167 + } 168 + } 169 + } 170 + 142 171 export function migrate(): Promise<void> { 143 172 if (_migrated) return Promise.resolve(); 144 173 if (_migrationPromise) return _migrationPromise; ··· 147 176 for (const stmt of SCHEMA_STATEMENTS) { 148 177 await c.execute(stmt); 149 178 } 179 + await applyAdditiveMigrations(c); 150 180 _migrated = true; 151 181 })(); 152 182 return _migrationPromise;
+12 -21
lib/lexicons.ts
··· 53 53 category: Category | string; 54 54 subcategories?: string[]; 55 55 website?: string; 56 - supportUrl?: string; 57 - bskyHandle?: string; 58 - atmosphereHandle?: string; 56 + /** Preferred Bluesky client (bluesky | blacksky | anisota | deer | witchsky). */ 57 + bskyClient?: string; 59 58 tags?: string[]; 60 59 createdAt: string; 61 60 } ··· 71 70 entries: FeaturedEntry[]; 72 71 } 73 72 74 - const HANDLE_RE = 75 - /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/; 73 + import { BSKY_CLIENT_IDS } from "./bsky-clients.ts"; 74 + 76 75 const DID_RE = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/; 77 76 78 77 function isStr(v: unknown, max?: number): v is string { ··· 146 145 if (v.website !== undefined && !isUrl(v.website)) { 147 146 return { ok: false, error: "website: must be http(s) URL" }; 148 147 } 149 - if (v.supportUrl !== undefined && !isUrl(v.supportUrl)) { 150 - return { ok: false, error: "supportUrl: must be http(s) URL" }; 151 - } 152 148 if ( 153 - v.bskyHandle !== undefined && 154 - (!isStr(v.bskyHandle) || !HANDLE_RE.test(v.bskyHandle as string)) 155 - ) { 156 - return { ok: false, error: "bskyHandle: invalid handle" }; 157 - } 158 - if ( 159 - v.atmosphereHandle !== undefined && 160 - (!isStr(v.atmosphereHandle) || 161 - !HANDLE_RE.test(v.atmosphereHandle as string)) 149 + v.bskyClient !== undefined && 150 + (!isStr(v.bskyClient) || 151 + !(BSKY_CLIENT_IDS as readonly string[]).includes(v.bskyClient as string)) 162 152 ) { 163 - return { ok: false, error: "atmosphereHandle: invalid handle" }; 153 + return { 154 + ok: false, 155 + error: `bskyClient: must be one of ${BSKY_CLIENT_IDS.join(", ")}`, 156 + }; 164 157 } 165 158 if (v.subcategories !== undefined) { 166 159 if (!Array.isArray(v.subcategories) || v.subcategories.length > 10) { ··· 196 189 category: v.category as string, 197 190 subcategories: v.subcategories as string[] | undefined, 198 191 website: v.website as string | undefined, 199 - supportUrl: v.supportUrl as string | undefined, 200 - bskyHandle: v.bskyHandle as string | undefined, 201 - atmosphereHandle: v.atmosphereHandle as string | undefined, 192 + bskyClient: v.bskyClient as string | undefined, 202 193 tags: v.tags as string[] | undefined, 203 194 createdAt: v.createdAt as string, 204 195 },
+8 -20
lib/registry.ts
··· 14 14 category: Category | string; 15 15 subcategories: string[]; 16 16 website: string | null; 17 - supportUrl: string | null; 18 - bskyHandle: string | null; 19 - atmosphereHandle: string | null; 17 + bskyClient: string | null; 20 18 tags: string[]; 21 19 avatarCid: string | null; 22 20 avatarMime: string | null; ··· 40 38 category: string; 41 39 subcategories: string; 42 40 website: string | null; 43 - support_url: string | null; 44 - bsky_handle: string | null; 45 - atmosphere_handle: string | null; 41 + bsky_client: string | null; 46 42 tags: string; 47 43 avatar_cid: string | null; 48 44 avatar_mime: string | null; ··· 74 70 category: r.category, 75 71 subcategories: safeJsonArray(r.subcategories), 76 72 website: r.website, 77 - supportUrl: r.support_url, 78 - bskyHandle: r.bsky_handle, 79 - atmosphereHandle: r.atmosphere_handle, 73 + bskyClient: r.bsky_client, 80 74 tags: safeJsonArray(r.tags), 81 75 avatarCid: r.avatar_cid, 82 76 avatarMime: r.avatar_mime, ··· 103 97 category: string; 104 98 subcategories: string[]; 105 99 website?: string | null; 106 - supportUrl?: string | null; 107 - bskyHandle?: string | null; 108 - atmosphereHandle?: string | null; 100 + bskyClient?: string | null; 109 101 tags: string[]; 110 102 avatarCid?: string | null; 111 103 avatarMime?: string | null; ··· 122 114 sql: ` 123 115 INSERT INTO profile ( 124 116 did, handle, name, description, category, subcategories, 125 - website, support_url, bsky_handle, atmosphere_handle, tags, 117 + website, bsky_client, tags, 126 118 avatar_cid, avatar_mime, pds_url, record_cid, record_rev, 127 119 created_at, indexed_at 128 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 120 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 129 121 ON CONFLICT(did) DO UPDATE SET 130 122 handle=excluded.handle, 131 123 name=excluded.name, ··· 133 125 category=excluded.category, 134 126 subcategories=excluded.subcategories, 135 127 website=excluded.website, 136 - support_url=excluded.support_url, 137 - bsky_handle=excluded.bsky_handle, 138 - atmosphere_handle=excluded.atmosphere_handle, 128 + bsky_client=excluded.bsky_client, 139 129 tags=excluded.tags, 140 130 avatar_cid=excluded.avatar_cid, 141 131 avatar_mime=excluded.avatar_mime, ··· 153 143 input.category, 154 144 JSON.stringify(input.subcategories ?? []), 155 145 input.website ?? null, 156 - input.supportUrl ?? null, 157 - input.bskyHandle ?? null, 158 - input.atmosphereHandle ?? null, 146 + input.bskyClient ?? null, 159 147 JSON.stringify(input.tags ?? []), 160 148 input.avatarCid ?? null, 161 149 input.avatarMime ?? null,
+2 -6
routes/api/registry/profile.ts
··· 22 22 category?: string; 23 23 subcategories?: string[]; 24 24 website?: string; 25 - supportUrl?: string; 26 - bskyHandle?: string; 27 - atmosphereHandle?: string; 25 + bskyClient?: string; 28 26 tags?: string[]; 29 27 /** Either keep an existing avatar (passed as the BlobRef) or upload new bytes */ 30 28 avatar?: { ··· 99 97 category: trimOrNull(body.category) ?? "", 100 98 subcategories: asArray(body.subcategories), 101 99 website: trimOrNull(body.website), 102 - supportUrl: trimOrNull(body.supportUrl), 103 - bskyHandle: trimOrNull(body.bskyHandle), 104 - atmosphereHandle: trimOrNull(body.atmosphereHandle), 100 + bskyClient: trimOrNull(body.bskyClient), 105 101 tags: asArray(body.tags), 106 102 avatar: avatar ?? undefined, 107 103 createdAt: new Date().toISOString(),
+24 -8
routes/explore/manage.tsx
··· 21 21 const t = getMessages(ctx.state.locale); 22 22 23 23 let initial: Parameters<typeof CreateProfileForm>[0]["initial"] = null; 24 + /** When showing a Bluesky-prefilled draft (no registry record yet), we 25 + * display the user's PDS-hosted avatar directly via getBlob. After the 26 + * registry record exists, the form switches to the cached 27 + * /api/registry/avatar/:did proxy. */ 28 + let initialAvatarUrl: string | null = null; 24 29 const existing = await getProfileByDid(user.did).catch(() => null); 25 30 if (existing) { 26 31 initial = { ··· 29 34 category: existing.category, 30 35 subcategories: existing.subcategories, 31 36 website: existing.website, 32 - supportUrl: existing.supportUrl, 33 - bskyHandle: existing.bskyHandle, 34 - atmosphereHandle: existing.atmosphereHandle, 37 + bskyClient: existing.bskyClient, 35 38 tags: existing.tags, 36 39 avatar: existing.avatarCid && existing.avatarMime 37 40 ? { ref: existing.avatarCid, mime: existing.avatarMime } 38 41 : null, 39 42 }; 40 43 } else { 41 - // Pre-fill from app.bsky.actor.profile if no registry entry yet. 42 44 const session = await loadSession(user.did); 43 45 if (session) { 44 46 const bsky = await getBskyProfile(session.pdsUrl, user.did).catch(() => ··· 51 53 category: "app", 52 54 subcategories: [], 53 55 website: null, 54 - supportUrl: null, 55 - bskyHandle: user.handle, 56 - atmosphereHandle: user.handle, 56 + bskyClient: null, 57 57 tags: [], 58 58 avatar: bsky.avatar 59 59 ? { ··· 62 62 } 63 63 : null, 64 64 }; 65 + if (bsky.avatar) { 66 + const cid = bsky.avatar.ref.$link; 67 + const u = new URL( 68 + `${ 69 + session.pdsUrl.replace(/\/$/, "") 70 + }/xrpc/com.atproto.sync.getBlob`, 71 + ); 72 + u.searchParams.set("did", user.did); 73 + u.searchParams.set("cid", cid); 74 + initialAvatarUrl = u.toString(); 75 + } 65 76 } 66 77 } 67 78 } ··· 70 81 <ManagePage 71 82 user={user} 72 83 initial={initial} 84 + initialAvatarUrl={initialAvatarUrl} 73 85 t={t} 74 86 />, 75 87 ); ··· 79 91 interface ManagePageProps { 80 92 user: { did: string; handle: string }; 81 93 initial: Parameters<typeof CreateProfileForm>[0]["initial"]; 94 + initialAvatarUrl: string | null; 82 95 // deno-lint-ignore no-explicit-any 83 96 t: any; 84 97 } 85 98 86 - function ManagePage({ user, initial, t }: ManagePageProps) { 99 + function ManagePage( 100 + { user, initial, initialAvatarUrl, t }: ManagePageProps, 101 + ) { 87 102 const explore = t.explore; 88 103 return ( 89 104 <div id="page-top"> ··· 114 129 did={user.did} 115 130 handle={user.handle} 116 131 initial={initial} 132 + initialAvatarUrl={initialAvatarUrl} 117 133 /> 118 134 </div> 119 135 </div>
+1 -3
worker/indexer.ts
··· 113 113 category: r.category, 114 114 subcategories: r.subcategories ?? [], 115 115 website: r.website ?? null, 116 - supportUrl: r.supportUrl ?? null, 117 - bskyHandle: r.bskyHandle ?? null, 118 - atmosphereHandle: r.atmosphereHandle ?? null, 116 + bskyClient: r.bskyClient ?? null, 119 117 tags: r.tags ?? [], 120 118 avatarCid: r.avatar?.ref.$link ?? null, 121 119 avatarMime: r.avatar?.mimeType ?? null,