Fix redrafting for images and video #10

merged
opened by scanash.com targeting main
Changed files
+216 -18
src
components
PostControls
lib
api
state
shell
composer
view
+56 -3
src/components/PostControls/PostMenu/PostMenuItems.tsx
···
type AppBskyFeedThreadgate,
AtUri,
type RichText as RichTextAPI,
+
type BlobRef,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
···
width: number
height: number
altText?: string
+
blobRef?: AppBskyEmbedImages.Image['image']
}[] = []
+
const recordEmbed = record.embed
+
let recordImages: AppBskyEmbedImages.Image[] = []
+
if (recordEmbed?.$type === 'app.bsky.embed.images') {
+
recordImages = (recordEmbed as AppBskyEmbedImages.Main).images
+
} else if (recordEmbed?.$type === 'app.bsky.embed.recordWithMedia') {
+
const media = (recordEmbed as AppBskyEmbedRecordWithMedia.Main).media
+
if (media.$type === 'app.bsky.embed.images') {
+
recordImages = (media as AppBskyEmbedImages.Main).images
+
}
+
}
+
if (post.embed?.$type === 'app.bsky.embed.images#view') {
const embed = post.embed as AppBskyEmbedImages.View
-
imageUris = embed.images.map(img => ({
+
imageUris = embed.images.map((img, i) => ({
uri: img.fullsize,
width: img.aspectRatio?.width ?? 1000,
height: img.aspectRatio?.height ?? 1000,
altText: img.alt,
+
blobRef: recordImages[i]?.image,
}))
} else if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') {
const embed = post.embed as AppBskyEmbedRecordWithMedia.View
if (embed.media.$type === 'app.bsky.embed.images#view') {
const images = embed.media as AppBskyEmbedImages.View
-
imageUris = images.images.map(img => ({
+
imageUris = images.images.map((img, i) => ({
uri: img.fullsize,
width: img.aspectRatio?.width ?? 1000,
height: img.aspectRatio?.height ?? 1000,
altText: img.alt,
+
blobRef: recordImages[i]?.image,
}))
}
}
···
}
}
+
let videoUri: {uri: string; width: number; height: number; blobRef?: BlobRef; altText?: string} | undefined
+
let recordVideo: AppBskyEmbedVideo.Main | undefined
+
+
if (recordEmbed?.$type === 'app.bsky.embed.video') {
+
recordVideo = recordEmbed as AppBskyEmbedVideo.Main
+
} else if (recordEmbed?.$type === 'app.bsky.embed.recordWithMedia') {
+
const media = (recordEmbed as AppBskyEmbedRecordWithMedia.Main).media
+
if (media.$type === 'app.bsky.embed.video') {
+
recordVideo = media as AppBskyEmbedVideo.Main
+
}
+
}
+
+
if (post.embed?.$type === 'app.bsky.embed.video#view') {
+
const embed = post.embed as AppBskyEmbedVideo.View
+
if (recordVideo) {
+
videoUri = {
+
uri: embed.playlist || '',
+
width: embed.aspectRatio?.width ?? 1000,
+
height: embed.aspectRatio?.height ?? 1000,
+
blobRef: recordVideo.video,
+
altText: embed.alt || '',
+
}
+
}
+
} else if (post.embed?.$type === 'app.bsky.embed.recordWithMedia#view') {
+
const embed = post.embed as AppBskyEmbedRecordWithMedia.View
+
if (embed.media.$type === 'app.bsky.embed.video#view' && recordVideo) {
+
const video = embed.media as AppBskyEmbedVideo.View
+
videoUri = {
+
uri: video.playlist || '',
+
width: video.aspectRatio?.width ?? 1000,
+
height: video.aspectRatio?.height ?? 1000,
+
blobRef: recordVideo.video,
+
altText: video.alt || '',
+
}
+
}
+
}
+
openComposer({
text: record.text,
imageUris,
+
videoUri,
onPost: () => {
onDeletePost()
},
···
control={redraftPromptControl}
title={_(msg`Redraft this skeet?`)}
description={_(
-
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.)`,
+
msg`This will delete the original skeet and open the composer with its content.`,
)}
onConfirm={onConfirmRedraft}
confirmButtonCta={_(msg`Redraft`)}
+20 -4
src/lib/api/index.ts
···
onStateChange?.(t`Uploading images...`)
const images: AppBskyEmbedImages.Image[] = await Promise.all(
imagesDraft.map(async (image, i) => {
+
if (image.blobRef) {
+
logger.debug(`Reusing existing blob for image #${i}`)
+
return {
+
image: image.blobRef,
+
alt: image.alt,
+
aspectRatio: {
+
width: image.source.width,
+
height: image.source.height,
+
},
+
}
+
}
logger.debug(`Compressing image #${i}`)
const {path, width, height, mime} = await compressImage(image)
logger.debug(`Uploading image #${i}`)
···
}),
)
-
// lexicon numbers must be floats
-
const width = Math.round(videoDraft.asset.width)
-
const height = Math.round(videoDraft.asset.height)
+
const width = Math.round(
+
videoDraft.asset?.width ||
+
('redraftDimensions' in videoDraft ? videoDraft.redraftDimensions.width : 1000)
+
)
+
const height = Math.round(
+
videoDraft.asset?.height ||
+
('redraftDimensions' in videoDraft ? videoDraft.redraftDimensions.height : 1000)
+
)
// aspect ratio values must be >0 - better to leave as unset otherwise
// posting will fail if aspect ratio is set to 0
···
if (!aspectRatio) {
logger.error(
-
`Invalid aspect ratio - got { width: ${videoDraft.asset.width}, height: ${videoDraft.asset.height} }`,
+
`Invalid aspect ratio - got { width: ${width}, height: ${height} }`,
)
}
+5 -2
src/state/gallery.ts
···
+
import {type BlobRef} from '@atproto/api'
import {
cacheDirectory,
deleteAsync,
···
type ComposerImageBase = {
alt: string
source: ImageSource
+
blobRef?: BlobRef
}
type ComposerImageWithoutTransformation = ComposerImageBase & {
transformed?: undefined
···
width: number
height: number
altText?: string
+
blobRef?: BlobRef
}
export function createInitialImages(
uris: InitialImage[] = [],
): ComposerImageWithoutTransformation[] {
-
return uris.map(({uri, width, height, altText = ''}) => {
+
return uris.map(({uri, width, height, altText = '', blobRef}) => {
return {
alt: altText,
source: {
···
height: height,
mime: 'image/jpeg',
},
+
blobRef,
}
})
}
···
export async function compressImage(img: ComposerImage): Promise<PickerImage> {
const source = img.transformed || img.source
-
const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
let minQualityPercentage = 0
+3 -2
src/state/shell/composer/index.tsx
···
type AppBskyActorDefs,
type AppBskyFeedDefs,
type AppBskyUnspeccedGetPostThreadV2,
+
type BlobRef,
type ModerationDecision,
} from '@atproto/api'
import {msg} from '@lingui/macro'
···
mention?: string // handle of user to mention
openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void
text?: string
-
imageUris?: {uri: string; width: number; height: number; altText?: string}[]
-
videoUri?: {uri: string; width: number; height: number}
+
imageUris?: {uri: string; width: number; height: number; altText?: string; blobRef?: BlobRef}[]
+
videoUri?: {uri: string; width: number; height: number; blobRef?: BlobRef; altText?: string}
}
type StateContext = ComposerOpts | undefined
+18 -5
src/view/com/composer/Composer.tsx
···
import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
+
import {VideoEmbedRedraft} from '#/view/com/composer/videos/VideoEmbedRedraft'
import {Text} from '#/view/com/util/text/Text'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf'
···
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
+
import {Play_Stroke2_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed'
import * as Prompt from '#/components/Prompt'
···
const [composerState, composerDispatch] = useReducer(
composerReducer,
-
{
+
createComposerState({
initImageUris,
initQuoteUri: initQuote?.uri,
initText,
initMention,
initInteractionSettings: preferences?.postInteractionSettings,
-
},
-
createComposerState,
+
initVideoUri,
+
}),
)
const thread = composerState.thread
···
)
const onInitVideo = useNonReactiveCallback(() => {
-
if (initVideoUri) {
+
if (initVideoUri && !initVideoUri.blobRef) {
selectVideo(activePost.id, initVideoUri)
}
})
···
canRemoveQuote: boolean
isActivePost: boolean
}) {
+
const theme = useTheme()
const video = embed.media?.type === 'video' ? embed.media.video : null
return (
<>
···
clear={clearVideo}
/>
) : null)}
+
{!video.asset && video.status === 'done' && 'playlistUri' in video && (
+
<View style={[a.relative, a.mt_lg]}>
+
<VideoEmbedRedraft
+
blobRef={video.pendingPublish?.blobRef!}
+
playlistUri={video.playlistUri}
+
aspectRatio={video.redraftDimensions}
+
onRemove={clearVideo}
+
/>
+
</View>
+
)}
<SubtitleDialogBtn
defaultAltText={video.altText}
saveAltText={altText =>
···
})
captions={video.captions}
-
setCaptions={updater => {
+
setCaptions={(updater: (captions: any[]) => any[]) => {
dispatch({
type: 'embed_update_video',
videoAction: {
+20 -2
src/view/com/composer/state/composer.ts
···
postUriToRelativePath,
toBskyAppUrl,
} from '#/lib/strings/url-helpers'
-
import {type ComposerImage, createInitialImages} from '#/state/gallery'
+
import {
+
type ComposerImage,
+
createInitialImages,
+
} from '#/state/gallery'
import {createPostgateRecord} from '#/state/queries/postgate/util'
import {type Gif} from '#/state/queries/tenor'
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
···
} from '#/view/com/composer/text-input/text-input-util'
import {
createVideoState,
+
createRedraftVideoState,
+
type RedraftState,
type VideoAction,
videoReducer,
type VideoState,
···
initImageUris,
initQuoteUri,
initInteractionSettings,
+
initVideoUri,
}: {
initText: string | undefined
initMention: string | undefined
···
initInteractionSettings:
| BskyPreferences['postInteractionSettings']
| undefined
+
initVideoUri?: ComposerOpts['videoUri']
}): ComposerState {
-
let media: ImagesMedia | undefined
+
let media: ImagesMedia | VideoMedia | undefined
if (initImageUris?.length) {
media = {
type: 'images',
images: createInitialImages(initImageUris),
}
+
} else if (initVideoUri?.blobRef) {
+
media = {
+
type: 'video',
+
video: createRedraftVideoState({
+
blobRef: initVideoUri.blobRef,
+
width: initVideoUri.width,
+
height: initVideoUri.height,
+
altText: initVideoUri.altText || '',
+
playlistUri: initVideoUri.uri,
+
}),
+
}
}
let quote: Link | undefined
if (initQuoteUri) {
+36
src/view/com/composer/state/video.ts
···
captions: CaptionsTrack[]
}
+
export type RedraftState = {
+
status: 'done'
+
progress: 100
+
abortController: AbortController
+
asset: null
+
video?: undefined
+
jobId?: undefined
+
pendingPublish: {blobRef: BlobRef}
+
altText: string
+
captions: CaptionsTrack[]
+
redraftDimensions: {width: number; height: number}
+
playlistUri: string
+
}
+
export type VideoState =
| ErrorState
| CompressingState
| UploadingState
| ProcessingState
| DoneState
+
| RedraftState
export function createVideoState(
asset: ImagePickerAsset,
···
}
}
+
export function createRedraftVideoState(opts: {
+
blobRef: BlobRef
+
width: number
+
height: number
+
altText?: string
+
playlistUri: string
+
}): RedraftState {
+
const noopController = new AbortController()
+
return {
+
status: 'done',
+
progress: 100,
+
abortController: noopController,
+
asset: null,
+
pendingPublish: {blobRef: opts.blobRef},
+
altText: opts.altText || '',
+
captions: [],
+
redraftDimensions: {width: opts.width, height: opts.height},
+
playlistUri: opts.playlistUri,
+
}
+
}
+
export function videoReducer(
state: VideoState,
action: VideoAction,
+57
src/view/com/composer/videos/VideoEmbedRedraft.tsx
···
+
import React from 'react'
+
import {Platform, View} from 'react-native'
+
import {type BlobRef} from '@atproto/api'
+
import {BlueskyVideoView} from '@haileyok/bluesky-video'
+
+
import {atoms as a} from '#/alf'
+
import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
+
import {VideoEmbedInnerWeb} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb'
+
+
interface Props {
+
blobRef: BlobRef
+
playlistUri: string
+
aspectRatio: {width: number; height: number}
+
onRemove: () => void
+
}
+
+
export function VideoEmbedRedraft({blobRef, playlistUri, aspectRatio, onRemove}: Props) {
+
const cidString = blobRef.ref.toString()
+
const aspectRatioValue = aspectRatio.width / aspectRatio.height || 16 / 9
+
const thumbnailUrl = playlistUri.replace('playlist.m3u8', 'thumbnail.jpg')
+
+
const mockEmbed = {
+
$type: 'app.bsky.embed.video#view' as const,
+
video: blobRef,
+
playlist: playlistUri,
+
thumbnail: thumbnailUrl,
+
aspectRatio,
+
alt: '',
+
captions: [],
+
cid: cidString,
+
}
+
+
return (
+
<View style={[a.w_full, a.rounded_sm, {aspectRatio: aspectRatioValue}]}>
+
{Platform.OS === 'web' ? (
+
<VideoEmbedInnerWeb
+
embed={mockEmbed}
+
active={false}
+
setActive={() => {}}
+
onScreen={true}
+
lastKnownTime={{current: undefined}}
+
/>
+
) : (
+
<BlueskyVideoView
+
url={playlistUri}
+
autoplay={false}
+
beginMuted={true}
+
style={[a.flex_1, a.rounded_sm]}
+
/>
+
)}
+
<ExternalEmbedRemoveBtn
+
onRemove={onRemove}
+
style={{top: 16, right: 16, position: 'absolute', zIndex: 10}}
+
/>
+
</View>
+
)
+
}
+1
src/view/shell/Composer.web.tsx
···
openEmojiPicker={onOpenPicker}
text={state.text}
imageUris={state.imageUris}
+
videoUri={state.videoUri}
/>
</View>
<EmojiPicker state={pickerState} close={onClosePicker} />