Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 644 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 {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}