Bluesky app fork with some witchin' additions 💫

Fix redrafting for images and video

authored by scanash.com and committed by

Tangled 1320f38b 9616502f

+216 -18
+56 -3
src/components/PostControls/PostMenu/PostMenuItems.tsx
··· 17 type AppBskyFeedThreadgate, 18 AtUri, 19 type RichText as RichTextAPI, 20 } from '@atproto/api' 21 import {msg} from '@lingui/macro' 22 import {useLingui} from '@lingui/react' ··· 230 width: number 231 height: number 232 altText?: string 233 }[] = [] 234 235 if (post.embed?.$type === 'app.bsky.embed.images#view') { 236 const embed = post.embed as AppBskyEmbedImages.View 237 - imageUris = embed.images.map(img => ({ 238 uri: img.fullsize, 239 width: img.aspectRatio?.width ?? 1000, 240 height: img.aspectRatio?.height ?? 1000, 241 altText: img.alt, 242 })) 243 } else if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') { 244 const embed = post.embed as AppBskyEmbedRecordWithMedia.View 245 if (embed.media.$type === 'app.bsky.embed.images#view') { 246 const images = embed.media as AppBskyEmbedImages.View 247 - imageUris = images.images.map(img => ({ 248 uri: img.fullsize, 249 width: img.aspectRatio?.width ?? 1000, 250 height: img.aspectRatio?.height ?? 1000, 251 altText: img.alt, 252 })) 253 } 254 } ··· 297 } 298 } 299 300 openComposer({ 301 text: record.text, 302 imageUris, 303 onPost: () => { 304 onDeletePost() 305 }, ··· 606 control={redraftPromptControl} 607 title={_(msg`Redraft this skeet?`)} 608 description={_( 609 - msg`This will delete the original skeet and open the composer with its content. (WARNING: DOESN'T WORK ON SKEETS WITH MEDIA ALREADY ATTACHED. Probably no threads support either.)`, 610 )} 611 onConfirm={onConfirmRedraft} 612 confirmButtonCta={_(msg`Redraft`)}
··· 17 type AppBskyFeedThreadgate, 18 AtUri, 19 type RichText as RichTextAPI, 20 + type BlobRef, 21 } from '@atproto/api' 22 import {msg} from '@lingui/macro' 23 import {useLingui} from '@lingui/react' ··· 231 width: number 232 height: number 233 altText?: string 234 + blobRef?: AppBskyEmbedImages.Image['image'] 235 }[] = [] 236 237 + const recordEmbed = record.embed 238 + let recordImages: AppBskyEmbedImages.Image[] = [] 239 + if (recordEmbed?.$type === 'app.bsky.embed.images') { 240 + recordImages = (recordEmbed as AppBskyEmbedImages.Main).images 241 + } else if (recordEmbed?.$type === 'app.bsky.embed.recordWithMedia') { 242 + const media = (recordEmbed as AppBskyEmbedRecordWithMedia.Main).media 243 + if (media.$type === 'app.bsky.embed.images') { 244 + recordImages = (media as AppBskyEmbedImages.Main).images 245 + } 246 + } 247 + 248 if (post.embed?.$type === 'app.bsky.embed.images#view') { 249 const embed = post.embed as AppBskyEmbedImages.View 250 + imageUris = embed.images.map((img, i) => ({ 251 uri: img.fullsize, 252 width: img.aspectRatio?.width ?? 1000, 253 height: img.aspectRatio?.height ?? 1000, 254 altText: img.alt, 255 + blobRef: recordImages[i]?.image, 256 })) 257 } else if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') { 258 const embed = post.embed as AppBskyEmbedRecordWithMedia.View 259 if (embed.media.$type === 'app.bsky.embed.images#view') { 260 const images = embed.media as AppBskyEmbedImages.View 261 + imageUris = images.images.map((img, i) => ({ 262 uri: img.fullsize, 263 width: img.aspectRatio?.width ?? 1000, 264 height: img.aspectRatio?.height ?? 1000, 265 altText: img.alt, 266 + blobRef: recordImages[i]?.image, 267 })) 268 } 269 } ··· 312 } 313 } 314 315 + let videoUri: {uri: string; width: number; height: number; blobRef?: BlobRef; altText?: string} | undefined 316 + let recordVideo: AppBskyEmbedVideo.Main | undefined 317 + 318 + if (recordEmbed?.$type === 'app.bsky.embed.video') { 319 + recordVideo = recordEmbed as AppBskyEmbedVideo.Main 320 + } else if (recordEmbed?.$type === 'app.bsky.embed.recordWithMedia') { 321 + const media = (recordEmbed as AppBskyEmbedRecordWithMedia.Main).media 322 + if (media.$type === 'app.bsky.embed.video') { 323 + recordVideo = media as AppBskyEmbedVideo.Main 324 + } 325 + } 326 + 327 + if (post.embed?.$type === 'app.bsky.embed.video#view') { 328 + const embed = post.embed as AppBskyEmbedVideo.View 329 + if (recordVideo) { 330 + videoUri = { 331 + uri: embed.playlist || '', 332 + width: embed.aspectRatio?.width ?? 1000, 333 + height: embed.aspectRatio?.height ?? 1000, 334 + blobRef: recordVideo.video, 335 + altText: embed.alt || '', 336 + } 337 + } 338 + } else if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') { 339 + const embed = post.embed as AppBskyEmbedRecordWithMedia.View 340 + if (embed.media.$type === 'app.bsky.embed.video#view' && recordVideo) { 341 + const video = embed.media as AppBskyEmbedVideo.View 342 + videoUri = { 343 + uri: video.playlist || '', 344 + width: video.aspectRatio?.width ?? 1000, 345 + height: video.aspectRatio?.height ?? 1000, 346 + blobRef: recordVideo.video, 347 + altText: video.alt || '', 348 + } 349 + } 350 + } 351 + 352 openComposer({ 353 text: record.text, 354 imageUris, 355 + videoUri, 356 onPost: () => { 357 onDeletePost() 358 }, ··· 659 control={redraftPromptControl} 660 title={_(msg`Redraft this skeet?`)} 661 description={_( 662 + msg`This will delete the original skeet and open the composer with its content.`, 663 )} 664 onConfirm={onConfirmRedraft} 665 confirmButtonCta={_(msg`Redraft`)}
+20 -4
src/lib/api/index.ts
··· 324 onStateChange?.(t`Uploading images...`) 325 const images: AppBskyEmbedImages.Image[] = await Promise.all( 326 imagesDraft.map(async (image, i) => { 327 logger.debug(`Compressing image #${i}`) 328 const {path, width, height, mime} = await compressImage(image) 329 logger.debug(`Uploading image #${i}`) ··· 356 }), 357 ) 358 359 - // lexicon numbers must be floats 360 - const width = Math.round(videoDraft.asset.width) 361 - const height = Math.round(videoDraft.asset.height) 362 363 // aspect ratio values must be >0 - better to leave as unset otherwise 364 // posting will fail if aspect ratio is set to 0 ··· 366 367 if (!aspectRatio) { 368 logger.error( 369 - `Invalid aspect ratio - got { width: ${videoDraft.asset.width}, height: ${videoDraft.asset.height} }`, 370 ) 371 } 372
··· 324 onStateChange?.(t`Uploading images...`) 325 const images: AppBskyEmbedImages.Image[] = await Promise.all( 326 imagesDraft.map(async (image, i) => { 327 + if (image.blobRef) { 328 + logger.debug(`Reusing existing blob for image #${i}`) 329 + return { 330 + image: image.blobRef, 331 + alt: image.alt, 332 + aspectRatio: { 333 + width: image.source.width, 334 + height: image.source.height, 335 + }, 336 + } 337 + } 338 logger.debug(`Compressing image #${i}`) 339 const {path, width, height, mime} = await compressImage(image) 340 logger.debug(`Uploading image #${i}`) ··· 367 }), 368 ) 369 370 + const width = Math.round( 371 + videoDraft.asset?.width || 372 + ('redraftDimensions' in videoDraft ? videoDraft.redraftDimensions.width : 1000) 373 + ) 374 + const height = Math.round( 375 + videoDraft.asset?.height || 376 + ('redraftDimensions' in videoDraft ? videoDraft.redraftDimensions.height : 1000) 377 + ) 378 379 // aspect ratio values must be >0 - better to leave as unset otherwise 380 // posting will fail if aspect ratio is set to 0 ··· 382 383 if (!aspectRatio) { 384 logger.error( 385 + `Invalid aspect ratio - got { width: ${width}, height: ${height} }`, 386 ) 387 } 388
+5 -2
src/state/gallery.ts
··· 1 import { 2 cacheDirectory, 3 deleteAsync, ··· 37 type ComposerImageBase = { 38 alt: string 39 source: ImageSource 40 } 41 type ComposerImageWithoutTransformation = ComposerImageBase & { 42 transformed?: undefined ··· 81 width: number 82 height: number 83 altText?: string 84 } 85 86 export function createInitialImages( 87 uris: InitialImage[] = [], 88 ): ComposerImageWithoutTransformation[] { 89 - return uris.map(({uri, width, height, altText = ''}) => { 90 return { 91 alt: altText, 92 source: { ··· 96 height: height, 97 mime: 'image/jpeg', 98 }, 99 } 100 }) 101 } ··· 197 198 export async function compressImage(img: ComposerImage): Promise<PickerImage> { 199 const source = img.transformed || img.source 200 - 201 const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) 202 203 let minQualityPercentage = 0
··· 1 + import {type BlobRef} from '@atproto/api' 2 import { 3 cacheDirectory, 4 deleteAsync, ··· 38 type ComposerImageBase = { 39 alt: string 40 source: ImageSource 41 + blobRef?: BlobRef 42 } 43 type ComposerImageWithoutTransformation = ComposerImageBase & { 44 transformed?: undefined ··· 83 width: number 84 height: number 85 altText?: string 86 + blobRef?: BlobRef 87 } 88 89 export function createInitialImages( 90 uris: InitialImage[] = [], 91 ): ComposerImageWithoutTransformation[] { 92 + return uris.map(({uri, width, height, altText = '', blobRef}) => { 93 return { 94 alt: altText, 95 source: { ··· 99 height: height, 100 mime: 'image/jpeg', 101 }, 102 + blobRef, 103 } 104 }) 105 } ··· 201 202 export async function compressImage(img: ComposerImage): Promise<PickerImage> { 203 const source = img.transformed || img.source 204 const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) 205 206 let minQualityPercentage = 0
+3 -2
src/state/shell/composer/index.tsx
··· 3 type AppBskyActorDefs, 4 type AppBskyFeedDefs, 5 type AppBskyUnspeccedGetPostThreadV2, 6 type ModerationDecision, 7 } from '@atproto/api' 8 import {msg} from '@lingui/macro' ··· 41 mention?: string // handle of user to mention 42 openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void 43 text?: string 44 - imageUris?: {uri: string; width: number; height: number; altText?: string}[] 45 - videoUri?: {uri: string; width: number; height: number} 46 } 47 48 type StateContext = ComposerOpts | undefined
··· 3 type AppBskyActorDefs, 4 type AppBskyFeedDefs, 5 type AppBskyUnspeccedGetPostThreadV2, 6 + type BlobRef, 7 type ModerationDecision, 8 } from '@atproto/api' 9 import {msg} from '@lingui/macro' ··· 42 mention?: string // handle of user to mention 43 openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void 44 text?: string 45 + imageUris?: {uri: string; width: number; height: number; altText?: string; blobRef?: BlobRef}[] 46 + videoUri?: {uri: string; width: number; height: number; blobRef?: BlobRef; altText?: string} 47 } 48 49 type StateContext = ComposerOpts | undefined
+18 -5
src/view/com/composer/Composer.tsx
··· 119 import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' 120 import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' 121 import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress' 122 import {Text} from '#/view/com/util/text/Text' 123 import {UserAvatar} from '#/view/com/util/UserAvatar' 124 import {atoms as a, native, useTheme, web} from '#/alf' ··· 126 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' 127 import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' 128 import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 129 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 130 import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed' 131 import * as Prompt from '#/components/Prompt' ··· 238 239 const [composerState, composerDispatch] = useReducer( 240 composerReducer, 241 - { 242 initImageUris, 243 initQuoteUri: initQuote?.uri, 244 initText, 245 initMention, 246 initInteractionSettings: preferences?.postInteractionSettings, 247 - }, 248 - createComposerState, 249 ) 250 251 const thread = composerState.thread ··· 297 ) 298 299 const onInitVideo = useNonReactiveCallback(() => { 300 - if (initVideoUri) { 301 selectVideo(activePost.id, initVideoUri) 302 } 303 }) ··· 1172 canRemoveQuote: boolean 1173 isActivePost: boolean 1174 }) { 1175 const video = embed.media?.type === 'video' ? embed.media.video : null 1176 return ( 1177 <> ··· 1226 clear={clearVideo} 1227 /> 1228 ) : null)} 1229 <SubtitleDialogBtn 1230 defaultAltText={video.altText} 1231 saveAltText={altText => ··· 1239 }) 1240 } 1241 captions={video.captions} 1242 - setCaptions={updater => { 1243 dispatch({ 1244 type: 'embed_update_video', 1245 videoAction: {
··· 119 import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' 120 import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' 121 import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress' 122 + import {VideoEmbedRedraft} from '#/view/com/composer/videos/VideoEmbedRedraft' 123 import {Text} from '#/view/com/util/text/Text' 124 import {UserAvatar} from '#/view/com/util/UserAvatar' 125 import {atoms as a, native, useTheme, web} from '#/alf' ··· 127 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' 128 import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' 129 import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 130 + import {Play_Stroke2_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' 131 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 132 import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed' 133 import * as Prompt from '#/components/Prompt' ··· 240 241 const [composerState, composerDispatch] = useReducer( 242 composerReducer, 243 + createComposerState({ 244 initImageUris, 245 initQuoteUri: initQuote?.uri, 246 initText, 247 initMention, 248 initInteractionSettings: preferences?.postInteractionSettings, 249 + initVideoUri, 250 + }), 251 ) 252 253 const thread = composerState.thread ··· 299 ) 300 301 const onInitVideo = useNonReactiveCallback(() => { 302 + if (initVideoUri && !initVideoUri.blobRef) { 303 selectVideo(activePost.id, initVideoUri) 304 } 305 }) ··· 1174 canRemoveQuote: boolean 1175 isActivePost: boolean 1176 }) { 1177 + const theme = useTheme() 1178 const video = embed.media?.type === 'video' ? embed.media.video : null 1179 return ( 1180 <> ··· 1229 clear={clearVideo} 1230 /> 1231 ) : null)} 1232 + {!video.asset && video.status === 'done' && 'playlistUri' in video && ( 1233 + <View style={[a.relative, a.mt_lg]}> 1234 + <VideoEmbedRedraft 1235 + blobRef={video.pendingPublish?.blobRef!} 1236 + playlistUri={video.playlistUri} 1237 + aspectRatio={video.redraftDimensions} 1238 + onRemove={clearVideo} 1239 + /> 1240 + </View> 1241 + )} 1242 <SubtitleDialogBtn 1243 defaultAltText={video.altText} 1244 saveAltText={altText => ··· 1252 }) 1253 } 1254 captions={video.captions} 1255 + setCaptions={(updater: (captions: any[]) => any[]) => { 1256 dispatch({ 1257 type: 'embed_update_video', 1258 videoAction: {
+20 -2
src/view/com/composer/state/composer.ts
··· 18 postUriToRelativePath, 19 toBskyAppUrl, 20 } from '#/lib/strings/url-helpers' 21 - import {type ComposerImage, createInitialImages} from '#/state/gallery' 22 import {createPostgateRecord} from '#/state/queries/postgate/util' 23 import {type Gif} from '#/state/queries/tenor' 24 import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate' ··· 30 } from '#/view/com/composer/text-input/text-input-util' 31 import { 32 createVideoState, 33 type VideoAction, 34 videoReducer, 35 type VideoState, ··· 491 initImageUris, 492 initQuoteUri, 493 initInteractionSettings, 494 }: { 495 initText: string | undefined 496 initMention: string | undefined ··· 499 initInteractionSettings: 500 | BskyPreferences['postInteractionSettings'] 501 | undefined 502 }): ComposerState { 503 - let media: ImagesMedia | undefined 504 if (initImageUris?.length) { 505 media = { 506 type: 'images', 507 images: createInitialImages(initImageUris), 508 } 509 } 510 let quote: Link | undefined
··· 18 postUriToRelativePath, 19 toBskyAppUrl, 20 } from '#/lib/strings/url-helpers' 21 + import { 22 + type ComposerImage, 23 + createInitialImages, 24 + } from '#/state/gallery' 25 import {createPostgateRecord} from '#/state/queries/postgate/util' 26 import {type Gif} from '#/state/queries/tenor' 27 import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate' ··· 33 } from '#/view/com/composer/text-input/text-input-util' 34 import { 35 createVideoState, 36 + createRedraftVideoState, 37 + type RedraftState, 38 type VideoAction, 39 videoReducer, 40 type VideoState, ··· 496 initImageUris, 497 initQuoteUri, 498 initInteractionSettings, 499 + initVideoUri, 500 }: { 501 initText: string | undefined 502 initMention: string | undefined ··· 505 initInteractionSettings: 506 | BskyPreferences['postInteractionSettings'] 507 | undefined 508 + initVideoUri?: ComposerOpts['videoUri'] 509 }): ComposerState { 510 + let media: ImagesMedia | VideoMedia | undefined 511 if (initImageUris?.length) { 512 media = { 513 type: 'images', 514 images: createInitialImages(initImageUris), 515 + } 516 + } else if (initVideoUri?.blobRef) { 517 + media = { 518 + type: 'video', 519 + video: createRedraftVideoState({ 520 + blobRef: initVideoUri.blobRef, 521 + width: initVideoUri.width, 522 + height: initVideoUri.height, 523 + altText: initVideoUri.altText || '', 524 + playlistUri: initVideoUri.uri, 525 + }), 526 } 527 } 528 let quote: Link | undefined
+36
src/view/com/composer/state/video.ts
··· 130 captions: CaptionsTrack[] 131 } 132 133 export type VideoState = 134 | ErrorState 135 | CompressingState 136 | UploadingState 137 | ProcessingState 138 | DoneState 139 140 export function createVideoState( 141 asset: ImagePickerAsset, ··· 148 asset, 149 altText: '', 150 captions: [], 151 } 152 } 153
··· 130 captions: CaptionsTrack[] 131 } 132 133 + export type RedraftState = { 134 + status: 'done' 135 + progress: 100 136 + abortController: AbortController 137 + asset: null 138 + video?: undefined 139 + jobId?: undefined 140 + pendingPublish: {blobRef: BlobRef} 141 + altText: string 142 + captions: CaptionsTrack[] 143 + redraftDimensions: {width: number; height: number} 144 + playlistUri: string 145 + } 146 + 147 export type VideoState = 148 | ErrorState 149 | CompressingState 150 | UploadingState 151 | ProcessingState 152 | DoneState 153 + | RedraftState 154 155 export function createVideoState( 156 asset: ImagePickerAsset, ··· 163 asset, 164 altText: '', 165 captions: [], 166 + } 167 + } 168 + 169 + export function createRedraftVideoState(opts: { 170 + blobRef: BlobRef 171 + width: number 172 + height: number 173 + altText?: string 174 + playlistUri: string 175 + }): RedraftState { 176 + const noopController = new AbortController() 177 + return { 178 + status: 'done', 179 + progress: 100, 180 + abortController: noopController, 181 + asset: null, 182 + pendingPublish: {blobRef: opts.blobRef}, 183 + altText: opts.altText || '', 184 + captions: [], 185 + redraftDimensions: {width: opts.width, height: opts.height}, 186 + playlistUri: opts.playlistUri, 187 } 188 } 189
+57
src/view/com/composer/videos/VideoEmbedRedraft.tsx
···
··· 1 + import React from 'react' 2 + import {Platform, View} from 'react-native' 3 + import {type BlobRef} from '@atproto/api' 4 + import {BlueskyVideoView} from '@haileyok/bluesky-video' 5 + 6 + import {atoms as a} from '#/alf' 7 + import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn' 8 + import {VideoEmbedInnerWeb} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb' 9 + 10 + interface Props { 11 + blobRef: BlobRef 12 + playlistUri: string 13 + aspectRatio: {width: number; height: number} 14 + onRemove: () => void 15 + } 16 + 17 + export function VideoEmbedRedraft({blobRef, playlistUri, aspectRatio, onRemove}: Props) { 18 + const cidString = blobRef.ref.toString() 19 + const aspectRatioValue = aspectRatio.width / aspectRatio.height || 16 / 9 20 + const thumbnailUrl = playlistUri.replace('playlist.m3u8', 'thumbnail.jpg') 21 + 22 + const mockEmbed = { 23 + $type: 'app.bsky.embed.video#view' as const, 24 + video: blobRef, 25 + playlist: playlistUri, 26 + thumbnail: thumbnailUrl, 27 + aspectRatio, 28 + alt: '', 29 + captions: [], 30 + cid: cidString, 31 + } 32 + 33 + return ( 34 + <View style={[a.w_full, a.rounded_sm, {aspectRatio: aspectRatioValue}]}> 35 + {Platform.OS === 'web' ? ( 36 + <VideoEmbedInnerWeb 37 + embed={mockEmbed} 38 + active={false} 39 + setActive={() => {}} 40 + onScreen={true} 41 + lastKnownTime={{current: undefined}} 42 + /> 43 + ) : ( 44 + <BlueskyVideoView 45 + url={playlistUri} 46 + autoplay={false} 47 + beginMuted={true} 48 + style={[a.flex_1, a.rounded_sm]} 49 + /> 50 + )} 51 + <ExternalEmbedRemoveBtn 52 + onRemove={onRemove} 53 + style={{top: 16, right: 16, position: 'absolute', zIndex: 10}} 54 + /> 55 + </View> 56 + ) 57 + }
+1
src/view/shell/Composer.web.tsx
··· 110 openEmojiPicker={onOpenPicker} 111 text={state.text} 112 imageUris={state.imageUris} 113 /> 114 </View> 115 <EmojiPicker state={pickerState} close={onClosePicker} />
··· 110 openEmojiPicker={onOpenPicker} 111 text={state.text} 112 imageUris={state.imageUris} 113 + videoUri={state.videoUri} 114 /> 115 </View> 116 <EmojiPicker state={pickerState} close={onClosePicker} />