Merge branch 'main' into app-2067

This commit is contained in:
vineyardbovines
2026-04-17 09:02:19 -04:00
128 changed files with 7775 additions and 2556 deletions
+124 -140
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+19 -19
View File
@@ -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! Were gradually giving access to video, and youre 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'
}
+73 -20
View File
@@ -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,
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+10 -26
View File
@@ -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 (
<>
-27
View File
@@ -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]