Bluesky app fork with some witchin' additions 💫

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

authored by samuel.fm and committed by

GitHub 07b028ee 1fcd3134

+496 -428
+2 -9
src/components/Divider.tsx
··· 1 1 import {View} from 'react-native' 2 2 3 - import {atoms as a, flatten, useTheme, ViewStyleProp} from '#/alf' 3 + import {atoms as a, useTheme, type ViewStyleProp} from '#/alf' 4 4 5 5 export function Divider({style}: ViewStyleProp) { 6 6 const t = useTheme() 7 7 8 8 return ( 9 - <View 10 - style={[ 11 - a.w_full, 12 - a.border_t, 13 - t.atoms.border_contrast_low, 14 - flatten(style), 15 - ]} 16 - /> 9 + <View style={[a.w_full, a.border_t, t.atoms.border_contrast_low, style]} /> 17 10 ) 18 11 }
+1 -1
src/components/FeedCard.tsx
··· 214 214 export function Likes({count}: {count: number}) { 215 215 const t = useTheme() 216 216 return ( 217 - <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 217 + <Text style={[a.text_sm, t.atoms.text_contrast_medium, a.font_bold]}> 218 218 <Trans> 219 219 Liked by <Plural value={count || 0} one="# user" other="# users" /> 220 220 </Trans>
+7 -5
src/components/ListCard.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 3 import { 4 - AppBskyGraphDefs, 4 + type AppBskyGraphDefs, 5 5 AtUri, 6 6 moderateUserList, 7 - ModerationUI, 7 + type ModerationUI, 8 8 } from '@atproto/api' 9 9 import {msg, Trans} from '@lingui/macro' 10 10 import {useLingui} from '@lingui/react' ··· 22 22 Outer, 23 23 SaveButton, 24 24 } from '#/components/FeedCard' 25 - import {Link as InternalLink, LinkProps} from '#/components/Link' 25 + import {Link as InternalLink, type LinkProps} from '#/components/Link' 26 26 import * as Hider from '#/components/moderation/Hider' 27 27 import {Text} from '#/components/Typography' 28 - import * as bsky from '#/types/bsky' 28 + import type * as bsky from '#/types/bsky' 29 29 30 30 /* 31 31 * This component is based on `FeedCard` and is tightly coupled with that ··· 50 50 showPinButton?: boolean 51 51 } 52 52 53 - export function Default(props: Props) { 53 + export function Default( 54 + props: Props & Omit<LinkProps, 'to' | 'label' | 'children'>, 55 + ) { 54 56 const {view, showPinButton} = props 55 57 const moderationOpts = useModerationOpts() 56 58 const moderation = moderationOpts
+22 -21
src/components/Post/Embed/FeedEmbed.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet} from 'react-native' 1 + import {useMemo} from 'react' 3 2 import {moderateFeedGenerator} from '@atproto/api' 4 3 5 - import {usePalette} from '#/lib/hooks/usePalette' 6 4 import {useModerationOpts} from '#/state/preferences/moderation-opts' 7 - import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' 5 + import {atoms as a, useTheme} from '#/alf' 6 + import * as FeedCard from '#/components/FeedCard' 8 7 import {ContentHider} from '#/components/moderation/ContentHider' 9 8 import {type EmbedType} from '#/types/bsky/post' 10 9 import {type CommonProps} from './types' ··· 14 13 }: CommonProps & { 15 14 embed: EmbedType<'feed'> 16 15 }) { 17 - const pal = usePalette('default') 16 + const t = useTheme() 18 17 return ( 19 - <FeedSourceCard 20 - feedUri={embed.view.uri} 21 - style={[pal.view, pal.border, styles.customFeedOuter]} 22 - showLikes 23 - /> 18 + <FeedCard.Link 19 + view={embed.view} 20 + style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}> 21 + <FeedCard.Outer> 22 + <FeedCard.Header> 23 + <FeedCard.Avatar src={embed.view.avatar} /> 24 + <FeedCard.TitleAndByline 25 + title={embed.view.displayName} 26 + creator={embed.view.creator} 27 + /> 28 + </FeedCard.Header> 29 + <FeedCard.Likes count={embed.view.likeCount || 0} /> 30 + </FeedCard.Outer> 31 + </FeedCard.Link> 24 32 ) 25 33 } 26 34 ··· 30 38 embed: EmbedType<'feed'> 31 39 }) { 32 40 const moderationOpts = useModerationOpts() 33 - const moderation = React.useMemo(() => { 41 + const moderation = useMemo(() => { 34 42 return moderationOpts 35 43 ? moderateFeedGenerator(embed.view, moderationOpts) 36 44 : undefined 37 45 }, [embed.view, moderationOpts]) 38 46 return ( 39 - <ContentHider modui={moderation?.ui('contentList')}> 47 + <ContentHider 48 + modui={moderation?.ui('contentList')} 49 + childContainerStyle={[a.pt_xs]}> 40 50 <FeedEmbed embed={embed} /> 41 51 </ContentHider> 42 52 ) 43 53 } 44 - 45 - const styles = StyleSheet.create({ 46 - customFeedOuter: { 47 - borderWidth: StyleSheet.hairlineWidth, 48 - borderRadius: 8, 49 - paddingHorizontal: 12, 50 - paddingVertical: 12, 51 - }, 52 - })
+9 -8
src/components/Post/Embed/ListEmbed.tsx
··· 1 - import React from 'react' 2 - import {View} from 'react-native' 1 + import {useMemo} from 'react' 3 2 import {moderateUserList} from '@atproto/api' 4 3 5 4 import {useModerationOpts} from '#/state/preferences/moderation-opts' ··· 16 15 }) { 17 16 const t = useTheme() 18 17 return ( 19 - <View 20 - style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}> 21 - <ListCard.Default view={embed.view} /> 22 - </View> 18 + <ListCard.Default 19 + view={embed.view} 20 + style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]} 21 + /> 23 22 ) 24 23 } 25 24 ··· 29 28 embed: EmbedType<'list'> 30 29 }) { 31 30 const moderationOpts = useModerationOpts() 32 - const moderation = React.useMemo(() => { 31 + const moderation = useMemo(() => { 33 32 return moderationOpts 34 33 ? moderateUserList(embed.view, moderationOpts) 35 34 : undefined 36 35 }, [embed.view, moderationOpts]) 37 36 return ( 38 - <ContentHider modui={moderation?.ui('contentList')}> 37 + <ContentHider 38 + modui={moderation?.ui('contentList')} 39 + childContainerStyle={[a.pt_xs]}> 39 40 <ListEmbed embed={embed} /> 40 41 </ContentHider> 41 42 )
+51 -55
src/components/Post/Embed/index.tsx
··· 268 268 const [hover, setHover] = React.useState(false) 269 269 return ( 270 270 <View 271 - onPointerEnter={() => { 272 - setHover(true) 273 - }} 274 - onPointerLeave={() => { 275 - setHover(false) 276 - }}> 271 + style={[a.mt_sm]} 272 + onPointerEnter={() => setHover(true)} 273 + onPointerLeave={() => setHover(false)}> 277 274 <ContentHider 278 275 modui={moderation?.ui('contentList')} 279 - style={[ 280 - a.rounded_md, 281 - a.p_md, 282 - a.mt_sm, 283 - a.border, 284 - t.atoms.border_contrast_low, 285 - style, 286 - ]} 276 + style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]} 277 + activeStyle={[a.p_md, a.pt_sm]} 287 278 childContainerStyle={[a.pt_sm]}> 288 - <SubtleWebHover hover={hover} /> 289 - <Link 290 - hoverStyle={{borderColor: pal.colors.borderLinkHover}} 291 - href={itemHref} 292 - title={itemTitle} 293 - onBeforePress={onBeforePress}> 294 - <View pointerEvents="none"> 295 - <PostMeta 296 - author={quote.author} 297 - moderation={moderation} 298 - showAvatar 299 - postHref={itemHref} 300 - timestamp={quote.indexedAt} 301 - /> 302 - </View> 303 - {moderation ? ( 304 - <PostAlerts 305 - modui={moderation.ui('contentView')} 306 - style={[a.py_xs]} 307 - /> 308 - ) : null} 309 - {richText ? ( 310 - <RichText 311 - value={richText} 312 - style={a.text_md} 313 - numberOfLines={20} 314 - disableLinks 315 - /> 316 - ) : null} 317 - {quote.embed && ( 318 - <Embed 319 - embed={quote.embed} 320 - moderation={moderation} 321 - isWithinQuote={parentIsWithinQuote ?? true} 322 - // already within quote? override nested 323 - allowNestedQuotes={ 324 - parentIsWithinQuote ? false : parentAllowNestedQuotes 325 - } 326 - /> 327 - )} 328 - </Link> 279 + {({active}) => ( 280 + <> 281 + {!active && <SubtleWebHover hover={hover} style={[a.rounded_md]} />} 282 + <Link 283 + style={[!active && a.p_md]} 284 + hoverStyle={{borderColor: pal.colors.borderLinkHover}} 285 + href={itemHref} 286 + title={itemTitle} 287 + onBeforePress={onBeforePress}> 288 + <View pointerEvents="none"> 289 + <PostMeta 290 + author={quote.author} 291 + moderation={moderation} 292 + showAvatar 293 + postHref={itemHref} 294 + timestamp={quote.indexedAt} 295 + /> 296 + </View> 297 + {moderation ? ( 298 + <PostAlerts 299 + modui={moderation.ui('contentView')} 300 + style={[a.py_xs]} 301 + /> 302 + ) : null} 303 + {richText ? ( 304 + <RichText 305 + value={richText} 306 + style={a.text_md} 307 + numberOfLines={20} 308 + disableLinks 309 + /> 310 + ) : null} 311 + {quote.embed && ( 312 + <Embed 313 + embed={quote.embed} 314 + moderation={moderation} 315 + isWithinQuote={parentIsWithinQuote ?? true} 316 + // already within quote? override nested 317 + allowNestedQuotes={ 318 + parentIsWithinQuote ? false : parentAllowNestedQuotes 319 + } 320 + /> 321 + )} 322 + </Link> 323 + </> 324 + )} 329 325 </ContentHider> 330 326 </View> 331 327 )
+8 -5
src/components/moderation/ContentHider.tsx
··· 23 23 modui, 24 24 ignoreMute, 25 25 style, 26 + activeStyle, 26 27 childContainerStyle, 27 28 children, 28 - }: React.PropsWithChildren<{ 29 + }: { 29 30 testID?: string 30 31 modui: ModerationUI | undefined 31 32 ignoreMute?: boolean 32 33 style?: StyleProp<ViewStyle> 34 + activeStyle?: StyleProp<ViewStyle> 33 35 childContainerStyle?: StyleProp<ViewStyle> 34 - }>) { 36 + children?: React.ReactNode | ((props: {active: boolean}) => React.ReactNode) 37 + }) { 35 38 const blur = modui?.blurs[0] 36 39 if (!blur || (ignoreMute && isJustAMute(modui))) { 37 40 return ( 38 41 <View testID={testID} style={style}> 39 - {children} 42 + {typeof children === 'function' ? children({active: false}) : children} 40 43 </View> 41 44 ) 42 45 } ··· 44 47 <ContentHiderActive 45 48 testID={testID} 46 49 modui={modui} 47 - style={style} 50 + style={[style, activeStyle]} 48 51 childContainerStyle={childContainerStyle}> 49 - {children} 52 + {typeof children === 'function' ? children({active: true}) : children} 50 53 </ContentHiderActive> 51 54 ) 52 55 }
+158 -293
src/view/com/feeds/FeedSourceCard.tsx
··· 1 - import React from 'react' 1 + import {type StyleProp, View, type ViewStyle} from 'react-native' 2 2 import { 3 - Linking, 4 - Pressable, 5 - StyleProp, 6 - StyleSheet, 7 - View, 8 - ViewStyle, 9 - } from 'react-native' 10 - import {AtUri} from '@atproto/api' 11 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 3 + type $Typed, 4 + AppBskyFeedDefs, 5 + type AppBskyGraphDefs, 6 + AtUri, 7 + } from '@atproto/api' 12 8 import {msg, Plural, Trans} from '@lingui/macro' 13 9 import {useLingui} from '@lingui/react' 14 10 15 - import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped' 16 - import {usePalette} from '#/lib/hooks/usePalette' 17 11 import {sanitizeHandle} from '#/lib/strings/handles' 18 - import {s} from '#/lib/styles' 19 - import {logger} from '#/logger' 20 - import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' 21 12 import { 22 - useAddSavedFeedsMutation, 23 - usePreferencesQuery, 24 - UsePreferencesQueryResponse, 25 - useRemoveFeedMutation, 26 - } from '#/state/queries/preferences' 13 + type FeedSourceInfo, 14 + hydrateFeedGenerator, 15 + hydrateList, 16 + useFeedSourceInfoQuery, 17 + } from '#/state/queries/feed' 27 18 import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 28 - import * as Toast from '#/view/com/util/Toast' 29 - import {useTheme} from '#/alf' 30 - import {atoms as a} from '#/alf' 31 - import {shouldClickOpenNewTab} from '#/components/Link' 32 - import * as Prompt from '#/components/Prompt' 19 + import {UserAvatar} from '#/view/com/util/UserAvatar' 20 + import {atoms as a, useTheme} from '#/alf' 21 + import {Link} from '#/components/Link' 33 22 import {RichText} from '#/components/RichText' 34 - import {Text} from '../util/text/Text' 35 - import {UserAvatar} from '../util/UserAvatar' 23 + import {Text} from '#/components/Typography' 24 + import {MissingFeed} from './MissingFeed' 36 25 37 - export function FeedSourceCard({ 38 - feedUri, 39 - style, 40 - showSaveBtn = false, 41 - showDescription = false, 42 - showLikes = false, 43 - pinOnSave = false, 44 - showMinimalPlaceholder, 45 - hideTopBorder, 46 - }: { 26 + type FeedSourceCardProps = { 47 27 feedUri: string 28 + feedData?: 29 + | $Typed<AppBskyFeedDefs.GeneratorView> 30 + | $Typed<AppBskyGraphDefs.ListView> 48 31 style?: StyleProp<ViewStyle> 49 32 showSaveBtn?: boolean 50 33 showDescription?: boolean ··· 52 35 pinOnSave?: boolean 53 36 showMinimalPlaceholder?: boolean 54 37 hideTopBorder?: boolean 55 - }) { 56 - const {data: preferences} = usePreferencesQuery() 57 - const {data: feed} = useFeedSourceInfoQuery({uri: feedUri}) 38 + link?: boolean 39 + } 40 + 41 + export function FeedSourceCard({ 42 + feedUri, 43 + feedData, 44 + ...props 45 + }: FeedSourceCardProps) { 46 + if (feedData) { 47 + let feed: FeedSourceInfo 48 + if (AppBskyFeedDefs.isGeneratorView(feedData)) { 49 + feed = hydrateFeedGenerator(feedData) 50 + } else { 51 + feed = hydrateList(feedData) 52 + } 53 + return <FeedSourceCardLoaded feedUri={feedUri} feed={feed} {...props} /> 54 + } else { 55 + return <FeedSourceCardWithoutData feedUri={feedUri} {...props} /> 56 + } 57 + } 58 + 59 + export function FeedSourceCardWithoutData({ 60 + feedUri, 61 + ...props 62 + }: Omit<FeedSourceCardProps, 'feedData'>) { 63 + const {data: feed, error} = useFeedSourceInfoQuery({ 64 + uri: feedUri, 65 + }) 58 66 59 67 return ( 60 68 <FeedSourceCardLoaded 61 69 feedUri={feedUri} 62 70 feed={feed} 63 - preferences={preferences} 64 - style={style} 65 - showSaveBtn={showSaveBtn} 66 - showDescription={showDescription} 67 - showLikes={showLikes} 68 - pinOnSave={pinOnSave} 69 - showMinimalPlaceholder={showMinimalPlaceholder} 70 - hideTopBorder={hideTopBorder} 71 + error={error} 72 + {...props} 71 73 /> 72 74 ) 73 75 } ··· 75 77 export function FeedSourceCardLoaded({ 76 78 feedUri, 77 79 feed, 78 - preferences, 79 80 style, 80 - showSaveBtn = false, 81 81 showDescription = false, 82 82 showLikes = false, 83 - pinOnSave = false, 84 83 showMinimalPlaceholder, 85 84 hideTopBorder, 85 + link = true, 86 + error, 86 87 }: { 87 88 feedUri: string 88 89 feed?: FeedSourceInfo 89 - preferences?: UsePreferencesQueryResponse 90 90 style?: StyleProp<ViewStyle> 91 - showSaveBtn?: boolean 92 91 showDescription?: boolean 93 92 showLikes?: boolean 94 - pinOnSave?: boolean 95 93 showMinimalPlaceholder?: boolean 96 94 hideTopBorder?: boolean 95 + link?: boolean 96 + error?: unknown 97 97 }) { 98 98 const t = useTheme() 99 - const pal = usePalette('default') 100 99 const {_} = useLingui() 101 - const removePromptControl = Prompt.usePromptControl() 102 - const navigation = useNavigationDeduped() 103 - 104 - const {isPending: isAddSavedFeedPending, mutateAsync: addSavedFeeds} = 105 - useAddSavedFeedsMutation() 106 - const {isPending: isRemovePending, mutateAsync: removeFeed} = 107 - useRemoveFeedMutation() 108 - 109 - const savedFeedConfig = preferences?.savedFeeds?.find( 110 - f => f.value === feedUri, 111 - ) 112 - const isSaved = Boolean(savedFeedConfig) 113 - 114 - const onSave = React.useCallback(async () => { 115 - if (!feed || isSaved) return 116 - 117 - try { 118 - await addSavedFeeds([ 119 - { 120 - type: 'feed', 121 - value: feed.uri, 122 - pinned: pinOnSave, 123 - }, 124 - ]) 125 - Toast.show(_(msg`Added to my feeds`)) 126 - } catch (e) { 127 - Toast.show(_(msg`There was an issue contacting your server`), 'xmark') 128 - logger.error('Failed to save feed', {message: e}) 129 - } 130 - }, [_, feed, pinOnSave, addSavedFeeds, isSaved]) 131 - 132 - const onUnsave = React.useCallback(async () => { 133 - if (!savedFeedConfig) return 134 - 135 - try { 136 - await removeFeed(savedFeedConfig) 137 - // await item.unsave() 138 - Toast.show(_(msg`Removed from my feeds`)) 139 - } catch (e) { 140 - Toast.show(_(msg`There was an issue contacting your server`), 'xmark') 141 - logger.error('Failed to unsave feed', {message: e}) 142 - } 143 - }, [_, removeFeed, savedFeedConfig]) 144 - 145 - const onToggleSaved = React.useCallback(async () => { 146 - if (isSaved) { 147 - removePromptControl.open() 148 - } else { 149 - await onSave() 150 - } 151 - }, [isSaved, removePromptControl, onSave]) 152 100 153 101 /* 154 102 * LOAD STATE ··· 156 104 * This state also captures the scenario where a feed can't load for whatever 157 105 * reason. 158 106 */ 159 - if (!feed || !preferences) 107 + if (!feed) { 108 + if (error) { 109 + return ( 110 + <MissingFeed 111 + uri={feedUri} 112 + style={style} 113 + hideTopBorder={hideTopBorder} 114 + error={error} 115 + /> 116 + ) 117 + } 118 + 160 119 return ( 161 - <View 120 + <FeedLoadingPlaceholder 162 121 style={[ 163 - pal.border, 164 - { 165 - borderTopWidth: 166 - showMinimalPlaceholder || hideTopBorder 167 - ? 0 168 - : StyleSheet.hairlineWidth, 169 - flexDirection: 'row', 170 - alignItems: 'center', 171 - flex: 1, 172 - paddingRight: 18, 173 - }, 174 - ]}> 175 - {showMinimalPlaceholder ? ( 176 - <FeedLoadingPlaceholder 177 - style={{flex: 1}} 178 - showTopBorder={false} 179 - showLowerPlaceholder={false} 180 - /> 181 - ) : ( 182 - <FeedLoadingPlaceholder style={{flex: 1}} showTopBorder={false} /> 183 - )} 184 - 185 - {showSaveBtn && ( 186 - <Pressable 187 - testID={`feed-${feedUri}-toggleSave`} 188 - disabled={isRemovePending} 189 - accessibilityRole="button" 190 - accessibilityLabel={_(msg`Remove from my feeds`)} 191 - accessibilityHint="" 192 - onPress={onUnsave} 193 - hitSlop={15} 194 - style={styles.btn}> 195 - <FontAwesomeIcon 196 - icon={['far', 'trash-can']} 197 - size={19} 198 - color={pal.colors.icon} 199 - /> 200 - </Pressable> 201 - )} 202 - </View> 122 + t.atoms.border_contrast_low, 123 + !(showMinimalPlaceholder || hideTopBorder) && a.border_t, 124 + a.flex_1, 125 + style, 126 + ]} 127 + showTopBorder={false} 128 + showLowerPlaceholder={!showMinimalPlaceholder} 129 + /> 203 130 ) 131 + } 204 132 205 - return ( 133 + const inner = ( 206 134 <> 207 - <Pressable 208 - testID={`feed-${feed.displayName}`} 209 - accessibilityRole="button" 210 - style={[ 211 - styles.container, 212 - pal.border, 213 - style, 214 - {borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth}, 215 - ]} 216 - onPress={e => { 217 - const shouldOpenInNewTab = shouldClickOpenNewTab(e) 218 - if (feed.type === 'feed') { 219 - if (shouldOpenInNewTab) { 220 - Linking.openURL( 221 - `/profile/${feed.creatorDid}/feed/${new AtUri(feed.uri).rkey}`, 222 - ) 223 - } else { 224 - navigation.push('ProfileFeed', { 225 - name: feed.creatorDid, 226 - rkey: new AtUri(feed.uri).rkey, 227 - }) 228 - } 229 - } else if (feed.type === 'list') { 230 - if (shouldOpenInNewTab) { 231 - Linking.openURL( 232 - `/profile/${feed.creatorDid}/lists/${new AtUri(feed.uri).rkey}`, 233 - ) 234 - } else { 235 - navigation.push('ProfileList', { 236 - name: feed.creatorDid, 237 - rkey: new AtUri(feed.uri).rkey, 238 - }) 239 - } 240 - } 241 - }} 242 - key={feed.uri}> 243 - <View style={[styles.headerContainer, a.align_center]}> 244 - <View style={[s.mr10]}> 245 - <UserAvatar type="algo" size={36} avatar={feed.avatar} /> 246 - </View> 247 - <View style={[styles.headerTextContainer]}> 248 - <Text emoji style={[pal.text, s.bold]} numberOfLines={1}> 249 - {feed.displayName} 250 - </Text> 251 - <Text style={[pal.textLight]} numberOfLines={1}> 252 - {feed.type === 'feed' ? ( 253 - <Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans> 254 - ) : ( 255 - <Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans> 256 - )} 257 - </Text> 258 - </View> 259 - 260 - {showSaveBtn && ( 261 - <View style={{alignSelf: 'center'}}> 262 - <Pressable 263 - testID={`feed-${feed.displayName}-toggleSave`} 264 - disabled={isAddSavedFeedPending || isRemovePending} 265 - accessibilityRole="button" 266 - accessibilityLabel={ 267 - isSaved 268 - ? _(msg`Remove from my feeds`) 269 - : _(msg`Add to my feeds`) 270 - } 271 - accessibilityHint="" 272 - onPress={onToggleSaved} 273 - hitSlop={15} 274 - style={styles.btn}> 275 - {isSaved ? ( 276 - <FontAwesomeIcon 277 - icon={['far', 'trash-can']} 278 - size={19} 279 - color={pal.colors.icon} 280 - /> 281 - ) : ( 282 - <FontAwesomeIcon 283 - icon="plus" 284 - size={18} 285 - color={pal.colors.link} 286 - /> 287 - )} 288 - </Pressable> 289 - </View> 290 - )} 135 + <View style={[a.flex_row, a.align_center]}> 136 + <View style={[a.mr_md]}> 137 + <UserAvatar type="algo" size={36} avatar={feed.avatar} /> 291 138 </View> 292 - 293 - {showDescription && feed.description ? ( 294 - <RichText 295 - style={[t.atoms.text_contrast_high, styles.description]} 296 - value={feed.description} 297 - numberOfLines={3} 298 - /> 299 - ) : null} 300 - 301 - {showLikes && feed.type === 'feed' ? ( 302 - <Text type="sm-medium" style={[pal.text, pal.textLight]}> 303 - <Trans> 304 - Liked by{' '} 305 - <Plural 306 - value={feed.likeCount || 0} 307 - one="# user" 308 - other="# users" 309 - /> 310 - </Trans> 139 + <View style={[a.flex_1]}> 140 + <Text 141 + emoji 142 + style={[a.text_sm, a.font_bold, a.leading_snug]} 143 + numberOfLines={1}> 144 + {feed.displayName} 145 + </Text> 146 + <Text 147 + style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]} 148 + numberOfLines={1}> 149 + {feed.type === 'feed' ? ( 150 + <Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans> 151 + ) : ( 152 + <Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans> 153 + )} 311 154 </Text> 312 - ) : null} 313 - </Pressable> 314 - 315 - <Prompt.Basic 316 - control={removePromptControl} 317 - title={_(msg`Remove from your feeds?`)} 318 - description={_( 319 - msg`Are you sure you want to remove ${feed.displayName} from your feeds?`, 320 - )} 321 - onConfirm={onUnsave} 322 - confirmButtonCta={_(msg`Remove`)} 323 - confirmButtonColor="negative" 324 - /> 155 + </View> 156 + </View> 157 + {showDescription && feed.description ? ( 158 + <RichText 159 + style={[t.atoms.text_contrast_high, a.flex_1, a.flex_wrap]} 160 + value={feed.description} 161 + numberOfLines={3} 162 + /> 163 + ) : null} 164 + {showLikes && feed.type === 'feed' ? ( 165 + <Text 166 + style={[ 167 + a.text_sm, 168 + a.font_bold, 169 + t.atoms.text_contrast_medium, 170 + a.leading_snug, 171 + ]}> 172 + <Trans> 173 + Liked by{' '} 174 + <Plural value={feed.likeCount || 0} one="# user" other="# users" /> 175 + </Trans> 176 + </Text> 177 + ) : null} 325 178 </> 326 179 ) 327 - } 328 180 329 - const styles = StyleSheet.create({ 330 - container: { 331 - paddingHorizontal: 18, 332 - paddingVertical: 20, 333 - flexDirection: 'column', 334 - flex: 1, 335 - gap: 14, 336 - }, 337 - border: { 338 - borderTopWidth: StyleSheet.hairlineWidth, 339 - }, 340 - headerContainer: { 341 - flexDirection: 'row', 342 - }, 343 - headerTextContainer: { 344 - flexDirection: 'column', 345 - columnGap: 4, 346 - flex: 1, 347 - }, 348 - description: { 349 - flex: 1, 350 - flexWrap: 'wrap', 351 - }, 352 - btn: { 353 - paddingVertical: 6, 354 - }, 355 - }) 181 + if (link) { 182 + return ( 183 + <Link 184 + testID={`feed-${feed.displayName}`} 185 + label={_( 186 + feed.type === 'feed' 187 + ? msg`${feed.displayName}, a feed by ${sanitizeHandle(feed.creatorHandle, '@')}, liked by ${feed.likeCount || 0}` 188 + : msg`${feed.displayName}, a list by ${sanitizeHandle(feed.creatorHandle, '@')}`, 189 + )} 190 + to={{ 191 + screen: feed.type === 'feed' ? 'ProfileFeed' : 'ProfileList', 192 + params: {name: feed.creatorDid, rkey: new AtUri(feed.uri).rkey}, 193 + }} 194 + style={[ 195 + a.flex_1, 196 + a.p_lg, 197 + a.gap_md, 198 + !hideTopBorder && !a.border_t, 199 + t.atoms.border_contrast_low, 200 + style, 201 + ]}> 202 + {inner} 203 + </Link> 204 + ) 205 + } else { 206 + return ( 207 + <View 208 + style={[ 209 + a.flex_1, 210 + a.p_lg, 211 + a.gap_md, 212 + !hideTopBorder && !a.border_t, 213 + t.atoms.border_contrast_low, 214 + style, 215 + ]}> 216 + {inner} 217 + </View> 218 + ) 219 + } 220 + }
+222
src/view/com/feeds/MissingFeed.tsx
··· 1 + import {type StyleProp, View, type ViewStyle} from 'react-native' 2 + import {AtUri} from '@atproto/api' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {cleanError} from '#/lib/strings/errors' 7 + import {isNative, isWeb} from '#/platform/detection' 8 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 9 + import {getFeedTypeFromUri} from '#/state/queries/feed' 10 + import {useProfileQuery} from '#/state/queries/profile' 11 + import {atoms as a, useTheme, web} from '#/alf' 12 + import {Button, ButtonText} from '#/components/Button' 13 + import * as Dialog from '#/components/Dialog' 14 + import {Divider} from '#/components/Divider' 15 + import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 16 + import * as ProfileCard from '#/components/ProfileCard' 17 + import {Text} from '#/components/Typography' 18 + 19 + export function MissingFeed({ 20 + style, 21 + hideTopBorder, 22 + uri, 23 + error, 24 + }: { 25 + style?: StyleProp<ViewStyle> 26 + hideTopBorder?: boolean 27 + uri: string 28 + error?: unknown 29 + }) { 30 + const t = useTheme() 31 + const {_} = useLingui() 32 + const control = Dialog.useDialogControl() 33 + 34 + const type = getFeedTypeFromUri(uri) 35 + 36 + return ( 37 + <> 38 + <Button 39 + label={ 40 + type === 'feed' 41 + ? _(msg`Could not connect to custom feed`) 42 + : _(msg`Deleted list`) 43 + } 44 + accessibilityHint={_(msg`Tap for more information`)} 45 + onPress={control.open} 46 + style={[ 47 + a.flex_1, 48 + a.p_lg, 49 + a.gap_md, 50 + !hideTopBorder && !a.border_t, 51 + t.atoms.border_contrast_low, 52 + a.justify_start, 53 + style, 54 + ]}> 55 + <View style={[a.flex_row, a.align_center]}> 56 + <View 57 + style={[ 58 + {width: 36, height: 36}, 59 + t.atoms.bg_contrast_25, 60 + a.rounded_sm, 61 + a.mr_md, 62 + a.align_center, 63 + a.justify_center, 64 + ]}> 65 + <WarningIcon size="lg" /> 66 + </View> 67 + <View style={[a.flex_1]}> 68 + <Text 69 + emoji 70 + style={[a.text_sm, a.font_bold, a.leading_snug, a.italic]} 71 + numberOfLines={1}> 72 + {type === 'feed' ? ( 73 + <Trans>Feed unavailable</Trans> 74 + ) : ( 75 + <Trans>Deleted list</Trans> 76 + )} 77 + </Text> 78 + <Text 79 + style={[ 80 + a.text_sm, 81 + t.atoms.text_contrast_medium, 82 + a.leading_snug, 83 + a.italic, 84 + ]} 85 + numberOfLines={1}> 86 + {isWeb ? ( 87 + <Trans>Click for information</Trans> 88 + ) : ( 89 + <Trans>Tap for information</Trans> 90 + )} 91 + </Text> 92 + </View> 93 + </View> 94 + </Button> 95 + 96 + <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 97 + <Dialog.Handle /> 98 + <DialogInner uri={uri} type={type} error={error} /> 99 + </Dialog.Outer> 100 + </> 101 + ) 102 + } 103 + 104 + function DialogInner({ 105 + uri, 106 + type, 107 + error, 108 + }: { 109 + uri: string 110 + type: 'feed' | 'list' 111 + error: unknown 112 + }) { 113 + const control = Dialog.useDialogContext() 114 + const t = useTheme() 115 + const {_} = useLingui() 116 + const atUri = new AtUri(uri) 117 + const {data: profile, isError: isProfileError} = useProfileQuery({ 118 + did: atUri.host, 119 + }) 120 + const moderationOpts = useModerationOpts() 121 + 122 + return ( 123 + <Dialog.ScrollableInner 124 + label={ 125 + type === 'feed' 126 + ? _(msg`Unavailable feed information`) 127 + : _(msg`Deleted list`) 128 + } 129 + style={web({maxWidth: 500})}> 130 + <View style={[a.gap_sm]}> 131 + <Text style={[a.font_heavy, a.text_2xl]}> 132 + {type === 'feed' ? ( 133 + <Trans>Could not connect to feed service</Trans> 134 + ) : ( 135 + <Trans>Deleted list</Trans> 136 + )} 137 + </Text> 138 + <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> 139 + {type === 'feed' ? ( 140 + <Trans> 141 + We could not connect to the service that provides this custom 142 + feed. It may be temporarily unavailable and experiencing issues, 143 + or permanently unavailable. 144 + </Trans> 145 + ) : ( 146 + <Trans>We could not find this list. It was probably deleted.</Trans> 147 + )} 148 + </Text> 149 + <Divider style={[a.my_md]} /> 150 + <Text style={[a.font_bold, t.atoms.text_contrast_high]}> 151 + {type === 'feed' ? ( 152 + <Trans>Feed creator</Trans> 153 + ) : ( 154 + <Trans>List creator</Trans> 155 + )} 156 + </Text> 157 + {profile && moderationOpts && ( 158 + <View style={[a.w_full, a.align_start]}> 159 + <ProfileCard.Link profile={profile} onPress={() => control.close()}> 160 + <ProfileCard.Header> 161 + <ProfileCard.Avatar 162 + profile={profile} 163 + moderationOpts={moderationOpts} 164 + disabledPreview 165 + /> 166 + <ProfileCard.NameAndHandle 167 + profile={profile} 168 + moderationOpts={moderationOpts} 169 + /> 170 + </ProfileCard.Header> 171 + </ProfileCard.Link> 172 + </View> 173 + )} 174 + {isProfileError && ( 175 + <Text 176 + style={[ 177 + t.atoms.text_contrast_high, 178 + a.italic, 179 + a.text_center, 180 + a.w_full, 181 + ]}> 182 + <Trans>Could not find profile</Trans> 183 + </Text> 184 + )} 185 + {type === 'feed' && ( 186 + <> 187 + <Text style={[a.font_bold, t.atoms.text_contrast_high, a.mt_md]}> 188 + <Trans>Feed identifier</Trans> 189 + </Text> 190 + <Text style={[a.text_md, t.atoms.text_contrast_high, a.italic]}> 191 + {atUri.rkey} 192 + </Text> 193 + </> 194 + )} 195 + {error instanceof Error && ( 196 + <> 197 + <Text style={[a.font_bold, t.atoms.text_contrast_high, a.mt_md]}> 198 + <Trans>Error message</Trans> 199 + </Text> 200 + <Text style={[a.text_md, t.atoms.text_contrast_high, a.italic]}> 201 + {cleanError(error.message)} 202 + </Text> 203 + </> 204 + )} 205 + </View> 206 + {isNative && ( 207 + <Button 208 + label={_(msg`Close`)} 209 + onPress={() => control.close()} 210 + size="small" 211 + variant="solid" 212 + color="secondary" 213 + style={[a.mt_5xl]}> 214 + <ButtonText> 215 + <Trans>Close</Trans> 216 + </ButtonText> 217 + </Button> 218 + )} 219 + <Dialog.Close /> 220 + </Dialog.ScrollableInner> 221 + ) 222 + }
+2 -1
src/view/com/notifications/NotificationFeedItem.tsx
··· 671 671 {item.type === 'feedgen-like' && item.subjectUri ? ( 672 672 <FeedSourceCard 673 673 feedUri={item.subjectUri} 674 + link={false} 674 675 style={[ 675 676 t.atoms.bg, 676 677 t.atoms.border_contrast_low, 677 678 a.border, 679 + a.p_md, 678 680 styles.feedcard, 679 681 ]} 680 682 showLikes ··· 1000 1002 }, 1001 1003 feedcard: { 1002 1004 borderRadius: 8, 1003 - paddingVertical: 12, 1004 1005 marginTop: 6, 1005 1006 }, 1006 1007 addedContainer: {
+7 -14
src/view/com/util/LoadingPlaceholder.tsx
··· 1 1 import {useMemo} from 'react' 2 2 import { 3 - DimensionValue, 4 - StyleProp, 3 + type DimensionValue, 4 + type StyleProp, 5 5 StyleSheet, 6 6 View, 7 - ViewStyle, 7 + type ViewStyle, 8 8 } from 'react-native' 9 9 10 10 import {usePalette} from '#/lib/hooks/usePalette' ··· 233 233 <View 234 234 style={[ 235 235 { 236 - paddingHorizontal: 12, 237 - paddingVertical: 18, 236 + padding: 16, 238 237 borderTopWidth: showTopBorder ? StyleSheet.hairlineWidth : 0, 239 238 }, 240 239 pal.border, ··· 244 243 <LoadingPlaceholder 245 244 width={36} 246 245 height={36} 247 - style={[styles.avatar, {borderRadius: 6}]} 246 + style={[styles.avatar, {borderRadius: 8}]} 248 247 /> 249 248 <View style={[s.flex1]}> 250 249 <LoadingPlaceholder width={100} height={8} style={[s.mt5, s.mb10]} /> ··· 252 251 </View> 253 252 </View> 254 253 {showLowerPlaceholder && ( 255 - <View style={{paddingHorizontal: 5, marginTop: 10}}> 256 - <LoadingPlaceholder 257 - width={260} 258 - height={8} 259 - style={{marginVertical: 12}} 260 - /> 254 + <View style={{marginTop: 12}}> 261 255 <LoadingPlaceholder width={120} height={8} /> 262 256 </View> 263 257 )} ··· 352 346 }, 353 347 avatar: { 354 348 borderRadius: 999, 355 - marginRight: 10, 356 - marginLeft: 8, 349 + marginRight: 12, 357 350 }, 358 351 notification: { 359 352 flexDirection: 'row',
+7 -16
src/view/screens/SavedFeeds.tsx
··· 36 36 import {FloppyDisk_Stroke2_Corner0_Rounded as SaveIcon} from '#/components/icons/FloppyDisk' 37 37 import * as Layout from '#/components/Layout' 38 38 import {Loader} from '#/components/Loader' 39 + import {Text as NewText} from '#/components/Typography' 39 40 40 41 type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> 41 42 export function SavedFeeds({}: Props) { ··· 296 297 <FeedSourceCard 297 298 key={feedUri} 298 299 feedUri={feedUri} 299 - style={[isPinned && {paddingRight: 8}]} 300 + style={[isPinned && a.pr_sm]} 300 301 showMinimalPlaceholder 301 302 hideTopBorder={true} 302 303 /> ··· 391 392 function FollowingFeedCard() { 392 393 const t = useTheme() 393 394 return ( 394 - <View 395 - style={[ 396 - a.flex_row, 397 - a.align_center, 398 - a.flex_1, 399 - { 400 - paddingHorizontal: 18, 401 - paddingVertical: 20, 402 - }, 403 - ]}> 395 + <View style={[a.flex_row, a.align_center, a.flex_1, a.p_lg]}> 404 396 <View 405 397 style={[ 406 398 a.align_center, 407 399 a.justify_center, 408 400 a.rounded_sm, 401 + a.mr_md, 409 402 { 410 403 width: 36, 411 404 height: 36, 412 405 backgroundColor: t.palette.primary_500, 413 - marginRight: 10, 414 406 }, 415 407 ]}> 416 408 <FilterTimeline ··· 423 415 fill={t.palette.white} 424 416 /> 425 417 </View> 426 - <View 427 - style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> 428 - <Text type="lg-medium" style={[t.atoms.text]} numberOfLines={1}> 418 + <View style={[a.flex_1, a.flex_row, a.gap_sm, a.align_center]}> 419 + <NewText style={[a.text_sm, a.font_bold, a.leading_snug]}> 429 420 <Trans>Following</Trans> 430 - </Text> 421 + </NewText> 431 422 </View> 432 423 </View> 433 424 )