Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
122
fork

Configure Feed

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

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} />