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