a tool for shared writing and social publishing
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}