Merge branch 'main' into app-2067
This commit is contained in:
+124
-140
@@ -54,9 +54,8 @@ import {
|
||||
type BskyAgent,
|
||||
type RichText,
|
||||
} from '@atproto/api'
|
||||
import {msg, plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
@@ -73,7 +72,6 @@ import {
|
||||
} from '#/lib/constants'
|
||||
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {mimeToExt} from '#/lib/media/video/util'
|
||||
import {useCallOnce} from '#/lib/once'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
@@ -123,9 +121,10 @@ import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
|
||||
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
|
||||
import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as EmojiPicker from '#/components/EmojiPicker'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
|
||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
|
||||
@@ -186,7 +185,6 @@ export const ComposePost = ({
|
||||
onPostSuccess,
|
||||
quote: initQuote,
|
||||
mention: initMention,
|
||||
openEmojiPicker,
|
||||
text: initText,
|
||||
imageUris: initImageUris,
|
||||
videoUri: initVideoUri,
|
||||
@@ -197,16 +195,17 @@ export const ComposePost = ({
|
||||
cancelRef?: React.RefObject<CancelRef | null>
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const currentDid = currentAccount!.did
|
||||
const {closeComposer} = useComposerControls()
|
||||
const {_} = useLingui()
|
||||
const {t: l, i18n} = useLingui()
|
||||
const requireAltTextEnabled = useRequireAltTextEnabled()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
const textInput = useRef<TextInputRef>(null)
|
||||
const textInputRef = useRef<TextInputRef>(null)
|
||||
const discardPromptControl = Prompt.usePromptControl()
|
||||
const {mutateAsync: saveDraft, isPending: _isSavingDraft} =
|
||||
useSaveDraftMutation()
|
||||
@@ -313,7 +312,7 @@ export const ComposePost = ({
|
||||
abortController,
|
||||
},
|
||||
})
|
||||
processVideo(
|
||||
void processVideo(
|
||||
asset,
|
||||
videoAction => {
|
||||
composerDispatch({
|
||||
@@ -328,10 +327,10 @@ export const ComposePost = ({
|
||||
agent,
|
||||
currentDid,
|
||||
abortController.signal,
|
||||
_,
|
||||
i18n,
|
||||
)
|
||||
},
|
||||
[_, agent, currentDid, composerDispatch],
|
||||
[i18n, agent, currentDid, composerDispatch],
|
||||
)
|
||||
|
||||
const onInitVideo = useNonReactiveCallback(() => {
|
||||
@@ -460,7 +459,7 @@ export const ComposePost = ({
|
||||
}
|
||||
|
||||
// Start video compression and upload
|
||||
processVideo(
|
||||
void processVideo(
|
||||
asset,
|
||||
videoAction => {
|
||||
composerDispatch({
|
||||
@@ -475,7 +474,7 @@ export const ComposePost = ({
|
||||
agent,
|
||||
currentDid,
|
||||
abortController.signal,
|
||||
_,
|
||||
i18n,
|
||||
)
|
||||
} catch (e) {
|
||||
logger.error('Failed to restore video from draft', {
|
||||
@@ -484,7 +483,7 @@ export const ComposePost = ({
|
||||
})
|
||||
}
|
||||
},
|
||||
[_, agent, currentDid, composerDispatch],
|
||||
[i18n, agent, currentDid, composerDispatch],
|
||||
)
|
||||
|
||||
const handleSelectDraft = useCallback(
|
||||
@@ -558,11 +557,11 @@ export const ComposePost = ({
|
||||
const getDraftSaveError = useCallback(
|
||||
(e: unknown): string => {
|
||||
if (e instanceof AppBskyDraftCreateDraft.DraftLimitReachedError) {
|
||||
return _(msg`You've reached the maximum number of drafts`)
|
||||
return l`You've reached the maximum number of drafts`
|
||||
}
|
||||
return _(msg`Failed to save draft`)
|
||||
return l`Failed to save draft`
|
||||
},
|
||||
[_],
|
||||
[l],
|
||||
)
|
||||
|
||||
const validateDraftTextOrError = useCallback((): boolean => {
|
||||
@@ -571,14 +570,12 @@ export const ComposePost = ({
|
||||
)
|
||||
if (tooLong) {
|
||||
setError(
|
||||
_(
|
||||
msg`One or more posts are too long to save as a draft. ${plural(MAX_DRAFT_GRAPHEME_LENGTH, {one: 'The maximum number of characters is # character.', other: 'The maximum number of characters is # characters.'})}`,
|
||||
),
|
||||
l`One or more posts are too long to save as a draft. ${plural(MAX_DRAFT_GRAPHEME_LENGTH, {one: 'The maximum number of characters is # character.', other: 'The maximum number of characters is # characters.'})}`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [composerState.thread.posts, _])
|
||||
}, [composerState.thread.posts, l])
|
||||
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
setError('')
|
||||
@@ -710,7 +707,7 @@ export const ComposePost = ({
|
||||
)
|
||||
|
||||
const onPressCancel = useCallback(() => {
|
||||
if (textInput.current?.maybeClosePopup()) {
|
||||
if (textInputRef.current?.maybeClosePopup()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -768,21 +765,21 @@ export const ComposePost = ({
|
||||
const media = thread.posts[i].embed.media
|
||||
if (media) {
|
||||
if (media.type === 'images' && media.images.some(img => !img.alt)) {
|
||||
return _(msg`One or more images is missing alt text.`)
|
||||
return l`One or more images is missing alt text.`
|
||||
}
|
||||
if (media.type === 'gif' && !media.alt) {
|
||||
return _(msg`One or more GIFs is missing alt text.`)
|
||||
return l`One or more GIFs is missing alt text.`
|
||||
}
|
||||
if (
|
||||
media.type === 'video' &&
|
||||
media.video.status !== 'error' &&
|
||||
!media.video.altText
|
||||
) {
|
||||
return _(msg`One or more videos is missing alt text.`)
|
||||
return l`One or more videos is missing alt text.`
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [thread, requireAltTextEnabled, _])
|
||||
}, [thread, requireAltTextEnabled, l])
|
||||
|
||||
const canPost =
|
||||
!missingAltError &&
|
||||
@@ -895,11 +892,9 @@ export const ComposePost = ({
|
||||
|
||||
let err = cleanError(e.message)
|
||||
if (err.includes('not locate record')) {
|
||||
err = _(
|
||||
msg`We're sorry! The post you are replying to has been deleted.`,
|
||||
)
|
||||
err = l`We're sorry! The post you are replying to has been deleted.`
|
||||
} else if (e instanceof EmbeddingDisabledError) {
|
||||
err = _(msg`This post's author has disabled quote posts.`)
|
||||
err = l`This post's author has disabled quote posts.`
|
||||
}
|
||||
setError(err)
|
||||
setIsPublishing(false)
|
||||
@@ -979,14 +974,14 @@ export const ComposePost = ({
|
||||
<Toast.Icon />
|
||||
<Toast.Text>
|
||||
{thread.posts.length > 1
|
||||
? _(msg`Your posts were sent`)
|
||||
? l`Your posts were sent`
|
||||
: replyTo
|
||||
? _(msg`Your reply was sent`)
|
||||
: _(msg`Your post was sent`)}
|
||||
? l`Your reply was sent`
|
||||
: l`Your post was sent`}
|
||||
</Toast.Text>
|
||||
{postUri && (
|
||||
<Toast.Action
|
||||
label={_(msg`View post`)}
|
||||
label={l`View post`}
|
||||
onPress={() => {
|
||||
const {host: name, rkey} = new AtUri(postUri)
|
||||
navigation.navigate('PostThread', {name, rkey})
|
||||
@@ -1001,7 +996,7 @@ export const ComposePost = ({
|
||||
)
|
||||
}, 500)
|
||||
}, [
|
||||
_,
|
||||
l,
|
||||
ax,
|
||||
agent,
|
||||
thread,
|
||||
@@ -1026,7 +1021,7 @@ export const ComposePost = ({
|
||||
// Preserves the referential identity passed to each post item.
|
||||
// Avoids re-rendering all posts on each keystroke.
|
||||
const onComposerPostPublish = useNonReactiveCallback(() => {
|
||||
onPressPublish()
|
||||
void onPressPublish()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1047,7 +1042,7 @@ export const ComposePost = ({
|
||||
setPublishOnUpload(false)
|
||||
} else if (uploadingVideos === 0) {
|
||||
setPublishOnUpload(false)
|
||||
onPressPublish()
|
||||
void onPressPublish()
|
||||
}
|
||||
}
|
||||
}, [thread.posts, onPressPublish, publishOnUpload])
|
||||
@@ -1068,17 +1063,6 @@ export const ComposePost = ({
|
||||
}
|
||||
}
|
||||
|
||||
const onEmojiButtonPress = useCallback(() => {
|
||||
const rect = textInput.current?.getCursorPosition()
|
||||
if (rect) {
|
||||
openEmojiPicker?.({
|
||||
...rect,
|
||||
nextFocusRef:
|
||||
textInput as unknown as React.MutableRefObject<HTMLElement>,
|
||||
})
|
||||
}
|
||||
}, [openEmojiPicker])
|
||||
|
||||
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
||||
useEffect(() => {
|
||||
if (composerState.mutableNeedsFocusActive) {
|
||||
@@ -1086,7 +1070,7 @@ export const ComposePost = ({
|
||||
// On Android, this risks getting the cursor stuck behind the keyboard.
|
||||
// Not worth it.
|
||||
if (!IS_ANDROID) {
|
||||
textInput.current?.focus()
|
||||
textInputRef.current?.focus()
|
||||
}
|
||||
}
|
||||
}, [composerState])
|
||||
@@ -1127,7 +1111,6 @@ export const ComposePost = ({
|
||||
!isEmptyPost(activePost) && (!nextPost || !isEmptyPost(nextPost))
|
||||
}
|
||||
onError={setError}
|
||||
onEmojiButtonPress={onEmojiButtonPress}
|
||||
onSelectVideo={selectVideo}
|
||||
onAddPost={() => {
|
||||
composerDispatch({
|
||||
@@ -1137,6 +1120,7 @@ export const ComposePost = ({
|
||||
currentLanguages={currentLanguages}
|
||||
onSelectLanguage={onSelectLanguage}
|
||||
openGallery={openGallery}
|
||||
textInputRef={textInputRef}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
@@ -1189,7 +1173,13 @@ export const ComposePost = ({
|
||||
layout={native(LinearTransition)}
|
||||
onScroll={scrollHandler}
|
||||
contentContainerStyle={a.flex_grow}
|
||||
style={a.flex_1}
|
||||
style={[
|
||||
a.flex_1,
|
||||
web({
|
||||
scrollbarGutter: 'stable',
|
||||
scrollbarColor: `${t.palette.contrast_200} transparent`,
|
||||
}),
|
||||
]}
|
||||
keyboardShouldPersistTaps="always"
|
||||
onContentSizeChange={onScrollViewContentSizeChange}
|
||||
onLayout={onScrollViewLayout}>
|
||||
@@ -1199,7 +1189,7 @@ export const ComposePost = ({
|
||||
<ComposerPost
|
||||
post={post}
|
||||
dispatch={composerDispatch}
|
||||
textInput={post.id === activePost.id ? textInput : null}
|
||||
textInputRef={post.id === activePost.id ? textInputRef : null}
|
||||
isFirstPost={index === 0}
|
||||
isLastPost={index === thread.posts.length - 1}
|
||||
isPartOfThread={thread.posts.length > 1}
|
||||
@@ -1224,9 +1214,9 @@ export const ComposePost = ({
|
||||
{replyTo ? (
|
||||
<Prompt.Basic
|
||||
control={discardPromptControl}
|
||||
title={_(msg`Discard draft?`)}
|
||||
title={l`Discard draft?`}
|
||||
description=""
|
||||
confirmButtonCta={_(msg`Discard`)}
|
||||
confirmButtonCta={l`Discard`}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={handleDiscard}
|
||||
/>
|
||||
@@ -1264,21 +1254,17 @@ export const ComposePost = ({
|
||||
<Prompt.Actions>
|
||||
{allPostsWithinLimit && (
|
||||
<Prompt.Action
|
||||
cta={
|
||||
composerState.draftId
|
||||
? _(msg`Save changes`)
|
||||
: _(msg`Save draft`)
|
||||
}
|
||||
cta={composerState.draftId ? l`Save changes` : l`Save draft`}
|
||||
onPress={handleSaveDraft}
|
||||
color="primary"
|
||||
/>
|
||||
)}
|
||||
<Prompt.Action
|
||||
cta={_(msg`Discard`)}
|
||||
cta={l`Discard`}
|
||||
onPress={handleDiscard}
|
||||
color="negative_subtle"
|
||||
/>
|
||||
<Prompt.Cancel cta={_(msg`Keep editing`)} />
|
||||
<Prompt.Cancel cta={l`Keep editing`} />
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
)}
|
||||
@@ -1290,7 +1276,7 @@ export const ComposePost = ({
|
||||
let ComposerPost = memo(function ComposerPost({
|
||||
post,
|
||||
dispatch,
|
||||
textInput,
|
||||
textInputRef,
|
||||
isActive,
|
||||
isReply,
|
||||
isFirstPost,
|
||||
@@ -1305,7 +1291,7 @@ let ComposerPost = memo(function ComposerPost({
|
||||
}: {
|
||||
post: PostDraft
|
||||
dispatch: (action: ComposerAction) => void
|
||||
textInput: React.Ref<TextInputRef>
|
||||
textInputRef: React.RefObject<TextInputRef | null> | null
|
||||
isActive: boolean
|
||||
isReply: boolean
|
||||
isFirstPost: boolean
|
||||
@@ -1320,16 +1306,16 @@ let ComposerPost = memo(function ComposerPost({
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
const currentDid = currentAccount!.did
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {data: currentProfile} = useProfileQuery({did: currentDid})
|
||||
const richtext = post.richtext
|
||||
const isTextOnly = !post.embed.link && !post.embed.quote && !post.embed.media
|
||||
const forceMinHeight = IS_WEB && isTextOnly && isActive
|
||||
const selectTextInputPlaceholder = isReply
|
||||
? isFirstPost
|
||||
? _(msg`Write your reply`)
|
||||
: _(msg`Add another post`)
|
||||
: _(msg`What's up?`)
|
||||
? l`Write your reply`
|
||||
: l`Add another post`
|
||||
: l`What's up?`
|
||||
const discardPromptControl = Prompt.usePromptControl()
|
||||
|
||||
const dispatchPost = useCallback(
|
||||
@@ -1369,7 +1355,7 @@ let ComposerPost = memo(function ComposerPost({
|
||||
if (IS_NATIVE) return // web only
|
||||
const [mimeType] = uri.slice('data:'.length).split(';')
|
||||
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
|
||||
Toast.show(_(msg`Unsupported video type: ${mimeType}`), {
|
||||
Toast.show(l`Unsupported video type: ${mimeType}`, {
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
@@ -1384,7 +1370,7 @@ let ComposerPost = memo(function ComposerPost({
|
||||
onImageAdd([res])
|
||||
}
|
||||
},
|
||||
[post.id, onSelectVideo, onImageAdd, _],
|
||||
[post.id, onSelectVideo, onImageAdd, l],
|
||||
)
|
||||
|
||||
useHideKeyboardOnBackground()
|
||||
@@ -1406,7 +1392,7 @@ let ComposerPost = memo(function ComposerPost({
|
||||
style={[a.mt_xs]}
|
||||
/>
|
||||
<TextInput
|
||||
ref={textInput}
|
||||
ref={textInputRef}
|
||||
style={[a.pt_xs]}
|
||||
richtext={richtext}
|
||||
placeholder={selectTextInputPlaceholder}
|
||||
@@ -1429,19 +1415,20 @@ let ComposerPost = memo(function ComposerPost({
|
||||
onError={onError}
|
||||
onPressPublish={onPublish}
|
||||
accessible={true}
|
||||
accessibilityLabel={_(msg`Write post`)}
|
||||
accessibilityHint={_(
|
||||
msg`Compose posts up to ${plural(MAX_GRAPHEME_LENGTH || 0, {
|
||||
accessibilityLabel={l`Write post`}
|
||||
accessibilityHint={l`Compose posts up to ${plural(
|
||||
MAX_GRAPHEME_LENGTH || 0,
|
||||
{
|
||||
other: '# characters',
|
||||
})} in length`,
|
||||
)}
|
||||
},
|
||||
)} in length`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{canRemovePost && isActive && (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Delete post`)}
|
||||
label={l`Delete post`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
@@ -1466,15 +1453,15 @@ let ComposerPost = memo(function ComposerPost({
|
||||
</Button>
|
||||
<Prompt.Basic
|
||||
control={discardPromptControl}
|
||||
title={_(msg`Discard post?`)}
|
||||
description={_(msg`Are you sure you'd like to discard this post?`)}
|
||||
title={l`Discard post?`}
|
||||
description={l`Are you sure you'd like to discard this post?`}
|
||||
onConfirm={() => {
|
||||
dispatch({
|
||||
type: 'remove_post',
|
||||
postId: post.id,
|
||||
})
|
||||
}}
|
||||
confirmButtonCta={_(msg`Discard`)}
|
||||
confirmButtonCta={l`Discard`}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</>
|
||||
@@ -1531,7 +1518,8 @@ function ComposerTopBar({
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={topBarAnimatedStyle}
|
||||
@@ -1544,7 +1532,7 @@ function ComposerTopBar({
|
||||
IS_LIQUID_GLASS ? [a.px_lg, a.pt_lg, a.pb_md] : [a.p_sm],
|
||||
]}>
|
||||
<Button
|
||||
label={_(msg`Cancel`)}
|
||||
label={l`Cancel`}
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
shape="default"
|
||||
@@ -1552,9 +1540,7 @@ function ComposerTopBar({
|
||||
style={[{paddingLeft: 7, paddingRight: 7}]}
|
||||
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}
|
||||
onPress={onCancel}
|
||||
accessibilityHint={_(
|
||||
msg`Closes post composer and discards post draft`,
|
||||
)}>
|
||||
accessibilityHint={l`Closes post composer and discards post draft`}>
|
||||
<ButtonText style={[a.text_md]} maxFontSizeMultiplier={2}>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
@@ -1588,35 +1574,27 @@ function ComposerTopBar({
|
||||
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',
|
||||
}),
|
||||
)
|
||||
? l({
|
||||
message: 'Publish replies',
|
||||
comment:
|
||||
'Accessibility label for button to publish multiple replies in a thread',
|
||||
})
|
||||
: l({
|
||||
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',
|
||||
}),
|
||||
)
|
||||
? l({
|
||||
message: 'Publish posts',
|
||||
comment:
|
||||
'Accessibility label for button to publish multiple posts in a thread',
|
||||
})
|
||||
: l({
|
||||
message: 'Publish post',
|
||||
comment:
|
||||
'Accessibility label for button to publish a single post',
|
||||
})
|
||||
}
|
||||
color="primary"
|
||||
size="small"
|
||||
@@ -1832,27 +1810,27 @@ function ComposerFooter({
|
||||
post,
|
||||
dispatch,
|
||||
showAddButton,
|
||||
onEmojiButtonPress,
|
||||
onSelectVideo,
|
||||
onAddPost,
|
||||
currentLanguages,
|
||||
onSelectLanguage,
|
||||
openGallery,
|
||||
textInputRef,
|
||||
}: {
|
||||
post: PostDraft
|
||||
dispatch: (action: PostAction) => void
|
||||
showAddButton: boolean
|
||||
onEmojiButtonPress: () => void
|
||||
onError: (error: string) => void
|
||||
onSelectVideo: (postId: string, asset: ImagePickerAsset) => void
|
||||
onAddPost: () => void
|
||||
currentLanguages: string[]
|
||||
onSelectLanguage?: (language: string) => void
|
||||
openGallery?: boolean
|
||||
textInputRef: React.RefObject<TextInputRef | null>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const {t: l} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
/*
|
||||
* Once we've allowed a certain type of asset to be selected, we don't allow
|
||||
* other types of media to be selected.
|
||||
@@ -1975,17 +1953,23 @@ function ComposerFooter({
|
||||
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">
|
||||
<EmojiSmileIcon size="lg" />
|
||||
</Button>
|
||||
{IS_WEB && gtPhone ? (
|
||||
<EmojiPicker.Root nextFocusRef={textInputRef}>
|
||||
<EmojiPicker.Trigger label={l`Open emoji picker`}>
|
||||
{({props}) => (
|
||||
<Button
|
||||
style={a.p_sm}
|
||||
label={props.accessibilityLabel}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="primary"
|
||||
{...props}>
|
||||
<EmojiSmileIcon size="lg" />
|
||||
</Button>
|
||||
)}
|
||||
</EmojiPicker.Trigger>
|
||||
<EmojiPicker.Picker />
|
||||
</EmojiPicker.Root>
|
||||
) : null}
|
||||
</ToolbarWrapper>
|
||||
)}
|
||||
@@ -1994,7 +1978,7 @@ function ComposerFooter({
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||
{showAddButton && (
|
||||
<Button
|
||||
label={_(msg`Add another post to thread`)}
|
||||
label={l`Add another post to thread`}
|
||||
onPress={onAddPost}
|
||||
style={[a.p_sm]}
|
||||
variant="ghost"
|
||||
@@ -2276,7 +2260,7 @@ function ErrorBanner({
|
||||
clearVideo: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const videoError =
|
||||
videoState.status === 'error' ? videoState.error : undefined
|
||||
@@ -2311,7 +2295,7 @@ function ErrorBanner({
|
||||
{error}
|
||||
</Text>
|
||||
<Button
|
||||
label={_(msg`Dismiss error`)}
|
||||
label={l`Dismiss error`}
|
||||
size="tiny"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
@@ -2358,7 +2342,7 @@ function ToolbarWrapper({
|
||||
|
||||
function VideoUploadToolbar({state}: {state: VideoState}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const progress = state.progress
|
||||
const shouldRotate =
|
||||
state.status === 'processing' && (progress === 0 || progress === 1)
|
||||
@@ -2390,34 +2374,34 @@ function VideoUploadToolbar({state}: {state: VideoState}) {
|
||||
switch (state.status) {
|
||||
case 'compressing':
|
||||
if (isGif) {
|
||||
text = _(msg`Compressing GIF...`)
|
||||
text = l`Compressing GIF...`
|
||||
} else {
|
||||
text = _(msg`Compressing video...`)
|
||||
text = l`Compressing video...`
|
||||
}
|
||||
break
|
||||
case 'uploading':
|
||||
if (isGif) {
|
||||
text = _(msg`Uploading GIF...`)
|
||||
text = l`Uploading GIF...`
|
||||
} else {
|
||||
text = _(msg`Uploading video...`)
|
||||
text = l`Uploading video...`
|
||||
}
|
||||
break
|
||||
case 'processing':
|
||||
if (isGif) {
|
||||
text = _(msg`Processing GIF...`)
|
||||
text = l`Processing GIF...`
|
||||
} else {
|
||||
text = _(msg`Processing video...`)
|
||||
text = l`Processing video...`
|
||||
}
|
||||
break
|
||||
case 'error':
|
||||
text = _(msg`Error`)
|
||||
text = l`Error`
|
||||
wheelProgress = 100
|
||||
break
|
||||
case 'done':
|
||||
if (isGif) {
|
||||
text = _(msg`GIF uploaded`)
|
||||
text = l`GIF uploaded`
|
||||
} else {
|
||||
text = _(msg`Video uploaded`)
|
||||
text = l`Video uploaded`
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export type SelectMediaButtonProps = {
|
||||
type: AssetType
|
||||
assets: ImagePickerAsset[]
|
||||
errors: string[]
|
||||
}) => void
|
||||
}) => void | Promise<void>
|
||||
/**
|
||||
* If true, automatically open the media picker when the component mounts.
|
||||
*/
|
||||
|
||||
@@ -397,6 +397,8 @@ function parseGifFromUrl(
|
||||
url.searchParams.delete('ww')
|
||||
url.searchParams.delete('hh')
|
||||
url.searchParams.delete('alt')
|
||||
url.searchParams.delete('mp4')
|
||||
url.searchParams.delete('webm')
|
||||
|
||||
return {url: url.toString(), width, height, alt}
|
||||
} catch {
|
||||
|
||||
@@ -263,7 +263,7 @@ export async function processVideo(
|
||||
agent: BskyAgent,
|
||||
did: string,
|
||||
signal: AbortSignal,
|
||||
_: I18n['_'],
|
||||
i18n: I18n,
|
||||
) {
|
||||
let video: CompressedVideo | undefined
|
||||
try {
|
||||
@@ -274,7 +274,7 @@ export async function processVideo(
|
||||
signal,
|
||||
})
|
||||
} catch (e) {
|
||||
const message = getCompressErrorMessage(e, _)
|
||||
const message = getCompressErrorMessage(e, i18n)
|
||||
if (message !== null) {
|
||||
dispatch({
|
||||
type: 'to_error',
|
||||
@@ -297,13 +297,13 @@ export async function processVideo(
|
||||
agent,
|
||||
did,
|
||||
signal,
|
||||
_,
|
||||
i18n,
|
||||
setProgress: p => {
|
||||
dispatch({type: 'update_progress', progress: p, signal})
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
const message = getUploadErrorMessage(e, _)
|
||||
const message = getUploadErrorMessage(e, i18n)
|
||||
if (message !== null) {
|
||||
dispatch({
|
||||
type: 'to_error',
|
||||
@@ -355,7 +355,7 @@ export async function processVideo(
|
||||
logger.error('Error processing video', {safeMessage: e})
|
||||
dispatch({
|
||||
type: 'to_error',
|
||||
error: _(msg`Video failed to process`),
|
||||
error: i18n._(msg`Video failed to process`),
|
||||
signal,
|
||||
})
|
||||
return // Exit async loop
|
||||
@@ -387,20 +387,20 @@ export async function processVideo(
|
||||
}
|
||||
}
|
||||
|
||||
function getCompressErrorMessage(e: unknown, _: I18n['_']): string | null {
|
||||
function getCompressErrorMessage(e: unknown, i18n: I18n): string | null {
|
||||
if (e instanceof AbortError) {
|
||||
return null
|
||||
}
|
||||
if (e instanceof VideoTooLargeError) {
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`The selected video is larger than 100 MB. Please try again with a smaller file.`,
|
||||
)
|
||||
}
|
||||
logger.error('Error compressing video', {safeMessage: e})
|
||||
return _(msg`An error occurred while compressing the video.`)
|
||||
return i18n._(msg`An error occurred while compressing the video.`)
|
||||
}
|
||||
|
||||
function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null {
|
||||
function getUploadErrorMessage(e: unknown, i18n: I18n): string | null {
|
||||
if (e instanceof AbortError) {
|
||||
return null
|
||||
}
|
||||
@@ -408,38 +408,38 @@ function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null {
|
||||
// https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
|
||||
switch (e.message) {
|
||||
case 'User is not allowed to upload videos':
|
||||
return _(msg`You are not allowed to upload videos.`)
|
||||
return i18n._(msg`You are not allowed to upload videos.`)
|
||||
case 'Uploading is disabled at the moment':
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`,
|
||||
)
|
||||
case "Failed to get user's upload stats":
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
|
||||
)
|
||||
case 'User has exceeded daily upload bytes limit':
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`You've reached your daily limit for video uploads (too many bytes)`,
|
||||
)
|
||||
case 'User has exceeded daily upload videos limit':
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`You've reached your daily limit for video uploads (too many videos)`,
|
||||
)
|
||||
case 'Account is not old enough to upload videos':
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`Your account is not yet old enough to upload videos. Please try again later.`,
|
||||
)
|
||||
case 'file size (100000001 bytes) is larger than the maximum allowed size (100000000 bytes)':
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`The selected video is larger than 100 MB. Please try again with a smaller file.`,
|
||||
)
|
||||
case 'Confirm your email address to upload videos':
|
||||
return _(msg`Please confirm your email address to upload videos.`)
|
||||
return i18n._(msg`Please confirm your email address to upload videos.`)
|
||||
}
|
||||
}
|
||||
|
||||
if (isNetworkError(e)) {
|
||||
return _(
|
||||
return i18n._(
|
||||
msg`An error occurred while uploading the video. Please check your internet connection and try again.`,
|
||||
)
|
||||
} else {
|
||||
@@ -448,5 +448,5 @@ function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null {
|
||||
}
|
||||
|
||||
const message = e instanceof Error ? e.message : ''
|
||||
return _(msg`An error occurred while uploading the video. ${message}`)
|
||||
return i18n._(msg`An error occurred while uploading the video. ${message}`)
|
||||
}
|
||||
|
||||
@@ -32,11 +32,11 @@ import {
|
||||
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||
import {atoms as a, useAlf} from '#/alf'
|
||||
import {normalizeTextStyles} from '#/alf/typography'
|
||||
import {type Emoji} from '#/components/EmojiPicker'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {type TextInputProps} from './TextInput.types'
|
||||
import {type AutocompleteRef, createSuggestion} from './web/Autocomplete'
|
||||
import {type Emoji} from './web/EmojiPicker'
|
||||
import {LinkDecorator} from './web/LinkDecorator'
|
||||
import {TagDecorator} from './web/TagDecorator'
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import {useEffect, useMemo, useRef} from 'react'
|
||||
import {Pressable, useWindowDimensions, View} from 'react-native'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {DismissableLayer, FocusScope} from 'radix-ui/internal'
|
||||
|
||||
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||
import {atoms as a, flatten} from '#/alf'
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
const HEIGHT_OFFSET = 40
|
||||
const WIDTH_OFFSET = 100
|
||||
const PICKER_HEIGHT = 435 + HEIGHT_OFFSET
|
||||
const PICKER_WIDTH = 350 + WIDTH_OFFSET
|
||||
|
||||
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({state, close, pinToTop}: IProps) {
|
||||
const {_} = useLingui()
|
||||
const {height, width} = useWindowDimensions()
|
||||
|
||||
const isShiftDown = useRef(false)
|
||||
|
||||
const position = useMemo(() => {
|
||||
if (pinToTop) {
|
||||
return {
|
||||
top: state.pos.top - PICKER_HEIGHT + HEIGHT_OFFSET - 10,
|
||||
left: state.pos.left,
|
||||
}
|
||||
}
|
||||
|
||||
const fitsBelow = state.pos.top + PICKER_HEIGHT < height
|
||||
const fitsAbove = PICKER_HEIGHT < state.pos.top
|
||||
const placeOnLeft = PICKER_WIDTH < state.pos.left
|
||||
const screenYMiddle = height / 2 - PICKER_HEIGHT / 2
|
||||
|
||||
if (fitsBelow) {
|
||||
return {
|
||||
top: state.pos.top + HEIGHT_OFFSET,
|
||||
}
|
||||
} else if (fitsAbove) {
|
||||
return {
|
||||
bottom: height - state.pos.bottom + HEIGHT_OFFSET,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
top: screenYMiddle,
|
||||
left: placeOnLeft ? state.pos.left - PICKER_WIDTH : undefined,
|
||||
right: !placeOnLeft
|
||||
? width - state.pos.right - PICKER_WIDTH
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
}, [state.pos, height, width, pinToTop])
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isOpen) return
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
isShiftDown.current = true
|
||||
}
|
||||
}
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
isShiftDown.current = false
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('keyup', onKeyUp, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('keyup', onKeyUp, true)
|
||||
}
|
||||
}, [state.isOpen])
|
||||
|
||||
const onInsert = (emoji: Emoji) => {
|
||||
textInputWebEmitter.emit('emoji-inserted', emoji)
|
||||
|
||||
if (!isShiftDown.current) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.isOpen) return null
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<FocusScope.FocusScope
|
||||
loop
|
||||
trapped
|
||||
onUnmountAutoFocus={e => {
|
||||
const nextFocusRef = state.pos.nextFocusRef
|
||||
const node = nextFocusRef?.current
|
||||
if (node) {
|
||||
e.preventDefault()
|
||||
node.focus()
|
||||
}
|
||||
}}>
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel={_(msg`Close emoji picker`)}
|
||||
accessibilityHint={_(msg`Closes the emoji picker`)}
|
||||
onPress={close}
|
||||
style={[a.fixed, a.inset_0]}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={flatten([
|
||||
a.fixed,
|
||||
a.w_full,
|
||||
a.h_full,
|
||||
a.align_center,
|
||||
a.z_10,
|
||||
{
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
])}>
|
||||
<View style={[{position: 'absolute'}, position]}>
|
||||
<DismissableLayer.DismissableLayer
|
||||
onFocusOutside={evt => evt.preventDefault()}
|
||||
onDismiss={close}>
|
||||
<Picker
|
||||
data={async () => {
|
||||
return (await import('@emoji-mart/data')).default
|
||||
}}
|
||||
onEmojiSelect={onInsert}
|
||||
autoFocus={true}
|
||||
/>
|
||||
</DismissableLayer.DismissableLayer>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel={_(msg`Close emoji picker`)}
|
||||
accessibilityHint={_(msg`Closes the emoji picker`)}
|
||||
onPress={close}
|
||||
style={[a.fixed, a.inset_0]}
|
||||
/>
|
||||
</FocusScope.FocusScope>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import {useCallback} 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 = useCallback(async () => {
|
||||
if (loadRequested) return
|
||||
loadRequested = true
|
||||
try {
|
||||
const data = (await import('@emoji-mart/data')).default
|
||||
init({data})
|
||||
} catch (e) {}
|
||||
}, [])
|
||||
if (immediate) preload()
|
||||
return preload
|
||||
}
|
||||
@@ -7,7 +7,10 @@
|
||||
*/
|
||||
|
||||
import {type TransformsStyle} from 'react-native'
|
||||
import {type MeasuredDimensions} from 'react-native-reanimated'
|
||||
import {
|
||||
type AnimatedRef,
|
||||
type MeasuredDimensions,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
export type Dimensions = {
|
||||
width: number
|
||||
@@ -25,6 +28,7 @@ export type ImageSource = {
|
||||
thumbUri: string
|
||||
thumbDimensions: Dimensions | null
|
||||
thumbRect: MeasuredDimensions | null
|
||||
thumbRef?: AnimatedRef<any> | null
|
||||
alt?: string
|
||||
type: 'image' | 'circle-avi' | 'rect-avi'
|
||||
}
|
||||
|
||||
@@ -23,8 +23,10 @@ import Animated, {
|
||||
cancelAnimation,
|
||||
interpolate,
|
||||
measure,
|
||||
type MeasuredDimensions,
|
||||
ReduceMotion,
|
||||
runOnJS,
|
||||
runOnUI,
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedRef,
|
||||
@@ -73,12 +75,11 @@ const FAST_SPRING: WithSpringConfig = {
|
||||
}
|
||||
|
||||
function canAnimate(lightbox: Lightbox): boolean {
|
||||
return (
|
||||
!PlatformInfo.getIsReducedMotionEnabled() &&
|
||||
lightbox.images.every(
|
||||
img => img.thumbRect && (img.dimensions || img.thumbDimensions),
|
||||
)
|
||||
)
|
||||
if (PlatformInfo.getIsReducedMotionEnabled()) {
|
||||
return false
|
||||
}
|
||||
const img = lightbox.images[lightbox.index]
|
||||
return !!img.thumbRect && !!(img.dimensions || img.thumbDimensions)
|
||||
}
|
||||
|
||||
export default function ImageViewRoot({
|
||||
@@ -99,6 +100,9 @@ export default function ImageViewRoot({
|
||||
'portrait',
|
||||
)
|
||||
const openProgress = useSharedValue(0)
|
||||
const thumbRects = useSharedValue<Record<number, MeasuredDimensions | null>>(
|
||||
{},
|
||||
)
|
||||
|
||||
if (!activeLightbox && nextLightbox) {
|
||||
setActiveLightbox(nextLightbox)
|
||||
@@ -109,6 +113,12 @@ export default function ImageViewRoot({
|
||||
return
|
||||
}
|
||||
|
||||
const initial: Record<number, MeasuredDimensions | null> = {}
|
||||
nextLightbox.images.forEach((img, i) => {
|
||||
initial[i] = img.thumbRect ?? null
|
||||
})
|
||||
thumbRects.set(initial)
|
||||
|
||||
const isAnimated = canAnimate(nextLightbox)
|
||||
|
||||
// https://github.com/software-mansion/react-native-reanimated/issues/6677
|
||||
@@ -125,13 +135,21 @@ export default function ImageViewRoot({
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [nextLightbox, openProgress])
|
||||
}, [nextLightbox, openProgress, thumbRects])
|
||||
|
||||
const onFullyClosed = useCallback(() => {
|
||||
setActiveLightbox(null)
|
||||
runOnUI(() => {
|
||||
'worklet'
|
||||
thumbRects.set({})
|
||||
})()
|
||||
}, [thumbRects])
|
||||
|
||||
useAnimatedReaction(
|
||||
() => openProgress.get() === 0,
|
||||
(isGone, wasGone) => {
|
||||
if (isGone && !wasGone) {
|
||||
runOnJS(setActiveLightbox)(null)
|
||||
runOnJS(onFullyClosed)()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -184,6 +202,7 @@ export default function ImageViewRoot({
|
||||
onFlyAway={onFlyAway}
|
||||
safeAreaRef={ref}
|
||||
openProgress={openProgress}
|
||||
thumbRects={thumbRects}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
@@ -200,6 +219,7 @@ function ImageView({
|
||||
onFlyAway,
|
||||
safeAreaRef,
|
||||
openProgress,
|
||||
thumbRects,
|
||||
}: {
|
||||
lightbox: Lightbox
|
||||
orientation: 'portrait' | 'landscape'
|
||||
@@ -209,6 +229,7 @@ function ImageView({
|
||||
onFlyAway: () => void
|
||||
safeAreaRef: AnimatedRef<View>
|
||||
openProgress: SharedValue<number>
|
||||
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
|
||||
}) {
|
||||
const {images, index: initialImageIndex} = lightbox
|
||||
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
|
||||
@@ -216,7 +237,7 @@ function ImageView({
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [imageIndex, setImageIndex] = useState(initialImageIndex)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const [isAltExpanded, setAltExpanded] = useState(false)
|
||||
const [isAltExpanded, setIsAltExpanded] = useState(false)
|
||||
const dismissSwipeTranslateY = useSharedValue(0)
|
||||
const isFlyingAway = useSharedValue(false)
|
||||
|
||||
@@ -287,6 +308,24 @@ function ImageView({
|
||||
}
|
||||
})
|
||||
|
||||
const handleRequestClose = useCallback(() => {
|
||||
const activeRef = images[imageIndex]?.thumbRef
|
||||
if (isAnimated && activeRef) {
|
||||
runOnUI(() => {
|
||||
'worklet'
|
||||
const rect = measure(activeRef)
|
||||
thumbRects.modify(rects => {
|
||||
'worklet'
|
||||
rects[imageIndex] = rect
|
||||
return rects
|
||||
})
|
||||
runOnJS(onRequestClose)()
|
||||
})()
|
||||
} else {
|
||||
onRequestClose()
|
||||
}
|
||||
}, [isAnimated, images, imageIndex, thumbRects, onRequestClose])
|
||||
|
||||
const onTap = useCallback(() => {
|
||||
setShowControls(show => !show)
|
||||
}, [])
|
||||
@@ -355,7 +394,7 @@ function ImageView({
|
||||
onTap={onTap}
|
||||
onZoom={onZoom}
|
||||
imageSrc={imageSrc}
|
||||
onRequestClose={onRequestClose}
|
||||
onRequestClose={handleRequestClose}
|
||||
isScrollViewBeingDragged={isDragging}
|
||||
showControls={showControls}
|
||||
safeAreaRef={safeAreaRef}
|
||||
@@ -364,6 +403,8 @@ function ImageView({
|
||||
isActive={i === imageIndex}
|
||||
dismissSwipeTranslateY={dismissSwipeTranslateY}
|
||||
openProgress={openProgress}
|
||||
thumbRects={thumbRects}
|
||||
imageIndex={i}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
@@ -372,7 +413,7 @@ function ImageView({
|
||||
<Animated.View
|
||||
style={animatedHeaderStyle}
|
||||
renderToHardwareTextureAndroid>
|
||||
<ImageDefaultHeader onRequestClose={onRequestClose} />
|
||||
<ImageDefaultHeader onRequestClose={handleRequestClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={animatedFooterStyle}
|
||||
@@ -381,7 +422,7 @@ function ImageView({
|
||||
images={images}
|
||||
index={imageIndex}
|
||||
isAltExpanded={isAltExpanded}
|
||||
toggleAltExpanded={() => setAltExpanded(e => !e)}
|
||||
toggleAltExpanded={() => setIsAltExpanded(e => !e)}
|
||||
onPressSave={onPressSave}
|
||||
onPressShare={onPressShare}
|
||||
/>
|
||||
@@ -404,6 +445,8 @@ function LightboxImage({
|
||||
safeAreaRef,
|
||||
openProgress,
|
||||
dismissSwipeTranslateY,
|
||||
thumbRects,
|
||||
imageIndex,
|
||||
}: {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
@@ -417,6 +460,8 @@ function LightboxImage({
|
||||
safeAreaRef: AnimatedRef<View>
|
||||
openProgress: SharedValue<number>
|
||||
dismissSwipeTranslateY: SharedValue<number>
|
||||
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
|
||||
imageIndex: number
|
||||
}) {
|
||||
const [fetchedDims, setFetchedDims] = useState<Dimensions | null>(null)
|
||||
const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions
|
||||
@@ -449,7 +494,7 @@ function LightboxImage({
|
||||
return safeArea
|
||||
}, [safeAreaRef, heightDelayedForJSThreadOnly, widthDelayedForJSThreadOnly])
|
||||
|
||||
const {thumbRect} = imageSrc
|
||||
const {thumbRect: thumbRectJS} = imageSrc
|
||||
const transforms = useDerivedValue(() => {
|
||||
'worklet'
|
||||
const safeArea = measureSafeArea()
|
||||
@@ -467,13 +512,21 @@ function LightboxImage({
|
||||
}
|
||||
}
|
||||
|
||||
if (isActive && thumbRect && imageAspect && openProgressValue < 1) {
|
||||
return interpolateTransform(
|
||||
openProgressValue,
|
||||
thumbRect,
|
||||
safeArea,
|
||||
imageAspect,
|
||||
)
|
||||
if (isActive && imageAspect && openProgressValue < 1) {
|
||||
let thumbRect
|
||||
if (_WORKLET) {
|
||||
thumbRect = thumbRects.get()[imageIndex]
|
||||
} else {
|
||||
thumbRect = thumbRectJS
|
||||
}
|
||||
if (thumbRect) {
|
||||
return interpolateTransform(
|
||||
openProgressValue,
|
||||
thumbRect,
|
||||
safeArea,
|
||||
imageAspect,
|
||||
)
|
||||
}
|
||||
}
|
||||
return {
|
||||
isHidden: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import {forwardRef, memo, useCallback, useState} from 'react'
|
||||
import {type JSX} from 'react'
|
||||
import {type ScrollView, View} from 'react-native'
|
||||
import {useAnimatedRef} from 'react-native-reanimated'
|
||||
@@ -35,7 +35,7 @@ export interface PagerWithHeaderProps {
|
||||
onPageSelected?: (index: number) => void
|
||||
onCurrentPageSelected?: (index: number) => void
|
||||
}
|
||||
export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
export const PagerWithHeader = forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
function PageWithHeaderImpl(
|
||||
{
|
||||
children,
|
||||
@@ -49,9 +49,9 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
}: PagerWithHeaderProps,
|
||||
ref,
|
||||
) {
|
||||
const [currentPage, setCurrentPage] = React.useState(0)
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
|
||||
const renderTabBar = React.useCallback(
|
||||
const renderTabBar = useCallback(
|
||||
(props: RenderTabBarFnProps) => {
|
||||
return (
|
||||
<PagerTabBar
|
||||
@@ -76,7 +76,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
],
|
||||
)
|
||||
|
||||
const onPageSelectedInner = React.useCallback(
|
||||
const onPageSelectedInner = useCallback(
|
||||
(index: number) => {
|
||||
setCurrentPage(index)
|
||||
onPageSelected?.(index)
|
||||
@@ -162,7 +162,7 @@ let PagerTabBar = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
PagerTabBar = React.memo(PagerTabBar)
|
||||
PagerTabBar = memo(PagerTabBar)
|
||||
|
||||
function PagerItem({
|
||||
isFocused,
|
||||
|
||||
+101
-80
@@ -27,6 +27,10 @@ import {Link} from '#/view/com/util/Link'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {
|
||||
GalleryBleed,
|
||||
maybeApplyGalleryOffsetStyles,
|
||||
} from '#/components/images/Gallery'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
@@ -155,89 +159,106 @@ function PostInner({
|
||||
const [hover, setHover] = useState(false)
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={itemHref}
|
||||
style={[
|
||||
styles.outer,
|
||||
pal.border,
|
||||
!hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth},
|
||||
style,
|
||||
]}
|
||||
onBeforePress={onBeforePress}
|
||||
onPointerEnter={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHover(false)
|
||||
}}>
|
||||
<SubtleHover hover={hover} />
|
||||
{showReplyLine && <View style={styles.replyLine} />}
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<PostMeta
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={itemHref}
|
||||
/>
|
||||
{replyAuthorDid !== '' && (
|
||||
<PostRepliedTo parentAuthor={replyAuthorDid} />
|
||||
)}
|
||||
<LabelsOnMyPost post={post} />
|
||||
<ContentHider
|
||||
modui={moderation.ui('contentView')}
|
||||
style={styles.contentHider}
|
||||
childContainerStyle={styles.contentHiderChild}>
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentView')}
|
||||
style={[a.pb_xs]}
|
||||
<GalleryBleed>
|
||||
<Link
|
||||
href={itemHref}
|
||||
style={[
|
||||
styles.outer,
|
||||
pal.border,
|
||||
!hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth},
|
||||
style,
|
||||
]}
|
||||
onBeforePress={onBeforePress}
|
||||
onPointerEnter={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHover(false)
|
||||
}}>
|
||||
<SubtleHover hover={hover} />
|
||||
{showReplyLine && <View style={styles.replyLine} />}
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
{richText.text ? (
|
||||
<View style={[a.mb_2xs]}>
|
||||
<RichText
|
||||
enableTags
|
||||
testID="postText"
|
||||
value={richText}
|
||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||
style={[a.flex_1, a.text_md]}
|
||||
authorHandle={post.author.handle}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
{limitLines && (
|
||||
<ShowMoreTextButton
|
||||
style={[a.text_md]}
|
||||
onPress={onPressShowMore}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
) : undefined}
|
||||
<TranslatedPost hideTranslateLink post={post} />
|
||||
{post.embed ? (
|
||||
<Embed
|
||||
embed={post.embed}
|
||||
moderation={moderation}
|
||||
viewContext={PostEmbedViewContext.Feed}
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.layoutContent,
|
||||
maybeApplyGalleryOffsetStyles('meta', {
|
||||
post,
|
||||
modui: moderation.ui('contentList'),
|
||||
additionalCauses: [],
|
||||
}),
|
||||
]}>
|
||||
<PostMeta
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={itemHref}
|
||||
/>
|
||||
{replyAuthorDid !== '' && (
|
||||
<PostRepliedTo parentAuthor={replyAuthorDid} />
|
||||
)}
|
||||
<LabelsOnMyPost post={post} />
|
||||
<ContentHider
|
||||
modui={moderation.ui('contentView')}
|
||||
style={styles.contentHider}
|
||||
childContainerStyle={styles.contentHiderChild}>
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentView')}
|
||||
style={[a.pb_xs]}
|
||||
/>
|
||||
) : null}
|
||||
</ContentHider>
|
||||
<PostControls
|
||||
post={post}
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="Post"
|
||||
/>
|
||||
{richText.text ? (
|
||||
<View style={[a.mb_2xs]}>
|
||||
<RichText
|
||||
enableTags
|
||||
testID="postText"
|
||||
value={richText}
|
||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||
style={[a.flex_1, a.text_md]}
|
||||
authorHandle={post.author.handle}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
{limitLines && (
|
||||
<ShowMoreTextButton
|
||||
style={[a.text_md]}
|
||||
onPress={onPressShowMore}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
) : undefined}
|
||||
<TranslatedPost hideTranslateLink post={post} />
|
||||
{post.embed ? (
|
||||
<View
|
||||
style={maybeApplyGalleryOffsetStyles('embed', {
|
||||
post,
|
||||
modui: moderation.ui('contentList'),
|
||||
additionalCauses: [],
|
||||
})}>
|
||||
<Embed
|
||||
embed={post.embed}
|
||||
moderation={moderation}
|
||||
viewContext={PostEmbedViewContext.Feed}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</ContentHider>
|
||||
<PostControls
|
||||
post={post}
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="Post"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Link>
|
||||
</Link>
|
||||
</GalleryBleed>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+158
-139
@@ -34,6 +34,10 @@ import {Link} from '#/view/com/util/Link'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {
|
||||
GalleryBleed,
|
||||
maybeApplyGalleryOffsetStyles,
|
||||
} from '#/components/images/Gallery'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
@@ -163,6 +167,7 @@ let FeedItemInner = ({
|
||||
const queryClient = useQueryClient()
|
||||
const {openComposer} = useOpenComposer()
|
||||
const pal = usePalette('default')
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const [hover, setHover] = useState(false)
|
||||
|
||||
@@ -293,140 +298,6 @@ let FeedItemInner = ({
|
||||
}
|
||||
}, [reason])
|
||||
|
||||
return (
|
||||
<Link
|
||||
testID={`feedItem-by-${post.author.handle}`}
|
||||
style={outerStyles}
|
||||
href={href}
|
||||
noFeedback
|
||||
accessible={false}
|
||||
onBeforePress={onBeforePress}
|
||||
dataSet={{feedContext}}
|
||||
onPointerEnter={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHover(false)
|
||||
}}>
|
||||
<SubtleHover hover={hover} />
|
||||
<View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
|
||||
<View style={{width: 42}}>
|
||||
{isThreadChild && (
|
||||
<View
|
||||
style={[
|
||||
styles.replyLine,
|
||||
{
|
||||
flexGrow: 1,
|
||||
backgroundColor: pal.colors.replyLine,
|
||||
marginBottom: 4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[a.pt_sm, a.flex_shrink]}>
|
||||
{reason && (
|
||||
<PostFeedReason
|
||||
reason={reason}
|
||||
moderation={moderation}
|
||||
onOpenReposter={onOpenReposter}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
onBeforePress={onOpenAuthor}
|
||||
live={live}
|
||||
/>
|
||||
{isThreadParent && (
|
||||
<View
|
||||
style={[
|
||||
styles.replyLine,
|
||||
{
|
||||
flexGrow: 1,
|
||||
backgroundColor: pal.colors.replyLine,
|
||||
marginTop: live ? 8 : 4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<PostMeta
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={href}
|
||||
onOpenAuthor={onOpenAuthor}
|
||||
/>
|
||||
{showReplyTo &&
|
||||
(parentAuthor || isParentBlocked || isParentNotFound) && (
|
||||
<PostRepliedTo
|
||||
parentAuthor={parentAuthor}
|
||||
isParentBlocked={isParentBlocked}
|
||||
isParentNotFound={isParentNotFound}
|
||||
/>
|
||||
)}
|
||||
<LabelsOnMyPost post={post} />
|
||||
<PostContent
|
||||
moderation={moderation}
|
||||
richText={richText}
|
||||
postEmbed={post.embed}
|
||||
postAuthor={post.author}
|
||||
onOpenEmbed={onOpenEmbed}
|
||||
post={post}
|
||||
threadgateRecord={threadgateRecord}
|
||||
/>
|
||||
<PostControls
|
||||
post={post}
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="FeedItem"
|
||||
feedContext={feedContext}
|
||||
reqId={reqId}
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShowLess={onShowLess}
|
||||
viaRepost={viaRepost}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<DiscoverDebug feedContext={feedContext} />
|
||||
</View>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
FeedItemInner = memo(FeedItemInner)
|
||||
|
||||
let PostContent = ({
|
||||
post,
|
||||
moderation,
|
||||
richText,
|
||||
postEmbed,
|
||||
postAuthor,
|
||||
onOpenEmbed,
|
||||
threadgateRecord,
|
||||
}: {
|
||||
moderation: ModerationDecision
|
||||
richText: RichTextAPI
|
||||
postEmbed: AppBskyFeedDefs.PostView['embed']
|
||||
postAuthor: AppBskyFeedDefs.PostView['author']
|
||||
onOpenEmbed: () => void
|
||||
post: AppBskyFeedDefs.PostView
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
}): React.ReactNode => {
|
||||
const {currentAccount} = useSession()
|
||||
const [limitLines, setLimitLines] = useState(
|
||||
() => countLines(richText.text) >= MAX_POST_LINES,
|
||||
)
|
||||
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
|
||||
threadgateRecord,
|
||||
})
|
||||
@@ -451,6 +322,150 @@ let PostContent = ({
|
||||
: []
|
||||
}, [post, currentAccount?.did, threadgateHiddenReplies])
|
||||
|
||||
return (
|
||||
<GalleryBleed>
|
||||
<Link
|
||||
testID={`feedItem-by-${post.author.handle}`}
|
||||
style={outerStyles}
|
||||
href={href}
|
||||
noFeedback
|
||||
accessible={false}
|
||||
onBeforePress={onBeforePress}
|
||||
dataSet={{feedContext}}
|
||||
onPointerEnter={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHover(false)
|
||||
}}>
|
||||
<SubtleHover hover={hover} />
|
||||
<View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
|
||||
<View style={{width: 42}}>
|
||||
{isThreadChild && (
|
||||
<View
|
||||
style={[
|
||||
styles.replyLine,
|
||||
{
|
||||
flexGrow: 1,
|
||||
backgroundColor: pal.colors.replyLine,
|
||||
marginBottom: 4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[a.pt_sm, a.flex_shrink]}>
|
||||
{reason && (
|
||||
<PostFeedReason
|
||||
reason={reason}
|
||||
moderation={moderation}
|
||||
onOpenReposter={onOpenReposter}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
onBeforePress={onOpenAuthor}
|
||||
live={live}
|
||||
/>
|
||||
{isThreadParent && (
|
||||
<View
|
||||
style={[
|
||||
styles.replyLine,
|
||||
{
|
||||
flexGrow: 1,
|
||||
backgroundColor: pal.colors.replyLine,
|
||||
marginTop: live ? 8 : 4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.layoutContent,
|
||||
maybeApplyGalleryOffsetStyles('meta', {
|
||||
post,
|
||||
modui: moderation.ui('contentList'),
|
||||
additionalCauses: additionalPostAlerts,
|
||||
}),
|
||||
]}>
|
||||
<PostMeta
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={href}
|
||||
onOpenAuthor={onOpenAuthor}
|
||||
/>
|
||||
{showReplyTo &&
|
||||
(parentAuthor || isParentBlocked || isParentNotFound) && (
|
||||
<PostRepliedTo
|
||||
parentAuthor={parentAuthor}
|
||||
isParentBlocked={isParentBlocked}
|
||||
isParentNotFound={isParentNotFound}
|
||||
/>
|
||||
)}
|
||||
<LabelsOnMyPost post={post} />
|
||||
<PostContent
|
||||
moderation={moderation}
|
||||
richText={richText}
|
||||
postEmbed={post.embed}
|
||||
postAuthor={post.author}
|
||||
onOpenEmbed={onOpenEmbed}
|
||||
post={post}
|
||||
additionalPostAlerts={additionalPostAlerts}
|
||||
/>
|
||||
<PostControls
|
||||
post={post}
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="FeedItem"
|
||||
feedContext={feedContext}
|
||||
reqId={reqId}
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShowLess={onShowLess}
|
||||
viaRepost={viaRepost}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<DiscoverDebug feedContext={feedContext} />
|
||||
</View>
|
||||
</Link>
|
||||
</GalleryBleed>
|
||||
)
|
||||
}
|
||||
FeedItemInner = memo(FeedItemInner)
|
||||
|
||||
let PostContent = ({
|
||||
post,
|
||||
moderation,
|
||||
richText,
|
||||
postEmbed,
|
||||
postAuthor,
|
||||
onOpenEmbed,
|
||||
additionalPostAlerts,
|
||||
}: {
|
||||
moderation: ModerationDecision
|
||||
richText: RichTextAPI
|
||||
postEmbed: AppBskyFeedDefs.PostView['embed']
|
||||
postAuthor: AppBskyFeedDefs.PostView['author']
|
||||
onOpenEmbed: () => void
|
||||
post: AppBskyFeedDefs.PostView
|
||||
additionalPostAlerts?: AppModerationCause[]
|
||||
}): React.ReactNode => {
|
||||
const [limitLines, setLimitLines] = useState(
|
||||
() => countLines(richText.text) >= MAX_POST_LINES,
|
||||
)
|
||||
|
||||
const record = useMemo<AppBskyFeedPost.Record | undefined>(
|
||||
() =>
|
||||
bsky.validate(post.record, AppBskyFeedPost.validateRecord)
|
||||
@@ -492,7 +507,15 @@ let PostContent = ({
|
||||
) : undefined}
|
||||
{record && <TranslatedPost hideTranslateLink post={post} />}
|
||||
{postEmbed ? (
|
||||
<View style={[a.pb_xs]}>
|
||||
<View
|
||||
style={[
|
||||
a.pb_xs,
|
||||
maybeApplyGalleryOffsetStyles('embed', {
|
||||
post,
|
||||
modui: moderation.ui('contentList'),
|
||||
additionalCauses: additionalPostAlerts,
|
||||
}),
|
||||
]}>
|
||||
<Embed
|
||||
embed={postEmbed}
|
||||
moderation={moderation}
|
||||
@@ -524,13 +547,9 @@ const styles = StyleSheet.create({
|
||||
layoutAvi: {
|
||||
paddingLeft: 8,
|
||||
paddingRight: 10,
|
||||
position: 'relative',
|
||||
zIndex: 999,
|
||||
},
|
||||
layoutContent: {
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
zIndex: 0,
|
||||
},
|
||||
alert: {
|
||||
marginTop: 6,
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import {useCallback} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {
|
||||
measure,
|
||||
type MeasuredDimensions,
|
||||
runOnJS,
|
||||
runOnUI,
|
||||
useAnimatedRef,
|
||||
} from 'react-native-reanimated'
|
||||
import Animated, {useAnimatedRef} from 'react-native-reanimated'
|
||||
import {type AppBskyGraphDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -60,14 +54,17 @@ export function ProfileSubpageHeader({
|
||||
const canGoBack = navigation.canGoBack()
|
||||
const aviRef = useAnimatedRef()
|
||||
|
||||
const _openLightbox = useCallback(
|
||||
(uri: string, thumbRect: MeasuredDimensions | null) => {
|
||||
const onPressAvi = useCallback(() => {
|
||||
if (
|
||||
avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
|
||||
) {
|
||||
openLightbox({
|
||||
images: [
|
||||
{
|
||||
uri,
|
||||
thumbUri: uri,
|
||||
thumbRect,
|
||||
uri: avatar,
|
||||
thumbUri: avatar,
|
||||
thumbRect: null,
|
||||
thumbRef: aviRef,
|
||||
dimensions: {
|
||||
// It's fine if it's actually smaller but we know it's 1:1.
|
||||
height: 1000,
|
||||
@@ -79,21 +76,8 @@ export function ProfileSubpageHeader({
|
||||
],
|
||||
index: 0,
|
||||
})
|
||||
},
|
||||
[openLightbox],
|
||||
)
|
||||
|
||||
const onPressAvi = useCallback(() => {
|
||||
if (
|
||||
avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
|
||||
) {
|
||||
runOnUI(() => {
|
||||
'worklet'
|
||||
const rect = measure(aviRef)
|
||||
runOnJS(_openLightbox)(avatar, rect)
|
||||
})()
|
||||
}
|
||||
}, [_openLightbox, avatar, aviRef])
|
||||
}, [openLightbox, avatar, aviRef])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import {useCallback, useState} from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {DismissableLayer, FocusGuards, FocusScope} from 'radix-ui/internal'
|
||||
import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
||||
@@ -6,11 +5,6 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useModals} from '#/state/modals'
|
||||
import {type ComposerOpts, useComposerState} from '#/state/shell/composer'
|
||||
import {
|
||||
EmojiPicker,
|
||||
type EmojiPickerPosition,
|
||||
type EmojiPickerState,
|
||||
} from '#/view/com/composer/text-input/web/EmojiPicker'
|
||||
import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf'
|
||||
import {ComposePost, useComposerCancelRef} from '../com/composer/Composer'
|
||||
|
||||
@@ -41,25 +35,6 @@ function Inner({state}: {state: ComposerOpts}) {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {reduceMotionEnabled} = useA11y()
|
||||
const [pickerState, setPickerState] = useState<EmojiPickerState>({
|
||||
isOpen: false,
|
||||
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
|
||||
})
|
||||
|
||||
const onOpenPicker = useCallback((pos: EmojiPickerPosition | undefined) => {
|
||||
if (!pos) return
|
||||
setPickerState({
|
||||
isOpen: true,
|
||||
pos,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const onClosePicker = useCallback(() => {
|
||||
setPickerState(prev => ({
|
||||
...prev,
|
||||
isOpen: false,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
FocusGuards.useFocusGuards()
|
||||
|
||||
@@ -104,13 +79,11 @@ function Inner({state}: {state: ComposerOpts}) {
|
||||
onPost={state.onPost}
|
||||
onPostSuccess={state.onPostSuccess}
|
||||
mention={state.mention}
|
||||
openEmojiPicker={onOpenPicker}
|
||||
text={state.text}
|
||||
imageUris={state.imageUris}
|
||||
openGallery={state.openGallery}
|
||||
/>
|
||||
</View>
|
||||
<EmojiPicker state={pickerState} close={onClosePicker} />
|
||||
</DismissableLayer.DismissableLayer>
|
||||
</FocusScope.FocusScope>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {StyleSheet} from 'react-native'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {atoms as a, tokens} from '#/alf'
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
bottomBar: {
|
||||
@@ -10,8 +10,8 @@ export const styles = StyleSheet.create({
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 10,
|
||||
paddingLeft: tokens.space.sm,
|
||||
paddingRight: tokens.space.sm,
|
||||
},
|
||||
bottomBarWeb: a.fixed,
|
||||
ctrl: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import {useEffect, useRef} from 'react'
|
||||
import {View} from 'react-native'
|
||||
// Based on @react-navigation/native-stack/src/navigators/createNativeStackNavigator.ts
|
||||
// MIT License
|
||||
@@ -82,7 +82,7 @@ function NativeStackNavigator({
|
||||
UNSTABLE_router,
|
||||
})
|
||||
|
||||
React.useEffect(
|
||||
useEffect(
|
||||
() =>
|
||||
// @ts-expect-error: there may not be a tab navigator in parent
|
||||
navigation?.addListener?.('tabPress', (e: any) => {
|
||||
@@ -110,7 +110,7 @@ function NativeStackNavigator({
|
||||
|
||||
// --- our custom logic starts here ---
|
||||
// Web LRU: tracks route keys in most-recently-focused order
|
||||
const lruKeysRef = React.useRef<string[]>([])
|
||||
const lruKeysRef = useRef<string[]>([])
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const activeRoute = state.routes[state.index]
|
||||
const activeDescriptor = descriptors[activeRoute.key]
|
||||
|
||||
Reference in New Issue
Block a user