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