87da619aaa
* migrate to #/screens * rm unneeded import * block drawer gesture on recent profiles * rm recommendations (#8056) * [Explore] Disable Trending videos (#8054) * remove giant header * disable * [Explore] Dynamic module ordering (#8066) * Dynamic module ordering * [Explore] New headers, metrics (#8067) * new sticky headers * improve spacing between modules * view metric on modules * update metrics names * [Explore] Suggested accounts module (#8072) * use modern profile card, update load more * add tab bar * tabbed suggested accounts * [Explore] Discover feeds module (#8073) * cap number of feeds to 3 * change feed pin button * Apply suggestions from code review Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * restore statsig to log events * filter out followed profiles, make suer enough are loaded (#8090) * [Explore] Trending topics (#8055) * redesigned trending topics * rm borders on web * get post count / age / ranking from api * spacing tweaks * fetch more topics then slice * use api data for avis/category * rm top border * Integrate new SDK, part out components * Clean up * Use status field * Bump SDK * Send up interests and langs --------- Co-authored-by: Eric Bailey <git@esb.lol> * Clean up module spacing and borders (cherry picked from commit 63d19b6c2d67e226e0e14709b1047a1f88b3ce1c) (cherry picked from commit 62d7d394ab1dc31b40b9c2cf59075adbf94737a1) * Switch back border ordering (cherry picked from commit 34e3789f8b410132c1390df3c2bb8257630ebdd9) * [Explore] Starter Packs (#8095) * Temp WIP (cherry picked from commit 43b5d7b1e64b3adb1ed162262d0310e0bf026c18) * New SP card * Load state * Revert change * Cleanup * Interests and caching * Count total * Format * Caching * [Explore] Feed previews module (#8075) * wip new hook * get fetching working, maybe * get feed previews rendering! * fix header height * working pin button * extract out FeedLink * add loader * only make preview:header sticky * Fix headers * Header tweaks * Fix moderation filter * Fix threading --------- Co-authored-by: Eric Bailey <git@esb.lol> * Space it out * Fix query key * Mock new endpoint, filter saved feeds * Make sure we're pinning, lower cache time * add news category * Remove log * Improve suggested accounts load state * Integrate new app view endpoint * fragment * Update src/screens/Search/modules/ExploreTrendingTopics.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/screens/Search/modules/ExploreTrendingTopics.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * lint * maybe fix this --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey <git@esb.lol> Co-authored-by: Hailey <me@haileyok.com>
359 lines
8.9 KiB
TypeScript
359 lines
8.9 KiB
TypeScript
import React from 'react'
|
|
import {type GestureResponderEvent, View} from 'react-native'
|
|
import {
|
|
type AppBskyFeedDefs,
|
|
type AppBskyGraphDefs,
|
|
AtUri,
|
|
RichText as RichTextApi,
|
|
} from '@atproto/api'
|
|
import {msg, Plural, Trans} from '@lingui/macro'
|
|
import {useLingui} from '@lingui/react'
|
|
import {useQueryClient} from '@tanstack/react-query'
|
|
|
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
|
import {logger} from '#/logger'
|
|
import {precacheFeedFromGeneratorView} from '#/state/queries/feed'
|
|
import {
|
|
useAddSavedFeedsMutation,
|
|
usePreferencesQuery,
|
|
useRemoveFeedMutation,
|
|
} from '#/state/queries/preferences'
|
|
import {useSession} from '#/state/session'
|
|
import * as Toast from '#/view/com/util/Toast'
|
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
|
import {useTheme} from '#/alf'
|
|
import {atoms as a} from '#/alf'
|
|
import {
|
|
Button,
|
|
ButtonIcon,
|
|
type ButtonProps,
|
|
ButtonText,
|
|
} from '#/components/Button'
|
|
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
|
|
import {Link as InternalLink, type LinkProps} from '#/components/Link'
|
|
import {Loader} from '#/components/Loader'
|
|
import * as Prompt from '#/components/Prompt'
|
|
import {RichText, type RichTextProps} from '#/components/RichText'
|
|
import {Text} from '#/components/Typography'
|
|
import type * as bsky from '#/types/bsky'
|
|
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash'
|
|
|
|
type Props = {
|
|
view: AppBskyFeedDefs.GeneratorView
|
|
}
|
|
|
|
export function Default(props: Props) {
|
|
const {view} = props
|
|
return (
|
|
<Link {...props}>
|
|
<Outer>
|
|
<Header>
|
|
<Avatar src={view.avatar} />
|
|
<TitleAndByline title={view.displayName} creator={view.creator} />
|
|
<SaveButton view={view} pin />
|
|
</Header>
|
|
<Description description={view.description} />
|
|
<Likes count={view.likeCount || 0} />
|
|
</Outer>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
export function Link({
|
|
view,
|
|
children,
|
|
...props
|
|
}: Props & Omit<LinkProps, 'to' | 'label'>) {
|
|
const queryClient = useQueryClient()
|
|
|
|
const href = React.useMemo(() => {
|
|
return createProfileFeedHref({feed: view})
|
|
}, [view])
|
|
|
|
React.useEffect(() => {
|
|
precacheFeedFromGeneratorView(queryClient, view)
|
|
}, [view, queryClient])
|
|
|
|
return (
|
|
<InternalLink
|
|
label={view.displayName}
|
|
to={href}
|
|
style={[a.flex_col]}
|
|
{...props}>
|
|
{children}
|
|
</InternalLink>
|
|
)
|
|
}
|
|
|
|
export function Outer({children}: {children: React.ReactNode}) {
|
|
return <View style={[a.w_full, a.gap_sm]}>{children}</View>
|
|
}
|
|
|
|
export function Header({children}: {children: React.ReactNode}) {
|
|
return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
|
|
}
|
|
|
|
export type AvatarProps = {src: string | undefined; size?: number}
|
|
|
|
export function Avatar({src, size = 40}: AvatarProps) {
|
|
return <UserAvatar type="algo" size={size} avatar={src} />
|
|
}
|
|
|
|
export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
|
|
const t = useTheme()
|
|
return (
|
|
<View
|
|
style={[
|
|
t.atoms.bg_contrast_25,
|
|
{
|
|
width: size,
|
|
height: size,
|
|
borderRadius: 8,
|
|
},
|
|
]}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function TitleAndByline({
|
|
title,
|
|
creator,
|
|
}: {
|
|
title: string
|
|
creator?: bsky.profile.AnyProfileView
|
|
}) {
|
|
const t = useTheme()
|
|
|
|
return (
|
|
<View style={[a.flex_1]}>
|
|
<Text
|
|
emoji
|
|
style={[a.text_md, a.font_bold, a.leading_snug]}
|
|
numberOfLines={1}>
|
|
{title}
|
|
</Text>
|
|
{creator && (
|
|
<Text
|
|
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
|
numberOfLines={1}>
|
|
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
|
</Text>
|
|
)}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) {
|
|
const t = useTheme()
|
|
|
|
return (
|
|
<View style={[a.flex_1, a.gap_xs]}>
|
|
<View
|
|
style={[
|
|
a.rounded_xs,
|
|
t.atoms.bg_contrast_50,
|
|
{
|
|
width: '60%',
|
|
height: 14,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{creator && (
|
|
<View
|
|
style={[
|
|
a.rounded_xs,
|
|
t.atoms.bg_contrast_25,
|
|
{
|
|
width: '40%',
|
|
height: 10,
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export function Description({
|
|
description,
|
|
...rest
|
|
}: {description?: string} & Partial<RichTextProps>) {
|
|
const rt = React.useMemo(() => {
|
|
if (!description) return
|
|
const rt = new RichTextApi({text: description || ''})
|
|
rt.detectFacetsWithoutResolution()
|
|
return rt
|
|
}, [description])
|
|
if (!rt) return null
|
|
return <RichText value={rt} style={[a.leading_snug]} disableLinks {...rest} />
|
|
}
|
|
|
|
export function DescriptionPlaceholder() {
|
|
const t = useTheme()
|
|
return (
|
|
<View style={[a.gap_xs]}>
|
|
<View
|
|
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
|
/>
|
|
<View
|
|
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
|
/>
|
|
<View
|
|
style={[
|
|
a.rounded_xs,
|
|
a.w_full,
|
|
t.atoms.bg_contrast_50,
|
|
{height: 12, width: 100},
|
|
]}
|
|
/>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export function Likes({count}: {count: number}) {
|
|
const t = useTheme()
|
|
return (
|
|
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
|
<Trans>
|
|
Liked by <Plural value={count || 0} one="# user" other="# users" />
|
|
</Trans>
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
export function SaveButton({
|
|
view,
|
|
pin,
|
|
...props
|
|
}: {
|
|
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
|
pin?: boolean
|
|
text?: boolean
|
|
} & Partial<ButtonProps>) {
|
|
const {hasSession} = useSession()
|
|
if (!hasSession) return null
|
|
return <SaveButtonInner view={view} pin={pin} {...props} />
|
|
}
|
|
|
|
function SaveButtonInner({
|
|
view,
|
|
pin,
|
|
text = true,
|
|
...buttonProps
|
|
}: {
|
|
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
|
pin?: boolean
|
|
text?: boolean
|
|
} & Partial<ButtonProps>) {
|
|
const {_} = useLingui()
|
|
const {data: preferences} = usePreferencesQuery()
|
|
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
|
|
useAddSavedFeedsMutation()
|
|
const {isPending: isRemovePending, mutateAsync: removeFeed} =
|
|
useRemoveFeedMutation()
|
|
|
|
const uri = view.uri
|
|
const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list'
|
|
|
|
const savedFeedConfig = React.useMemo(() => {
|
|
return preferences?.savedFeeds?.find(feed => feed.value === uri)
|
|
}, [preferences?.savedFeeds, uri])
|
|
const removePromptControl = Prompt.usePromptControl()
|
|
const isPending = isAddSavedFeedPending || isRemovePending
|
|
|
|
const toggleSave = React.useCallback(
|
|
async (e: GestureResponderEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
try {
|
|
if (savedFeedConfig) {
|
|
await removeFeed(savedFeedConfig)
|
|
} else {
|
|
await saveFeeds([
|
|
{
|
|
type,
|
|
value: uri,
|
|
pinned: pin || false,
|
|
},
|
|
])
|
|
}
|
|
Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'})))
|
|
} catch (err: any) {
|
|
logger.error(err, {message: `FeedCard: failed to update feeds`, pin})
|
|
Toast.show(_(msg`Failed to update feeds`), 'xmark')
|
|
}
|
|
},
|
|
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
|
|
)
|
|
|
|
const onPrompRemoveFeed = React.useCallback(
|
|
async (e: GestureResponderEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
removePromptControl.open()
|
|
},
|
|
[removePromptControl],
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
disabled={isPending}
|
|
label={_(msg`Add this feed to your feeds`)}
|
|
size="small"
|
|
variant="solid"
|
|
color={savedFeedConfig ? 'secondary' : 'primary'}
|
|
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}
|
|
{...buttonProps}>
|
|
{savedFeedConfig ? (
|
|
<>
|
|
{isPending ? (
|
|
<ButtonIcon size="md" icon={Loader} />
|
|
) : (
|
|
!text && <ButtonIcon size="md" icon={TrashIcon} />
|
|
)}
|
|
{text && (
|
|
<ButtonText>
|
|
<Trans>Unpin Feed</Trans>
|
|
</ButtonText>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<ButtonIcon size="md" icon={isPending ? Loader : PinIcon} />
|
|
{text && (
|
|
<ButtonText>
|
|
<Trans>Pin Feed</Trans>
|
|
</ButtonText>
|
|
)}
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
<Prompt.Basic
|
|
control={removePromptControl}
|
|
title={_(msg`Remove from your feeds?`)}
|
|
description={_(
|
|
msg`Are you sure you want to remove this from your feeds?`,
|
|
)}
|
|
onConfirm={toggleSave}
|
|
confirmButtonCta={_(msg`Remove`)}
|
|
confirmButtonColor="negative"
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function createProfileFeedHref({
|
|
feed,
|
|
}: {
|
|
feed: AppBskyFeedDefs.GeneratorView
|
|
}) {
|
|
const urip = new AtUri(feed.uri)
|
|
const handleOrDid = feed.creator.handle || feed.creator.did
|
|
return `/profile/${handleOrDid}/feed/${urip.rkey}`
|
|
}
|