diff options
-rw-r--r-- | src/components/Divider.tsx | 11 | ||||
-rw-r--r-- | src/components/FeedCard.tsx | 2 | ||||
-rw-r--r-- | src/components/ListCard.tsx | 12 | ||||
-rw-r--r-- | src/components/Post/Embed/FeedEmbed.tsx | 43 | ||||
-rw-r--r-- | src/components/Post/Embed/ListEmbed.tsx | 17 | ||||
-rw-r--r-- | src/components/Post/Embed/index.tsx | 106 | ||||
-rw-r--r-- | src/components/moderation/ContentHider.tsx | 13 | ||||
-rw-r--r-- | src/view/com/feeds/FeedSourceCard.tsx | 451 | ||||
-rw-r--r-- | src/view/com/feeds/MissingFeed.tsx | 222 | ||||
-rw-r--r-- | src/view/com/notifications/NotificationFeedItem.tsx | 3 | ||||
-rw-r--r-- | src/view/com/util/LoadingPlaceholder.tsx | 21 | ||||
-rw-r--r-- | src/view/screens/SavedFeeds.tsx | 23 |
12 files changed, 496 insertions, 428 deletions
diff --git a/src/components/Divider.tsx b/src/components/Divider.tsx index e4891aacb..ec7a7356a 100644 --- a/src/components/Divider.tsx +++ b/src/components/Divider.tsx @@ -1,18 +1,11 @@ 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) { const t = useTheme() return ( - <View - style={[ - a.w_full, - a.border_t, - t.atoms.border_contrast_low, - flatten(style), - ]} - /> + <View style={[a.w_full, a.border_t, t.atoms.border_contrast_low, style]} /> ) } diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx index f94692e5b..33905dacd 100644 --- a/src/components/FeedCard.tsx +++ b/src/components/FeedCard.tsx @@ -214,7 +214,7 @@ export function DescriptionPlaceholder() { export function Likes({count}: {count: number}) { const t = useTheme() 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> Liked by <Plural value={count || 0} one="# user" other="# users" /> </Trans> diff --git a/src/components/ListCard.tsx b/src/components/ListCard.tsx index 30156ee0d..e34830ea9 100644 --- a/src/components/ListCard.tsx +++ b/src/components/ListCard.tsx @@ -1,10 +1,10 @@ import React from 'react' import {View} from 'react-native' import { - AppBskyGraphDefs, + type AppBskyGraphDefs, AtUri, moderateUserList, - ModerationUI, + type ModerationUI, } from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -22,10 +22,10 @@ import { Outer, SaveButton, } 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 {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 @@ -50,7 +50,9 @@ type Props = { showPinButton?: boolean } -export function Default(props: Props) { +export function Default( + props: Props & Omit<LinkProps, 'to' | 'label' | 'children'>, +) { const {view, showPinButton} = props const moderationOpts = useModerationOpts() const moderation = moderationOpts diff --git a/src/components/Post/Embed/FeedEmbed.tsx b/src/components/Post/Embed/FeedEmbed.tsx index fad4cd4d8..47d59e346 100644 --- a/src/components/Post/Embed/FeedEmbed.tsx +++ b/src/components/Post/Embed/FeedEmbed.tsx @@ -1,10 +1,9 @@ -import React from 'react' -import {StyleSheet} from 'react-native' +import {useMemo} from 'react' import {moderateFeedGenerator} from '@atproto/api' -import {usePalette} from '#/lib/hooks/usePalette' 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 {type EmbedType} from '#/types/bsky/post' import {type CommonProps} from './types' @@ -14,13 +13,22 @@ export function FeedEmbed({ }: CommonProps & { embed: EmbedType<'feed'> }) { - const pal = usePalette('default') + const t = useTheme() return ( - <FeedSourceCard - feedUri={embed.view.uri} - style={[pal.view, pal.border, styles.customFeedOuter]} - showLikes - /> + <FeedCard.Link + view={embed.view} + style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}> + <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'> }) { const moderationOpts = useModerationOpts() - const moderation = React.useMemo(() => { + const moderation = useMemo(() => { return moderationOpts ? moderateFeedGenerator(embed.view, moderationOpts) : undefined }, [embed.view, moderationOpts]) return ( - <ContentHider modui={moderation?.ui('contentList')}> + <ContentHider + modui={moderation?.ui('contentList')} + childContainerStyle={[a.pt_xs]}> <FeedEmbed embed={embed} /> </ContentHider> ) } - -const styles = StyleSheet.create({ - customFeedOuter: { - borderWidth: StyleSheet.hairlineWidth, - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 12, - }, -}) diff --git a/src/components/Post/Embed/ListEmbed.tsx b/src/components/Post/Embed/ListEmbed.tsx index 82685d271..c1450bdcf 100644 --- a/src/components/Post/Embed/ListEmbed.tsx +++ b/src/components/Post/Embed/ListEmbed.tsx @@ -1,5 +1,4 @@ -import React from 'react' -import {View} from 'react-native' +import {useMemo} from 'react' import {moderateUserList} from '@atproto/api' import {useModerationOpts} from '#/state/preferences/moderation-opts' @@ -16,10 +15,10 @@ export function ListEmbed({ }) { const t = useTheme() return ( - <View - style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}> - <ListCard.Default view={embed.view} /> - </View> + <ListCard.Default + view={embed.view} + style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]} + /> ) } @@ -29,13 +28,15 @@ export function ModeratedListEmbed({ embed: EmbedType<'list'> }) { const moderationOpts = useModerationOpts() - const moderation = React.useMemo(() => { + const moderation = useMemo(() => { return moderationOpts ? moderateUserList(embed.view, moderationOpts) : undefined }, [embed.view, moderationOpts]) return ( - <ContentHider modui={moderation?.ui('contentList')}> + <ContentHider + modui={moderation?.ui('contentList')} + childContainerStyle={[a.pt_xs]}> <ListEmbed embed={embed} /> </ContentHider> ) diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index ace85dc98..9c5444b27 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -268,64 +268,60 @@ export function QuoteEmbed({ const [hover, setHover] = React.useState(false) return ( <View - onPointerEnter={() => { - setHover(true) - }} - onPointerLeave={() => { - setHover(false) - }}> + style={[a.mt_sm]} + onPointerEnter={() => setHover(true)} + onPointerLeave={() => setHover(false)}> <ContentHider modui={moderation?.ui('contentList')} - style={[ - a.rounded_md, - a.p_md, - a.mt_sm, - a.border, - t.atoms.border_contrast_low, - style, - ]} + style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]} + activeStyle={[a.p_md, a.pt_sm]} childContainerStyle={[a.pt_sm]}> - <SubtleWebHover hover={hover} /> - <Link - hoverStyle={{borderColor: pal.colors.borderLinkHover}} - href={itemHref} - title={itemTitle} - onBeforePress={onBeforePress}> - <View pointerEvents="none"> - <PostMeta - author={quote.author} - moderation={moderation} - showAvatar - postHref={itemHref} - timestamp={quote.indexedAt} - /> - </View> - {moderation ? ( - <PostAlerts - modui={moderation.ui('contentView')} - style={[a.py_xs]} - /> - ) : null} - {richText ? ( - <RichText - value={richText} - style={a.text_md} - numberOfLines={20} - disableLinks - /> - ) : null} - {quote.embed && ( - <Embed - embed={quote.embed} - moderation={moderation} - isWithinQuote={parentIsWithinQuote ?? true} - // already within quote? override nested - allowNestedQuotes={ - parentIsWithinQuote ? false : parentAllowNestedQuotes - } - /> - )} - </Link> + {({active}) => ( + <> + {!active && <SubtleWebHover hover={hover} style={[a.rounded_md]} />} + <Link + style={[!active && a.p_md]} + hoverStyle={{borderColor: pal.colors.borderLinkHover}} + href={itemHref} + title={itemTitle} + onBeforePress={onBeforePress}> + <View pointerEvents="none"> + <PostMeta + author={quote.author} + moderation={moderation} + showAvatar + postHref={itemHref} + timestamp={quote.indexedAt} + /> + </View> + {moderation ? ( + <PostAlerts + modui={moderation.ui('contentView')} + style={[a.py_xs]} + /> + ) : null} + {richText ? ( + <RichText + value={richText} + style={a.text_md} + numberOfLines={20} + disableLinks + /> + ) : null} + {quote.embed && ( + <Embed + embed={quote.embed} + moderation={moderation} + isWithinQuote={parentIsWithinQuote ?? true} + // already within quote? override nested + allowNestedQuotes={ + parentIsWithinQuote ? false : parentAllowNestedQuotes + } + /> + )} + </Link> + </> + )} </ContentHider> </View> ) diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx index 9e94a413c..549a1b9f0 100644 --- a/src/components/moderation/ContentHider.tsx +++ b/src/components/moderation/ContentHider.tsx @@ -23,20 +23,23 @@ export function ContentHider({ modui, ignoreMute, style, + activeStyle, childContainerStyle, children, -}: React.PropsWithChildren<{ +}: { testID?: string modui: ModerationUI | undefined ignoreMute?: boolean style?: StyleProp<ViewStyle> + activeStyle?: StyleProp<ViewStyle> childContainerStyle?: StyleProp<ViewStyle> -}>) { + children?: React.ReactNode | ((props: {active: boolean}) => React.ReactNode) +}) { const blur = modui?.blurs[0] if (!blur || (ignoreMute && isJustAMute(modui))) { return ( <View testID={testID} style={style}> - {children} + {typeof children === 'function' ? children({active: false}) : children} </View> ) } @@ -44,9 +47,9 @@ export function ContentHider({ <ContentHiderActive testID={testID} modui={modui} - style={style} + style={[style, activeStyle]} childContainerStyle={childContainerStyle}> - {children} + {typeof children === 'function' ? children({active: true}) : children} </ContentHiderActive> ) } diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 3a658755a..18e2807a8 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -1,50 +1,33 @@ -import React from 'react' +import {type StyleProp, View, type ViewStyle} from 'react-native' import { - Linking, - Pressable, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native' -import {AtUri} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' + type $Typed, + AppBskyFeedDefs, + type AppBskyGraphDefs, + AtUri, +} from '@atproto/api' import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped' -import {usePalette} from '#/lib/hooks/usePalette' import {sanitizeHandle} from '#/lib/strings/handles' -import {s} from '#/lib/styles' -import {logger} from '#/logger' -import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' import { - useAddSavedFeedsMutation, - usePreferencesQuery, - UsePreferencesQueryResponse, - useRemoveFeedMutation, -} from '#/state/queries/preferences' + type FeedSourceInfo, + hydrateFeedGenerator, + hydrateList, + useFeedSourceInfoQuery, +} from '#/state/queries/feed' 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 {shouldClickOpenNewTab} from '#/components/Link' -import * as Prompt from '#/components/Prompt' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme} from '#/alf' +import {Link} from '#/components/Link' import {RichText} from '#/components/RichText' -import {Text} from '../util/text/Text' -import {UserAvatar} from '../util/UserAvatar' +import {Text} from '#/components/Typography' +import {MissingFeed} from './MissingFeed' -export function FeedSourceCard({ - feedUri, - style, - showSaveBtn = false, - showDescription = false, - showLikes = false, - pinOnSave = false, - showMinimalPlaceholder, - hideTopBorder, -}: { +type FeedSourceCardProps = { feedUri: string + feedData?: + | $Typed<AppBskyFeedDefs.GeneratorView> + | $Typed<AppBskyGraphDefs.ListView> style?: StyleProp<ViewStyle> showSaveBtn?: boolean showDescription?: boolean @@ -52,22 +35,41 @@ export function FeedSourceCard({ pinOnSave?: boolean showMinimalPlaceholder?: boolean hideTopBorder?: boolean -}) { - const {data: preferences} = usePreferencesQuery() - const {data: feed} = useFeedSourceInfoQuery({uri: feedUri}) + link?: boolean +} + +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 ( <FeedSourceCardLoaded feedUri={feedUri} feed={feed} - preferences={preferences} - style={style} - showSaveBtn={showSaveBtn} - showDescription={showDescription} - showLikes={showLikes} - pinOnSave={pinOnSave} - showMinimalPlaceholder={showMinimalPlaceholder} - hideTopBorder={hideTopBorder} + error={error} + {...props} /> ) } @@ -75,80 +77,26 @@ export function FeedSourceCard({ export function FeedSourceCardLoaded({ feedUri, feed, - preferences, style, - showSaveBtn = false, showDescription = false, showLikes = false, - pinOnSave = false, showMinimalPlaceholder, hideTopBorder, + link = true, + error, }: { feedUri: string feed?: FeedSourceInfo - preferences?: UsePreferencesQueryResponse style?: StyleProp<ViewStyle> - showSaveBtn?: boolean showDescription?: boolean showLikes?: boolean - pinOnSave?: boolean showMinimalPlaceholder?: boolean hideTopBorder?: boolean + link?: boolean + error?: unknown }) { const t = useTheme() - const pal = usePalette('default') 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 @@ -156,200 +104,117 @@ export function FeedSourceCardLoaded({ * This state also captures the scenario where a feed can't load for whatever * reason. */ - if (!feed || !preferences) - return ( - <View - style={[ - pal.border, - { - borderTopWidth: - showMinimalPlaceholder || hideTopBorder - ? 0 - : StyleSheet.hairlineWidth, - flexDirection: 'row', - alignItems: 'center', - flex: 1, - paddingRight: 18, - }, - ]}> - {showMinimalPlaceholder ? ( - <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> - ) + if (!feed) { + if (error) { + return ( + <MissingFeed + uri={feedUri} + style={style} + hideTopBorder={hideTopBorder} + error={error} + /> + ) + } - return ( - <> - <Pressable - testID={`feed-${feed.displayName}`} - accessibilityRole="button" + return ( + <FeedLoadingPlaceholder style={[ - styles.container, - pal.border, + t.atoms.border_contrast_low, + !(showMinimalPlaceholder || hideTopBorder) && a.border_t, + a.flex_1, 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> + showTopBorder={false} + showLowerPlaceholder={!showMinimalPlaceholder} + /> + ) + } - {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> - )} + const inner = ( + <> + <View style={[a.flex_row, a.align_center]}> + <View style={[a.mr_md]}> + <UserAvatar type="algo" size={36} avatar={feed.avatar} /> </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> + <View style={[a.flex_1]}> + <Text + emoji + style={[a.text_sm, a.font_bold, a.leading_snug]} + numberOfLines={1}> + {feed.displayName} </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" - /> + <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} </> ) -} -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, - }, -}) + 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 ( + <View + style={[ + a.flex_1, + a.p_lg, + a.gap_md, + !hideTopBorder && !a.border_t, + t.atoms.border_contrast_low, + style, + ]}> + {inner} + </View> + ) + } +} diff --git a/src/view/com/feeds/MissingFeed.tsx b/src/view/com/feeds/MissingFeed.tsx new file mode 100644 index 000000000..3d281a731 --- /dev/null +++ b/src/view/com/feeds/MissingFeed.tsx @@ -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> + ) +} diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index 89e2d20e7..4de21e598 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -671,10 +671,12 @@ let NotificationFeedItem = ({ {item.type === 'feedgen-like' && item.subjectUri ? ( <FeedSourceCard feedUri={item.subjectUri} + link={false} style={[ t.atoms.bg, t.atoms.border_contrast_low, a.border, + a.p_md, styles.feedcard, ]} showLikes @@ -1000,7 +1002,6 @@ const styles = StyleSheet.create({ }, feedcard: { borderRadius: 8, - paddingVertical: 12, marginTop: 6, }, addedContainer: { diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx index eee642df3..1b454598b 100644 --- a/src/view/com/util/LoadingPlaceholder.tsx +++ b/src/view/com/util/LoadingPlaceholder.tsx @@ -1,10 +1,10 @@ import {useMemo} from 'react' import { - DimensionValue, - StyleProp, + type DimensionValue, + type StyleProp, StyleSheet, View, - ViewStyle, + type ViewStyle, } from 'react-native' import {usePalette} from '#/lib/hooks/usePalette' @@ -233,8 +233,7 @@ export function FeedLoadingPlaceholder({ <View style={[ { - paddingHorizontal: 12, - paddingVertical: 18, + padding: 16, borderTopWidth: showTopBorder ? StyleSheet.hairlineWidth : 0, }, pal.border, @@ -244,7 +243,7 @@ export function FeedLoadingPlaceholder({ <LoadingPlaceholder width={36} height={36} - style={[styles.avatar, {borderRadius: 6}]} + style={[styles.avatar, {borderRadius: 8}]} /> <View style={[s.flex1]}> <LoadingPlaceholder width={100} height={8} style={[s.mt5, s.mb10]} /> @@ -252,12 +251,7 @@ export function FeedLoadingPlaceholder({ </View> </View> {showLowerPlaceholder && ( - <View style={{paddingHorizontal: 5, marginTop: 10}}> - <LoadingPlaceholder - width={260} - height={8} - style={{marginVertical: 12}} - /> + <View style={{marginTop: 12}}> <LoadingPlaceholder width={120} height={8} /> </View> )} @@ -352,8 +346,7 @@ const styles = StyleSheet.create({ }, avatar: { borderRadius: 999, - marginRight: 10, - marginLeft: 8, + marginRight: 12, }, notification: { flexDirection: 'row', diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index 9ae3dbd8d..b244c7558 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -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 * as Layout from '#/components/Layout' import {Loader} from '#/components/Loader' +import {Text as NewText} from '#/components/Typography' type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> export function SavedFeeds({}: Props) { @@ -296,7 +297,7 @@ function ListItem({ <FeedSourceCard key={feedUri} feedUri={feedUri} - style={[isPinned && {paddingRight: 8}]} + style={[isPinned && a.pr_sm]} showMinimalPlaceholder hideTopBorder={true} /> @@ -391,26 +392,17 @@ function ListItem({ function FollowingFeedCard() { const t = useTheme() return ( - <View - style={[ - a.flex_row, - a.align_center, - a.flex_1, - { - paddingHorizontal: 18, - paddingVertical: 20, - }, - ]}> + <View style={[a.flex_row, a.align_center, a.flex_1, a.p_lg]}> <View style={[ a.align_center, a.justify_center, a.rounded_sm, + a.mr_md, { width: 36, height: 36, backgroundColor: t.palette.primary_500, - marginRight: 10, }, ]}> <FilterTimeline @@ -423,11 +415,10 @@ function FollowingFeedCard() { fill={t.palette.white} /> </View> - <View - style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> - <Text type="lg-medium" style={[t.atoms.text]} numberOfLines={1}> + <View style={[a.flex_1, a.flex_row, a.gap_sm, a.align_center]}> + <NewText style={[a.text_sm, a.font_bold, a.leading_snug]}> <Trans>Following</Trans> - </Text> + </NewText> </View> </View> ) |