Bluesky app fork with some witchin' additions 馃挮
at main 640 lines 19 kB view raw
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}