Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecf275b7f4 | |||
| 42c8a07551 | |||
| a6d2921fa6 | |||
| c66529a6ef | |||
| 98658a313f | |||
| a26c08f95e | |||
| 0521488d05 | |||
| abc9b48eef | |||
| b5e57c8768 | |||
| df501d870c | |||
| aa1d1c23a5 | |||
| 6a8b670ae3 | |||
| e02ea9ee26 | |||
| 9351281fd9 | |||
| 699ddc8de0 | |||
| a255de4775 | |||
| bb53548541 | |||
| 9075b123d5 | |||
| d976684ddf | |||
| 6f35e50dbf | |||
| 38ce57acf1 | |||
| 11cc7c7d96 | |||
| b81632ef5e | |||
| 16cc338c86 | |||
| 620f958f72 | |||
| f6ec6ed207 | |||
| e3a4fcc152 |
@@ -0,0 +1,156 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ScrollView} from 'react-native-gesture-handler'
|
||||
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
|
||||
import {isJustAMute} from 'lib/moderation'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {useFollowMethods} from '#/components/hooks/useFollowMethods'
|
||||
import {useRichText} from '#/components/hooks/useRichText'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function SuggestedFollowCard({
|
||||
profile: profileUnshadowed,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {follow, unfollow} = useFollowMethods({
|
||||
profile,
|
||||
// @ts-ignore TODO
|
||||
logContext: 'FeedSuggestedFollowsCard',
|
||||
})
|
||||
// @ts-ignore TODO
|
||||
const [descriptionRT] = useRichText(profileUnshadowed?.description ?? '')
|
||||
|
||||
if (!moderationOpts) return null
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const modui = moderation.ui('profileList')
|
||||
if (modui.filter && !isJustAMute(modui)) return null
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.p_lg,
|
||||
a.rounded_md,
|
||||
a.gap_sm,
|
||||
t.atoms.bg,
|
||||
{
|
||||
width: 300,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<PreviewableUserAvatar
|
||||
size={40}
|
||||
did={profile.did}
|
||||
handle={profile.handle}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_lg,
|
||||
a.flex_1,
|
||||
]}>
|
||||
<View style={[a.gap_2xs, a.flex_1]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.leading_tight, a.flex_1]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_medium, a.flex_1]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeHandle(profile.handle, '@')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
label={
|
||||
profile.viewer?.following ? _(msg`Following`) : _(msg`Follow`)
|
||||
}
|
||||
size="small"
|
||||
shape="round"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={profile.viewer?.following ? unfollow : follow}>
|
||||
{profile.viewer?.following ? (
|
||||
<ButtonIcon icon={Check} />
|
||||
) : (
|
||||
<ButtonIcon icon={Plus} />
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[a.flex_1]} numberOfLines={2}>
|
||||
<RichText value={descriptionRT} style={[t.atoms.text_contrast_high]} />
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function FeedSuggestedFollows() {
|
||||
const t = useTheme()
|
||||
const {isLoading, data: suggestions, error} = useSuggestedFollowsQuery()
|
||||
|
||||
const profiles: AppBskyActorDefs.ProfileViewBasic[] = []
|
||||
if (suggestions) {
|
||||
// Currently the responses contain duplicate items.
|
||||
// Needs to be fixed on backend, but let's dedupe to be safe.
|
||||
let seen = new Set()
|
||||
for (const page of suggestions.pages) {
|
||||
for (const actor of page.actors) {
|
||||
if (!seen.has(actor.did)) {
|
||||
seen.add(actor.did)
|
||||
profiles.push(actor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
|
||||
<View style={[a.pt_xl, a.px_lg, a.flex_row, a.gap_md]}>
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
Suggested for you
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
|
||||
{isLoading
|
||||
? null
|
||||
: error || !profiles.length
|
||||
? null
|
||||
: profiles.map((profile, i) => (
|
||||
<SuggestedFollowCard key={i} profile={profile} />
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {DialogOuterProps} from '#/components/Dialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
|
||||
export function PrimaryAlgoNoticeDialog({
|
||||
control,
|
||||
}: {
|
||||
control: DialogOuterProps['control']
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<Prompt.Outer control={control}>
|
||||
<Prompt.TitleText>Your primary algorithm</Prompt.TitleText>
|
||||
<Prompt.DescriptionText>
|
||||
This feed is set as your primary algorithm, which is used as your home
|
||||
screen when you open the app.
|
||||
</Prompt.DescriptionText>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Cancel cta={_(msg`Close`)} />
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Home_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M11.46 1.362a2 2 0 0 1 1.08 0c.249.07.448.188.611.301.146.102.306.232.467.363l6.421 5.218.046.036c.169.137.38.308.54.53a2 2 0 0 1 .304.64c.073.264.072.536.071.753v9.229c0 .252 0 .498-.017.706a2.023 2.023 0 0 1-.201.77 2 2 0 0 1-.874.874 2.02 2.02 0 0 1-.77.201c-.208.017-.454.017-.706.017H5.568c-.252 0-.498 0-.706-.017a2.02 2.02 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C3 18.93 3 18.684 3 18.432V9.203c0-.217-.002-.49.07-.754a2 2 0 0 1 .304-.638c.16-.223.372-.394.541-.53l.045-.037 6.422-5.218c.161-.13.321-.26.467-.362.163-.114.362-.232.612-.302Zm.532 1.943c-.077.054-.18.136-.37.29l-6.4 5.2a6.315 6.315 0 0 0-.215.18c-.002 0-.003.002-.004.003v.004C5 9.036 5 9.112 5 9.262V18.4a8.18 8.18 0 0 0 .011.588l.014.002c.116.01.278.01.575.01H8v-5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v5h2.4a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V9.262c0-.15 0-.226-.003-.28v-.004l-.003-.003a6.448 6.448 0 0 0-.216-.18l-6.4-5.2a7.373 7.373 0 0 0-.37-.29L12 3.299l-.008.006ZM14 19v-5h-4v5h4Z',
|
||||
})
|
||||
|
||||
export const Home_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M13.261 1.736a2 2 0 0 0-2.522 0l-7 5.687A2 2 0 0 0 3 8.976V19a2 2 0 0 0 2 2h3v-8a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v8h3a2 2 0 0 0 2-2V8.976a2 2 0 0 0-.739-1.553l-7-5.687ZM14 21h-4v-7h4v7Z',
|
||||
})
|
||||
@@ -89,3 +89,6 @@ export const BSKY_FEED_OWNER_DIDS = [
|
||||
'did:plc:vpkhqolt662uhesyj6nxm7ys',
|
||||
'did:plc:q6gjnaw2blty4crticxkmujt',
|
||||
]
|
||||
|
||||
export const DISCOVER_FEED_URI =
|
||||
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'
|
||||
|
||||
@@ -8,5 +8,5 @@ export type Gate =
|
||||
| 'new_search'
|
||||
| 'receive_updates'
|
||||
| 'show_follow_back_label'
|
||||
| 'start_session_with_following'
|
||||
| 'use_new_suggestions_endpoint'
|
||||
| 'reduced_onboarding_and_home_algo'
|
||||
|
||||
+62
-17
@@ -13,6 +13,8 @@ import {
|
||||
useQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {DISCOVER_FEED_URI} from '#/lib/constants'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {STALE} from '#/state/queries'
|
||||
@@ -21,6 +23,7 @@ import {getAgent, useSession} from '#/state/session'
|
||||
import {router} from '#/routes'
|
||||
|
||||
export type FeedSourceFeedInfo = {
|
||||
isPrimaryAlgorithm: boolean
|
||||
type: 'feed'
|
||||
uri: string
|
||||
route: {
|
||||
@@ -39,6 +42,7 @@ export type FeedSourceFeedInfo = {
|
||||
}
|
||||
|
||||
export type FeedSourceListInfo = {
|
||||
isPrimaryAlgorithm: boolean
|
||||
type: 'list'
|
||||
uri: string
|
||||
route: {
|
||||
@@ -69,6 +73,7 @@ const feedSourceNSIDs = {
|
||||
|
||||
export function hydrateFeedGenerator(
|
||||
view: AppBskyFeedDefs.GeneratorView,
|
||||
options?: Pick<FeedSourceFeedInfo, 'isPrimaryAlgorithm'>,
|
||||
): FeedSourceInfo {
|
||||
const urip = new AtUri(view.uri)
|
||||
const collection =
|
||||
@@ -77,6 +82,7 @@ export function hydrateFeedGenerator(
|
||||
const route = router.matchPath(href)
|
||||
|
||||
return {
|
||||
isPrimaryAlgorithm: options?.isPrimaryAlgorithm ?? false,
|
||||
type: 'feed',
|
||||
uri: view.uri,
|
||||
cid: view.cid,
|
||||
@@ -108,6 +114,7 @@ export function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo {
|
||||
const route = router.matchPath(href)
|
||||
|
||||
return {
|
||||
isPrimaryAlgorithm: false,
|
||||
type: 'list',
|
||||
uri: view.uri,
|
||||
route: {
|
||||
@@ -199,10 +206,14 @@ export function useSearchPopularFeedsMutation() {
|
||||
})
|
||||
}
|
||||
|
||||
const FOLLOWING_FEED_STUB: FeedSourceInfo = {
|
||||
/**
|
||||
* The following feed, with fallbacks to Discover
|
||||
*/
|
||||
const HOME_FEED_STUB: FeedSourceInfo = {
|
||||
isPrimaryAlgorithm: false,
|
||||
type: 'feed',
|
||||
displayName: 'Following',
|
||||
uri: '',
|
||||
uri: 'home',
|
||||
route: {
|
||||
href: '/',
|
||||
name: 'Home',
|
||||
@@ -216,10 +227,11 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = {
|
||||
likeCount: 0,
|
||||
likeUri: '',
|
||||
}
|
||||
const DISCOVER_FEED_STUB: FeedSourceInfo = {
|
||||
const PWI_DISCOVER_FEED_STUB: FeedSourceInfo = {
|
||||
isPrimaryAlgorithm: true,
|
||||
type: 'feed',
|
||||
displayName: 'Discover',
|
||||
uri: '',
|
||||
uri: DISCOVER_FEED_URI,
|
||||
route: {
|
||||
href: '/',
|
||||
name: 'Home',
|
||||
@@ -239,22 +251,38 @@ const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos'
|
||||
export function usePinnedFeedsInfos() {
|
||||
const {hasSession} = useSession()
|
||||
const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery()
|
||||
const gate = useGate()
|
||||
const isPrimaryAlgoExperimentEnabled = gate(
|
||||
'reduced_onboarding_and_home_algo',
|
||||
)
|
||||
const primaryAlgo = preferences?.primaryAlgorithm
|
||||
const pinnedUris = preferences?.feeds?.pinned ?? []
|
||||
const feedUris = pinnedUris.filter(uri => getFeedTypeFromUri(uri) === 'feed')
|
||||
const listUris = pinnedUris.filter(uri => getFeedTypeFromUri(uri) === 'list')
|
||||
|
||||
if (
|
||||
isPrimaryAlgoExperimentEnabled &&
|
||||
hasSession &&
|
||||
primaryAlgo?.enabled &&
|
||||
primaryAlgo?.uri
|
||||
) {
|
||||
feedUris.unshift(primaryAlgo.uri)
|
||||
}
|
||||
|
||||
// used for query key
|
||||
const allUris = feedUris.concat(listUris)
|
||||
|
||||
return useQuery({
|
||||
staleTime: STALE.INFINITY,
|
||||
enabled: !isLoadingPrefs,
|
||||
queryKey: [
|
||||
pinnedFeedInfosQueryKeyRoot,
|
||||
(hasSession ? 'authed:' : 'unauthed:') + pinnedUris.join(','),
|
||||
(hasSession ? 'authed:' : 'unauthed:') + allUris.join(','),
|
||||
],
|
||||
queryFn: async () => {
|
||||
let resolved = new Map()
|
||||
let resolved = new Map<string, FeedSourceInfo>()
|
||||
|
||||
// Get all feeds. We can do this in a batch.
|
||||
const feedUris = pinnedUris.filter(
|
||||
uri => getFeedTypeFromUri(uri) === 'feed',
|
||||
)
|
||||
let feedsPromise = Promise.resolve()
|
||||
if (feedUris.length > 0) {
|
||||
feedsPromise = getAgent()
|
||||
@@ -269,9 +297,6 @@ export function usePinnedFeedsInfos() {
|
||||
}
|
||||
|
||||
// Get all lists. This currently has to be done individually.
|
||||
const listUris = pinnedUris.filter(
|
||||
uri => getFeedTypeFromUri(uri) === 'list',
|
||||
)
|
||||
const listsPromises = listUris.map(listUri =>
|
||||
getAgent()
|
||||
.app.bsky.graph.getList({
|
||||
@@ -284,14 +309,34 @@ export function usePinnedFeedsInfos() {
|
||||
}),
|
||||
)
|
||||
|
||||
// The returned result will have the original order.
|
||||
const result = [hasSession ? FOLLOWING_FEED_STUB : DISCOVER_FEED_STUB]
|
||||
const result = [hasSession ? HOME_FEED_STUB : PWI_DISCOVER_FEED_STUB]
|
||||
|
||||
await Promise.allSettled([feedsPromise, ...listsPromises])
|
||||
for (let pinnedUri of pinnedUris) {
|
||||
if (resolved.has(pinnedUri)) {
|
||||
result.push(resolved.get(pinnedUri))
|
||||
|
||||
// if primary algo is enabled and was fetched, add it to the front of the list
|
||||
if (primaryAlgo?.enabled && primaryAlgo?.uri) {
|
||||
const feedInfo = resolved.get(primaryAlgo.uri)
|
||||
if (feedInfo) {
|
||||
feedInfo.isPrimaryAlgorithm = true
|
||||
result.unshift(feedInfo)
|
||||
}
|
||||
}
|
||||
|
||||
const pinnedUrisSansPrimary = pinnedUris.filter(uri => {
|
||||
if (primaryAlgo?.enabled) {
|
||||
return uri !== primaryAlgo?.uri
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// order the feeds/lists in the order they were pinned
|
||||
for (let pinnedUri of pinnedUrisSansPrimary) {
|
||||
const feedInfo = resolved.get(pinnedUri)
|
||||
if (feedInfo) {
|
||||
result.push(feedInfo)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
@@ -43,6 +43,11 @@ type AuthorFilter =
|
||||
| 'posts_with_media'
|
||||
type FeedUri = string
|
||||
type ListUri = string
|
||||
/**
|
||||
* Represents a "not found" state, that results in the left-most tab being
|
||||
* selected on the Home screen.
|
||||
*/
|
||||
export const DEFAULT_FEED_DESCRIPTOR = '__default__'
|
||||
export type FeedDescriptor =
|
||||
| 'home'
|
||||
| 'following'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
UsePreferencesQueryResponse,
|
||||
ThreadViewPreferences,
|
||||
} from '#/state/queries/preferences/types'
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
|
||||
import {
|
||||
ThreadViewPreferences,
|
||||
UsePreferencesQueryResponse,
|
||||
} from '#/state/queries/preferences/types'
|
||||
|
||||
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
|
||||
{
|
||||
@@ -45,4 +45,5 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
|
||||
threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
|
||||
userAge: 13, // TODO(pwi)
|
||||
interests: {tags: []},
|
||||
primaryAlgorithm: {enabled: undefined},
|
||||
}
|
||||
|
||||
@@ -345,3 +345,17 @@ export function useRemoveMutedWordMutation() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetPrimaryAlgorithmMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (pref: AppBskyActorDefs.PrimaryAlgoPref) => {
|
||||
await getAgent().setPrimaryAlgorithm(pref)
|
||||
// triggers a refetch
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: preferencesQueryKey,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {Gate} from '#/lib/statsig/gates'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {DEFAULT_FEED_DESCRIPTOR} from '#/state/queries/post-feed'
|
||||
|
||||
type StateContext = string
|
||||
type SetContext = (v: string) => void
|
||||
@@ -12,29 +13,43 @@ const stateContext = React.createContext<StateContext>('home')
|
||||
const setContext = React.createContext<SetContext>((_: string) => {})
|
||||
|
||||
function getInitialFeed(gate: (gateName: Gate) => boolean) {
|
||||
const isPrimaryAlgoExperimentEnabled = gate(
|
||||
'reduced_onboarding_and_home_algo',
|
||||
)
|
||||
let feed = DEFAULT_FEED_DESCRIPTOR
|
||||
|
||||
if (isWeb) {
|
||||
if (window.location.pathname === '/') {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const feedFromUrl = params.get('feed')
|
||||
if (feedFromUrl) {
|
||||
// If explicitly booted from a link like /?feed=..., prefer that.
|
||||
// basically a link to a specific tab, use that every time if present
|
||||
return feedFromUrl
|
||||
}
|
||||
}
|
||||
const feedFromSession = sessionStorage.getItem('lastSelectedHomeFeed')
|
||||
if (feedFromSession) {
|
||||
// Fall back to a previously chosen feed for this browser tab.
|
||||
return feedFromSession
|
||||
feed = feedFromSession
|
||||
}
|
||||
}
|
||||
if (!gate('start_session_with_following')) {
|
||||
const feedFromPersisted = persisted.get('lastSelectedHomeFeed')
|
||||
if (feedFromPersisted) {
|
||||
// Fall back to the last chosen one across all tabs.
|
||||
return feedFromPersisted
|
||||
}
|
||||
|
||||
const feedFromPersisted = persisted.get('lastSelectedHomeFeed')
|
||||
if (feedFromPersisted) {
|
||||
// Fall back to the last chosen one across all tabs.
|
||||
feed = feedFromPersisted
|
||||
}
|
||||
return 'home'
|
||||
|
||||
if (isPrimaryAlgoExperimentEnabled) {
|
||||
// following feed
|
||||
if (feed === 'home') {
|
||||
return 'home'
|
||||
}
|
||||
// or left-most tab
|
||||
return DEFAULT_FEED_DESCRIPTOR
|
||||
}
|
||||
|
||||
return DEFAULT_FEED_DESCRIPTOR
|
||||
}
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
import React from 'react'
|
||||
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {s} from 'lib/styles'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {pluralize} from 'lib/strings/helpers'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
|
||||
import {
|
||||
usePinFeedMutation,
|
||||
UsePreferencesQueryResponse,
|
||||
usePreferencesQuery,
|
||||
useSaveFeedMutation,
|
||||
UsePreferencesQueryResponse,
|
||||
useRemoveFeedMutation,
|
||||
useSaveFeedMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
|
||||
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {useTheme} from '#/alf'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {pluralize} from 'lib/strings/helpers'
|
||||
import {s} from 'lib/styles'
|
||||
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {PrimaryAlgoNoticeDialog} from '#/components/PrimaryAlgoNoticeDialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
|
||||
export function FeedSourceCard({
|
||||
feedUri,
|
||||
@@ -87,6 +93,8 @@ export function FeedSourceCardLoaded({
|
||||
const {_} = useLingui()
|
||||
const removePromptControl = Prompt.usePromptControl()
|
||||
const navigation = useNavigationDeduped()
|
||||
const gate = useGate()
|
||||
const primaryAlgoDialogControl = Prompt.usePromptControl()
|
||||
|
||||
const {isPending: isSavePending, mutateAsync: saveFeed} =
|
||||
useSaveFeedMutation()
|
||||
@@ -185,6 +193,10 @@ export function FeedSourceCardLoaded({
|
||||
</View>
|
||||
)
|
||||
|
||||
const primaryAlgo = preferences?.primaryAlgorithm
|
||||
const isPrimaryAlgo =
|
||||
primaryAlgo?.enabled && primaryAlgo?.uri && primaryAlgo.uri === feed.uri
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
@@ -205,7 +217,7 @@ export function FeedSourceCardLoaded({
|
||||
}
|
||||
}}
|
||||
key={feed.uri}>
|
||||
<View style={[styles.headerContainer]}>
|
||||
<View style={[styles.headerContainer, a.align_start]}>
|
||||
<View style={[s.mr10]}>
|
||||
<UserAvatar type="algo" size={36} avatar={feed.avatar} />
|
||||
</View>
|
||||
@@ -223,35 +235,55 @@ export function FeedSourceCardLoaded({
|
||||
</View>
|
||||
|
||||
{showSaveBtn && feed.type === 'feed' && (
|
||||
<View style={[s.justifyCenter]}>
|
||||
<Pressable
|
||||
testID={`feed-${feed.displayName}-toggleSave`}
|
||||
disabled={isSavePending || isPinPending || isRemovePending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
isSaved
|
||||
? _(msg`Remove from my feeds`)
|
||||
: _(msg`Add to my feeds`)
|
||||
}
|
||||
accessibilityHint=""
|
||||
onPress={onToggleSaved}
|
||||
hitSlop={15}
|
||||
style={styles.btn}>
|
||||
{isSaved ? (
|
||||
<FontAwesomeIcon
|
||||
icon={['far', 'trash-can']}
|
||||
size={19}
|
||||
color={pal.colors.icon}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon="plus"
|
||||
size={18}
|
||||
color={pal.colors.link}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
<>
|
||||
{gate('reduced_onboarding_and_home_algo') && isPrimaryAlgo ? (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
label={_(
|
||||
msg`This feed is already set as your primary algorithm.`,
|
||||
)}
|
||||
onPress={() => {
|
||||
primaryAlgoDialogControl.open()
|
||||
}}>
|
||||
<ButtonIcon icon={Check} position="left" />
|
||||
<ButtonText>
|
||||
<Trans>Primary Algorithm</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
) : (
|
||||
<View style={[s.justifyCenter]}>
|
||||
<Pressable
|
||||
testID={`feed-${feed.displayName}-toggleSave`}
|
||||
disabled={isSavePending || isPinPending || isRemovePending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
isSaved
|
||||
? _(msg`Remove from my feeds`)
|
||||
: _(msg`Add to my feeds`)
|
||||
}
|
||||
accessibilityHint=""
|
||||
onPress={onToggleSaved}
|
||||
hitSlop={15}
|
||||
style={styles.btn}>
|
||||
{isSaved ? (
|
||||
<FontAwesomeIcon
|
||||
icon={['far', 'trash-can']}
|
||||
size={19}
|
||||
color={pal.colors.icon}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon="plus"
|
||||
size={18}
|
||||
color={pal.colors.link}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -283,6 +315,8 @@ export function FeedSourceCardLoaded({
|
||||
confirmButtonCta={_(msg`Remove`)}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
|
||||
<PrimaryAlgoNoticeDialog control={primaryAlgoDialogControl} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from 'react'
|
||||
import {RenderTabBarFnProps} from 'view/com/pager/Pager'
|
||||
import {HomeHeaderLayout} from './HomeHeaderLayout'
|
||||
import {FeedSourceInfo} from '#/state/queries/feed'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {FeedSourceInfo} from '#/state/queries/feed'
|
||||
import {useSession} from '#/state/session'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {RenderTabBarFnProps} from 'view/com/pager/Pager'
|
||||
import {TabBar} from '../pager/TabBar'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {HomeHeaderLayout} from './HomeHeaderLayout'
|
||||
|
||||
export function HomeHeader(
|
||||
props: RenderTabBarFnProps & {
|
||||
@@ -16,12 +18,19 @@ export function HomeHeader(
|
||||
},
|
||||
) {
|
||||
const {feeds} = props
|
||||
const {hasSession} = useSession()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const pal = usePalette('default')
|
||||
|
||||
const hasPinnedCustom = React.useMemo<boolean>(() => {
|
||||
return feeds.some(tab => tab.uri !== '')
|
||||
}, [feeds])
|
||||
if (!hasSession) return false
|
||||
return feeds.some(tab => {
|
||||
const isFollowing = ['home', 'following'].includes(tab.uri)
|
||||
const isPrimaryAlgo = tab.isPrimaryAlgorithm
|
||||
const isCustom = !isFollowing && !isPrimaryAlgo
|
||||
return isCustom
|
||||
})
|
||||
}, [feeds, hasSession])
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const pinnedNames = feeds.map(f => f.displayName)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, {useRef, useMemo, useEffect, useState, useCallback} from 'react'
|
||||
import {StyleSheet, View, ScrollView, LayoutChangeEvent} from 'react-native'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {PressableWithHover} from '../util/PressableWithHover'
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {PressableWithHover} from '../util/PressableWithHover'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {DraggableScrollView} from './DraggableScrollView'
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
export interface TabBarProps {
|
||||
testID?: string
|
||||
@@ -139,7 +140,10 @@ export function TabBar({
|
||||
<Text
|
||||
type={isDesktop || isTablet ? 'xl-bold' : 'lg-bold'}
|
||||
testID={testID ? `${testID}-${item}` : undefined}
|
||||
style={selected ? pal.text : pal.textLight}>
|
||||
style={[
|
||||
selected ? pal.text : pal.textLight,
|
||||
{lineHeight: 20},
|
||||
]}>
|
||||
{item}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {useSession} from '#/state/session'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {FeedSuggestedFollows} from '#/components/FeedSuggestedFollows'
|
||||
import {List, ListRef} from '../util/List'
|
||||
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||
@@ -40,6 +41,7 @@ const LOADING_ITEM = {_reactKey: '__loading__'}
|
||||
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||
const ERROR_ITEM = {_reactKey: '__error__'}
|
||||
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||
const SUGGESTED_FOLLOWS_ITEM = {_reactKey: '__suggested_follows__'}
|
||||
|
||||
// DISABLED need to check if this is causing random feed refreshes -prf
|
||||
// const REFRESH_AFTER = STALE.HOURS.ONE
|
||||
@@ -198,6 +200,7 @@ let Feed = ({
|
||||
} else if (isEmpty) {
|
||||
arr = arr.concat([EMPTY_FEED_ITEM])
|
||||
} else if (data) {
|
||||
arr = arr.concat(SUGGESTED_FOLLOWS_ITEM)
|
||||
for (const page of data?.pages) {
|
||||
arr = arr.concat(page.slices)
|
||||
}
|
||||
@@ -297,6 +300,8 @@ let Feed = ({
|
||||
// see home.ts (feed api) for more info
|
||||
// -prf
|
||||
return <DiscoverFallbackHeader />
|
||||
} else if (item === SUGGESTED_FOLLOWS_ITEM) {
|
||||
return <FeedSuggestedFollows />
|
||||
}
|
||||
return <FeedSlice slice={item} />
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {
|
||||
getAvatarTypeFromUri,
|
||||
@@ -150,6 +151,7 @@ export function FeedsScreen(_props: Props) {
|
||||
const {hasSession} = useSession()
|
||||
const listRef = React.useRef<FlatList>(null)
|
||||
const searchInputRef = React.useRef<SearchInputRef>(null)
|
||||
const gate = useGate()
|
||||
|
||||
/**
|
||||
* A search query is present. We may not have search results yet.
|
||||
@@ -236,15 +238,41 @@ export function FeedsScreen(_props: Props) {
|
||||
// pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
|
||||
})
|
||||
} else {
|
||||
const isPrimaryAlgoExperimentEnabled = gate(
|
||||
'reduced_onboarding_and_home_algo',
|
||||
)
|
||||
const primaryAlgo = preferences.primaryAlgorithm
|
||||
|
||||
if (
|
||||
isPrimaryAlgoExperimentEnabled &&
|
||||
primaryAlgo?.enabled &&
|
||||
primaryAlgo?.uri
|
||||
) {
|
||||
slices = slices.concat({
|
||||
key: `savedFeed:${primaryAlgo?.uri}`,
|
||||
type: 'savedFeed',
|
||||
feedUri: primaryAlgo?.uri,
|
||||
})
|
||||
}
|
||||
|
||||
if (preferences?.feeds?.saved.length !== 0) {
|
||||
const {saved, pinned} = preferences.feeds
|
||||
|
||||
slices = slices.concat(
|
||||
pinned.map(uri => ({
|
||||
key: `savedFeed:${uri}`,
|
||||
type: 'savedFeed',
|
||||
feedUri: uri,
|
||||
})),
|
||||
pinned
|
||||
.filter(uri => {
|
||||
return !(
|
||||
isPrimaryAlgoExperimentEnabled &&
|
||||
primaryAlgo?.enabled &&
|
||||
primaryAlgo?.uri &&
|
||||
uri === primaryAlgo?.uri
|
||||
)
|
||||
})
|
||||
.map(uri => ({
|
||||
key: `savedFeed:${uri}`,
|
||||
type: 'savedFeed',
|
||||
feedUri: uri,
|
||||
})),
|
||||
)
|
||||
|
||||
slices = slices.concat(
|
||||
@@ -323,7 +351,17 @@ export function FeedsScreen(_props: Props) {
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return !preferences?.feeds?.saved.includes(feed.uri)
|
||||
const isPrimaryAlgoExperimentEnabled = gate(
|
||||
'reduced_onboarding_and_home_algo',
|
||||
)
|
||||
const isPrimaryAlgo =
|
||||
isPrimaryAlgoExperimentEnabled &&
|
||||
preferences?.primaryAlgorithm?.enabled &&
|
||||
preferences?.primaryAlgorithm?.uri === feed.uri
|
||||
return (
|
||||
!preferences?.feeds?.saved.includes(feed.uri) &&
|
||||
!isPrimaryAlgo
|
||||
)
|
||||
})
|
||||
.map(feed => ({
|
||||
key: `popularFeed:${feed.uri}`,
|
||||
@@ -358,6 +396,7 @@ export function FeedsScreen(_props: Props) {
|
||||
isSearchPending,
|
||||
searchError,
|
||||
isUserSearching,
|
||||
gate,
|
||||
])
|
||||
|
||||
const renderHeaderBtn = React.useCallback(() => {
|
||||
@@ -479,7 +518,18 @@ export function FeedsScreen(_props: Props) {
|
||||
</View>
|
||||
)
|
||||
} else if (item.type === 'savedFeed') {
|
||||
return <SavedFeed feedUri={item.feedUri} />
|
||||
const isPrimaryAlgoExperimentEnabled = gate(
|
||||
'reduced_onboarding_and_home_algo',
|
||||
)
|
||||
const primaryAlgo = preferences?.primaryAlgorithm
|
||||
const isPrimaryAlgo =
|
||||
isPrimaryAlgoExperimentEnabled &&
|
||||
primaryAlgo?.enabled &&
|
||||
primaryAlgo?.uri === item.feedUri
|
||||
|
||||
return (
|
||||
<SavedFeed feedUri={item.feedUri} isPrimaryAlgo={isPrimaryAlgo} />
|
||||
)
|
||||
} else if (item.type === 'popularFeedsHeader') {
|
||||
return (
|
||||
<>
|
||||
@@ -532,6 +582,7 @@ export function FeedsScreen(_props: Props) {
|
||||
pal.icon,
|
||||
pal.textLight,
|
||||
_,
|
||||
preferences?.primaryAlgorithm,
|
||||
preferences?.feeds?.saved?.length,
|
||||
query,
|
||||
onChangeQuery,
|
||||
@@ -539,6 +590,7 @@ export function FeedsScreen(_props: Props) {
|
||||
onSubmitQuery,
|
||||
onChangeSearchFocus,
|
||||
hasSession,
|
||||
gate,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -585,7 +637,13 @@ export function FeedsScreen(_props: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
function SavedFeed({feedUri}: {feedUri: string}) {
|
||||
function SavedFeed({
|
||||
feedUri,
|
||||
isPrimaryAlgo,
|
||||
}: {
|
||||
feedUri: string
|
||||
isPrimaryAlgo?: boolean
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const {data: info, error} = useFeedSourceInfoQuery({uri: feedUri})
|
||||
@@ -632,6 +690,12 @@ function SavedFeed({feedUri}: {feedUri: string}) {
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{isPrimaryAlgo && (
|
||||
<Text type="xs" style={[pal.textLight, s.semiBold]}>
|
||||
<Trans>Primary Algorithm</Trans>
|
||||
</Text>
|
||||
)}
|
||||
{isMobile && (
|
||||
<FontAwesomeIcon
|
||||
icon="chevron-right"
|
||||
|
||||
+24
-13
@@ -53,15 +53,19 @@ function HomeScreenReady({
|
||||
pinnedFeedInfos: FeedSourceInfo[]
|
||||
}) {
|
||||
useOTAUpdates()
|
||||
const gate = useGate()
|
||||
|
||||
const allFeeds = React.useMemo(() => {
|
||||
const feeds: FeedDescriptor[] = []
|
||||
feeds.push('home')
|
||||
for (const {uri} of pinnedFeedInfos) {
|
||||
if (uri.includes('app.bsky.feed.generator')) {
|
||||
feeds.push(`feedgen|${uri}`)
|
||||
} else if (uri.includes('app.bsky.graph.list')) {
|
||||
feeds.push(`list|${uri}`)
|
||||
} else if (uri === 'home') {
|
||||
feeds.push('home')
|
||||
} else if (uri === 'following') {
|
||||
feeds.push('following')
|
||||
}
|
||||
}
|
||||
return feeds
|
||||
@@ -70,6 +74,10 @@ function HomeScreenReady({
|
||||
const rawSelectedFeed = useSelectedFeed()
|
||||
const setSelectedFeed = useSetSelectedFeed()
|
||||
const maybeFoundIndex = allFeeds.indexOf(rawSelectedFeed as FeedDescriptor)
|
||||
/*
|
||||
* N.B. if `rawSelectedFeed` returns `DEFAULT_FEED_DESCRIPTOR`,
|
||||
* `maybeFoundIndex` will be -1 and we'll fall back to left-most tab
|
||||
*/
|
||||
const selectedIndex = Math.max(0, maybeFoundIndex)
|
||||
const selectedFeed = allFeeds[selectedIndex]
|
||||
|
||||
@@ -111,7 +119,6 @@ function HomeScreenReady({
|
||||
}),
|
||||
)
|
||||
|
||||
const gate = useGate()
|
||||
React.useEffect(() => {
|
||||
const listener = AppState.addEventListener('change', nextAppState => {
|
||||
if (nextAppState === 'active') {
|
||||
@@ -186,7 +193,6 @@ function HomeScreenReady({
|
||||
return <CustomFeedEmptyState />
|
||||
}, [])
|
||||
|
||||
const [homeFeed, ...customFeeds] = allFeeds
|
||||
const homeFeedParams = React.useMemo<FeedParams>(() => {
|
||||
return {
|
||||
mergeFeedEnabled: Boolean(preferences.feedViewPrefs.lab_mergeFeedEnabled),
|
||||
@@ -206,16 +212,21 @@ function HomeScreenReady({
|
||||
onPageSelected={onPageSelected}
|
||||
onPageScrollStateChanged={onPageScrollStateChanged}
|
||||
renderTabBar={renderTabBar}>
|
||||
<FeedPage
|
||||
key={homeFeed}
|
||||
testID="followingFeedPage"
|
||||
isPageFocused={selectedFeed === homeFeed}
|
||||
feed={homeFeed}
|
||||
feedParams={homeFeedParams}
|
||||
renderEmptyState={renderFollowingEmptyState}
|
||||
renderEndOfFeed={FollowingEndOfFeed}
|
||||
/>
|
||||
{customFeeds.map(feed => {
|
||||
{allFeeds.map(feed => {
|
||||
if (feed === 'home' || feed === 'following') {
|
||||
return (
|
||||
<FeedPage
|
||||
key={feed}
|
||||
testID="followingFeedPage"
|
||||
isPageFocused={selectedFeed === feed}
|
||||
feed={feed}
|
||||
feedParams={homeFeedParams}
|
||||
renderEmptyState={renderFollowingEmptyState}
|
||||
renderEndOfFeed={FollowingEndOfFeed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FeedPage
|
||||
key={feed}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {listenSoftReset} from '#/state/events'
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
UsePreferencesQueryResponse,
|
||||
useRemoveFeedMutation,
|
||||
useSaveFeedMutation,
|
||||
useSetPrimaryAlgorithmMutation,
|
||||
useUnpinFeedMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
@@ -52,18 +54,22 @@ import {Text} from 'view/com/util/text/Text'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button as NewButton, ButtonText} from '#/components/Button'
|
||||
import {Button as NewButton, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
|
||||
import {
|
||||
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
|
||||
Heart2_Stroke2_Corner0_Rounded as HeartOutline,
|
||||
} from '#/components/icons/Heart2'
|
||||
import {Home_Stroke2_Corner0_Rounded as Home} from '#/components/icons/Home'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {PrimaryAlgoNoticeDialog} from '#/components/PrimaryAlgoNoticeDialog'
|
||||
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
|
||||
import {RichText} from '#/components/RichText'
|
||||
|
||||
@@ -162,6 +168,9 @@ export function ProfileFeedScreenInner({
|
||||
const playHaptic = useHaptics()
|
||||
const feedSectionRef = React.useRef<SectionRef>(null)
|
||||
const isScreenFocused = useIsFocused()
|
||||
const gate = useGate()
|
||||
const primaryAlgoDialogControl = useDialogControl()
|
||||
const primaryAlgo = preferences.primaryAlgorithm
|
||||
|
||||
const {
|
||||
mutateAsync: saveFeed,
|
||||
@@ -187,6 +196,11 @@ export function ProfileFeedScreenInner({
|
||||
reset: resetUnpinFeed,
|
||||
isPending: isUnpinPending,
|
||||
} = useUnpinFeedMutation()
|
||||
const {
|
||||
mutateAsync: setPrimaryAlgo,
|
||||
variables: primaryAlgoVariables,
|
||||
isPending: isSetPrimaryAlgoPending,
|
||||
} = useSetPrimaryAlgorithmMutation()
|
||||
|
||||
const isSaved =
|
||||
!removedFeed &&
|
||||
@@ -194,6 +208,11 @@ export function ProfileFeedScreenInner({
|
||||
const isPinned =
|
||||
!unpinnedFeed &&
|
||||
(!!pinnedFeed || preferences.feeds.pinned.includes(feedInfo.uri))
|
||||
const isPrimaryAlgo =
|
||||
(primaryAlgo?.enabled &&
|
||||
primaryAlgo?.uri &&
|
||||
primaryAlgo.uri === feedInfo.uri) ||
|
||||
(primaryAlgoVariables?.enabled && primaryAlgoVariables.uri === feedInfo.uri)
|
||||
|
||||
useSetTitle(feedInfo?.displayName)
|
||||
|
||||
@@ -258,6 +277,18 @@ export function ProfileFeedScreenInner({
|
||||
_,
|
||||
])
|
||||
|
||||
const onSetPrimaryAlgo = React.useCallback(async () => {
|
||||
try {
|
||||
playHaptic()
|
||||
await setPrimaryAlgo({enabled: true, uri: feedInfo.uri})
|
||||
} catch (e: any) {
|
||||
Toast.show(_(msg`There was an issue contacting the server`))
|
||||
logger.error('ProfileFeed: failed to set primary algo', {
|
||||
message: e.message,
|
||||
})
|
||||
}
|
||||
}, [setPrimaryAlgo, feedInfo, _, playHaptic])
|
||||
|
||||
const onPressShare = React.useCallback(() => {
|
||||
const url = toShareUrl(feedInfo.route.href)
|
||||
shareUrl(url)
|
||||
@@ -294,18 +325,40 @@ export function ProfileFeedScreenInner({
|
||||
avatarType="algo">
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
{feedInfo && hasSession && (
|
||||
<NewButton
|
||||
testID={isPinned ? 'unpinBtn' : 'pinBtn'}
|
||||
disabled={isPinPending || isUnpinPending}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color={isPinned ? 'secondary' : 'primary'}
|
||||
label={isPinned ? _(msg`Unpin from home`) : _(msg`Pin to home`)}
|
||||
onPress={onTogglePinned}>
|
||||
<ButtonText>
|
||||
{isPinned ? _(msg`Unpin`) : _(msg`Pin to Home`)}
|
||||
</ButtonText>
|
||||
</NewButton>
|
||||
<>
|
||||
{gate('reduced_onboarding_and_home_algo') && isPrimaryAlgo ? (
|
||||
<NewButton
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
label={_(
|
||||
msg`This feed is already set as your primary algorithm.`,
|
||||
)}
|
||||
onPress={() => {
|
||||
primaryAlgoDialogControl.open()
|
||||
}}>
|
||||
<ButtonIcon icon={Check} position="left" />
|
||||
<ButtonText>
|
||||
<Trans>Primary Algorithm</Trans>
|
||||
</ButtonText>
|
||||
</NewButton>
|
||||
) : (
|
||||
<NewButton
|
||||
testID={isPinned ? 'unpinBtn' : 'pinBtn'}
|
||||
disabled={isPinPending || isUnpinPending}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color={isPinned ? 'secondary' : 'primary'}
|
||||
label={
|
||||
isPinned ? _(msg`Unpin from home`) : _(msg`Pin to home`)
|
||||
}
|
||||
onPress={onTogglePinned}>
|
||||
<ButtonText>
|
||||
{isPinned ? _(msg`Unpin`) : _(msg`Pin to Home`)}
|
||||
</ButtonText>
|
||||
</NewButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Open feed options menu`)}>
|
||||
@@ -338,25 +391,42 @@ export function ProfileFeedScreenInner({
|
||||
<Menu.Group>
|
||||
{hasSession && (
|
||||
<>
|
||||
<Menu.Item
|
||||
disabled={isSavePending || isRemovePending}
|
||||
testID="feedHeaderDropdownToggleSavedBtn"
|
||||
label={
|
||||
isSaved
|
||||
? _(msg`Remove from my feeds`)
|
||||
: _(msg`Save to my feeds`)
|
||||
}
|
||||
onPress={onToggleSaved}>
|
||||
<Menu.ItemText>
|
||||
{isSaved
|
||||
? _(msg`Remove from my feeds`)
|
||||
: _(msg`Save to my feeds`)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={isSaved ? Trash : Plus}
|
||||
position="right"
|
||||
/>
|
||||
</Menu.Item>
|
||||
{!isPrimaryAlgo && (
|
||||
<>
|
||||
<Menu.Item
|
||||
disabled={isSavePending || isRemovePending}
|
||||
testID="feedHeaderDropdownToggleSavedBtn"
|
||||
label={
|
||||
isSaved
|
||||
? _(msg`Remove from my feeds`)
|
||||
: _(msg`Save to my feeds`)
|
||||
}
|
||||
onPress={onToggleSaved}>
|
||||
<Menu.ItemText>
|
||||
{isSaved
|
||||
? _(msg`Remove from my feeds`)
|
||||
: _(msg`Save to my feeds`)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={isSaved ? Trash : Plus}
|
||||
position="right"
|
||||
/>
|
||||
</Menu.Item>
|
||||
|
||||
{gate('reduced_onboarding_and_home_algo') && (
|
||||
<Menu.Item
|
||||
disabled={isSetPrimaryAlgoPending}
|
||||
testID="feedHeaderDropdownSetPrimaryAlgoBtn"
|
||||
label={_(msg`Set as primary algorithm`)}
|
||||
onPress={onSetPrimaryAlgo}>
|
||||
<Menu.ItemText>
|
||||
{_(msg`Set as primary algorithm`)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Home} position="right" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
testID="feedHeaderDropdownReportBtn"
|
||||
@@ -385,6 +455,8 @@ export function ProfileFeedScreenInner({
|
||||
feedRkey={feedInfo.route.params.rkey}
|
||||
feedInfo={feedInfo}
|
||||
/>
|
||||
|
||||
<PrimaryAlgoNoticeDialog control={primaryAlgoDialogControl} />
|
||||
</>
|
||||
)
|
||||
}, [
|
||||
@@ -403,6 +475,11 @@ export function ProfileFeedScreenInner({
|
||||
onPressReport,
|
||||
onPressShare,
|
||||
t,
|
||||
gate,
|
||||
isPrimaryAlgo,
|
||||
primaryAlgoDialogControl,
|
||||
onSetPrimaryAlgo,
|
||||
isSetPrimaryAlgoPending,
|
||||
])
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,6 +7,7 @@ import {useFocusEffect} from '@react-navigation/native'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
usePinFeedMutation,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
useSetSaveFeedsMutation,
|
||||
useUnpinFeedMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useHaptics} from 'lib/haptics'
|
||||
@@ -112,6 +114,7 @@ export function SavedFeeds({}: Props) {
|
||||
setSavedFeeds={setSavedFeeds}
|
||||
resetSaveFeedsMutationState={resetSaveFeedsMutationState}
|
||||
currentFeeds={currentFeeds}
|
||||
preferences={preferences}
|
||||
/>
|
||||
))
|
||||
)
|
||||
@@ -145,6 +148,7 @@ export function SavedFeeds({}: Props) {
|
||||
setSavedFeeds={setSavedFeeds}
|
||||
resetSaveFeedsMutationState={resetSaveFeedsMutationState}
|
||||
currentFeeds={currentFeeds}
|
||||
preferences={preferences}
|
||||
/>
|
||||
))
|
||||
)
|
||||
@@ -179,6 +183,7 @@ function ListItem({
|
||||
currentFeeds,
|
||||
setSavedFeeds,
|
||||
resetSaveFeedsMutationState,
|
||||
preferences,
|
||||
}: {
|
||||
feedUri: string // uri
|
||||
isPinned: boolean
|
||||
@@ -187,6 +192,7 @@ function ListItem({
|
||||
resetSaveFeedsMutationState: ReturnType<
|
||||
typeof useSetSaveFeedsMutation
|
||||
>['reset']
|
||||
preferences: UsePreferencesQueryResponse
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
@@ -195,6 +201,13 @@ function ListItem({
|
||||
const {isPending: isUnpinPending, mutateAsync: unpinFeed} =
|
||||
useUnpinFeedMutation()
|
||||
const isPending = isPinPending || isUnpinPending
|
||||
const gate = useGate()
|
||||
const primaryAlgo = preferences.primaryAlgorithm
|
||||
const isPrimaryAlgoExperimentEnabled = gate(
|
||||
'reduced_onboarding_and_home_algo',
|
||||
)
|
||||
const isPrimaryAlgo = primaryAlgo?.enabled && primaryAlgo?.uri === feedUri
|
||||
const showPinButton = !(isPrimaryAlgoExperimentEnabled && isPrimaryAlgo)
|
||||
|
||||
const onTogglePinned = React.useCallback(async () => {
|
||||
playHaptic()
|
||||
@@ -303,20 +316,24 @@ function ListItem({
|
||||
showSaveBtn
|
||||
showMinimalPlaceholder
|
||||
/>
|
||||
<Pressable
|
||||
disabled={isPending}
|
||||
accessibilityRole="button"
|
||||
hitSlop={10}
|
||||
onPress={onTogglePinned}
|
||||
style={state => ({
|
||||
opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
|
||||
})}>
|
||||
<FontAwesomeIcon
|
||||
icon="thumb-tack"
|
||||
size={20}
|
||||
color={isPinned ? colors.blue3 : pal.colors.icon}
|
||||
/>
|
||||
</Pressable>
|
||||
{showPinButton && (
|
||||
<View style={{paddingRight: 16}}>
|
||||
<Pressable
|
||||
disabled={isPending}
|
||||
accessibilityRole="button"
|
||||
hitSlop={10}
|
||||
onPress={onTogglePinned}
|
||||
style={state => ({
|
||||
opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
|
||||
})}>
|
||||
<FontAwesomeIcon
|
||||
icon="thumb-tack"
|
||||
size={20}
|
||||
color={isPinned ? colors.blue3 : pal.colors.icon}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -345,7 +362,6 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 1,
|
||||
paddingRight: 16,
|
||||
},
|
||||
webArrowButtonsContainer: {
|
||||
paddingLeft: 16,
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import React from 'react'
|
||||
import {View, StyleSheet} from 'react-native'
|
||||
import {useNavigationState, useNavigation} from '@react-navigation/native'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {TextLink} from 'view/com/util/Link'
|
||||
import {getCurrentRoute} from 'lib/routes/helpers'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {usePinnedFeedsInfos} from '#/state/queries/feed'
|
||||
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation, useNavigationState} from '@react-navigation/native'
|
||||
|
||||
import {emitSoftReset} from '#/state/events'
|
||||
import {usePinnedFeedsInfos} from '#/state/queries/feed'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {getCurrentRoute} from 'lib/routes/helpers'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {TextLink} from 'view/com/util/Link'
|
||||
|
||||
export function DesktopFeeds() {
|
||||
const pal = usePalette('default')
|
||||
@@ -33,8 +34,10 @@ export function DesktopFeeds() {
|
||||
{pinnedFeedInfos.map(feedInfo => {
|
||||
const uri = feedInfo.uri
|
||||
let feed: FeedDescriptor
|
||||
if (!uri) {
|
||||
if (uri === 'home') {
|
||||
feed = 'home'
|
||||
} else if (uri === 'following') {
|
||||
feed = 'following'
|
||||
} else if (uri.includes('app.bsky.feed.generator')) {
|
||||
feed = `feedgen|${uri}`
|
||||
} else if (uri.includes('app.bsky.graph.list')) {
|
||||
|
||||
Reference in New Issue
Block a user