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 17 type AppBskyFeedThreadgate, 18 18 AtUri, 19 19 type RichText as RichTextAPI, 20 + type BlobRef, 20 21 } from '@atproto/api' 21 22 import {msg} from '@lingui/macro' 22 23 import {useLingui} from '@lingui/react' ··· 230 231 width: number 231 232 height: number 232 233 altText?: string 234 + blobRef?: AppBskyEmbedImages.Image['image'] 233 235 }[] = [] 234 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 + 235 248 if (post.embed?.$type === 'app.bsky.embed.images#view') { 236 249 const embed = post.embed as AppBskyEmbedImages.View 237 - imageUris = embed.images.map(img => ({ 250 + imageUris = embed.images.map((img, i) => ({ 238 251 uri: img.fullsize, 239 252 width: img.aspectRatio?.width ?? 1000, 240 253 height: img.aspectRatio?.height ?? 1000, 241 254 altText: img.alt, 255 + blobRef: recordImages[i]?.image, 242 256 })) 243 257 } else if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') { 244 258 const embed = post.embed as AppBskyEmbedRecordWithMedia.View 245 259 if (embed.media.$type === 'app.bsky.embed.images#view') { 246 260 const images = embed.media as AppBskyEmbedImages.View 247 - imageUris = images.images.map(img => ({ 261 + imageUris = images.images.map((img, i) => ({ 248 262 uri: img.fullsize, 249 263 width: img.aspectRatio?.width ?? 1000, 250 264 height: img.aspectRatio?.height ?? 1000, 251 265 altText: img.alt, 266 + blobRef: recordImages[i]?.image, 252 267 })) 253 268 } 254 269 } ··· 297 312 } 298 313 } 299 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 + 300 352 openComposer({ 301 353 text: record.text, 302 354 imageUris, 355 + videoUri, 303 356 onPost: () => { 304 357 onDeletePost() 305 358 }, ··· 606 659 control={redraftPromptControl} 607 660 title={_(msg`Redraft this skeet?`)} 608 661 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.)`, 662 + msg`This will delete the original skeet and open the composer with its content.`, 610 663 )} 611 664 onConfirm={onConfirmRedraft} 612 665 confirmButtonCta={_(msg`Redraft`)}
+20 -4
src/lib/api/index.ts
··· 324 324 onStateChange?.(t`Uploading images...`) 325 325 const images: AppBskyEmbedImages.Image[] = await Promise.all( 326 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 + } 327 338 logger.debug(`Compressing image #${i}`) 328 339 const {path, width, height, mime} = await compressImage(image) 329 340 logger.debug(`Uploading image #${i}`) ··· 356 367 }), 357 368 ) 358 369 359 - // lexicon numbers must be floats 360 - const width = Math.round(videoDraft.asset.width) 361 - const height = Math.round(videoDraft.asset.height) 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 + ) 362 378 363 379 // aspect ratio values must be >0 - better to leave as unset otherwise 364 380 // posting will fail if aspect ratio is set to 0 ··· 366 382 367 383 if (!aspectRatio) { 368 384 logger.error( 369 - `Invalid aspect ratio - got { width: ${videoDraft.asset.width}, height: ${videoDraft.asset.height} }`, 385 + `Invalid aspect ratio - got { width: ${width}, height: ${height} }`, 370 386 ) 371 387 } 372 388
+5 -2
src/state/gallery.ts
··· 1 + import {type BlobRef} from '@atproto/api' 1 2 import { 2 3 cacheDirectory, 3 4 deleteAsync, ··· 37 38 type ComposerImageBase = { 38 39 alt: string 39 40 source: ImageSource 41 + blobRef?: BlobRef 40 42 } 41 43 type ComposerImageWithoutTransformation = ComposerImageBase & { 42 44 transformed?: undefined ··· 81 83 width: number 82 84 height: number 83 85 altText?: string 86 + blobRef?: BlobRef 84 87 } 85 88 86 89 export function createInitialImages( 87 90 uris: InitialImage[] = [], 88 91 ): ComposerImageWithoutTransformation[] { 89 - return uris.map(({uri, width, height, altText = ''}) => { 92 + return uris.map(({uri, width, height, altText = '', blobRef}) => { 90 93 return { 91 94 alt: altText, 92 95 source: { ··· 96 99 height: height, 97 100 mime: 'image/jpeg', 98 101 }, 102 + blobRef, 99 103 } 100 104 }) 101 105 } ··· 197 201 198 202 export async function compressImage(img: ComposerImage): Promise<PickerImage> { 199 203 const source = img.transformed || img.source 200 - 201 204 const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) 202 205 203 206 let minQualityPercentage = 0
+3 -2
src/state/shell/composer/index.tsx
··· 3 3 type AppBskyActorDefs, 4 4 type AppBskyFeedDefs, 5 5 type AppBskyUnspeccedGetPostThreadV2, 6 + type BlobRef, 6 7 type ModerationDecision, 7 8 } from '@atproto/api' 8 9 import {msg} from '@lingui/macro' ··· 41 42 mention?: string // handle of user to mention 42 43 openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void 43 44 text?: string 44 - imageUris?: {uri: string; width: number; height: number; altText?: string}[] 45 - videoUri?: {uri: string; width: number; height: number} 45 + imageUris?: {uri: string; width: number; height: number; altText?: string; blobRef?: BlobRef}[] 46 + videoUri?: {uri: string; width: number; height: number; blobRef?: BlobRef; altText?: string} 46 47 } 47 48 48 49 type StateContext = ComposerOpts | undefined
+18 -5
src/view/com/composer/Composer.tsx
··· 119 119 import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' 120 120 import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' 121 121 import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress' 122 + import {VideoEmbedRedraft} from '#/view/com/composer/videos/VideoEmbedRedraft' 122 123 import {Text} from '#/view/com/util/text/Text' 123 124 import {UserAvatar} from '#/view/com/util/UserAvatar' 124 125 import {atoms as a, native, useTheme, web} from '#/alf' ··· 126 127 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' 127 128 import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' 128 129 import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 130 + import {Play_Stroke2_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' 129 131 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 130 132 import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed' 131 133 import * as Prompt from '#/components/Prompt' ··· 238 240 239 241 const [composerState, composerDispatch] = useReducer( 240 242 composerReducer, 241 - { 243 + createComposerState({ 242 244 initImageUris, 243 245 initQuoteUri: initQuote?.uri, 244 246 initText, 245 247 initMention, 246 248 initInteractionSettings: preferences?.postInteractionSettings, 247 - }, 248 - createComposerState, 249 + initVideoUri, 250 + }), 249 251 ) 250 252 251 253 const thread = composerState.thread ··· 297 299 ) 298 300 299 301 const onInitVideo = useNonReactiveCallback(() => { 300 - if (initVideoUri) { 302 + if (initVideoUri && !initVideoUri.blobRef) { 301 303 selectVideo(activePost.id, initVideoUri) 302 304 } 303 305 }) ··· 1172 1174 canRemoveQuote: boolean 1173 1175 isActivePost: boolean 1174 1176 }) { 1177 + const theme = useTheme() 1175 1178 const video = embed.media?.type === 'video' ? embed.media.video : null 1176 1179 return ( 1177 1180 <> ··· 1226 1229 clear={clearVideo} 1227 1230 /> 1228 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 + )} 1229 1242 <SubtitleDialogBtn 1230 1243 defaultAltText={video.altText} 1231 1244 saveAltText={altText => ··· 1239 1252 }) 1240 1253 } 1241 1254 captions={video.captions} 1242 - setCaptions={updater => { 1255 + setCaptions={(updater: (captions: any[]) => any[]) => { 1243 1256 dispatch({ 1244 1257 type: 'embed_update_video', 1245 1258 videoAction: {
+20 -2
src/view/com/composer/state/composer.ts
··· 18 18 postUriToRelativePath, 19 19 toBskyAppUrl, 20 20 } from '#/lib/strings/url-helpers' 21 - import {type ComposerImage, createInitialImages} from '#/state/gallery' 21 + import { 22 + type ComposerImage, 23 + createInitialImages, 24 + } from '#/state/gallery' 22 25 import {createPostgateRecord} from '#/state/queries/postgate/util' 23 26 import {type Gif} from '#/state/queries/tenor' 24 27 import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate' ··· 30 33 } from '#/view/com/composer/text-input/text-input-util' 31 34 import { 32 35 createVideoState, 36 + createRedraftVideoState, 37 + type RedraftState, 33 38 type VideoAction, 34 39 videoReducer, 35 40 type VideoState, ··· 491 496 initImageUris, 492 497 initQuoteUri, 493 498 initInteractionSettings, 499 + initVideoUri, 494 500 }: { 495 501 initText: string | undefined 496 502 initMention: string | undefined ··· 499 505 initInteractionSettings: 500 506 | BskyPreferences['postInteractionSettings'] 501 507 | undefined 508 + initVideoUri?: ComposerOpts['videoUri'] 502 509 }): ComposerState { 503 - let media: ImagesMedia | undefined 510 + let media: ImagesMedia | VideoMedia | undefined 504 511 if (initImageUris?.length) { 505 512 media = { 506 513 type: 'images', 507 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 + }), 508 526 } 509 527 } 510 528 let quote: Link | undefined
+36
src/view/com/composer/state/video.ts
··· 130 130 captions: CaptionsTrack[] 131 131 } 132 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 + 133 147 export type VideoState = 134 148 | ErrorState 135 149 | CompressingState 136 150 | UploadingState 137 151 | ProcessingState 138 152 | DoneState 153 + | RedraftState 139 154 140 155 export function createVideoState( 141 156 asset: ImagePickerAsset, ··· 148 163 asset, 149 164 altText: '', 150 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, 151 187 } 152 188 } 153 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 110 openEmojiPicker={onOpenPicker} 111 111 text={state.text} 112 112 imageUris={state.imageUris} 113 + videoUri={state.videoUri} 113 114 /> 114 115 </View> 115 116 <EmojiPicker state={pickerState} close={onClosePicker} />