diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index eca1c86f00..e37d2c3e0e 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -133,16 +133,26 @@ function useExperimentalSuggestedUsersQuery() { const {currentAccount} = useSession() const userActionSnapshot = userActionHistory.useActionHistorySnapshot() const dids = React.useMemo(() => { - const {likes, follows, seen} = userActionSnapshot + const {likes, follows, followSuggestions, seen} = userActionSnapshot const likeDids = likes .map(l => new AtUri(l)) .map(uri => uri.host) .filter(did => !follows.includes(did)) + let suggestedDids: string[] = [] + if (followSuggestions.length > 0) { + suggestedDids = [ + // It's ok if these will pick the same item (weighed by its frequency) + followSuggestions[Math.floor(Math.random() * followSuggestions.length)], + followSuggestions[Math.floor(Math.random() * followSuggestions.length)], + followSuggestions[Math.floor(Math.random() * followSuggestions.length)], + followSuggestions[Math.floor(Math.random() * followSuggestions.length)], + ] + } const seenDids = seen .sort(sortSeenPosts) .map(l => new AtUri(l.uri)) .map(uri => uri.host) - return [...new Set([...likeDids, ...seenDids])].filter( + return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter( did => did !== currentAccount?.did, ) }, [userActionSnapshot, currentAccount]) diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx index 45122a4efe..f2d13f6424 100644 --- a/src/components/moderation/ContentHider.tsx +++ b/src/components/moderation/ContentHider.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {StyleProp, View, ViewStyle} from 'react-native' import {ModerationUI} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -40,7 +40,7 @@ export function ContentHider({ if (!blur || (ignoreMute && isJustAMute(modui))) { return ( - + {children} ) @@ -163,21 +163,3 @@ export function ContentHider({ ) } - -const styles = StyleSheet.create({ - outer: {}, - cover: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - borderRadius: 8, - marginTop: 4, - paddingVertical: 14, - paddingLeft: 14, - paddingRight: 18, - }, - showBtn: { - marginLeft: 'auto', - alignSelf: 'center', - }, -}) diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index fafc66143a..88b9eee3a5 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -5,7 +5,7 @@ import {BskyAgent} from '@atproto/api' import {logger} from '#/logger' import {SessionAccount, useAgent, useSession} from '#/state/session' -import {logEvent, useGate} from 'lib/statsig/statsig' +import {logEvent} from 'lib/statsig/statsig' import {devicePlatform, isAndroid, isNative} from 'platform/detection' import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler' @@ -86,7 +86,6 @@ export function useNotificationsRegistration() { } export function useRequestNotificationsPermission() { - const gate = useGate() const {currentAccount} = useSession() const agent = useAgent() @@ -102,16 +101,7 @@ export function useRequestNotificationsPermission() { ) { return } - if ( - context === 'StartOnboarding' && - gate('request_notifications_permission_after_onboarding_v2') - ) { - return - } - if ( - context === 'AfterOnboarding' && - !gate('request_notifications_permission_after_onboarding_v2') - ) { + if (context === 'AfterOnboarding') { return } if (context === 'Home' && !currentAccount) { diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 997a366a41..9a427ad40f 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -159,6 +159,7 @@ export type LogEvents = { | 'AvatarButton' | 'StarterPackProfilesList' | 'FeedInterstitial' + | 'ProfileHeaderSuggestedFollows' } 'profile:unfollow': { logContext: @@ -173,6 +174,7 @@ export type LogEvents = { | 'AvatarButton' | 'StarterPackProfilesList' | 'FeedInterstitial' + | 'ProfileHeaderSuggestedFollows' } 'chat:create': { logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' @@ -211,6 +213,8 @@ export type LogEvents = { 'feed:interstitial:profileCard:press': {} 'feed:interstitial:feedCard:press': {} + 'profile:header:suggestedFollowsCard:press': {} + 'debug:followingPrefs': { followingShowRepliesFromPref: 'all' | 'following' | 'off' followingRepliesMinLikePref: number diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 4b482b47dc..5ae6bd5300 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -1,18 +1,10 @@ export type Gate = // Keep this alphabetic please. | 'debug_show_feedcontext' - | 'explore_page_profile_card_social_proof' - | 'native_pwi_disabled' | 'new_user_guided_tour' - | 'new_user_progress_guide' | 'onboarding_minimum_interests' - | 'request_notifications_permission_after_onboarding_v2' | 'session_withproxy_fix' - | 'show_avi_follow_button' | 'show_follow_back_label_v2' | 'suggested_feeds_interstitial' - | 'suggested_follows_interstitial' - | 'ungroup_follow_backs' | 'video_debug' | 'videos' - | 'small_avi_thumb' diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index b275b31910..2b6353b276 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -157,7 +157,7 @@ let ProfileHeaderStandard = ({ hideBackButton={hideBackButton} isPlaceholderProfile={isPlaceholderProfile}> gate('ungroup_follow_backs'), priority, }) page = fetchedPage diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index b5f7d0d60b..7bb325ea98 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -8,7 +8,6 @@ import {useQueryClient} from '@tanstack/react-query' import EventEmitter from 'eventemitter3' import BroadcastChannel from '#/lib/broadcast' -import {useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {useAgent, useSession} from '#/state/session' import {resetBadgeCount} from 'lib/notifications/notifications' @@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const agent = useAgent() const queryClient = useQueryClient() const moderationOpts = useModerationOpts() - const gate = useGate() const [numUnread, setNumUnread] = React.useState('') @@ -151,7 +149,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { // only fetch subjects when the page is going to be used // in the notifications query, otherwise skip it fetchAdditionalData: !!invalidate, - shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'), }) const unreadCount = countUnread(page) const unreadCountStr = @@ -192,7 +189,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } }, } - }, [setNumUnread, queryClient, moderationOpts, agent, gate]) + }, [setNumUnread, queryClient, moderationOpts, agent]) checkUnreadRef.current = api.checkUnread return ( diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index 7651e414a4..e0ee02294e 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -30,7 +30,6 @@ export async function fetchPage({ queryClient, moderationOpts, fetchAdditionalData, - shouldUngroupFollowBacks, }: { agent: BskyAgent cursor: string | undefined @@ -38,7 +37,6 @@ export async function fetchPage({ queryClient: QueryClient moderationOpts: ModerationOpts | undefined fetchAdditionalData: boolean - shouldUngroupFollowBacks?: () => boolean priority?: boolean }): Promise<{ page: FeedPage @@ -58,7 +56,7 @@ export async function fetchPage({ ) // group notifications which are essentially similar (follows, likes on a post) - let notifsGrouped = groupNotifications(notifs, {shouldUngroupFollowBacks}) + let notifsGrouped = groupNotifications(notifs) // we fetch subjects of notifications (usually posts) now instead of lazily // in the UI to avoid relayouts @@ -117,7 +115,6 @@ export function shouldFilterNotif( export function groupNotifications( notifs: AppBskyNotificationListNotifications.Notification[], - options?: {shouldUngroupFollowBacks?: () => boolean}, ): FeedNotification[] { const groupedNotifs: FeedNotification[] = [] for (const notif of notifs) { @@ -137,9 +134,7 @@ export function groupNotifications( const prevIsFollowBack = groupedNotif.notification.reason === 'follow' && groupedNotif.notification.author.viewer?.following - const shouldUngroup = - (nextIsFollowBack || prevIsFollowBack) && - options?.shouldUngroupFollowBacks?.() + const shouldUngroup = nextIsFollowBack || prevIsFollowBack if (!shouldUngroup) { groupedNotif.additional = groupedNotif.additional || [] groupedNotif.additional.push(notif) diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index c01b96ed81..fd419d1c44 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -137,6 +137,7 @@ export function sortThread( opts: UsePreferencesQueryResponse['threadViewPrefs'], modCache: ThreadModerationCache, currentDid: string | undefined, + justPostedUris: Set, ): ThreadNode { if (node.type !== 'post') { return node @@ -150,6 +151,20 @@ export function sortThread( return -1 } + if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) { + const aIsJustPosted = + a.post.author.did === currentDid && justPostedUris.has(a.post.uri) + const bIsJustPosted = + b.post.author.did === currentDid && justPostedUris.has(b.post.uri) + if (aIsJustPosted && bIsJustPosted) { + return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest + } else if (aIsJustPosted) { + return -1 // reply while onscreen + } else if (bIsJustPosted) { + return 1 // reply while onscreen + } + } + const aIsByOp = a.post.author.did === node.post?.author.did const bIsByOp = b.post.author.did === node.post?.author.did if (aIsByOp && bIsByOp) { @@ -206,7 +221,9 @@ export function sortThread( } return b.post.indexedAt.localeCompare(a.post.indexedAt) }) - node.replies.forEach(reply => sortThread(reply, opts, modCache, currentDid)) + node.replies.forEach(reply => + sortThread(reply, opts, modCache, currentDid, justPostedUris), + ) } return node } diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts index 75e3dd6e48..03c983ff80 100644 --- a/src/state/queries/profile-lists.ts +++ b/src/state/queries/profile-lists.ts @@ -40,18 +40,10 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { pages: data.pages.map(page => { return { ...page, - lists: page.lists - /* - * Starter packs use a reference list, which we do not want to - * show on profiles. At some point we could probably just filter - * this out on the backend instead of in the client. - */ - .filter(l => l.purpose !== 'app.bsky.graph.defs#referencelist') - // filter by labels - .filter(list => { - const decision = moderateUserList(list, moderationOpts!) - return !decision.ui('contentList').filter - }), + lists: page.lists.filter(list => { + const decision = moderateUserList(list, moderationOpts!) + return !decision.ui('contentList').filter + }), } }), } diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 1f866d26d2..6682cf3c89 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -222,6 +222,7 @@ export function useProfileFollowMutationQueue( logContext: LogEvents['profile:follow']['logContext'] & LogEvents['profile:unfollow']['logContext'], ) { + const agent = useAgent() const queryClient = useQueryClient() const did = profile.did const initialFollowingUri = profile.viewer?.following @@ -253,6 +254,20 @@ export function useProfileFollowMutationQueue( updateProfileShadow(queryClient, did, { followingUri: finalFollowingUri, }) + + if (finalFollowingUri) { + agent.app.bsky.graph + .getSuggestedFollowsByActor({ + actor: did, + }) + .then(res => { + const dids = res.data.suggestions + .filter(a => !a.viewer?.following) + .map(a => a.did) + .slice(0, 8) + userActionHistory.followSuggestion(dids) + }) + } }, }) diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index a1244721a2..f5d51a974a 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -106,6 +106,7 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) { export function useSuggestedFollowsByActorQuery({did}: {did: string}) { const agent = useAgent() return useQuery({ + gcTime: 0, queryKey: suggestedFollowsByActorQueryKey(did), queryFn: async () => { const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx index 5b4e505439..e28d6b4ac0 100644 --- a/src/state/shell/composer.tsx +++ b/src/state/shell/composer.tsx @@ -1,10 +1,11 @@ import React from 'react' import { + AppBskyActorDefs, AppBskyEmbedRecord, AppBskyRichtextFacet, ModerationDecision, - AppBskyActorDefs, } from '@atproto/api' + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' export interface ComposerOptsPostRef { @@ -31,7 +32,7 @@ export interface ComposerOptsQuote { } export interface ComposerOpts { replyTo?: ComposerOptsPostRef - onPost?: () => void + onPost?: (postUri: string | undefined) => void quote?: ComposerOptsQuote mention?: string // handle of user to mention openPicker?: (pos: DOMRect | undefined) => void diff --git a/src/state/shell/progress-guide.tsx b/src/state/shell/progress-guide.tsx index c9b42a263e..d64e9984f5 100644 --- a/src/state/shell/progress-guide.tsx +++ b/src/state/shell/progress-guide.tsx @@ -2,7 +2,6 @@ import React from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useGate} from '#/lib/statsig/statsig' import { ProgressGuideToast, ProgressGuideToastRef, @@ -61,7 +60,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const {data: preferences} = usePreferencesQuery() const {mutateAsync, variables, isPending} = useSetActiveProgressGuideMutation() - const gate = useGate() const activeProgressGuide = ( isPending ? variables : preferences?.bskyAppState?.activeProgressGuide @@ -89,9 +87,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const controls = React.useMemo(() => { return { startProgressGuide(guide: ProgressGuideName) { - if (!gate('new_user_progress_guide')) { - return - } if (guide === 'like-10-and-follow-7') { const guideObj = { guide: 'like-10-and-follow-7', @@ -148,7 +143,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { mutateAsync(guide?.isComplete ? undefined : guide) }, } - }, [activeProgressGuide, mutateAsync, gate, setLocalGuideState]) + }, [activeProgressGuide, mutateAsync, setLocalGuideState]) return ( diff --git a/src/state/userActionHistory.ts b/src/state/userActionHistory.ts index d82b3723a4..8ffe7241e6 100644 --- a/src/state/userActionHistory.ts +++ b/src/state/userActionHistory.ts @@ -2,6 +2,7 @@ import React from 'react' const LIKE_WINDOW = 100 const FOLLOW_WINDOW = 100 +const FOLLOW_SUGGESTION_WINDOW = 100 const SEEN_WINDOW = 100 export type SeenPost = { @@ -22,6 +23,10 @@ export type UserActionHistory = { * The last 100 DIDs the user has followed */ follows: string[] + /* + * The last 100 DIDs of suggested follows based on last follows + */ + followSuggestions: string[] /** * The last 100 post URIs the user has seen from the Discover feed only */ @@ -31,6 +36,7 @@ export type UserActionHistory = { const userActionHistory: UserActionHistory = { likes: [], follows: [], + followSuggestions: [], seen: [], } @@ -58,6 +64,13 @@ export function follow(dids: string[]) { .concat(dids) .slice(-FOLLOW_WINDOW) } + +export function followSuggestion(dids: string[]) { + userActionHistory.followSuggestions = userActionHistory.followSuggestions + .concat(dids) + .slice(-FOLLOW_SUGGESTION_WINDOW) +} + export function unfollow(dids: string[]) { userActionHistory.follows = userActionHistory.follows.filter( uri => !dids.includes(uri), diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index cc71f648e9..cc57238053 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -1,25 +1,20 @@ import React from 'react' import {Pressable, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' import {useAnalytics} from '#/lib/analytics/analytics' import {usePalette} from '#/lib/hooks/usePalette' import {logEvent} from '#/lib/statsig/statsig' import {s} from '#/lib/styles' -import {isIOS, isNative} from '#/platform/detection' -import {useSession} from '#/state/session' +import {isIOS} from '#/platform/detection' import { useLoggedOutView, useLoggedOutViewControls, } from '#/state/shell/logged-out' import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' -import {NavigationProp} from 'lib/routes/types' -import {useGate} from 'lib/statsig/statsig' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' -import {Text} from '#/view/com/util/text/Text' import {Login} from '#/screens/Login' import {Signup} from '#/screens/Signup' import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen' @@ -34,7 +29,6 @@ enum ScreenState { export {ScreenState as LoggedOutScreenState} export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { - const {hasSession} = useSession() const {_} = useLingui() const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() @@ -52,10 +46,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { } }) const {clearRequestedAccount} = useLoggedOutViewControls() - const navigation = useNavigation() - const gate = useGate() - const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount React.useEffect(() => { screen('Login') setMinimalShellMode(true) @@ -68,10 +59,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { clearRequestedAccount() }, [clearRequestedAccount, onDismiss]) - const onPressSearch = React.useCallback(() => { - navigation.navigate(`SearchTab`) - }, [navigation]) - return ( @@ -98,39 +85,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { }} /> - ) : isNative && - !hasSession && - isFirstScreen && - !gate('native_pwi_disabled') ? ( - - - Search{' '} - - - ) : null} {screenState === ScreenState.S_StarterPack ? ( diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 08ce4441f0..dba37d82bf 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -392,7 +392,7 @@ export const ComposePost = observer(function ComposePost({ emitPostCreated() } setLangPrefs.savePostLanguageToHistory() - onPost?.() + onPost?.(postUri) onClose() Toast.show( replyTo diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx index 6b38caff03..d4ba1f3a86 100644 --- a/src/view/com/composer/ComposerReplyTo.tsx +++ b/src/view/com/composer/ComposerReplyTo.tsx @@ -91,7 +91,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { type={replyTo.author.associated?.labeler ? 'labeler' : 'user'} /> - + {sanitizeDisplayName( replyTo.author.displayName || sanitizeHandle(replyTo.author.handle), )} diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index eac9c3ad10..5ecefa8c26 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -25,7 +25,6 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' -import {useGate} from '#/lib/statsig/statsig' import {parseTenorGif} from '#/lib/strings/embed-player' import {logger} from '#/logger' import {FeedNotification} from '#/state/queries/notifications/feed' @@ -87,7 +86,6 @@ let FeedItem = ({ const pal = usePalette('default') const {_} = useLingui() const t = useTheme() - const gate = useGate() const [isAuthorsExpanded, setAuthorsExpanded] = useState(false) const itemHref = useMemo(() => { if (item.type === 'post-like' || item.type === 'repost') { @@ -275,7 +273,7 @@ let FeedItem = ({ } } - if (isFollowBack && gate('ungroup_follow_backs')) { + if (isFollowBack) { a11yLabel = authors.length > 1 ? _( @@ -404,6 +402,7 @@ let FeedItem = ({ borderColor: pal.colors.unreadNotifBorder, }, {borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth}, + a.overflow_hidden, ]} href={itemHref} noFeedback @@ -676,7 +675,7 @@ function ExpandedAuthorsList({ }, [heightInterp, visible]) return ( - + {visible && authors.map(author => ( { return item._reactKey } -export function PostThread({ - uri, - onCanReply, - onPressReply, -}: { - uri: string | undefined - onCanReply: (canReply: boolean) => void - onPressReply: () => unknown -}) { +export function PostThread({uri}: {uri: string | undefined}) { const {hasSession, currentAccount} = useSession() const {_} = useLingui() const t = useTheme() @@ -163,12 +160,22 @@ export function PostThread({ return cache }, [thread, moderationOpts]) + const [justPostedUris, setJustPostedUris] = React.useState( + () => new Set(), + ) + const skeleton = React.useMemo(() => { const threadViewPrefs = preferences?.threadViewPrefs if (!threadViewPrefs || !thread) return null return createThreadSkeleton( - sortThread(thread, threadViewPrefs, threadModerationCache, currentDid), + sortThread( + thread, + threadViewPrefs, + threadModerationCache, + currentDid, + justPostedUris, + ), !!currentDid, treeView, threadModerationCache, @@ -181,6 +188,7 @@ export function PostThread({ treeView, threadModerationCache, hiddenRepliesState, + justPostedUris, ]) const error = React.useMemo(() => { @@ -210,14 +218,6 @@ export function PostThread({ return null }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError]) - useEffect(() => { - if (error) { - onCanReply(false) - } else if (rootPost) { - onCanReply(!rootPost.viewer?.replyDisabled) - } - }, [rootPost, onCanReply, error]) - // construct content const posts = React.useMemo(() => { if (!skeleton) return [] @@ -313,6 +313,38 @@ export function PostThread({ setMaxReplies(prev => prev + 50) }, [isFetching, maxReplies, posts.length]) + const onPostReply = React.useCallback( + (postUri: string | undefined) => { + refetch() + if (postUri) { + setJustPostedUris(set => { + const nextSet = new Set(set) + nextSet.add(postUri) + return nextSet + }) + } + }, + [refetch], + ) + + const {openComposer} = useComposerControls() + const onPressReply = React.useCallback(() => { + if (thread?.type !== 'post') { + return + } + openComposer({ + replyTo: { + uri: thread.post.uri, + cid: thread.post.cid, + text: thread.record.text, + author: thread.post.author, + embed: thread.post.embed, + }, + onPost: onPostReply, + }) + }, [openComposer, thread, onPostReply]) + + const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled const hasParents = skeleton?.highlightedPost?.type === 'post' && (skeleton.highlightedPost.ctx.isParentLoading || @@ -324,7 +356,9 @@ export function PostThread({ if (item === REPLY_PROMPT && hasSession) { return ( - {!isMobile && } + {!isMobile && ( + + )} ) } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) { @@ -406,7 +440,7 @@ export function PostThread({ HiddenRepliesState.ShowAndOverridePostHider && item.ctx.depth > 0 } - onPostReply={refetch} + onPostReply={onPostReply} hideTopBorder={index === 0 && !item.ctx.isParentLoading} /> @@ -473,10 +507,30 @@ export function PostThread({ sideBorders={false} /> + {isMobile && canReply && hasSession && ( + + )} ) } +function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { + const safeAreaInsets = useSafeAreaInsets() + const fabMinimalShellTransform = useMinimalShellFabTransform() + return ( + + + + ) +} + function isThreadPost(v: unknown): v is ThreadPost { return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' } @@ -622,3 +676,12 @@ function hasBranchingReplies(node?: ThreadNode) { } return true } + +const styles = StyleSheet.create({ + prompt: { + // @ts-ignore web-only + position: isWeb ? 'fixed' : 'absolute', + left: 0, + right: 0, + }, +}) diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx similarity index 94% rename from src/view/com/composer/Prompt.tsx rename to src/view/com/post-thread/PostThreadComposePrompt.tsx index 20637c7e9f..62b28cc759 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx @@ -10,7 +10,11 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' -export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { +export function PostThreadComposePrompt({ + onPressCompose, +}: { + onPressCompose: () => void +}) { const {currentAccount} = useSession() const {data: profile} = useProfileQuery({did: currentAccount?.did}) const pal = usePalette('default') diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 63dac300a8..8adbb17e29 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -75,7 +75,7 @@ export function PostThreadItem({ showParentReplyLine?: boolean hasPrecedingItem: boolean overrideBlur: boolean - onPostReply: () => void + onPostReply: (postUri: string | undefined) => void hideTopBorder?: boolean }) { const postShadowed = usePostShadow(post) @@ -169,7 +169,7 @@ let PostThreadItemLoaded = ({ showParentReplyLine?: boolean hasPrecedingItem: boolean overrideBlur: boolean - onPostReply: () => void + onPostReply: (postUri: string | undefined) => void hideTopBorder?: boolean }): React.ReactNode => { const pal = usePalette('default') @@ -742,6 +742,7 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', paddingBottom: 4, paddingRight: 10, + overflow: 'hidden', }, postTextLargeContainer: { paddingHorizontal: 0, diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index ade0e7f10f..8121b8abcc 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -12,17 +12,17 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {MAX_POST_LINES} from '#/lib/constants' +import {usePalette} from '#/lib/hooks/usePalette' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' +import {makeProfileLink} from '#/lib/routes/links' +import {countLines} from '#/lib/strings/helpers' +import {colors, s} from '#/lib/styles' import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {precacheProfile} from '#/state/queries/profile' import {useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' -import {MAX_POST_LINES} from 'lib/constants' -import {usePalette} from 'lib/hooks/usePalette' -import {makeProfileLink} from 'lib/routes/links' -import {countLines} from 'lib/strings/helpers' -import {colors, s} from 'lib/styles' -import {precacheProfile} from 'state/queries/profile' import {AviFollowButton} from '#/view/com/posts/AviFollowButton' import {atoms as a} from '#/alf' import {ProfileHoverCard} from '#/components/ProfileHoverCard' @@ -280,6 +280,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', + overflow: 'hidden', }, replyLine: { position: 'absolute', diff --git a/src/view/com/posts/AviFollowButton.tsx b/src/view/com/posts/AviFollowButton.tsx index f7141ee421..00428cbe61 100644 --- a/src/view/com/posts/AviFollowButton.tsx +++ b/src/view/com/posts/AviFollowButton.tsx @@ -7,7 +7,6 @@ import {useNavigation} from '@react-navigation/native' import {createHitslop} from '#/lib/constants' import {NavigationProp} from '#/lib/routes/types' -import {useGate} from '#/lib/statsig/statsig' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession} from '#/state/session' @@ -37,7 +36,6 @@ export function AviFollowButton({ profile: profile, logContext: 'AvatarButton', }) - const gate = useGate() const {currentAccount, hasSession} = useSession() const navigation = useNavigation() @@ -80,7 +78,7 @@ export function AviFollowButton({ }, ] - return hasSession && gate('show_avi_follow_button') ? ( + return hasSession ? ( {children} diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index ef46333193..54ea9b1400 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -349,8 +349,7 @@ let Feed = ({ const shouldShow = (interstitial.type === feedInterstitialType && gate('suggested_feeds_interstitial')) || - (interstitial.type === followInterstitialType && - gate('suggested_follows_interstitial')) || + interstitial.type === followInterstitialType || interstitial.type === progressGuideInterstitialType if (shouldShow) { diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 6660a8d9d6..8592f0bec2 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -544,6 +544,7 @@ const styles = StyleSheet.create({ alignItems: 'center', flexWrap: 'wrap', paddingBottom: 2, + overflow: 'hidden', }, contentHiderChild: { marginTop: 6, diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx index c7df4d75be..356b3f09cf 100644 --- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx +++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx @@ -1,32 +1,60 @@ import React from 'react' -import {Pressable, ScrollView, StyleSheet, View} from 'react-native' -import {AppBskyActorDefs, moderateProfile} from '@atproto/api' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' +import {ScrollView, View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useProfileShadow} from '#/state/cache/profile-shadow' +import {logEvent} from '#/lib/statsig/statsig' import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' -import {useAnalytics} from 'lib/analytics/analytics' -import {usePalette} from 'lib/hooks/usePalette' -import {makeProfileLink} from 'lib/routes/links' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' import {isWeb} from 'platform/detection' -import {Button} from 'view/com/util/forms/Button' -import {Link} from 'view/com/util/Link' -import {Text} from 'view/com/util/text/Text' -import {PreviewableUserAvatar} from 'view/com/util/UserAvatar' -import * as Toast from '../util/Toast' +import {atoms as a, useTheme, ViewStyleProp} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' -const OUTER_PADDING = 10 -const INNER_PADDING = 14 -const TOTAL_HEIGHT = 250 +const OUTER_PADDING = a.p_md.padding +const INNER_PADDING = a.p_lg.padding +const TOTAL_HEIGHT = 232 +const MOBILE_CARD_WIDTH = 300 + +function CardOuter({ + children, + style, +}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { + const t = useTheme() + return ( + + {children} + + ) +} + +export function SuggestedFollowPlaceholder() { + const t = useTheme() + return ( + + + + + + + + + ) +} export function ProfileHeaderSuggestedFollows({ actorDid, @@ -35,47 +63,55 @@ export function ProfileHeaderSuggestedFollows({ actorDid: string requestDismiss: () => void }) { - const pal = usePalette('default') - const {isLoading, data} = useSuggestedFollowsByActorQuery({ - did: actorDid, - }) + const t = useTheme() + const {_} = useLingui() + const {isLoading: isSuggestionsLoading, data} = + useSuggestedFollowsByActorQuery({ + did: actorDid, + }) + const moderationOpts = useModerationOpts() + const isLoading = isSuggestionsLoading || !moderationOpts + return ( + style={[ + t.atoms.bg_contrast_25, + { + height: '100%', + paddingTop: INNER_PADDING / 2, + }, + ]}> - - Suggested for you + style={[ + a.flex_row, + a.justify_between, + a.align_center, + a.pt_xs, + { + paddingBottom: INNER_PADDING / 2, + paddingLeft: INNER_PADDING, + paddingRight: INNER_PADDING / 2, + }, + ]}> + + Similar accounts - - - + label={_(msg`Dismiss`)} + size="xsmall" + variant="ghost" + color="secondary" + shape="round"> + + - {isLoading ? ( - <> - - - - - - - - ) : data ? ( - data.suggestions - .filter(s => (s.associated?.labeler ? false : true)) - .map(profile => ( - - )) - ) : ( - - )} + snapToInterval={MOBILE_CARD_WIDTH + a.gap_sm.gap} + decelerationRate="fast"> + + {isLoading ? ( + <> + + + + + + + ) : data ? ( + data.suggestions + .filter(s => (s.associated?.labeler ? false : true)) + .map(profile => ( + { + logEvent('profile:header:suggestedFollowsCard:press', {}) + }} + style={[a.flex_1]}> + {({hovered, pressed}) => ( + + + + + + + + + + + )} + + )) + ) : ( + + )} + ) } - -function SuggestedFollowSkeleton() { - const pal = usePalette('default') - return ( - - - - - - - ) -} - -function SuggestedFollow({ - profile: profileUnshadowed, -}: { - profile: AppBskyActorDefs.ProfileView -}) { - const {track} = useAnalytics() - const pal = usePalette('default') - const {_} = useLingui() - const moderationOpts = useModerationOpts() - const profile = useProfileShadow(profileUnshadowed) - const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( - profile, - 'ProfileHeaderSuggestedFollows', - ) - - const onPressFollow = React.useCallback(async () => { - try { - track('ProfileHeader:SuggestedFollowFollowed') - await queueFollow() - } catch (e: any) { - if (e?.name !== 'AbortError') { - Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') - } - } - }, [queueFollow, track, _]) - - const onPressUnfollow = React.useCallback(async () => { - try { - await queueUnfollow() - } catch (e: any) { - if (e?.name !== 'AbortError') { - Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') - } - } - }, [queueUnfollow, _]) - - if (!moderationOpts) { - return null - } - const moderation = moderateProfile(profile, moderationOpts) - const following = profile.viewer?.following - return ( - - - - - - - {sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.ui('displayName'), - )} - - - {sanitizeHandle(profile.handle, '@')} - - - -