[Sheets] [Pt. 15] Fix up dialogs inside the composer (#5589)

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: dan <dan.abramov@gmail.com>
This commit is contained in:
Hailey
2024-10-02 22:08:36 -07:00
committed by GitHub
parent 058ddb0c53
commit 89ac12516d
14 changed files with 954 additions and 829 deletions
+344 -296
View File
@@ -36,6 +36,7 @@ import Animated, {
ZoomOut,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {ImagePickerAsset} from 'expo-image-picker'
import {
AppBskyFeedDefs,
AppBskyFeedGetPostThread,
@@ -82,9 +83,10 @@ import {Gif} from '#/state/queries/tenor'
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
import {
createVideoState,
processVideo,
State as VideoUploadState,
useUploadVideo,
VideoUploadDispatch,
videoReducer,
} from '#/state/queries/video/video'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
@@ -118,10 +120,13 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {createPortalGroup} from '#/components/Portal'
import * as Prompt from '#/components/Prompt'
import {Text as NewText} from '#/components/Typography'
import {composerReducer, createComposerState} from './state'
const Portal = createPortalGroup()
const MAX_IMAGES = 4
type CancelRef = {
@@ -147,7 +152,8 @@ export const ComposePost = ({
}) => {
const {currentAccount} = useSession()
const agent = useAgent()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
const currentDid = currentAccount!.did
const {data: currentProfile} = useProfileQuery({did: currentDid})
const {isModalActive} = useModals()
const {closeComposer} = useComposerControls()
const pal = usePalette('default')
@@ -189,21 +195,50 @@ export const ComposePost = ({
const [videoAltText, setVideoAltText] = useState('')
const [captions, setCaptions] = useState<{lang: string; file: File}[]>([])
const {
selectVideo,
clearVideo,
state: videoUploadState,
updateVideoDimensions,
dispatch: videoUploadDispatch,
} = useUploadVideo({
setStatus: setProcessingState,
onSuccess: () => {
if (publishOnUpload) {
onPressPublish(true)
}
const [videoUploadState, videoDispatch] = useReducer(
videoReducer,
undefined,
createVideoState,
)
const selectVideo = React.useCallback(
(asset: ImagePickerAsset) => {
processVideo(
asset,
videoDispatch,
agent,
currentDid,
videoUploadState.abortController.signal,
_,
)
},
initialVideoUri: initVideoUri,
})
[_, videoUploadState.abortController, videoDispatch, agent, currentDid],
)
// Whenever we receive an initial video uri, we should immediately run compression if necessary
useEffect(() => {
if (initVideoUri) {
selectVideo({uri: initVideoUri} as ImagePickerAsset)
}
}, [initVideoUri, selectVideo])
const clearVideo = React.useCallback(() => {
videoUploadState.abortController.abort()
videoDispatch({type: 'to_idle', nextController: new AbortController()})
}, [videoUploadState.abortController, videoDispatch])
const updateVideoDimensions = useCallback(
(width: number, height: number) => {
videoDispatch({
type: 'update_dimensions',
width,
height,
signal: videoUploadState.abortController.signal,
})
},
[videoUploadState.abortController],
)
const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video)
const [publishOnUpload, setPublishOnUpload] = useState(false)
@@ -400,19 +435,18 @@ export const ComposePost = ({
postgate,
onStateChange: setProcessingState,
langs: toPostLanguages(langPrefs.postLanguage),
video: videoUploadState.pendingPublish?.blobRef
? {
blobRef: videoUploadState.pendingPublish.blobRef,
altText: videoAltText,
captions: captions,
aspectRatio: videoUploadState.asset
? {
width: videoUploadState.asset?.width,
height: videoUploadState.asset?.height,
}
: undefined,
}
: undefined,
video:
videoUploadState.status === 'done'
? {
blobRef: videoUploadState.pendingPublish.blobRef,
altText: videoAltText,
captions: captions,
aspectRatio: {
width: videoUploadState.asset.width,
height: videoUploadState.asset.height,
},
}
: undefined,
})
).uri
try {
@@ -598,272 +632,285 @@ export const ComposePost = ({
const keyboardVerticalOffset = useKeyboardVerticalOffset()
return (
<KeyboardAvoidingView
testID="composePostView"
behavior={isIOS ? 'padding' : 'height'}
keyboardVerticalOffset={keyboardVerticalOffset}
style={a.flex_1}>
<View style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal>
<Animated.View
style={topBarAnimatedStyle}
layout={native(LinearTransition)}>
<View style={styles.topbarInner}>
<Button
label={_(msg`Cancel`)}
variant="ghost"
color="primary"
shape="default"
size="small"
style={[
a.rounded_full,
a.py_sm,
{paddingLeft: 7, paddingRight: 7},
]}
onPress={onPressCancel}
accessibilityHint={_(
msg`Closes post composer and discards post draft`,
)}>
<ButtonText style={[a.text_md]}>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
{isProcessing ? (
<>
<Text style={pal.textLight}>{processingState}</Text>
<View style={styles.postBtn}>
<ActivityIndicator />
</View>
</>
) : (
<View style={[styles.postBtnWrapper]}>
<LabelsBtn
labels={labels}
onChange={setLabels}
hasMedia={hasMedia}
/>
{canPost ? (
<Button
testID="composerPublishBtn"
label={
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
}
variant="solid"
color="primary"
shape="default"
size="small"
style={[a.rounded_full, a.py_sm]}
onPress={() => onPressPublish()}
disabled={
videoUploadState.status !== 'idle' && publishOnUpload
}>
<ButtonText style={[a.text_md]}>
{replyTo ? (
<Trans context="action">Reply</Trans>
) : (
<Trans context="action">Post</Trans>
)}
</ButtonText>
</Button>
) : (
<View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>
<Trans context="action">Post</Trans>
</Text>
<Portal.Provider>
<KeyboardAvoidingView
testID="composePostView"
behavior={isIOS ? 'padding' : 'height'}
keyboardVerticalOffset={keyboardVerticalOffset}
style={a.flex_1}>
<View
style={[a.flex_1, viewStyles]}
aria-modal
accessibilityViewIsModal>
<Animated.View
style={topBarAnimatedStyle}
layout={native(LinearTransition)}>
<View style={styles.topbarInner}>
<Button
label={_(msg`Cancel`)}
variant="ghost"
color="primary"
shape="default"
size="small"
style={[
a.rounded_full,
a.py_sm,
{paddingLeft: 7, paddingRight: 7},
]}
onPress={onPressCancel}
accessibilityHint={_(
msg`Closes post composer and discards post draft`,
)}>
<ButtonText style={[a.text_md]}>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
{isProcessing ? (
<>
<Text style={pal.textLight}>{processingState}</Text>
<View style={styles.postBtn}>
<ActivityIndicator />
</View>
)}
</>
) : (
<View style={[styles.postBtnWrapper]}>
<LabelsBtn
labels={labels}
onChange={setLabels}
hasMedia={hasMedia}
/>
{canPost ? (
<Button
testID="composerPublishBtn"
label={
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
}
variant="solid"
color="primary"
shape="default"
size="small"
style={[a.rounded_full, a.py_sm]}
onPress={() => onPressPublish()}
disabled={
videoUploadState.status !== 'idle' && publishOnUpload
}>
<ButtonText style={[a.text_md]}>
{replyTo ? (
<Trans context="action">Reply</Trans>
) : (
<Trans context="action">Post</Trans>
)}
</ButtonText>
</Button>
) : (
<View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>
<Trans context="action">Post</Trans>
</Text>
</View>
)}
</View>
)}
</View>
{isAltTextRequiredAndMissing && (
<View style={[styles.reminderLine, pal.viewLight]}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
/>
</View>
<Text style={[pal.text, a.flex_1]}>
<Trans>One or more images is missing alt text.</Trans>
</Text>
</View>
)}
</View>
<ErrorBanner
error={error}
videoUploadState={videoUploadState}
clearError={() => setError('')}
clearVideo={clearVideo}
/>
</Animated.View>
<Animated.ScrollView
layout={native(LinearTransition)}
onScroll={scrollHandler}
style={styles.scrollView}
keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
{isAltTextRequiredAndMissing && (
<View style={[styles.reminderLine, pal.viewLight]}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
<View
style={[
styles.textInputLayout,
isNative && styles.textInputLayoutMobile,
]}>
<UserAvatar
avatar={currentProfile?.avatar}
size={50}
type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
/>
<TextInput
ref={textInput}
richtext={richtext}
placeholder={selectTextInputPlaceholder}
autoFocus
setRichText={setRichText}
onPhotoPasted={onPhotoPasted}
onPressPublish={() => onPressPublish()}
onNewLink={onNewLink}
onError={setError}
accessible={true}
accessibilityLabel={_(msg`Write post`)}
accessibilityHint={_(
msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
)}
/>
</View>
<Gallery
images={images}
dispatch={dispatch}
Portal={Portal.Portal}
/>
{images.length === 0 && extLink && (
<View style={a.relative}>
<ExternalEmbed
link={extLink}
gif={extGif}
onRemove={() => {
setExtLink(undefined)
setExtGif(undefined)
}}
/>
<GifAltText
link={extLink}
gif={extGif}
onSubmit={handleChangeGifAltText}
Portal={Portal.Portal}
/>
</View>
<Text style={[pal.text, a.flex_1]}>
<Trans>One or more images is missing alt text.</Trans>
</Text>
)}
<LayoutAnimationConfig skipExiting>
{hasVideo && (
<Animated.View
style={[a.w_full, a.mt_lg]}
entering={native(ZoomIn)}
exiting={native(ZoomOut)}>
{videoUploadState.asset &&
(videoUploadState.status === 'compressing' ? (
<VideoTranscodeProgress
asset={videoUploadState.asset}
progress={videoUploadState.progress}
clear={clearVideo}
/>
) : videoUploadState.video ? (
<VideoPreview
asset={videoUploadState.asset}
video={videoUploadState.video}
setDimensions={updateVideoDimensions}
clear={clearVideo}
/>
) : null)}
<SubtitleDialogBtn
defaultAltText={videoAltText}
saveAltText={setVideoAltText}
captions={captions}
setCaptions={setCaptions}
Portal={Portal.Portal}
/>
</Animated.View>
)}
</LayoutAnimationConfig>
<View style={!hasVideo ? [a.mt_md] : []}>
{quote ? (
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} />
</View>
{quote.uri !== initQuote?.uri && (
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View>
) : null}
</View>
)}
<ErrorBanner
error={error}
videoUploadState={videoUploadState}
clearError={() => setError('')}
videoUploadDispatch={videoUploadDispatch}
/>
</Animated.View>
<Animated.ScrollView
layout={native(LinearTransition)}
onScroll={scrollHandler}
style={styles.scrollView}
keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
</Animated.ScrollView>
<SuggestedLanguage text={richtext.text} />
{replyTo ? null : (
<ThreadgateBtn
postgate={postgate}
onChangePostgate={setPostgate}
threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={
onChangeThreadgateAllowUISettings
}
style={bottomBarAnimatedStyle}
Portal={Portal.Portal}
/>
)}
<View
style={[
styles.textInputLayout,
isNative && styles.textInputLayoutMobile,
t.atoms.bg,
t.atoms.border_contrast_medium,
styles.bottomBar,
]}>
<UserAvatar
avatar={currentProfile?.avatar}
size={50}
type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
/>
<TextInput
ref={textInput}
richtext={richtext}
placeholder={selectTextInputPlaceholder}
autoFocus
setRichText={setRichText}
onPhotoPasted={onPhotoPasted}
onPressPublish={() => onPressPublish()}
onNewLink={onNewLink}
onError={setError}
accessible={true}
accessibilityLabel={_(msg`Write post`)}
accessibilityHint={_(
msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
)}
/>
</View>
<Gallery images={images} dispatch={dispatch} />
{images.length === 0 && extLink && (
<View style={a.relative}>
<ExternalEmbed
link={extLink}
gif={extGif}
onRemove={() => {
setExtLink(undefined)
setExtGif(undefined)
}}
/>
<GifAltText
link={extLink}
gif={extGif}
onSubmit={handleChangeGifAltText}
/>
</View>
)}
<LayoutAnimationConfig skipExiting>
{hasVideo && (
<Animated.View
style={[a.w_full, a.mt_lg]}
entering={native(ZoomIn)}
exiting={native(ZoomOut)}>
{videoUploadState.asset &&
(videoUploadState.status === 'compressing' ? (
<VideoTranscodeProgress
asset={videoUploadState.asset}
progress={videoUploadState.progress}
clear={clearVideo}
/>
) : videoUploadState.video ? (
<VideoPreview
asset={videoUploadState.asset}
video={videoUploadState.video}
setDimensions={updateVideoDimensions}
clear={clearVideo}
/>
) : null)}
<SubtitleDialogBtn
defaultAltText={videoAltText}
saveAltText={setVideoAltText}
captions={captions}
setCaptions={setCaptions}
{videoUploadState.status !== 'idle' &&
videoUploadState.status !== 'done' ? (
<VideoUploadToolbar state={videoUploadState} />
) : (
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn
size={images.length}
disabled={!canSelectImages}
onAdd={onImageAdd}
/>
</Animated.View>
<SelectVideoBtn
onSelectVideo={selectVideo}
disabled={!canSelectImages}
setError={setError}
/>
<OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
<SelectGifBtn
onClose={focusTextInput}
onSelectGif={onSelectGif}
disabled={hasMedia}
/>
{!isMobile ? (
<Button
onPress={onEmojiButtonPress}
style={a.p_sm}
label={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Open emoji picker`)}
variant="ghost"
shape="round"
color="primary">
<EmojiSmile size="lg" />
</Button>
) : null}
</ToolbarWrapper>
)}
</LayoutAnimationConfig>
<View style={!hasVideo ? [a.mt_md] : []}>
{quote ? (
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} />
</View>
{quote.uri !== initQuote?.uri && (
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View>
) : null}
<View style={a.flex_1} />
<SelectLangBtn />
<CharProgress count={graphemeLength} />
</View>
</Animated.ScrollView>
<SuggestedLanguage text={richtext.text} />
{replyTo ? null : (
<ThreadgateBtn
postgate={postgate}
onChangePostgate={setPostgate}
threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={
onChangeThreadgateAllowUISettings
}
style={bottomBarAnimatedStyle}
/>
)}
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_medium,
styles.bottomBar,
]}>
{videoUploadState.status !== 'idle' &&
videoUploadState.status !== 'done' ? (
<VideoUploadToolbar state={videoUploadState} />
) : (
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn
size={images.length}
disabled={!canSelectImages}
onAdd={onImageAdd}
/>
<SelectVideoBtn
onSelectVideo={selectVideo}
disabled={!canSelectImages}
setError={setError}
/>
<OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
<SelectGifBtn
onClose={focusTextInput}
onSelectGif={onSelectGif}
disabled={hasMedia}
/>
{!isMobile ? (
<Button
onPress={onEmojiButtonPress}
style={a.p_sm}
label={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Open emoji picker`)}
variant="ghost"
shape="round"
color="primary">
<EmojiSmile size="lg" />
</Button>
) : null}
</ToolbarWrapper>
)}
<View style={a.flex_1} />
<SelectLangBtn />
<CharProgress count={graphemeLength} />
</View>
</View>
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard draft?`)}
description={_(msg`Are you sure you'd like to discard this draft?`)}
onConfirm={onClose}
confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative"
withoutPortal={true}
/>
</KeyboardAvoidingView>
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard draft?`)}
description={_(msg`Are you sure you'd like to discard this draft?`)}
onConfirm={onClose}
confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative"
Portal={Portal.Portal}
/>
</KeyboardAvoidingView>
<Portal.Outlet />
</Portal.Provider>
)
}
@@ -1084,25 +1131,25 @@ function ErrorBanner({
error: standardError,
videoUploadState,
clearError,
videoUploadDispatch,
clearVideo,
}: {
error: string
videoUploadState: VideoUploadState
clearError: () => void
videoUploadDispatch: VideoUploadDispatch
clearVideo: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const videoError =
videoUploadState.status !== 'idle' ? videoUploadState.error : undefined
videoUploadState.status === 'error' ? videoUploadState.error : undefined
const error = standardError || videoError
const onClearError = () => {
if (standardError) {
clearError()
} else {
videoUploadDispatch({type: 'Reset'})
clearVideo()
}
}
@@ -1137,7 +1184,7 @@ function ErrorBanner({
<ButtonIcon icon={X} />
</Button>
</View>
{videoError && videoUploadState.jobStatus?.jobId && (
{videoError && videoUploadState.jobId && (
<NewText
style={[
{paddingLeft: 28},
@@ -1146,7 +1193,7 @@ function ErrorBanner({
a.leading_snug,
t.atoms.text_contrast_low,
]}>
<Trans>Job ID: {videoUploadState.jobStatus.jobId}</Trans>
<Trans>Job ID: {videoUploadState.jobId}</Trans>
</NewText>
)}
</View>
@@ -1175,9 +1222,7 @@ function ToolbarWrapper({
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
const t = useTheme()
const {_} = useLingui()
const progress = state.jobStatus?.progress
? state.jobStatus.progress / 100
: state.progress
const progress = state.progress
const shouldRotate =
state.status === 'processing' && (progress === 0 || progress === 1)
let wheelProgress = shouldRotate ? 0.33 : progress
@@ -1213,16 +1258,15 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
case 'processing':
text = _('Processing video...')
break
case 'error':
text = _('Error')
wheelProgress = 100
break
case 'done':
text = _('Video uploaded')
break
}
if (state.error) {
text = _('Error')
wheelProgress = 100
}
return (
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
<Animated.View style={[animatedStyle]}>
@@ -1230,7 +1274,11 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
size={30}
borderWidth={1}
borderColor={t.atoms.border_contrast_low.borderColor}
color={state.error ? t.palette.negative_500 : t.palette.primary_500}
color={
state.status === 'error'
? t.palette.negative_500
: t.palette.primary_500
}
progress={wheelProgress}
/>
</Animated.View>
+4 -1
View File
@@ -19,6 +19,7 @@ import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography'
import {GifEmbed} from '../util/post-embeds/GifEmbed'
import {AltTextReminder} from './photos/Gallery'
@@ -27,10 +28,12 @@ export function GifAltText({
link: linkProp,
gif,
onSubmit,
Portal,
}: {
link: ExternalEmbedDraft
gif?: Gif
onSubmit: (alt: string) => void
Portal: PortalComponent
}) {
const control = Dialog.useDialogControl()
const {_} = useLingui()
@@ -95,7 +98,7 @@ export function GifAltText({
<AltTextReminder />
<Dialog.Outer control={control}>
<Dialog.Outer control={control} Portal={Portal}>
<AltTextInner
onSubmit={onPressSubmit}
link={link}
+12 -1
View File
@@ -21,6 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery'
import {Text} from '#/view/com/util/text/Text'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {PortalComponent} from '#/components/Portal'
import {ComposerAction} from '../state'
import {EditImageDialog} from './EditImageDialog'
import {ImageAltTextDialog} from './ImageAltTextDialog'
@@ -30,6 +31,7 @@ const IMAGE_GAP = 8
interface GalleryProps {
images: ComposerImage[]
dispatch: (action: ComposerAction) => void
Portal: PortalComponent
}
export let Gallery = (props: GalleryProps): React.ReactNode => {
@@ -57,7 +59,12 @@ interface GalleryInnerProps extends GalleryProps {
containerInfo: Dimensions
}
const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
const GalleryInner = ({
images,
containerInfo,
dispatch,
Portal,
}: GalleryInnerProps) => {
const {isMobile} = useWebMediaQueries()
const {altTextControlStyle, imageControlsStyle, imageStyle} =
@@ -111,6 +118,7 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
onRemove={() => {
dispatch({type: 'embed_remove_image', image})
}}
Portal={Portal}
/>
)
})}
@@ -127,6 +135,7 @@ type GalleryItemProps = {
imageStyle?: ViewStyle
onChange: (next: ComposerImage) => void
onRemove: () => void
Portal: PortalComponent
}
const GalleryItem = ({
@@ -136,6 +145,7 @@ const GalleryItem = ({
imageStyle,
onChange,
onRemove,
Portal,
}: GalleryItemProps): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
@@ -230,6 +240,7 @@ const GalleryItem = ({
control={altTextControl}
image={image}
onChange={onChange}
Portal={Portal}
/>
<EditImageDialog
@@ -11,17 +11,19 @@ import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography'
type Props = {
control: Dialog.DialogOuterProps['control']
image: ComposerImage
onChange: (next: ComposerImage) => void
Portal: PortalComponent
}
export const ImageAltTextDialog = (props: Props): React.ReactNode => {
return (
<Dialog.Outer control={props.control}>
<Dialog.Outer control={props.control} Portal={props.Portal}>
<ImageAltTextInner {...props} />
</Dialog.Outer>
)
@@ -13,6 +13,7 @@ import * as Dialog from '#/components/Dialog'
import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {PortalComponent} from '#/components/Portal'
export function ThreadgateBtn({
postgate,
@@ -20,6 +21,7 @@ export function ThreadgateBtn({
threadgateAllowUISettings,
onChangeThreadgateAllowUISettings,
style,
Portal,
}: {
postgate: AppBskyFeedPostgate.Record
onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
@@ -28,6 +30,8 @@ export function ThreadgateBtn({
onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
style?: StyleProp<AnimatedStyle<ViewStyle>>
Portal: PortalComponent
}) {
const {_} = useLingui()
const t = useTheme()
@@ -77,6 +81,7 @@ export function ThreadgateBtn({
onChangePostgate={onChangePostgate}
threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings}
Portal={Portal}
/>
</>
)
+36 -11
View File
@@ -9,12 +9,14 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
import {BSKY_SERVICE} from '#/lib/constants'
import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
import {getHostnameFromUrl} from '#/lib/strings/url-helpers'
import {isWeb} from '#/platform/detection'
import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {useSession} from '#/state/session'
import {BSKY_SERVICE} from 'lib/constants'
import {getHostnameFromUrl} from 'lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
@@ -58,16 +60,25 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
UIImagePickerPreferredAssetRepresentationMode.Current,
})
if (response.assets && response.assets.length > 0) {
if (isNative) {
if (typeof response.assets[0].duration !== 'number')
throw Error('Asset is not a video')
if (response.assets[0].duration > VIDEO_MAX_DURATION) {
setError(_(msg`Videos must be less than 60 seconds long`))
return
}
}
const asset = response.assets[0]
try {
onSelectVideo(response.assets[0])
if (isWeb) {
// compression step on native converts to mp4, so no need to check there
const mimeType = getMimeType(asset)
if (
!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)
) {
throw Error(_(msg`Unsupported video type: ${mimeType}`))
}
} else {
if (typeof asset.duration !== 'number') {
throw Error('Asset is not a video')
}
if (asset.duration > VIDEO_MAX_DURATION) {
throw Error(_(msg`Videos must be less than 60 seconds long`))
}
}
onSelectVideo(asset)
} catch (err) {
if (err instanceof Error) {
setError(err.message)
@@ -132,3 +143,17 @@ function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
/>
)
}
function getMimeType(asset: ImagePickerAsset) {
if (isWeb) {
const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
if (!mimeType) {
throw new Error('Could not determine mime type')
}
return mimeType
}
if (!asset.mimeType) {
throw new Error('Could not determine mime type')
}
return asset.mimeType
}
@@ -17,6 +17,7 @@ import {CC_Stroke2_Corner0_Rounded as CCIcon} from '#/components/icons/CC'
import {PageText_Stroke2_Corner0_Rounded as PageTextIcon} from '#/components/icons/PageText'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography'
import {SubtitleFilePicker} from './SubtitleFilePicker'
@@ -29,6 +30,7 @@ interface Props {
setCaptions: React.Dispatch<
React.SetStateAction<{lang: string; file: File}[]>
>
Portal: PortalComponent
}
export function SubtitleDialogBtn(props: Props) {
@@ -56,7 +58,7 @@ export function SubtitleDialogBtn(props: Props) {
{isWeb ? <Trans>Captions & alt text</Trans> : <Trans>Alt text</Trans>}
</ButtonText>
</Button>
<Dialog.Outer control={control}>
<Dialog.Outer control={control} Portal={props.Portal}>
<SubtitleDialogInner {...props} />
</Dialog.Outer>
</View>