Fix quote+list card padding (#8623)

* fix quote padding not being pressable

* fix list padding not being pressable

* Fix unnecessary loading of feeds (#8578)

* stop layout shifts in feed loading

* don't load feed data if we already have it

* adjust styles, alf stuff

* remove unused button, massively simplify

* fix layout shifting in notifs

* use feedcard for feed post embeds

* use bold text to match other style

* use Link component rather than jank Pressable

* prevent nested anchors in notifs

* match following text size

* add space between content hider

* Better dead feed handling (#8579)

* add space between content hider

* add handling for feeds that fail to load

* cleanError, in case of network funkiness

* handle deleted lists

* split out missingfeed
This commit is contained in:
Samuel Newman
2025-07-09 10:14:30 +03:00
committed by GitHub
parent 1fcd313424
commit 07b028ee66
12 changed files with 499 additions and 431 deletions
+2 -9
View File
@@ -1,18 +1,11 @@
import {View} from 'react-native' import {View} from 'react-native'
import {atoms as a, flatten, useTheme, ViewStyleProp} from '#/alf' import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
export function Divider({style}: ViewStyleProp) { export function Divider({style}: ViewStyleProp) {
const t = useTheme() const t = useTheme()
return ( return (
<View <View style={[a.w_full, a.border_t, t.atoms.border_contrast_low, style]} />
style={[
a.w_full,
a.border_t,
t.atoms.border_contrast_low,
flatten(style),
]}
/>
) )
} }
+1 -1
View File
@@ -214,7 +214,7 @@ export function DescriptionPlaceholder() {
export function Likes({count}: {count: number}) { export function Likes({count}: {count: number}) {
const t = useTheme() const t = useTheme()
return ( return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}> <Text style={[a.text_sm, t.atoms.text_contrast_medium, a.font_bold]}>
<Trans> <Trans>
Liked by <Plural value={count || 0} one="# user" other="# users" /> Liked by <Plural value={count || 0} one="# user" other="# users" />
</Trans> </Trans>
+7 -5
View File
@@ -1,10 +1,10 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import { import {
AppBskyGraphDefs, type AppBskyGraphDefs,
AtUri, AtUri,
moderateUserList, moderateUserList,
ModerationUI, type ModerationUI,
} from '@atproto/api' } 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'
@@ -22,10 +22,10 @@ import {
Outer, Outer,
SaveButton, SaveButton,
} from '#/components/FeedCard' } from '#/components/FeedCard'
import {Link as InternalLink, LinkProps} from '#/components/Link' import {Link as InternalLink, type LinkProps} from '#/components/Link'
import * as Hider from '#/components/moderation/Hider' import * as Hider from '#/components/moderation/Hider'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
/* /*
* This component is based on `FeedCard` and is tightly coupled with that * This component is based on `FeedCard` and is tightly coupled with that
@@ -50,7 +50,9 @@ type Props = {
showPinButton?: boolean showPinButton?: boolean
} }
export function Default(props: Props) { export function Default(
props: Props & Omit<LinkProps, 'to' | 'label' | 'children'>,
) {
const {view, showPinButton} = props const {view, showPinButton} = props
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const moderation = moderationOpts const moderation = moderationOpts
+22 -21
View File
@@ -1,10 +1,9 @@
import React from 'react' import {useMemo} from 'react'
import {StyleSheet} from 'react-native'
import {moderateFeedGenerator} from '@atproto/api' import {moderateFeedGenerator} from '@atproto/api'
import {usePalette} from '#/lib/hooks/usePalette'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' import {atoms as a, useTheme} from '#/alf'
import * as FeedCard from '#/components/FeedCard'
import {ContentHider} from '#/components/moderation/ContentHider' import {ContentHider} from '#/components/moderation/ContentHider'
import {type EmbedType} from '#/types/bsky/post' import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types' import {type CommonProps} from './types'
@@ -14,13 +13,22 @@ export function FeedEmbed({
}: CommonProps & { }: CommonProps & {
embed: EmbedType<'feed'> embed: EmbedType<'feed'>
}) { }) {
const pal = usePalette('default') const t = useTheme()
return ( return (
<FeedSourceCard <FeedCard.Link
feedUri={embed.view.uri} view={embed.view}
style={[pal.view, pal.border, styles.customFeedOuter]} style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}>
showLikes <FeedCard.Outer>
/> <FeedCard.Header>
<FeedCard.Avatar src={embed.view.avatar} />
<FeedCard.TitleAndByline
title={embed.view.displayName}
creator={embed.view.creator}
/>
</FeedCard.Header>
<FeedCard.Likes count={embed.view.likeCount || 0} />
</FeedCard.Outer>
</FeedCard.Link>
) )
} }
@@ -30,23 +38,16 @@ export function ModeratedFeedEmbed({
embed: EmbedType<'feed'> embed: EmbedType<'feed'>
}) { }) {
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => { const moderation = useMemo(() => {
return moderationOpts return moderationOpts
? moderateFeedGenerator(embed.view, moderationOpts) ? moderateFeedGenerator(embed.view, moderationOpts)
: undefined : undefined
}, [embed.view, moderationOpts]) }, [embed.view, moderationOpts])
return ( return (
<ContentHider modui={moderation?.ui('contentList')}> <ContentHider
modui={moderation?.ui('contentList')}
childContainerStyle={[a.pt_xs]}>
<FeedEmbed embed={embed} /> <FeedEmbed embed={embed} />
</ContentHider> </ContentHider>
) )
} }
const styles = StyleSheet.create({
customFeedOuter: {
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
},
})
+9 -8
View File
@@ -1,5 +1,4 @@
import React from 'react' import {useMemo} from 'react'
import {View} from 'react-native'
import {moderateUserList} from '@atproto/api' import {moderateUserList} from '@atproto/api'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -16,10 +15,10 @@ export function ListEmbed({
}) { }) {
const t = useTheme() const t = useTheme()
return ( return (
<View <ListCard.Default
style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}> view={embed.view}
<ListCard.Default view={embed.view} /> style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}
</View> />
) )
} }
@@ -29,13 +28,15 @@ export function ModeratedListEmbed({
embed: EmbedType<'list'> embed: EmbedType<'list'>
}) { }) {
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => { const moderation = useMemo(() => {
return moderationOpts return moderationOpts
? moderateUserList(embed.view, moderationOpts) ? moderateUserList(embed.view, moderationOpts)
: undefined : undefined
}, [embed.view, moderationOpts]) }, [embed.view, moderationOpts])
return ( return (
<ContentHider modui={moderation?.ui('contentList')}> <ContentHider
modui={moderation?.ui('contentList')}
childContainerStyle={[a.pt_xs]}>
<ListEmbed embed={embed} /> <ListEmbed embed={embed} />
</ContentHider> </ContentHider>
) )
+51 -55
View File
@@ -268,64 +268,60 @@ export function QuoteEmbed({
const [hover, setHover] = React.useState(false) const [hover, setHover] = React.useState(false)
return ( return (
<View <View
onPointerEnter={() => { style={[a.mt_sm]}
setHover(true) onPointerEnter={() => setHover(true)}
}} onPointerLeave={() => setHover(false)}>
onPointerLeave={() => {
setHover(false)
}}>
<ContentHider <ContentHider
modui={moderation?.ui('contentList')} modui={moderation?.ui('contentList')}
style={[ style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
a.rounded_md, activeStyle={[a.p_md, a.pt_sm]}
a.p_md,
a.mt_sm,
a.border,
t.atoms.border_contrast_low,
style,
]}
childContainerStyle={[a.pt_sm]}> childContainerStyle={[a.pt_sm]}>
<SubtleWebHover hover={hover} /> {({active}) => (
<Link <>
hoverStyle={{borderColor: pal.colors.borderLinkHover}} {!active && <SubtleWebHover hover={hover} style={[a.rounded_md]} />}
href={itemHref} <Link
title={itemTitle} style={[!active && a.p_md]}
onBeforePress={onBeforePress}> hoverStyle={{borderColor: pal.colors.borderLinkHover}}
<View pointerEvents="none"> href={itemHref}
<PostMeta title={itemTitle}
author={quote.author} onBeforePress={onBeforePress}>
moderation={moderation} <View pointerEvents="none">
showAvatar <PostMeta
postHref={itemHref} author={quote.author}
timestamp={quote.indexedAt} moderation={moderation}
/> showAvatar
</View> postHref={itemHref}
{moderation ? ( timestamp={quote.indexedAt}
<PostAlerts />
modui={moderation.ui('contentView')} </View>
style={[a.py_xs]} {moderation ? (
/> <PostAlerts
) : null} modui={moderation.ui('contentView')}
{richText ? ( style={[a.py_xs]}
<RichText />
value={richText} ) : null}
style={a.text_md} {richText ? (
numberOfLines={20} <RichText
disableLinks value={richText}
/> style={a.text_md}
) : null} numberOfLines={20}
{quote.embed && ( disableLinks
<Embed />
embed={quote.embed} ) : null}
moderation={moderation} {quote.embed && (
isWithinQuote={parentIsWithinQuote ?? true} <Embed
// already within quote? override nested embed={quote.embed}
allowNestedQuotes={ moderation={moderation}
parentIsWithinQuote ? false : parentAllowNestedQuotes isWithinQuote={parentIsWithinQuote ?? true}
} // already within quote? override nested
/> allowNestedQuotes={
)} parentIsWithinQuote ? false : parentAllowNestedQuotes
</Link> }
/>
)}
</Link>
</>
)}
</ContentHider> </ContentHider>
</View> </View>
) )
+8 -5
View File
@@ -23,20 +23,23 @@ export function ContentHider({
modui, modui,
ignoreMute, ignoreMute,
style, style,
activeStyle,
childContainerStyle, childContainerStyle,
children, children,
}: React.PropsWithChildren<{ }: {
testID?: string testID?: string
modui: ModerationUI | undefined modui: ModerationUI | undefined
ignoreMute?: boolean ignoreMute?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
activeStyle?: StyleProp<ViewStyle>
childContainerStyle?: StyleProp<ViewStyle> childContainerStyle?: StyleProp<ViewStyle>
}>) { children?: React.ReactNode | ((props: {active: boolean}) => React.ReactNode)
}) {
const blur = modui?.blurs[0] const blur = modui?.blurs[0]
if (!blur || (ignoreMute && isJustAMute(modui))) { if (!blur || (ignoreMute && isJustAMute(modui))) {
return ( return (
<View testID={testID} style={style}> <View testID={testID} style={style}>
{children} {typeof children === 'function' ? children({active: false}) : children}
</View> </View>
) )
} }
@@ -44,9 +47,9 @@ export function ContentHider({
<ContentHiderActive <ContentHiderActive
testID={testID} testID={testID}
modui={modui} modui={modui}
style={style} style={[style, activeStyle]}
childContainerStyle={childContainerStyle}> childContainerStyle={childContainerStyle}>
{children} {typeof children === 'function' ? children({active: true}) : children}
</ContentHiderActive> </ContentHiderActive>
) )
} }
+161 -296
View File
@@ -1,50 +1,33 @@
import React from 'react' import {type StyleProp, View, type ViewStyle} from 'react-native'
import { import {
Linking, type $Typed,
Pressable, AppBskyFeedDefs,
StyleProp, type AppBskyGraphDefs,
StyleSheet, AtUri,
View, } from '@atproto/api'
ViewStyle,
} from 'react-native'
import {AtUri} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Plural, Trans} from '@lingui/macro' import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
import {usePalette} from '#/lib/hooks/usePalette'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {s} from '#/lib/styles'
import {logger} from '#/logger'
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
import { import {
useAddSavedFeedsMutation, type FeedSourceInfo,
usePreferencesQuery, hydrateFeedGenerator,
UsePreferencesQueryResponse, hydrateList,
useRemoveFeedMutation, useFeedSourceInfoQuery,
} from '#/state/queries/preferences' } from '#/state/queries/feed'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {atoms as a} from '#/alf' import {Link} from '#/components/Link'
import {shouldClickOpenNewTab} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import {Text} from '../util/text/Text' import {Text} from '#/components/Typography'
import {UserAvatar} from '../util/UserAvatar' import {MissingFeed} from './MissingFeed'
export function FeedSourceCard({ type FeedSourceCardProps = {
feedUri,
style,
showSaveBtn = false,
showDescription = false,
showLikes = false,
pinOnSave = false,
showMinimalPlaceholder,
hideTopBorder,
}: {
feedUri: string feedUri: string
feedData?:
| $Typed<AppBskyFeedDefs.GeneratorView>
| $Typed<AppBskyGraphDefs.ListView>
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
showSaveBtn?: boolean showSaveBtn?: boolean
showDescription?: boolean showDescription?: boolean
@@ -52,22 +35,41 @@ export function FeedSourceCard({
pinOnSave?: boolean pinOnSave?: boolean
showMinimalPlaceholder?: boolean showMinimalPlaceholder?: boolean
hideTopBorder?: boolean hideTopBorder?: boolean
}) { link?: boolean
const {data: preferences} = usePreferencesQuery() }
const {data: feed} = useFeedSourceInfoQuery({uri: feedUri})
export function FeedSourceCard({
feedUri,
feedData,
...props
}: FeedSourceCardProps) {
if (feedData) {
let feed: FeedSourceInfo
if (AppBskyFeedDefs.isGeneratorView(feedData)) {
feed = hydrateFeedGenerator(feedData)
} else {
feed = hydrateList(feedData)
}
return <FeedSourceCardLoaded feedUri={feedUri} feed={feed} {...props} />
} else {
return <FeedSourceCardWithoutData feedUri={feedUri} {...props} />
}
}
export function FeedSourceCardWithoutData({
feedUri,
...props
}: Omit<FeedSourceCardProps, 'feedData'>) {
const {data: feed, error} = useFeedSourceInfoQuery({
uri: feedUri,
})
return ( return (
<FeedSourceCardLoaded <FeedSourceCardLoaded
feedUri={feedUri} feedUri={feedUri}
feed={feed} feed={feed}
preferences={preferences} error={error}
style={style} {...props}
showSaveBtn={showSaveBtn}
showDescription={showDescription}
showLikes={showLikes}
pinOnSave={pinOnSave}
showMinimalPlaceholder={showMinimalPlaceholder}
hideTopBorder={hideTopBorder}
/> />
) )
} }
@@ -75,80 +77,26 @@ export function FeedSourceCard({
export function FeedSourceCardLoaded({ export function FeedSourceCardLoaded({
feedUri, feedUri,
feed, feed,
preferences,
style, style,
showSaveBtn = false,
showDescription = false, showDescription = false,
showLikes = false, showLikes = false,
pinOnSave = false,
showMinimalPlaceholder, showMinimalPlaceholder,
hideTopBorder, hideTopBorder,
link = true,
error,
}: { }: {
feedUri: string feedUri: string
feed?: FeedSourceInfo feed?: FeedSourceInfo
preferences?: UsePreferencesQueryResponse
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
showSaveBtn?: boolean
showDescription?: boolean showDescription?: boolean
showLikes?: boolean showLikes?: boolean
pinOnSave?: boolean
showMinimalPlaceholder?: boolean showMinimalPlaceholder?: boolean
hideTopBorder?: boolean hideTopBorder?: boolean
link?: boolean
error?: unknown
}) { }) {
const t = useTheme() const t = useTheme()
const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const removePromptControl = Prompt.usePromptControl()
const navigation = useNavigationDeduped()
const {isPending: isAddSavedFeedPending, mutateAsync: addSavedFeeds} =
useAddSavedFeedsMutation()
const {isPending: isRemovePending, mutateAsync: removeFeed} =
useRemoveFeedMutation()
const savedFeedConfig = preferences?.savedFeeds?.find(
f => f.value === feedUri,
)
const isSaved = Boolean(savedFeedConfig)
const onSave = React.useCallback(async () => {
if (!feed || isSaved) return
try {
await addSavedFeeds([
{
type: 'feed',
value: feed.uri,
pinned: pinOnSave,
},
])
Toast.show(_(msg`Added to my feeds`))
} catch (e) {
Toast.show(_(msg`There was an issue contacting your server`), 'xmark')
logger.error('Failed to save feed', {message: e})
}
}, [_, feed, pinOnSave, addSavedFeeds, isSaved])
const onUnsave = React.useCallback(async () => {
if (!savedFeedConfig) return
try {
await removeFeed(savedFeedConfig)
// await item.unsave()
Toast.show(_(msg`Removed from my feeds`))
} catch (e) {
Toast.show(_(msg`There was an issue contacting your server`), 'xmark')
logger.error('Failed to unsave feed', {message: e})
}
}, [_, removeFeed, savedFeedConfig])
const onToggleSaved = React.useCallback(async () => {
if (isSaved) {
removePromptControl.open()
} else {
await onSave()
}
}, [isSaved, removePromptControl, onSave])
/* /*
* LOAD STATE * LOAD STATE
@@ -156,200 +104,117 @@ export function FeedSourceCardLoaded({
* This state also captures the scenario where a feed can't load for whatever * This state also captures the scenario where a feed can't load for whatever
* reason. * reason.
*/ */
if (!feed || !preferences) if (!feed) {
if (error) {
return (
<MissingFeed
uri={feedUri}
style={style}
hideTopBorder={hideTopBorder}
error={error}
/>
)
}
return (
<FeedLoadingPlaceholder
style={[
t.atoms.border_contrast_low,
!(showMinimalPlaceholder || hideTopBorder) && a.border_t,
a.flex_1,
style,
]}
showTopBorder={false}
showLowerPlaceholder={!showMinimalPlaceholder}
/>
)
}
const inner = (
<>
<View style={[a.flex_row, a.align_center]}>
<View style={[a.mr_md]}>
<UserAvatar type="algo" size={36} avatar={feed.avatar} />
</View>
<View style={[a.flex_1]}>
<Text
emoji
style={[a.text_sm, a.font_bold, a.leading_snug]}
numberOfLines={1}>
{feed.displayName}
</Text>
<Text
style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}
numberOfLines={1}>
{feed.type === 'feed' ? (
<Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
) : (
<Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
)}
</Text>
</View>
</View>
{showDescription && feed.description ? (
<RichText
style={[t.atoms.text_contrast_high, a.flex_1, a.flex_wrap]}
value={feed.description}
numberOfLines={3}
/>
) : null}
{showLikes && feed.type === 'feed' ? (
<Text
style={[
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
a.leading_snug,
]}>
<Trans>
Liked by{' '}
<Plural value={feed.likeCount || 0} one="# user" other="# users" />
</Trans>
</Text>
) : null}
</>
)
if (link) {
return (
<Link
testID={`feed-${feed.displayName}`}
label={_(
feed.type === 'feed'
? msg`${feed.displayName}, a feed by ${sanitizeHandle(feed.creatorHandle, '@')}, liked by ${feed.likeCount || 0}`
: msg`${feed.displayName}, a list by ${sanitizeHandle(feed.creatorHandle, '@')}`,
)}
to={{
screen: feed.type === 'feed' ? 'ProfileFeed' : 'ProfileList',
params: {name: feed.creatorDid, rkey: new AtUri(feed.uri).rkey},
}}
style={[
a.flex_1,
a.p_lg,
a.gap_md,
!hideTopBorder && !a.border_t,
t.atoms.border_contrast_low,
style,
]}>
{inner}
</Link>
)
} else {
return ( return (
<View <View
style={[ style={[
pal.border, a.flex_1,
{ a.p_lg,
borderTopWidth: a.gap_md,
showMinimalPlaceholder || hideTopBorder !hideTopBorder && !a.border_t,
? 0 t.atoms.border_contrast_low,
: StyleSheet.hairlineWidth, style,
flexDirection: 'row',
alignItems: 'center',
flex: 1,
paddingRight: 18,
},
]}> ]}>
{showMinimalPlaceholder ? ( {inner}
<FeedLoadingPlaceholder
style={{flex: 1}}
showTopBorder={false}
showLowerPlaceholder={false}
/>
) : (
<FeedLoadingPlaceholder style={{flex: 1}} showTopBorder={false} />
)}
{showSaveBtn && (
<Pressable
testID={`feed-${feedUri}-toggleSave`}
disabled={isRemovePending}
accessibilityRole="button"
accessibilityLabel={_(msg`Remove from my feeds`)}
accessibilityHint=""
onPress={onUnsave}
hitSlop={15}
style={styles.btn}>
<FontAwesomeIcon
icon={['far', 'trash-can']}
size={19}
color={pal.colors.icon}
/>
</Pressable>
)}
</View> </View>
) )
}
return (
<>
<Pressable
testID={`feed-${feed.displayName}`}
accessibilityRole="button"
style={[
styles.container,
pal.border,
style,
{borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth},
]}
onPress={e => {
const shouldOpenInNewTab = shouldClickOpenNewTab(e)
if (feed.type === 'feed') {
if (shouldOpenInNewTab) {
Linking.openURL(
`/profile/${feed.creatorDid}/feed/${new AtUri(feed.uri).rkey}`,
)
} else {
navigation.push('ProfileFeed', {
name: feed.creatorDid,
rkey: new AtUri(feed.uri).rkey,
})
}
} else if (feed.type === 'list') {
if (shouldOpenInNewTab) {
Linking.openURL(
`/profile/${feed.creatorDid}/lists/${new AtUri(feed.uri).rkey}`,
)
} else {
navigation.push('ProfileList', {
name: feed.creatorDid,
rkey: new AtUri(feed.uri).rkey,
})
}
}
}}
key={feed.uri}>
<View style={[styles.headerContainer, a.align_center]}>
<View style={[s.mr10]}>
<UserAvatar type="algo" size={36} avatar={feed.avatar} />
</View>
<View style={[styles.headerTextContainer]}>
<Text emoji style={[pal.text, s.bold]} numberOfLines={1}>
{feed.displayName}
</Text>
<Text style={[pal.textLight]} numberOfLines={1}>
{feed.type === 'feed' ? (
<Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
) : (
<Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
)}
</Text>
</View>
{showSaveBtn && (
<View style={{alignSelf: 'center'}}>
<Pressable
testID={`feed-${feed.displayName}-toggleSave`}
disabled={isAddSavedFeedPending || 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>
{showDescription && feed.description ? (
<RichText
style={[t.atoms.text_contrast_high, styles.description]}
value={feed.description}
numberOfLines={3}
/>
) : null}
{showLikes && feed.type === 'feed' ? (
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
<Trans>
Liked by{' '}
<Plural
value={feed.likeCount || 0}
one="# user"
other="# users"
/>
</Trans>
</Text>
) : null}
</Pressable>
<Prompt.Basic
control={removePromptControl}
title={_(msg`Remove from your feeds?`)}
description={_(
msg`Are you sure you want to remove ${feed.displayName} from your feeds?`,
)}
onConfirm={onUnsave}
confirmButtonCta={_(msg`Remove`)}
confirmButtonColor="negative"
/>
</>
)
} }
const styles = StyleSheet.create({
container: {
paddingHorizontal: 18,
paddingVertical: 20,
flexDirection: 'column',
flex: 1,
gap: 14,
},
border: {
borderTopWidth: StyleSheet.hairlineWidth,
},
headerContainer: {
flexDirection: 'row',
},
headerTextContainer: {
flexDirection: 'column',
columnGap: 4,
flex: 1,
},
description: {
flex: 1,
flexWrap: 'wrap',
},
btn: {
paddingVertical: 6,
},
})
+222
View File
@@ -0,0 +1,222 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {isNative, isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {getFeedTypeFromUri} from '#/state/queries/feed'
import {useProfileQuery} from '#/state/queries/profile'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
export function MissingFeed({
style,
hideTopBorder,
uri,
error,
}: {
style?: StyleProp<ViewStyle>
hideTopBorder?: boolean
uri: string
error?: unknown
}) {
const t = useTheme()
const {_} = useLingui()
const control = Dialog.useDialogControl()
const type = getFeedTypeFromUri(uri)
return (
<>
<Button
label={
type === 'feed'
? _(msg`Could not connect to custom feed`)
: _(msg`Deleted list`)
}
accessibilityHint={_(msg`Tap for more information`)}
onPress={control.open}
style={[
a.flex_1,
a.p_lg,
a.gap_md,
!hideTopBorder && !a.border_t,
t.atoms.border_contrast_low,
a.justify_start,
style,
]}>
<View style={[a.flex_row, a.align_center]}>
<View
style={[
{width: 36, height: 36},
t.atoms.bg_contrast_25,
a.rounded_sm,
a.mr_md,
a.align_center,
a.justify_center,
]}>
<WarningIcon size="lg" />
</View>
<View style={[a.flex_1]}>
<Text
emoji
style={[a.text_sm, a.font_bold, a.leading_snug, a.italic]}
numberOfLines={1}>
{type === 'feed' ? (
<Trans>Feed unavailable</Trans>
) : (
<Trans>Deleted list</Trans>
)}
</Text>
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
a.leading_snug,
a.italic,
]}
numberOfLines={1}>
{isWeb ? (
<Trans>Click for information</Trans>
) : (
<Trans>Tap for information</Trans>
)}
</Text>
</View>
</View>
</Button>
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<DialogInner uri={uri} type={type} error={error} />
</Dialog.Outer>
</>
)
}
function DialogInner({
uri,
type,
error,
}: {
uri: string
type: 'feed' | 'list'
error: unknown
}) {
const control = Dialog.useDialogContext()
const t = useTheme()
const {_} = useLingui()
const atUri = new AtUri(uri)
const {data: profile, isError: isProfileError} = useProfileQuery({
did: atUri.host,
})
const moderationOpts = useModerationOpts()
return (
<Dialog.ScrollableInner
label={
type === 'feed'
? _(msg`Unavailable feed information`)
: _(msg`Deleted list`)
}
style={web({maxWidth: 500})}>
<View style={[a.gap_sm]}>
<Text style={[a.font_heavy, a.text_2xl]}>
{type === 'feed' ? (
<Trans>Could not connect to feed service</Trans>
) : (
<Trans>Deleted list</Trans>
)}
</Text>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
{type === 'feed' ? (
<Trans>
We could not connect to the service that provides this custom
feed. It may be temporarily unavailable and experiencing issues,
or permanently unavailable.
</Trans>
) : (
<Trans>We could not find this list. It was probably deleted.</Trans>
)}
</Text>
<Divider style={[a.my_md]} />
<Text style={[a.font_bold, t.atoms.text_contrast_high]}>
{type === 'feed' ? (
<Trans>Feed creator</Trans>
) : (
<Trans>List creator</Trans>
)}
</Text>
{profile && moderationOpts && (
<View style={[a.w_full, a.align_start]}>
<ProfileCard.Link profile={profile} onPress={() => control.close()}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
</ProfileCard.Link>
</View>
)}
{isProfileError && (
<Text
style={[
t.atoms.text_contrast_high,
a.italic,
a.text_center,
a.w_full,
]}>
<Trans>Could not find profile</Trans>
</Text>
)}
{type === 'feed' && (
<>
<Text style={[a.font_bold, t.atoms.text_contrast_high, a.mt_md]}>
<Trans>Feed identifier</Trans>
</Text>
<Text style={[a.text_md, t.atoms.text_contrast_high, a.italic]}>
{atUri.rkey}
</Text>
</>
)}
{error instanceof Error && (
<>
<Text style={[a.font_bold, t.atoms.text_contrast_high, a.mt_md]}>
<Trans>Error message</Trans>
</Text>
<Text style={[a.text_md, t.atoms.text_contrast_high, a.italic]}>
{cleanError(error.message)}
</Text>
</>
)}
</View>
{isNative && (
<Button
label={_(msg`Close`)}
onPress={() => control.close()}
size="small"
variant="solid"
color="secondary"
style={[a.mt_5xl]}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
)}
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
@@ -671,10 +671,12 @@ let NotificationFeedItem = ({
{item.type === 'feedgen-like' && item.subjectUri ? ( {item.type === 'feedgen-like' && item.subjectUri ? (
<FeedSourceCard <FeedSourceCard
feedUri={item.subjectUri} feedUri={item.subjectUri}
link={false}
style={[ style={[
t.atoms.bg, t.atoms.bg,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
a.border, a.border,
a.p_md,
styles.feedcard, styles.feedcard,
]} ]}
showLikes showLikes
@@ -1000,7 +1002,6 @@ const styles = StyleSheet.create({
}, },
feedcard: { feedcard: {
borderRadius: 8, borderRadius: 8,
paddingVertical: 12,
marginTop: 6, marginTop: 6,
}, },
addedContainer: { addedContainer: {
+7 -14
View File
@@ -1,10 +1,10 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import { import {
DimensionValue, type DimensionValue,
StyleProp, type StyleProp,
StyleSheet, StyleSheet,
View, View,
ViewStyle, type ViewStyle,
} from 'react-native' } from 'react-native'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
@@ -233,8 +233,7 @@ export function FeedLoadingPlaceholder({
<View <View
style={[ style={[
{ {
paddingHorizontal: 12, padding: 16,
paddingVertical: 18,
borderTopWidth: showTopBorder ? StyleSheet.hairlineWidth : 0, borderTopWidth: showTopBorder ? StyleSheet.hairlineWidth : 0,
}, },
pal.border, pal.border,
@@ -244,7 +243,7 @@ export function FeedLoadingPlaceholder({
<LoadingPlaceholder <LoadingPlaceholder
width={36} width={36}
height={36} height={36}
style={[styles.avatar, {borderRadius: 6}]} style={[styles.avatar, {borderRadius: 8}]}
/> />
<View style={[s.flex1]}> <View style={[s.flex1]}>
<LoadingPlaceholder width={100} height={8} style={[s.mt5, s.mb10]} /> <LoadingPlaceholder width={100} height={8} style={[s.mt5, s.mb10]} />
@@ -252,12 +251,7 @@ export function FeedLoadingPlaceholder({
</View> </View>
</View> </View>
{showLowerPlaceholder && ( {showLowerPlaceholder && (
<View style={{paddingHorizontal: 5, marginTop: 10}}> <View style={{marginTop: 12}}>
<LoadingPlaceholder
width={260}
height={8}
style={{marginVertical: 12}}
/>
<LoadingPlaceholder width={120} height={8} /> <LoadingPlaceholder width={120} height={8} />
</View> </View>
)} )}
@@ -352,8 +346,7 @@ const styles = StyleSheet.create({
}, },
avatar: { avatar: {
borderRadius: 999, borderRadius: 999,
marginRight: 10, marginRight: 12,
marginLeft: 8,
}, },
notification: { notification: {
flexDirection: 'row', flexDirection: 'row',
+7 -16
View File
@@ -36,6 +36,7 @@ import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/compon
import {FloppyDisk_Stroke2_Corner0_Rounded as SaveIcon} from '#/components/icons/FloppyDisk' import {FloppyDisk_Stroke2_Corner0_Rounded as SaveIcon} from '#/components/icons/FloppyDisk'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text as NewText} from '#/components/Typography'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
export function SavedFeeds({}: Props) { export function SavedFeeds({}: Props) {
@@ -296,7 +297,7 @@ function ListItem({
<FeedSourceCard <FeedSourceCard
key={feedUri} key={feedUri}
feedUri={feedUri} feedUri={feedUri}
style={[isPinned && {paddingRight: 8}]} style={[isPinned && a.pr_sm]}
showMinimalPlaceholder showMinimalPlaceholder
hideTopBorder={true} hideTopBorder={true}
/> />
@@ -391,26 +392,17 @@ function ListItem({
function FollowingFeedCard() { function FollowingFeedCard() {
const t = useTheme() const t = useTheme()
return ( return (
<View <View style={[a.flex_row, a.align_center, a.flex_1, a.p_lg]}>
style={[
a.flex_row,
a.align_center,
a.flex_1,
{
paddingHorizontal: 18,
paddingVertical: 20,
},
]}>
<View <View
style={[ style={[
a.align_center, a.align_center,
a.justify_center, a.justify_center,
a.rounded_sm, a.rounded_sm,
a.mr_md,
{ {
width: 36, width: 36,
height: 36, height: 36,
backgroundColor: t.palette.primary_500, backgroundColor: t.palette.primary_500,
marginRight: 10,
}, },
]}> ]}>
<FilterTimeline <FilterTimeline
@@ -423,11 +415,10 @@ function FollowingFeedCard() {
fill={t.palette.white} fill={t.palette.white}
/> />
</View> </View>
<View <View style={[a.flex_1, a.flex_row, a.gap_sm, a.align_center]}>
style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> <NewText style={[a.text_sm, a.font_bold, a.leading_snug]}>
<Text type="lg-medium" style={[t.atoms.text]} numberOfLines={1}>
<Trans>Following</Trans> <Trans>Following</Trans>
</Text> </NewText>
</View> </View>
</View> </View>
) )