this repo has no description
10
fork

Configure Feed

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

feat(registry): add protocol user profiles and portable reviews

Publish user profiles and reviews as registry protocol records while keeping project discovery indexed separately for Explore and moderation.

Made-with: Cursor

+855 -183
+77 -16
assets/styles.css
··· 11 11 } 12 12 13 13 html { 14 - scroll-behavior: smooth; 15 14 scroll-padding-top: 5rem; 16 15 -webkit-text-size-adjust: 100%; 16 + } 17 + 18 + html.sky-effects { 19 + scroll-behavior: smooth; 17 20 } 18 21 19 22 body { ··· 1342 1345 ================================ */ 1343 1346 1344 1347 .reveal { 1348 + opacity: 1; 1349 + transform: none; 1350 + } 1351 + 1352 + .sky-effects .reveal { 1345 1353 opacity: 0; 1346 1354 transform: translateY(30px); 1347 1355 transition: opacity 0.8s ease, transform 0.8s ease; 1348 1356 } 1349 1357 1350 - .reveal.visible { 1358 + .sky-effects .reveal.visible { 1351 1359 opacity: 1; 1352 1360 transform: translateY(0); 1353 1361 } ··· 3531 3539 body.explore-no-effects #nav-effects-bar { 3532 3540 display: none !important; 3533 3541 } 3534 - body.explore-no-effects .reveal { 3535 - opacity: 1; 3536 - transform: none; 3537 - transition: none; 3538 - } 3539 - 3540 3542 /* ---- Sign-in handle preview dropdown ---- */ 3541 3543 /* IMPORTANT: do NOT set z-index here. position:relative alone is enough 3542 3544 to anchor the absolutely positioned dropdown without creating a stacking ··· 5302 5304 color: rgba(18, 26, 47, 0.58); 5303 5305 } 5304 5306 .user-profile-bio { 5305 - margin-top: 0.7rem; 5307 + margin: 0.7rem 0 0; 5306 5308 white-space: pre-wrap; 5307 5309 } 5310 + .user-profile-view-link { 5311 + display: inline-flex; 5312 + margin-top: 0.85rem; 5313 + text-decoration: none; 5314 + color: inherit; 5315 + } 5308 5316 .user-profile-client-form { 5309 5317 display: flex; 5310 5318 flex-direction: column; 5311 5319 gap: 0.75rem; 5312 5320 } 5313 - .user-profile-client-form label span { 5321 + .user-bsky-picker-label { 5314 5322 display: block; 5315 5323 margin-bottom: 0.35rem; 5316 5324 font-size: 0.82rem; 5317 5325 font-weight: 750; 5318 5326 color: rgba(18, 26, 47, 0.7); 5319 5327 } 5320 - .user-profile-client-form select { 5328 + .user-bsky-picker { 5329 + position: relative; 5330 + } 5331 + .user-bsky-picker-trigger { 5321 5332 width: 100%; 5333 + display: flex; 5334 + align-items: center; 5335 + gap: 0.85rem; 5322 5336 border: 1px solid rgba(18, 26, 47, 0.14); 5323 - border-radius: 0.8rem; 5324 - padding: 0.7rem 0.8rem; 5325 - background: rgba(255, 255, 255, 0.92); 5337 + border-radius: 0.95rem; 5338 + padding: 0.65rem 0.85rem; 5339 + background: rgba(255, 255, 255, 0.82); 5326 5340 color: inherit; 5327 5341 font: inherit; 5342 + text-align: left; 5343 + cursor: pointer; 5344 + transition: 5345 + background 0.15s ease, 5346 + border-color 0.15s ease, 5347 + box-shadow 0.15s ease; 5348 + } 5349 + .user-bsky-picker-trigger:hover, 5350 + .user-bsky-picker-trigger[aria-expanded="true"] { 5351 + background: rgba(255, 255, 255, 0.96); 5352 + border-color: rgba(42, 90, 168, 0.3); 5353 + box-shadow: 0 10px 24px rgba(14, 20, 40, 0.08); 5354 + } 5355 + .user-bsky-picker-trigger:focus-visible { 5356 + outline: 2px solid rgba(42, 90, 168, 0.45); 5357 + outline-offset: 2px; 5358 + } 5359 + .user-bsky-picker-chevron { 5360 + margin-left: auto; 5361 + color: rgba(18, 26, 47, 0.55); 5362 + transition: transform 0.15s ease; 5363 + } 5364 + .user-bsky-picker-trigger[aria-expanded="true"] .user-bsky-picker-chevron { 5365 + transform: rotate(180deg); 5366 + } 5367 + .user-bsky-picker-popover { 5368 + position: absolute; 5369 + top: calc(100% + 0.45rem); 5370 + left: 0; 5371 + right: 0; 5372 + z-index: 30; 5373 + margin-top: 0; 5374 + box-shadow: 0 18px 46px rgba(14, 20, 40, 0.18); 5375 + } 5376 + .user-bsky-picker-popover .bsky-client-row { 5377 + width: 100%; 5378 + font: inherit; 5379 + color: inherit; 5380 + text-align: left; 5328 5381 } 5329 5382 .user-review-list { 5330 5383 display: flex; ··· 5378 5431 } 5379 5432 .dark-phase .account-type-option span, 5380 5433 .dark-phase .user-profile-handle, 5381 - .dark-phase .user-profile-client-form label span, 5434 + .dark-phase .user-bsky-picker-label, 5382 5435 .dark-phase .user-review-row-actions { 5383 5436 color: rgba(255, 255, 255, 0.6); 5384 5437 } ··· 5387 5440 background: rgba(255, 255, 255, 0.12); 5388 5441 color: rgba(255, 255, 255, 0.75); 5389 5442 } 5390 - .dark-phase .user-profile-client-form select { 5443 + .dark-phase .user-bsky-picker-trigger { 5391 5444 background: rgba(255, 255, 255, 0.06); 5392 5445 border-color: rgba(255, 255, 255, 0.12); 5446 + } 5447 + .dark-phase .user-bsky-picker-trigger:hover, 5448 + .dark-phase .user-bsky-picker-trigger[aria-expanded="true"] { 5449 + background: rgba(255, 255, 255, 0.1); 5450 + border-color: rgba(160, 200, 255, 0.35); 5451 + } 5452 + .dark-phase .user-bsky-picker-chevron { 5453 + color: rgba(255, 255, 255, 0.55); 5393 5454 } 5394 5455 @media (max-width: 640px) { 5395 5456 .account-type-options {
+1
components/explore/ProfileCard.tsx
··· 36 36 src={`/api/registry/avatar/${encodeURIComponent(profile.did)}`} 37 37 alt="" 38 38 loading="lazy" 39 + decoding="async" 39 40 /> 40 41 ) 41 42 : (
+1
components/explore/ProfileHero.tsx
··· 58 58 <img 59 59 src={`/api/registry/avatar/${encodeURIComponent(profile.did)}`} 60 60 alt={profile.name} 61 + decoding="async" 61 62 /> 62 63 ) 63 64 : (
+121
islands/UserBskyClientPicker.tsx
··· 1 + import { useEffect } from "preact/hooks"; 2 + import { useSignal } from "@preact/signals"; 3 + import { BSKY_CLIENTS, getBskyClient } from "../lib/bsky-clients.ts"; 4 + 5 + interface Props { 6 + selectedClientId: string | null; 7 + label: string; 8 + saveLabel: string; 9 + } 10 + 11 + export default function UserBskyClientPicker( 12 + { selectedClientId, label, saveLabel }: Props, 13 + ) { 14 + const selected = useSignal(getBskyClient(selectedClientId).id); 15 + const open = useSignal(false); 16 + 17 + useEffect(() => { 18 + if (!open.value) return; 19 + 20 + const close = (event: MouseEvent) => { 21 + const target = event.target as HTMLElement | null; 22 + if (!target?.closest(".user-bsky-picker")) { 23 + open.value = false; 24 + } 25 + }; 26 + 27 + const onKey = (event: KeyboardEvent) => { 28 + if (event.key === "Escape") open.value = false; 29 + }; 30 + 31 + globalThis.addEventListener("click", close); 32 + globalThis.addEventListener("keydown", onKey); 33 + return () => { 34 + globalThis.removeEventListener("click", close); 35 + globalThis.removeEventListener("keydown", onKey); 36 + }; 37 + }, [open.value]); 38 + 39 + const active = getBskyClient(selected.value); 40 + 41 + return ( 42 + <form 43 + method="POST" 44 + action="/api/account/profile" 45 + class="user-profile-client-form" 46 + > 47 + <input type="hidden" name="bskyClientId" value={selected.value} /> 48 + <label class="user-bsky-picker-label" id="user-bsky-picker-label"> 49 + {label} 50 + </label> 51 + <div class="user-bsky-picker"> 52 + <button 53 + type="button" 54 + class="user-bsky-picker-trigger" 55 + aria-haspopup="listbox" 56 + aria-expanded={open.value} 57 + aria-labelledby="user-bsky-picker-label" 58 + onClick={(event) => { 59 + event.stopPropagation(); 60 + open.value = !open.value; 61 + }} 62 + > 63 + <img 64 + src={active.iconUrl} 65 + alt="" 66 + class="bsky-client-icon" 67 + loading="lazy" 68 + decoding="async" 69 + /> 70 + <span class="bsky-client-meta"> 71 + <span class="bsky-client-name">{active.name}</span> 72 + <span class="bsky-client-domain">{active.domain}</span> 73 + </span> 74 + <span class="user-bsky-picker-chevron" aria-hidden="true">▾</span> 75 + </button> 76 + 77 + {open.value && ( 78 + <ul 79 + class="bsky-client-list user-bsky-picker-popover" 80 + role="listbox" 81 + aria-labelledby="user-bsky-picker-label" 82 + > 83 + {BSKY_CLIENTS.map((client) => { 84 + const isSelected = client.id === selected.value; 85 + return ( 86 + <li key={client.id}> 87 + <button 88 + type="button" 89 + class={`bsky-client-row ${isSelected ? "is-selected" : ""}`} 90 + role="option" 91 + aria-selected={isSelected} 92 + onClick={() => { 93 + selected.value = client.id; 94 + open.value = false; 95 + }} 96 + > 97 + <img 98 + src={client.iconUrl} 99 + alt="" 100 + class="bsky-client-icon" 101 + loading="lazy" 102 + decoding="async" 103 + /> 104 + <span class="bsky-client-meta"> 105 + <span class="bsky-client-name">{client.name}</span> 106 + <span class="bsky-client-domain">{client.domain}</span> 107 + </span> 108 + <span class="bsky-client-radio" aria-hidden="true" /> 109 + </button> 110 + </li> 111 + ); 112 + })} 113 + </ul> 114 + )} 115 + </div> 116 + <button type="submit" class="profile-form-button-primary"> 117 + {saveLabel} 118 + </button> 119 + </form> 120 + ); 121 + }
+3 -2
lexicons/com/atmosphereaccount/registry/fullPermissions.json
··· 5 5 "main": { 6 6 "type": "permission-set", 7 7 "title": "Atmosphere Account", 8 - "detail": "Manage your Atmosphere Explore profile (create, update, and remove your project listing).", 8 + "detail": "Manage your Atmosphere profile and reviews.", 9 9 "permissions": [ 10 10 { 11 11 "type": "permission", 12 12 "resource": "repo", 13 13 "collection": [ 14 - "com.atmosphereaccount.registry.profile" 14 + "com.atmosphereaccount.registry.profile", 15 + "com.atmosphereaccount.registry.review" 15 16 ] 16 17 } 17 18 ]
+10 -4
lexicons/com/atmosphereaccount/registry/profile.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 - "description": "A project's profile in the Atmosphere registry. Created by the project's own account on its PDS; one record per account. Kept intentionally minimal so future additions (reviews, age ratings, donations metadata, etc.) live in sibling com.atmosphereaccount.registry.* records.", 7 + "description": "A user or project profile in the Atmosphere registry. Created by the account owner on their PDS; one record per account. Reviews, ratings, moderation, and other social data live in sibling com.atmosphereaccount.registry.* records.", 8 8 "key": "literal:self", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["name", "categories", "createdAt"], 11 + "required": ["name", "createdAt"], 12 12 "properties": { 13 + "profileType": { 14 + "type": "string", 15 + "maxLength": 16, 16 + "knownValues": ["project", "user"], 17 + "description": "Distinguishes project profiles from user profiles. Omitted means project for backwards compatibility with existing records." 18 + }, 13 19 "name": { 14 20 "type": "string", 15 21 "minLength": 1, 16 22 "maxLength": 60, 17 23 "maxGraphemes": 60, 18 - "description": "Display name for the project." 24 + "description": "Display name for the user or project." 19 25 }, 20 26 "description": { 21 27 "type": "string", 22 28 "maxLength": 500, 23 29 "maxGraphemes": 500, 24 - "description": "Optional short description of the project." 30 + "description": "Optional short description for a project or bio for a user profile." 25 31 }, 26 32 "mainLink": { 27 33 "type": "string",
+51
lexicons/com/atmosphereaccount/registry/review.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atmosphereaccount.registry.review", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A portable user-owned review of an Atmosphere project profile. The reviewer writes this record to their own PDS; AppViews may index and moderate their own display of it.", 8 + "key": "any", 9 + "record": { 10 + "type": "object", 11 + "required": ["subject", "rating", "createdAt"], 12 + "properties": { 13 + "subject": { 14 + "type": "string", 15 + "format": "did", 16 + "maxLength": 256, 17 + "description": "DID of the project profile being reviewed." 18 + }, 19 + "subjectUri": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "maxLength": 512, 23 + "description": "Optional AT URI of the reviewed project profile record." 24 + }, 25 + "rating": { 26 + "type": "integer", 27 + "minimum": 1, 28 + "maximum": 5, 29 + "description": "Star rating from 1 to 5." 30 + }, 31 + "body": { 32 + "type": "string", 33 + "maxLength": 300, 34 + "maxGraphemes": 300, 35 + "description": "Optional short review text." 36 + }, 37 + "createdAt": { 38 + "type": "string", 39 + "format": "datetime", 40 + "maxLength": 64 41 + }, 42 + "updatedAt": { 43 + "type": "string", 44 + "format": "datetime", 45 + "maxLength": 64 46 + } 47 + } 48 + } 49 + } 50 + } 51 + }
+5 -4
lib/account-types.ts
··· 199 199 ): Promise<AccountType | null> { 200 200 const user = await getAppUser(did); 201 201 if (user) return user.accountType; 202 - const profile = await getProfileByDid(did, { includeTakenDown: true }).catch( 203 - () => null, 204 - ); 205 - return profile ? "project" : null; 202 + const profile = await getProfileByDid(did, { 203 + includeTakenDown: true, 204 + profileType: "any", 205 + }).catch(() => null); 206 + return profile?.profileType ?? null; 206 207 } 207 208 208 209 export async function requiresAccountTypeChoice(did: string): Promise<boolean> {
+27
lib/db.ts
··· 90 90 `CREATE TABLE IF NOT EXISTS profile ( 91 91 did TEXT PRIMARY KEY, 92 92 handle TEXT NOT NULL, 93 + profile_type TEXT NOT NULL DEFAULT 'project', 93 94 name TEXT NOT NULL, 94 95 description TEXT NOT NULL, 95 96 main_link TEXT, ··· 224 225 id INTEGER PRIMARY KEY AUTOINCREMENT, 225 226 target_did TEXT NOT NULL, 226 227 reviewer_did TEXT NOT NULL, 228 + review_uri TEXT, 229 + review_cid TEXT, 230 + review_rkey TEXT, 227 231 rating INTEGER NOT NULL CHECK(rating >= 1 AND rating <= 5), 228 232 body TEXT NOT NULL DEFAULT '', 229 233 status TEXT NOT NULL DEFAULT 'visible', ··· 289 293 * handful of rows are non-NULL at any time so the index stays cheap. 290 294 */ 291 295 `CREATE INDEX IF NOT EXISTS profile_icon_access ON profile(icon_access_status)`, 296 + `CREATE INDEX IF NOT EXISTS profile_type_takedown ON profile(profile_type, takedown_status)`, 297 + `CREATE UNIQUE INDEX IF NOT EXISTS review_uri_unique ON review(review_uri) WHERE review_uri IS NOT NULL`, 292 298 ]; 293 299 294 300 /** ··· 307 313 [ 308 314 { 309 315 table: "profile", 316 + column: "profile_type", 317 + ddl: 318 + "ALTER TABLE profile ADD COLUMN profile_type TEXT NOT NULL DEFAULT 'project'", 319 + }, 320 + { 321 + table: "profile", 310 322 column: "categories", 311 323 ddl: 312 324 "ALTER TABLE profile ADD COLUMN categories TEXT NOT NULL DEFAULT '[]'", ··· 416 428 table: "profile", 417 429 column: "takedown_at", 418 430 ddl: "ALTER TABLE profile ADD COLUMN takedown_at INTEGER", 431 + }, 432 + { 433 + table: "review", 434 + column: "review_uri", 435 + ddl: "ALTER TABLE review ADD COLUMN review_uri TEXT", 436 + }, 437 + { 438 + table: "review", 439 + column: "review_cid", 440 + ddl: "ALTER TABLE review ADD COLUMN review_cid TEXT", 441 + }, 442 + { 443 + table: "review", 444 + column: "review_rkey", 445 + ddl: "ALTER TABLE review ADD COLUMN review_rkey TEXT", 419 446 }, 420 447 { 421 448 table: "app_user",
+94 -20
lib/lexicons.ts
··· 8 8 */ 9 9 10 10 export const PROFILE_NSID = "com.atmosphereaccount.registry.profile"; 11 + export const REVIEW_NSID = "com.atmosphereaccount.registry.review"; 11 12 export const FEATURED_NSID = "com.atmosphereaccount.registry.featured"; 12 13 /** 13 14 * Permission-set lexicon NSID requested via the OAuth `include:` scope. ··· 19 20 20 21 export const REGISTRY_NSIDS = [ 21 22 PROFILE_NSID, 23 + REVIEW_NSID, 22 24 FEATURED_NSID, 23 25 PERMISSION_SET_NSID, 24 26 ] as const; 27 + 28 + export const PROFILE_TYPES = ["project", "user"] as const; 29 + export type ProfileType = typeof PROFILE_TYPES[number]; 25 30 26 31 export const CATEGORIES = [ 27 32 "app", ··· 120 125 121 126 export interface ProfileRecord { 122 127 $type?: typeof PROFILE_NSID; 128 + profileType?: ProfileType; 123 129 name: string; 124 130 description: string; 125 131 /** ··· 143 149 screenshots?: ScreenshotEntry[]; 144 150 /** All categories that apply to the project (1-4). The first item is the 145 151 * primary category used for sort/grouping in lists. */ 146 - categories: string[]; 152 + categories?: string[]; 147 153 subcategories?: string[]; 148 154 /** 149 155 * Outbound buttons shown on the public profile after the Web / iOS / ··· 155 161 createdAt: string; 156 162 } 157 163 164 + export interface ReviewRecord { 165 + $type?: typeof REVIEW_NSID; 166 + subject: string; 167 + subjectUri?: string; 168 + rating: 1 | 2 | 3 | 4 | 5; 169 + body?: string; 170 + createdAt: string; 171 + updatedAt?: string; 172 + } 173 + 158 174 export interface FeaturedEntry { 159 175 did: string; 160 176 badges?: FeaturedBadge[] | string[]; ··· 167 183 } 168 184 169 185 const DID_RE = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/; 186 + const AT_URI_RE = 187 + /^at:\/\/did:[a-z]+:[a-zA-Z0-9._:%-]+\/[a-zA-Z0-9.:-]+\/[a-zA-Z0-9._~:-]+$/; 170 188 171 189 function isStr(v: unknown, max?: number): v is string { 172 190 if (typeof v !== "string") return false; ··· 354 372 return { ok: false, error: "record must be an object" }; 355 373 } 356 374 const v = input as Record<string, unknown>; 375 + const profileType: ProfileType = v.profileType === "user" 376 + ? "user" 377 + : "project"; 357 378 358 379 if ( 359 380 !isStr(v.name) || (v.name as string).length < 1 || ··· 397 418 } 398 419 normalizedAndroidLink = (v.androidLink as string).trim(); 399 420 } 400 - // categories[]: required, deduped, every entry must be a known CATEGORY. 421 + // categories[]: required for projects, optional for user profiles. 401 422 // The first entry is treated as the primary category by the UI. 402 - let normalizedCategories: string[]; 423 + let normalizedCategories: string[] | undefined; 403 424 { 404 425 if (!Array.isArray(v.categories) || v.categories.length === 0) { 405 - return { ok: false, error: "categories: non-empty array required" }; 406 - } 407 - if (v.categories.length > 4) { 408 - return { ok: false, error: "categories: at most 4" }; 409 - } 410 - const seen = new Set<string>(); 411 - const out: string[] = []; 412 - for (const c of v.categories) { 413 - if (!isStr(c) || !(CATEGORIES as readonly string[]).includes(c)) { 414 - return { 415 - ok: false, 416 - error: `categories: items must be one of ${CATEGORIES.join(", ")}`, 417 - }; 426 + if (profileType === "project") { 427 + return { ok: false, error: "categories: non-empty array required" }; 418 428 } 419 - if (!seen.has(c)) { 420 - seen.add(c); 421 - out.push(c); 429 + normalizedCategories = undefined; 430 + } else { 431 + if (v.categories.length > 4) { 432 + return { ok: false, error: "categories: at most 4" }; 422 433 } 434 + const seen = new Set<string>(); 435 + const out: string[] = []; 436 + for (const c of v.categories) { 437 + if (!isStr(c) || !(CATEGORIES as readonly string[]).includes(c)) { 438 + return { 439 + ok: false, 440 + error: `categories: items must be one of ${CATEGORIES.join(", ")}`, 441 + }; 442 + } 443 + if (!seen.has(c)) { 444 + seen.add(c); 445 + out.push(c); 446 + } 447 + } 448 + normalizedCategories = out; 423 449 } 424 - normalizedCategories = out; 425 450 } 426 451 if (!isStr(v.createdAt)) { 427 452 return { ok: false, error: "createdAt required (ISO 8601)" }; ··· 459 484 ok: true, 460 485 value: { 461 486 $type: PROFILE_NSID, 487 + profileType, 462 488 name: v.name as string, 463 489 description: normalizedDescription, 464 490 mainLink: normalizedMainLink, ··· 524 550 }; 525 551 } 526 552 553 + export function validateReview( 554 + input: unknown, 555 + ): ValidationResult<ReviewRecord> { 556 + if (!input || typeof input !== "object") { 557 + return { ok: false, error: "record must be an object" }; 558 + } 559 + const v = input as Record<string, unknown>; 560 + if (typeof v.subject !== "string" || !DID_RE.test(v.subject)) { 561 + return { ok: false, error: "subject: invalid DID" }; 562 + } 563 + if ( 564 + v.subjectUri !== undefined && 565 + (typeof v.subjectUri !== "string" || !AT_URI_RE.test(v.subjectUri) || 566 + v.subjectUri.length > 512) 567 + ) { 568 + return { ok: false, error: "subjectUri: invalid AT URI" }; 569 + } 570 + if ( 571 + typeof v.rating !== "number" || !Number.isInteger(v.rating) || 572 + v.rating < 1 || v.rating > 5 573 + ) { 574 + return { ok: false, error: "rating: integer 1..5 required" }; 575 + } 576 + const body = typeof v.body === "string" ? v.body.trim() : ""; 577 + if (body.length > 300) { 578 + return { ok: false, error: "body: must be <=300 chars" }; 579 + } 580 + if (!isStr(v.createdAt, 64)) { 581 + return { ok: false, error: "createdAt required (ISO 8601)" }; 582 + } 583 + if (v.updatedAt !== undefined && !isStr(v.updatedAt, 64)) { 584 + return { ok: false, error: "updatedAt: string <=64" }; 585 + } 586 + return { 587 + ok: true, 588 + value: { 589 + $type: REVIEW_NSID, 590 + subject: v.subject, 591 + subjectUri: typeof v.subjectUri === "string" ? v.subjectUri : undefined, 592 + rating: v.rating as 1 | 2 | 3 | 4 | 5, 593 + body: body || undefined, 594 + createdAt: v.createdAt as string, 595 + updatedAt: typeof v.updatedAt === "string" ? v.updatedAt : undefined, 596 + }, 597 + }; 598 + } 599 + 527 600 /** 528 601 * The literal JSON for each lexicon (loaded at module init). Used by the 529 602 * `/.well-known/atproto-lexicon/<NSID>` route to publish the schemas, and ··· 535 608 } 536 609 const fileMap: Record<string, string> = { 537 610 [PROFILE_NSID]: "profile.json", 611 + [REVIEW_NSID]: "review.json", 538 612 [FEATURED_NSID]: "featured.json", 539 613 [PERMISSION_SET_NSID]: "fullPermissions.json", 540 614 };
+1 -1
lib/oauth.ts
··· 43 43 * - `include:com.atmosphereaccount.registry.fullPermissions` - repo writes 44 44 * to our 45 45 * profile + 46 - * featured 46 + * review 47 47 * collections 48 48 * (resolved 49 49 * dynamically
+30 -1
lib/pds.ts
··· 3 3 * One thin function per XRPC method we actually call from the registry. 4 4 */ 5 5 import { authedFetch } from "./oauth.ts"; 6 - import { type BlobRef, PROFILE_NSID, type ProfileRecord } from "./lexicons.ts"; 6 + import { 7 + type BlobRef, 8 + PROFILE_NSID, 9 + type ProfileRecord, 10 + REVIEW_NSID, 11 + type ReviewRecord, 12 + } from "./lexicons.ts"; 7 13 8 14 export interface PutRecordResult { 9 15 uri: string; ··· 35 41 return await res.json() as PutRecordResult; 36 42 } 37 43 44 + export async function putReviewRecord( 45 + did: string, 46 + pdsUrl: string, 47 + rkey: string, 48 + record: ReviewRecord, 49 + ): Promise<PutRecordResult> { 50 + return await putRecord( 51 + did, 52 + pdsUrl, 53 + REVIEW_NSID, 54 + rkey, 55 + record as unknown as Record<string, unknown>, 56 + ); 57 + } 58 + 38 59 /** 39 60 * Generic putRecord helper for arbitrary collections (e.g. our curated 40 61 * featured directory). Always uses the authed user's own repo. ··· 69 90 pdsUrl: string, 70 91 ): Promise<void> { 71 92 await deleteRecord(did, pdsUrl, PROFILE_NSID, "self"); 93 + } 94 + 95 + export async function deleteReviewRecord( 96 + did: string, 97 + pdsUrl: string, 98 + rkey: string, 99 + ): Promise<void> { 100 + await deleteRecord(did, pdsUrl, REVIEW_NSID, rkey); 72 101 } 73 102 74 103 /**
+51 -18
lib/registry.ts
··· 4 4 */ 5 5 import type { InValue } from "@libsql/client"; 6 6 import { withDb } from "./db.ts"; 7 - import type { FeaturedBadge, LinkEntry, ScreenshotEntry } from "./lexicons.ts"; 7 + import type { 8 + FeaturedBadge, 9 + LinkEntry, 10 + ProfileType, 11 + ScreenshotEntry, 12 + } from "./lexicons.ts"; 8 13 9 14 /** 10 15 * Approval state of the developer-facing SVG icon. ··· 47 52 export interface ProfileRow { 48 53 did: string; 49 54 handle: string; 55 + profileType: ProfileType; 50 56 name: string; 51 57 description: string; 52 58 /** Primary web destination rendered as the Web button. May be null ··· 102 108 interface RawProfileRow { 103 109 did: string; 104 110 handle: string; 111 + profile_type: string | null; 105 112 name: string; 106 113 description: string; 107 114 main_link: string | null; ··· 213 220 return v === "taken_down" ? "taken_down" : null; 214 221 } 215 222 223 + function normalizeProfileType(v: string | null): ProfileType { 224 + return v === "user" ? "user" : "project"; 225 + } 226 + 216 227 function rowToProfile(r: RawProfileRow): ProfileRow { 217 228 const out: ProfileRow = { 218 229 did: r.did, 219 230 handle: r.handle, 231 + profileType: normalizeProfileType(r.profile_type), 220 232 name: r.name, 221 233 description: r.description, 222 234 mainLink: r.main_link && r.main_link.length > 0 ? r.main_link : null, ··· 270 282 export interface UpsertProfileInput { 271 283 did: string; 272 284 handle: string; 285 + profileType?: ProfileType; 273 286 name: string; 274 287 description: string; 275 288 /** Optional: nullable for legacy records that pre-date the field. ··· 278 291 mainLink?: string | null; 279 292 iosLink?: string | null; 280 293 androidLink?: string | null; 281 - /** Required: 1-4 known category strings. The first is the primary. */ 294 + /** Required for project profiles; empty for user profiles. */ 282 295 categories: string[]; 283 296 subcategories: string[]; 284 297 links?: LinkEntry[] | null; ··· 295 308 296 309 export async function upsertProfile(input: UpsertProfileInput): Promise<void> { 297 310 const now = Date.now(); 311 + const profileType = input.profileType ?? "project"; 298 312 // Defensive dedupe + drop empties; the lexicon validator already does 299 313 // this but the worker also calls upsertProfile from the Jetstream path, 300 314 // and the registry invariant (categories non-empty) is worth enforcing ··· 310 324 } 311 325 return out; 312 326 })(); 313 - if (cats.length === 0) { 327 + if (profileType === "project" && cats.length === 0) { 314 328 throw new Error("upsertProfile: categories[] is required and non-empty"); 315 329 } 316 330 /** ··· 325 339 await c.execute({ 326 340 sql: ` 327 341 INSERT INTO profile ( 328 - did, handle, name, description, main_link, ios_link, android_link, 342 + did, handle, profile_type, name, description, main_link, ios_link, android_link, 329 343 categories, subcategories, links, screenshots, 330 344 avatar_cid, avatar_mime, icon_cid, icon_mime, icon_status, 331 345 icon_reviewed_by, icon_reviewed_at, icon_rejected_reason, ··· 335 349 takedown_status, takedown_reason, takedown_by, takedown_at, 336 350 pds_url, record_cid, record_rev, created_at, indexed_at 337 351 ) VALUES ( 338 - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 352 + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 339 353 NULL, NULL, NULL, 340 354 NULL, NULL, NULL, NULL, NULL, NULL, 341 355 NULL, NULL, NULL, NULL, ··· 343 357 ) 344 358 ON CONFLICT(did) DO UPDATE SET 345 359 handle=excluded.handle, 360 + profile_type=excluded.profile_type, 346 361 name=excluded.name, 347 362 description=excluded.description, 348 363 main_link=excluded.main_link, ··· 421 436 args: [ 422 437 input.did, 423 438 input.handle, 439 + profileType, 424 440 input.name, 425 441 input.description, 426 442 input.mainLink ?? null, ··· 558 574 FROM profile p 559 575 WHERE ${where} 560 576 AND p.takedown_status IS NULL 577 + AND p.profile_type = 'project' 561 578 LIMIT 1 562 579 `, 563 580 args: [raw], ··· 622 639 SELECT did, handle, name, icon_access_email, icon_access_requested_at 623 640 FROM profile 624 641 WHERE icon_access_status = 'requested' 642 + AND profile_type = 'project' 625 643 ORDER BY icon_access_requested_at ASC 626 644 `); 627 645 return r.rows.map((row) => { ··· 648 666 export async function countPendingIconAccess(): Promise<number> { 649 667 return await withDb(async (c) => { 650 668 const r = await c.execute( 651 - `SELECT COUNT(*) AS n FROM profile WHERE icon_access_status = 'requested'`, 669 + `SELECT COUNT(*) AS n FROM profile WHERE icon_access_status = 'requested' AND profile_type = 'project'`, 652 670 ); 653 671 return Number((r.rows[0] as Record<string, unknown>).n ?? 0); 654 672 }); ··· 663 681 SELECT did, handle, name, icon_access_status 664 682 FROM profile 665 683 WHERE takedown_status IS NULL 684 + AND profile_type = 'project' 666 685 AND (icon_access_status IS NULL OR icon_access_status = 'denied') 667 686 ORDER BY indexed_at DESC 668 687 `); ··· 691 710 icon_access_reviewed_at, icon_access_reviewed_by 692 711 FROM profile 693 712 WHERE icon_access_status = 'granted' 713 + AND profile_type = 'project' 694 714 ORDER BY icon_access_reviewed_at DESC 695 715 `); 696 716 return r.rows.map((row) => { ··· 831 851 */ 832 852 export interface ProfileLookupOptions { 833 853 includeTakenDown?: boolean; 854 + profileType?: ProfileType | "any"; 834 855 } 835 856 836 857 export async function getProfileByDid( 837 858 did: string, 838 859 opts: ProfileLookupOptions = {}, 839 860 ): Promise<ProfileRow | null> { 840 - const where = opts.includeTakenDown 841 - ? `WHERE p.did = ?` 842 - : `WHERE p.did = ? AND p.takedown_status IS NULL`; 861 + const type = opts.profileType ?? "project"; 862 + const where = [ 863 + "p.did = ?", 864 + ...(opts.includeTakenDown ? [] : ["p.takedown_status IS NULL"]), 865 + ...(type === "any" ? [] : ["p.profile_type = ?"]), 866 + ]; 867 + const args: InValue[] = type === "any" ? [did] : [did, type]; 843 868 return await withDb(async (c) => { 844 869 const r = await c.execute({ 845 - sql: `${SELECT_PROFILE} ${where} LIMIT 1`, 846 - args: [did], 870 + sql: `${SELECT_PROFILE} WHERE ${where.join(" AND ")} LIMIT 1`, 871 + args, 847 872 }); 848 873 if (r.rows.length === 0) return null; 849 874 return rowToProfile(r.rows[0] as unknown as RawProfileRow); ··· 854 879 handle: string, 855 880 opts: ProfileLookupOptions = {}, 856 881 ): Promise<ProfileRow | null> { 857 - const where = opts.includeTakenDown 858 - ? `WHERE p.handle = ?` 859 - : `WHERE p.handle = ? AND p.takedown_status IS NULL`; 882 + const type = opts.profileType ?? "project"; 883 + const where = [ 884 + "p.handle = ?", 885 + ...(opts.includeTakenDown ? [] : ["p.takedown_status IS NULL"]), 886 + ...(type === "any" ? [] : ["p.profile_type = ?"]), 887 + ]; 888 + const args: InValue[] = type === "any" ? [handle] : [handle, type]; 860 889 return await withDb(async (c) => { 861 890 const r = await c.execute({ 862 - sql: `${SELECT_PROFILE} ${where} LIMIT 1`, 863 - args: [handle], 891 + sql: `${SELECT_PROFILE} WHERE ${where.join(" AND ")} LIMIT 1`, 892 + args, 864 893 }); 865 894 if (r.rows.length === 0) return null; 866 895 return rowToProfile(r.rows[0] as unknown as RawProfileRow); ··· 895 924 * the purpose of the takedown; admin tooling reads via 896 925 * `listTakenDownProfiles` instead. 897 926 */ 898 - const where: string[] = ["p.takedown_status IS NULL"]; 927 + const where: string[] = [ 928 + "p.takedown_status IS NULL", 929 + "p.profile_type = 'project'", 930 + ]; 899 931 const args: InValue[] = []; 900 932 901 933 if (opts.query && opts.query.trim()) { ··· 967 999 return await withDb(async (c) => { 968 1000 const r = await c.execute( 969 1001 `SELECT did, handle, name FROM profile 970 - WHERE takedown_status IS NULL 1002 + WHERE takedown_status IS NULL AND profile_type = 'project' 971 1003 ORDER BY handle ASC`, 972 1004 ); 973 1005 return r.rows.map((row) => { ··· 983 1015 sql: ` 984 1016 ${SELECT_PROFILE} 985 1017 WHERE f.did IS NOT NULL AND p.takedown_status IS NULL 1018 + AND p.profile_type = 'project' 986 1019 ORDER BY COALESCE(f.position, 999999) ASC, p.indexed_at DESC 987 1020 LIMIT ? 988 1021 `,
+78 -5
lib/reviews.ts
··· 4 4 * without changing a user's PDS records. 5 5 */ 6 6 import { withDb } from "./db.ts"; 7 + import { REVIEW_NSID, type ReviewRecord } from "./lexicons.ts"; 7 8 8 9 export const MAX_REVIEW_BODY_LENGTH = 300; 9 10 export const REVIEW_AGGREGATE_MIN_COUNT = 5; ··· 46 47 id: number; 47 48 targetDid: string; 48 49 reviewerDid: string; 50 + reviewUri: string | null; 51 + reviewCid: string | null; 52 + reviewRkey: string | null; 49 53 rating: 1 | 2 | 3 | 4 | 5; 50 54 body: string; 51 55 status: ReviewStatus; ··· 77 81 id: number; 78 82 target_did: string; 79 83 reviewer_did: string; 84 + review_uri: string | null; 85 + review_cid: string | null; 86 + review_rkey: string | null; 80 87 rating: number; 81 88 body: string; 82 89 status: string; ··· 141 148 id: Number(r.id), 142 149 targetDid: r.target_did, 143 150 reviewerDid: r.reviewer_did, 151 + reviewUri: r.review_uri, 152 + reviewCid: r.review_cid, 153 + reviewRkey: r.review_rkey, 144 154 rating: normalizeRating(Number(r.rating)), 145 155 body: r.body ?? "", 146 156 status: normalizeReviewStatus(r.status), ··· 195 205 return body; 196 206 } 197 207 208 + export function reviewUriForRkey(reviewerDid: string, rkey: string): string { 209 + return `at://${reviewerDid}/${REVIEW_NSID}/${rkey}`; 210 + } 211 + 212 + export async function reviewRkeyForTarget(targetDid: string): Promise<string> { 213 + const bytes = new TextEncoder().encode(targetDid); 214 + const hash = await crypto.subtle.digest("SHA-256", bytes); 215 + const hex = Array.from(new Uint8Array(hash)) 216 + .map((b) => b.toString(16).padStart(2, "0")) 217 + .join(""); 218 + return `review-${hex.slice(0, 32)}`; 219 + } 220 + 221 + export function reviewRowToRecord(review: ReviewRow): ReviewRecord { 222 + return { 223 + subject: review.targetDid, 224 + subjectUri: 225 + `at://${review.targetDid}/com.atmosphereaccount.registry.profile/self`, 226 + rating: review.rating, 227 + body: review.body || undefined, 228 + createdAt: new Date(review.createdAt).toISOString(), 229 + updatedAt: new Date(review.updatedAt).toISOString(), 230 + }; 231 + } 232 + 198 233 export async function createOrUpdateReview(input: { 199 234 targetDid: string; 200 235 reviewerDid: string; 236 + reviewUri?: string | null; 237 + reviewCid?: string | null; 238 + reviewRkey?: string | null; 201 239 rating: 1 | 2 | 3 | 4 | 5; 202 240 body: string; 241 + createdAt?: number; 242 + updatedAt?: number; 203 243 }): Promise<ReviewRow> { 204 244 return await withDb(async (c) => { 205 - const now = Date.now(); 245 + const now = input.updatedAt ?? Date.now(); 246 + const createdAt = input.createdAt ?? now; 206 247 await c.execute({ 207 248 sql: ` 208 249 INSERT INTO review ( 209 - target_did, reviewer_did, rating, body, status, created_at, updated_at 210 - ) VALUES (?, ?, ?, ?, 'visible', ?, ?) 250 + target_did, reviewer_did, review_uri, review_cid, review_rkey, 251 + rating, body, status, created_at, updated_at 252 + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'visible', ?, ?) 211 253 ON CONFLICT(target_did, reviewer_did) DO UPDATE SET 254 + review_uri = COALESCE(excluded.review_uri, review.review_uri), 255 + review_cid = COALESCE(excluded.review_cid, review.review_cid), 256 + review_rkey = COALESCE(excluded.review_rkey, review.review_rkey), 212 257 rating = excluded.rating, 213 258 body = excluded.body, 214 259 status = 'visible', ··· 222 267 args: [ 223 268 input.targetDid, 224 269 input.reviewerDid, 270 + input.reviewUri ?? null, 271 + input.reviewCid ?? null, 272 + input.reviewRkey ?? null, 225 273 input.rating, 226 274 input.body, 227 - now, 275 + createdAt, 228 276 now, 229 277 ], 230 278 }); 231 279 const review = await getOwnReview(input.targetDid, input.reviewerDid); 232 280 if (!review) throw new Error("review_write_failed"); 233 281 return review; 282 + }); 283 + } 284 + 285 + export async function markReviewRemovedByRkey( 286 + reviewerDid: string, 287 + reviewRkey: string, 288 + ): Promise<boolean> { 289 + return await withDb(async (c) => { 290 + const now = Date.now(); 291 + const r = await c.execute({ 292 + sql: ` 293 + UPDATE review SET 294 + status = 'removed', 295 + updated_at = ?, 296 + removed_at = ?, 297 + removed_by = ? 298 + WHERE reviewer_did = ? AND review_rkey = ? AND status != 'removed' 299 + `, 300 + args: [now, now, reviewerDid, reviewerDid, reviewRkey], 301 + }); 302 + return Number(r.rowsAffected ?? 0) > 0; 234 303 }); 235 304 } 236 305 ··· 495 564 rp.id AS report_id, rp.review_id, rp.reporter_did, rp.reason, 496 565 rp.details, rp.status AS report_status, rp.admin_notes AS report_notes, 497 566 rp.created_at AS report_created_at, rp.resolved_at, rp.resolved_by, 498 - rv.id, rv.target_did, rv.reviewer_did, rv.rating, rv.body, 567 + rv.id, rv.target_did, rv.reviewer_did, rv.review_uri, rv.review_cid, 568 + rv.review_rkey, rv.rating, rv.body, 499 569 rv.status, rv.created_at, rv.updated_at, rv.hidden_at, rv.hidden_by, 500 570 rv.removed_at, rv.removed_by, rv.admin_notes 501 571 FROM review_report rp ··· 523 593 id: Number(record.id), 524 594 target_did: String(record.target_did), 525 595 reviewer_did: String(record.reviewer_did), 596 + review_uri: record.review_uri as string | null, 597 + review_cid: record.review_cid as string | null, 598 + review_rkey: record.review_rkey as string | null, 526 599 rating: Number(record.rating), 527 600 body: String(record.body ?? ""), 528 601 status: String(record.status ?? "visible"),
+1 -1
routes/_app.tsx
··· 252 252 const locale = state.locale; 253 253 const t = getMessages(locale); 254 254 const effectsOn = url.pathname === "/"; 255 - const htmlClass = effectsOn ? undefined : "sky-static"; 255 + const htmlClass = effectsOn ? "sky-effects" : "sky-static"; 256 256 const bodyClass = effectsOn ? "sky-bg" : "sky-bg explore-no-effects"; 257 257 return ( 258 258 <html lang={locale} class={htmlClass}>
+15 -28
routes/account/reviews.tsx
··· 1 1 import { define } from "../../utils.ts"; 2 2 import Nav from "../../components/Nav.tsx"; 3 - import GlassClouds from "../../components/GlassClouds.tsx"; 4 3 import Footer from "../../components/Footer.tsx"; 4 + import UserBskyClientPicker from "../../islands/UserBskyClientPicker.tsx"; 5 5 import UserReviewRow from "../../islands/UserReviewRow.tsx"; 6 6 import { getMessages } from "../../i18n/mod.ts"; 7 7 import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; ··· 9 9 getAppUser, 10 10 getEffectiveAccountType, 11 11 } from "../../lib/account-types.ts"; 12 - import { BSKY_CLIENTS } from "../../lib/bsky-clients.ts"; 13 12 import { getProfileByDid } from "../../lib/registry.ts"; 14 13 import { listReviewsByReviewer, type ReviewRow } from "../../lib/reviews.ts"; 15 14 ··· 94 93 const displayName = profile?.displayName || handle; 95 94 return ( 96 95 <div id="page-top"> 97 - <GlassClouds /> 98 96 <div class="content-layer"> 99 97 <Nav account={account} /> 100 98 <section class="account-reviews-section"> ··· 109 107 <div class="user-profile-preview"> 110 108 <div class="user-profile-avatar"> 111 109 {avatarUrl 112 - ? <img src={avatarUrl} alt="" loading="lazy" /> 110 + ? ( 111 + <img 112 + src={avatarUrl} 113 + alt="" 114 + loading="lazy" 115 + decoding="async" 116 + /> 117 + ) 113 118 : <span>{displayName.slice(0, 1).toUpperCase()}</span>} 114 119 </div> 115 120 <div> ··· 118 123 {profile?.bio && <p class="user-profile-bio">{profile.bio} 119 124 </p>} 120 125 <a 121 - class="profile-form-button-secondary" 126 + class="profile-form-button-secondary user-profile-view-link" 122 127 href={`/users/${encodeURIComponent(handle)}`} 123 128 > 124 129 {copy.viewProfile} 125 130 </a> 126 131 </div> 127 132 </div> 128 - <form 129 - method="POST" 130 - action="/api/account/profile" 131 - class="user-profile-client-form" 132 - > 133 - <label> 134 - <span>{copy.clientLabel}</span> 135 - <select name="bskyClientId"> 136 - {BSKY_CLIENTS.map((client) => ( 137 - <option 138 - key={client.id} 139 - value={client.id} 140 - selected={profile?.bskyClientId === client.id} 141 - > 142 - {client.name} 143 - </option> 144 - ))} 145 - </select> 146 - </label> 147 - <button type="submit" class="profile-form-button-primary"> 148 - {copy.saveClient} 149 - </button> 150 - </form> 133 + <UserBskyClientPicker 134 + selectedClientId={profile?.bskyClientId ?? null} 135 + label={copy.clientLabel} 136 + saveLabel={copy.saveClient} 137 + /> 151 138 </section> 152 139 153 140 {reviews.length === 0
-2
routes/account/type.tsx
··· 1 1 import { define } from "../../utils.ts"; 2 2 import Nav from "../../components/Nav.tsx"; 3 - import GlassClouds from "../../components/GlassClouds.tsx"; 4 3 import Footer from "../../components/Footer.tsx"; 5 4 import { getMessages } from "../../i18n/mod.ts"; 6 5 import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; ··· 64 63 const copy = t.accountType; 65 64 return ( 66 65 <div id="page-top"> 67 - <GlassClouds /> 68 66 <div class="content-layer"> 69 67 <Nav account={account} /> 70 68 <section class="account-type-section">
-2
routes/admin/featured.tsx
··· 5 5 */ 6 6 import { define } from "../../utils.ts"; 7 7 import Nav from "../../components/Nav.tsx"; 8 - import GlassClouds from "../../components/GlassClouds.tsx"; 9 8 import Footer from "../../components/Footer.tsx"; 10 9 import AdminFeaturedEditor, { 11 10 type FeaturedCandidate, ··· 55 54 const t = getMessages(locale).admin; 56 55 return ( 57 56 <div id="page-top"> 58 - <GlassClouds /> 59 57 <div class="content-layer"> 60 58 <Nav account={account} /> 61 59 <section class="admin-section">
-2
routes/admin/icon-access.tsx
··· 5 5 */ 6 6 import { define } from "../../utils.ts"; 7 7 import Nav from "../../components/Nav.tsx"; 8 - import GlassClouds from "../../components/GlassClouds.tsx"; 9 8 import Footer from "../../components/Footer.tsx"; 10 9 import AdminIconAccessGrant from "../../islands/AdminIconAccessGrant.tsx"; 11 10 import AdminIconAccessRow from "../../islands/AdminIconAccessRow.tsx"; ··· 55 54 const ti = t.iconAccess; 56 55 return ( 57 56 <div id="page-top"> 58 - <GlassClouds /> 59 57 <div class="content-layer"> 60 58 <Nav account={account} /> 61 59 <section class="admin-section">
-2
routes/admin/index.tsx
··· 4 4 */ 5 5 import { define } from "../../utils.ts"; 6 6 import Nav from "../../components/Nav.tsx"; 7 - import GlassClouds from "../../components/GlassClouds.tsx"; 8 7 import Footer from "../../components/Footer.tsx"; 9 8 import { getMessages } from "../../i18n/mod.ts"; 10 9 import type { Locale } from "../../i18n/mod.ts"; ··· 60 59 const t = getMessages(locale).admin; 61 60 return ( 62 61 <div id="page-top"> 63 - <GlassClouds /> 64 62 <div class="content-layer"> 65 63 <Nav account={account} /> 66 64 <section class="admin-section">
-2
routes/admin/reports.tsx
··· 4 4 */ 5 5 import { define } from "../../utils.ts"; 6 6 import Nav from "../../components/Nav.tsx"; 7 - import GlassClouds from "../../components/GlassClouds.tsx"; 8 7 import Footer from "../../components/Footer.tsx"; 9 8 import AdminReportRow from "../../islands/AdminReportRow.tsx"; 10 9 import { getMessages } from "../../i18n/mod.ts"; ··· 51 50 const t = getMessages(locale).admin; 52 51 return ( 53 52 <div id="page-top"> 54 - <GlassClouds /> 55 53 <div class="content-layer"> 56 54 <Nav account={account} /> 57 55 <section class="admin-section">
-2
routes/admin/reviews.tsx
··· 3 3 */ 4 4 import { define } from "../../utils.ts"; 5 5 import Nav from "../../components/Nav.tsx"; 6 - import GlassClouds from "../../components/GlassClouds.tsx"; 7 6 import Footer from "../../components/Footer.tsx"; 8 7 import AdminReviewReportRow from "../../islands/AdminReviewReportRow.tsx"; 9 8 import { getMessages } from "../../i18n/mod.ts"; ··· 58 57 const t = getMessages(locale).admin; 59 58 return ( 60 59 <div id="page-top"> 61 - <GlassClouds /> 62 60 <div class="content-layer"> 63 61 <Nav account={account} /> 64 62 <section class="admin-section">
-2
routes/admin/takedowns.tsx
··· 5 5 */ 6 6 import { define } from "../../utils.ts"; 7 7 import Nav from "../../components/Nav.tsx"; 8 - import GlassClouds from "../../components/GlassClouds.tsx"; 9 8 import Footer from "../../components/Footer.tsx"; 10 9 import AdminTakedownRow from "../../islands/AdminTakedownRow.tsx"; 11 10 import { getMessages } from "../../i18n/mod.ts"; ··· 41 40 const t = getMessages(locale).admin; 42 41 return ( 43 42 <div id="page-top"> 44 - <GlassClouds /> 45 43 <div class="content-layer"> 46 44 <Nav account={account} /> 47 45 <section class="admin-section">
+55 -2
routes/api/account/type.ts
··· 7 7 setAppUserType, 8 8 } from "../../../lib/account-types.ts"; 9 9 import { loadSession } from "../../../lib/oauth.ts"; 10 - import { getBskyProfile } from "../../../lib/pds.ts"; 11 - import { getProfileByDid } from "../../../lib/registry.ts"; 10 + import { getBskyProfile, putProfileRecord } from "../../../lib/pds.ts"; 11 + import { getProfileByDid, upsertProfile } from "../../../lib/registry.ts"; 12 + import { type ProfileRecord, validateProfile } from "../../../lib/lexicons.ts"; 12 13 13 14 export const handler = define.handlers({ 14 15 async POST(ctx) { ··· 42 43 } 43 44 44 45 const session = await loadSession(user.did).catch(() => null); 46 + if (accountType === "user" && !session) { 47 + return new Response("OAuth session expired, please sign in again", { 48 + status: 401, 49 + }); 50 + } 51 + 45 52 const bskyProfile = session 46 53 ? await getBskyProfile(session.pdsUrl, user.did).catch(() => null) 47 54 : null; ··· 55 62 avatarMime: bskyProfile?.avatar?.mimeType ?? null, 56 63 accountType, 57 64 }); 65 + 66 + if (accountType === "user" && session) { 67 + const now = new Date().toISOString(); 68 + const draft: ProfileRecord = { 69 + profileType: "user", 70 + name: bskyProfile?.displayName?.trim() || user.handle, 71 + description: bskyProfile?.description?.trim() ?? "", 72 + avatar: bskyProfile?.avatar, 73 + createdAt: now, 74 + }; 75 + const validation = validateProfile(draft); 76 + if (!validation.ok || !validation.value) { 77 + return new Response(`invalid user profile: ${validation.error}`, { 78 + status: 400, 79 + }); 80 + } 81 + const result = await putProfileRecord( 82 + user.did, 83 + session.pdsUrl, 84 + validation.value, 85 + ).then((value) => value).catch((err) => 86 + err instanceof Error ? err : new Error(String(err)) 87 + ); 88 + if (result instanceof Error) { 89 + return new Response(`putRecord failed: ${result.message}`, { 90 + status: 502, 91 + }); 92 + } 93 + await upsertProfile({ 94 + did: user.did, 95 + handle: user.handle, 96 + profileType: "user", 97 + name: validation.value.name, 98 + description: validation.value.description, 99 + categories: [], 100 + subcategories: [], 101 + links: [], 102 + screenshots: [], 103 + avatarCid: validation.value.avatar?.ref.$link ?? null, 104 + avatarMime: validation.value.avatar?.mimeType ?? null, 105 + pdsUrl: session.pdsUrl, 106 + recordCid: result.cid, 107 + recordRev: result.commit?.rev ?? result.cid, 108 + createdAt: Date.parse(validation.value.createdAt) || Date.now(), 109 + }); 110 + } 58 111 59 112 return new Response(null, { 60 113 status: 303,
+3 -1
routes/api/registry/profile.ts
··· 354 354 } 355 355 356 356 const draft: ProfileRecord = { 357 + profileType: "project", 357 358 name: trimOrNull(body.name) ?? "", 358 359 description: trimOrNull(body.description) ?? "", 359 360 mainLink, ··· 397 398 await upsertProfile({ 398 399 did: user.did, 399 400 handle: user.handle, 401 + profileType: validation.value.profileType, 400 402 name: validation.value.name, 401 403 description: validation.value.description, 402 404 mainLink: validation.value.mainLink ?? null, 403 405 iosLink: validation.value.iosLink ?? null, 404 406 androidLink: validation.value.androidLink ?? null, 405 - categories: validation.value.categories, 407 + categories: validation.value.categories ?? [], 406 408 subcategories: validation.value.subcategories ?? [], 407 409 links: validation.value.links ?? [], 408 410 screenshots: validation.value.screenshots ?? [],
+52
routes/api/registry/profile/[id]/reviews.ts
··· 6 6 */ 7 7 import { define } from "../../../../../utils.ts"; 8 8 import { withRateLimit } from "../../../../../lib/rate-limit.ts"; 9 + import { loadSession } from "../../../../../lib/oauth.ts"; 10 + import { putReviewRecord } from "../../../../../lib/pds.ts"; 9 11 import { 10 12 getProfileByDid, 11 13 getProfileByHandle, ··· 16 18 getReviewSummary, 17 19 listVisibleReviews, 18 20 normalizeReviewBody, 21 + reviewRkeyForTarget, 22 + reviewUriForRkey, 19 23 validateReviewRating, 20 24 } from "../../../../../lib/reviews.ts"; 25 + import { 26 + type ReviewRecord, 27 + validateReview, 28 + } from "../../../../../lib/lexicons.ts"; 21 29 22 30 interface ReviewPayload { 23 31 rating?: unknown; ··· 60 68 const target = await resolveTarget(ctx.params.id); 61 69 if (!target) return jsonError(404, "not_found"); 62 70 if (target.did === user.did) return jsonError(400, "cannot_review_self"); 71 + if (target.profileType !== "project") return jsonError(400, "not_project"); 72 + 73 + const session = await loadSession(user.did); 74 + if (!session) return jsonError(401, "oauth_session_expired"); 63 75 64 76 const body = await ctx.req.json().catch(() => null) as 65 77 | ReviewPayload ··· 72 84 const reviewBody = normalizeReviewBody(body.body); 73 85 if (reviewBody == null) return jsonError(400, "body_too_long"); 74 86 87 + const existing = await getOwnReview(target.did, user.did).catch(() => null); 88 + const now = new Date(); 89 + const rkey = existing?.reviewRkey ?? await reviewRkeyForTarget(target.did); 90 + const createdAt = existing 91 + ? new Date(existing.createdAt).toISOString() 92 + : now.toISOString(); 93 + const record: ReviewRecord = { 94 + subject: target.did, 95 + subjectUri: 96 + `at://${target.did}/com.atmosphereaccount.registry.profile/self`, 97 + rating, 98 + body: reviewBody || undefined, 99 + createdAt, 100 + updatedAt: now.toISOString(), 101 + }; 102 + const validation = validateReview(record); 103 + if (!validation.ok || !validation.value) { 104 + return jsonError(400, "invalid_review_record"); 105 + } 106 + 107 + const result = await putReviewRecord( 108 + user.did, 109 + session.pdsUrl, 110 + rkey, 111 + validation.value, 112 + ).catch((err) => err instanceof Error ? err : new Error(String(err))); 113 + if (result instanceof Error) { 114 + return jsonResponse(502, { 115 + error: "put_record_failed", 116 + detail: result.message, 117 + }); 118 + } 119 + 75 120 const review = await createOrUpdateReview({ 76 121 targetDid: target.did, 77 122 reviewerDid: user.did, 123 + reviewUri: reviewUriForRkey(user.did, rkey), 124 + reviewCid: result.cid, 125 + reviewRkey: rkey, 78 126 rating, 79 127 body: reviewBody, 128 + createdAt: Date.parse(validation.value.createdAt) || Date.now(), 129 + updatedAt: 130 + Date.parse(validation.value.updatedAt ?? validation.value.createdAt) || 131 + Date.now(), 80 132 }); 81 133 const summary = await getReviewSummary(target.did); 82 134 return jsonResponse(200, { ok: true, review, summary });
+21
routes/api/registry/profile/[id]/reviews/me.ts
··· 5 5 */ 6 6 import { define } from "../../../../../../utils.ts"; 7 7 import { withRateLimit } from "../../../../../../lib/rate-limit.ts"; 8 + import { loadSession } from "../../../../../../lib/oauth.ts"; 9 + import { deleteReviewRecord } from "../../../../../../lib/pds.ts"; 8 10 import { 9 11 getProfileByDid, 10 12 getProfileByHandle, 11 13 } from "../../../../../../lib/registry.ts"; 12 14 import { 13 15 deleteOwnReview, 16 + getOwnReview, 14 17 getReviewSummary, 15 18 } from "../../../../../../lib/reviews.ts"; 16 19 ··· 22 25 const target = await resolveTarget(ctx.params.id); 23 26 if (!target) return jsonError(404, "not_found"); 24 27 28 + const existing = await getOwnReview(target.did, user.did); 29 + if (existing?.reviewRkey) { 30 + const session = await loadSession(user.did); 31 + if (!session) return jsonError(401, "oauth_session_expired"); 32 + const deleted = await deleteReviewRecord( 33 + user.did, 34 + session.pdsUrl, 35 + existing.reviewRkey, 36 + ).then(() => null).catch((err) => 37 + err instanceof Error ? err : new Error(String(err)) 38 + ); 39 + if (deleted) { 40 + return jsonResponse(502, { 41 + error: "delete_record_failed", 42 + detail: deleted.message, 43 + }); 44 + } 45 + } 25 46 const removed = await deleteOwnReview(target.did, user.did); 26 47 const summary = await getReviewSummary(target.did); 27 48 return jsonResponse(200, { ok: true, removed, summary });
+24 -1
routes/api/registry/reviews/[reviewId].ts
··· 5 5 */ 6 6 import { define } from "../../../../utils.ts"; 7 7 import { withRateLimit } from "../../../../lib/rate-limit.ts"; 8 - import { deleteOwnReviewById } from "../../../../lib/reviews.ts"; 8 + import { loadSession } from "../../../../lib/oauth.ts"; 9 + import { deleteReviewRecord } from "../../../../lib/pds.ts"; 10 + import { deleteOwnReviewById, getReviewById } from "../../../../lib/reviews.ts"; 9 11 10 12 export const handler = define.handlers({ 11 13 DELETE: withRateLimit(async (ctx) => { ··· 17 19 return jsonError(400, "invalid_review_id"); 18 20 } 19 21 22 + const existing = await getReviewById(reviewId); 23 + if (existing && existing.reviewerDid !== user.did) { 24 + return jsonError(404, "not_found"); 25 + } 26 + if (existing?.reviewRkey) { 27 + const session = await loadSession(user.did); 28 + if (!session) return jsonError(401, "oauth_session_expired"); 29 + const deleted = await deleteReviewRecord( 30 + user.did, 31 + session.pdsUrl, 32 + existing.reviewRkey, 33 + ).then(() => null).catch((err) => 34 + err instanceof Error ? err : new Error(String(err)) 35 + ); 36 + if (deleted) { 37 + return jsonResponse(502, { 38 + error: "delete_record_failed", 39 + detail: deleted.message, 40 + }); 41 + } 42 + } 20 43 const removed = await deleteOwnReviewById(reviewId, user.did); 21 44 return jsonResponse(200, { ok: true, removed }); 22 45 }),
-2
routes/developer-resources.tsx
··· 1 1 import { define } from "../utils.ts"; 2 2 import Nav from "../components/Nav.tsx"; 3 - import GlassClouds from "../components/GlassClouds.tsx"; 4 3 import DeveloperResources from "../components/DeveloperResources.tsx"; 5 4 import Footer from "../components/Footer.tsx"; 6 5 7 6 export default define.page(function DeveloperResourcesPage() { 8 7 return ( 9 8 <div id="page-top"> 10 - <GlassClouds /> 11 9 <div class="content-layer"> 12 10 <Nav /> 13 11 <section style={{ paddingTop: "8rem" }}>
-2
routes/explore.tsx
··· 1 1 import { define } from "../utils.ts"; 2 2 import Nav from "../components/Nav.tsx"; 3 - import GlassClouds from "../components/GlassClouds.tsx"; 4 3 import Footer from "../components/Footer.tsx"; 5 4 import StoreHero from "../components/explore/StoreHero.tsx"; 6 5 import CategoryTabs from "../components/explore/CategoryTabs.tsx"; ··· 94 93 function ExplorePage({ data, locale: _locale }: ExplorePageProps) { 95 94 return ( 96 95 <div id="page-top"> 97 - <GlassClouds /> 98 96 <div class="content-layer"> 99 97 <Nav account={data.account} /> 100 98 <StoreHero
+4 -38
routes/explore/[handle].tsx
··· 1 1 import { define } from "../../utils.ts"; 2 2 import Nav from "../../components/Nav.tsx"; 3 - import GlassClouds from "../../components/GlassClouds.tsx"; 4 3 import Footer from "../../components/Footer.tsx"; 5 4 import ProfileHero from "../../components/explore/ProfileHero.tsx"; 6 5 import ProfileLinks from "../../components/explore/ProfileLinks.tsx"; ··· 27 26 } from "../../lib/reviews.ts"; 28 27 import { accountProviderName } from "../../lib/account-providers.ts"; 29 28 import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 30 - import { getAppUser, updateAppUserProfile } from "../../lib/account-types.ts"; 31 - import { resolveIdentity } from "../../lib/identity.ts"; 32 - import { getBskyProfile } from "../../lib/pds.ts"; 29 + import { getAppUser } from "../../lib/account-types.ts"; 33 30 34 31 function bskyCdnAvatarUrl(did: string, cid: string, mime: string): string { 35 32 const ext = mime === "image/png" ··· 120 117 const providerName = accountProviderName(profile.pdsUrl); 121 118 return ( 122 119 <div id="page-top"> 123 - <GlassClouds /> 124 120 <div class="content-layer"> 125 121 <Nav account={account} /> 126 122 <section class="explore-profile-detail"> ··· 258 254 getAppUser(review.reviewerDid).catch(() => null), 259 255 getProfileByDid(review.reviewerDid).catch(() => null), 260 256 ]); 261 - let reviewerName = appUser?.displayName ?? profile?.name ?? null; 262 - let reviewerHandle = appUser?.handle ?? profile?.handle ?? null; 263 - let reviewerAvatarUrl = appUser?.avatarCid && appUser.avatarMime 257 + const reviewerName = appUser?.displayName ?? profile?.name ?? null; 258 + const reviewerHandle = appUser?.handle ?? profile?.handle ?? null; 259 + const reviewerAvatarUrl = appUser?.avatarCid && appUser.avatarMime 264 260 ? bskyCdnAvatarUrl( 265 261 review.reviewerDid, 266 262 appUser.avatarCid, ··· 269 265 : profile?.avatarCid 270 266 ? `/api/registry/avatar/${encodeURIComponent(review.reviewerDid)}` 271 267 : null; 272 - if (!reviewerName || !reviewerAvatarUrl) { 273 - const identity = await resolveIdentity(review.reviewerDid).catch(() => 274 - null 275 - ); 276 - const bsky = identity 277 - ? await getBskyProfile(identity.pdsUrl, review.reviewerDid).catch( 278 - () => null, 279 - ) 280 - : null; 281 - reviewerName = bsky?.displayName ?? null; 282 - reviewerHandle = reviewerHandle ?? identity?.handle ?? null; 283 - reviewerAvatarUrl = bsky?.avatar 284 - ? bskyCdnAvatarUrl( 285 - review.reviewerDid, 286 - bsky.avatar.ref.$link, 287 - bsky.avatar.mimeType, 288 - ) 289 - : reviewerAvatarUrl; 290 - if (appUser && identity) { 291 - await updateAppUserProfile({ 292 - did: review.reviewerDid, 293 - handle: identity.handle, 294 - displayName: bsky?.displayName ?? null, 295 - bio: bsky?.description ?? null, 296 - avatarCid: bsky?.avatar?.ref.$link ?? null, 297 - avatarMime: bsky?.avatar?.mimeType ?? null, 298 - }).catch(() => {}); 299 - } 300 - } 301 268 return { 302 269 ...review, 303 270 reviewerName, ··· 321 288 const t = getMessages(locale).explore.detail; 322 289 return ( 323 290 <div id="page-top"> 324 - <GlassClouds /> 325 291 <div class="content-layer"> 326 292 <Nav account={account} /> 327 293 <section class="explore-profile-detail">
-2
routes/explore/create.tsx
··· 1 1 import { define } from "../../utils.ts"; 2 2 import Nav from "../../components/Nav.tsx"; 3 - import GlassClouds from "../../components/GlassClouds.tsx"; 4 3 import Footer from "../../components/Footer.tsx"; 5 4 import SignInForm from "../../islands/SignInForm.tsx"; 6 5 import { getMessages } from "../../i18n/mod.ts"; ··· 40 39 41 40 return ( 42 41 <div id="page-top"> 43 - <GlassClouds /> 44 42 <div class="content-layer"> 45 43 <Nav account={account} /> 46 44 <section class="explore-create" style={{ paddingTop: "8rem" }}>
-2
routes/explore/manage.tsx
··· 1 1 import { define } from "../../utils.ts"; 2 2 import Nav from "../../components/Nav.tsx"; 3 - import GlassClouds from "../../components/GlassClouds.tsx"; 4 3 import Footer from "../../components/Footer.tsx"; 5 4 import CreateProfileForm from "../../islands/CreateProfileForm.tsx"; 6 5 import { getMessages } from "../../i18n/mod.ts"; ··· 185 184 const takedownCopy = t.manageTakedown; 186 185 return ( 187 186 <div id="page-top"> 188 - <GlassClouds /> 189 187 <div class="content-layer"> 190 188 <Nav account={account} /> 191 189 <section class="explore-manage" style={{ paddingTop: "8rem" }}>
+44 -1
routes/oauth/callback.ts
··· 10 10 import { define } from "../../utils.ts"; 11 11 import { completeCallback, isOAuthConfigured } from "../../lib/oauth.ts"; 12 12 import { buildSessionCookie, createSession } from "../../lib/session.ts"; 13 - import { getBskyProfile } from "../../lib/pds.ts"; 13 + import { getBskyProfile, putProfileRecord } from "../../lib/pds.ts"; 14 14 import { 15 15 addRememberedAccountCookie, 16 16 readRememberedAccountsFromHeader, 17 17 } from "../../lib/remembered-accounts.ts"; 18 18 import { 19 + getEffectiveAccountType, 19 20 requiresAccountTypeChoice, 20 21 updateAppUserProfile, 21 22 } from "../../lib/account-types.ts"; 23 + import { type ProfileRecord, validateProfile } from "../../lib/lexicons.ts"; 24 + import { upsertProfile } from "../../lib/registry.ts"; 22 25 23 26 export const handler = define.handlers({ 24 27 async GET(ctx) { ··· 64 67 avatarCid: bskyProfile?.avatar?.ref.$link ?? null, 65 68 avatarMime: bskyProfile?.avatar?.mimeType ?? null, 66 69 }).catch(() => {}); 70 + const accountType = await getEffectiveAccountType(result.did).catch(() => 71 + null 72 + ); 73 + if (accountType === "user") { 74 + const now = new Date().toISOString(); 75 + const draft: ProfileRecord = { 76 + profileType: "user", 77 + name: bskyProfile?.displayName?.trim() || result.handle, 78 + description: bskyProfile?.description?.trim() ?? "", 79 + avatar: bskyProfile?.avatar, 80 + createdAt: now, 81 + }; 82 + const validation = validateProfile(draft); 83 + if (validation.ok && validation.value) { 84 + const put = await putProfileRecord( 85 + result.did, 86 + result.pdsUrl, 87 + validation.value, 88 + ).catch(() => null); 89 + if (put) { 90 + await upsertProfile({ 91 + did: result.did, 92 + handle: result.handle, 93 + profileType: "user", 94 + name: validation.value.name, 95 + description: validation.value.description, 96 + categories: [], 97 + subcategories: [], 98 + links: [], 99 + screenshots: [], 100 + avatarCid: validation.value.avatar?.ref.$link ?? null, 101 + avatarMime: validation.value.avatar?.mimeType ?? null, 102 + pdsUrl: result.pdsUrl, 103 + recordCid: put.cid, 104 + recordRev: put.commit?.rev ?? put.cid, 105 + createdAt: Date.parse(validation.value.createdAt) || Date.now(), 106 + }).catch(() => {}); 107 + } 108 + } 109 + } 67 110 const returnTo = result.returnTo && result.returnTo.startsWith("/") && 68 111 !result.returnTo.startsWith("//") 69 112 ? result.returnTo
+28 -16
routes/users/[handle].tsx
··· 1 1 import { define } from "../../utils.ts"; 2 2 import Nav from "../../components/Nav.tsx"; 3 - import GlassClouds from "../../components/GlassClouds.tsx"; 4 3 import Footer from "../../components/Footer.tsx"; 5 4 import { getMessages } from "../../i18n/mod.ts"; 6 5 import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 7 6 import { getAppUserByHandle } from "../../lib/account-types.ts"; 8 7 import { getBskyClient } from "../../lib/bsky-clients.ts"; 8 + import { getProfileByHandle } from "../../lib/registry.ts"; 9 9 10 10 function bskyCdnAvatarUrl(did: string, cid: string, mime: string): string { 11 11 const ext = mime === "image/png" ··· 20 20 async GET(ctx) { 21 21 const handle = decodeURIComponent(ctx.params.handle ?? "").trim() 22 22 .toLowerCase(); 23 - const profile = handle 24 - ? await getAppUserByHandle(handle).catch(() => null) 25 - : null; 23 + const [profile, appUser] = handle 24 + ? await Promise.all([ 25 + getProfileByHandle(handle, { profileType: "user" }).catch(() => null), 26 + getAppUserByHandle(handle).catch(() => null), 27 + ]) 28 + : [null, null]; 26 29 return ctx.render( 27 30 <UserProfilePage 28 31 account={buildAccountMenuProps(ctx.state)} 29 - profile={profile?.accountType === "user" ? profile : null} 32 + profile={profile} 33 + bskyClientId={appUser?.bskyClientId ?? null} 30 34 t={getMessages(ctx.state.locale)} 31 35 />, 32 - { status: profile?.accountType === "user" ? 200 : 404 }, 36 + { status: profile ? 200 : 404 }, 33 37 ); 34 38 }, 35 39 }); 36 40 37 41 interface UserProfilePageProps { 38 42 account: ReturnType<typeof buildAccountMenuProps>; 39 - profile: Awaited<ReturnType<typeof getAppUserByHandle>>; 43 + profile: Awaited<ReturnType<typeof getProfileByHandle>>; 44 + bskyClientId: string | null; 40 45 // deno-lint-ignore no-explicit-any 41 46 t: any; 42 47 } 43 48 44 - function UserProfilePage({ account, profile, t }: UserProfilePageProps) { 49 + function UserProfilePage( 50 + { account, profile, bskyClientId, t }: UserProfilePageProps, 51 + ) { 45 52 const copy = t.userProfile; 46 53 if (!profile) { 47 54 return ( 48 55 <div id="page-top"> 49 - <GlassClouds /> 50 56 <div class="content-layer"> 51 57 <Nav account={account} showEffects={false} /> 52 58 <section class="user-public-section"> ··· 63 69 ); 64 70 } 65 71 66 - const displayName = profile.displayName || profile.handle; 72 + const displayName = profile.name || profile.handle; 67 73 const avatarUrl = profile.avatarCid && profile.avatarMime 68 74 ? bskyCdnAvatarUrl(profile.did, profile.avatarCid, profile.avatarMime) 69 75 : null; 70 - const client = getBskyClient(profile.bskyClientId); 76 + const client = getBskyClient(bskyClientId); 71 77 return ( 72 78 <div id="page-top"> 73 - <GlassClouds /> 74 79 <div class="content-layer"> 75 80 <Nav account={account} showEffects={false} /> 76 81 <section class="user-public-section"> ··· 83 88 <div class="glass user-public-card"> 84 89 <div class="user-public-avatar"> 85 90 {avatarUrl 86 - ? <img src={avatarUrl} alt="" /> 91 + ? <img src={avatarUrl} alt="" decoding="async" /> 87 92 : <span>{displayName.slice(0, 1).toUpperCase()}</span>} 88 93 </div> 89 94 <div class="user-public-body"> 90 95 <h1 class="text-section">{displayName}</h1> 91 96 <p class="user-profile-handle">@{profile.handle}</p> 92 - {profile.bio && ( 93 - <p class="text-body user-profile-bio">{profile.bio}</p> 97 + {profile.description && ( 98 + <p class="text-body user-profile-bio"> 99 + {profile.description} 100 + </p> 94 101 )} 95 102 <a 96 103 class="profile-hero-action user-public-client-link" ··· 99 106 rel="noopener noreferrer" 100 107 > 101 108 <span class="profile-hero-action-icon"> 102 - <img src={client.iconUrl} alt="" /> 109 + <img 110 + src={client.iconUrl} 111 + alt="" 112 + loading="lazy" 113 + decoding="async" 114 + /> 103 115 </span> 104 116 <span>{copy.openIn(client.name)}</span> 105 117 <span class="profile-hero-action-arrow" aria-hidden="true">
+58 -2
worker/indexer.ts
··· 15 15 import { 16 16 FEATURED_NSID, 17 17 PROFILE_NSID, 18 + REVIEW_NSID, 18 19 validateFeatured, 19 20 validateProfile, 21 + validateReview, 20 22 } from "../lib/lexicons.ts"; 21 23 import { 22 24 deleteProfile, 23 25 getJetstreamCursor, 26 + getProfileByDid, 24 27 replaceFeatured, 25 28 setJetstreamCursor, 26 29 upsertProfile, 27 30 } from "../lib/registry.ts"; 31 + import { 32 + createOrUpdateReview, 33 + markReviewRemovedByRkey, 34 + reviewUriForRkey, 35 + } from "../lib/reviews.ts"; 28 36 import { findPdsEndpoint, resolveDidDocument } from "../lib/identity.ts"; 29 37 import { getRecordPublic } from "../lib/pds.ts"; 30 38 import { JETSTREAM_URL } from "../lib/env.ts"; ··· 45 53 commit?: JetstreamCommit; 46 54 } 47 55 48 - const COLLECTIONS = [PROFILE_NSID, FEATURED_NSID]; 56 + const COLLECTIONS = [PROFILE_NSID, REVIEW_NSID, FEATURED_NSID]; 49 57 const RECONNECT_DELAY_MS = 5_000; 50 58 const CURSOR_PERSIST_INTERVAL_MS = 5_000; 51 59 ··· 108 116 await upsertProfile({ 109 117 did: event.did, 110 118 handle, 119 + profileType: r.profileType, 111 120 name: r.name, 112 121 description: r.description, 113 122 mainLink: r.mainLink ?? null, 114 123 iosLink: r.iosLink ?? null, 115 124 androidLink: r.androidLink ?? null, 116 - categories: r.categories, 125 + categories: r.categories ?? [], 117 126 subcategories: r.subcategories ?? [], 118 127 links: r.links ?? [], 119 128 screenshots: r.screenshots ?? [], ··· 129 138 console.log(`[indexer] upsert profile ${handle} (${event.did})`); 130 139 } 131 140 141 + async function handleReviewEvent(event: JetstreamEvent): Promise<void> { 142 + const commit = event.commit; 143 + if (!commit) return; 144 + 145 + if (commit.operation === "delete") { 146 + await markReviewRemovedByRkey(event.did, commit.rkey); 147 + return; 148 + } 149 + 150 + const pdsUrl = await resolvePdsForDid(event.did); 151 + const fetched = await getRecordPublic( 152 + pdsUrl, 153 + event.did, 154 + REVIEW_NSID, 155 + commit.rkey, 156 + ); 157 + if (!fetched) return; 158 + 159 + const validation = validateReview(fetched.value); 160 + if (!validation.ok || !validation.value) { 161 + console.warn( 162 + `[indexer] invalid review from ${event.did}: ${validation.error}`, 163 + ); 164 + return; 165 + } 166 + const r = validation.value; 167 + const target = await getProfileByDid(r.subject).catch(() => null); 168 + if (!target || target.profileType !== "project") { 169 + console.warn(`[indexer] ignoring review for non-project ${r.subject}`); 170 + return; 171 + } 172 + await createOrUpdateReview({ 173 + targetDid: r.subject, 174 + reviewerDid: event.did, 175 + reviewUri: reviewUriForRkey(event.did, commit.rkey), 176 + reviewCid: fetched.cid, 177 + reviewRkey: commit.rkey, 178 + rating: r.rating, 179 + body: r.body ?? "", 180 + createdAt: Date.parse(r.createdAt) || Date.now(), 181 + updatedAt: Date.parse(r.updatedAt ?? r.createdAt) || Date.now(), 182 + }); 183 + console.log(`[indexer] upsert review ${event.did} -> ${r.subject}`); 184 + } 185 + 132 186 async function handleFeaturedEvent(event: JetstreamEvent): Promise<void> { 133 187 const commit = event.commit; 134 188 if (!commit) return; ··· 180 234 try { 181 235 if (collection === PROFILE_NSID) { 182 236 await handleProfileEvent(event); 237 + } else if (collection === REVIEW_NSID) { 238 + await handleReviewEvent(event); 183 239 } else if (collection === FEATURED_NSID) { 184 240 await handleFeaturedEvent(event); 185 241 }