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(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}