Merge remote-tracking branch 'upstream/main' into Improve-notification-localization

This commit is contained in:
Minseo Lee
2024-08-09 09:12:11 +09:00
32 changed files with 348 additions and 496 deletions
+12 -2
View File
@@ -133,16 +133,26 @@ function useExperimentalSuggestedUsersQuery() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const userActionSnapshot = userActionHistory.useActionHistorySnapshot() const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = React.useMemo(() => { const dids = React.useMemo(() => {
const {likes, follows, seen} = userActionSnapshot const {likes, follows, followSuggestions, seen} = userActionSnapshot
const likeDids = likes const likeDids = likes
.map(l => new AtUri(l)) .map(l => new AtUri(l))
.map(uri => uri.host) .map(uri => uri.host)
.filter(did => !follows.includes(did)) .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 const seenDids = seen
.sort(sortSeenPosts) .sort(sortSeenPosts)
.map(l => new AtUri(l.uri)) .map(l => new AtUri(l.uri))
.map(uri => uri.host) .map(uri => uri.host)
return [...new Set([...likeDids, ...seenDids])].filter( return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter(
did => did !== currentAccount?.did, did => did !== currentAccount?.did,
) )
}, [userActionSnapshot, currentAccount]) }, [userActionSnapshot, currentAccount])
+2 -20
View File
@@ -1,5 +1,5 @@
import React from 'react' 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 {ModerationUI} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -40,7 +40,7 @@ export function ContentHider({
if (!blur || (ignoreMute && isJustAMute(modui))) { if (!blur || (ignoreMute && isJustAMute(modui))) {
return ( return (
<View testID={testID} style={[styles.outer, style]}> <View testID={testID} style={style}>
{children} {children}
</View> </View>
) )
@@ -163,21 +163,3 @@ export function ContentHider({
</View> </View>
) )
} }
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',
},
})
+2 -12
View File
@@ -5,7 +5,7 @@ import {BskyAgent} from '@atproto/api'
import {logger} from '#/logger' import {logger} from '#/logger'
import {SessionAccount, useAgent, useSession} from '#/state/session' 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 {devicePlatform, isAndroid, isNative} from 'platform/detection'
import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler' import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler'
@@ -86,7 +86,6 @@ export function useNotificationsRegistration() {
} }
export function useRequestNotificationsPermission() { export function useRequestNotificationsPermission() {
const gate = useGate()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
@@ -102,16 +101,7 @@ export function useRequestNotificationsPermission() {
) { ) {
return return
} }
if ( if (context === 'AfterOnboarding') {
context === 'StartOnboarding' &&
gate('request_notifications_permission_after_onboarding_v2')
) {
return
}
if (
context === 'AfterOnboarding' &&
!gate('request_notifications_permission_after_onboarding_v2')
) {
return return
} }
if (context === 'Home' && !currentAccount) { if (context === 'Home' && !currentAccount) {
+4
View File
@@ -159,6 +159,7 @@ export type LogEvents = {
| 'AvatarButton' | 'AvatarButton'
| 'StarterPackProfilesList' | 'StarterPackProfilesList'
| 'FeedInterstitial' | 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
} }
'profile:unfollow': { 'profile:unfollow': {
logContext: logContext:
@@ -173,6 +174,7 @@ export type LogEvents = {
| 'AvatarButton' | 'AvatarButton'
| 'StarterPackProfilesList' | 'StarterPackProfilesList'
| 'FeedInterstitial' | 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
} }
'chat:create': { 'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
@@ -211,6 +213,8 @@ export type LogEvents = {
'feed:interstitial:profileCard:press': {} 'feed:interstitial:profileCard:press': {}
'feed:interstitial:feedCard:press': {} 'feed:interstitial:feedCard:press': {}
'profile:header:suggestedFollowsCard:press': {}
'debug:followingPrefs': { 'debug:followingPrefs': {
followingShowRepliesFromPref: 'all' | 'following' | 'off' followingShowRepliesFromPref: 'all' | 'following' | 'off'
followingRepliesMinLikePref: number followingRepliesMinLikePref: number
-8
View File
@@ -1,18 +1,10 @@
export type Gate = export type Gate =
// Keep this alphabetic please. // Keep this alphabetic please.
| 'debug_show_feedcontext' | 'debug_show_feedcontext'
| 'explore_page_profile_card_social_proof'
| 'native_pwi_disabled'
| 'new_user_guided_tour' | 'new_user_guided_tour'
| 'new_user_progress_guide'
| 'onboarding_minimum_interests' | 'onboarding_minimum_interests'
| 'request_notifications_permission_after_onboarding_v2'
| 'session_withproxy_fix' | 'session_withproxy_fix'
| 'show_avi_follow_button'
| 'show_follow_back_label_v2' | 'show_follow_back_label_v2'
| 'suggested_feeds_interstitial' | 'suggested_feeds_interstitial'
| 'suggested_follows_interstitial'
| 'ungroup_follow_backs'
| 'video_debug' | 'video_debug'
| 'videos' | 'videos'
| 'small_avi_thumb'
@@ -157,7 +157,7 @@ let ProfileHeaderStandard = ({
hideBackButton={hideBackButton} hideBackButton={hideBackButton}
isPlaceholderProfile={isPlaceholderProfile}> isPlaceholderProfile={isPlaceholderProfile}>
<View <View
style={[a.px_lg, a.pt_md, a.pb_sm]} style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
pointerEvents={isIOS ? 'auto' : 'box-none'}> pointerEvents={isIOS ? 'auto' : 'box-none'}>
<View <View
style={[ style={[
-3
View File
@@ -26,7 +26,6 @@ import {
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {useGate} from '#/lib/statsig/statsig'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useModerationOpts} from '../../preferences/moderation-opts' import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..' import {STALE} from '..'
@@ -59,7 +58,6 @@ export function useNotificationFeedQuery(opts?: {
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const unreads = useUnreadNotificationsApi() const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false const enabled = opts?.enabled !== false
const gate = useGate()
// false: force showing all notifications // false: force showing all notifications
// undefined: let the server decide // undefined: let the server decide
@@ -88,7 +86,6 @@ export function useNotificationFeedQuery(opts?: {
queryClient, queryClient,
moderationOpts, moderationOpts,
fetchAdditionalData: true, fetchAdditionalData: true,
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
priority, priority,
}) })
page = fetchedPage page = fetchedPage
+1 -4
View File
@@ -8,7 +8,6 @@ import {useQueryClient} from '@tanstack/react-query'
import EventEmitter from 'eventemitter3' import EventEmitter from 'eventemitter3'
import BroadcastChannel from '#/lib/broadcast' import BroadcastChannel from '#/lib/broadcast'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {resetBadgeCount} from 'lib/notifications/notifications' import {resetBadgeCount} from 'lib/notifications/notifications'
@@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const gate = useGate()
const [numUnread, setNumUnread] = React.useState('') 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 // only fetch subjects when the page is going to be used
// in the notifications query, otherwise skip it // in the notifications query, otherwise skip it
fetchAdditionalData: !!invalidate, fetchAdditionalData: !!invalidate,
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
}) })
const unreadCount = countUnread(page) const unreadCount = countUnread(page)
const unreadCountStr = 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 checkUnreadRef.current = api.checkUnread
return ( return (
+2 -7
View File
@@ -30,7 +30,6 @@ export async function fetchPage({
queryClient, queryClient,
moderationOpts, moderationOpts,
fetchAdditionalData, fetchAdditionalData,
shouldUngroupFollowBacks,
}: { }: {
agent: BskyAgent agent: BskyAgent
cursor: string | undefined cursor: string | undefined
@@ -38,7 +37,6 @@ export async function fetchPage({
queryClient: QueryClient queryClient: QueryClient
moderationOpts: ModerationOpts | undefined moderationOpts: ModerationOpts | undefined
fetchAdditionalData: boolean fetchAdditionalData: boolean
shouldUngroupFollowBacks?: () => boolean
priority?: boolean priority?: boolean
}): Promise<{ }): Promise<{
page: FeedPage page: FeedPage
@@ -58,7 +56,7 @@ export async function fetchPage({
) )
// group notifications which are essentially similar (follows, likes on a post) // 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 // we fetch subjects of notifications (usually posts) now instead of lazily
// in the UI to avoid relayouts // in the UI to avoid relayouts
@@ -117,7 +115,6 @@ export function shouldFilterNotif(
export function groupNotifications( export function groupNotifications(
notifs: AppBskyNotificationListNotifications.Notification[], notifs: AppBskyNotificationListNotifications.Notification[],
options?: {shouldUngroupFollowBacks?: () => boolean},
): FeedNotification[] { ): FeedNotification[] {
const groupedNotifs: FeedNotification[] = [] const groupedNotifs: FeedNotification[] = []
for (const notif of notifs) { for (const notif of notifs) {
@@ -137,9 +134,7 @@ export function groupNotifications(
const prevIsFollowBack = const prevIsFollowBack =
groupedNotif.notification.reason === 'follow' && groupedNotif.notification.reason === 'follow' &&
groupedNotif.notification.author.viewer?.following groupedNotif.notification.author.viewer?.following
const shouldUngroup = const shouldUngroup = nextIsFollowBack || prevIsFollowBack
(nextIsFollowBack || prevIsFollowBack) &&
options?.shouldUngroupFollowBacks?.()
if (!shouldUngroup) { if (!shouldUngroup) {
groupedNotif.additional = groupedNotif.additional || [] groupedNotif.additional = groupedNotif.additional || []
groupedNotif.additional.push(notif) groupedNotif.additional.push(notif)
+18 -1
View File
@@ -137,6 +137,7 @@ export function sortThread(
opts: UsePreferencesQueryResponse['threadViewPrefs'], opts: UsePreferencesQueryResponse['threadViewPrefs'],
modCache: ThreadModerationCache, modCache: ThreadModerationCache,
currentDid: string | undefined, currentDid: string | undefined,
justPostedUris: Set<string>,
): ThreadNode { ): ThreadNode {
if (node.type !== 'post') { if (node.type !== 'post') {
return node return node
@@ -150,6 +151,20 @@ export function sortThread(
return -1 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 aIsByOp = a.post.author.did === node.post?.author.did
const bIsByOp = b.post.author.did === node.post?.author.did const bIsByOp = b.post.author.did === node.post?.author.did
if (aIsByOp && bIsByOp) { if (aIsByOp && bIsByOp) {
@@ -206,7 +221,9 @@ export function sortThread(
} }
return b.post.indexedAt.localeCompare(a.post.indexedAt) 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 return node
} }
+1 -9
View File
@@ -40,15 +40,7 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
pages: data.pages.map(page => { pages: data.pages.map(page => {
return { return {
...page, ...page,
lists: page.lists lists: page.lists.filter(list => {
/*
* 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!) const decision = moderateUserList(list, moderationOpts!)
return !decision.ui('contentList').filter return !decision.ui('contentList').filter
}), }),
+15
View File
@@ -222,6 +222,7 @@ export function useProfileFollowMutationQueue(
logContext: LogEvents['profile:follow']['logContext'] & logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext'], LogEvents['profile:unfollow']['logContext'],
) { ) {
const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const did = profile.did const did = profile.did
const initialFollowingUri = profile.viewer?.following const initialFollowingUri = profile.viewer?.following
@@ -253,6 +254,20 @@ export function useProfileFollowMutationQueue(
updateProfileShadow(queryClient, did, { updateProfileShadow(queryClient, did, {
followingUri: finalFollowingUri, 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)
})
}
}, },
}) })
+1
View File
@@ -106,6 +106,7 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
export function useSuggestedFollowsByActorQuery({did}: {did: string}) { export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
const agent = useAgent() const agent = useAgent()
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({ return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
gcTime: 0,
queryKey: suggestedFollowsByActorQueryKey(did), queryKey: suggestedFollowsByActorQueryKey(did),
queryFn: async () => { queryFn: async () => {
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
+3 -2
View File
@@ -1,10 +1,11 @@
import React from 'react' import React from 'react'
import { import {
AppBskyActorDefs,
AppBskyEmbedRecord, AppBskyEmbedRecord,
AppBskyRichtextFacet, AppBskyRichtextFacet,
ModerationDecision, ModerationDecision,
AppBskyActorDefs,
} from '@atproto/api' } from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
export interface ComposerOptsPostRef { export interface ComposerOptsPostRef {
@@ -31,7 +32,7 @@ export interface ComposerOptsQuote {
} }
export interface ComposerOpts { export interface ComposerOpts {
replyTo?: ComposerOptsPostRef replyTo?: ComposerOptsPostRef
onPost?: () => void onPost?: (postUri: string | undefined) => void
quote?: ComposerOptsQuote quote?: ComposerOptsQuote
mention?: string // handle of user to mention mention?: string // handle of user to mention
openPicker?: (pos: DOMRect | undefined) => void openPicker?: (pos: DOMRect | undefined) => void
+1 -6
View File
@@ -2,7 +2,6 @@ import React from 'react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig'
import { import {
ProgressGuideToast, ProgressGuideToast,
ProgressGuideToastRef, ProgressGuideToastRef,
@@ -61,7 +60,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
const {mutateAsync, variables, isPending} = const {mutateAsync, variables, isPending} =
useSetActiveProgressGuideMutation() useSetActiveProgressGuideMutation()
const gate = useGate()
const activeProgressGuide = ( const activeProgressGuide = (
isPending ? variables : preferences?.bskyAppState?.activeProgressGuide isPending ? variables : preferences?.bskyAppState?.activeProgressGuide
@@ -89,9 +87,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const controls = React.useMemo(() => { const controls = React.useMemo(() => {
return { return {
startProgressGuide(guide: ProgressGuideName) { startProgressGuide(guide: ProgressGuideName) {
if (!gate('new_user_progress_guide')) {
return
}
if (guide === 'like-10-and-follow-7') { if (guide === 'like-10-and-follow-7') {
const guideObj = { const guideObj = {
guide: 'like-10-and-follow-7', guide: 'like-10-and-follow-7',
@@ -148,7 +143,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
mutateAsync(guide?.isComplete ? undefined : guide) mutateAsync(guide?.isComplete ? undefined : guide)
}, },
} }
}, [activeProgressGuide, mutateAsync, gate, setLocalGuideState]) }, [activeProgressGuide, mutateAsync, setLocalGuideState])
return ( return (
<ProgressGuideContext.Provider value={localGuideState}> <ProgressGuideContext.Provider value={localGuideState}>
+13
View File
@@ -2,6 +2,7 @@ import React from 'react'
const LIKE_WINDOW = 100 const LIKE_WINDOW = 100
const FOLLOW_WINDOW = 100 const FOLLOW_WINDOW = 100
const FOLLOW_SUGGESTION_WINDOW = 100
const SEEN_WINDOW = 100 const SEEN_WINDOW = 100
export type SeenPost = { export type SeenPost = {
@@ -22,6 +23,10 @@ export type UserActionHistory = {
* The last 100 DIDs the user has followed * The last 100 DIDs the user has followed
*/ */
follows: string[] 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 * The last 100 post URIs the user has seen from the Discover feed only
*/ */
@@ -31,6 +36,7 @@ export type UserActionHistory = {
const userActionHistory: UserActionHistory = { const userActionHistory: UserActionHistory = {
likes: [], likes: [],
follows: [], follows: [],
followSuggestions: [],
seen: [], seen: [],
} }
@@ -58,6 +64,13 @@ export function follow(dids: string[]) {
.concat(dids) .concat(dids)
.slice(-FOLLOW_WINDOW) .slice(-FOLLOW_WINDOW)
} }
export function followSuggestion(dids: string[]) {
userActionHistory.followSuggestions = userActionHistory.followSuggestions
.concat(dids)
.slice(-FOLLOW_SUGGESTION_WINDOW)
}
export function unfollow(dids: string[]) { export function unfollow(dids: string[]) {
userActionHistory.follows = userActionHistory.follows.filter( userActionHistory.follows = userActionHistory.follows.filter(
uri => !dids.includes(uri), uri => !dids.includes(uri),
+2 -48
View File
@@ -1,25 +1,20 @@
import React from 'react' import React from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useAnalytics} from '#/lib/analytics/analytics' import {useAnalytics} from '#/lib/analytics/analytics'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles' import {s} from '#/lib/styles'
import {isIOS, isNative} from '#/platform/detection' import {isIOS} from '#/platform/detection'
import {useSession} from '#/state/session'
import { import {
useLoggedOutView, useLoggedOutView,
useLoggedOutViewControls, useLoggedOutViewControls,
} from '#/state/shell/logged-out' } from '#/state/shell/logged-out'
import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' 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 {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {Text} from '#/view/com/util/text/Text'
import {Login} from '#/screens/Login' import {Login} from '#/screens/Login'
import {Signup} from '#/screens/Signup' import {Signup} from '#/screens/Signup'
import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen' import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen'
@@ -34,7 +29,6 @@ enum ScreenState {
export {ScreenState as LoggedOutScreenState} export {ScreenState as LoggedOutScreenState}
export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
const {hasSession} = useSession()
const {_} = useLingui() const {_} = useLingui()
const pal = usePalette('default') const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
@@ -52,10 +46,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
} }
}) })
const {clearRequestedAccount} = useLoggedOutViewControls() const {clearRequestedAccount} = useLoggedOutViewControls()
const navigation = useNavigation<NavigationProp>()
const gate = useGate()
const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount
React.useEffect(() => { React.useEffect(() => {
screen('Login') screen('Login')
setMinimalShellMode(true) setMinimalShellMode(true)
@@ -68,10 +59,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
clearRequestedAccount() clearRequestedAccount()
}, [clearRequestedAccount, onDismiss]) }, [clearRequestedAccount, onDismiss])
const onPressSearch = React.useCallback(() => {
navigation.navigate(`SearchTab`)
}, [navigation])
return ( return (
<View testID="noSessionView" style={[s.hContentRegion, pal.view]}> <View testID="noSessionView" style={[s.hContentRegion, pal.view]}>
<ErrorBoundary> <ErrorBoundary>
@@ -98,39 +85,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
}} }}
/> />
</Pressable> </Pressable>
) : isNative &&
!hasSession &&
isFirstScreen &&
!gate('native_pwi_disabled') ? (
<Pressable
accessibilityHint={_(msg`Search for users`)}
accessibilityLabel={_(msg`Search for users`)}
accessibilityRole="button"
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 4,
position: 'absolute',
top: 20,
right: 20,
paddingHorizontal: 16,
paddingVertical: 8,
zIndex: 100,
backgroundColor: pal.btn.backgroundColor,
borderRadius: 100,
}}
onPress={onPressSearch}>
<Text type="lg-bold" style={[pal.text]}>
<Trans>Search</Trans>{' '}
</Text>
<FontAwesomeIcon
icon="search"
size={16}
style={{
color: String(pal.text.color),
}}
/>
</Pressable>
) : null} ) : null}
{screenState === ScreenState.S_StarterPack ? ( {screenState === ScreenState.S_StarterPack ? (
+1 -1
View File
@@ -392,7 +392,7 @@ export const ComposePost = observer(function ComposePost({
emitPostCreated() emitPostCreated()
} }
setLangPrefs.savePostLanguageToHistory() setLangPrefs.savePostLanguageToHistory()
onPost?.() onPost?.(postUri)
onClose() onClose()
Toast.show( Toast.show(
replyTo replyTo
+1 -1
View File
@@ -91,7 +91,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
type={replyTo.author.associated?.labeler ? 'labeler' : 'user'} type={replyTo.author.associated?.labeler ? 'labeler' : 'user'}
/> />
<View style={styles.replyToPost}> <View style={styles.replyToPost}>
<Text type="xl-medium" style={t.atoms.text}> <Text type="xl-medium" style={t.atoms.text} numberOfLines={1}>
{sanitizeDisplayName( {sanitizeDisplayName(
replyTo.author.displayName || sanitizeHandle(replyTo.author.handle), replyTo.author.displayName || sanitizeHandle(replyTo.author.handle),
)} )}
+3 -7
View File
@@ -25,7 +25,6 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useGate} from '#/lib/statsig/statsig'
import {parseTenorGif} from '#/lib/strings/embed-player' import {parseTenorGif} from '#/lib/strings/embed-player'
import {logger} from '#/logger' import {logger} from '#/logger'
import {FeedNotification} from '#/state/queries/notifications/feed' import {FeedNotification} from '#/state/queries/notifications/feed'
@@ -87,7 +86,6 @@ let FeedItem = ({
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const gate = useGate()
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false) const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
const itemHref = useMemo(() => { const itemHref = useMemo(() => {
if (item.type === 'post-like' || item.type === 'repost') { if (item.type === 'post-like' || item.type === 'repost') {
@@ -275,7 +273,7 @@ let FeedItem = ({
} }
} }
if (isFollowBack && gate('ungroup_follow_backs')) { if (isFollowBack) {
a11yLabel = a11yLabel =
authors.length > 1 authors.length > 1
? _( ? _(
@@ -404,6 +402,7 @@ let FeedItem = ({
borderColor: pal.colors.unreadNotifBorder, borderColor: pal.colors.unreadNotifBorder,
}, },
{borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth}, {borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth},
a.overflow_hidden,
]} ]}
href={itemHref} href={itemHref}
noFeedback noFeedback
@@ -676,7 +675,7 @@ function ExpandedAuthorsList({
}, [heightInterp, visible]) }, [heightInterp, visible])
return ( return (
<Animated.View style={[heightStyle, styles.overflowHidden]}> <Animated.View style={[a.overflow_hidden, heightStyle]}>
{visible && {visible &&
authors.map(author => ( authors.map(author => (
<NewLink <NewLink
@@ -772,9 +771,6 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
overflowHidden: {
overflow: 'hidden',
},
pointer: isWeb pointer: isWeb
? { ? {
// @ts-ignore web only // @ts-ignore web only
+86 -23
View File
@@ -1,11 +1,14 @@
import React, {useEffect, useRef} from 'react' import React, {useRef} from 'react'
import {useWindowDimensions, View} from 'react-native' import {StyleSheet, useWindowDimensions, View} from 'react-native'
import {runOnJS} from 'react-native-reanimated' import {runOnJS} from 'react-native-reanimated'
import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyFeedDefs} from '@atproto/api' import {AppBskyFeedDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {clamp} from '#/lib/numbers'
import {ScrollProvider} from '#/lib/ScrollContext' import {ScrollProvider} from '#/lib/ScrollContext'
import {isAndroid, isNative, isWeb} from '#/platform/detection' import {isAndroid, isNative, isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -21,7 +24,9 @@ import {
} from '#/state/queries/post-thread' } from '#/state/queries/post-thread'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform'
import {useSetTitle} from 'lib/hooks/useSetTitle' import {useSetTitle} from 'lib/hooks/useSetTitle'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
@@ -30,9 +35,9 @@ import {CenteredView} from 'view/com/util/Views'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {ComposePrompt} from '../composer/Prompt'
import {List, ListMethods} from '../util/List' import {List, ListMethods} from '../util/List'
import {ViewHeader} from '../util/ViewHeader' import {ViewHeader} from '../util/ViewHeader'
import {PostThreadComposePrompt} from './PostThreadComposePrompt'
import {PostThreadItem} from './PostThreadItem' import {PostThreadItem} from './PostThreadItem'
import {PostThreadLoadMore} from './PostThreadLoadMore' import {PostThreadLoadMore} from './PostThreadLoadMore'
import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies' import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies'
@@ -80,15 +85,7 @@ const keyExtractor = (item: RowItem) => {
return item._reactKey return item._reactKey
} }
export function PostThread({ export function PostThread({uri}: {uri: string | undefined}) {
uri,
onCanReply,
onPressReply,
}: {
uri: string | undefined
onCanReply: (canReply: boolean) => void
onPressReply: () => unknown
}) {
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
@@ -163,12 +160,22 @@ export function PostThread({
return cache return cache
}, [thread, moderationOpts]) }, [thread, moderationOpts])
const [justPostedUris, setJustPostedUris] = React.useState(
() => new Set<string>(),
)
const skeleton = React.useMemo(() => { const skeleton = React.useMemo(() => {
const threadViewPrefs = preferences?.threadViewPrefs const threadViewPrefs = preferences?.threadViewPrefs
if (!threadViewPrefs || !thread) return null if (!threadViewPrefs || !thread) return null
return createThreadSkeleton( return createThreadSkeleton(
sortThread(thread, threadViewPrefs, threadModerationCache, currentDid), sortThread(
thread,
threadViewPrefs,
threadModerationCache,
currentDid,
justPostedUris,
),
!!currentDid, !!currentDid,
treeView, treeView,
threadModerationCache, threadModerationCache,
@@ -181,6 +188,7 @@ export function PostThread({
treeView, treeView,
threadModerationCache, threadModerationCache,
hiddenRepliesState, hiddenRepliesState,
justPostedUris,
]) ])
const error = React.useMemo(() => { const error = React.useMemo(() => {
@@ -210,14 +218,6 @@ export function PostThread({
return null return null
}, [thread, skeleton?.highlightedPost, isThreadError, _, threadError]) }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
useEffect(() => {
if (error) {
onCanReply(false)
} else if (rootPost) {
onCanReply(!rootPost.viewer?.replyDisabled)
}
}, [rootPost, onCanReply, error])
// construct content // construct content
const posts = React.useMemo(() => { const posts = React.useMemo(() => {
if (!skeleton) return [] if (!skeleton) return []
@@ -313,6 +313,38 @@ export function PostThread({
setMaxReplies(prev => prev + 50) setMaxReplies(prev => prev + 50)
}, [isFetching, maxReplies, posts.length]) }, [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 = const hasParents =
skeleton?.highlightedPost?.type === 'post' && skeleton?.highlightedPost?.type === 'post' &&
(skeleton.highlightedPost.ctx.isParentLoading || (skeleton.highlightedPost.ctx.isParentLoading ||
@@ -324,7 +356,9 @@ export function PostThread({
if (item === REPLY_PROMPT && hasSession) { if (item === REPLY_PROMPT && hasSession) {
return ( return (
<View> <View>
{!isMobile && <ComposePrompt onPressCompose={onPressReply} />} {!isMobile && (
<PostThreadComposePrompt onPressCompose={onPressReply} />
)}
</View> </View>
) )
} else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) { } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) {
@@ -406,7 +440,7 @@ export function PostThread({
HiddenRepliesState.ShowAndOverridePostHider && HiddenRepliesState.ShowAndOverridePostHider &&
item.ctx.depth > 0 item.ctx.depth > 0
} }
onPostReply={refetch} onPostReply={onPostReply}
hideTopBorder={index === 0 && !item.ctx.isParentLoading} hideTopBorder={index === 0 && !item.ctx.isParentLoading}
/> />
</View> </View>
@@ -473,10 +507,30 @@ export function PostThread({
sideBorders={false} sideBorders={false}
/> />
</ScrollProvider> </ScrollProvider>
{isMobile && canReply && hasSession && (
<MobileComposePrompt onPressReply={onPressReply} />
)}
</CenteredView> </CenteredView>
) )
} }
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
const safeAreaInsets = useSafeAreaInsets()
const fabMinimalShellTransform = useMinimalShellFabTransform()
return (
<Animated.View
style={[
styles.prompt,
fabMinimalShellTransform,
{
bottom: clamp(safeAreaInsets.bottom, 15, 30),
},
]}>
<PostThreadComposePrompt onPressCompose={onPressReply} />
</Animated.View>
)
}
function isThreadPost(v: unknown): v is ThreadPost { function isThreadPost(v: unknown): v is ThreadPost {
return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
} }
@@ -622,3 +676,12 @@ function hasBranchingReplies(node?: ThreadNode) {
} }
return true return true
} }
const styles = StyleSheet.create({
prompt: {
// @ts-ignore web-only
position: isWeb ? 'fixed' : 'absolute',
left: 0,
right: 0,
},
})
@@ -10,7 +10,11 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { export function PostThreadComposePrompt({
onPressCompose,
}: {
onPressCompose: () => void
}) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did}) const {data: profile} = useProfileQuery({did: currentAccount?.did})
const pal = usePalette('default') const pal = usePalette('default')
+3 -2
View File
@@ -75,7 +75,7 @@ export function PostThreadItem({
showParentReplyLine?: boolean showParentReplyLine?: boolean
hasPrecedingItem: boolean hasPrecedingItem: boolean
overrideBlur: boolean overrideBlur: boolean
onPostReply: () => void onPostReply: (postUri: string | undefined) => void
hideTopBorder?: boolean hideTopBorder?: boolean
}) { }) {
const postShadowed = usePostShadow(post) const postShadowed = usePostShadow(post)
@@ -169,7 +169,7 @@ let PostThreadItemLoaded = ({
showParentReplyLine?: boolean showParentReplyLine?: boolean
hasPrecedingItem: boolean hasPrecedingItem: boolean
overrideBlur: boolean overrideBlur: boolean
onPostReply: () => void onPostReply: (postUri: string | undefined) => void
hideTopBorder?: boolean hideTopBorder?: boolean
}): React.ReactNode => { }): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
@@ -742,6 +742,7 @@ const styles = StyleSheet.create({
flexWrap: 'wrap', flexWrap: 'wrap',
paddingBottom: 4, paddingBottom: 4,
paddingRight: 10, paddingRight: 10,
overflow: 'hidden',
}, },
postTextLargeContainer: { postTextLargeContainer: {
paddingHorizontal: 0, paddingHorizontal: 0,
+7 -6
View File
@@ -12,17 +12,17 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' 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 {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 {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {precacheProfile} from '#/state/queries/profile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer' 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 {AviFollowButton} from '#/view/com/posts/AviFollowButton'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {ProfileHoverCard} from '#/components/ProfileHoverCard'
@@ -280,6 +280,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
flexWrap: 'wrap', flexWrap: 'wrap',
overflow: 'hidden',
}, },
replyLine: { replyLine: {
position: 'absolute', position: 'absolute',
+1 -3
View File
@@ -7,7 +7,6 @@ import {useNavigation} from '@react-navigation/native'
import {createHitslop} from '#/lib/constants' import {createHitslop} from '#/lib/constants'
import {NavigationProp} from '#/lib/routes/types' import {NavigationProp} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@@ -37,7 +36,6 @@ export function AviFollowButton({
profile: profile, profile: profile,
logContext: 'AvatarButton', logContext: 'AvatarButton',
}) })
const gate = useGate()
const {currentAccount, hasSession} = useSession() const {currentAccount, hasSession} = useSession()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
@@ -80,7 +78,7 @@ export function AviFollowButton({
}, },
] ]
return hasSession && gate('show_avi_follow_button') ? ( return hasSession ? (
<View style={a.relative}> <View style={a.relative}>
{children} {children}
+1 -2
View File
@@ -349,8 +349,7 @@ let Feed = ({
const shouldShow = const shouldShow =
(interstitial.type === feedInterstitialType && (interstitial.type === feedInterstitialType &&
gate('suggested_feeds_interstitial')) || gate('suggested_feeds_interstitial')) ||
(interstitial.type === followInterstitialType && interstitial.type === followInterstitialType ||
gate('suggested_follows_interstitial')) ||
interstitial.type === progressGuideInterstitialType interstitial.type === progressGuideInterstitialType
if (shouldShow) { if (shouldShow) {
+1
View File
@@ -544,6 +544,7 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
flexWrap: 'wrap', flexWrap: 'wrap',
paddingBottom: 2, paddingBottom: 2,
overflow: 'hidden',
}, },
contentHiderChild: { contentHiderChild: {
marginTop: 6, marginTop: 6,
@@ -1,32 +1,60 @@
import React from 'react' import React from 'react'
import {Pressable, ScrollView, StyleSheet, View} from 'react-native' import {ScrollView, View} from 'react-native'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' 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 {isWeb} from 'platform/detection'
import {Button} from 'view/com/util/forms/Button' import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
import {Link} from 'view/com/util/Link' import {Button, ButtonIcon} from '#/components/Button'
import {Text} from 'view/com/util/text/Text' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar' import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '../util/Toast' import {Text} from '#/components/Typography'
const OUTER_PADDING = 10 const OUTER_PADDING = a.p_md.padding
const INNER_PADDING = 14 const INNER_PADDING = a.p_lg.padding
const TOTAL_HEIGHT = 250 const TOTAL_HEIGHT = 232
const MOBILE_CARD_WIDTH = 300
function CardOuter({
children,
style,
}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
const t = useTheme()
return (
<View
style={[
a.w_full,
a.p_lg,
a.rounded_md,
a.border,
t.atoms.bg,
t.atoms.border_contrast_low,
{
width: MOBILE_CARD_WIDTH,
},
style,
]}>
{children}
</View>
)
}
export function SuggestedFollowPlaceholder() {
const t = useTheme()
return (
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
<ProfileCard.Header>
<ProfileCard.AvatarPlaceholder />
<ProfileCard.NameAndHandlePlaceholder />
</ProfileCard.Header>
<ProfileCard.DescriptionPlaceholder />
</CardOuter>
)
}
export function ProfileHeaderSuggestedFollows({ export function ProfileHeaderSuggestedFollows({
actorDid, actorDid,
@@ -35,47 +63,55 @@ export function ProfileHeaderSuggestedFollows({
actorDid: string actorDid: string
requestDismiss: () => void requestDismiss: () => void
}) { }) {
const pal = usePalette('default') const t = useTheme()
const {isLoading, data} = useSuggestedFollowsByActorQuery({ const {_} = useLingui()
const {isLoading: isSuggestionsLoading, data} =
useSuggestedFollowsByActorQuery({
did: actorDid, did: actorDid,
}) })
const moderationOpts = useModerationOpts()
const isLoading = isSuggestionsLoading || !moderationOpts
return ( return (
<View <View
style={{paddingVertical: OUTER_PADDING, height: TOTAL_HEIGHT}} style={{paddingVertical: OUTER_PADDING, height: TOTAL_HEIGHT}}
pointerEvents="box-none"> pointerEvents="box-none">
<View <View
pointerEvents="box-none" pointerEvents="box-none"
style={{ style={[
backgroundColor: pal.viewLight.backgroundColor, t.atoms.bg_contrast_25,
{
height: '100%', height: '100%',
paddingTop: INNER_PADDING / 2, paddingTop: INNER_PADDING / 2,
}}> },
]}>
<View <View
pointerEvents="box-none" pointerEvents="box-none"
style={{ style={[
flexDirection: 'row', a.flex_row,
justifyContent: 'space-between', a.justify_between,
alignItems: 'center', a.align_center,
paddingTop: 4, a.pt_xs,
{
paddingBottom: INNER_PADDING / 2, paddingBottom: INNER_PADDING / 2,
paddingLeft: INNER_PADDING, paddingLeft: INNER_PADDING,
paddingRight: INNER_PADDING / 2, paddingRight: INNER_PADDING / 2,
}}> },
<Text type="sm-bold" style={[pal.textLight]}> ]}>
<Trans>Suggested for you</Trans> <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
<Trans>Similar accounts</Trans>
</Text> </Text>
<Pressable <Button
accessibilityRole="button"
onPress={requestDismiss} onPress={requestDismiss}
hitSlop={10} hitSlop={10}
style={{padding: INNER_PADDING / 2}}> label={_(msg`Dismiss`)}
<FontAwesomeIcon size="xsmall"
icon="x" variant="ghost"
size={12} color="secondary"
style={pal.textLight as FontAwesomeIconStyle} shape="round">
/> <ButtonIcon icon={X} size="sm" />
</Pressable> </Button>
</View> </View>
<ScrollView <ScrollView
@@ -83,187 +119,72 @@ export function ProfileHeaderSuggestedFollows({
showsHorizontalScrollIndicator={isWeb} showsHorizontalScrollIndicator={isWeb}
persistentScrollbar={true} persistentScrollbar={true}
scrollIndicatorInsets={{bottom: 0}} scrollIndicatorInsets={{bottom: 0}}
scrollEnabled={true} snapToInterval={MOBILE_CARD_WIDTH + a.gap_sm.gap}
contentContainerStyle={{ decelerationRate="fast">
alignItems: 'flex-start', <View
paddingLeft: INNER_PADDING / 2, style={[
a.flex_row,
a.gap_sm,
{
paddingHorizontal: INNER_PADDING,
paddingBottom: INNER_PADDING, paddingBottom: INNER_PADDING,
}}> },
]}>
{isLoading ? ( {isLoading ? (
<> <>
<SuggestedFollowSkeleton /> <SuggestedFollowPlaceholder />
<SuggestedFollowSkeleton /> <SuggestedFollowPlaceholder />
<SuggestedFollowSkeleton /> <SuggestedFollowPlaceholder />
<SuggestedFollowSkeleton /> <SuggestedFollowPlaceholder />
<SuggestedFollowSkeleton /> <SuggestedFollowPlaceholder />
<SuggestedFollowSkeleton />
</> </>
) : data ? ( ) : data ? (
data.suggestions data.suggestions
.filter(s => (s.associated?.labeler ? false : true)) .filter(s => (s.associated?.labeler ? false : true))
.map(profile => ( .map(profile => (
<SuggestedFollow key={profile.did} profile={profile} /> <ProfileCard.Link
key={profile.did}
profile={profile}
onPress={() => {
logEvent('profile:header:suggestedFollowsCard:press', {})
}}
style={[a.flex_1]}>
{({hovered, pressed}) => (
<CardOuter
style={[
a.flex_1,
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext="ProfileHeaderSuggestedFollows"
color="secondary_inverted"
shape="round"
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} />
</ProfileCard.Outer>
</CardOuter>
)}
</ProfileCard.Link>
)) ))
) : ( ) : (
<View /> <View />
)} )}
</View>
</ScrollView> </ScrollView>
</View> </View>
</View> </View>
) )
} }
function SuggestedFollowSkeleton() {
const pal = usePalette('default')
return (
<View
style={[
styles.suggestedFollowCardOuter,
{
backgroundColor: pal.view.backgroundColor,
},
]}>
<View
style={{
height: 60,
width: 60,
borderRadius: 60,
backgroundColor: pal.viewLight.backgroundColor,
opacity: 0.6,
}}
/>
<View
style={{
height: 17,
width: 70,
borderRadius: 4,
backgroundColor: pal.viewLight.backgroundColor,
marginTop: 12,
marginBottom: 4,
}}
/>
<View
style={{
height: 12,
width: 70,
borderRadius: 4,
backgroundColor: pal.viewLight.backgroundColor,
marginBottom: 12,
opacity: 0.6,
}}
/>
<View
style={{
height: 32,
borderRadius: 32,
width: '100%',
backgroundColor: pal.viewLight.backgroundColor,
}}
/>
</View>
)
}
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 (
<Link
href={makeProfileLink(profile)}
title={profile.handle}
asAnchor
anchorNoUnderline>
<View
style={[
styles.suggestedFollowCardOuter,
{
backgroundColor: pal.view.backgroundColor,
},
]}>
<PreviewableUserAvatar
size={60}
profile={profile}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
<View style={{width: '100%', paddingVertical: 12}}>
<Text
type="xs-medium"
style={[pal.text, {textAlign: 'center'}]}
numberOfLines={1}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
</Text>
<Text
type="xs-medium"
style={[pal.textLight, {textAlign: 'center'}]}
numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
<Button
label={following ? _(msg`Unfollow`) : _(msg`Follow`)}
type="inverted"
labelStyle={{textAlign: 'center'}}
onPress={following ? onPressUnfollow : onPressFollow}
/>
</View>
</Link>
)
}
const styles = StyleSheet.create({
suggestedFollowCardOuter: {
marginHorizontal: INNER_PADDING / 2,
paddingTop: 10,
paddingBottom: 12,
paddingHorizontal: 10,
borderRadius: 8,
width: 130,
alignItems: 'center',
overflow: 'hidden',
flexShrink: 1,
},
})
+2 -10
View File
@@ -8,7 +8,6 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import { import {
@@ -179,7 +178,6 @@ let UserAvatar = ({
const pal = usePalette('default') const pal = usePalette('default')
const backgroundColor = pal.colors.backgroundLight const backgroundColor = pal.colors.backgroundLight
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
const gate = useGate()
const aviStyle = useMemo(() => { const aviStyle = useMemo(() => {
if (finalShape === 'square') { if (finalShape === 'square') {
@@ -223,10 +221,7 @@ let UserAvatar = ({
style={aviStyle} style={aviStyle}
resizeMode="cover" resizeMode="cover"
source={{ source={{
uri: hackModifyThumbnailPath( uri: hackModifyThumbnailPath(avatar, size < 90),
avatar,
size < 90 && gate('small_avi_thumb'),
),
}} }}
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
/> />
@@ -236,10 +231,7 @@ let UserAvatar = ({
style={aviStyle} style={aviStyle}
contentFit="cover" contentFit="cover"
source={{ source={{
uri: hackModifyThumbnailPath( uri: hackModifyThumbnailPath(avatar, size < 90),
avatar,
size < 90 && gate('small_avi_thumb'),
),
}} }}
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
/> />
+3 -71
View File
@@ -1,39 +1,19 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {View} from 'react-native'
import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {clamp} from 'lodash'
import {isWeb} from '#/platform/detection'
import {
RQKEY as POST_THREAD_RQKEY,
ThreadNode,
} from '#/state/queries/post-thread'
import {useSession} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
import {useComposerControls} from '#/state/shell/composer'
import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {makeRecordUri} from 'lib/strings/url-helpers' import {makeRecordUri} from 'lib/strings/url-helpers'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {ComposePrompt} from 'view/com/composer/Prompt'
import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread' import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
export function PostThreadScreen({route}: Props) { export function PostThreadScreen({route}: Props) {
const queryClient = useQueryClient()
const {hasSession} = useSession()
const fabMinimalShellTransform = useMinimalShellFabTransform()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const {openComposer} = useComposerControls()
const safeAreaInsets = useSafeAreaInsets()
const {name, rkey} = route.params const {name, rkey} = route.params
const {isMobile} = useWebMediaQueries()
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
const [canReply, setCanReply] = React.useState(false)
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
@@ -41,59 +21,11 @@ export function PostThreadScreen({route}: Props) {
}, [setMinimalShellMode]), }, [setMinimalShellMode]),
) )
const onPressReply = React.useCallback(() => {
if (!uri) {
return
}
const thread = queryClient.getQueryData<ThreadNode>(POST_THREAD_RQKEY(uri))
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: () =>
queryClient.invalidateQueries({
queryKey: POST_THREAD_RQKEY(uri),
}),
})
}, [openComposer, queryClient, uri])
return ( return (
<View style={s.hContentRegion}> <View style={s.hContentRegion}>
<View style={s.flex1}> <View style={s.flex1}>
<PostThreadComponent <PostThreadComponent uri={uri} />
uri={uri}
onPressReply={onPressReply}
onCanReply={setCanReply}
/>
</View> </View>
{isMobile && canReply && hasSession && (
<Animated.View
style={[
styles.prompt,
fabMinimalShellTransform,
{
bottom: clamp(safeAreaInsets.bottom, 15, 30),
},
]}>
<ComposePrompt onPressCompose={onPressReply} />
</Animated.View>
)}
</View> </View>
) )
} }
const styles = StyleSheet.create({
prompt: {
// @ts-ignore web-only
position: isWeb ? 'fixed' : 'absolute',
left: 0,
right: 0,
},
})
+2 -6
View File
@@ -10,7 +10,6 @@ import {
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -293,7 +292,6 @@ export function Explore() {
error: feedsError, error: feedsError,
fetchNextPage: fetchNextFeedsPage, fetchNextPage: fetchNextFeedsPage,
} = useGetPopularFeedsQuery({limit: 10}) } = useGetPopularFeedsQuery({limit: 10})
const gate = useGate()
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
const onLoadMoreProfiles = React.useCallback(async () => { const onLoadMoreProfiles = React.useCallback(async () => {
@@ -499,9 +497,7 @@ export function Explore() {
profile={item.profile} profile={item.profile}
noBg noBg
noBorder noBorder
showKnownFollowers={gate( showKnownFollowers
'explore_page_profile_card_social_proof',
)}
/> />
</View> </View>
) )
@@ -565,7 +561,7 @@ export function Explore() {
} }
} }
}, },
[t, moderationOpts, gate], [t, moderationOpts],
) )
return ( return (
@@ -29,7 +29,6 @@ import {
useLoggedOutView, useLoggedOutView,
useLoggedOutViewControls, useLoggedOutViewControls,
} from '#/state/shell/logged-out' } from '#/state/shell/logged-out'
import {useGate} from 'lib/statsig/statsig'
import {isNative, isWeb} from 'platform/detection' import {isNative, isWeb} from 'platform/detection'
import {Deactivated} from '#/screens/Deactivated' import {Deactivated} from '#/screens/Deactivated'
import {Onboarding} from '#/screens/Onboarding' import {Onboarding} from '#/screens/Onboarding'
@@ -51,7 +50,6 @@ function NativeStackNavigator({
screenOptions, screenOptions,
...rest ...rest
}: NativeStackNavigatorProps) { }: NativeStackNavigatorProps) {
const gate = useGate()
// --- this is copy and pasted from the original native stack navigator --- // --- this is copy and pasted from the original native stack navigator ---
const {state, descriptors, navigation, NavigationContent} = const {state, descriptors, navigation, NavigationContent} =
useNavigationBuilder< useNavigationBuilder<
@@ -102,12 +100,7 @@ function NativeStackNavigator({
const {showLoggedOut} = useLoggedOutView() const {showLoggedOut} = useLoggedOutView()
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const {isMobile, isTabletOrMobile} = useWebMediaQueries() const {isMobile, isTabletOrMobile} = useWebMediaQueries()
if ( if (!hasSession && (!PWI_ENABLED || activeRouteRequiresAuth || isNative)) {
!hasSession &&
(!PWI_ENABLED ||
activeRouteRequiresAuth ||
(isNative && gate('native_pwi_disabled')))
) {
return <LoggedOut /> return <LoggedOut />
} }
if (hasSession && currentAccount?.signupQueued) { if (hasSession && currentAccount?.signupQueued) {