a tool for shared writing and social publishing
at update/thread-viewer 332 lines 11 kB view raw
1"use server"; 2import { 3 AtpBaseClient, 4 PubLeafletPublication, 5 PubLeafletThemeColor, 6 SiteStandardPublication, 7} from "lexicons/api"; 8import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 9import { getIdentityData } from "actions/getIdentityData"; 10import { supabaseServerClient } from "supabase/serverClient"; 11import { Json } from "supabase/database.types"; 12import { AtUri } from "@atproto/syntax"; 13import { $Typed } from "@atproto/api"; 14import { 15 normalizePublicationRecord, 16 type NormalizedPublication, 17} from "src/utils/normalizeRecords"; 18import { getPublicationType } from "src/utils/collectionHelpers"; 19 20type UpdatePublicationResult = 21 | { success: true; publication: any } 22 | { success: false; error?: OAuthSessionError }; 23 24type PublicationType = "pub.leaflet.publication" | "site.standard.publication"; 25 26type RecordBuilder = (args: { 27 normalizedPub: NormalizedPublication | null; 28 existingBasePath: string | undefined; 29 publicationType: PublicationType; 30 agent: AtpBaseClient; 31}) => Promise<PubLeafletPublication.Record | SiteStandardPublication.Record>; 32 33/** 34 * Shared helper for publication updates. Handles: 35 * - Authentication and session restoration 36 * - Fetching existing publication from database 37 * - Normalizing the existing record 38 * - Calling the record builder to create the updated record 39 * - Writing to PDS via putRecord 40 * - Writing to database 41 */ 42async function withPublicationUpdate( 43 uri: string, 44 recordBuilder: RecordBuilder, 45): Promise<UpdatePublicationResult> { 46 // Get identity and validate authentication 47 const identity = await getIdentityData(); 48 if (!identity || !identity.atp_did) { 49 return { 50 success: false, 51 error: { 52 type: "oauth_session_expired", 53 message: "Not authenticated", 54 did: "", 55 }, 56 }; 57 } 58 59 // Restore OAuth session 60 const sessionResult = await restoreOAuthSession(identity.atp_did); 61 if (!sessionResult.ok) { 62 return { success: false, error: sessionResult.error }; 63 } 64 const credentialSession = sessionResult.value; 65 const agent = new AtpBaseClient( 66 credentialSession.fetchHandler.bind(credentialSession), 67 ); 68 69 // Fetch existing publication from database 70 const { data: existingPub } = await supabaseServerClient 71 .from("publications") 72 .select("*") 73 .eq("uri", uri) 74 .single(); 75 if (!existingPub || existingPub.identity_did !== identity.atp_did) { 76 return { success: false }; 77 } 78 79 const aturi = new AtUri(existingPub.uri); 80 const publicationType = getPublicationType(aturi.collection) as PublicationType; 81 82 // Normalize existing record 83 const normalizedPub = normalizePublicationRecord(existingPub.record); 84 const existingBasePath = normalizedPub?.url 85 ? normalizedPub.url.replace(/^https?:\/\//, "") 86 : undefined; 87 88 // Build the updated record 89 const record = await recordBuilder({ 90 normalizedPub, 91 existingBasePath, 92 publicationType, 93 agent, 94 }); 95 96 // Write to PDS 97 await agent.com.atproto.repo.putRecord({ 98 repo: credentialSession.did!, 99 rkey: aturi.rkey, 100 record, 101 collection: publicationType, 102 validate: false, 103 }); 104 105 // Optimistically write to database 106 const { data: publication } = await supabaseServerClient 107 .from("publications") 108 .update({ 109 name: record.name, 110 record: record as Json, 111 }) 112 .eq("uri", uri) 113 .select() 114 .single(); 115 116 return { success: true, publication }; 117} 118 119/** Fields that can be overridden when building a record */ 120interface RecordOverrides { 121 name?: string; 122 description?: string; 123 icon?: any; 124 theme?: any; 125 basicTheme?: NormalizedPublication["basicTheme"]; 126 preferences?: NormalizedPublication["preferences"]; 127 basePath?: string; 128} 129 130/** Merges override with existing value, respecting explicit undefined */ 131function resolveField<T>(override: T | undefined, existing: T | undefined, hasOverride: boolean): T | undefined { 132 return hasOverride ? override : existing; 133} 134 135/** 136 * Builds a pub.leaflet.publication record. 137 * Uses base_path for the URL path component. 138 */ 139function buildLeafletRecord( 140 normalizedPub: NormalizedPublication | null, 141 existingBasePath: string | undefined, 142 overrides: RecordOverrides, 143): PubLeafletPublication.Record { 144 const preferences = overrides.preferences ?? normalizedPub?.preferences; 145 146 return { 147 $type: "pub.leaflet.publication", 148 name: overrides.name ?? normalizedPub?.name ?? "", 149 description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), 150 icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), 151 theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), 152 base_path: overrides.basePath ?? existingBasePath, 153 preferences: preferences ? { 154 $type: "pub.leaflet.publication#preferences", 155 showInDiscover: preferences.showInDiscover, 156 showComments: preferences.showComments, 157 showMentions: preferences.showMentions, 158 showPrevNext: preferences.showPrevNext, 159 } : undefined, 160 }; 161} 162 163/** 164 * Builds a site.standard.publication record. 165 * Uses url for the full URL. Also supports basicTheme. 166 */ 167function buildStandardRecord( 168 normalizedPub: NormalizedPublication | null, 169 existingBasePath: string | undefined, 170 overrides: RecordOverrides, 171): SiteStandardPublication.Record { 172 const preferences = overrides.preferences ?? normalizedPub?.preferences; 173 const basePath = overrides.basePath ?? existingBasePath; 174 175 return { 176 $type: "site.standard.publication", 177 name: overrides.name ?? normalizedPub?.name ?? "", 178 description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides), 179 icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides), 180 theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides), 181 basicTheme: resolveField(overrides.basicTheme, normalizedPub?.basicTheme, "basicTheme" in overrides), 182 url: basePath ? `https://${basePath}` : normalizedPub?.url || "", 183 preferences: preferences ? { 184 showInDiscover: preferences.showInDiscover, 185 showComments: preferences.showComments, 186 showMentions: preferences.showMentions, 187 showPrevNext: preferences.showPrevNext, 188 } : undefined, 189 }; 190} 191 192/** 193 * Builds a record for the appropriate publication type. 194 */ 195function buildRecord( 196 normalizedPub: NormalizedPublication | null, 197 existingBasePath: string | undefined, 198 publicationType: PublicationType, 199 overrides: RecordOverrides, 200): PubLeafletPublication.Record | SiteStandardPublication.Record { 201 if (publicationType === "pub.leaflet.publication") { 202 return buildLeafletRecord(normalizedPub, existingBasePath, overrides); 203 } 204 return buildStandardRecord(normalizedPub, existingBasePath, overrides); 205} 206 207export async function updatePublication({ 208 uri, 209 name, 210 description, 211 iconFile, 212 preferences, 213}: { 214 uri: string; 215 name: string; 216 description?: string; 217 iconFile?: File | null; 218 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 219}): Promise<UpdatePublicationResult> { 220 return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 221 // Upload icon if provided 222 let iconBlob = normalizedPub?.icon; 223 if (iconFile && iconFile.size > 0) { 224 const buffer = await iconFile.arrayBuffer(); 225 const uploadResult = await agent.com.atproto.repo.uploadBlob( 226 new Uint8Array(buffer), 227 { encoding: iconFile.type }, 228 ); 229 if (uploadResult.data.blob) { 230 iconBlob = uploadResult.data.blob; 231 } 232 } 233 234 return buildRecord(normalizedPub, existingBasePath, publicationType, { 235 name, 236 description, 237 icon: iconBlob, 238 preferences, 239 }); 240 }); 241} 242 243export async function updatePublicationBasePath({ 244 uri, 245 base_path, 246}: { 247 uri: string; 248 base_path: string; 249}): Promise<UpdatePublicationResult> { 250 return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType }) => { 251 return buildRecord(normalizedPub, existingBasePath, publicationType, { 252 basePath: base_path, 253 }); 254 }); 255} 256 257type Color = 258 | $Typed<PubLeafletThemeColor.Rgb, "pub.leaflet.theme.color#rgb"> 259 | $Typed<PubLeafletThemeColor.Rgba, "pub.leaflet.theme.color#rgba">; 260 261export async function updatePublicationTheme({ 262 uri, 263 theme, 264}: { 265 uri: string; 266 theme: { 267 backgroundImage?: File | null; 268 backgroundRepeat?: number | null; 269 backgroundColor: Color; 270 pageWidth?: number; 271 primary: Color; 272 pageBackground: Color; 273 showPageBackground: boolean; 274 accentBackground: Color; 275 accentText: Color; 276 }; 277}): Promise<UpdatePublicationResult> { 278 return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 279 // Build theme object 280 const themeData = { 281 $type: "pub.leaflet.publication#theme" as const, 282 backgroundImage: theme.backgroundImage 283 ? { 284 $type: "pub.leaflet.theme.backgroundImage", 285 image: ( 286 await agent.com.atproto.repo.uploadBlob( 287 new Uint8Array(await theme.backgroundImage.arrayBuffer()), 288 { encoding: theme.backgroundImage.type }, 289 ) 290 )?.data.blob, 291 width: theme.backgroundRepeat || undefined, 292 repeat: !!theme.backgroundRepeat, 293 } 294 : theme.backgroundImage === null 295 ? undefined 296 : normalizedPub?.theme?.backgroundImage, 297 backgroundColor: theme.backgroundColor 298 ? { 299 ...theme.backgroundColor, 300 } 301 : undefined, 302 pageWidth: theme.pageWidth, 303 primary: { 304 ...theme.primary, 305 }, 306 pageBackground: { 307 ...theme.pageBackground, 308 }, 309 showPageBackground: theme.showPageBackground, 310 accentBackground: { 311 ...theme.accentBackground, 312 }, 313 accentText: { 314 ...theme.accentText, 315 }, 316 }; 317 318 // Derive basicTheme from the theme colors for site.standard.publication 319 const basicTheme: NormalizedPublication["basicTheme"] = { 320 $type: "site.standard.theme.basic", 321 background: { $type: "site.standard.theme.color#rgb", r: theme.backgroundColor.r, g: theme.backgroundColor.g, b: theme.backgroundColor.b }, 322 foreground: { $type: "site.standard.theme.color#rgb", r: theme.primary.r, g: theme.primary.g, b: theme.primary.b }, 323 accent: { $type: "site.standard.theme.color#rgb", r: theme.accentBackground.r, g: theme.accentBackground.g, b: theme.accentBackground.b }, 324 accentForeground: { $type: "site.standard.theme.color#rgb", r: theme.accentText.r, g: theme.accentText.g, b: theme.accentText.b }, 325 }; 326 327 return buildRecord(normalizedPub, existingBasePath, publicationType, { 328 theme: themeData, 329 basicTheme, 330 }); 331 }); 332}