Merge remote-tracking branch 'origin/main' into next/embeds
* origin/main: (241 commits) Nightly source-language update Update react-navigation (#5967) Update SetNewPasswordForm.tsx (#8349) Nightly source-language update bskyweb: bump indigo (several codegen updates) (#8442) Replace "Note about sharing" prompt with an inline hint (#8452) Bump version to v1.104 (#8447) show misclass button by the same logic as feedContext (#8445) change fontSize: 15 to a.text_sm (#7896) Nightly source-language update use method that's actually available on android (#8448) Release 1.103.0 prep (#8434) Add language: European Portuguese (`pt-PT`) (#8375) Small test fix tweak ordering of explore components (#8432) add metrics (#8426) invert flag for sending to statsig (#8431) Fix link to like-via-repost (#8428) Nightly source-language update Make via-repost notifs groupable (#8429) ...
This commit is contained in:
+123
-106
@@ -12,17 +12,17 @@ import {
|
||||
BackHandler,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
LayoutChangeEvent,
|
||||
type LayoutChangeEvent,
|
||||
ScrollView,
|
||||
StyleProp,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
// @ts-expect-error no type definition
|
||||
import ProgressCircle from 'react-native-progress/Circle'
|
||||
import Animated, {
|
||||
AnimatedRef,
|
||||
type AnimatedRef,
|
||||
Easing,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
@@ -41,15 +41,15 @@ import Animated, {
|
||||
ZoomOut,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetPostThread,
|
||||
BskyAgent,
|
||||
RichText,
|
||||
type AppBskyFeedGetPostThread,
|
||||
type BskyAgent,
|
||||
type RichText,
|
||||
} from '@atproto/api'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg, plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
@@ -59,10 +59,9 @@ import {until} from '#/lib/async/until'
|
||||
import {
|
||||
MAX_GRAPHEME_LENGTH,
|
||||
SUPPORTED_MIME_TYPES,
|
||||
SupportedMimeTypes,
|
||||
type SupportedMimeTypes,
|
||||
} from '#/lib/constants'
|
||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||
import {useEmail} from '#/lib/hooks/useEmail'
|
||||
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
@@ -75,7 +74,7 @@ import {logger} from '#/logger'
|
||||
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {emitPostCreated} from '#/state/events'
|
||||
import {ComposerImage, pasteImage} from '#/state/gallery'
|
||||
import {type ComposerImage, pasteImage} from '#/state/gallery'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useRequireAltTextEnabled} from '#/state/preferences'
|
||||
import {
|
||||
@@ -85,10 +84,10 @@ import {
|
||||
} from '#/state/preferences/languages'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {Gif} from '#/state/queries/tenor'
|
||||
import {type Gif} from '#/state/queries/tenor'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {ComposerOpts} from '#/state/shell/composer'
|
||||
import {type ComposerOpts} from '#/state/shell/composer'
|
||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
|
||||
import {
|
||||
@@ -105,7 +104,10 @@ import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn'
|
||||
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
|
||||
// TODO: Prevent naming components that coincide with RN primitives
|
||||
// due to linting false positives
|
||||
import {TextInput, TextInputRef} from '#/view/com/composer/text-input/TextInput'
|
||||
import {
|
||||
TextInput,
|
||||
type TextInputRef,
|
||||
} from '#/view/com/composer/text-input/TextInput'
|
||||
import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
|
||||
import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn'
|
||||
import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
|
||||
@@ -115,10 +117,8 @@ import {LazyQuoteEmbed, QuoteX} from '#/view/com/util/post-embeds/QuoteEmbed'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
|
||||
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'
|
||||
@@ -126,16 +126,21 @@ import * as Prompt from '#/components/Prompt'
|
||||
import {Text as NewText} from '#/components/Typography'
|
||||
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
||||
import {
|
||||
ComposerAction,
|
||||
type ComposerAction,
|
||||
composerReducer,
|
||||
createComposerState,
|
||||
EmbedDraft,
|
||||
type EmbedDraft,
|
||||
MAX_IMAGES,
|
||||
PostAction,
|
||||
PostDraft,
|
||||
ThreadDraft,
|
||||
type PostAction,
|
||||
type PostDraft,
|
||||
type ThreadDraft,
|
||||
} from './state/composer'
|
||||
import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
|
||||
import {
|
||||
NO_VIDEO,
|
||||
type NoVideoState,
|
||||
processVideo,
|
||||
type VideoState,
|
||||
} from './state/video'
|
||||
import {getVideoMetadata} from './videos/pickVideo'
|
||||
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
|
||||
|
||||
@@ -323,15 +328,6 @@ export const ComposePost = ({
|
||||
}
|
||||
}, [onPressCancel, closeAllDialogs, closeAllModals])
|
||||
|
||||
const {needsEmailVerification} = useEmail()
|
||||
const emailVerificationControl = useDialogControl()
|
||||
|
||||
useEffect(() => {
|
||||
if (needsEmailVerification) {
|
||||
emailVerificationControl.open()
|
||||
}
|
||||
}, [needsEmailVerification, emailVerificationControl])
|
||||
|
||||
const missingAltError = useMemo(() => {
|
||||
if (!requireAltTextEnabled) {
|
||||
return
|
||||
@@ -404,7 +400,7 @@ export const ComposePost = ({
|
||||
).uris[0]
|
||||
try {
|
||||
await whenAppViewReady(agent, postUri, res => {
|
||||
const postedThread = res.data.thread
|
||||
const postedThread = res?.data?.thread
|
||||
return AppBskyFeedDefs.isThreadViewPost(postedThread)
|
||||
})
|
||||
} catch (waitErr: any) {
|
||||
@@ -612,15 +608,6 @@ export const ComposePost = ({
|
||||
const isWebFooterSticky = !isNative && thread.posts.length > 1
|
||||
return (
|
||||
<BottomSheetPortalProvider>
|
||||
<VerifyEmailDialog
|
||||
control={emailVerificationControl}
|
||||
onCloseWithoutVerifying={() => {
|
||||
onClose()
|
||||
}}
|
||||
reasonText={_(
|
||||
msg`Before creating a post, you must first verify your email.`,
|
||||
)}
|
||||
/>
|
||||
<KeyboardAvoidingView
|
||||
testID="composePostView"
|
||||
behavior={isIOS ? 'padding' : 'height'}
|
||||
@@ -657,6 +644,7 @@ export const ComposePost = ({
|
||||
ref={scrollViewRef}
|
||||
layout={native(LinearTransition)}
|
||||
onScroll={scrollHandler}
|
||||
contentContainerStyle={a.flex_grow}
|
||||
style={a.flex_1}
|
||||
keyboardShouldPersistTaps="always"
|
||||
onContentSizeChange={onScrollViewContentSizeChange}
|
||||
@@ -795,19 +783,22 @@ let ComposerPost = React.memo(function ComposerPost({
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[styles.post, !isActive && styles.inactivePost]}>
|
||||
<View
|
||||
style={[
|
||||
styles.textInputLayout,
|
||||
isNative && styles.textInputLayoutMobile,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.mx_lg,
|
||||
!isActive && styles.inactivePost,
|
||||
isTextOnly && isNative && a.flex_grow,
|
||||
]}>
|
||||
<View style={[a.flex_row, isNative && a.flex_1]}>
|
||||
<UserAvatar
|
||||
avatar={currentProfile?.avatar}
|
||||
size={50}
|
||||
type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
style={[a.mt_xs]}
|
||||
/>
|
||||
<TextInput
|
||||
ref={textInput}
|
||||
style={[a.pt_xs]}
|
||||
richtext={richtext}
|
||||
placeholder={selectTextInputPlaceholder}
|
||||
autoFocus
|
||||
@@ -831,7 +822,9 @@ let ComposerPost = React.memo(function ComposerPost({
|
||||
accessible={true}
|
||||
accessibilityLabel={_(msg`Write post`)}
|
||||
accessibilityHint={_(
|
||||
msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
|
||||
msg`Compose posts up to ${plural(MAX_GRAPHEME_LENGTH || 0, {
|
||||
other: '# characters',
|
||||
})} in length`,
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
@@ -913,20 +906,23 @@ function ComposerTopBar({
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<Animated.View
|
||||
style={topBarAnimatedStyle}
|
||||
layout={native(LinearTransition)}>
|
||||
<View style={styles.topbarInner}>
|
||||
<Button
|
||||
label="Cancel"
|
||||
label={_(msg`Cancel`)}
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
shape="default"
|
||||
size="small"
|
||||
style={[a.rounded_full, a.py_sm, {paddingLeft: 7, paddingRight: 7}]}
|
||||
onPress={onCancel}
|
||||
accessibilityHint="Closes post composer and discards post draft">
|
||||
accessibilityHint={_(
|
||||
msg`Closes post composer and discards post draft`,
|
||||
)}>
|
||||
<ButtonText style={[a.text_md]}>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
@@ -942,7 +938,39 @@ function ComposerTopBar({
|
||||
) : (
|
||||
<Button
|
||||
testID="composerPublishBtn"
|
||||
label={isReply ? 'Publish reply' : 'Publish post'}
|
||||
label={
|
||||
isReply
|
||||
? isThread
|
||||
? _(
|
||||
msg({
|
||||
message: 'Publish replies',
|
||||
comment:
|
||||
'Accessibility label for button to publish multiple replies in a thread',
|
||||
}),
|
||||
)
|
||||
: _(
|
||||
msg({
|
||||
message: 'Publish reply',
|
||||
comment:
|
||||
'Accessibility label for button to publish a single reply',
|
||||
}),
|
||||
)
|
||||
: isThread
|
||||
? _(
|
||||
msg({
|
||||
message: 'Publish posts',
|
||||
comment:
|
||||
'Accessibility label for button to publish multiple posts in a thread',
|
||||
}),
|
||||
)
|
||||
: _(
|
||||
msg({
|
||||
message: 'Publish post',
|
||||
comment:
|
||||
'Accessibility label for button to publish a single post',
|
||||
}),
|
||||
)
|
||||
}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
shape="default"
|
||||
@@ -1077,9 +1105,8 @@ function ComposerEmbeds({
|
||||
</Animated.View>
|
||||
)}
|
||||
</LayoutAnimationConfig>
|
||||
|
||||
<View style={!video ? [a.mt_md] : []}>
|
||||
{embed.quote?.uri ? (
|
||||
{embed.quote?.uri ? (
|
||||
<View style={!video ? [a.mt_md] : []}>
|
||||
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
||||
<View style={{pointerEvents: 'none'}}>
|
||||
<LazyQuoteEmbed uri={embed.quote.uri} />
|
||||
@@ -1088,8 +1115,8 @@ function ComposerEmbeds({
|
||||
<QuoteX onRemove={() => dispatch({type: 'embed_remove_quote'})} />
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1218,39 +1245,41 @@ function ComposerFooter({
|
||||
a.justify_between,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
{video && video.status !== 'done' ? (
|
||||
<VideoUploadToolbar state={video} />
|
||||
) : (
|
||||
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SelectPhotoBtn
|
||||
size={images.length}
|
||||
disabled={media?.type === 'images' ? isMaxImages : !!media}
|
||||
onAdd={onImageAdd}
|
||||
/>
|
||||
<SelectVideoBtn
|
||||
onSelectVideo={asset => onSelectVideo(post.id, asset)}
|
||||
disabled={!!media}
|
||||
setError={onError}
|
||||
/>
|
||||
<OpenCameraBtn
|
||||
disabled={media?.type === 'images' ? isMaxImages : !!media}
|
||||
onAdd={onImageAdd}
|
||||
/>
|
||||
<SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} />
|
||||
{!isMobile ? (
|
||||
<Button
|
||||
onPress={onEmojiButtonPress}
|
||||
style={a.p_sm}
|
||||
label={_(msg`Open emoji picker`)}
|
||||
accessibilityHint={_(msg`Opens emoji picker`)}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="primary">
|
||||
<EmojiSmile size="lg" />
|
||||
</Button>
|
||||
) : null}
|
||||
</ToolbarWrapper>
|
||||
)}
|
||||
<LayoutAnimationConfig skipEntering skipExiting>
|
||||
{video && video.status !== 'done' ? (
|
||||
<VideoUploadToolbar state={video} />
|
||||
) : (
|
||||
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SelectPhotoBtn
|
||||
size={images.length}
|
||||
disabled={media?.type === 'images' ? isMaxImages : !!media}
|
||||
onAdd={onImageAdd}
|
||||
/>
|
||||
<SelectVideoBtn
|
||||
onSelectVideo={asset => onSelectVideo(post.id, asset)}
|
||||
disabled={!!media}
|
||||
setError={onError}
|
||||
/>
|
||||
<OpenCameraBtn
|
||||
disabled={media?.type === 'images' ? isMaxImages : !!media}
|
||||
onAdd={onImageAdd}
|
||||
/>
|
||||
<SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} />
|
||||
{!isMobile ? (
|
||||
<Button
|
||||
onPress={onEmojiButtonPress}
|
||||
style={a.p_sm}
|
||||
label={_(msg`Open emoji picker`)}
|
||||
accessibilityHint={_(msg`Opens emoji picker`)}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="primary">
|
||||
<EmojiSmile size="lg" />
|
||||
</Button>
|
||||
) : null}
|
||||
</ToolbarWrapper>
|
||||
)}
|
||||
</LayoutAnimationConfig>
|
||||
</View>
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||
{showAddButton && (
|
||||
@@ -1416,8 +1445,7 @@ function useKeyboardVerticalOffset() {
|
||||
|
||||
// Android etc
|
||||
if (!isIOS) {
|
||||
// if Android <35 or web, bottom is 0 anyway. if >=35, this is needed to account
|
||||
// for the edge-to-edge nav bar
|
||||
// need to account for the edge-to-edge nav bar
|
||||
return bottom * -1
|
||||
}
|
||||
|
||||
@@ -1468,11 +1496,10 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 6,
|
||||
marginLeft: 12,
|
||||
},
|
||||
stickyFooterWeb: {
|
||||
// @ts-ignore web-only
|
||||
stickyFooterWeb: web({
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
},
|
||||
}),
|
||||
errorLine: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -1503,19 +1530,9 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
marginRight: 5,
|
||||
},
|
||||
post: {
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
inactivePost: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
textInputLayout: {
|
||||
flexDirection: 'row',
|
||||
paddingTop: 4,
|
||||
},
|
||||
textInputLayoutMobile: {
|
||||
flex: 1,
|
||||
},
|
||||
addExtLinkBtn: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 24,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {LayoutAnimation, Pressable, StyleSheet, View} from 'react-native'
|
||||
import {useCallback, useMemo, useState} from 'react'
|
||||
import {LayoutAnimation, Pressable, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {
|
||||
AppBskyEmbedImages,
|
||||
@@ -12,11 +12,13 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {ComposerOptsPostRef} from '#/state/shell/composer'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {type ComposerOptsPostRef} from '#/state/shell/composer'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {QuoteEmbed} from '#/components/Post/Embed'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
import {parseEmbed} from '#/types/bsky/post'
|
||||
|
||||
export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
@@ -24,9 +26,9 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
const {_} = useLingui()
|
||||
const {embed} = replyTo
|
||||
|
||||
const [showFull, setShowFull] = React.useState(false)
|
||||
const [showFull, setShowFull] = useState(false)
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
const onPress = useCallback(() => {
|
||||
setShowFull(prev => !prev)
|
||||
LayoutAnimation.configureNext({
|
||||
duration: 350,
|
||||
@@ -34,7 +36,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const quoteEmbed = React.useMemo(() => {
|
||||
const quoteEmbed = useMemo(() => {
|
||||
if (
|
||||
AppBskyEmbedRecord.isView(embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record) &&
|
||||
@@ -57,7 +59,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
})
|
||||
: null
|
||||
|
||||
const images = React.useMemo(() => {
|
||||
const images = useMemo(() => {
|
||||
if (AppBskyEmbedImages.isView(embed)) {
|
||||
return embed.images
|
||||
} else if (
|
||||
@@ -68,17 +70,27 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
}
|
||||
}, [embed])
|
||||
|
||||
const verification = useSimpleVerificationState({profile: replyTo.author})
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[t.atoms.border_contrast_medium, styles.replyToLayout]}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_start,
|
||||
a.pt_xs,
|
||||
a.pb_lg,
|
||||
a.mb_md,
|
||||
a.mx_lg,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_medium,
|
||||
web(a.user_select_text),
|
||||
]}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(
|
||||
msg`Expand or collapse the full post you are replying to`,
|
||||
)}
|
||||
accessibilityHint={_(
|
||||
msg`Expands or collapses the full post you are replying to`,
|
||||
)}>
|
||||
accessibilityHint="">
|
||||
<PreviewableUserAvatar
|
||||
size={50}
|
||||
profile={replyTo.author}
|
||||
@@ -86,17 +98,30 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
type={replyTo.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
disableNavigation={true}
|
||||
/>
|
||||
<View style={styles.replyToPost}>
|
||||
<Text type="xl-medium" style={t.atoms.text} numberOfLines={1} emoji>
|
||||
{sanitizeDisplayName(
|
||||
replyTo.author.displayName || sanitizeHandle(replyTo.author.handle),
|
||||
<View style={[a.flex_1, a.pl_md, a.pr_sm, a.gap_2xs]}>
|
||||
<View style={[a.flex_row, a.align_center, a.pr_xs]}>
|
||||
<Text
|
||||
style={[a.font_bold, a.text_md, a.leading_snug, a.flex_shrink]}
|
||||
numberOfLines={1}
|
||||
emoji>
|
||||
{sanitizeDisplayName(
|
||||
replyTo.author.displayName ||
|
||||
sanitizeHandle(replyTo.author.handle),
|
||||
)}
|
||||
</Text>
|
||||
{verification.showBadge && (
|
||||
<View style={[a.pl_xs]}>
|
||||
<VerificationCheck
|
||||
width={14}
|
||||
verifier={verification.role === 'verifier'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Text>
|
||||
<View style={styles.replyToBody}>
|
||||
<View style={styles.replyToText}>
|
||||
</View>
|
||||
<View style={[a.flex_row, a.gap_md]}>
|
||||
<View style={[a.flex_1, a.flex_grow]}>
|
||||
<Text
|
||||
type="post-text"
|
||||
style={t.atoms.text}
|
||||
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}
|
||||
numberOfLines={!showFull ? 6 : undefined}
|
||||
emoji>
|
||||
{replyTo.text}
|
||||
@@ -121,7 +146,17 @@ function ComposerReplyToImages({
|
||||
showFull: boolean
|
||||
}) {
|
||||
return (
|
||||
<View style={[styles.imagesContainer, a.mx_xs]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
a.overflow_hidden,
|
||||
a.mt_2xs,
|
||||
a.mx_xs,
|
||||
{
|
||||
height: 64,
|
||||
width: 64,
|
||||
},
|
||||
]}>
|
||||
{(images.length === 1 && (
|
||||
<Image
|
||||
source={{uri: images[0].thumb}}
|
||||
@@ -205,35 +240,3 @@ function ComposerReplyToImages({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
replyToLayout: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 16,
|
||||
marginBottom: 12,
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
replyToPost: {
|
||||
flex: 1,
|
||||
paddingLeft: 13,
|
||||
paddingRight: 8,
|
||||
},
|
||||
replyToBody: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
replyToText: {
|
||||
flex: 1,
|
||||
flexGrow: 1,
|
||||
},
|
||||
imagesContainer: {
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
marginTop: 2,
|
||||
height: 64,
|
||||
width: 64,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import {useState} from 'react'
|
||||
import {TouchableOpacity, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants'
|
||||
import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
|
||||
import {
|
||||
EmbedPlayerParams,
|
||||
type EmbedPlayerParams,
|
||||
parseEmbedPlayerFromUrl,
|
||||
} from '#/lib/strings/embed-player'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {useResolveGifQuery} from '#/state/queries/resolve-link'
|
||||
import {Gif} from '#/state/queries/tenor'
|
||||
import {type Gif} from '#/state/queries/tenor'
|
||||
import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {DialogControlProps} from '#/components/Dialog'
|
||||
import {type DialogControlProps} from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
@@ -177,8 +177,11 @@ function AltTextInner({
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
Alt text will be truncated. Limit:{' '}
|
||||
{i18n.number(MAX_ALT_TEXT)} characters.
|
||||
Alt text will be truncated.{' '}
|
||||
<Plural
|
||||
value={MAX_ALT_TEXT}
|
||||
other={`Limit: ${i18n.number(MAX_ALT_TEXT)} characters.`}
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -4,10 +4,10 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
ADULT_CONTENT_LABELS,
|
||||
AdultSelfLabel,
|
||||
type AdultSelfLabel,
|
||||
OTHER_SELF_LABELS,
|
||||
OtherSelfLabel,
|
||||
SelfLabel,
|
||||
type OtherSelfLabel,
|
||||
type SelfLabel,
|
||||
} from '#/lib/moderation'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
@@ -113,9 +113,8 @@ function DialogInner({
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
<Trans>
|
||||
Choose self-labels that are applicable for the media you are
|
||||
posting. If none are selected, this post is suitable for all
|
||||
audiences.
|
||||
Please add any content warning labels that are applicable for the
|
||||
media you are posting.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from 'react'
|
||||
import type React from 'react'
|
||||
|
||||
import {ComposerImage} from '#/state/gallery'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {type ComposerImage} from '#/state/gallery'
|
||||
import type * as Dialog from '#/components/Dialog'
|
||||
|
||||
export type EditImageDialogProps = {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
image: ComposerImage
|
||||
image?: ComposerImage
|
||||
onChange: (next: ComposerImage) => void
|
||||
aspectRatio?: number
|
||||
circularCrop?: boolean
|
||||
}
|
||||
|
||||
export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => {
|
||||
|
||||
@@ -1,43 +1,130 @@
|
||||
import 'react-image-crop/dist/ReactCrop.css'
|
||||
|
||||
import React from 'react'
|
||||
import {useCallback, useImperativeHandle, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import ReactCrop, {PercentCrop} from 'react-image-crop'
|
||||
import ReactCrop, {type PercentCrop} from 'react-image-crop'
|
||||
|
||||
import {
|
||||
ImageSource,
|
||||
ImageTransformation,
|
||||
type ImageSource,
|
||||
type ImageTransformation,
|
||||
manipulateImage,
|
||||
} from '#/state/gallery'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {EditImageDialogProps} from './EditImageDialog'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {type EditImageDialogProps} from './EditImageDialog'
|
||||
|
||||
export const EditImageDialog = (props: EditImageDialogProps) => {
|
||||
export function EditImageDialog(props: EditImageDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Handle />
|
||||
<EditImageInner key={props.image.source.id} {...props} />
|
||||
<DialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
|
||||
function DialogInner({
|
||||
control,
|
||||
image,
|
||||
onChange,
|
||||
circularCrop,
|
||||
aspectRatio,
|
||||
}: EditImageDialogProps) {
|
||||
const {_} = useLingui()
|
||||
const [pending, setPending] = useState(false)
|
||||
const ref = useRef<{save: () => Promise<void>}>(null)
|
||||
|
||||
const cancelButton = useCallback(
|
||||
() => (
|
||||
<Button
|
||||
label={_(msg`Cancel`)}
|
||||
disabled={pending}
|
||||
onPress={() => control.close()}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="ghost"
|
||||
style={[a.rounded_full]}
|
||||
testID="cropImageCancelBtn">
|
||||
<ButtonText style={[a.text_md]}>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
),
|
||||
[control, _, pending],
|
||||
)
|
||||
|
||||
const saveButton = useCallback(
|
||||
() => (
|
||||
<Button
|
||||
label={_(msg`Save`)}
|
||||
onPress={async () => {
|
||||
setPending(true)
|
||||
await ref.current?.save()
|
||||
setPending(false)
|
||||
}}
|
||||
disabled={pending}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="ghost"
|
||||
style={[a.rounded_full]}
|
||||
testID="cropImageSaveBtn">
|
||||
<ButtonText style={[a.text_md]}>
|
||||
<Trans>Save</Trans>
|
||||
</ButtonText>
|
||||
{pending && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
),
|
||||
[_, pending],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Inner
|
||||
label={_(msg`Edit image`)}
|
||||
header={
|
||||
<Dialog.Header renderLeft={cancelButton} renderRight={saveButton}>
|
||||
<Dialog.HeaderText>
|
||||
<Trans>Edit image</Trans>
|
||||
</Dialog.HeaderText>
|
||||
</Dialog.Header>
|
||||
}>
|
||||
{image && (
|
||||
<EditImageInner
|
||||
saveRef={ref}
|
||||
key={image.source.id}
|
||||
image={image}
|
||||
onChange={onChange}
|
||||
circularCrop={circularCrop}
|
||||
aspectRatio={aspectRatio}
|
||||
/>
|
||||
)}
|
||||
</Dialog.Inner>
|
||||
)
|
||||
}
|
||||
|
||||
function EditImageInner({
|
||||
image,
|
||||
onChange,
|
||||
saveRef,
|
||||
circularCrop = false,
|
||||
aspectRatio,
|
||||
}: Required<Pick<EditImageDialogProps, 'image'>> &
|
||||
Omit<EditImageDialogProps, 'control' | 'image'> & {
|
||||
saveRef: React.RefObject<{save: () => Promise<void>}>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
const source = image.source
|
||||
|
||||
const initialCrop = getInitialCrop(source, image.manips)
|
||||
const [crop, setCrop] = React.useState(initialCrop)
|
||||
const [crop, setCrop] = useState(initialCrop)
|
||||
|
||||
const isEmpty = !crop || (crop.width || crop.height) === 0
|
||||
const isNew = initialCrop ? true : !isEmpty
|
||||
|
||||
const onPressSubmit = React.useCallback(async () => {
|
||||
const onPressSubmit = useCallback(async () => {
|
||||
const result = await manipulateImage(image, {
|
||||
crop:
|
||||
crop && (crop.width || crop.height) !== 0
|
||||
@@ -50,41 +137,43 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
|
||||
: undefined,
|
||||
})
|
||||
|
||||
onChange(result)
|
||||
control.close()
|
||||
control.close(() => {
|
||||
onChange(result)
|
||||
})
|
||||
}, [crop, image, source, control, onChange])
|
||||
|
||||
useImperativeHandle(
|
||||
saveRef,
|
||||
() => ({
|
||||
save: onPressSubmit,
|
||||
}),
|
||||
[onPressSubmit],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Inner label={_(msg`Edit image`)}>
|
||||
<Dialog.Close />
|
||||
|
||||
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
|
||||
<Trans>Edit image</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.align_center]}>
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
|
||||
className="ReactCrop--no-animate">
|
||||
<img src={source.path} style={{maxHeight: `50vh`}} />
|
||||
</ReactCrop>
|
||||
</View>
|
||||
|
||||
<View style={[a.mt_md, a.gap_md]}>
|
||||
<Button
|
||||
disabled={!isNew}
|
||||
label={_(msg`Save`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
onPress={onPressSubmit}>
|
||||
<ButtonText>
|
||||
<Trans>Save</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</Dialog.Inner>
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_xs,
|
||||
a.overflow_hidden,
|
||||
a.align_center,
|
||||
]}>
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
aspect={aspectRatio}
|
||||
circularCrop={circularCrop}
|
||||
onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
|
||||
className="ReactCrop--no-animate"
|
||||
onDragStart={() => setIsDragging(true)}
|
||||
onDragEnd={() => setIsDragging(false)}>
|
||||
<img src={source.path} style={{maxHeight: `50vh`}} />
|
||||
</ReactCrop>
|
||||
{/* Eat clicks when dragging, otherwise mousing up over the backdrop
|
||||
causes the dialog to close */}
|
||||
{isDragging && <View style={[a.fixed, a.inset_0]} />}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import React from 'react'
|
||||
import {ImageStyle, useWindowDimensions, View} from 'react-native'
|
||||
import {type ImageStyle, useWindowDimensions, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {MAX_ALT_TEXT} from '#/lib/constants'
|
||||
import {enforceLen} from '#/lib/strings/helpers'
|
||||
import {isAndroid, isWeb} from '#/platform/detection'
|
||||
import {ComposerImage} from '#/state/gallery'
|
||||
import {type ComposerImage} from '#/state/gallery'
|
||||
import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {DialogControlProps} from '#/components/Dialog'
|
||||
import {type DialogControlProps} from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -137,8 +137,11 @@ const ImageAltTextInner = ({
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
Alt text will be truncated. Limit: {i18n.number(MAX_ALT_TEXT)}{' '}
|
||||
characters.
|
||||
Alt text will be truncated.{' '}
|
||||
<Plural
|
||||
value={MAX_ALT_TEXT}
|
||||
other={`Limit: ${i18n.number(MAX_ALT_TEXT)} characters.`}
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {useCameraPermission} from '#/lib/hooks/usePermissions'
|
||||
import {openCamera} from '#/lib/media/picker'
|
||||
import {logger} from '#/logger'
|
||||
import {isMobileWeb, isNative} from '#/platform/detection'
|
||||
import {ComposerImage, createComposerImage} from '#/state/gallery'
|
||||
import {type ComposerImage, createComposerImage} from '#/state/gallery'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera'
|
||||
@@ -35,9 +35,7 @@ export function OpenCameraBtn({disabled, onAdd}: Props) {
|
||||
}
|
||||
|
||||
const img = await openCamera({
|
||||
width: POST_IMG_MAX.width,
|
||||
height: POST_IMG_MAX.height,
|
||||
freeStyleCropEnabled: true,
|
||||
aspect: [POST_IMG_MAX.width, POST_IMG_MAX.height],
|
||||
})
|
||||
|
||||
// If we don't have permissions it's fine, we just wont save it. The post itself will still have access to
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import lande from 'lande'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {s} from '#/lib/styles'
|
||||
import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers'
|
||||
import {
|
||||
toPostLanguages,
|
||||
useLanguagePrefs,
|
||||
useLanguagePrefsApi,
|
||||
} from '#/state/preferences/languages'
|
||||
import {Button} from '../../util/forms/Button'
|
||||
import {Text} from '../../util/text/Text'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
// fallbacks for safari
|
||||
const onIdle = globalThis.requestIdleCallback || (cb => setTimeout(cb, 1))
|
||||
@@ -29,7 +25,7 @@ export function SuggestedLanguage({text}: {text: string}) {
|
||||
>()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
const pal = usePalette('default')
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,32 +55,37 @@ export function SuggestedLanguage({text}: {text: string}) {
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[pal.border, styles.infoBar]}>
|
||||
<FontAwesomeIcon
|
||||
icon="language"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
size={24}
|
||||
/>
|
||||
<Text style={[pal.text, s.flex1]}>
|
||||
<View
|
||||
style={[
|
||||
t.atoms.border_contrast_low,
|
||||
a.gap_sm,
|
||||
a.border,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_sm,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
a.mx_md,
|
||||
a.my_sm,
|
||||
t.atoms.bg,
|
||||
]}>
|
||||
<EarthIcon />
|
||||
<Text style={[a.flex_1]}>
|
||||
<Trans>
|
||||
Are you writing in{' '}
|
||||
<Text type="sm-bold" style={pal.text}>
|
||||
{suggestedLanguageName}
|
||||
</Text>
|
||||
?
|
||||
<Text style={[a.font_bold]}>{suggestedLanguageName}</Text>?
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
type="default"
|
||||
color="secondary"
|
||||
size="small"
|
||||
variant="solid"
|
||||
onPress={() => setLangPrefs.setPostLanguage(suggestedLanguage)}
|
||||
accessibilityLabel={_(
|
||||
msg`Change post language to ${suggestedLanguageName}`,
|
||||
)}
|
||||
accessibilityHint="">
|
||||
<Text type="button" style={[pal.link, s.fw600]}>
|
||||
label={_(msg`Change post language to ${suggestedLanguageName}`)}>
|
||||
<ButtonText>
|
||||
<Trans>Yes</Trans>
|
||||
</Text>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
@@ -93,20 +94,6 @@ export function SuggestedLanguage({text}: {text: string}) {
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
infoBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
marginHorizontal: 10,
|
||||
marginBottom: 10,
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* This function is using the lande language model to attempt to detect the language
|
||||
* We want to only make suggestions when we feel a high degree of certainty
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {
|
||||
ComponentProps,
|
||||
type ComponentProps,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
@@ -7,16 +7,16 @@ import React, {
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
NativeSyntheticEvent,
|
||||
type NativeSyntheticEvent,
|
||||
Text as RNText,
|
||||
TextInput as RNTextInput,
|
||||
TextInputSelectionChangeEventData,
|
||||
type TextInput as RNTextInput,
|
||||
type TextInputSelectionChangeEventData,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||
import PasteInput, {
|
||||
PastedFile,
|
||||
PasteInputRef,
|
||||
type PastedFile,
|
||||
type PasteInputRef,
|
||||
} from '@mattermost/react-native-paste-input'
|
||||
|
||||
import {POST_IMG_MAX} from '#/lib/constants'
|
||||
@@ -27,7 +27,7 @@ import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip'
|
||||
import {useTheme} from '#/lib/ThemeContext'
|
||||
import {isAndroid, isNative} from '#/platform/detection'
|
||||
import {
|
||||
LinkFacetMatch,
|
||||
type LinkFacetMatch,
|
||||
suggestLinkCardUri,
|
||||
} from '#/view/com/composer/text-input/text-input-util'
|
||||
import {atoms as a, useAlf} from '#/alf'
|
||||
@@ -249,9 +249,11 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
multiline
|
||||
scrollEnabled={false}
|
||||
numberOfLines={2}
|
||||
{...props}
|
||||
style={[
|
||||
inputTextStyle,
|
||||
a.w_full,
|
||||
!autocompletePrefix && a.h_full,
|
||||
{
|
||||
textAlignVertical: 'top',
|
||||
minHeight: 60,
|
||||
@@ -261,8 +263,8 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
]}
|
||||
{...props}>
|
||||
props.style,
|
||||
]}>
|
||||
{textDecorated}
|
||||
</PasteInput>
|
||||
<Autocomplete
|
||||
|
||||
@@ -12,14 +12,14 @@ import {Placeholder} from '@tiptap/extension-placeholder'
|
||||
import {Text as TiptapText} from '@tiptap/extension-text'
|
||||
import {generateJSON} from '@tiptap/html'
|
||||
import {Fragment, Node, Slice} from '@tiptap/pm/model'
|
||||
import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
|
||||
import {EditorContent, type JSONContent, useEditor} from '@tiptap/react'
|
||||
|
||||
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {blobToDataUri, isUriImage} from '#/lib/media/util'
|
||||
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
||||
import {
|
||||
LinkFacetMatch,
|
||||
type LinkFacetMatch,
|
||||
suggestLinkCardUri,
|
||||
} from '#/view/com/composer/text-input/text-input-util'
|
||||
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||
@@ -28,7 +28,7 @@ import {normalizeTextStyles} from '#/alf/typography'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {Text} from '../../util/text/Text'
|
||||
import {createSuggestion} from './web/Autocomplete'
|
||||
import {Emoji} from './web/EmojiPicker.web'
|
||||
import {type Emoji} from './web/EmojiPicker'
|
||||
import {LinkDecorator} from './web/LinkDecorator'
|
||||
import {TagDecorator} from './web/TagDecorator'
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {View} from 'react-native'
|
||||
import Animated, {FadeInDown, FadeOut} from 'react-native-reanimated'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
@@ -7,8 +8,10 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, platform, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
|
||||
export function Autocomplete({
|
||||
prefix,
|
||||
@@ -42,51 +45,15 @@ export function Autocomplete({
|
||||
{suggestions?.length ? (
|
||||
suggestions.slice(0, 5).map((item, index, arr) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
index !== arr.length - 1 && a.border_b,
|
||||
t.atoms.border_contrast_high,
|
||||
a.px_sm,
|
||||
a.py_md,
|
||||
]}
|
||||
key={item.handle}>
|
||||
<PressableScale
|
||||
testID="autocompleteButton"
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
]}
|
||||
onPress={() => onSelect(item.handle)}
|
||||
accessibilityLabel={`Select ${item.handle}`}
|
||||
accessibilityHint="">
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
<UserAvatar
|
||||
avatar={item.avatar ?? null}
|
||||
size={24}
|
||||
type={item.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<Text
|
||||
style={[a.flex_1, a.text_md, a.font_bold]}
|
||||
emoji
|
||||
numberOfLines={1}>
|
||||
{sanitizeDisplayName(
|
||||
item.displayName || sanitizeHandle(item.handle),
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_right,
|
||||
{maxWidth: '50%'},
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeHandle(item.handle, '@')}
|
||||
</Text>
|
||||
</View>
|
||||
</PressableScale>
|
||||
</View>
|
||||
<AutocompleteProfileCard
|
||||
key={item.did}
|
||||
profile={item}
|
||||
itemIndex={index}
|
||||
totalItems={arr.length}
|
||||
onPress={() => {
|
||||
onSelect(item.handle)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
@@ -97,3 +64,78 @@ export function Autocomplete({
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteProfileCard({
|
||||
profile,
|
||||
itemIndex,
|
||||
totalItems,
|
||||
onPress,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
itemIndex: number
|
||||
totalItems: number
|
||||
onPress: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const state = useSimpleVerificationState({profile})
|
||||
const displayName = sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
)
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
itemIndex !== totalItems - 1 && a.border_b,
|
||||
t.atoms.border_contrast_high,
|
||||
a.px_sm,
|
||||
a.py_md,
|
||||
]}
|
||||
key={profile.did}>
|
||||
<PressableScale
|
||||
testID="autocompleteButton"
|
||||
style={[a.flex_row, a.gap_lg, a.justify_between, a.align_center]}
|
||||
onPress={onPress}
|
||||
accessibilityLabel={`Select ${profile.handle}`}
|
||||
accessibilityHint="">
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center, a.flex_1]}>
|
||||
<UserAvatar
|
||||
avatar={profile.avatar ?? null}
|
||||
size={24}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_xs,
|
||||
platform({ios: a.flex_1}),
|
||||
]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.leading_snug]}
|
||||
emoji
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{state.isVerified && (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
marginTop: platform({android: -2}),
|
||||
},
|
||||
]}>
|
||||
<VerificationCheck
|
||||
width={12}
|
||||
verifier={state.role === 'verifier'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_medium, a.text_right, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeHandle(profile.handle, '@')}
|
||||
</Text>
|
||||
</PressableScale>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import {forwardRef, useEffect, useImperativeHandle, useState} from 'react'
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {ReactRenderer} from '@tiptap/react'
|
||||
import {
|
||||
SuggestionKeyDownProps,
|
||||
SuggestionOptions,
|
||||
SuggestionProps,
|
||||
type SuggestionKeyDownProps,
|
||||
type SuggestionOptions,
|
||||
type SuggestionProps,
|
||||
} from '@tiptap/suggestion'
|
||||
import tippy, {Instance as TippyInstance} from 'tippy.js'
|
||||
import tippy, {type Instance as TippyInstance} from 'tippy.js'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
||||
import {type ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
import {useGrapheme} from '../hooks/useGrapheme'
|
||||
|
||||
interface MentionListRef {
|
||||
@@ -95,7 +99,6 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
|
||||
function MentionListImpl(props: SuggestionProps, ref) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const pal = usePalette('default')
|
||||
const {getGraphemeString} = useGrapheme()
|
||||
|
||||
const selectItem = (index: number) => {
|
||||
const item = props.items[index]
|
||||
@@ -149,45 +152,19 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
|
||||
<View style={[pal.borderDark, pal.view, styles.container]}>
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => {
|
||||
const {name: displayName} = getGraphemeString(
|
||||
sanitizeDisplayName(
|
||||
item.displayName || sanitizeHandle(item.handle),
|
||||
),
|
||||
30, // Heuristic value; can be modified
|
||||
)
|
||||
const isSelected = selectedIndex === index
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
<AutocompleteProfileCard
|
||||
key={item.handle}
|
||||
style={[
|
||||
isSelected ? pal.viewLight : undefined,
|
||||
pal.borderDark,
|
||||
styles.mentionContainer,
|
||||
index === 0
|
||||
? styles.firstMention
|
||||
: index === items.length - 1
|
||||
? styles.lastMention
|
||||
: undefined,
|
||||
]}
|
||||
profile={item}
|
||||
isSelected={isSelected}
|
||||
itemIndex={index}
|
||||
totalItems={items.length}
|
||||
onPress={() => {
|
||||
selectItem(index)
|
||||
}}
|
||||
accessibilityRole="button">
|
||||
<View style={styles.avatarAndDisplayName}>
|
||||
<UserAvatar
|
||||
avatar={item.avatar ?? null}
|
||||
size={26}
|
||||
type={item.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<Text emoji style={pal.text} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</View>
|
||||
<Text type="xs" style={pal.textLight} numberOfLines={1}>
|
||||
{sanitizeHandle(item.handle, '@')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
@@ -201,6 +178,71 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
|
||||
},
|
||||
)
|
||||
|
||||
function AutocompleteProfileCard({
|
||||
profile,
|
||||
isSelected,
|
||||
itemIndex,
|
||||
totalItems,
|
||||
onPress,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
isSelected: boolean
|
||||
itemIndex: number
|
||||
totalItems: number
|
||||
onPress: () => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {getGraphemeString} = useGrapheme()
|
||||
const {name: displayName} = getGraphemeString(
|
||||
sanitizeDisplayName(profile.displayName || sanitizeHandle(profile.handle)),
|
||||
30, // Heuristic value; can be modified
|
||||
)
|
||||
const state = useSimpleVerificationState({
|
||||
profile,
|
||||
})
|
||||
return (
|
||||
<Pressable
|
||||
style={[
|
||||
isSelected ? pal.viewLight : undefined,
|
||||
pal.borderDark,
|
||||
styles.mentionContainer,
|
||||
itemIndex === 0
|
||||
? styles.firstMention
|
||||
: itemIndex === totalItems - 1
|
||||
? styles.lastMention
|
||||
: undefined,
|
||||
]}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button">
|
||||
<View style={[styles.avatarAndDisplayName, a.flex_1]}>
|
||||
<UserAvatar
|
||||
avatar={profile.avatar ?? null}
|
||||
size={26}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
|
||||
<Text emoji style={[pal.text]} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{state.isVerified && (
|
||||
<View>
|
||||
<VerificationCheck
|
||||
width={12}
|
||||
verifier={state.role === 'verifier'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<Text type="xs" style={pal.textLight} numberOfLines={1}>
|
||||
{sanitizeHandle(profile.handle, '@')}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: 500,
|
||||
@@ -216,7 +258,7 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
gap: 4,
|
||||
gap: 16,
|
||||
},
|
||||
firstMention: {
|
||||
borderTopLeftRadius: 2,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export type Emoji = {
|
||||
aliases?: string[]
|
||||
emoticons: string[]
|
||||
id: string
|
||||
keywords: string[]
|
||||
name: string
|
||||
native: string
|
||||
shortcodes?: string
|
||||
unified: string
|
||||
}
|
||||
|
||||
export interface EmojiPickerPosition {
|
||||
top: number
|
||||
left: number
|
||||
right: number
|
||||
bottom: number
|
||||
nextFocusRef: React.MutableRefObject<HTMLElement> | null
|
||||
}
|
||||
|
||||
export interface EmojiPickerState {
|
||||
isOpen: boolean
|
||||
pos: EmojiPickerPosition
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
state: EmojiPickerState
|
||||
close: () => void
|
||||
/**
|
||||
* If `true`, overrides position and ensures picker is pinned to the top of
|
||||
* the target element.
|
||||
*/
|
||||
pinToTop?: boolean
|
||||
}
|
||||
|
||||
export function EmojiPicker(_opts: IProps) {
|
||||
return null
|
||||
}
|
||||
@@ -3,8 +3,7 @@ import {Pressable, useWindowDimensions, View} from 'react-native'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {DismissableLayer} from '@radix-ui/react-dismissable-layer'
|
||||
import {FocusScope} from '@radix-ui/react-focus-scope'
|
||||
import {DismissableLayer, FocusScope} from 'radix-ui/internal'
|
||||
|
||||
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||
import {atoms as a, flatten} from '#/alf'
|
||||
@@ -121,7 +120,7 @@ export function EmojiPicker({state, close, pinToTop}: IProps) {
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<FocusScope
|
||||
<FocusScope.FocusScope
|
||||
loop
|
||||
trapped
|
||||
onUnmountAutoFocus={e => {
|
||||
@@ -154,7 +153,7 @@ export function EmojiPicker({state, close, pinToTop}: IProps) {
|
||||
},
|
||||
])}>
|
||||
<View style={[{position: 'absolute'}, position]}>
|
||||
<DismissableLayer
|
||||
<DismissableLayer.DismissableLayer
|
||||
onFocusOutside={evt => evt.preventDefault()}
|
||||
onDismiss={close}>
|
||||
<Picker
|
||||
@@ -164,7 +163,7 @@ export function EmojiPicker({state, close, pinToTop}: IProps) {
|
||||
onEmojiSelect={onInsert}
|
||||
autoFocus={true}
|
||||
/>
|
||||
</DismissableLayer>
|
||||
</DismissableLayer.DismissableLayer>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -175,7 +174,7 @@ export function EmojiPicker({state, close, pinToTop}: IProps) {
|
||||
onPress={close}
|
||||
style={[a.fixed, a.inset_0]}
|
||||
/>
|
||||
</FocusScope>
|
||||
</FocusScope.FocusScope>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import {init} from 'emoji-mart'
|
||||
|
||||
/**
|
||||
* Only load the emoji picker data once per page load.
|
||||
*/
|
||||
let loadRequested = false
|
||||
|
||||
/**
|
||||
* Preload the emoji picker data to prevent flash.
|
||||
* {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194}
|
||||
*/
|
||||
export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) {
|
||||
const preload = React.useCallback(async () => {
|
||||
if (loadRequested) return
|
||||
loadRequested = true
|
||||
try {
|
||||
const data = (await import('./EmojiPickerData.json')).default
|
||||
init({data})
|
||||
} catch (e) {}
|
||||
}, [])
|
||||
if (immediate) preload()
|
||||
return preload
|
||||
}
|
||||
@@ -1,26 +1,19 @@
|
||||
import {useCallback} from 'react'
|
||||
import {Keyboard} from 'react-native'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
SUPPORTED_MIME_TYPES,
|
||||
SupportedMimeTypes,
|
||||
type SupportedMimeTypes,
|
||||
VIDEO_MAX_DURATION_MS,
|
||||
} 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 {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
|
||||
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {pickVideo} from './pickVideo'
|
||||
|
||||
type Props = {
|
||||
@@ -33,66 +26,45 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
||||
const control = Prompt.usePromptControl()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const onPressSelectVideo = useCallback(async () => {
|
||||
if (isNative && !(await requestVideoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
currentAccount &&
|
||||
!currentAccount.emailConfirmed &&
|
||||
getHostnameFromUrl(currentAccount.service) ===
|
||||
getHostnameFromUrl(BSKY_SERVICE)
|
||||
) {
|
||||
Keyboard.dismiss()
|
||||
control.open()
|
||||
} else {
|
||||
const response = await pickVideo()
|
||||
if (response.assets && response.assets.length > 0) {
|
||||
const asset = response.assets[0]
|
||||
try {
|
||||
if (isWeb) {
|
||||
// asset.duration is null for gifs (see the TODO in pickVideo.web.ts)
|
||||
if (asset.duration && asset.duration > VIDEO_MAX_DURATION_MS) {
|
||||
throw Error(_(msg`Videos must be less than 3 minutes long`))
|
||||
}
|
||||
// compression step on native converts to mp4, so no need to check there
|
||||
if (
|
||||
!SUPPORTED_MIME_TYPES.includes(
|
||||
asset.mimeType as SupportedMimeTypes,
|
||||
)
|
||||
) {
|
||||
throw Error(_(msg`Unsupported video type: ${asset.mimeType}`))
|
||||
}
|
||||
} else {
|
||||
if (typeof asset.duration !== 'number') {
|
||||
throw Error('Asset is not a video')
|
||||
}
|
||||
if (asset.duration > VIDEO_MAX_DURATION_MS) {
|
||||
throw Error(_(msg`Videos must be less than 3 minutes long`))
|
||||
}
|
||||
const response = await pickVideo()
|
||||
if (response.assets && response.assets.length > 0) {
|
||||
const asset = response.assets[0]
|
||||
try {
|
||||
if (isWeb) {
|
||||
// asset.duration is null for gifs (see the TODO in pickVideo.web.ts)
|
||||
if (asset.duration && asset.duration > VIDEO_MAX_DURATION_MS) {
|
||||
throw Error(_(msg`Videos must be less than 3 minutes long`))
|
||||
}
|
||||
onSelectVideo(asset)
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(_(msg`An error occurred while selecting the video`))
|
||||
// compression step on native converts to mp4, so no need to check there
|
||||
if (
|
||||
!SUPPORTED_MIME_TYPES.includes(asset.mimeType as SupportedMimeTypes)
|
||||
) {
|
||||
throw Error(_(msg`Unsupported video type: ${asset.mimeType}`))
|
||||
}
|
||||
} else {
|
||||
if (typeof asset.duration !== 'number') {
|
||||
throw Error('Asset is not a video')
|
||||
}
|
||||
if (asset.duration > VIDEO_MAX_DURATION_MS) {
|
||||
throw Error(_(msg`Videos must be less than 3 minutes long`))
|
||||
}
|
||||
}
|
||||
onSelectVideo(asset)
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(_(msg`An error occurred while selecting the video`))
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
requestVideoAccessIfNeeded,
|
||||
currentAccount,
|
||||
control,
|
||||
setError,
|
||||
_,
|
||||
onSelectVideo,
|
||||
])
|
||||
}, [requestVideoAccessIfNeeded, setError, _, onSelectVideo])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -111,30 +83,6 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
|
||||
style={disabled && t.atoms.text_contrast_low}
|
||||
/>
|
||||
</Button>
|
||||
<VerifyEmailPrompt control={control} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
|
||||
const {_} = useLingui()
|
||||
const verifyEmailDialogControl = useDialogControl()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Prompt.Basic
|
||||
control={control}
|
||||
title={_(msg`Verified email required`)}
|
||||
description={_(
|
||||
msg`To upload videos to Bluesky, you must first verify your email.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Verify now`)}
|
||||
confirmButtonColor="primary"
|
||||
onConfirm={() => {
|
||||
verifyEmailDialogControl.open()
|
||||
}}
|
||||
/>
|
||||
<VerifyEmailDialog control={verifyEmailDialogControl} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {useCallback, useState} from 'react'
|
||||
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import RNPickerSelect from 'react-native-picker-select'
|
||||
import {Keyboard, type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -240,19 +239,21 @@ function SubtitleFileRow({
|
||||
numberOfLines={1}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<RNPickerSelect
|
||||
placeholder={{
|
||||
label: _(msg`Select language...`),
|
||||
value: '',
|
||||
}}
|
||||
<select
|
||||
value={language}
|
||||
onValueChange={handleValueChange}
|
||||
items={otherLanguages.map(lang => ({
|
||||
label: `${lang.name} (${langCode(lang)})`,
|
||||
value: langCode(lang),
|
||||
}))}
|
||||
style={{viewContainer: {maxWidth: 200, flex: 1}}}
|
||||
/>
|
||||
onChange={evt => handleValueChange(evt.target.value)}
|
||||
style={{maxWidth: 200, flex: 1}}>
|
||||
<option value="" disabled selected hidden>
|
||||
{/* eslint-disable-next-line bsky-internal/avoid-unwrapped-text */}
|
||||
<Trans>Select language...</Trans>
|
||||
</option>
|
||||
{otherLanguages.map(lang => (
|
||||
<option key={langCode(lang)} value={langCode(lang)}>
|
||||
{/* eslint-disable-next-line bsky-internal/avoid-unwrapped-text */}
|
||||
{`${lang.name} (${langCode(lang)})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {clearCache, createVideoThumbnail} from 'react-native-compressor'
|
||||
import Animated, {FadeIn} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
import {QueryClient, useQuery} from '@tanstack/react-query'
|
||||
import {type QueryClient, useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export const RQKEY = 'video-thumbnail'
|
||||
|
||||
export function clearThumbnailCache(queryClient: QueryClient) {
|
||||
clearCache()
|
||||
clearCache().catch(() => {})
|
||||
queryClient.resetQueries({queryKey: [RQKEY]})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user