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:
Eric Bailey
2025-06-10 10:27:43 -05:00
556 changed files with 149398 additions and 82913 deletions
+123 -106
View File
@@ -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,
+58 -55
View File
@@ -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,
},
})
+9 -6
View File
@@ -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>
+5 -6
View File
@@ -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
+11 -9
View File
@@ -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
}
+30 -82
View File
@@ -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} />
</>
)
}
+15 -14
View File
@@ -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]})
}