this repo has no description
0
fork

Configure Feed

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

feat(registry): add project update history

Add project-owned What's New records with local indexing and owner/public UI so update history can live on the project PDS while rendering quickly in Explore.

Made-with: Cursor

+1373 -63
+188
assets/styles.css
··· 2082 2082 padding: 1.5rem; 2083 2083 border-radius: 24px; 2084 2084 } 2085 + .profile-hero-media { 2086 + flex: 0 0 120px; 2087 + display: flex; 2088 + flex-direction: column; 2089 + align-items: center; 2090 + gap: 0.75rem; 2091 + } 2085 2092 .profile-hero-avatar { 2086 2093 width: 120px; 2087 2094 height: 120px; ··· 2092 2099 display: flex; 2093 2100 align-items: center; 2094 2101 justify-content: center; 2102 + } 2103 + .profile-hero-secondary-actions { 2104 + display: flex; 2105 + flex-wrap: wrap; 2106 + justify-content: center; 2107 + gap: 0.5rem; 2108 + max-width: 120px; 2095 2109 } 2096 2110 .profile-hero-avatar img { 2097 2111 width: 100%; ··· 2182 2196 @media (max-width: 720px) { 2183 2197 .profile-hero { 2184 2198 flex-wrap: wrap; 2199 + } 2200 + .profile-hero-media { 2201 + align-items: flex-start; 2202 + } 2203 + .profile-hero-secondary-actions { 2204 + justify-content: flex-start; 2185 2205 } 2186 2206 .profile-hero-actions { 2187 2207 width: 100%; ··· 3449 3469 color: rgba(194, 80, 72, 0.9); 3450 3470 } 3451 3471 3472 + .profile-update-editor { 3473 + padding: 1.35rem; 3474 + border-radius: 24px; 3475 + } 3476 + .profile-update-editor-header h2, 3477 + .profile-update-editor-header p { 3478 + margin: 0; 3479 + } 3480 + .profile-update-editor-header h2 { 3481 + font-size: 1.25rem; 3482 + } 3483 + .profile-update-editor-header p { 3484 + margin-top: 0.25rem; 3485 + color: rgba(18, 26, 47, 0.68); 3486 + } 3487 + .profile-update-form { 3488 + display: flex; 3489 + flex-direction: column; 3490 + gap: 0.9rem; 3491 + margin-top: 1rem; 3492 + } 3493 + .profile-update-form-grid { 3494 + display: grid; 3495 + grid-template-columns: minmax(0, 1fr) minmax(150px, 220px); 3496 + gap: 0.9rem; 3497 + } 3498 + .profile-update-actions, 3499 + .profile-update-list-actions { 3500 + display: flex; 3501 + flex-wrap: wrap; 3502 + align-items: center; 3503 + gap: 0.65rem; 3504 + } 3505 + .profile-update-list { 3506 + display: flex; 3507 + flex-direction: column; 3508 + gap: 0.7rem; 3509 + margin-top: 1rem; 3510 + } 3511 + .profile-update-list-item { 3512 + display: flex; 3513 + justify-content: space-between; 3514 + gap: 1rem; 3515 + padding: 0.95rem; 3516 + border-radius: 16px; 3517 + background: rgba(255, 255, 255, 0.4); 3518 + border: 1px solid rgba(255, 255, 255, 0.46); 3519 + } 3520 + .profile-update-list-item h3, 3521 + .profile-update-list-item p { 3522 + margin: 0; 3523 + } 3524 + .profile-update-list-item h3 { 3525 + font-size: 1rem; 3526 + } 3527 + .profile-update-list-item p { 3528 + margin-top: 0.25rem; 3529 + color: rgba(18, 26, 47, 0.68); 3530 + white-space: pre-wrap; 3531 + } 3532 + .profile-update-list-meta { 3533 + display: flex; 3534 + gap: 0.45rem; 3535 + flex-wrap: wrap; 3536 + font-size: 0.78rem; 3537 + font-weight: 700; 3538 + color: rgba(18, 26, 47, 0.55); 3539 + } 3540 + .dark-phase .profile-update-editor-header p, 3541 + .dark-phase .profile-update-list-item p, 3542 + .dark-phase .profile-update-list-meta { 3543 + color: rgba(255, 255, 255, 0.62); 3544 + } 3545 + .dark-phase .profile-update-list-item { 3546 + background: rgba(255, 255, 255, 0.06); 3547 + border-color: rgba(255, 255, 255, 0.12); 3548 + } 3549 + @media (max-width: 720px) { 3550 + .profile-update-form-grid, 3551 + .profile-update-list-item { 3552 + grid-template-columns: 1fr; 3553 + } 3554 + .profile-update-list-item { 3555 + flex-direction: column; 3556 + } 3557 + } 3558 + 3452 3559 /* ---- Sign-in form ---- */ 3453 3560 .signin-form { 3454 3561 display: flex; ··· 4912 5019 gap: 0.85rem; 4913 5020 margin-top: 1.5rem; 4914 5021 } 5022 + .profile-whats-new { 5023 + margin-top: 1.25rem; 5024 + padding: 1.2rem; 5025 + border-radius: 1.2rem; 5026 + } 5027 + .profile-whats-new-main { 5028 + display: flex; 5029 + justify-content: space-between; 5030 + align-items: flex-start; 5031 + gap: 1rem; 5032 + } 5033 + .profile-whats-new h2, 5034 + .profile-whats-new h3, 5035 + .profile-whats-new h4, 5036 + .profile-whats-new p { 5037 + margin: 0; 5038 + } 5039 + .profile-whats-new h2 { 5040 + margin-top: 0.25rem; 5041 + font-size: clamp(1.2rem, 2.3vw, 1.55rem); 5042 + } 5043 + .profile-whats-new-main p { 5044 + margin-top: 0.45rem; 5045 + color: rgba(18, 26, 47, 0.76); 5046 + white-space: pre-wrap; 5047 + } 5048 + .profile-whats-new-empty { 5049 + color: rgba(18, 26, 47, 0.62); 5050 + } 5051 + .profile-whats-new-meta { 5052 + display: flex; 5053 + flex-wrap: wrap; 5054 + gap: 0.45rem; 5055 + font-size: 0.78rem; 5056 + font-weight: 750; 5057 + color: rgba(18, 26, 47, 0.58); 5058 + } 5059 + .profile-whats-new-commit { 5060 + flex: 0 0 auto; 5061 + text-decoration: none; 5062 + color: inherit; 5063 + } 5064 + .profile-version-history { 5065 + margin-top: 1rem; 5066 + padding-top: 1rem; 5067 + border-top: 1px solid rgba(18, 26, 47, 0.1); 5068 + } 5069 + .profile-version-history h3 { 5070 + font-size: 0.95rem; 5071 + margin-bottom: 0.75rem; 5072 + } 5073 + .profile-version-history-item { 5074 + padding: 0.85rem 0; 5075 + border-top: 1px solid rgba(18, 26, 47, 0.08); 5076 + } 5077 + .profile-version-history-item:first-of-type { 5078 + border-top: 0; 5079 + padding-top: 0; 5080 + } 5081 + .profile-version-history-item h4 { 5082 + margin-top: 0.2rem; 5083 + font-size: 0.98rem; 5084 + } 5085 + .profile-version-history-item p { 5086 + margin-top: 0.25rem; 5087 + color: rgba(18, 26, 47, 0.7); 5088 + white-space: pre-wrap; 5089 + } 4915 5090 .profile-reviews-summary, 4916 5091 .profile-reviews-panel, 4917 5092 .profile-review-card { ··· 5149 5324 color: rgba(255, 255, 255, 0.65); 5150 5325 } 5151 5326 .dark-phase .profile-reviews-threshold, 5327 + .dark-phase .profile-whats-new-empty, 5328 + .dark-phase .profile-whats-new-main p, 5329 + .dark-phase .profile-version-history-item p, 5330 + .dark-phase .profile-whats-new-meta, 5152 5331 .dark-phase .profile-rating-row, 5153 5332 .dark-phase .profile-review-handle, 5154 5333 .dark-phase .profile-review-date, ··· 5173 5352 background: rgba(255, 255, 255, 0.12); 5174 5353 color: rgba(255, 255, 255, 0.72); 5175 5354 } 5355 + .dark-phase .profile-version-history { 5356 + border-top-color: rgba(255, 255, 255, 0.12); 5357 + } 5358 + .dark-phase .profile-version-history-item { 5359 + border-top-color: rgba(255, 255, 255, 0.1); 5360 + } 5176 5361 .dark-phase .profile-review-body-field textarea, 5177 5362 .dark-phase .profile-review-response-composer textarea { 5178 5363 background: rgba(255, 255, 255, 0.06); ··· 5182 5367 border-left-color: rgba(255, 255, 255, 0.2); 5183 5368 } 5184 5369 @media (max-width: 640px) { 5370 + .profile-whats-new-main { 5371 + flex-direction: column; 5372 + } 5185 5373 .profile-reviews-summary { 5186 5374 grid-template-columns: 1fr; 5187 5375 }
+82 -15
components/explore/ProfileHero.tsx
··· 1 1 import type { ProfileRow } from "../../lib/registry.ts"; 2 2 import { PUBLIC_CATEGORIES } from "../../lib/lexicons.ts"; 3 + import { 4 + type ResolvedIconKind, 5 + resolveLink, 6 + } from "../../lib/atmosphere-links.ts"; 3 7 import { useT } from "../../i18n/mod.ts"; 4 8 import VerifiedBadge from "../VerifiedBadge.tsx"; 5 9 import WebsiteIcon from "../icons/WebsiteIcon.tsx"; 6 10 import { AndroidIcon, AppleIcon } from "../icons/PlatformIcons.tsx"; 11 + import BskyIcon from "../icons/BskyIcon.tsx"; 12 + import TangledIcon from "../icons/TangledIcon.tsx"; 7 13 8 14 interface Props { 9 15 profile: ProfileRow; ··· 11 17 12 18 /** 13 19 * Profile detail hero. Primary app destinations live in a right-side rail; 14 - * secondary Atmosphere/custom links render below the card. 20 + * secondary Atmosphere/custom links sit under the avatar. 15 21 */ 16 22 export default function ProfileHero({ profile }: Props) { 17 23 const t = useT(); ··· 49 55 } 50 56 : null, 51 57 ].filter((link): link is NonNullable<typeof link> => link !== null); 58 + const secondaryLinks = profile.links 59 + .filter((entry) => entry.kind !== "website") 60 + .map((entry) => resolveLink(entry, profile.handle, tLink)) 61 + .filter((r): r is NonNullable<typeof r> => r !== null); 52 62 53 63 return ( 54 64 <div class="profile-hero glass"> 55 - <div class="profile-hero-avatar"> 56 - {profile.avatarCid 57 - ? ( 58 - <img 59 - src={`/api/registry/avatar/${encodeURIComponent(profile.did)}`} 60 - alt={profile.name} 61 - decoding="async" 62 - /> 63 - ) 64 - : ( 65 - <div class="profile-hero-avatar-fallback" aria-hidden="true"> 66 - {profile.name.slice(0, 1).toUpperCase()} 67 - </div> 68 - )} 65 + <div class="profile-hero-media"> 66 + <div class="profile-hero-avatar"> 67 + {profile.avatarCid 68 + ? ( 69 + <img 70 + src={`/api/registry/avatar/${encodeURIComponent(profile.did)}`} 71 + alt={profile.name} 72 + decoding="async" 73 + /> 74 + ) 75 + : ( 76 + <div class="profile-hero-avatar-fallback" aria-hidden="true"> 77 + {profile.name.slice(0, 1).toUpperCase()} 78 + </div> 79 + )} 80 + </div> 81 + {secondaryLinks.length > 0 && ( 82 + <div 83 + class="profile-hero-secondary-actions" 84 + aria-label="Profile links" 85 + > 86 + {secondaryLinks.map((link, i) => ( 87 + <a 88 + class="profile-action profile-action--compact" 89 + href={link.href} 90 + target="_blank" 91 + rel="noopener noreferrer" 92 + aria-label={link.title} 93 + title={link.title} 94 + key={`${link.href}-${i}`} 95 + > 96 + {renderIcon(link.iconKind, link.iconUrl, link.glyph)} 97 + </a> 98 + ))} 99 + </div> 100 + )} 69 101 </div> 70 102 <div class="profile-hero-body"> 71 103 <div class="profile-hero-name-row"> ··· 136 168 </div> 137 169 ); 138 170 } 171 + 172 + function renderIcon( 173 + iconKind: ResolvedIconKind | undefined, 174 + iconUrl: string | null, 175 + glyph: string, 176 + ) { 177 + if (iconKind === "bsky") { 178 + return ( 179 + <span class="profile-action-icon profile-action-icon--brand"> 180 + <BskyIcon class="profile-action-icon-svg" /> 181 + </span> 182 + ); 183 + } 184 + if (iconKind === "tangled") { 185 + return ( 186 + <span class="profile-action-icon profile-action-icon--brand"> 187 + <TangledIcon class="profile-action-icon-svg" /> 188 + </span> 189 + ); 190 + } 191 + if (iconUrl) { 192 + return ( 193 + <img 194 + src={iconUrl} 195 + alt="" 196 + class="profile-action-icon" 197 + loading="lazy" 198 + decoding="async" 199 + /> 200 + ); 201 + } 202 + return ( 203 + <span class="profile-action-icon profile-action-icon--glyph">{glyph}</span> 204 + ); 205 + }
+88
components/explore/ProfileWhatsNew.tsx
··· 1 + import type { ProfileUpdateRow } from "../../lib/profile-updates.ts"; 2 + 3 + interface Props { 4 + updates: ProfileUpdateRow[]; 5 + copy: { 6 + heading: string; 7 + empty: string; 8 + versionHistory: string; 9 + viewCommit: string; 10 + }; 11 + } 12 + 13 + function dateLabel(ms: number): string { 14 + return new Intl.DateTimeFormat("en", { 15 + month: "short", 16 + day: "numeric", 17 + year: "numeric", 18 + }).format(new Date(ms)); 19 + } 20 + 21 + function UpdateMeta({ update }: { update: ProfileUpdateRow }) { 22 + return ( 23 + <div class="profile-whats-new-meta"> 24 + {update.version && <span>{update.version}</span>} 25 + <time dateTime={new Date(update.createdAt).toISOString()}> 26 + {dateLabel(update.createdAt)} 27 + </time> 28 + </div> 29 + ); 30 + } 31 + 32 + export default function ProfileWhatsNew({ updates, copy }: Props) { 33 + const [latest, ...history] = updates; 34 + if (!latest) { 35 + return ( 36 + <section class="profile-whats-new glass"> 37 + <h2>{copy.heading}</h2> 38 + <p class="profile-whats-new-empty">{copy.empty}</p> 39 + </section> 40 + ); 41 + } 42 + 43 + return ( 44 + <section class="profile-whats-new glass"> 45 + <div class="profile-whats-new-main"> 46 + <div> 47 + <p class="text-eyebrow">{copy.heading}</p> 48 + <UpdateMeta update={latest} /> 49 + <h2>{latest.title}</h2> 50 + <p>{latest.body}</p> 51 + </div> 52 + {latest.tangledCommitUrl && ( 53 + <a 54 + href={latest.tangledCommitUrl} 55 + target="_blank" 56 + rel="noopener noreferrer" 57 + class="profile-form-button-secondary profile-whats-new-commit" 58 + > 59 + {copy.viewCommit} 60 + </a> 61 + )} 62 + </div> 63 + 64 + {history.length > 0 && ( 65 + <div class="profile-version-history"> 66 + <h3>{copy.versionHistory}</h3> 67 + {history.slice(0, 5).map((update) => ( 68 + <article class="profile-version-history-item" key={update.uri}> 69 + <UpdateMeta update={update} /> 70 + <h4>{update.title}</h4> 71 + <p>{update.body}</p> 72 + {update.tangledCommitUrl && ( 73 + <a 74 + href={update.tangledCommitUrl} 75 + target="_blank" 76 + rel="noopener noreferrer" 77 + class="text-link-button" 78 + > 79 + {copy.viewCommit} 80 + </a> 81 + )} 82 + </article> 83 + ))} 84 + </div> 85 + )} 86 + </section> 87 + ); 88 + }
+35 -3
i18n/messages/en.tsx
··· 499 499 missingProfile: "We couldn't find a profile for that handle.", 500 500 backToExplore: "Back to Explore", 501 501 categoryLabel: "Category", 502 + whatsNew: { 503 + heading: "What's New", 504 + empty: "No updates yet.", 505 + versionHistory: "Version History", 506 + viewCommit: "View commit", 507 + }, 502 508 notFoundTitle: "404", 503 509 notFoundBody: "We couldn't find a profile for that handle.", 504 510 }, 505 511 create: { 506 512 eyebrow: "Add to Explore", 507 - headline: "Sign in with your project's Atmosphere account", 513 + headline: "Sign in with your Atmosphere account", 508 514 body: 509 - "Anyone can list a project. Sign in with the account that controls the project — anyone with that account can publish or update the entry. Nothing else is written to your PDS.", 515 + "Use this page to sign in, write reviews, or submit a project. If you're submitting a project, sign in with that project's Atmosphere account so the right account can publish and update it.", 510 516 signInLabel: "Sign in with your Atmosphere handle", 511 - handlePlaceholder: "yourproject.com", 517 + handlePlaceholder: "yourhandle.com", 512 518 signIn: "Sign in", 513 519 configError: 514 520 "OAuth isn't configured on this deployment yet. Try again shortly.", ··· 684 690 /** Generic failure surface — server text appended after. */ 685 691 errorPrefix: "Couldn't submit request", 686 692 }, 693 + }, 694 + profileUpdates: { 695 + eyebrow: "What's New", 696 + title: "Project updates", 697 + body: 698 + "Post release notes for your public profile. Each update is saved as its own record on your project account.", 699 + titleLabel: "Update title", 700 + titlePlaceholder: "e.g. New beta release", 701 + versionLabel: "Version (optional)", 702 + versionPlaceholder: "e.g. 1.2.0", 703 + notesLabel: "Update notes", 704 + notesPlaceholder: "What's changed?", 705 + commitLabel: "Tangled commit link (optional)", 706 + commitPlaceholder: "https://tangled.org/…", 707 + publishButton: "Publish update", 708 + updateButton: "Update note", 709 + saving: "Saving…", 710 + saved: "Update saved.", 711 + saveError: "Could not save update.", 712 + delete: "Delete", 713 + deleting: "Deleting…", 714 + deleted: "Update deleted.", 715 + deleteError: "Could not delete update.", 716 + edit: "Edit", 717 + cancelEdit: "Cancel edit", 718 + confirmDelete: "Delete this update?", 687 719 }, 688 720 }, 689 721 },
+285
islands/ProfileUpdateEditor.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import { useT } from "../i18n/mod.ts"; 3 + import type { ProfileUpdateRow } from "../lib/profile-updates.ts"; 4 + 5 + export type EditableProfileUpdate = Pick< 6 + ProfileUpdateRow, 7 + | "rkey" 8 + | "title" 9 + | "body" 10 + | "version" 11 + | "tangledCommitUrl" 12 + | "createdAt" 13 + >; 14 + 15 + interface Props { 16 + initialUpdates: EditableProfileUpdate[]; 17 + disabled?: boolean; 18 + } 19 + 20 + function dateLabel(ms: number): string { 21 + return new Intl.DateTimeFormat("en", { 22 + month: "short", 23 + day: "numeric", 24 + year: "numeric", 25 + }).format(new Date(ms)); 26 + } 27 + 28 + function updateFromResponse(update: unknown): EditableProfileUpdate | null { 29 + if (!update || typeof update !== "object") return null; 30 + const u = update as Record<string, unknown>; 31 + if (typeof u.rkey !== "string" || typeof u.title !== "string") return null; 32 + if (typeof u.body !== "string" || typeof u.createdAt !== "number") { 33 + return null; 34 + } 35 + return { 36 + rkey: u.rkey, 37 + title: u.title, 38 + body: u.body, 39 + version: typeof u.version === "string" ? u.version : null, 40 + tangledCommitUrl: typeof u.tangledCommitUrl === "string" 41 + ? u.tangledCommitUrl 42 + : null, 43 + createdAt: u.createdAt, 44 + }; 45 + } 46 + 47 + export default function ProfileUpdateEditor( 48 + { initialUpdates, disabled = false }: Props, 49 + ) { 50 + const t = useT().forms.profile.profileUpdates; 51 + const updates = useSignal<EditableProfileUpdate[]>(initialUpdates); 52 + const editingRkey = useSignal<string | null>(null); 53 + const title = useSignal(""); 54 + const version = useSignal(""); 55 + const body = useSignal(""); 56 + const tangledCommitUrl = useSignal(""); 57 + const submitting = useSignal(false); 58 + const deletingRkey = useSignal<string | null>(null); 59 + const message = useSignal<{ kind: "ok" | "error"; text: string } | null>( 60 + null, 61 + ); 62 + 63 + const resetForm = () => { 64 + editingRkey.value = null; 65 + title.value = ""; 66 + version.value = ""; 67 + body.value = ""; 68 + tangledCommitUrl.value = ""; 69 + }; 70 + 71 + const editUpdate = (update: EditableProfileUpdate) => { 72 + editingRkey.value = update.rkey; 73 + title.value = update.title; 74 + version.value = update.version ?? ""; 75 + body.value = update.body; 76 + tangledCommitUrl.value = update.tangledCommitUrl ?? ""; 77 + message.value = null; 78 + }; 79 + 80 + const saveUpdate = async (event: Event) => { 81 + event.preventDefault(); 82 + if (disabled || submitting.value) return; 83 + submitting.value = true; 84 + message.value = null; 85 + try { 86 + const res = await fetch("/api/registry/profile/updates", { 87 + method: "POST", 88 + headers: { "content-type": "application/json" }, 89 + body: JSON.stringify({ 90 + rkey: editingRkey.value ?? undefined, 91 + title: title.value, 92 + version: version.value, 93 + body: body.value, 94 + tangledCommitUrl: tangledCommitUrl.value, 95 + }), 96 + }); 97 + const data = await res.json().catch(() => ({})); 98 + if (!res.ok) { 99 + throw new Error(data.detail || data.error || t.saveError); 100 + } 101 + const update = updateFromResponse(data.update); 102 + if (update) { 103 + updates.value = [ 104 + update, 105 + ...updates.value.filter((row) => row.rkey !== update.rkey), 106 + ].sort((a, b) => b.createdAt - a.createdAt); 107 + } 108 + resetForm(); 109 + message.value = { kind: "ok", text: t.saved }; 110 + } catch (err) { 111 + message.value = { 112 + kind: "error", 113 + text: err instanceof Error ? err.message : t.saveError, 114 + }; 115 + } finally { 116 + submitting.value = false; 117 + } 118 + }; 119 + 120 + const deleteUpdate = async (rkey: string) => { 121 + if (disabled || deletingRkey.value) return; 122 + if (!confirm(t.confirmDelete)) return; 123 + deletingRkey.value = rkey; 124 + message.value = null; 125 + try { 126 + const res = await fetch( 127 + `/api/registry/profile/updates?rkey=${encodeURIComponent(rkey)}`, 128 + { method: "DELETE" }, 129 + ); 130 + const data = await res.json().catch(() => ({})); 131 + if (!res.ok) throw new Error(data.detail || data.error || t.deleteError); 132 + updates.value = updates.value.filter((update) => update.rkey !== rkey); 133 + if (editingRkey.value === rkey) resetForm(); 134 + message.value = { kind: "ok", text: t.deleted }; 135 + } catch (err) { 136 + message.value = { 137 + kind: "error", 138 + text: err instanceof Error ? err.message : t.deleteError, 139 + }; 140 + } finally { 141 + deletingRkey.value = null; 142 + } 143 + }; 144 + 145 + return ( 146 + <section class="profile-update-editor glass"> 147 + <div class="profile-update-editor-header"> 148 + <div> 149 + <p class="text-eyebrow">{t.eyebrow}</p> 150 + <h2>{t.title}</h2> 151 + <p>{t.body}</p> 152 + </div> 153 + </div> 154 + 155 + <form class="profile-update-form" onSubmit={saveUpdate}> 156 + <div class="profile-update-form-grid"> 157 + <label class="profile-form-field"> 158 + <span class="profile-form-label">{t.titleLabel}</span> 159 + <input 160 + type="text" 161 + required 162 + maxLength={80} 163 + value={title.value} 164 + placeholder={t.titlePlaceholder} 165 + onInput={(e) => 166 + title.value = (e.currentTarget as HTMLInputElement).value} 167 + class="profile-form-input" 168 + disabled={disabled} 169 + /> 170 + </label> 171 + <label class="profile-form-field"> 172 + <span class="profile-form-label">{t.versionLabel}</span> 173 + <input 174 + type="text" 175 + maxLength={32} 176 + value={version.value} 177 + placeholder={t.versionPlaceholder} 178 + onInput={(e) => 179 + version.value = (e.currentTarget as HTMLInputElement).value} 180 + class="profile-form-input" 181 + disabled={disabled} 182 + /> 183 + </label> 184 + </div> 185 + <label class="profile-form-field"> 186 + <span class="profile-form-label">{t.notesLabel}</span> 187 + <textarea 188 + required 189 + maxLength={1000} 190 + rows={5} 191 + value={body.value} 192 + placeholder={t.notesPlaceholder} 193 + onInput={(e) => 194 + body.value = (e.currentTarget as HTMLTextAreaElement).value} 195 + class="profile-form-input" 196 + disabled={disabled} 197 + /> 198 + </label> 199 + <label class="profile-form-field"> 200 + <span class="profile-form-label">{t.commitLabel}</span> 201 + <input 202 + type="url" 203 + maxLength={512} 204 + value={tangledCommitUrl.value} 205 + placeholder={t.commitPlaceholder} 206 + onInput={(e) => 207 + tangledCommitUrl.value = 208 + (e.currentTarget as HTMLInputElement).value} 209 + class="profile-form-input" 210 + disabled={disabled} 211 + /> 212 + </label> 213 + <div class="profile-update-actions"> 214 + <button 215 + type="submit" 216 + class="profile-form-button-primary" 217 + disabled={disabled || submitting.value} 218 + > 219 + {submitting.value 220 + ? t.saving 221 + : editingRkey.value 222 + ? t.updateButton 223 + : t.publishButton} 224 + </button> 225 + {editingRkey.value && ( 226 + <button 227 + type="button" 228 + class="profile-form-button-secondary" 229 + onClick={resetForm} 230 + disabled={submitting.value} 231 + > 232 + {t.cancelEdit} 233 + </button> 234 + )} 235 + {message.value && ( 236 + <span 237 + class={`profile-form-status profile-form-status--${message.value.kind}`} 238 + role="status" 239 + > 240 + {message.value.text} 241 + </span> 242 + )} 243 + </div> 244 + </form> 245 + 246 + {updates.value.length > 0 && ( 247 + <div class="profile-update-list"> 248 + {updates.value.map((update) => ( 249 + <article class="profile-update-list-item" key={update.rkey}> 250 + <div> 251 + <div class="profile-update-list-meta"> 252 + {update.version && <span>{update.version}</span>} 253 + <time dateTime={new Date(update.createdAt).toISOString()}> 254 + {dateLabel(update.createdAt)} 255 + </time> 256 + </div> 257 + <h3>{update.title}</h3> 258 + <p>{update.body}</p> 259 + </div> 260 + <div class="profile-update-list-actions"> 261 + <button 262 + type="button" 263 + class="profile-form-button-secondary" 264 + onClick={() => 265 + editUpdate(update)} 266 + > 267 + {t.edit} 268 + </button> 269 + <button 270 + type="button" 271 + class="profile-form-button-link" 272 + onClick={() => 273 + deleteUpdate(update.rkey)} 274 + disabled={deletingRkey.value === update.rkey} 275 + > 276 + {deletingRkey.value === update.rkey ? t.deleting : t.delete} 277 + </button> 278 + </div> 279 + </article> 280 + ))} 281 + </div> 282 + )} 283 + </section> 284 + ); 285 + }
+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 profile and reviews.", 8 + "detail": "Manage your Atmosphere profile, reviews, and updates.", 9 9 "permissions": [ 10 10 { 11 11 "type": "permission", 12 12 "resource": "repo", 13 13 "collection": [ 14 14 "com.atmosphereaccount.registry.profile", 15 - "com.atmosphereaccount.registry.review" 15 + "com.atmosphereaccount.registry.review", 16 + "com.atmosphereaccount.registry.update" 16 17 ] 17 18 } 18 19 ]
+65
lexicons/com/atmosphereaccount/registry/update.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atmosphereaccount.registry.update", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A project-owned What's New update for an Atmosphere registry profile. Each record represents one release/update note and may optionally link to Tangled commit metadata.", 8 + "key": "any", 9 + "record": { 10 + "type": "object", 11 + "required": ["title", "body", "createdAt"], 12 + "properties": { 13 + "title": { 14 + "type": "string", 15 + "minLength": 1, 16 + "maxLength": 80, 17 + "maxGraphemes": 80, 18 + "description": "Short update title." 19 + }, 20 + "body": { 21 + "type": "string", 22 + "minLength": 1, 23 + "maxLength": 1000, 24 + "maxGraphemes": 1000, 25 + "description": "Update notes shown in What's New and version history." 26 + }, 27 + "version": { 28 + "type": "string", 29 + "maxLength": 32, 30 + "maxGraphemes": 32, 31 + "description": "Optional version or release label." 32 + }, 33 + "tangledCommitUrl": { 34 + "type": "string", 35 + "format": "uri", 36 + "maxLength": 512, 37 + "description": "Optional Tangled commit URL related to this update." 38 + }, 39 + "tangledRepoUrl": { 40 + "type": "string", 41 + "format": "uri", 42 + "maxLength": 512, 43 + "description": "Optional Tangled repository URL used for future sync provenance." 44 + }, 45 + "source": { 46 + "type": "string", 47 + "maxLength": 32, 48 + "knownValues": ["manual", "tangled"], 49 + "description": "Where the update came from. Omitted means manual." 50 + }, 51 + "createdAt": { 52 + "type": "string", 53 + "format": "datetime", 54 + "maxLength": 64 55 + }, 56 + "updatedAt": { 57 + "type": "string", 58 + "format": "datetime", 59 + "maxLength": 64 60 + } 61 + } 62 + } 63 + } 64 + } 65 + }
+22
lib/db.ts
··· 272 272 created_at INTEGER NOT NULL, 273 273 updated_at INTEGER NOT NULL 274 274 )`, 275 + /** 276 + * Project-owned update history ("What's New"). Records live on the 277 + * project account's PDS and this table is the local AppView projection. 278 + */ 279 + `CREATE TABLE IF NOT EXISTS profile_update ( 280 + uri TEXT PRIMARY KEY, 281 + cid TEXT NOT NULL, 282 + rkey TEXT NOT NULL, 283 + project_did TEXT NOT NULL, 284 + title TEXT NOT NULL, 285 + body TEXT NOT NULL, 286 + version TEXT, 287 + tangled_commit_url TEXT, 288 + tangled_repo_url TEXT, 289 + source TEXT NOT NULL DEFAULT 'manual', 290 + status TEXT NOT NULL DEFAULT 'visible', 291 + created_at INTEGER NOT NULL, 292 + updated_at INTEGER NOT NULL, 293 + indexed_at INTEGER NOT NULL 294 + )`, 275 295 ]; 276 296 277 297 /** ··· 295 315 `CREATE INDEX IF NOT EXISTS profile_icon_access ON profile(icon_access_status)`, 296 316 `CREATE INDEX IF NOT EXISTS profile_type_takedown ON profile(profile_type, takedown_status)`, 297 317 `CREATE UNIQUE INDEX IF NOT EXISTS review_uri_unique ON review(review_uri) WHERE review_uri IS NOT NULL`, 318 + `CREATE INDEX IF NOT EXISTS profile_update_project_status_created ON profile_update(project_did, status, created_at)`, 319 + `CREATE UNIQUE INDEX IF NOT EXISTS profile_update_project_rkey ON profile_update(project_did, rkey)`, 298 320 ]; 299 321 300 322 /**
+85
lib/lexicons.ts
··· 9 9 10 10 export const PROFILE_NSID = "com.atmosphereaccount.registry.profile"; 11 11 export const REVIEW_NSID = "com.atmosphereaccount.registry.review"; 12 + export const UPDATE_NSID = "com.atmosphereaccount.registry.update"; 12 13 export const FEATURED_NSID = "com.atmosphereaccount.registry.featured"; 13 14 /** 14 15 * Permission-set lexicon NSID requested via the OAuth `include:` scope. ··· 21 22 export const REGISTRY_NSIDS = [ 22 23 PROFILE_NSID, 23 24 REVIEW_NSID, 25 + UPDATE_NSID, 24 26 FEATURED_NSID, 25 27 PERMISSION_SET_NSID, 26 28 ] as const; ··· 167 169 subjectUri?: string; 168 170 rating: 1 | 2 | 3 | 4 | 5; 169 171 body?: string; 172 + createdAt: string; 173 + updatedAt?: string; 174 + } 175 + 176 + export interface UpdateRecord { 177 + $type?: typeof UPDATE_NSID; 178 + title: string; 179 + body: string; 180 + version?: string; 181 + tangledCommitUrl?: string; 182 + tangledRepoUrl?: string; 183 + source?: "manual" | "tangled" | string; 170 184 createdAt: string; 171 185 updatedAt?: string; 172 186 } ··· 597 611 }; 598 612 } 599 613 614 + export function validateUpdate( 615 + input: unknown, 616 + ): ValidationResult<UpdateRecord> { 617 + if (!input || typeof input !== "object") { 618 + return { ok: false, error: "record must be an object" }; 619 + } 620 + const v = input as Record<string, unknown>; 621 + const title = typeof v.title === "string" ? v.title.trim() : ""; 622 + if (!title || title.length > 80) { 623 + return { ok: false, error: "title: 1..80 chars required" }; 624 + } 625 + const body = typeof v.body === "string" ? v.body.trim() : ""; 626 + if (!body || body.length > 1000) { 627 + return { ok: false, error: "body: 1..1000 chars required" }; 628 + } 629 + const version = typeof v.version === "string" ? v.version.trim() : ""; 630 + if (version.length > 32) { 631 + return { ok: false, error: "version: must be <=32 chars" }; 632 + } 633 + let tangledCommitUrl: string | undefined; 634 + if ( 635 + v.tangledCommitUrl !== undefined && v.tangledCommitUrl !== null && 636 + v.tangledCommitUrl !== "" 637 + ) { 638 + if (!isStr(v.tangledCommitUrl, 512) || !isUrl(v.tangledCommitUrl)) { 639 + return { 640 + ok: false, 641 + error: "tangledCommitUrl: must be an http(s) URL <=512", 642 + }; 643 + } 644 + tangledCommitUrl = (v.tangledCommitUrl as string).trim(); 645 + } 646 + let tangledRepoUrl: string | undefined; 647 + if ( 648 + v.tangledRepoUrl !== undefined && v.tangledRepoUrl !== null && 649 + v.tangledRepoUrl !== "" 650 + ) { 651 + if (!isStr(v.tangledRepoUrl, 512) || !isUrl(v.tangledRepoUrl)) { 652 + return { 653 + ok: false, 654 + error: "tangledRepoUrl: must be an http(s) URL <=512", 655 + }; 656 + } 657 + tangledRepoUrl = (v.tangledRepoUrl as string).trim(); 658 + } 659 + const source = typeof v.source === "string" && v.source.trim() 660 + ? v.source.trim().slice(0, 32) 661 + : "manual"; 662 + if (!isStr(v.createdAt, 64)) { 663 + return { ok: false, error: "createdAt required (ISO 8601)" }; 664 + } 665 + if (v.updatedAt !== undefined && !isStr(v.updatedAt, 64)) { 666 + return { ok: false, error: "updatedAt: string <=64" }; 667 + } 668 + return { 669 + ok: true, 670 + value: { 671 + $type: UPDATE_NSID, 672 + title, 673 + body, 674 + version: version || undefined, 675 + tangledCommitUrl, 676 + tangledRepoUrl, 677 + source, 678 + createdAt: v.createdAt as string, 679 + updatedAt: typeof v.updatedAt === "string" ? v.updatedAt : undefined, 680 + }, 681 + }; 682 + } 683 + 600 684 /** 601 685 * The literal JSON for each lexicon (loaded at module init). Used by the 602 686 * `/.well-known/atproto-lexicon/<NSID>` route to publish the schemas, and ··· 609 693 const fileMap: Record<string, string> = { 610 694 [PROFILE_NSID]: "profile.json", 611 695 [REVIEW_NSID]: "review.json", 696 + [UPDATE_NSID]: "update.json", 612 697 [FEATURED_NSID]: "featured.json", 613 698 [PERMISSION_SET_NSID]: "fullPermissions.json", 614 699 };
+25
lib/pds.ts
··· 9 9 type ProfileRecord, 10 10 REVIEW_NSID, 11 11 type ReviewRecord, 12 + UPDATE_NSID, 13 + type UpdateRecord, 12 14 } from "./lexicons.ts"; 13 15 14 16 export interface PutRecordResult { ··· 56 58 ); 57 59 } 58 60 61 + export async function putUpdateRecord( 62 + did: string, 63 + pdsUrl: string, 64 + rkey: string, 65 + record: UpdateRecord, 66 + ): Promise<PutRecordResult> { 67 + return await putRecord( 68 + did, 69 + pdsUrl, 70 + UPDATE_NSID, 71 + rkey, 72 + record as unknown as Record<string, unknown>, 73 + ); 74 + } 75 + 59 76 /** 60 77 * Generic putRecord helper for arbitrary collections (e.g. our curated 61 78 * featured directory). Always uses the authed user's own repo. ··· 98 115 rkey: string, 99 116 ): Promise<void> { 100 117 await deleteRecord(did, pdsUrl, REVIEW_NSID, rkey); 118 + } 119 + 120 + export async function deleteUpdateRecord( 121 + did: string, 122 + pdsUrl: string, 123 + rkey: string, 124 + ): Promise<void> { 125 + await deleteRecord(did, pdsUrl, UPDATE_NSID, rkey); 101 126 } 102 127 103 128 /**
+213
lib/profile-updates.ts
··· 1 + import { withDb } from "./db.ts"; 2 + import { UPDATE_NSID, type UpdateRecord } from "./lexicons.ts"; 3 + 4 + export const MAX_UPDATE_TITLE_LENGTH = 80; 5 + export const MAX_UPDATE_BODY_LENGTH = 1000; 6 + export const MAX_UPDATE_VERSION_LENGTH = 32; 7 + 8 + export interface ProfileUpdateRow { 9 + uri: string; 10 + cid: string; 11 + rkey: string; 12 + projectDid: string; 13 + title: string; 14 + body: string; 15 + version: string | null; 16 + tangledCommitUrl: string | null; 17 + tangledRepoUrl: string | null; 18 + source: string; 19 + status: "visible" | "removed"; 20 + createdAt: number; 21 + updatedAt: number; 22 + indexedAt: number; 23 + } 24 + 25 + interface RawProfileUpdateRow { 26 + uri: string; 27 + cid: string; 28 + rkey: string; 29 + project_did: string; 30 + title: string; 31 + body: string; 32 + version: string | null; 33 + tangled_commit_url: string | null; 34 + tangled_repo_url: string | null; 35 + source: string; 36 + status: string; 37 + created_at: number; 38 + updated_at: number; 39 + indexed_at: number; 40 + } 41 + 42 + function rowToUpdate(row: RawProfileUpdateRow): ProfileUpdateRow { 43 + return { 44 + uri: row.uri, 45 + cid: row.cid, 46 + rkey: row.rkey, 47 + projectDid: row.project_did, 48 + title: row.title, 49 + body: row.body, 50 + version: row.version, 51 + tangledCommitUrl: row.tangled_commit_url, 52 + tangledRepoUrl: row.tangled_repo_url, 53 + source: row.source || "manual", 54 + status: row.status === "removed" ? "removed" : "visible", 55 + createdAt: Number(row.created_at), 56 + updatedAt: Number(row.updated_at), 57 + indexedAt: Number(row.indexed_at), 58 + }; 59 + } 60 + 61 + export function updateUriForRkey(projectDid: string, rkey: string): string { 62 + return `at://${projectDid}/${UPDATE_NSID}/${rkey}`; 63 + } 64 + 65 + export function createUpdateRkey(): string { 66 + const rand = crypto.randomUUID().replace(/-/g, "").slice(0, 12); 67 + return `update-${Date.now().toString(36)}-${rand}`; 68 + } 69 + 70 + export function updateRowToRecord(update: ProfileUpdateRow): UpdateRecord { 71 + return { 72 + title: update.title, 73 + body: update.body, 74 + version: update.version ?? undefined, 75 + tangledCommitUrl: update.tangledCommitUrl ?? undefined, 76 + tangledRepoUrl: update.tangledRepoUrl ?? undefined, 77 + source: update.source, 78 + createdAt: new Date(update.createdAt).toISOString(), 79 + updatedAt: new Date(update.updatedAt).toISOString(), 80 + }; 81 + } 82 + 83 + export async function upsertProfileUpdate(input: { 84 + uri: string; 85 + cid: string; 86 + rkey: string; 87 + projectDid: string; 88 + title: string; 89 + body: string; 90 + version?: string | null; 91 + tangledCommitUrl?: string | null; 92 + tangledRepoUrl?: string | null; 93 + source?: string | null; 94 + createdAt: number; 95 + updatedAt: number; 96 + }): Promise<ProfileUpdateRow> { 97 + return await withDb(async (c) => { 98 + const now = Date.now(); 99 + await c.execute({ 100 + sql: ` 101 + INSERT INTO profile_update ( 102 + uri, cid, rkey, project_did, title, body, version, 103 + tangled_commit_url, tangled_repo_url, source, status, 104 + created_at, updated_at, indexed_at 105 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'visible', ?, ?, ?) 106 + ON CONFLICT(uri) DO UPDATE SET 107 + cid = excluded.cid, 108 + rkey = excluded.rkey, 109 + project_did = excluded.project_did, 110 + title = excluded.title, 111 + body = excluded.body, 112 + version = excluded.version, 113 + tangled_commit_url = excluded.tangled_commit_url, 114 + tangled_repo_url = excluded.tangled_repo_url, 115 + source = excluded.source, 116 + status = 'visible', 117 + created_at = excluded.created_at, 118 + updated_at = excluded.updated_at, 119 + indexed_at = excluded.indexed_at 120 + `, 121 + args: [ 122 + input.uri, 123 + input.cid, 124 + input.rkey, 125 + input.projectDid, 126 + input.title, 127 + input.body, 128 + input.version ?? null, 129 + input.tangledCommitUrl ?? null, 130 + input.tangledRepoUrl ?? null, 131 + input.source ?? "manual", 132 + input.createdAt, 133 + input.updatedAt, 134 + now, 135 + ], 136 + }); 137 + const update = await getProfileUpdateByRkey(input.projectDid, input.rkey, { 138 + includeRemoved: true, 139 + }); 140 + if (!update) throw new Error("profile_update_write_failed"); 141 + return update; 142 + }); 143 + } 144 + 145 + export async function markProfileUpdateRemovedByRkey( 146 + projectDid: string, 147 + rkey: string, 148 + ): Promise<boolean> { 149 + return await withDb(async (c) => { 150 + const r = await c.execute({ 151 + sql: ` 152 + UPDATE profile_update SET 153 + status = 'removed', 154 + updated_at = ?, 155 + indexed_at = ? 156 + WHERE project_did = ? AND rkey = ? AND status != 'removed' 157 + `, 158 + args: [Date.now(), Date.now(), projectDid, rkey], 159 + }); 160 + return Number(r.rowsAffected ?? 0) > 0; 161 + }); 162 + } 163 + 164 + export async function getProfileUpdateByRkey( 165 + projectDid: string, 166 + rkey: string, 167 + opts: { includeRemoved?: boolean } = {}, 168 + ): Promise<ProfileUpdateRow | null> { 169 + return await withDb(async (c) => { 170 + const r = await c.execute({ 171 + sql: ` 172 + SELECT * 173 + FROM profile_update 174 + WHERE project_did = ? AND rkey = ? 175 + ${opts.includeRemoved ? "" : "AND status = 'visible'"} 176 + LIMIT 1 177 + `, 178 + args: [projectDid, rkey], 179 + }); 180 + const row = r.rows[0] as unknown as RawProfileUpdateRow | undefined; 181 + return row ? rowToUpdate(row) : null; 182 + }); 183 + } 184 + 185 + export async function listProfileUpdates( 186 + projectDid: string, 187 + opts: { limit?: number; includeRemoved?: boolean } = {}, 188 + ): Promise<ProfileUpdateRow[]> { 189 + const limit = Math.max(1, Math.min(opts.limit ?? 6, 25)); 190 + return await withDb(async (c) => { 191 + const r = await c.execute({ 192 + sql: ` 193 + SELECT * 194 + FROM profile_update 195 + WHERE project_did = ? 196 + ${opts.includeRemoved ? "" : "AND status = 'visible'"} 197 + ORDER BY created_at DESC, indexed_at DESC 198 + LIMIT ? 199 + `, 200 + args: [projectDid, limit], 201 + }); 202 + return r.rows.map((row) => 203 + rowToUpdate(row as unknown as RawProfileUpdateRow) 204 + ); 205 + }); 206 + } 207 + 208 + export async function getLatestProfileUpdate( 209 + projectDid: string, 210 + ): Promise<ProfileUpdateRow | null> { 211 + const rows = await listProfileUpdates(projectDid, { limit: 1 }); 212 + return rows[0] ?? null; 213 + }
+3 -8
routes/account/reviews.tsx
··· 12 12 import { getProfileByDid } from "../../lib/registry.ts"; 13 13 import { listReviewsByReviewer, type ReviewRow } from "../../lib/reviews.ts"; 14 14 15 - function bskyCdnAvatarUrl(did: string, cid: string, mime: string): string { 16 - const ext = mime === "image/png" 17 - ? "png" 18 - : mime === "image/webp" 19 - ? "webp" 20 - : "jpeg"; 21 - return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}@${ext}`; 15 + function bskyCdnAvatarUrl(did: string, cid: string): string { 16 + return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}`; 22 17 } 23 18 24 19 interface ReviewWithTarget extends ReviewRow { ··· 88 83 ) { 89 84 const copy = t.accountReviews; 90 85 const avatarUrl = profile?.avatarCid && profile.avatarMime 91 - ? bskyCdnAvatarUrl(profile.did, profile.avatarCid, profile.avatarMime) 86 + ? bskyCdnAvatarUrl(profile.did, profile.avatarCid) 92 87 : null; 93 88 const displayName = profile?.displayName || handle; 94 89 return (
+6 -2
routes/api/registry/avatar/[did].ts
··· 8 8 import { fetchBlobPublic } from "../../../../lib/pds.ts"; 9 9 import { withRateLimit } from "../../../../lib/rate-limit.ts"; 10 10 11 + function bskyCdnAvatarUrl(did: string, cid: string): string { 12 + return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}`; 13 + } 14 + 11 15 export const handler = define.handlers({ 12 16 GET: withRateLimit(async (ctx) => { 13 17 const did = decodeURIComponent(ctx.params.did); ··· 22 26 profile.avatarCid, 23 27 ); 24 28 if (!upstream.ok) { 25 - return new Response("not found", { status: 404 }); 29 + return Response.redirect(bskyCdnAvatarUrl(did, profile.avatarCid), 302); 26 30 } 27 31 const headers = new Headers(); 28 32 const ct = upstream.headers.get("content-type") ?? profile.avatarMime ?? ··· 36 40 return new Response(upstream.body, { status: 200, headers }); 37 41 } catch (err) { 38 42 console.warn("avatar proxy error:", err); 39 - return new Response("upstream error", { status: 502 }); 43 + return Response.redirect(bskyCdnAvatarUrl(did, profile.avatarCid), 302); 40 44 } 41 45 }), 42 46 });
+159
routes/api/registry/profile/updates.ts
··· 1 + /** 2 + * Authenticated project-owner writes for What's New update records. 3 + * 4 + * POST /api/registry/profile/updates create/update 5 + * DELETE /api/registry/profile/updates?rkey=... delete 6 + */ 7 + import { define } from "../../../../utils.ts"; 8 + import { getEffectiveAccountType } from "../../../../lib/account-types.ts"; 9 + import { loadSession } from "../../../../lib/oauth.ts"; 10 + import { deleteUpdateRecord, putUpdateRecord } from "../../../../lib/pds.ts"; 11 + import { getProfileByDid } from "../../../../lib/registry.ts"; 12 + import { 13 + createUpdateRkey, 14 + getProfileUpdateByRkey, 15 + markProfileUpdateRemovedByRkey, 16 + updateUriForRkey, 17 + upsertProfileUpdate, 18 + } from "../../../../lib/profile-updates.ts"; 19 + import { type UpdateRecord, validateUpdate } from "../../../../lib/lexicons.ts"; 20 + 21 + interface UpdatePayload { 22 + rkey?: unknown; 23 + title?: unknown; 24 + body?: unknown; 25 + version?: unknown; 26 + tangledCommitUrl?: unknown; 27 + } 28 + 29 + export const handler = define.handlers({ 30 + async POST(ctx) { 31 + const user = ctx.state.user; 32 + if (!user) return jsonError(401, "not_authenticated"); 33 + 34 + const accountType = await getEffectiveAccountType(user.did).catch(() => 35 + null 36 + ); 37 + if (accountType !== "project") return jsonError(403, "project_required"); 38 + 39 + const [session, profile] = await Promise.all([ 40 + loadSession(user.did), 41 + getProfileByDid(user.did, { includeTakenDown: true }).catch(() => null), 42 + ]); 43 + if (!session) return jsonError(401, "oauth_session_expired"); 44 + if (!profile || profile.profileType !== "project") { 45 + return jsonError(403, "project_profile_required"); 46 + } 47 + if (profile.takedownStatus === "taken_down") { 48 + return jsonError(403, "profile_taken_down"); 49 + } 50 + 51 + const payload = await ctx.req.json().catch(() => null) as 52 + | UpdatePayload 53 + | null; 54 + if (!payload) return jsonError(400, "invalid_body"); 55 + 56 + const rkey = typeof payload.rkey === "string" && payload.rkey.trim() 57 + ? payload.rkey.trim() 58 + : createUpdateRkey(); 59 + const existing = await getProfileUpdateByRkey(user.did, rkey, { 60 + includeRemoved: true, 61 + }).catch(() => null); 62 + const now = new Date(); 63 + const record: UpdateRecord = { 64 + title: typeof payload.title === "string" ? payload.title : "", 65 + body: typeof payload.body === "string" ? payload.body : "", 66 + version: typeof payload.version === "string" 67 + ? payload.version 68 + : undefined, 69 + tangledCommitUrl: typeof payload.tangledCommitUrl === "string" 70 + ? payload.tangledCommitUrl 71 + : undefined, 72 + tangledRepoUrl: profile.links.find((link) => link.kind === "tangled") 73 + ?.url ?? undefined, 74 + source: "manual", 75 + createdAt: existing 76 + ? new Date(existing.createdAt).toISOString() 77 + : now.toISOString(), 78 + updatedAt: now.toISOString(), 79 + }; 80 + const validation = validateUpdate(record); 81 + if (!validation.ok || !validation.value) { 82 + return jsonResponse(400, { 83 + error: "invalid_update_record", 84 + detail: validation.error, 85 + }); 86 + } 87 + 88 + const result = await putUpdateRecord( 89 + user.did, 90 + session.pdsUrl, 91 + rkey, 92 + validation.value, 93 + ).catch((err) => err instanceof Error ? err : new Error(String(err))); 94 + if (result instanceof Error) { 95 + return jsonResponse(502, { 96 + error: "put_record_failed", 97 + detail: result.message, 98 + }); 99 + } 100 + 101 + const update = await upsertProfileUpdate({ 102 + uri: updateUriForRkey(user.did, rkey), 103 + cid: result.cid, 104 + rkey, 105 + projectDid: user.did, 106 + title: validation.value.title, 107 + body: validation.value.body, 108 + version: validation.value.version ?? null, 109 + tangledCommitUrl: validation.value.tangledCommitUrl ?? null, 110 + tangledRepoUrl: validation.value.tangledRepoUrl ?? null, 111 + source: validation.value.source ?? "manual", 112 + createdAt: Date.parse(validation.value.createdAt) || Date.now(), 113 + updatedAt: 114 + Date.parse(validation.value.updatedAt ?? validation.value.createdAt) || 115 + Date.now(), 116 + }); 117 + return jsonResponse(200, { ok: true, update }); 118 + }, 119 + 120 + async DELETE(ctx) { 121 + const user = ctx.state.user; 122 + if (!user) return jsonError(401, "not_authenticated"); 123 + 124 + const accountType = await getEffectiveAccountType(user.did).catch(() => 125 + null 126 + ); 127 + if (accountType !== "project") return jsonError(403, "project_required"); 128 + 129 + const session = await loadSession(user.did); 130 + if (!session) return jsonError(401, "oauth_session_expired"); 131 + 132 + const url = new URL(ctx.req.url); 133 + const rkey = url.searchParams.get("rkey")?.trim(); 134 + if (!rkey) return jsonError(400, "missing_rkey"); 135 + 136 + const deleted = await deleteUpdateRecord(user.did, session.pdsUrl, rkey) 137 + .then(() => null) 138 + .catch((err) => err instanceof Error ? err : new Error(String(err))); 139 + if (deleted) { 140 + return jsonResponse(502, { 141 + error: "delete_record_failed", 142 + detail: deleted.message, 143 + }); 144 + } 145 + const removed = await markProfileUpdateRemovedByRkey(user.did, rkey); 146 + return jsonResponse(200, { ok: true, removed }); 147 + }, 148 + }); 149 + 150 + function jsonResponse(status: number, body: unknown): Response { 151 + return new Response(JSON.stringify(body), { 152 + status, 153 + headers: { "content-type": "application/json; charset=utf-8" }, 154 + }); 155 + } 156 + 157 + function jsonError(status: number, code: string): Response { 158 + return jsonResponse(status, { error: code }); 159 + }
+28 -16
routes/explore/[handle].tsx
··· 2 2 import Nav from "../../components/Nav.tsx"; 3 3 import Footer from "../../components/Footer.tsx"; 4 4 import ProfileHero from "../../components/explore/ProfileHero.tsx"; 5 - import ProfileLinks from "../../components/explore/ProfileLinks.tsx"; 6 5 import ProfileScreenshots from "../../components/explore/ProfileScreenshots.tsx"; 6 + import ProfileWhatsNew from "../../components/explore/ProfileWhatsNew.tsx"; 7 7 import ProfileRatingSummary from "../../components/explore/ProfileRatingSummary.tsx"; 8 8 import ProfileReviewList, { 9 9 type DisplayReview, ··· 27 27 import { accountProviderName } from "../../lib/account-providers.ts"; 28 28 import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 29 29 import { getAppUser } from "../../lib/account-types.ts"; 30 + import { 31 + listProfileUpdates, 32 + type ProfileUpdateRow, 33 + } from "../../lib/profile-updates.ts"; 30 34 31 - function bskyCdnAvatarUrl(did: string, cid: string, mime: string): string { 32 - const ext = mime === "image/png" 33 - ? "png" 34 - : mime === "image/webp" 35 - ? "webp" 36 - : "jpeg"; 37 - return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}@${ext}`; 35 + function bskyCdnAvatarUrl(did: string, cid: string): string { 36 + return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}`; 38 37 } 39 38 40 39 export const handler = define.handlers({ ··· 51 50 null, 52 51 ), 53 52 ]); 54 - const [reviewSummary, reviews, ownReview] = profile 53 + const [reviewSummary, reviews, ownReview, updates] = profile 55 54 ? await Promise.all([ 56 55 getReviewSummary(profile.did).catch(() => emptyReviewSummary()), 57 56 listVisibleReviews(profile.did, { limit: 20 }).catch(() => []), 58 57 user ? getOwnReview(profile.did, user.did).catch(() => null) : null, 58 + listProfileUpdates(profile.did, { limit: 6 }).catch(() => []), 59 59 ]) 60 - : [emptyReviewSummary(), [] as ReviewRow[], null]; 60 + : [ 61 + emptyReviewSummary(), 62 + [] as ReviewRow[], 63 + null, 64 + [] as ProfileUpdateRow[], 65 + ]; 61 66 const displayReviews = profile ? await enrichReviews(reviews) : []; 62 67 return ctx.render( 63 68 <ProfileDetailPage 64 69 profile={profile} 65 70 reviewSummary={reviewSummary} 66 71 reviews={displayReviews} 72 + updates={updates} 67 73 ownReview={ownReview?.status === "visible" ? ownReview : null} 68 74 signedInUser={user ? { did: user.did, handle: user.handle } : null} 69 75 account={buildAccountMenuProps(ctx.state, ownerProfile?.handle ?? null)} ··· 79 85 profile: ProfileRow | null; 80 86 reviewSummary: ReviewSummary; 81 87 reviews: DisplayReview[]; 88 + updates: ProfileUpdateRow[]; 82 89 ownReview: ReviewRow | null; 83 90 signedInUser: { did: string; handle: string } | null; 84 91 account: ReturnType<typeof buildAccountMenuProps>; ··· 91 98 profile, 92 99 reviewSummary, 93 100 reviews, 101 + updates, 94 102 ownReview, 95 103 signedInUser, 96 104 account, ··· 129 137 <div style={{ marginTop: "1rem" }}> 130 138 <ProfileHero profile={profile} /> 131 139 </div> 132 - <ProfileLinks profile={profile} /> 133 140 <ProfileScreenshots profile={profile} /> 141 + <ProfileWhatsNew 142 + updates={updates} 143 + copy={{ 144 + heading: t.detail.whatsNew.heading, 145 + empty: t.detail.whatsNew.empty, 146 + versionHistory: t.detail.whatsNew.versionHistory, 147 + viewCommit: t.detail.whatsNew.viewCommit, 148 + }} 149 + /> 134 150 135 151 <div class="profile-reviews-shell"> 136 152 <ProfileRatingSummary ··· 257 273 const reviewerName = appUser?.displayName ?? profile?.name ?? null; 258 274 const reviewerHandle = appUser?.handle ?? profile?.handle ?? null; 259 275 const reviewerAvatarUrl = appUser?.avatarCid && appUser.avatarMime 260 - ? bskyCdnAvatarUrl( 261 - review.reviewerDid, 262 - appUser.avatarCid, 263 - appUser.avatarMime, 264 - ) 276 + ? bskyCdnAvatarUrl(review.reviewerDid, appUser.avatarCid) 265 277 : profile?.avatarCid 266 278 ? `/api/registry/avatar/${encodeURIComponent(review.reviewerDid)}` 267 279 : null;
+24 -8
routes/explore/manage.tsx
··· 2 2 import Nav from "../../components/Nav.tsx"; 3 3 import Footer from "../../components/Footer.tsx"; 4 4 import CreateProfileForm from "../../islands/CreateProfileForm.tsx"; 5 + import ProfileUpdateEditor from "../../islands/ProfileUpdateEditor.tsx"; 5 6 import { getMessages } from "../../i18n/mod.ts"; 6 7 import { getProfileByDid } from "../../lib/registry.ts"; 7 8 import { loadSession } from "../../lib/oauth.ts"; 8 9 import { getBskyProfile } from "../../lib/pds.ts"; 9 10 import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 10 11 import { getEffectiveAccountType } from "../../lib/account-types.ts"; 12 + import { listProfileUpdates } from "../../lib/profile-updates.ts"; 11 13 12 14 /** 13 15 * Build the deterministic public Bluesky CDN URL for a user's avatar ··· 17 19 * routing the prefill avatar through our own server (which adds a hop 18 20 * and can fail in subtle ways on some PDS hosts). 19 21 */ 20 - function bskyCdnAvatarUrl(did: string, cid: string, mime: string): string { 21 - const ext = mime === "image/png" 22 - ? "png" 23 - : mime === "image/webp" 24 - ? "webp" 25 - : "jpeg"; 26 - return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}@${ext}`; 22 + function bskyCdnAvatarUrl(did: string, cid: string): string { 23 + return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}`; 27 24 } 28 25 29 26 export const handler = define.handlers({ ··· 122 119 initialAvatarUrl = bskyCdnAvatarUrl( 123 120 user.did, 124 121 bsky.avatar.ref.$link, 125 - bsky.avatar.mimeType, 126 122 ); 127 123 } 128 124 } ··· 141 137 : null; 142 138 143 139 const publicProfileHandle = takedown ? null : existing?.handle ?? null; 140 + const updates = existing 141 + ? await listProfileUpdates(user.did, { limit: 8 }).catch(() => []) 142 + : []; 144 143 return ctx.render( 145 144 <ManagePage 146 145 user={user} ··· 149 148 initialAvatarUrl={initialAvatarUrl} 150 149 initialPublished={!!existing && !takedown} 151 150 publicProfileHandle={publicProfileHandle} 151 + updates={updates.map((update) => ({ 152 + rkey: update.rkey, 153 + title: update.title, 154 + body: update.body, 155 + version: update.version, 156 + tangledCommitUrl: update.tangledCommitUrl, 157 + createdAt: update.createdAt, 158 + }))} 152 159 takedown={takedown} 153 160 t={t} 154 161 />, ··· 163 170 initialAvatarUrl: string | null; 164 171 initialPublished: boolean; 165 172 publicProfileHandle: string | null; 173 + updates: Parameters<typeof ProfileUpdateEditor>[0]["initialUpdates"]; 166 174 takedown: { reason: string; at: number | null } | null; 167 175 // deno-lint-ignore no-explicit-any 168 176 t: any; ··· 176 184 initialAvatarUrl, 177 185 initialPublished, 178 186 publicProfileHandle, 187 + updates, 179 188 takedown, 180 189 t, 181 190 }: ManagePageProps, ··· 217 226 initialAvatarUrl={initialAvatarUrl} 218 227 initialPublished={initialPublished} 219 228 publicProfileHandle={publicProfileHandle} 229 + /> 230 + </div> 231 + 232 + <div style={{ marginTop: "1.25rem" }}> 233 + <ProfileUpdateEditor 234 + initialUpdates={updates} 235 + disabled={!initialPublished || !!takedown} 220 236 /> 221 237 </div> 222 238 </div>
+3 -8
routes/users/[handle].tsx
··· 7 7 import { getBskyClient } from "../../lib/bsky-clients.ts"; 8 8 import { getProfileByHandle } from "../../lib/registry.ts"; 9 9 10 - function bskyCdnAvatarUrl(did: string, cid: string, mime: string): string { 11 - const ext = mime === "image/png" 12 - ? "png" 13 - : mime === "image/webp" 14 - ? "webp" 15 - : "jpeg"; 16 - return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}@${ext}`; 10 + function bskyCdnAvatarUrl(did: string, cid: string): string { 11 + return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}`; 17 12 } 18 13 19 14 export const handler = define.handlers({ ··· 71 66 72 67 const displayName = profile.name || profile.handle; 73 68 const avatarUrl = profile.avatarCid && profile.avatarMime 74 - ? bskyCdnAvatarUrl(profile.did, profile.avatarCid, profile.avatarMime) 69 + ? bskyCdnAvatarUrl(profile.did, profile.avatarCid) 75 70 : null; 76 71 const client = getBskyClient(bskyClientId); 77 72 return (
+59 -1
worker/indexer.ts
··· 16 16 FEATURED_NSID, 17 17 PROFILE_NSID, 18 18 REVIEW_NSID, 19 + UPDATE_NSID, 19 20 validateFeatured, 20 21 validateProfile, 21 22 validateReview, 23 + validateUpdate, 22 24 } from "../lib/lexicons.ts"; 23 25 import { 24 26 deleteProfile, ··· 33 35 markReviewRemovedByRkey, 34 36 reviewUriForRkey, 35 37 } from "../lib/reviews.ts"; 38 + import { 39 + markProfileUpdateRemovedByRkey, 40 + updateUriForRkey, 41 + upsertProfileUpdate, 42 + } from "../lib/profile-updates.ts"; 36 43 import { findPdsEndpoint, resolveDidDocument } from "../lib/identity.ts"; 37 44 import { getRecordPublic } from "../lib/pds.ts"; 38 45 import { JETSTREAM_URL } from "../lib/env.ts"; ··· 53 60 commit?: JetstreamCommit; 54 61 } 55 62 56 - const COLLECTIONS = [PROFILE_NSID, REVIEW_NSID, FEATURED_NSID]; 63 + const COLLECTIONS = [PROFILE_NSID, REVIEW_NSID, UPDATE_NSID, FEATURED_NSID]; 57 64 const RECONNECT_DELAY_MS = 5_000; 58 65 const CURSOR_PERSIST_INTERVAL_MS = 5_000; 59 66 ··· 228 235 ); 229 236 } 230 237 238 + async function handleUpdateEvent(event: JetstreamEvent): Promise<void> { 239 + const commit = event.commit; 240 + if (!commit) return; 241 + 242 + if (commit.operation === "delete") { 243 + await markProfileUpdateRemovedByRkey(event.did, commit.rkey); 244 + return; 245 + } 246 + 247 + const project = await getProfileByDid(event.did).catch(() => null); 248 + if (!project || project.profileType !== "project") { 249 + console.warn(`[indexer] ignoring update for non-project ${event.did}`); 250 + return; 251 + } 252 + 253 + const pdsUrl = await resolvePdsForDid(event.did); 254 + const fetched = await getRecordPublic( 255 + pdsUrl, 256 + event.did, 257 + UPDATE_NSID, 258 + commit.rkey, 259 + ); 260 + if (!fetched) return; 261 + 262 + const validation = validateUpdate(fetched.value); 263 + if (!validation.ok || !validation.value) { 264 + console.warn( 265 + `[indexer] invalid update from ${event.did}: ${validation.error}`, 266 + ); 267 + return; 268 + } 269 + const r = validation.value; 270 + await upsertProfileUpdate({ 271 + uri: updateUriForRkey(event.did, commit.rkey), 272 + cid: fetched.cid, 273 + rkey: commit.rkey, 274 + projectDid: event.did, 275 + title: r.title, 276 + body: r.body, 277 + version: r.version ?? null, 278 + tangledCommitUrl: r.tangledCommitUrl ?? null, 279 + tangledRepoUrl: r.tangledRepoUrl ?? null, 280 + source: r.source ?? "manual", 281 + createdAt: Date.parse(r.createdAt) || Date.now(), 282 + updatedAt: Date.parse(r.updatedAt ?? r.createdAt) || Date.now(), 283 + }); 284 + console.log(`[indexer] upsert update ${event.did}/${commit.rkey}`); 285 + } 286 + 231 287 async function processEvent(event: JetstreamEvent): Promise<void> { 232 288 if (event.kind !== "commit" || !event.commit) return; 233 289 const collection = event.commit.collection; ··· 236 292 await handleProfileEvent(event); 237 293 } else if (collection === REVIEW_NSID) { 238 294 await handleReviewEvent(event); 295 + } else if (collection === UPDATE_NSID) { 296 + await handleUpdateEvent(event); 239 297 } else if (collection === FEATURED_NSID) { 240 298 await handleFeaturedEvent(event); 241 299 }