diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 70ce627fa..c7bac2fec 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -373,6 +373,10 @@ export type MetricEvents = { } 'feed:interstitial:feedCard:press': {} + 'desktopFeeds:feed:click': { + feedUri: string + feedDescriptor: string + } 'profile:header:suggestedFollowsCard:press': {} 'profile:addToStarterPack': {} diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index df89d95ca..641b90f3e 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -5,6 +5,7 @@ import {useNavigation, useNavigationState} from '@react-navigation/native' import {getCurrentRoute} from '#/lib/routes/helpers' import {type NavigationProp} from '#/lib/routes/types' +import {logger} from '#/logger' import {emitSoftReset} from '#/state/events' import {usePinnedFeedsInfos} from '#/state/queries/feed' import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' @@ -75,6 +76,14 @@ export function DesktopFeeds() { key={feedInfo.uri} label={feedInfo.displayName} {...createStaticClick(() => { + logger.metric( + 'desktopFeeds:feed:click', + { + feedUri: feedInfo.uri, + feedDescriptor: feed, + }, + {statsig: false}, + ) setSelectedFeed(feed) navigation.navigate('Home') if (route.name === 'Home' && feed === selectedFeed) { -- 2.51.2 From 4145878819c5d3b484e085952b956032f1e167bc Mon Sep 17 00:00:00 2001 From: surfdude29 <149612116+surfdude29@users.noreply.github.com> Date: Mon, 17 Nov 2025 11:26:39 +0000 Subject: [PATCH 02/10] remove `accessibilityHint` and tweak labels (#9391) --- src/components/dialogs/PostInteractionSettingsDialog.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx index b499b4075..cac82bc4a 100644 --- a/src/components/dialogs/PostInteractionSettingsDialog.tsx +++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx @@ -513,7 +513,6 @@ export function PostInteractionSettingsForm({ ? _(msg`Hide lists`) : _(msg`Show lists of users to select from`) } - accessibilityHint={_(msg`Toggle showing lists`)} accessibilityRole="togglebutton" hitSlop={0} onPress={() => { @@ -606,8 +605,8 @@ export function PostInteractionSettingsForm({ type="checkbox" label={ quotesEnabled - ? _(msg`Disable quote posts of this post.`) - : _(msg`Enable quote posts of this post.`) + ? _(msg`Disable quote posts of this post`) + : _(msg`Enable quote posts of this post`) } value={quotesEnabled} onChange={onChangeQuotesEnabled}> -- 2.51.2 From a35a81a508861f2e651dff646eee36423def0781 Mon Sep 17 00:00:00 2001 From: surfdude29 <149612116+surfdude29@users.noreply.github.com> Date: Mon, 17 Nov 2025 11:27:43 +0000 Subject: [PATCH 03/10] Change text on threadgate button from 'Anybody' to 'Anyone' (#9390) --- src/view/com/composer/threadgate/ThreadgateBtn.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx index 788e831dc..edfdbe994 100644 --- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx +++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx @@ -126,7 +126,7 @@ export function ThreadgateBtn({ !postgate.embeddingRules || postgate.embeddingRules.length === 0 const anyoneCanInteract = anyoneCanReply && anyoneCanQuote const label = anyoneCanInteract - ? _(msg`Anybody can interact`) + ? _(msg`Anyone can interact`) : _(msg`Interaction limited`) return ( -- 2.51.2 From d464cde4121861b80c9835f9c357c104ded03b53 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 17 Nov 2025 14:59:56 +0200 Subject: [PATCH 04/10] add transparency prop to tab bar (#9336) --- src/components/Layout/Header/index.tsx | 5 ++++- src/view/com/home/HomeHeader.tsx | 1 + src/view/com/home/HomeHeaderLayoutMobile.tsx | 1 + src/view/com/pager/TabBar.tsx | 4 +++- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index 3c895e4d8..762223fc3 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -163,7 +163,10 @@ export function MenuButton() { shape="square" onPress={onPress} hitSlop={HITSLOP_30} - style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}]}> + style={[ + {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, + a.bg_transparent, + ]}> diff --git a/src/view/com/home/HomeHeader.tsx b/src/view/com/home/HomeHeader.tsx index 4ae344549..4a2cf881f 100644 --- a/src/view/com/home/HomeHeader.tsx +++ b/src/view/com/home/HomeHeader.tsx @@ -61,6 +61,7 @@ export function HomeHeader( items={items} dragProgress={props.dragProgress} dragState={props.dragState} + transparent /> ) diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx index a0d7b3e78..ef1e93837 100644 --- a/src/view/com/home/HomeHeaderLayoutMobile.tsx +++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx @@ -77,6 +77,7 @@ export function HomeHeaderLayoutMobile({ style={[ a.justify_center, {marginRight: -Layout.BUTTON_VISUAL_ALIGNMENT_OFFSET}, + a.bg_transparent, ]}> diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 70358fd10..c07f44ac8 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -30,6 +30,7 @@ export interface TabBarProps { onPressSelected?: (index: number) => void dragProgress: SharedValue dragState: SharedValue<'idle' | 'dragging' | 'settling'> + transparent?: boolean } const ITEM_PADDING = 10 @@ -46,6 +47,7 @@ export function TabBar({ onPressSelected, dragProgress, dragState, + transparent, }: TabBarProps) { const t = useTheme() const scrollElRef = useAnimatedRef() @@ -313,7 +315,7 @@ export function TabBar({ return ( Date: Mon, 17 Nov 2025 05:00:26 -0800 Subject: [PATCH 05/10] Adds a "follow back" button to follow notifications (#9359) * Adds a "follow back" button to follow notifications * get shadowcache logic working, strip out manual optimistic update * whoops, don't just stick any old profile in there --------- Co-authored-by: Samuel Newman --- src/state/cache/profile-shadow.ts | 2 + src/state/queries/notifications/feed.ts | 8 +- .../notifications/NotificationFeedItem.tsx | 167 +++++++++++++++--- 3 files changed, 148 insertions(+), 29 deletions(-) diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index 168661e0d..e1cff9409 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -12,6 +12,7 @@ import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} fro import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations' import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts' import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts' +import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed' import {findAllProfilesInQueryData as findAllProfilesInFeedsQueryData} from '#/state/queries/post-feed' import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by' import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes' @@ -176,4 +177,5 @@ function* findProfilesInCache( yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did) yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did) + yield* findAllProfilesInNotifsQueryData(queryClient, did) } diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 6010f11b4..7959c67a7 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -18,7 +18,6 @@ import {useCallback, useEffect, useMemo, useRef} from 'react' import { - type AppBskyActorDefs, AppBskyFeedDefs, AppBskyFeedPost, AtUri, @@ -36,6 +35,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts' import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' +import type * as bsky from '#/types/bsky' import { didOrHandleUriMatches, embedViewRecordToPostView, @@ -309,7 +309,7 @@ export function* findAllPostsInQueryData( export function* findAllProfilesInQueryData( queryClient: QueryClient, did: string, -): Generator { +): Generator { const queryDatas = queryClient.getQueriesData>({ queryKey: [RQKEY_ROOT], }) @@ -319,7 +319,9 @@ export function* findAllProfilesInQueryData( } for (const page of queryData?.pages) { for (const item of page.items) { - if ( + if (item.type === 'follow' && item.notification.author.did === did) { + yield item.notification.author + } else if ( item.type !== 'starterpack-joined' && item.subject?.author.did === did ) { diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index 5809e7106..f78b9650e 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -42,23 +42,28 @@ import {sanitizeHandle} from '#/lib/strings/handles' import {niceDate} from '#/lib/strings/time' import {s} from '#/lib/styles' import {logger} from '#/logger' +import {useProfileShadow} from '#/state/cache/profile-shadow' import {type FeedNotification} from '#/state/queries/notifications/feed' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' -import {useAgent} from '#/state/session' +import {useAgent, useSession} from '#/state/session' import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' import {Post} from '#/view/com/post/Post' import {formatCount} from '#/view/com/util/numeric/format' import {TimeElapsed} from '#/view/com/util/TimeElapsed' +import * as Toast from '#/view/com/util/Toast' import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, platform, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' +import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import { ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, } from '#/components/icons/Chevron' import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2' import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' import {StarterPack} from '#/components/icons/StarterPack' import {VerifiedCheck} from '#/components/icons/VerifiedCheck' @@ -180,6 +185,32 @@ let NotificationFeedItem = ({ firstAuthor.profile.displayName || firstAuthor.profile.handle, ) + // Calculate if this is a follow-back notification + const isFollowBack = useMemo(() => { + if (item.type !== 'follow') return false + if ( + item.notification.author.viewer?.following && + bsky.dangerousIsType( + item.notification.record, + AppBskyGraphFollow.isRecord, + ) + ) { + let followingTimestamp + try { + const rkey = new AtUri(item.notification.author.viewer.following).rkey + followingTimestamp = TID.fromStr(rkey).timestamp() + } catch (e) { + return false + } + if (followingTimestamp) { + const followedTimestamp = + new Date(item.notification.record.createdAt).getTime() * 1000 + return followedTimestamp > followingTimestamp + } + } + return false + }, [item]) + if (item.subjectUri && !item.subject && item.type !== 'feedgen-like') { // don't render anything if the target post was deleted or unfindable return @@ -309,30 +340,6 @@ let NotificationFeedItem = ({ ) icon = } else if (item.type === 'follow') { - let isFollowBack = false - - if ( - item.notification.author.viewer?.following && - bsky.dangerousIsType( - item.notification.record, - AppBskyGraphFollow.isRecord, - ) - ) { - let followingTimestamp - try { - const rkey = new AtUri(item.notification.author.viewer.following).rkey - followingTimestamp = TID.fromStr(rkey).timestamp() - } catch (e) { - // For some reason the following URI was invalid. Default to it not being a follow back. - console.error('Invalid following URI') - } - if (followingTimestamp) { - const followedTimestamp = - new Date(item.notification.record.createdAt).getTime() * 1000 - isFollowBack = followedTimestamp > followingTimestamp - } - } - if (isFollowBack && !hasMultipleAuthors) { /* * Follow-backs are ungrouped, grouped follow-backs not supported atm, @@ -663,6 +670,9 @@ let NotificationFeedItem = ({ + {item.type === 'follow' && !hasMultipleAuthors && !isFollowBack ? ( + + ) : null} {item.type === 'post-like' || item.type === 'repost' || item.type === 'like-via-repost' || @@ -732,6 +742,111 @@ function ExpandListPressable({ } } +function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { + const {_} = useLingui() + const {currentAccount, hasSession} = useSession() + const profileShadow = useProfileShadow(profile) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( + profileShadow, + 'ProfileCard', + ) + + // Don't show button if not logged in or for own profile + if (!hasSession || profile.did === currentAccount?.did) { + return null + } + + const onPressFollow = async (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + + try { + await queueFollow() + Toast.show( + _( + msg`Following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, + ), + ) + } catch (err: any) { + if (err?.name !== 'AbortError') { + Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') + } + } + } + + const onPressUnfollow = async (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + + try { + await queueUnfollow() + Toast.show( + _( + msg`No longer following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, + ), + ) + } catch (err: any) { + if (err?.name !== 'AbortError') { + Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') + } + } + } + + // Don't show button if viewer data is missing or user is blocked + if (!profileShadow.viewer) { + return null + } + if ( + profileShadow.viewer.blockedBy || + profileShadow.viewer.blocking || + profileShadow.viewer.blockingByList + ) { + return null + } + + const isFollowing = profileShadow.viewer.following + const followingLabel = _( + msg({ + message: 'Following', + comment: 'User is following this account, click to unfollow', + }), + ) + + return ( + + {isFollowing ? ( + + ) : ( + + )} + + ) +} + function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileView}) { const {_} = useLingui() const agent = useAgent() -- 2.51.2 From 084f60c88f5341b657e09a0b9723dc32304be567 Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Mon, 17 Nov 2025 05:04:11 -0800 Subject: [PATCH 06/10] Profile follow client events (#9385) * Add parameters to profile:follow Track who was followed, whose profile generated the follow, and the position of the person who was followed in the list * Add profileCard:seen event * Don't send "profileCard:seen" event to Statsig * Clean up * prevent overzealous clearing --------- Co-authored-by: Samuel Newman --- src/components/ProfileCard.tsx | 18 ++++++++++ src/logger/metrics.ts | 8 +++++ src/state/queries/profile.ts | 14 +++++++- src/view/com/profile/ProfileCard.tsx | 6 ++++ src/view/com/profile/ProfileFollowers.tsx | 41 ++++++++++++++++++++++- src/view/com/profile/ProfileFollows.tsx | 41 ++++++++++++++++++++++- 6 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 41626ff39..55bef226b 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -48,11 +48,15 @@ export function Default({ moderationOpts, logContext = 'ProfileCard', testID, + position, + contextProfileDid, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts logContext?: 'ProfileCard' | 'StarterPackProfilesList' testID?: string + position?: number + contextProfileDid?: string }) { return ( @@ -60,6 +64,8 @@ export function Default({ profile={profile} moderationOpts={moderationOpts} logContext={logContext} + position={position} + contextProfileDid={contextProfileDid} /> ) @@ -69,10 +75,14 @@ export function Card({ profile, moderationOpts, logContext = 'ProfileCard', + position, + contextProfileDid, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts logContext?: 'ProfileCard' | 'StarterPackProfilesList' + position?: number + contextProfileDid?: string }) { return ( @@ -83,6 +93,8 @@ export function Card({ profile={profile} moderationOpts={moderationOpts} logContext={logContext} + position={position} + contextProfileDid={contextProfileDid} /> @@ -437,6 +449,8 @@ export type FollowButtonProps = { colorInverted?: boolean onFollow?: () => void withIcon?: boolean + position?: number + contextProfileDid?: string } & Partial export function FollowButton(props: FollowButtonProps) { @@ -453,6 +467,8 @@ export function FollowButtonInner({ onFollow, colorInverted, withIcon = true, + position, + contextProfileDid, ...rest }: FollowButtonProps) { const {_} = useLingui() @@ -461,6 +477,8 @@ export function FollowButtonInner({ const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profile, logContext, + position, + contextProfileDid, ) const isRound = Boolean(rest.shape && rest.shape === 'round') diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index c7bac2fec..37b8e21f7 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -256,9 +256,12 @@ export type MetricEvents = { 'bookmarks:view': {} 'bookmarks:post-clicked': {} 'profile:follow': { + contextProfileDid?: string didBecomeMutual: boolean | undefined followeeClout: number | undefined + followeeDid: string followerClout: number | undefined + position?: number logContext: | 'RecommendedFollowsItem' | 'PostThreadItem' @@ -276,6 +279,11 @@ export type MetricEvents = { | 'ExploreSuggestedAccounts' | 'OnboardingSuggestedAccounts' } + 'profileCard:seen': { + contextProfileDid?: string + profileDid: string + position?: number + } 'suggestedUser:follow': { logContext: | 'Explore' diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index eb65fef7c..9d30288d4 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -242,12 +242,19 @@ export function useProfileFollowMutationQueue( profile: Shadow, logContext: LogEvents['profile:follow']['logContext'] & LogEvents['profile:follow']['logContext'], + position?: number, + contextProfileDid?: string, ) { const agent = useAgent() const queryClient = useQueryClient() const did = profile.did const initialFollowingUri = profile.viewer?.following - const followMutation = useProfileFollowMutation(logContext, profile) + const followMutation = useProfileFollowMutation( + logContext, + profile, + position, + contextProfileDid, + ) const unfollowMutation = useProfileUnfollowMutation(logContext) const queueToggle = useToggleMutationQueue({ @@ -314,6 +321,8 @@ export function useProfileFollowMutationQueue( function useProfileFollowMutation( logContext: LogEvents['profile:follow']['logContext'], profile: Shadow, + position?: number, + contextProfileDid?: string, ) { const {currentAccount} = useSession() const agent = useAgent() @@ -336,7 +345,10 @@ function useProfileFollowMutation( 'followersCount' in profile ? toClout(profile.followersCount) : undefined, + followeeDid: did, followerClout: toClout(ownProfile?.followersCount), + position, + contextProfileDid, }) return await agent.follow(did) }, diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index cee950703..f200a62cb 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -9,10 +9,14 @@ export function ProfileCardWithFollowBtn({ profile, noBorder, logContext = 'ProfileCard', + position, + contextProfileDid, }: { profile: AppBskyActorDefs.ProfileView noBorder?: boolean logContext?: 'ProfileCard' | 'StarterPackProfilesList' + position?: number + contextProfileDid?: string }) { const t = useTheme() const moderationOpts = useModerationOpts() @@ -30,6 +34,8 @@ export function ProfileCardWithFollowBtn({ profile={profile} moderationOpts={moderationOpts} logContext={logContext} + position={position} + contextProfileDid={contextProfileDid} /> ) diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index dfb63909e..b5838f0c1 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -16,15 +16,19 @@ import {ProfileCardWithFollowBtn} from './ProfileCard' function renderItem({ item, index, + contextProfileDid, }: { item: ActorDefs.ProfileView index: number + contextProfileDid: string | undefined }) { return ( ) } @@ -83,6 +87,40 @@ export function ProfileFollowers({name}: {name: string}) { } }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) + const renderItemWithContext = React.useCallback( + ({item, index}: {item: ActorDefs.ProfileView; index: number}) => + renderItem({item, index, contextProfileDid: resolvedDid}), + [resolvedDid], + ) + + // track seen items + const seenItemsRef = React.useRef>(new Set()) + React.useEffect(() => { + seenItemsRef.current.clear() + }, [resolvedDid]) + const onItemSeen = React.useCallback( + (item: ActorDefs.ProfileView) => { + if (seenItemsRef.current.has(item.did)) { + return + } + seenItemsRef.current.add(item.did) + const position = followers.findIndex(p => p.did === item.did) + 1 + if (position === 0) { + return + } + logger.metric( + 'profileCard:seen', + { + profileDid: item.did, + position, + ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), + }, + {statsig: false}, + ) + }, + [followers, resolvedDid], + ) + if (followers.length < 1) { return ( ) } @@ -83,6 +87,40 @@ export function ProfileFollows({name}: {name: string}) { } }, [error, fetchNextPage, hasNextPage, isFetchingNextPage]) + const renderItemWithContext = React.useCallback( + ({item, index}: {item: ActorDefs.ProfileView; index: number}) => + renderItem({item, index, contextProfileDid: resolvedDid}), + [resolvedDid], + ) + + // track seen items + const seenItemsRef = React.useRef>(new Set()) + React.useEffect(() => { + seenItemsRef.current.clear() + }, [resolvedDid]) + const onItemSeen = React.useCallback( + (item: ActorDefs.ProfileView) => { + if (seenItemsRef.current.has(item.did)) { + return + } + seenItemsRef.current.add(item.did) + const position = follows.findIndex(p => p.did === item.did) + 1 + if (position === 0) { + return + } + logger.metric( + 'profileCard:seen', + { + profileDid: item.did, + position, + ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), + }, + {statsig: false}, + ) + }, + [follows, resolvedDid], + ) + if (follows.length < 1) { return ( Date: Mon, 17 Nov 2025 15:23:28 +0200 Subject: [PATCH 07/10] add haptics to segmented control (#9398) --- src/components/forms/SegmentedControl.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/forms/SegmentedControl.tsx b/src/components/forms/SegmentedControl.tsx index 71da847e1..68f57c83a 100644 --- a/src/components/forms/SegmentedControl.tsx +++ b/src/components/forms/SegmentedControl.tsx @@ -9,6 +9,7 @@ import { import {type StyleProp, View, type ViewStyle} from 'react-native' import Animated, {Easing, LinearTransition} from 'react-native-reanimated' +import {useHaptics} from '#/lib/haptics' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a, native, platform, useTheme} from '#/alf' import { @@ -142,6 +143,7 @@ export function Item({ onPress: onPressProp, ...props }: {value: string; children: React.ReactNode} & Omit) { + const playHaptic = useHaptics() const [position, setPosition] = useState<{x: number; width: number} | null>( null, ) @@ -174,10 +176,11 @@ export function Item({ const onPress = useCallback( (evt: any) => { + playHaptic('Light') ctx.onSelectValue(value, position) onPressProp?.(evt) }, - [ctx, value, position, onPressProp], + [ctx, value, position, onPressProp, playHaptic], ) return ( -- 2.51.2 From ef97a20c23673ca4a6c1da7e08a172044d07b31e Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 17 Nov 2025 16:27:00 +0200 Subject: [PATCH 08/10] Fix password autofill on iOS (#9397) * fix password autofill on iOS * update autocomplete props, rm textContentType --- src/screens/Login/LoginForm.tsx | 21 +++++++++++++++---- src/screens/Login/SetNewPasswordForm.tsx | 4 ++-- .../components/ChangePasswordDialog.tsx | 1 + 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index b6a528e42..212059986 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -18,10 +18,11 @@ import {isNetworkError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' import {logger} from '#/logger' +import {isIOS} from '#/platform/detection' import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' import {useSessionApi} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, ios, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {FormError} from '#/components/forms/FormError' import {HostingProvider} from '#/components/forms/HostingProvider' @@ -69,7 +70,9 @@ export const LoginForm = ({ const identifierValueRef = useRef(initialHandle || '') const passwordValueRef = useRef('') const authFactorTokenValueRef = useRef('') + const identifierRef = useRef(null) const passwordRef = useRef(null) + const hasFocusedOnce = useRef(false) const {_} = useLingui() const {login} = useSessionApi() const requestNotificationsPermission = useRequestNotificationsPermission() @@ -198,9 +201,10 @@ export const LoginForm = ({ { passwordValueRef.current = v @@ -241,6 +244,16 @@ export const LoginForm = ({ blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing editable={!isProcessing} accessibilityHint={_(msg`Enter your password`)} + onLayout={ios(() => { + if (hasFocusedOnce.current) return + hasFocusedOnce.current = true + // kinda dumb, but if we use `autoFocus` to focus + // the username input, it happens before the password + // input gets rendered. this breaks the password autofill + // on iOS (it only does the username part). delaying + // it until both inputs are rendered fixes the autofill -sfn + identifierRef.current?.focus() + })} />