Merge remote-tracking branch 'origin/main' into next/embeds

* origin/main:
  [Threads V2] Preliminary integration of unspecced V2 APIs (#8443)
  Post source handling updates (#8472)
  Fix using screen names in `Link` (#8473)
  Instant Feed Update on Mute or Moderation Action (#8463)
  use ref instead of source (#8471)
This commit is contained in:
Eric Bailey
2025-06-11 14:33:41 -05:00
69 changed files with 5564 additions and 182 deletions
+49 -9
View File
@@ -45,6 +45,7 @@ import {type ImagePickerAsset} from 'expo-image-picker'
import {
AppBskyFeedDefs,
type AppBskyFeedGetPostThread,
AppBskyUnspeccedDefs,
type BskyAgent,
type RichText,
} from '@atproto/api'
@@ -55,6 +56,7 @@ import {useQueryClient} from '@tanstack/react-query'
import * as apilib from '#/lib/api/index'
import {EmbeddingDisabledError} from '#/lib/api/resolve'
import {retry} from '#/lib/async/retry'
import {until} from '#/lib/async/until'
import {
MAX_GRAPHEME_LENGTH,
@@ -87,7 +89,7 @@ import {useProfileQuery} from '#/state/queries/profile'
import {type Gif} from '#/state/queries/tenor'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {type ComposerOpts} from '#/state/shell/composer'
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
import {
@@ -152,6 +154,7 @@ type Props = ComposerOpts
export const ComposePost = ({
replyTo,
onPost,
onPostSuccess,
quote: initQuote,
mention: initMention,
openEmojiPicker,
@@ -388,8 +391,10 @@ export const ComposePost = ({
setError('')
setIsPublishing(true)
let postUri
let postUri: string | undefined
let postSuccessData: OnPostSuccessData
try {
logger.info(`composer: posting...`)
postUri = (
await apilib.post(agent, queryClient, {
thread,
@@ -398,16 +403,48 @@ export const ComposePost = ({
langs: toPostLanguages(langPrefs.postLanguage),
})
).uris[0]
/*
* Wait for app view to have received the post(s). If this fails, it's
* ok, because the post _was_ actually published above.
*/
try {
await whenAppViewReady(agent, postUri, res => {
const postedThread = res?.data?.thread
return AppBskyFeedDefs.isThreadViewPost(postedThread)
})
if (postUri) {
logger.info(`composer: waiting for app view`)
const posts = await retry(
5,
_e => true,
async () => {
const res = await agent.app.bsky.unspecced.getPostThreadV2({
anchor: postUri!,
above: false,
below: thread.posts.length - 1,
branchingFactor: 1,
})
if (res.data.thread.length !== thread.posts.length) {
throw new Error(`composer: app view is not ready`)
}
if (
!res.data.thread.every(p =>
AppBskyUnspeccedDefs.isThreadItemPost(p.value),
)
) {
throw new Error(`composer: app view returned non-post items`)
}
return res.data.thread
},
1e3,
)
postSuccessData = {
replyToUri: replyTo?.uri,
posts,
}
}
} catch (waitErr: any) {
logger.error(waitErr, {
message: `Waiting for app view failed`,
logger.info(`composer: waiting for app view failed`, {
safeMessage: waitErr,
})
// Keep going because the post *was* published.
}
} catch (e: any) {
logger.error(e, {
@@ -465,12 +502,14 @@ export const ComposePost = ({
quotedThread.post.quoteCount !== initQuote.quoteCount
) {
onPost?.(postUri)
onPostSuccess?.(postSuccessData)
return true
}
return false
})
} else {
onPost?.(postUri)
onPostSuccess?.(postSuccessData)
}
onClose()
Toast.show(
@@ -489,6 +528,7 @@ export const ComposePost = ({
langPrefs.postLanguage,
onClose,
onPost,
onPostSuccess,
initQuote,
replyTo,
setLangPrefs,
+3 -11
View File
@@ -1,4 +1,3 @@
import React from 'react'
import {View} from 'react-native'
import Animated from 'react-native-reanimated'
import {msg} from '@lingui/macro'
@@ -56,13 +55,8 @@ export function HomeHeaderLayoutMobile({
<PressableScale
targetScale={0.9}
onPress={() => {
emitSoftReset()
}}
onPressIn={() => {
playHaptic('Heavy')
}}
onPressOut={() => {
playHaptic('Light')
emitSoftReset()
}}>
<Logo width={30} />
</PressableScale>
@@ -72,7 +66,7 @@ export function HomeHeaderLayoutMobile({
{hasSession && (
<Link
testID="viewHeaderHomeFeedPrefsBtn"
to="/feeds"
to={{screen: 'Feeds'}}
hitSlop={HITSLOP_10}
label={_(msg`View your feeds and explore more`)}
size="small"
@@ -81,9 +75,7 @@ export function HomeHeaderLayoutMobile({
shape="square"
style={[
a.justify_center,
{
marginRight: -Layout.BUTTON_VISUAL_ALIGNMENT_OFFSET,
},
{marginRight: -Layout.BUTTON_VISUAL_ALIGNMENT_OFFSET},
]}>
<ButtonIcon icon={FeedsIcon} size="lg" />
</Link>
+44 -30
View File
@@ -1,8 +1,7 @@
import React, {memo, useRef, useState} from 'react'
import {StyleSheet, useWindowDimensions, View} from 'react-native'
import {runOnJS} from 'react-native-reanimated'
import {useWindowDimensions, View} from 'react-native'
import {runOnJS, useAnimatedStyle} from 'react-native-reanimated'
import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {
AppBskyFeedDefs,
type AppBskyFeedThreadgate,
@@ -13,15 +12,14 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {clamp} from '#/lib/numbers'
import {ScrollProvider} from '#/lib/ScrollContext'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {cleanError} from '#/lib/strings/errors'
import {isAndroid, isNative, isWeb} from '#/platform/detection'
import {useFeedFeedback} from '#/state/feed-feedback'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
fillThreadModerationCache,
@@ -36,7 +34,9 @@ import {
import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {useUnstablePostSource} from '#/state/unstable-post-source'
import {List, type ListMethods} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
@@ -93,7 +93,7 @@ const keyExtractor = (item: RowItem) => {
return item._reactKey
}
export function PostThread({uri}: {uri: string | undefined}) {
export function PostThread({uri}: {uri: string}) {
const {hasSession, currentAccount} = useSession()
const {_} = useLingui()
const t = useTheme()
@@ -104,6 +104,8 @@ export function PostThread({uri}: {uri: string | undefined}) {
HiddenRepliesState.Hide,
)
const headerRef = React.useRef<View | null>(null)
const anchorPostSource = useUnstablePostSource(uri)
const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession)
const {data: preferences} = usePreferencesQuery()
const {
@@ -297,11 +299,14 @@ export function PostThread({uri}: {uri: string | undefined}) {
// maintainVisibleContentPosition and onContentSizeChange
// to "hold onto" the correct row instead of the first one.
/*
* This is basically `!!parents.length`, see notes on `isParentLoading`
*/
if (!highlightedPost.ctx.isParentLoading && !deferParents) {
// When progressively revealing parents, rendering a placeholder
// here will cause scrolling jumps. Don't add it unless you test it.
// QT'ing this thread is a great way to test all the scrolling hacks:
// https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
// https://bsky.app/profile/samuel.bsky.team/post/3kjqhblh6qk2o
// Everything is loaded
let startIndex = Math.max(0, parents.length - maxParents)
@@ -395,10 +400,18 @@ export function PostThread({uri}: {uri: string | undefined}) {
)
const {openComposer} = useOpenComposer()
const onPressReply = React.useCallback(() => {
const onReplyToAnchor = React.useCallback(() => {
if (thread?.type !== 'post') {
return
}
if (anchorPostSource) {
feedFeedback.sendInteraction({
item: thread.post.uri,
event: 'app.bsky.feed.defs#interactionReply',
feedContext: anchorPostSource.post.feedContext,
reqId: anchorPostSource.post.reqId,
})
}
openComposer({
replyTo: {
uri: thread.post.uri,
@@ -410,7 +423,14 @@ export function PostThread({uri}: {uri: string | undefined}) {
},
onPost: onPostReply,
})
}, [openComposer, thread, onPostReply, threadModerationCache])
}, [
openComposer,
thread,
onPostReply,
threadModerationCache,
anchorPostSource,
feedFeedback,
])
const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled
const hasParents =
@@ -423,7 +443,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
return (
<View>
{!isMobile && (
<PostThreadComposePrompt onPressCompose={onPressReply} />
<PostThreadComposePrompt onPressCompose={onReplyToAnchor} />
)}
</View>
)
@@ -511,6 +531,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
}
onPostReply={onPostReply}
hideTopBorder={index === 0 && !item.ctx.isParentLoading}
anchorPostSource={anchorPostSource}
/>
</View>
)
@@ -561,6 +582,9 @@ export function PostThread({uri}: {uri: string | undefined}) {
onEndReached={onEndReached}
onEndReachedThreshold={2}
onScrollToTop={onScrollToTop}
/**
* @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition
*/
maintainVisibleContentPosition={
isNative && hasParents
? MAINTAIN_VISIBLE_CONTENT_POSITION
@@ -586,7 +610,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
/>
</ScrollProvider>
{isMobile && canReply && hasSession && (
<MobileComposePrompt onPressReply={onPressReply} />
<MobileComposePrompt onPressReply={onReplyToAnchor} />
)}
</>
)
@@ -709,17 +733,16 @@ let ThreadMenu = ({
ThreadMenu = memo(ThreadMenu)
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
const safeAreaInsets = useSafeAreaInsets()
const fabMinimalShellTransform = useMinimalShellFabTransform()
const {footerHeight} = useShellLayout()
const animatedStyle = useAnimatedStyle(() => {
return {
bottom: footerHeight.get(),
}
})
return (
<Animated.View
style={[
styles.prompt,
fabMinimalShellTransform,
{
bottom: clamp(safeAreaInsets.bottom, 13, 60),
},
]}>
<Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
<PostThreadComposePrompt onPressCompose={onPressReply} />
</Animated.View>
)
@@ -884,12 +907,3 @@ function hasBranchingReplies(node?: ThreadNode) {
}
return true
}
const styles = StyleSheet.create({
prompt: {
// @ts-ignore web-only
position: isWeb ? 'fixed' : 'absolute',
left: 0,
right: 0,
},
})
@@ -1,20 +1,25 @@
import {View} from 'react-native'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useHaptics} from '#/lib/haptics'
import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, ios, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf'
import {transparentifyColor} from '#/alf/util/colorGeneration'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text} from '#/components/Typography'
export function PostThreadComposePrompt({
onPressCompose,
style,
}: {
onPressCompose: () => void
style?: StyleProp<ViewStyle>
}) {
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
@@ -28,29 +33,49 @@ export function PostThreadComposePrompt({
onOut: onHoverOut,
} = useInteractionState()
useHideBottomBarBorderForScreen()
return (
<PressableScale
accessibilityRole="button"
accessibilityLabel={_(msg`Compose reply`)}
accessibilityHint={_(msg`Opens composer`)}
<View
style={[
gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11},
a.px_sm,
a.border_t,
t.atoms.border_contrast_low,
t.atoms.bg,
]}
onPress={() => {
onPressCompose()
playHaptic('Light')
}}
onLongPress={ios(() => {
onPressCompose()
playHaptic('Heavy')
})}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}>
<View
gtMobile
? [
a.py_xs,
a.px_sm,
a.border_t,
t.atoms.border_contrast_low,
t.atoms.bg,
]
: [a.px_md, a.pb_2xs],
style,
]}>
{!gtMobile && (
<LinearGradient
key={t.name} // android does not update when you change the colors. sigh.
start={[0.5, 0]}
end={[0.5, 1]}
colors={[
transparentifyColor(t.atoms.bg.backgroundColor, 0),
t.atoms.bg.backgroundColor,
]}
locations={[0.15, 0.4]}
style={[a.absolute, a.inset_0]}
/>
)}
<PressableScale
accessibilityRole="button"
accessibilityLabel={_(msg`Compose reply`)}
accessibilityHint={_(msg`Opens composer`)}
onPress={() => {
onPressCompose()
playHaptic('Light')
}}
onLongPress={ios(() => {
onPressCompose()
playHaptic('Heavy')
})}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
style={[
a.flex_row,
a.align_center,
@@ -58,6 +83,7 @@ export function PostThreadComposePrompt({
a.gap_sm,
a.rounded_full,
(!gtMobile || hovered) && t.atoms.bg_contrast_25,
native([a.border, t.atoms.border_contrast_low]),
a.transition_color,
]}>
<UserAvatar
@@ -68,7 +94,7 @@ export function PostThreadComposePrompt({
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans>Write your reply</Trans>
</Text>
</View>
</PressableScale>
</PressableScale>
</View>
)
}
+26 -15
View File
@@ -39,8 +39,9 @@ import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {useLanguagePrefs} from '#/state/preferences'
import {type ThreadPost} from '#/state/queries/post-thread'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {useUnstablePostSource} from '#/state/unstable-post-source'
import {type PostSource} from '#/state/unstable-post-source'
import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {Link, TextLink} from '#/view/com/util/Link'
@@ -85,8 +86,10 @@ export function PostThreadItem({
hasPrecedingItem,
overrideBlur,
onPostReply,
onPostSuccess,
hideTopBorder,
threadgateRecord,
anchorPostSource,
}: {
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
@@ -102,8 +105,10 @@ export function PostThreadItem({
hasPrecedingItem: boolean
overrideBlur: boolean
onPostReply: (postUri: string | undefined) => void
onPostSuccess?: (data: OnPostSuccessData) => void
hideTopBorder?: boolean
threadgateRecord?: AppBskyFeedThreadgate.Record
anchorPostSource?: PostSource
}) {
const postShadowed = usePostShadow(post)
const richText = useMemo(
@@ -137,8 +142,10 @@ export function PostThreadItem({
hasPrecedingItem={hasPrecedingItem}
overrideBlur={overrideBlur}
onPostReply={onPostReply}
onPostSuccess={onPostSuccess}
hideTopBorder={hideTopBorder}
threadgateRecord={threadgateRecord}
anchorPostSource={anchorPostSource}
/>
)
}
@@ -182,8 +189,10 @@ let PostThreadItemLoaded = ({
hasPrecedingItem,
overrideBlur,
onPostReply,
onPostSuccess,
hideTopBorder,
threadgateRecord,
anchorPostSource,
}: {
post: Shadow<AppBskyFeedDefs.PostView>
record: AppBskyFeedPost.Record
@@ -200,12 +209,13 @@ let PostThreadItemLoaded = ({
hasPrecedingItem: boolean
overrideBlur: boolean
onPostReply: (postUri: string | undefined) => void
onPostSuccess?: (data: OnPostSuccessData) => void
hideTopBorder?: boolean
threadgateRecord?: AppBskyFeedThreadgate.Record
anchorPostSource?: PostSource
}): React.ReactNode => {
const {currentAccount, hasSession} = useSession()
const source = useUnstablePostSource(post.uri)
const feedFeedback = useFeedFeedback(source?.feed, hasSession)
const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession)
const t = useTheme()
const pal = usePalette('default')
@@ -276,12 +286,12 @@ let PostThreadItemLoaded = ({
)
const onPressReply = () => {
if (source) {
if (anchorPostSource && isHighlightedPost) {
feedFeedback.sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionReply',
feedContext: source.post.feedContext,
reqId: source.post.reqId,
feedContext: anchorPostSource.post.feedContext,
reqId: anchorPostSource.post.reqId,
})
}
openComposer({
@@ -294,27 +304,28 @@ let PostThreadItemLoaded = ({
moderation,
},
onPost: onPostReply,
onPostSuccess: onPostSuccess,
})
}
const onOpenAuthor = () => {
if (source) {
if (anchorPostSource) {
feedFeedback.sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#clickthroughAuthor',
feedContext: source.post.feedContext,
reqId: source.post.reqId,
feedContext: anchorPostSource.post.feedContext,
reqId: anchorPostSource.post.reqId,
})
}
}
const onOpenEmbed = () => {
if (source) {
if (anchorPostSource) {
feedFeedback.sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#clickthroughEmbed',
feedContext: source.post.feedContext,
reqId: source.post.reqId,
feedContext: anchorPostSource.post.feedContext,
reqId: anchorPostSource.post.reqId,
})
}
}
@@ -325,7 +336,7 @@ let PostThreadItemLoaded = ({
const {isActive: live} = useActorStatus(post.author)
const reason = source?.post.reason
const reason = anchorPostSource?.post.reason
const viaRepost = useMemo(() => {
if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) {
return {
@@ -550,8 +561,8 @@ let PostThreadItemLoaded = ({
onPostReply={onPostReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
feedContext={source?.post?.feedContext}
reqId={source?.post?.reqId}
feedContext={anchorPostSource?.post?.feedContext}
reqId={anchorPostSource?.post?.reqId}
viaRepost={viaRepost}
/>
</FeedFeedbackProvider>
+5 -3
View File
@@ -36,7 +36,10 @@ import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {unstableCacheProfileView} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {useSetUnstablePostSource} from '#/state/unstable-post-source'
import {
buildPostSourceKey,
setUnstablePostSource,
} from '#/state/unstable-post-source'
import {FeedNameText} from '#/view/com/util/FeedInfoText'
import {Link, TextLink, TextLinkOnWebOnly} from '#/view/com/util/Link'
import {PostMeta} from '#/view/com/util/PostMeta'
@@ -177,7 +180,6 @@ let FeedItemInner = ({
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
const {sendInteraction, feedDescriptor} = useFeedFeedbackContext()
const unstableSetPostSource = useSetUnstablePostSource()
const onPressReply = () => {
sendInteraction({
@@ -233,7 +235,7 @@ let FeedItemInner = ({
reqId,
})
unstableCacheProfileView(queryClient, post.author)
unstableSetPostSource(post.uri, {
setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), {
feed: feedDescriptor,
post: {
post,