a tool for shared writing and social publishing

fix feed construction and simplify updatePub functions

+198 -305
+197 -304
app/lish/createPub/updatePublication.ts
··· 22 22 | { success: true; publication: any } 23 23 | { success: false; error?: OAuthSessionError }; 24 24 25 - export async function updatePublication({ 26 - uri, 27 - name, 28 - description, 29 - iconFile, 30 - preferences, 31 - }: { 32 - uri: string; 33 - name: string; 34 - description?: string; 35 - iconFile?: File | null; 36 - preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 37 - }): Promise<UpdatePublicationResult> { 38 - let identity = await getIdentityData(); 25 + type PublicationType = "pub.leaflet.publication" | "site.standard.publication"; 26 + 27 + type RecordBuilder = (args: { 28 + normalizedPub: NormalizedPublication | null; 29 + existingBasePath: string | undefined; 30 + publicationType: PublicationType; 31 + agent: AtpBaseClient; 32 + }) => Promise<PubLeafletPublication.Record | SiteStandardPublication.Record>; 33 + 34 + /** 35 + * Shared helper for publication updates. Handles: 36 + * - Authentication and session restoration 37 + * - Fetching existing publication from database 38 + * - Normalizing the existing record 39 + * - Calling the record builder to create the updated record 40 + * - Writing to PDS via putRecord 41 + * - Writing to database 42 + */ 43 + async function withPublicationUpdate( 44 + uri: string, 45 + buildRecord: RecordBuilder, 46 + ): Promise<UpdatePublicationResult> { 47 + // Get identity and validate authentication 48 + const identity = await getIdentityData(); 39 49 if (!identity || !identity.atp_did) { 40 50 return { 41 51 success: false, ··· 47 57 }; 48 58 } 49 59 60 + // Restore OAuth session 50 61 const sessionResult = await restoreOAuthSession(identity.atp_did); 51 62 if (!sessionResult.ok) { 52 63 return { success: false, error: sessionResult.error }; 53 64 } 54 - let credentialSession = sessionResult.value; 55 - let agent = new AtpBaseClient( 65 + const credentialSession = sessionResult.value; 66 + const agent = new AtpBaseClient( 56 67 credentialSession.fetchHandler.bind(credentialSession), 57 68 ); 58 - let { data: existingPub } = await supabaseServerClient 69 + 70 + // Fetch existing publication from database 71 + const { data: existingPub } = await supabaseServerClient 59 72 .from("publications") 60 73 .select("*") 61 74 .eq("uri", uri) ··· 63 76 if (!existingPub || existingPub.identity_did !== identity.atp_did) { 64 77 return { success: false }; 65 78 } 66 - let aturi = new AtUri(existingPub.uri); 67 - // Preserve existing schema when updating 68 - const publicationType = getPublicationType(aturi.collection); 79 + 80 + const aturi = new AtUri(existingPub.uri); 81 + const publicationType = getPublicationType(aturi.collection) as PublicationType; 69 82 70 - // Normalize the existing record to read its properties 83 + // Normalize existing record 71 84 const normalizedPub = normalizePublicationRecord(existingPub.record); 72 - // Extract base_path from url if it exists (url format is https://domain, base_path is just domain) 73 85 const existingBasePath = normalizedPub?.url 74 86 ? normalizedPub.url.replace(/^https?:\/\//, "") 75 87 : undefined; 76 88 77 - // Upload the icon if provided 78 - let iconBlob = normalizedPub?.icon; 79 - if (iconFile && iconFile.size > 0) { 80 - const buffer = await iconFile.arrayBuffer(); 81 - const uploadResult = await agent.com.atproto.repo.uploadBlob( 82 - new Uint8Array(buffer), 83 - { encoding: iconFile.type }, 84 - ); 85 - 86 - if (uploadResult.data.blob) { 87 - iconBlob = uploadResult.data.blob; 88 - } 89 - } 90 - 91 - // Build preferences based on input or existing normalized preferences 92 - const preferencesData = preferences || normalizedPub?.preferences; 89 + // Build the updated record 90 + const record = await buildRecord({ 91 + normalizedPub, 92 + existingBasePath, 93 + publicationType, 94 + agent, 95 + }); 93 96 94 - // Build the record with the correct field based on publication type 95 - const record = 96 - publicationType === "site.standard.publication" 97 - ? ({ 98 - $type: publicationType, 99 - name, 100 - description: description !== undefined ? description : normalizedPub?.description, 101 - icon: iconBlob, 102 - theme: normalizedPub?.theme, 103 - preferences: preferencesData 104 - ? { 105 - $type: "site.standard.publication#preferences" as const, 106 - showInDiscover: preferencesData.showInDiscover, 107 - showComments: preferencesData.showComments, 108 - showMentions: preferencesData.showMentions, 109 - showPrevNext: preferencesData.showPrevNext, 110 - } 111 - : undefined, 112 - url: normalizedPub?.url || "", 113 - } as SiteStandardPublication.Record) 114 - : ({ 115 - $type: publicationType, 116 - name, 117 - description: description !== undefined ? description : normalizedPub?.description, 118 - icon: iconBlob, 119 - theme: normalizedPub?.theme, 120 - preferences: preferencesData 121 - ? { 122 - $type: "pub.leaflet.publication#preferences" as const, 123 - showInDiscover: preferencesData.showInDiscover, 124 - showComments: preferencesData.showComments, 125 - showMentions: preferencesData.showMentions, 126 - showPrevNext: preferencesData.showPrevNext, 127 - } 128 - : undefined, 129 - base_path: existingBasePath, 130 - } as PubLeafletPublication.Record); 131 - 132 - let result = await agent.com.atproto.repo.putRecord({ 97 + // Write to PDS 98 + await agent.com.atproto.repo.putRecord({ 133 99 repo: credentialSession.did!, 134 100 rkey: aturi.rkey, 135 101 record, ··· 137 103 validate: false, 138 104 }); 139 105 140 - //optimistically write to our db! 141 - let { data: publication, error } = await supabaseServerClient 106 + // Optimistically write to database 107 + const { data: publication } = await supabaseServerClient 142 108 .from("publications") 143 109 .update({ 144 110 name: record.name, ··· 147 113 .eq("uri", uri) 148 114 .select() 149 115 .single(); 116 + 150 117 return { success: true, publication }; 151 118 } 152 119 120 + /** 121 + * Helper to build preferences object with correct $type based on publication type 122 + */ 123 + function buildPreferences( 124 + preferencesData: NormalizedPublication["preferences"] | undefined, 125 + publicationType: PublicationType, 126 + ) { 127 + if (!preferencesData) return undefined; 128 + 129 + const $type = 130 + publicationType === "site.standard.publication" 131 + ? ("site.standard.publication#preferences" as const) 132 + : ("pub.leaflet.publication#preferences" as const); 133 + 134 + return { 135 + $type, 136 + showInDiscover: preferencesData.showInDiscover, 137 + showComments: preferencesData.showComments, 138 + showMentions: preferencesData.showMentions, 139 + showPrevNext: preferencesData.showPrevNext, 140 + }; 141 + } 142 + 143 + /** 144 + * Helper to build the base record fields (shared between all update functions) 145 + */ 146 + function buildBaseRecord( 147 + normalizedPub: NormalizedPublication | null, 148 + existingBasePath: string | undefined, 149 + publicationType: PublicationType, 150 + overrides: { 151 + name?: string; 152 + description?: string; 153 + icon?: any; 154 + theme?: any; 155 + preferences?: NormalizedPublication["preferences"]; 156 + basePath?: string; 157 + }, 158 + ): PubLeafletPublication.Record | SiteStandardPublication.Record { 159 + const name = overrides.name ?? normalizedPub?.name ?? ""; 160 + const description = overrides.description !== undefined 161 + ? overrides.description 162 + : normalizedPub?.description; 163 + const icon = overrides.icon !== undefined ? overrides.icon : normalizedPub?.icon; 164 + const theme = overrides.theme !== undefined ? overrides.theme : normalizedPub?.theme; 165 + const preferencesData = overrides.preferences ?? normalizedPub?.preferences; 166 + const basePath = overrides.basePath ?? existingBasePath; 167 + 168 + if (publicationType === "site.standard.publication") { 169 + return { 170 + $type: publicationType, 171 + name, 172 + description, 173 + icon, 174 + theme, 175 + preferences: buildPreferences(preferencesData, publicationType), 176 + url: basePath ? `https://${basePath}` : normalizedPub?.url || "", 177 + } as SiteStandardPublication.Record; 178 + } 179 + 180 + return { 181 + $type: publicationType, 182 + name, 183 + description, 184 + icon, 185 + theme, 186 + preferences: buildPreferences(preferencesData, publicationType), 187 + base_path: basePath, 188 + } as PubLeafletPublication.Record; 189 + } 190 + 191 + export async function updatePublication({ 192 + uri, 193 + name, 194 + description, 195 + iconFile, 196 + preferences, 197 + }: { 198 + uri: string; 199 + name: string; 200 + description?: string; 201 + iconFile?: File | null; 202 + preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 203 + }): Promise<UpdatePublicationResult> { 204 + return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 205 + // Upload icon if provided 206 + let iconBlob = normalizedPub?.icon; 207 + if (iconFile && iconFile.size > 0) { 208 + const buffer = await iconFile.arrayBuffer(); 209 + const uploadResult = await agent.com.atproto.repo.uploadBlob( 210 + new Uint8Array(buffer), 211 + { encoding: iconFile.type }, 212 + ); 213 + if (uploadResult.data.blob) { 214 + iconBlob = uploadResult.data.blob; 215 + } 216 + } 217 + 218 + return buildBaseRecord(normalizedPub, existingBasePath, publicationType, { 219 + name, 220 + description, 221 + icon: iconBlob, 222 + preferences, 223 + }); 224 + }); 225 + } 226 + 153 227 export async function updatePublicationBasePath({ 154 228 uri, 155 229 base_path, ··· 157 231 uri: string; 158 232 base_path: string; 159 233 }): Promise<UpdatePublicationResult> { 160 - let identity = await getIdentityData(); 161 - if (!identity || !identity.atp_did) { 162 - return { 163 - success: false, 164 - error: { 165 - type: "oauth_session_expired", 166 - message: "Not authenticated", 167 - did: "", 168 - }, 169 - }; 170 - } 171 - 172 - const sessionResult = await restoreOAuthSession(identity.atp_did); 173 - if (!sessionResult.ok) { 174 - return { success: false, error: sessionResult.error }; 175 - } 176 - let credentialSession = sessionResult.value; 177 - let agent = new AtpBaseClient( 178 - credentialSession.fetchHandler.bind(credentialSession), 179 - ); 180 - let { data: existingPub } = await supabaseServerClient 181 - .from("publications") 182 - .select("*") 183 - .eq("uri", uri) 184 - .single(); 185 - if (!existingPub || existingPub.identity_did !== identity.atp_did) { 186 - return { success: false }; 187 - } 188 - let aturi = new AtUri(existingPub.uri); 189 - // Preserve existing schema when updating 190 - const publicationType = getPublicationType(aturi.collection); 191 - 192 - // Normalize the existing record to read its properties 193 - const normalizedPub = normalizePublicationRecord(existingPub.record); 194 - // Extract base_path from url if it exists (url format is https://domain, base_path is just domain) 195 - const existingBasePath = normalizedPub?.url 196 - ? normalizedPub.url.replace(/^https?:\/\//, "") 197 - : undefined; 198 - 199 - // Build the record with the correct field based on publication type 200 - const record = 201 - publicationType === "site.standard.publication" 202 - ? ({ 203 - $type: publicationType, 204 - name: normalizedPub?.name || "", 205 - description: normalizedPub?.description, 206 - icon: normalizedPub?.icon, 207 - theme: normalizedPub?.theme, 208 - preferences: normalizedPub?.preferences 209 - ? { 210 - $type: "site.standard.publication#preferences" as const, 211 - showInDiscover: normalizedPub.preferences.showInDiscover, 212 - showComments: normalizedPub.preferences.showComments, 213 - showMentions: normalizedPub.preferences.showMentions, 214 - showPrevNext: normalizedPub.preferences.showPrevNext, 215 - } 216 - : undefined, 217 - url: `https://${base_path}`, 218 - } as SiteStandardPublication.Record) 219 - : ({ 220 - $type: publicationType, 221 - name: normalizedPub?.name || "", 222 - description: normalizedPub?.description, 223 - icon: normalizedPub?.icon, 224 - theme: normalizedPub?.theme, 225 - preferences: normalizedPub?.preferences 226 - ? { 227 - $type: "pub.leaflet.publication#preferences" as const, 228 - showInDiscover: normalizedPub.preferences.showInDiscover, 229 - showComments: normalizedPub.preferences.showComments, 230 - showMentions: normalizedPub.preferences.showMentions, 231 - showPrevNext: normalizedPub.preferences.showPrevNext, 232 - } 233 - : undefined, 234 - base_path, 235 - } as PubLeafletPublication.Record); 236 - 237 - let result = await agent.com.atproto.repo.putRecord({ 238 - repo: credentialSession.did!, 239 - rkey: aturi.rkey, 240 - record, 241 - collection: publicationType, 242 - validate: false, 234 + return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType }) => { 235 + return buildBaseRecord(normalizedPub, existingBasePath, publicationType, { 236 + basePath: base_path, 237 + }); 243 238 }); 244 - 245 - //optimistically write to our db! 246 - let { data: publication, error } = await supabaseServerClient 247 - .from("publications") 248 - .update({ 249 - name: record.name, 250 - record: record as Json, 251 - }) 252 - .eq("uri", uri) 253 - .select() 254 - .single(); 255 - return { success: true, publication }; 256 239 } 257 240 258 241 type Color = 259 242 | $Typed<PubLeafletThemeColor.Rgb, "pub.leaflet.theme.color#rgb"> 260 243 | $Typed<PubLeafletThemeColor.Rgba, "pub.leaflet.theme.color#rgba">; 244 + 261 245 export async function updatePublicationTheme({ 262 246 uri, 263 247 theme, ··· 275 259 accentText: Color; 276 260 }; 277 261 }): Promise<UpdatePublicationResult> { 278 - let identity = await getIdentityData(); 279 - if (!identity || !identity.atp_did) { 280 - return { 281 - success: false, 282 - error: { 283 - type: "oauth_session_expired", 284 - message: "Not authenticated", 285 - did: "", 262 + return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { 263 + // Build theme object 264 + const themeData = { 265 + backgroundImage: theme.backgroundImage 266 + ? { 267 + $type: "pub.leaflet.theme.backgroundImage", 268 + image: ( 269 + await agent.com.atproto.repo.uploadBlob( 270 + new Uint8Array(await theme.backgroundImage.arrayBuffer()), 271 + { encoding: theme.backgroundImage.type }, 272 + ) 273 + )?.data.blob, 274 + width: theme.backgroundRepeat || undefined, 275 + repeat: !!theme.backgroundRepeat, 276 + } 277 + : theme.backgroundImage === null 278 + ? undefined 279 + : normalizedPub?.theme?.backgroundImage, 280 + backgroundColor: theme.backgroundColor 281 + ? { 282 + ...theme.backgroundColor, 283 + } 284 + : undefined, 285 + pageWidth: theme.pageWidth, 286 + primary: { 287 + ...theme.primary, 288 + }, 289 + pageBackground: { 290 + ...theme.pageBackground, 291 + }, 292 + showPageBackground: theme.showPageBackground, 293 + accentBackground: { 294 + ...theme.accentBackground, 295 + }, 296 + accentText: { 297 + ...theme.accentText, 286 298 }, 287 299 }; 288 - } 289 300 290 - const sessionResult = await restoreOAuthSession(identity.atp_did); 291 - if (!sessionResult.ok) { 292 - return { success: false, error: sessionResult.error }; 293 - } 294 - let credentialSession = sessionResult.value; 295 - let agent = new AtpBaseClient( 296 - credentialSession.fetchHandler.bind(credentialSession), 297 - ); 298 - let { data: existingPub } = await supabaseServerClient 299 - .from("publications") 300 - .select("*") 301 - .eq("uri", uri) 302 - .single(); 303 - if (!existingPub || existingPub.identity_did !== identity.atp_did) { 304 - return { success: false }; 305 - } 306 - let aturi = new AtUri(existingPub.uri); 307 - // Preserve existing schema when updating 308 - const publicationType = getPublicationType(aturi.collection); 309 - 310 - // Normalize the existing record to read its properties 311 - const normalizedPub = normalizePublicationRecord(existingPub.record); 312 - // Extract base_path from url if it exists (url format is https://domain, base_path is just domain) 313 - const existingBasePath = normalizedPub?.url 314 - ? normalizedPub.url.replace(/^https?:\/\//, "") 315 - : undefined; 316 - 317 - // Build theme object (shared between both publication types) 318 - const themeData = { 319 - backgroundImage: theme.backgroundImage 320 - ? { 321 - $type: "pub.leaflet.theme.backgroundImage", 322 - image: ( 323 - await agent.com.atproto.repo.uploadBlob( 324 - new Uint8Array(await theme.backgroundImage.arrayBuffer()), 325 - { encoding: theme.backgroundImage.type }, 326 - ) 327 - )?.data.blob, 328 - width: theme.backgroundRepeat || undefined, 329 - repeat: !!theme.backgroundRepeat, 330 - } 331 - : theme.backgroundImage === null 332 - ? undefined 333 - : normalizedPub?.theme?.backgroundImage, 334 - backgroundColor: theme.backgroundColor 335 - ? { 336 - ...theme.backgroundColor, 337 - } 338 - : undefined, 339 - pageWidth: theme.pageWidth, 340 - primary: { 341 - ...theme.primary, 342 - }, 343 - pageBackground: { 344 - ...theme.pageBackground, 345 - }, 346 - showPageBackground: theme.showPageBackground, 347 - accentBackground: { 348 - ...theme.accentBackground, 349 - }, 350 - accentText: { 351 - ...theme.accentText, 352 - }, 353 - }; 354 - 355 - // Build the record with the correct field based on publication type 356 - const record = 357 - publicationType === "site.standard.publication" 358 - ? ({ 359 - $type: publicationType, 360 - name: normalizedPub?.name || "", 361 - description: normalizedPub?.description, 362 - icon: normalizedPub?.icon, 363 - url: normalizedPub?.url || "", 364 - preferences: normalizedPub?.preferences 365 - ? { 366 - $type: "site.standard.publication#preferences" as const, 367 - showInDiscover: normalizedPub.preferences.showInDiscover, 368 - showComments: normalizedPub.preferences.showComments, 369 - showMentions: normalizedPub.preferences.showMentions, 370 - showPrevNext: normalizedPub.preferences.showPrevNext, 371 - } 372 - : undefined, 373 - theme: themeData, 374 - } as SiteStandardPublication.Record) 375 - : ({ 376 - $type: publicationType, 377 - name: normalizedPub?.name || "", 378 - description: normalizedPub?.description, 379 - icon: normalizedPub?.icon, 380 - base_path: existingBasePath, 381 - preferences: normalizedPub?.preferences 382 - ? { 383 - $type: "pub.leaflet.publication#preferences" as const, 384 - showInDiscover: normalizedPub.preferences.showInDiscover, 385 - showComments: normalizedPub.preferences.showComments, 386 - showMentions: normalizedPub.preferences.showMentions, 387 - showPrevNext: normalizedPub.preferences.showPrevNext, 388 - } 389 - : undefined, 390 - theme: themeData, 391 - } as PubLeafletPublication.Record); 392 - 393 - let result = await agent.com.atproto.repo.putRecord({ 394 - repo: credentialSession.did!, 395 - rkey: aturi.rkey, 396 - record, 397 - collection: publicationType, 398 - validate: false, 301 + return buildBaseRecord(normalizedPub, existingBasePath, publicationType, { 302 + theme: themeData, 303 + }); 399 304 }); 400 - 401 - //optimistically write to our db! 402 - let { data: publication, error } = await supabaseServerClient 403 - .from("publications") 404 - .update({ 405 - name: record.name, 406 - record: record as Json, 407 - }) 408 - .eq("uri", uri) 409 - .select() 410 - .single(); 411 - return { success: true, publication }; 412 305 }
+1 -1
feeds/index.ts
··· 115 115 ); 116 116 } 117 117 query = query 118 - .not("data -> postRef", "is", null) 118 + .or("data->postRef.not.is.null,data->bskyPostRef.not.is.null") 119 119 .order("indexed_at", { ascending: false }) 120 120 .order("uri", { ascending: false }) 121 121 .limit(25);