forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1/**
2 * Type converters for Draft API - convert between ComposerState and server Draft types.
3 */
4import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
5import {nanoid} from 'nanoid/non-secure'
6
7import {resolveLink} from '#/lib/api/resolve'
8import {getDeviceName} from '#/lib/deviceName'
9import {getImageDim} from '#/lib/media/manip'
10import {mimeToExt} from '#/lib/media/video/util'
11import {shortenLinks} from '#/lib/strings/rich-text-manip'
12import {type ComposerImage} from '#/state/gallery'
13import {type Gif} from '#/state/queries/tenor'
14import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
15import {createPublicAgent} from '#/state/session/agent'
16import {
17 type ComposerState,
18 type EmbedDraft,
19 type PostDraft,
20} from '#/view/com/composer/state/composer'
21import {type VideoState} from '#/view/com/composer/state/video'
22import {type AnalyticsContextType} from '#/analytics'
23import {getDeviceId} from '#/analytics/identifiers'
24import {logger} from './logger'
25import {type DraftPostDisplay, type DraftSummary} from './schema'
26import * as storage from './storage'
27
28const TENOR_HOSTNAME = 'media.tenor.com'
29const KLIPY_HOSTNAME = 'static.klipy.com'
30
31/**
32 * Video data from a draft that needs to be restored by re-processing.
33 * Contains the local file URI, alt text, mime type, and captions to restore.
34 */
35export type RestoredVideo = {
36 uri: string
37 altText: string
38 mimeType: string
39 localRefPath: string
40 captions: Array<{lang: string; content: string}>
41}
42
43/**
44 * Parse mime type from video localRefPath.
45 * Format: `video:${mimeType}:${nanoid()}` (new) or `video:${nanoid()}` (legacy)
46 */
47function parseVideoMimeType(localRefPath: string): string {
48 const parts = localRefPath.split(':')
49 // New format: video:video/mp4:abc123 -> parts[1] is mime type
50 // Legacy format: video:abc123 -> no mime type, default to video/mp4
51 if (parts.length >= 3 && parts[1].includes('/')) {
52 return parts[1]
53 }
54 return 'video/mp4' // Default for legacy drafts
55}
56
57/**
58 * Convert ComposerState to server Draft format for saving.
59 * Returns both the draft and a map of localRef paths to their source paths.
60 */
61export async function composerStateToDraft(state: ComposerState): Promise<{
62 draft: AppBskyDraftDefs.Draft
63 localRefPaths: Map<string, string>
64}> {
65 const localRefPaths = new Map<string, string>()
66
67 const posts: AppBskyDraftDefs.DraftPost[] = await Promise.all(
68 state.thread.posts.map(post => {
69 return postDraftToServerPost(post, localRefPaths)
70 }),
71 )
72
73 const draft: AppBskyDraftDefs.Draft = {
74 $type: 'app.bsky.draft.defs#draft',
75 deviceId: getDeviceId(),
76 deviceName: getDeviceName().slice(0, 100), // max length of 100 in lex
77 posts,
78 threadgateAllow: threadgateAllowUISettingToAllowRecordValue(
79 state.thread.threadgate,
80 ),
81 postgateEmbeddingRules:
82 state.thread.postgate.embeddingRules &&
83 state.thread.postgate.embeddingRules.length > 0
84 ? state.thread.postgate.embeddingRules
85 : undefined,
86 }
87
88 return {draft, localRefPaths}
89}
90
91/**
92 * Convert a single PostDraft to server DraftPost format.
93 */
94async function postDraftToServerPost(
95 post: PostDraft,
96 localRefPaths: Map<string, string>,
97): Promise<AppBskyDraftDefs.DraftPost> {
98 const draftPost: AppBskyDraftDefs.DraftPost = {
99 $type: 'app.bsky.draft.defs#draftPost',
100 text: post.richtext.text,
101 }
102
103 // Add labels if present
104 if (post.labels.length > 0) {
105 draftPost.labels = {
106 $type: 'com.atproto.label.defs#selfLabels',
107 values: post.labels.map(label => ({val: label})),
108 }
109 }
110
111 // Add embeds
112 if (post.embed.media) {
113 if (post.embed.media.type === 'images') {
114 draftPost.embedImages = serializeImages(
115 post.embed.media.images,
116 localRefPaths,
117 )
118 } else if (post.embed.media.type === 'video') {
119 const video = await serializeVideo(post.embed.media.video, localRefPaths)
120 if (video) {
121 draftPost.embedVideos = [video]
122 }
123 } else if (post.embed.media.type === 'gif') {
124 const external = serializeGif(post.embed.media)
125 if (external) {
126 draftPost.embedExternals = [external]
127 }
128 }
129 }
130
131 // Add quote record embed
132 if (post.embed.quote) {
133 const resolved = await resolveLink(
134 createPublicAgent(),
135 post.embed.quote.uri,
136 )
137 if (resolved && resolved.type === 'record') {
138 draftPost.embedRecords = [
139 {
140 $type: 'app.bsky.draft.defs#draftEmbedRecord',
141 record: {
142 uri: resolved.record.uri,
143 cid: resolved.record.cid,
144 },
145 },
146 ]
147 }
148 }
149
150 // Add external link embed (only if no media, otherwise it's ignored)
151 if (post.embed.link && !post.embed.media) {
152 draftPost.embedExternals = [
153 {
154 $type: 'app.bsky.draft.defs#draftEmbedExternal',
155 uri: post.embed.link.uri,
156 },
157 ]
158 }
159
160 return draftPost
161}
162
163/**
164 * Serialize images to server format with localRef paths.
165 * Reuses existing localRefPath if present (when editing a draft),
166 * otherwise generates a new one.
167 */
168function serializeImages(
169 images: ComposerImage[],
170 localRefPaths: Map<string, string>,
171): AppBskyDraftDefs.DraftEmbedImage[] {
172 return images.map(image => {
173 const sourcePath = image.transformed?.path || image.source.path
174 // Reuse existing localRefPath if present (editing draft), otherwise generate new
175 const isReusing = !!image.localRefPath
176 const localRefPath = image.localRefPath || `image:${nanoid()}`
177 localRefPaths.set(localRefPath, sourcePath)
178
179 logger.debug('serializing image', {
180 localRefPath,
181 isReusing,
182 sourcePath,
183 })
184
185 return {
186 $type: 'app.bsky.draft.defs#draftEmbedImage',
187 localRef: {
188 $type: 'app.bsky.draft.defs#draftEmbedLocalRef',
189 path: localRefPath,
190 },
191 alt: image.alt || undefined,
192 }
193 })
194}
195
196/**
197 * Serialize video to server format with localRef path.
198 * The localRef path encodes the mime type: `video:${mimeType}:${nanoid()}`
199 */
200async function serializeVideo(
201 videoState: VideoState,
202 localRefPaths: Map<string, string>,
203): Promise<AppBskyDraftDefs.DraftEmbedVideo | undefined> {
204 // Only save videos that have been compressed (have a video file)
205 if (!videoState.video) {
206 return undefined
207 }
208
209 // Encode mime type in the path for restoration
210 const mimeType = videoState.video.mimeType || 'video/mp4'
211 const ext = mimeToExt(mimeType)
212 const localRefPath = `video:${mimeType}:${nanoid()}.${ext}`
213 localRefPaths.set(localRefPath, videoState.video.uri)
214
215 // Read caption file contents as text
216 const captions: AppBskyDraftDefs.DraftEmbedCaption[] = []
217 for (const caption of videoState.captions) {
218 if (caption.lang) {
219 const content = await caption.file.text()
220 captions.push({
221 $type: 'app.bsky.draft.defs#draftEmbedCaption',
222 lang: caption.lang,
223 content,
224 })
225 }
226 }
227
228 return {
229 $type: 'app.bsky.draft.defs#draftEmbedVideo',
230 localRef: {
231 $type: 'app.bsky.draft.defs#draftEmbedLocalRef',
232 path: localRefPath,
233 },
234 alt: videoState.altText || undefined,
235 captions: captions.length > 0 ? captions : undefined,
236 }
237}
238
239/**
240 * Serialize GIF to server format as external embed.
241 * URL format: https://media.tenor.com/{id}/{filename}.gif?hh=HEIGHT&ww=WIDTH&alt=ALT_TEXT
242 */
243function serializeGif(gifMedia: {
244 type: 'gif'
245 gif: Gif
246 alt: string
247}): AppBskyDraftDefs.DraftEmbedExternal | undefined {
248 const gif = gifMedia.gif
249 const gifFormat = gif.media_formats.gif || gif.media_formats.tinygif
250
251 if (!gifFormat?.url) {
252 return undefined
253 }
254
255 // Build URL with dimensions and alt text in query params
256 const url = new URL(gifFormat.url)
257 if (gifFormat.dims) {
258 url.searchParams.set('ww', String(gifFormat.dims[0]))
259 url.searchParams.set('hh', String(gifFormat.dims[1]))
260 }
261 // Store alt text if present
262 if (gifMedia.alt) {
263 url.searchParams.set('alt', gifMedia.alt)
264 }
265
266 return {
267 $type: 'app.bsky.draft.defs#draftEmbedExternal',
268 uri: url.toString(),
269 }
270}
271
272/**
273 * Convert server DraftView to DraftSummary for list display.
274 * Also checks which media files exist locally.
275 */
276export function draftViewToSummary({
277 view,
278 analytics,
279}: {
280 view: AppBskyDraftDefs.DraftView
281 analytics: AnalyticsContextType
282}): DraftSummary {
283 const meta = {
284 isOriginatingDevice: view.draft.deviceId === getDeviceId(),
285 postCount: view.draft.posts.length,
286 // minus anchor post
287 replyCount: view.draft.posts.length - 1,
288 hasMedia: false,
289 hasMissingMedia: false,
290 mediaCount: 0,
291 hasQuotes: false,
292 quoteCount: 0,
293 }
294
295 const posts: DraftPostDisplay[] = view.draft.posts.map((post, index) => {
296 const images: DraftPostDisplay['images'] = []
297 const videos: DraftPostDisplay['video'][] = []
298 let gif: DraftPostDisplay['gif']
299
300 // Process images
301 if (post.embedImages) {
302 for (const img of post.embedImages) {
303 meta.mediaCount++
304 meta.hasMedia = true
305 const exists = storage.mediaExists(img.localRef.path)
306 if (!exists) {
307 meta.hasMissingMedia = true
308 }
309 images.push({
310 localPath: img.localRef.path,
311 altText: img.alt || '',
312 exists,
313 })
314 }
315 }
316
317 // Process videos
318 if (post.embedVideos) {
319 for (const vid of post.embedVideos) {
320 meta.mediaCount++
321 meta.hasMedia = true
322 const exists = storage.mediaExists(vid.localRef.path)
323 if (!exists) {
324 meta.hasMissingMedia = true
325 }
326 videos.push({
327 localPath: vid.localRef.path,
328 altText: vid.alt || '',
329 exists,
330 })
331 }
332 }
333
334 // Process externals (check for GIFs)
335 if (post.embedExternals) {
336 for (const ext of post.embedExternals) {
337 const gifData = parseGifFromUrl(ext.uri)
338 if (gifData) {
339 meta.mediaCount++
340 meta.hasMedia = true
341 gif = gifData
342 }
343 }
344 }
345
346 if (post.embedRecords && post.embedRecords.length > 0) {
347 meta.quoteCount += post.embedRecords.length
348 meta.hasQuotes = true
349 }
350
351 return {
352 id: `post-${index}`,
353 text: post.text || '',
354 images: images.length > 0 ? images : undefined,
355 video: videos[0], // Only one video per post
356 gif,
357 }
358 })
359
360 if (meta.isOriginatingDevice && meta.hasMissingMedia) {
361 analytics.logger.warn(`Draft is missing media on originating device`, {})
362 }
363
364 return {
365 id: view.id,
366 createdAt: view.createdAt,
367 updatedAt: view.updatedAt,
368 draft: view.draft,
369 posts,
370 meta,
371 }
372}
373
374/**
375 * Parse GIF data from a Tenor URL.
376 * URL format: https://media.tenor.com/{id}/{filename}.gif?hh=HEIGHT&ww=WIDTH&alt=ALT_TEXT
377 */
378function parseGifFromUrl(
379 uri: string,
380): {url: string; width: number; height: number; alt: string} | undefined {
381 try {
382 const url = new URL(uri)
383 if (url.hostname !== TENOR_HOSTNAME && url.hostname !== KLIPY_HOSTNAME) {
384 return undefined
385 }
386
387 const height = parseInt(url.searchParams.get('hh') || '', 10)
388 const width = parseInt(url.searchParams.get('ww') || '', 10)
389 const alt = url.searchParams.get('alt') || ''
390
391 if (!height || !width) {
392 return undefined
393 }
394
395 // Strip our custom params to get clean base URL
396 // This prevents double query strings when resolveGif() adds params again
397 url.searchParams.delete('ww')
398 url.searchParams.delete('hh')
399 url.searchParams.delete('alt')
400 url.searchParams.delete('mp4')
401 url.searchParams.delete('webm')
402
403 return {url: url.toString(), width, height, alt}
404 } catch {
405 return undefined
406 }
407}
408
409/**
410 * Convert server Draft back to composer-compatible format for restoration.
411 * Returns posts and a map of videos that need to be restored by re-processing.
412 *
413 * Videos cannot be restored synchronously like images because they need to go through
414 * the compression and upload pipeline. The caller should handle the restoredVideos
415 * by initiating video processing for each entry.
416 */
417export async function draftToComposerPosts(
418 draft: AppBskyDraftDefs.Draft,
419 loadedMedia: Map<string, string>,
420): Promise<{posts: PostDraft[]; restoredVideos: Map<number, RestoredVideo>}> {
421 const restoredVideos = new Map<number, RestoredVideo>()
422
423 const posts = await Promise.all(
424 draft.posts.map(async (post, index) => {
425 const richtext = new RichText({text: post.text || ''})
426 richtext.detectFacetsWithoutResolution()
427
428 const embed: EmbedDraft = {
429 quote: undefined,
430 link: undefined,
431 media: undefined,
432 }
433
434 // Restore images
435 if (post.embedImages && post.embedImages.length > 0) {
436 const imagePromises = post.embedImages.map(async img => {
437 const path = loadedMedia.get(img.localRef.path)
438 if (!path) {
439 return null
440 }
441
442 let width = 0
443 let height = 0
444 try {
445 const dims = await getImageDim(path)
446 width = dims.width
447 height = dims.height
448 } catch (e) {
449 logger.warn('Failed to get image dimensions', {
450 path,
451 error: e,
452 })
453 }
454
455 logger.debug('restoring image with localRefPath', {
456 localRefPath: img.localRef.path,
457 loadedPath: path,
458 width,
459 height,
460 })
461
462 return {
463 alt: img.alt || '',
464 // Preserve the original localRefPath for reuse when saving
465 localRefPath: img.localRef.path,
466 source: {
467 id: nanoid(),
468 path,
469 width,
470 height,
471 mime: 'image/jpeg',
472 },
473 }
474 })
475
476 const images = (await Promise.all(imagePromises)).filter(
477 (img): img is ComposerImage => img !== null,
478 )
479 if (images.length > 0) {
480 embed.media = {type: 'images', images}
481 }
482 }
483
484 // Restore GIF from external embed
485 if (post.embedExternals) {
486 for (const ext of post.embedExternals) {
487 const gifData = parseGifFromUrl(ext.uri)
488 if (gifData) {
489 // Reconstruct a Gif object with all required properties
490 const mediaObject = {
491 url: gifData.url,
492 dims: [gifData.width, gifData.height] as [number, number],
493 duration: 0,
494 size: 0,
495 }
496 embed.media = {
497 type: 'gif',
498 gif: {
499 id: '',
500 created: 0,
501 hasaudio: false,
502 hascaption: false,
503 flags: '',
504 tags: [],
505 title: '',
506 content_description: gifData.alt || '',
507 itemurl: '',
508 url: gifData.url, // Required for useResolveGifQuery
509 media_formats: {
510 gif: mediaObject,
511 tinygif: mediaObject,
512 preview: mediaObject,
513 },
514 },
515 alt: gifData.alt,
516 }
517 break
518 }
519 }
520 }
521
522 // Collect video for restoration (processed async by caller)
523 if (post.embedVideos && post.embedVideos.length > 0) {
524 const vid = post.embedVideos[0]
525 const videoUri = loadedMedia.get(vid.localRef.path)
526 if (videoUri) {
527 const mimeType = parseVideoMimeType(vid.localRef.path)
528 logger.debug('found video to restore', {
529 localRefPath: vid.localRef.path,
530 videoUri,
531 altText: vid.alt,
532 mimeType,
533 captionCount: vid.captions?.length ?? 0,
534 })
535 restoredVideos.set(index, {
536 uri: videoUri,
537 altText: vid.alt || '',
538 mimeType,
539 localRefPath: vid.localRef.path,
540 captions:
541 vid.captions?.map(c => ({lang: c.lang, content: c.content})) ??
542 [],
543 })
544 }
545 }
546
547 // Restore quote embed
548 if (post.embedRecords && post.embedRecords.length > 0) {
549 const record = post.embedRecords[0]
550 const urip = new AtUri(record.record.uri)
551 const url = `https://bsky.app/profile/${urip.host}/post/${urip.rkey}`
552 embed.quote = {type: 'link', uri: url}
553 }
554
555 // Restore link embed (only if not a GIF)
556 if (post.embedExternals && !embed.media) {
557 for (const ext of post.embedExternals) {
558 const gifData = parseGifFromUrl(ext.uri)
559 if (!gifData) {
560 embed.link = {type: 'link', uri: ext.uri}
561 break
562 }
563 }
564 }
565
566 // Parse labels
567 const labels: string[] = []
568 if (post.labels && 'values' in post.labels) {
569 for (const val of post.labels.values) {
570 labels.push(val.val)
571 }
572 }
573
574 return {
575 id: `draft-post-${index}`,
576 richtext,
577 shortenedGraphemeLength: shortenLinks(richtext).graphemeLength,
578 labels,
579 embed,
580 } as PostDraft
581 }),
582 )
583
584 return {posts, restoredVideos}
585}
586
587/**
588 * Convert server threadgate rules back to UI settings.
589 */
590export function threadgateToUISettings(
591 threadgateAllow?: AppBskyDraftDefs.Draft['threadgateAllow'],
592): Array<{type: string; list?: string}> {
593 if (!threadgateAllow) {
594 return []
595 }
596
597 return threadgateAllow
598 .map(rule => {
599 if ('$type' in rule) {
600 if (rule.$type === 'app.bsky.feed.threadgate#mentionRule') {
601 return {type: 'mention'}
602 }
603 if (rule.$type === 'app.bsky.feed.threadgate#followingRule') {
604 return {type: 'following'}
605 }
606 if (rule.$type === 'app.bsky.feed.threadgate#followerRule') {
607 return {type: 'followers'}
608 }
609 if (
610 rule.$type === 'app.bsky.feed.threadgate#listRule' &&
611 'list' in rule
612 ) {
613 return {type: 'list', list: (rule as {list: string}).list}
614 }
615 }
616 return null
617 })
618 .filter((s): s is {type: string; list?: string} => s !== null)
619}
620
621/**
622 * Extract all localRef paths from a draft.
623 * Used to identify which media files belong to a draft for cleanup.
624 */
625export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set<string> {
626 const refs = new Set<string>()
627 for (const post of draft.posts) {
628 if (post.embedImages) {
629 for (const img of post.embedImages) {
630 refs.add(img.localRef.path)
631 }
632 }
633 if (post.embedVideos) {
634 for (const vid of post.embedVideos) {
635 refs.add(vid.localRef.path)
636 }
637 }
638 }
639 logger.debug('extracted localRefs from draft', {
640 count: refs.size,
641 refs: Array.from(refs),
642 })
643 return refs
644}