a tool for shared writing and social publishing
at update/reader 401 lines 12 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( 81 aturi.collection, 82 ) as PublicationType; 83 84 // Normalize existing record 85 const normalizedPub = normalizePublicationRecord(existingPub.record); 86 const existingBasePath = normalizedPub?.url 87 ? normalizedPub.url.replace(/^https?:\/\//, "") 88 : undefined; 89 90 // Build the updated record 91 const record = await recordBuilder({ 92 normalizedPub, 93 existingBasePath, 94 publicationType, 95 agent, 96 }); 97 98 // Write to PDS 99 await agent.com.atproto.repo.putRecord({ 100 repo: credentialSession.did!, 101 rkey: aturi.rkey, 102 record, 103 collection: publicationType, 104 validate: false, 105 }); 106 107 // Optimistically write to database 108 const { data: publication } = await supabaseServerClient 109 .from("publications") 110 .update({ 111 name: record.name, 112 record: record as Json, 113 }) 114 .eq("uri", uri) 115 .select() 116 .single(); 117 118 return { success: true, publication }; 119} 120 121/** Fields that can be overridden when building a record */ 122interface RecordOverrides { 123 name?: string; 124 description?: string; 125 icon?: any; 126 theme?: any; 127 basicTheme?: NormalizedPublication["basicTheme"]; 128 preferences?: NormalizedPublication["preferences"]; 129 basePath?: string; 130} 131 132/** Merges override with existing value, respecting explicit undefined */ 133function resolveField<T>( 134 override: T | undefined, 135 existing: T | undefined, 136 hasOverride: boolean, 137): T | undefined { 138 return hasOverride ? override : existing; 139} 140 141/** 142 * Builds a pub.leaflet.publication record. 143 * Uses base_path for the URL path component. 144 */ 145function buildLeafletRecord( 146 normalizedPub: NormalizedPublication | null, 147 existingBasePath: string | undefined, 148 overrides: RecordOverrides, 149): PubLeafletPublication.Record { 150 const preferences = overrides.preferences ?? normalizedPub?.preferences; 151 152 return { 153 $type: "pub.leaflet.publication", 154 name: overrides.name ?? normalizedPub?.name ?? "", 155 description: resolveField( 156 overrides.description, 157 normalizedPub?.description, 158 "description" in overrides, 159 ), 160 icon: resolveField( 161 overrides.icon, 162 normalizedPub?.icon, 163 "icon" in overrides, 164 ), 165 theme: resolveField( 166 overrides.theme, 167 normalizedPub?.theme, 168 "theme" in overrides, 169 ), 170 base_path: overrides.basePath ?? existingBasePath, 171 preferences: preferences 172 ? { 173 $type: "pub.leaflet.publication#preferences", 174 showInDiscover: preferences.showInDiscover, 175 showComments: preferences.showComments, 176 showMentions: preferences.showMentions, 177 showPrevNext: preferences.showPrevNext, 178 showRecommends: preferences.showRecommends, 179 } 180 : undefined, 181 }; 182} 183 184/** 185 * Builds a site.standard.publication record. 186 * Uses url for the full URL. Also supports basicTheme. 187 */ 188function buildStandardRecord( 189 normalizedPub: NormalizedPublication | null, 190 existingBasePath: string | undefined, 191 overrides: RecordOverrides, 192): SiteStandardPublication.Record { 193 const preferences = overrides.preferences ?? normalizedPub?.preferences; 194 const basePath = overrides.basePath ?? existingBasePath; 195 196 return { 197 $type: "site.standard.publication", 198 name: overrides.name ?? normalizedPub?.name ?? "", 199 description: resolveField( 200 overrides.description, 201 normalizedPub?.description, 202 "description" in overrides, 203 ), 204 icon: resolveField( 205 overrides.icon, 206 normalizedPub?.icon, 207 "icon" in overrides, 208 ), 209 theme: resolveField( 210 overrides.theme, 211 normalizedPub?.theme, 212 "theme" in overrides, 213 ), 214 basicTheme: resolveField( 215 overrides.basicTheme, 216 normalizedPub?.basicTheme, 217 "basicTheme" in overrides, 218 ), 219 url: basePath ? `https://${basePath}` : normalizedPub?.url || "", 220 preferences: preferences 221 ? { 222 showInDiscover: preferences.showInDiscover, 223 showComments: preferences.showComments, 224 showMentions: preferences.showMentions, 225 showPrevNext: preferences.showPrevNext, 226 showRecommends: preferences.showRecommends, 227 } 228 : undefined, 229 }; 230} 231 232/** 233 * Builds a record for the appropriate publication type. 234 */ 235function buildRecord( 236 normalizedPub: NormalizedPublication | null, 237 existingBasePath: string | undefined, 238 publicationType: PublicationType, 239 overrides: RecordOverrides, 240): PubLeafletPublication.Record | SiteStandardPublication.Record { 241 if (publicationType === "pub.leaflet.publication") { 242 return buildLeafletRecord(normalizedPub, existingBasePath, overrides); 243 } 244 return buildStandardRecord(normalizedPub, existingBasePath, overrides); 245} 246 247export async function updatePublication({ 248 uri, 249 name, 250 description, 251 iconFile, 252 preferences, 253}: { 254 uri: string; 255 name: string; 256 description?: string; 257 iconFile?: File | null; 258 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 259}): Promise<UpdatePublicationResult> { 260 return withPublicationUpdate( 261 uri, 262 async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 263 // Upload icon if provided 264 let iconBlob = normalizedPub?.icon; 265 if (iconFile && iconFile.size > 0) { 266 const buffer = await iconFile.arrayBuffer(); 267 const uploadResult = await agent.com.atproto.repo.uploadBlob( 268 new Uint8Array(buffer), 269 { encoding: iconFile.type }, 270 ); 271 if (uploadResult.data.blob) { 272 iconBlob = uploadResult.data.blob; 273 } 274 } 275 276 return buildRecord(normalizedPub, existingBasePath, publicationType, { 277 name, 278 ...(description !== undefined && { description }), 279 icon: iconBlob, 280 preferences, 281 }); 282 }, 283 ); 284} 285 286export async function updatePublicationBasePath({ 287 uri, 288 base_path, 289}: { 290 uri: string; 291 base_path: string; 292}): Promise<UpdatePublicationResult> { 293 return withPublicationUpdate( 294 uri, 295 async ({ normalizedPub, existingBasePath, publicationType }) => { 296 return buildRecord(normalizedPub, existingBasePath, publicationType, { 297 basePath: base_path, 298 }); 299 }, 300 ); 301} 302 303type Color = 304 | $Typed<PubLeafletThemeColor.Rgb, "pub.leaflet.theme.color#rgb"> 305 | $Typed<PubLeafletThemeColor.Rgba, "pub.leaflet.theme.color#rgba">; 306 307export async function updatePublicationTheme({ 308 uri, 309 theme, 310}: { 311 uri: string; 312 theme: { 313 backgroundImage?: File | null; 314 backgroundRepeat?: number | null; 315 backgroundColor: Color; 316 pageWidth?: number; 317 primary: Color; 318 pageBackground: Color; 319 showPageBackground: boolean; 320 accentBackground: Color; 321 accentText: Color; 322 }; 323}): Promise<UpdatePublicationResult> { 324 return withPublicationUpdate( 325 uri, 326 async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 327 // Build theme object 328 const themeData = { 329 $type: "pub.leaflet.publication#theme" as const, 330 backgroundImage: theme.backgroundImage 331 ? { 332 $type: "pub.leaflet.theme.backgroundImage", 333 image: ( 334 await agent.com.atproto.repo.uploadBlob( 335 new Uint8Array(await theme.backgroundImage.arrayBuffer()), 336 { encoding: theme.backgroundImage.type }, 337 ) 338 )?.data.blob, 339 width: theme.backgroundRepeat || undefined, 340 repeat: !!theme.backgroundRepeat, 341 } 342 : theme.backgroundImage === null 343 ? undefined 344 : normalizedPub?.theme?.backgroundImage, 345 backgroundColor: theme.backgroundColor 346 ? { 347 ...theme.backgroundColor, 348 } 349 : undefined, 350 pageWidth: theme.pageWidth, 351 primary: { 352 ...theme.primary, 353 }, 354 pageBackground: { 355 ...theme.pageBackground, 356 }, 357 showPageBackground: theme.showPageBackground, 358 accentBackground: { 359 ...theme.accentBackground, 360 }, 361 accentText: { 362 ...theme.accentText, 363 }, 364 }; 365 366 // Derive basicTheme from the theme colors for site.standard.publication 367 const basicTheme: NormalizedPublication["basicTheme"] = { 368 $type: "site.standard.theme.basic", 369 background: { 370 $type: "site.standard.theme.color#rgb", 371 r: theme.backgroundColor.r, 372 g: theme.backgroundColor.g, 373 b: theme.backgroundColor.b, 374 }, 375 foreground: { 376 $type: "site.standard.theme.color#rgb", 377 r: theme.primary.r, 378 g: theme.primary.g, 379 b: theme.primary.b, 380 }, 381 accent: { 382 $type: "site.standard.theme.color#rgb", 383 r: theme.accentBackground.r, 384 g: theme.accentBackground.g, 385 b: theme.accentBackground.b, 386 }, 387 accentForeground: { 388 $type: "site.standard.theme.color#rgb", 389 r: theme.accentText.r, 390 g: theme.accentText.g, 391 b: theme.accentText.b, 392 }, 393 }; 394 395 return buildRecord(normalizedPub, existingBasePath, publicationType, { 396 theme: themeData, 397 basicTheme, 398 }); 399 }, 400 ); 401}