Lists updates: curate lists and blocklists (#1689)

* Add lists screen

* Update Lists screen and List create/edit modal to support curate lists

* Rework the ProfileList screen and add curatelist support

* More ProfileList progress

* Update list modals

* Rename mutelists to modlists

* Layout updates/fixes

* More layout fixes

* Modal fixes

* List list screen updates

* Update feed page to give more info

* Layout fixes to ListAddUser modal

* Layout fixes to FlatList and Feed on desktop

* Layout fix to LoadLatestBtn on Web

* Handle did resolution before showing the ProfileList screen

* Rename the CustomFeed routes to ProfileFeed for consistency

* Fix layout issues with the pager and feeds

* Factor out some common code

* Fix UIs for mobile

* Fix user list rendering

* Fix: dont bubble custom feed errors in the merge feed

* Refactor feed models to reduce usage of the SavedFeeds model

* Replace CustomFeedModel with FeedSourceModel which abstracts feed-generators and lists

* Add the ability to pin lists

* Add pinned lists to mobile

* Remove dead code

* Rework the ProfileScreenHeader to create more real-estate for action buttons

* Improve layout behavior on web mobile breakpoints

* Refactor feed & list pages to use new Tabs layout component

* Refactor to ProfileSubpageHeader

* Implement modlist block and mute

* Switch to new api and just modify state on modlist actions

* Fix some UI overflows

* Fix: dont show edit buttons on lists you dont own

* Fix alignment issue on long titles

* Improve loading and error states for feeds & lists

* Update list dropdown icons for ios

* Fetch feed display names in the mergefeed

* Improve rendering off offline feeds in the feed-listing page

* Update Feeds listing UI to react to changes in saved/pinned state

* Refresh list and feed on posts tab press

* Fix pinned feed ordering UI

* Fixes to list pinning

* Remove view=simple qp

* Add list to feed tuners

* Render richtext

* Add list href

* Add 'view avatar'

* Remove unused import

* Fix missing import

* Correctly reflect block by list state

* Replace the <Tabs> component with the more effective <PagerWithHeader> component

* Improve the responsiveness of the PagerWithHeader

* Fix visual jank in the feed loading state

* Improve performance of the PagerWithHeader

* Fix a case that would cause the header to animate too aggressively

* Add the ability to scroll to top by tapping the selected tab

* Fix unit test runner

* Update modlists test

* Add curatelist tests

* Fix: remove link behavior in ListAddUser modal

* Fix some layout jank in the PagerWithHeader on iOS

* Simplify ListItems header rendering

* Wait for the appview to recognize the list before proceeding with list creation

* Fix glitch in the onPageSelecting index of the Pager

* Fix until()

* Copy fix

Co-authored-by: Eric Bailey <git@esb.lol>

---------

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Paul Frazee 2023-11-01 16:15:40 -07:00 committed by GitHub
parent f9944b55e2
commit f57a8cf8ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
87 changed files with 4090 additions and 1988 deletions

View file

@ -1,495 +0,0 @@
import React, {useMemo, useRef} from 'react'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation, useIsFocused} from '@react-navigation/native'
import {usePalette} from 'lib/hooks/usePalette'
import {HeartIcon, HeartIconSolid} from 'lib/icons'
import {CommonNavigatorParams} from 'lib/routes/types'
import {makeRecordUri} from 'lib/strings/url-helpers'
import {colors, s} from 'lib/styles'
import {observer} from 'mobx-react-lite'
import {FlatList, StyleSheet, View, ActivityIndicator} from 'react-native'
import {useStores} from 'state/index'
import {PostsFeedModel} from 'state/models/feeds/posts'
import {useCustomFeed} from 'lib/hooks/useCustomFeed'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {Feed} from 'view/com/posts/Feed'
import {TextLink} from 'view/com/util/Link'
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
import {Button} from 'view/com/util/forms/Button'
import {Text} from 'view/com/util/text/Text'
import * as Toast from 'view/com/util/Toast'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {shareUrl} from 'lib/sharing'
import {toShareUrl} from 'lib/strings/url-helpers'
import {Haptics} from 'lib/haptics'
import {ComposeIcon2} from 'lib/icons'
import {FAB} from '../com/util/fab/FAB'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {EmptyState} from 'view/com/util/EmptyState'
import {useAnalytics} from 'lib/analytics/analytics'
import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
import {resolveName} from 'lib/api'
import {CenteredView} from 'view/com/util/Views'
import {NavigationProp} from 'lib/routes/types'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'>
export const CustomFeedScreen = withAuthRequired(
observer(function CustomFeedScreenImpl(props: Props) {
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {name: handleOrDid} = props.route.params
const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>()
const [error, setError] = React.useState<string | undefined>()
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
React.useEffect(() => {
/*
* We must resolve the DID of the feed owner before we can fetch the feed.
*/
async function fetchDid() {
try {
const did = await resolveName(store, handleOrDid)
setFeedOwnerDid(did)
} catch (e) {
setError(
`We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`,
)
}
}
fetchDid()
}, [store, handleOrDid, setFeedOwnerDid])
if (error) {
return (
<CenteredView>
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
<Text type="title-lg" style={[pal.text, s.mb10]}>
Could not load feed
</Text>
<Text type="md" style={[pal.text, s.mb20]}>
{error}
</Text>
<View style={{flexDirection: 'row'}}>
<Button
type="default"
accessibilityLabel="Go Back"
accessibilityHint="Return to previous page"
onPress={onPressBack}
style={{flexShrink: 1}}>
<Text type="button" style={pal.text}>
Go Back
</Text>
</Button>
</View>
</View>
</CenteredView>
)
}
return feedOwnerDid ? (
<CustomFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} />
) : (
<CenteredView>
<View style={s.p20}>
<ActivityIndicator size="large" />
</View>
</CenteredView>
)
}),
)
export const CustomFeedScreenInner = observer(
function CustomFeedScreenInnerImpl({
route,
feedOwnerDid,
}: Props & {feedOwnerDid: string}) {
const store = useStores()
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const navigation = useNavigation<NavigationProp>()
const isScreenFocused = useIsFocused()
const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
const {track} = useAnalytics()
const {rkey, name: handleOrDid} = route.params
const uri = useMemo(
() => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey),
[rkey, feedOwnerDid],
)
const scrollElRef = useRef<FlatList>(null)
const currentFeed = useCustomFeed(uri)
const algoFeed: PostsFeedModel = useMemo(() => {
const feed = new PostsFeedModel(store, 'custom', {
feed: uri,
})
feed.setup()
return feed
}, [store, uri])
const isPinned = store.me.savedFeeds.isPinned(uri)
const [onMainScroll, isScrolledDown, resetMainScroll] =
useOnMainScroll(store)
useSetTitle(currentFeed?.displayName)
const onToggleSaved = React.useCallback(async () => {
try {
Haptics.default()
if (currentFeed?.isSaved) {
await currentFeed?.unsave()
} else {
await currentFeed?.save()
}
} catch (err) {
Toast.show(
'There was an an issue updating your feeds, please check your internet connection and try again.',
)
store.log.error('Failed up update feeds', {err})
}
}, [store, currentFeed])
const onToggleLiked = React.useCallback(async () => {
Haptics.default()
try {
if (currentFeed?.isLiked) {
await currentFeed?.unlike()
} else {
await currentFeed?.like()
}
} catch (err) {
Toast.show(
'There was an an issue contacting the server, please check your internet connection and try again.',
)
store.log.error('Failed up toggle like', {err})
}
}, [store, currentFeed])
const onTogglePinned = React.useCallback(async () => {
Haptics.default()
store.me.savedFeeds.togglePinnedFeed(currentFeed!).catch(e => {
Toast.show('There was an issue contacting the server')
store.log.error('Failed to toggle pinned feed', {e})
})
}, [store, currentFeed])
const onPressAbout = React.useCallback(() => {
store.shell.openModal({
name: 'confirm',
title: currentFeed?.displayName || '',
message:
currentFeed?.data.description || 'This feed has no description.',
confirmBtnText: 'Close',
onPressConfirm() {},
})
}, [store, currentFeed])
const onPressViewAuthor = React.useCallback(() => {
navigation.navigate('Profile', {name: handleOrDid})
}, [handleOrDid, navigation])
const onPressShare = React.useCallback(() => {
const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`)
shareUrl(url)
track('CustomFeed:Share')
}, [handleOrDid, rkey, track])
const onPressReport = React.useCallback(() => {
if (!currentFeed) return
store.shell.openModal({
name: 'report',
uri: currentFeed.uri,
cid: currentFeed.data.cid,
})
}, [store, currentFeed])
const onScrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: 0, animated: true})
resetMainScroll()
}, [scrollElRef, resetMainScroll])
const onPressCompose = React.useCallback(() => {
store.shell.openComposer({})
}, [store])
const onSoftReset = React.useCallback(() => {
if (isScreenFocused) {
onScrollToTop()
algoFeed.refresh()
}
}, [isScreenFocused, onScrollToTop, algoFeed])
// fires when page within screen is activated/deactivated
React.useEffect(() => {
if (!isScreenFocused) {
return
}
const softResetSub = store.onScreenSoftReset(onSoftReset)
return () => {
softResetSub.remove()
}
}, [store, onSoftReset, isScreenFocused])
const dropdownItems: DropdownItem[] = React.useMemo(() => {
return [
currentFeed
? {
testID: 'feedHeaderDropdownAboutBtn',
label: 'About this feed',
onPress: onPressAbout,
icon: {
ios: {
name: 'info.circle',
},
android: '',
web: 'info',
},
}
: undefined,
{
testID: 'feedHeaderDropdownViewAuthorBtn',
label: 'View author',
onPress: onPressViewAuthor,
icon: {
ios: {
name: 'person',
},
android: '',
web: ['far', 'user'],
},
},
{
testID: 'feedHeaderDropdownToggleSavedBtn',
label: currentFeed?.isSaved
? 'Remove from my feeds'
: 'Add to my feeds',
onPress: onToggleSaved,
icon: currentFeed?.isSaved
? {
ios: {
name: 'trash',
},
android: 'ic_delete',
web: 'trash',
}
: {
ios: {
name: 'plus',
},
android: '',
web: 'plus',
},
},
{
testID: 'feedHeaderDropdownReportBtn',
label: 'Report feed',
onPress: onPressReport,
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
},
{
testID: 'feedHeaderDropdownShareBtn',
label: 'Share link',
onPress: onPressShare,
icon: {
ios: {
name: 'square.and.arrow.up',
},
android: 'ic_menu_share',
web: 'share',
},
},
].filter(Boolean) as DropdownItem[]
}, [
currentFeed,
onPressAbout,
onToggleSaved,
onPressReport,
onPressShare,
onPressViewAuthor,
])
const renderEmptyState = React.useCallback(() => {
return (
<View style={[pal.border, {borderTopWidth: 1, paddingTop: 20}]}>
<EmptyState icon="feed" message="This feed is empty!" />
</View>
)
}, [pal.border])
return (
<View style={s.hContentRegion}>
<SimpleViewHeader
showBackButton={isMobile}
style={
!isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
}>
<Text type="title-lg" style={styles.headerText} numberOfLines={1}>
{currentFeed ? (
<TextLink
type="title-lg"
href="/"
style={[pal.text, {fontWeight: 'bold'}]}
text={currentFeed?.displayName || ''}
onPress={() => store.emitScreenSoftReset()}
/>
) : (
'Loading...'
)}
</Text>
{currentFeed ? (
<>
<Button
type="default-light"
testID="toggleLikeBtn"
accessibilityLabel="Like this feed"
accessibilityHint=""
onPress={onToggleLiked}
style={styles.headerBtn}>
{currentFeed?.isLiked ? (
<HeartIconSolid size={19} style={styles.liked} />
) : (
<HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
)}
</Button>
{currentFeed?.isSaved ? (
<Button
type="default-light"
accessibilityLabel={
isPinned ? 'Unpin this feed' : 'Pin this feed'
}
accessibilityHint=""
onPress={onTogglePinned}
style={styles.headerBtn}>
<FontAwesomeIcon
icon="thumb-tack"
size={17}
color={isPinned ? colors.blue3 : pal.colors.textLight}
style={styles.top1}
/>
</Button>
) : (
<Button
type="inverted"
onPress={onToggleSaved}
accessibilityLabel="Add to my feeds"
accessibilityHint=""
style={styles.headerAddBtn}>
<FontAwesomeIcon
icon="plus"
color={palInverted.colors.text}
size={19}
/>
<Text type="button" style={palInverted.text}>
Add{!isMobile && ' to My Feeds'}
</Text>
</Button>
)}
</>
) : null}
<NativeDropdown
testID="feedHeaderDropdownBtn"
items={dropdownItems}
accessibilityLabel="More options"
accessibilityHint="">
<View
style={{
paddingLeft: 12,
paddingRight: isMobile ? 12 : 0,
}}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
color={pal.colors.textLight}
/>
</View>
</NativeDropdown>
</SimpleViewHeader>
<Feed
scrollElRef={scrollElRef}
feed={algoFeed}
onScroll={onMainScroll}
scrollEventThrottle={100}
renderEmptyState={renderEmptyState}
extraData={[uri, isPinned]}
style={!isTabletOrDesktop ? {flex: 1} : undefined}
/>
{isScrolledDown ? (
<LoadLatestBtn
onPress={onSoftReset}
label="Scroll to top"
showIndicator={false}
/>
) : null}
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel="New post"
accessibilityHint=""
/>
</View>
)
},
)
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
gap: 12,
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 16,
borderTopWidth: 1,
},
headerText: {
flex: 1,
fontWeight: 'bold',
},
headerBtn: {
paddingVertical: 0,
},
headerAddBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingVertical: 4,
paddingLeft: 10,
},
liked: {
color: colors.red3,
},
top1: {
position: 'relative',
top: 1,
},
top2: {
position: 'relative',
top: 2,
},
notFoundContainer: {
margin: 10,
paddingHorizontal: 18,
paddingVertical: 14,
borderRadius: 6,
},
})

View file

@ -2,7 +2,6 @@ import React from 'react'
import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {AtUri} from '@atproto/api'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {FAB} from 'view/com/util/fab/FAB'
@ -24,9 +23,10 @@ import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import debounce from 'lodash.debounce'
import {Text} from 'view/com/util/text/Text'
import {MyFeedsUIModel, MyFeedsItem} from 'state/models/ui/my-feeds'
import {FeedSourceModel} from 'state/models/content/feed-source'
import {FlatList} from 'view/com/util/Views'
import {useFocusEffect} from '@react-navigation/native'
import {CustomFeed} from 'view/com/feeds/CustomFeed'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
export const FeedsScreen = withAuthRequired(
@ -52,6 +52,10 @@ export const FeedsScreen = withAuthRequired(
}
}, [store, myFeeds]),
)
React.useEffect(() => {
// watch for changes to saved/pinned feeds
return myFeeds.registerListeners()
}, [myFeeds])
const onPressCompose = React.useCallback(() => {
store.shell.openComposer({})
@ -139,13 +143,7 @@ export const FeedsScreen = withAuthRequired(
</>
)
} else if (item.type === 'saved-feed') {
return (
<SavedFeed
uri={item.feed.uri}
avatar={item.feed.data.avatar}
displayName={item.feed.displayName}
/>
)
return <SavedFeed feed={item.feed} />
} else if (item.type === 'discover-feeds-header') {
return (
<>
@ -187,7 +185,7 @@ export const FeedsScreen = withAuthRequired(
)
} else if (item.type === 'discover-feed') {
return (
<CustomFeed
<FeedSourceCard
item={item.feed}
showSaveBtn
showDescription
@ -257,33 +255,43 @@ export const FeedsScreen = withAuthRequired(
}),
)
function SavedFeed({
uri,
avatar,
displayName,
}: {
uri: string
avatar: string | undefined
displayName: string
}) {
function SavedFeed({feed}: {feed: FeedSourceModel}) {
const pal = usePalette('default')
const urip = new AtUri(uri)
const href = `/profile/${urip.hostname}/feed/${urip.rkey}`
const {isMobile} = useWebMediaQueries()
return (
<Link
testID={`saved-feed-${displayName}`}
href={href}
testID={`saved-feed-${feed.displayName}`}
href={feed.href}
style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]}
hoverStyle={pal.viewLight}
accessibilityLabel={displayName}
accessibilityLabel={feed.displayName}
accessibilityHint=""
asAnchor
anchorNoUnderline>
<UserAvatar type="algo" size={28} avatar={avatar} />
<Text type="lg-medium" style={[pal.text, s.flex1]} numberOfLines={1}>
{displayName}
</Text>
{feed.error ? (
<View
style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}>
<FontAwesomeIcon
icon="exclamation-circle"
color={pal.colors.textLight}
/>
</View>
) : (
<UserAvatar type="algo" size={28} avatar={feed.avatar} />
)}
<View
style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}>
<Text type="lg-medium" style={pal.text} numberOfLines={1}>
{feed.displayName}
</Text>
{feed.error && (
<View style={[styles.offlineSlug, pal.borderDark]}>
<Text type="xs" style={pal.textLight}>
Feed offline
</Text>
</View>
)}
</View>
{isMobile && (
<FontAwesomeIcon
icon="chevron-right"
@ -342,4 +350,10 @@ const styles = StyleSheet.create({
savedFeedMobile: {
paddingVertical: 10,
},
offlineSlug: {
borderWidth: 1,
borderRadius: 4,
paddingHorizontal: 4,
paddingVertical: 2,
},
})

View file

@ -1,7 +1,6 @@
import React from 'react'
import {useWindowDimensions} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import isEqual from 'lodash.isequal'
import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
@ -30,29 +29,29 @@ export const HomeScreen = withAuthRequired(
>([])
React.useEffect(() => {
const {pinned} = store.me.savedFeeds
const pinned = store.preferences.pinnedFeeds
if (
isEqual(
pinned.map(p => p.uri),
requestedCustomFeeds,
)
) {
if (isEqual(pinned, requestedCustomFeeds)) {
// no changes
return
}
const feeds = []
for (const feed of pinned) {
const model = new PostsFeedModel(store, 'custom', {feed: feed.uri})
feeds.push(model)
for (const uri of pinned) {
if (uri.includes('app.bsky.feed.generator')) {
const model = new PostsFeedModel(store, 'custom', {feed: uri})
feeds.push(model)
} else if (uri.includes('app.bsky.graph.list')) {
const model = new PostsFeedModel(store, 'list', {list: uri})
feeds.push(model)
}
}
pagerRef.current?.setPage(0)
setCustomFeeds(feeds)
setRequestedCustomFeeds(pinned.map(p => p.uri))
setRequestedCustomFeeds(pinned)
}, [
store,
store.me.savedFeeds.pinned,
store.preferences.pinnedFeeds,
customFeeds,
setCustomFeeds,
pagerRef,
@ -124,7 +123,7 @@ export const HomeScreen = withAuthRequired(
{customFeeds.map((f, index) => {
return (
<FeedPage
key={(f.params as GetCustomFeed.QueryParams).feed}
key={f.reactKey}
testID="customFeedPage"
isPageFocused={selectedPage === 1 + index}
feed={f}

View file

@ -0,0 +1,92 @@
import React from 'react'
import {View} from 'react-native'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AtUri} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {useStores} from 'state/index'
import {ListsListModel} from 'state/models/lists/lists-list'
import {ListsList} from 'view/com/lists/ListsList'
import {Text} from 'view/com/util/text/Text'
import {Button} from 'view/com/util/forms/Button'
import {NavigationProp} from 'lib/routes/types'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
import {s} from 'lib/styles'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'>
export const ListsScreen = withAuthRequired(
observer(function ListsScreenImpl({}: Props) {
const pal = usePalette('default')
const store = useStores()
const {isMobile} = useWebMediaQueries()
const navigation = useNavigation<NavigationProp>()
const listsLists: ListsListModel = React.useMemo(
() => new ListsListModel(store, 'my-curatelists'),
[store],
)
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
listsLists.refresh()
}, [store, listsLists]),
)
const onPressNewList = React.useCallback(() => {
store.shell.openModal({
name: 'create-or-edit-list',
purpose: 'app.bsky.graph.defs#curatelist',
onSave: (uri: string) => {
try {
const urip = new AtUri(uri)
navigation.navigate('ProfileList', {
name: urip.hostname,
rkey: urip.rkey,
})
} catch {}
},
})
}, [store, navigation])
return (
<View style={s.hContentRegion} testID="listsScreen">
<SimpleViewHeader
showBackButton={isMobile}
style={
!isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
}>
<View style={{flex: 1}}>
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
User Lists
</Text>
<Text style={pal.textLight}>
Public, shareable lists which can drive feeds.
</Text>
</View>
<View>
<Button
testID="newUserListBtn"
type="default"
onPress={onPressNewList}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}>
<FontAwesomeIcon icon="plus" color={pal.colors.text} />
<Text type="button" style={pal.text}>
New
</Text>
</Button>
</View>
</SimpleViewHeader>
<ListsList listsList={listsLists} />
</View>
)
}),
)

View file

@ -66,9 +66,9 @@ export const ModerationScreen = withAuthRequired(
</Text>
</TouchableOpacity>
<Link
testID="mutelistsBtn"
testID="moderationlistsBtn"
style={[styles.linkCard, pal.view]}
href="/moderation/mute-lists">
href="/moderation/modlists">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="users-slash"
@ -76,7 +76,7 @@ export const ModerationScreen = withAuthRequired(
/>
</View>
<Text type="lg" style={pal.text}>
Mute lists
Moderation lists
</Text>
</Link>
<Link

View file

@ -0,0 +1,92 @@
import React from 'react'
import {View} from 'react-native'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AtUri} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {useStores} from 'state/index'
import {ListsListModel} from 'state/models/lists/lists-list'
import {ListsList} from 'view/com/lists/ListsList'
import {Text} from 'view/com/util/text/Text'
import {Button} from 'view/com/util/forms/Button'
import {NavigationProp} from 'lib/routes/types'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
import {s} from 'lib/styles'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ModerationModlists'>
export const ModerationModlistsScreen = withAuthRequired(
observer(function ModerationModlistsScreenImpl({}: Props) {
const pal = usePalette('default')
const store = useStores()
const {isMobile} = useWebMediaQueries()
const navigation = useNavigation<NavigationProp>()
const mutelists: ListsListModel = React.useMemo(
() => new ListsListModel(store, 'my-modlists'),
[store],
)
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
mutelists.refresh()
}, [store, mutelists]),
)
const onPressNewList = React.useCallback(() => {
store.shell.openModal({
name: 'create-or-edit-list',
purpose: 'app.bsky.graph.defs#modlist',
onSave: (uri: string) => {
try {
const urip = new AtUri(uri)
navigation.navigate('ProfileList', {
name: urip.hostname,
rkey: urip.rkey,
})
} catch {}
},
})
}, [store, navigation])
return (
<View style={s.hContentRegion} testID="moderationModlistsScreen">
<SimpleViewHeader
showBackButton={isMobile}
style={
!isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
}>
<View style={{flex: 1}}>
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
Moderation Lists
</Text>
<Text style={pal.textLight}>
Public, shareable lists of users to mute or block in bulk.
</Text>
</View>
<View>
<Button
testID="newModListBtn"
type="default"
onPress={onPressNewList}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}>
<FontAwesomeIcon icon="plus" color={pal.colors.text} />
<Text type="button" style={pal.text}>
New
</Text>
</Button>
</View>
</SimpleViewHeader>
<ListsList listsList={mutelists} />
</View>
)
}),
)

View file

@ -1,124 +0,0 @@
import React from 'react'
import {StyleSheet} from 'react-native'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {AtUri} from '@atproto/api'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {EmptyStateWithButton} from 'view/com/util/EmptyStateWithButton'
import {useStores} from 'state/index'
import {ListsListModel} from 'state/models/lists/lists-list'
import {ListsList} from 'view/com/lists/ListsList'
import {Button} from 'view/com/util/forms/Button'
import {NavigationProp} from 'lib/routes/types'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {CenteredView} from 'view/com/util/Views'
import {ViewHeader} from 'view/com/util/ViewHeader'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'ModerationMuteLists'
>
export const ModerationMuteListsScreen = withAuthRequired(({}: Props) => {
const pal = usePalette('default')
const store = useStores()
const {isTabletOrDesktop} = useWebMediaQueries()
const navigation = useNavigation<NavigationProp>()
const mutelists: ListsListModel = React.useMemo(
() => new ListsListModel(store, 'my-modlists'),
[store],
)
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
mutelists.refresh()
}, [store, mutelists]),
)
const onPressNewMuteList = React.useCallback(() => {
store.shell.openModal({
name: 'create-or-edit-mute-list',
onSave: (uri: string) => {
try {
const urip = new AtUri(uri)
navigation.navigate('ProfileList', {
name: urip.hostname,
rkey: urip.rkey,
})
} catch {}
},
})
}, [store, navigation])
const renderEmptyState = React.useCallback(() => {
return (
<EmptyStateWithButton
testID="emptyMuteLists"
icon="users-slash"
message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private."
buttonLabel="New Mute List"
onPress={onPressNewMuteList}
/>
)
}, [onPressNewMuteList])
const renderHeaderButton = React.useCallback(
() => (
<Button
type="primary-light"
onPress={onPressNewMuteList}
style={styles.createBtn}>
<FontAwesomeIcon
icon="plus"
style={pal.link as FontAwesomeIconStyle}
size={18}
/>
</Button>
),
[onPressNewMuteList, pal],
)
return (
<CenteredView
style={[
styles.container,
pal.view,
pal.border,
isTabletOrDesktop && styles.containerDesktop,
]}
testID="moderationMutelistsScreen">
<ViewHeader
title="Mute Lists"
showOnDesktop
renderButton={renderHeaderButton}
/>
<ListsList
listsList={mutelists}
showAddBtns={isTabletOrDesktop}
renderEmptyState={renderEmptyState}
onPressCreateNew={onPressNewMuteList}
/>
</CenteredView>
)
})
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: 100,
},
containerDesktop: {
borderLeftWidth: 1,
borderRightWidth: 1,
paddingBottom: 0,
},
createBtn: {
width: 40,
},
})

View file

@ -25,8 +25,8 @@ import {FAB} from '../com/util/fab/FAB'
import {s, colors} from 'lib/styles'
import {useAnalytics} from 'lib/analytics/analytics'
import {ComposeIcon2} from 'lib/icons'
import {CustomFeed} from 'view/com/feeds/CustomFeed'
import {CustomFeedModel} from 'state/models/feeds/custom-feed'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {FeedSourceModel} from 'state/models/content/feed-source'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {combinedDisplayName} from 'lib/strings/display-names'
@ -189,9 +189,14 @@ export const ProfileScreen = withAuthRequired(
style={styles.emptyState}
/>
)
} else if (item instanceof CustomFeedModel) {
} else if (item instanceof FeedSourceModel) {
return (
<CustomFeed item={item} showSaveBtn showLikes showDescription />
<FeedSourceCard
item={item}
showSaveBtn
showLikes
showDescription
/>
)
}
// if section is posts or posts & replies

View file

@ -0,0 +1,535 @@
import React, {useMemo, useCallback} from 'react'
import {FlatList, StyleSheet, View, ActivityIndicator} from 'react-native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useNavigation} from '@react-navigation/native'
import {usePalette} from 'lib/hooks/usePalette'
import {HeartIcon, HeartIconSolid} from 'lib/icons'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {CommonNavigatorParams} from 'lib/routes/types'
import {makeRecordUri} from 'lib/strings/url-helpers'
import {colors, s} from 'lib/styles'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {FeedSourceModel} from 'state/models/content/feed-source'
import {PostsFeedModel} from 'state/models/feeds/posts'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
import {Feed} from 'view/com/posts/Feed'
import {TextLink} from 'view/com/util/Link'
import {Button} from 'view/com/util/forms/Button'
import {Text} from 'view/com/util/text/Text'
import {RichText} from 'view/com/util/text/RichText'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {FAB} from 'view/com/util/fab/FAB'
import {EmptyState} from 'view/com/util/EmptyState'
import * as Toast from 'view/com/util/Toast'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {useCustomFeed} from 'lib/hooks/useCustomFeed'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {shareUrl} from 'lib/sharing'
import {toShareUrl} from 'lib/strings/url-helpers'
import {Haptics} from 'lib/haptics'
import {useAnalytics} from 'lib/analytics/analytics'
import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
import {resolveName} from 'lib/api'
import {makeCustomFeedLink} from 'lib/routes/links'
import {pluralize} from 'lib/strings/helpers'
import {CenteredView, ScrollView} from 'view/com/util/Views'
import {NavigationProp} from 'lib/routes/types'
import {sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links'
import {ComposeIcon2} from 'lib/icons'
const SECTION_TITLES = ['Posts', 'About']
interface SectionRef {
scrollToTop: () => void
}
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'>
export const ProfileFeedScreen = withAuthRequired(
observer(function ProfileFeedScreenImpl(props: Props) {
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {name: handleOrDid} = props.route.params
const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>()
const [error, setError] = React.useState<string | undefined>()
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
React.useEffect(() => {
/*
* We must resolve the DID of the feed owner before we can fetch the feed.
*/
async function fetchDid() {
try {
const did = await resolveName(store, handleOrDid)
setFeedOwnerDid(did)
} catch (e) {
setError(
`We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`,
)
}
}
fetchDid()
}, [store, handleOrDid, setFeedOwnerDid])
if (error) {
return (
<CenteredView>
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
<Text type="title-lg" style={[pal.text, s.mb10]}>
Could not load feed
</Text>
<Text type="md" style={[pal.text, s.mb20]}>
{error}
</Text>
<View style={{flexDirection: 'row'}}>
<Button
type="default"
accessibilityLabel="Go Back"
accessibilityHint="Return to previous page"
onPress={onPressBack}
style={{flexShrink: 1}}>
<Text type="button" style={pal.text}>
Go Back
</Text>
</Button>
</View>
</View>
</CenteredView>
)
}
return feedOwnerDid ? (
<ProfileFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} />
) : (
<CenteredView>
<View style={s.p20}>
<ActivityIndicator size="large" />
</View>
</CenteredView>
)
}),
)
export const ProfileFeedScreenInner = observer(
function ProfileFeedScreenInnerImpl({
route,
feedOwnerDid,
}: Props & {feedOwnerDid: string}) {
const pal = usePalette('default')
const store = useStores()
const {track} = useAnalytics()
const feedSectionRef = React.useRef<SectionRef>(null)
const {rkey, name: handleOrDid} = route.params
const uri = useMemo(
() => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey),
[rkey, feedOwnerDid],
)
const feedInfo = useCustomFeed(uri)
const feed: PostsFeedModel = useMemo(() => {
const model = new PostsFeedModel(store, 'custom', {
feed: uri,
})
model.setup()
return model
}, [store, uri])
const isPinned = store.preferences.isPinnedFeed(uri)
useSetTitle(feedInfo?.displayName)
// events
// =
const onToggleSaved = React.useCallback(async () => {
try {
Haptics.default()
if (feedInfo?.isSaved) {
await feedInfo?.unsave()
} else {
await feedInfo?.save()
}
} catch (err) {
Toast.show(
'There was an an issue updating your feeds, please check your internet connection and try again.',
)
store.log.error('Failed up update feeds', {err})
}
}, [store, feedInfo])
const onToggleLiked = React.useCallback(async () => {
Haptics.default()
try {
if (feedInfo?.isLiked) {
await feedInfo?.unlike()
} else {
await feedInfo?.like()
}
} catch (err) {
Toast.show(
'There was an an issue contacting the server, please check your internet connection and try again.',
)
store.log.error('Failed up toggle like', {err})
}
}, [store, feedInfo])
const onTogglePinned = React.useCallback(async () => {
Haptics.default()
if (feedInfo) {
feedInfo.togglePin().catch(e => {
Toast.show('There was an issue contacting the server')
store.log.error('Failed to toggle pinned feed', {e})
})
}
}, [store, feedInfo])
const onPressShare = React.useCallback(() => {
const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`)
shareUrl(url)
track('CustomFeed:Share')
}, [handleOrDid, rkey, track])
const onPressReport = React.useCallback(() => {
if (!feedInfo) return
store.shell.openModal({
name: 'report',
uri: feedInfo.uri,
cid: feedInfo.cid,
})
}, [store, feedInfo])
const onCurrentPageSelected = React.useCallback(
(index: number) => {
if (index === 0) {
feedSectionRef.current?.scrollToTop()
}
},
[feedSectionRef],
)
// render
// =
const dropdownItems: DropdownItem[] = React.useMemo(() => {
return [
{
testID: 'feedHeaderDropdownToggleSavedBtn',
label: feedInfo?.isSaved ? 'Remove from my feeds' : 'Add to my feeds',
onPress: onToggleSaved,
icon: feedInfo?.isSaved
? {
ios: {
name: 'trash',
},
android: 'ic_delete',
web: ['far', 'trash-can'],
}
: {
ios: {
name: 'plus',
},
android: '',
web: 'plus',
},
},
{
testID: 'feedHeaderDropdownReportBtn',
label: 'Report feed',
onPress: onPressReport,
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
},
{
testID: 'feedHeaderDropdownShareBtn',
label: 'Share link',
onPress: onPressShare,
icon: {
ios: {
name: 'square.and.arrow.up',
},
android: 'ic_menu_share',
web: 'share',
},
},
] as DropdownItem[]
}, [feedInfo, onToggleSaved, onPressReport, onPressShare])
const renderHeader = useCallback(() => {
return (
<ProfileSubpageHeader
isLoading={!feedInfo?.hasLoaded}
href={makeCustomFeedLink(feedOwnerDid, rkey)}
title={feedInfo?.displayName}
avatar={feedInfo?.avatar}
isOwner={feedInfo?.isOwner}
creator={
feedInfo
? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle}
: undefined
}
avatarType="algo">
{feedInfo && (
<>
<Button
type="default"
label={feedInfo?.isSaved ? 'Unsave' : 'Save'}
onPress={onToggleSaved}
style={styles.btn}
/>
<Button
type={isPinned ? 'default' : 'inverted'}
label={isPinned ? 'Unpin' : 'Pin to home'}
onPress={onTogglePinned}
style={styles.btn}
/>
</>
)}
<NativeDropdown
testID="headerDropdownBtn"
items={dropdownItems}
accessibilityLabel="More options"
accessibilityHint="">
<View style={[pal.viewLight, styles.btn]}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
color={pal.colors.text}
/>
</View>
</NativeDropdown>
</ProfileSubpageHeader>
)
}, [
pal,
feedOwnerDid,
rkey,
feedInfo,
isPinned,
onTogglePinned,
onToggleSaved,
dropdownItems,
])
return (
<View style={s.hContentRegion}>
<PagerWithHeader
items={SECTION_TITLES}
renderHeader={renderHeader}
onCurrentPageSelected={onCurrentPageSelected}>
{({onScroll, headerHeight, isScrolledDown}) => (
<FeedSection
key="1"
ref={feedSectionRef}
feed={feed}
onScroll={onScroll}
headerHeight={headerHeight}
isScrolledDown={isScrolledDown}
/>
)}
{({onScroll, headerHeight}) => (
<ScrollView
key="2"
onScroll={onScroll}
scrollEventThrottle={1}
contentContainerStyle={{paddingTop: headerHeight}}>
<AboutSection
feedOwnerDid={feedOwnerDid}
feedRkey={rkey}
feedInfo={feedInfo}
onToggleLiked={onToggleLiked}
/>
</ScrollView>
)}
</PagerWithHeader>
<FAB
testID="composeFAB"
onPress={() => store.shell.openComposer({})}
icon={
<ComposeIcon2
strokeWidth={1.5}
size={29}
style={{color: 'white'}}
/>
}
accessibilityRole="button"
accessibilityLabel="New post"
accessibilityHint=""
/>
</View>
)
},
)
interface FeedSectionProps {
feed: PostsFeedModel
onScroll: OnScrollCb
headerHeight: number
isScrolledDown: boolean
}
const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
function FeedSectionImpl(
{feed, onScroll, headerHeight, isScrolledDown},
ref,
) {
const hasNew = feed.hasNewLatest && !feed.isRefreshing
const scrollElRef = React.useRef<FlatList>(null)
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerHeight})
}, [scrollElRef, headerHeight])
const onPressLoadLatest = React.useCallback(() => {
onScrollToTop()
feed.refresh()
}, [feed, onScrollToTop])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
const renderPostsEmpty = useCallback(() => {
return <EmptyState icon="feed" message="This feed is empty!" />
}, [])
return (
<View>
<Feed
feed={feed}
scrollElRef={scrollElRef}
onScroll={onScroll}
scrollEventThrottle={5}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onPressLoadLatest}
label="Load new posts"
showIndicator={hasNew}
/>
)}
</View>
)
},
)
const AboutSection = observer(function AboutPageImpl({
feedOwnerDid,
feedRkey,
feedInfo,
onToggleLiked,
}: {
feedOwnerDid: string
feedRkey: string
feedInfo: FeedSourceModel | undefined
onToggleLiked: () => void
}) {
const pal = usePalette('default')
if (!feedInfo) {
return <View />
}
return (
<View
style={[
{
borderTopWidth: 1,
paddingVertical: 20,
paddingHorizontal: 20,
gap: 12,
},
pal.border,
]}>
{feedInfo.descriptionRT ? (
<RichText
testID="listDescription"
type="lg"
style={pal.text}
richText={feedInfo.descriptionRT}
/>
) : (
<Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}>
No description
</Text>
)}
<View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}>
<Button
type="default"
testID="toggleLikeBtn"
accessibilityLabel="Like this feed"
accessibilityHint=""
onPress={onToggleLiked}
style={{paddingHorizontal: 10}}>
{feedInfo?.isLiked ? (
<HeartIconSolid size={19} style={styles.liked} />
) : (
<HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
)}
</Button>
{typeof feedInfo.likeCount === 'number' && (
<TextLink
href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')}
text={`Liked by ${feedInfo.likeCount} ${pluralize(
feedInfo.likeCount,
'user',
)}`}
style={[pal.textLight, s.semiBold]}
/>
)}
</View>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
Created by{' '}
{feedInfo.isOwner ? (
'you'
) : (
<TextLink
text={sanitizeHandle(feedInfo.creatorHandle, '@')}
href={makeProfileLink({
did: feedInfo.creatorDid,
handle: feedInfo.creatorHandle,
})}
style={pal.textLight}
/>
)}
</Text>
</View>
)
})
const styles = StyleSheet.create({
btn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 7,
paddingHorizontal: 14,
borderRadius: 50,
marginLeft: 6,
},
liked: {
color: colors.red3,
},
notFoundContainer: {
margin: 10,
paddingHorizontal: 18,
paddingVertical: 14,
borderRadius: 6,
},
})

View file

@ -8,8 +8,8 @@ import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedB
import {useStores} from 'state/index'
import {makeRecordUri} from 'lib/strings/url-helpers'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeedLikedBy'>
export const CustomFeedLikedByScreen = withAuthRequired(({route}: Props) => {
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeedLikedBy'>
export const ProfileFeedLikedByScreen = withAuthRequired(({route}: Props) => {
const store = useStores()
const {name, rkey} = route.params
const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey)

View file

@ -1,166 +1,802 @@
import React from 'react'
import {StyleSheet} from 'react-native'
import React, {useCallback, useMemo} from 'react'
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
View,
} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {useNavigation} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {observer} from 'mobx-react-lite'
import {RichText as RichTextAPI} from '@atproto/api'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
import {Feed} from 'view/com/posts/Feed'
import {Text} from 'view/com/util/text/Text'
import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
import {CenteredView} from 'view/com/util/Views'
import {ListItems} from 'view/com/lists/ListItems'
import {EmptyState} from 'view/com/util/EmptyState'
import {RichText} from 'view/com/util/text/RichText'
import {Button} from 'view/com/util/forms/Button'
import {TextLink} from 'view/com/util/Link'
import * as Toast from 'view/com/util/Toast'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {FAB} from 'view/com/util/fab/FAB'
import {Haptics} from 'lib/haptics'
import {ListModel} from 'state/models/content/list'
import {PostsFeedModel} from 'state/models/feeds/posts'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {NavigationProp} from 'lib/routes/types'
import {toShareUrl} from 'lib/strings/url-helpers'
import {shareUrl} from 'lib/sharing'
import {ListActions} from 'view/com/lists/ListActions'
import {resolveName} from 'lib/api'
import {s} from 'lib/styles'
import {sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink, makeListLink} from 'lib/routes/links'
import {ComposeIcon2} from 'lib/icons'
import {ListItems} from 'view/com/lists/ListItems'
const SECTION_TITLES_CURATE = ['Posts', 'About']
const SECTION_TITLES_MOD = ['About']
interface SectionRef {
scrollToTop: () => void
}
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
export const ProfileListScreen = withAuthRequired(
observer(function ProfileListScreenImpl({route}: Props) {
observer(function ProfileListScreenImpl(props: Props) {
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {isTabletOrDesktop} = useWebMediaQueries()
const pal = usePalette('default')
const {name, rkey} = route.params
const list: ListModel = React.useMemo(() => {
const model = new ListModel(
store,
`at://${name}/app.bsky.graph.list/${rkey}`,
)
return model
}, [store, name, rkey])
useSetTitle(list.list?.name)
const {name: handleOrDid} = props.route.params
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
list.loadMore(true)
}, [store, list]),
)
const [listOwnerDid, setListOwnerDid] = React.useState<string | undefined>()
const [error, setError] = React.useState<string | undefined>()
const onToggleSubscribed = React.useCallback(async () => {
try {
if (list.list?.viewer?.muted) {
await list.unsubscribe()
} else {
await list.subscribe()
}
} catch (err) {
Toast.show(
'There was an an issue updating your subscription, please check your internet connection and try again.',
)
store.log.error('Failed up update subscription', {err})
const onPressBack = useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [store, list])
}, [navigation])
const onPressEditList = React.useCallback(() => {
store.shell.openModal({
name: 'create-or-edit-mute-list',
list,
onSave() {
list.refresh()
},
})
}, [store, list])
React.useEffect(() => {
/*
* We must resolve the DID of the list owner before we can fetch the list.
*/
async function fetchDid() {
try {
const did = await resolveName(store, handleOrDid)
setListOwnerDid(did)
} catch (e) {
setError(
`We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`,
)
}
}
const onPressDeleteList = React.useCallback(() => {
store.shell.openModal({
name: 'confirm',
title: 'Delete List',
message: 'Are you sure?',
async onPressConfirm() {
await list.delete()
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
},
})
}, [store, list, navigation])
fetchDid()
}, [store, handleOrDid, setListOwnerDid])
const onPressReportList = React.useCallback(() => {
if (!list.list) return
store.shell.openModal({
name: 'report',
uri: list.uri,
cid: list.list.cid,
})
}, [store, list])
const onPressShareList = React.useCallback(() => {
const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`)
shareUrl(url)
}, [list.creatorDid, rkey])
const renderEmptyState = React.useCallback(() => {
return <EmptyState icon="users-slash" message="This list is empty!" />
}, [])
const renderHeaderBtns = React.useCallback(() => {
if (error) {
return (
<ListActions
muted={list.list?.viewer?.muted}
isOwner={list.isOwner}
onPressDeleteList={onPressDeleteList}
onPressEditList={onPressEditList}
onToggleSubscribed={onToggleSubscribed}
onPressShareList={onPressShareList}
onPressReportList={onPressReportList}
reversed={true}
/>
)
}, [
list.isOwner,
list.list?.viewer?.muted,
onPressDeleteList,
onPressEditList,
onPressShareList,
onToggleSubscribed,
onPressReportList,
])
<CenteredView>
<View
style={[
pal.view,
pal.border,
{
margin: 10,
paddingHorizontal: 18,
paddingVertical: 14,
borderRadius: 6,
},
]}>
<Text type="title-lg" style={[pal.text, s.mb10]}>
Could not load list
</Text>
<Text type="md" style={[pal.text, s.mb20]}>
{error}
</Text>
return (
<CenteredView
style={[
styles.container,
isTabletOrDesktop && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="moderationMutelistsScreen">
<ViewHeader title="" renderButton={renderHeaderBtns} />
<ListItems
list={list}
renderEmptyState={renderEmptyState}
onToggleSubscribed={onToggleSubscribed}
onPressEditList={onPressEditList}
onPressDeleteList={onPressDeleteList}
onPressReportList={onPressReportList}
onPressShareList={onPressShareList}
style={[s.flex1]}
/>
<View style={{flexDirection: 'row'}}>
<Button
type="default"
accessibilityLabel="Go Back"
accessibilityHint="Return to previous page"
onPress={onPressBack}
style={{flexShrink: 1}}>
<Text type="button" style={pal.text}>
Go Back
</Text>
</Button>
</View>
</View>
</CenteredView>
)
}
return listOwnerDid ? (
<ProfileListScreenInner {...props} listOwnerDid={listOwnerDid} />
) : (
<CenteredView>
<View style={s.p20}>
<ActivityIndicator size="large" />
</View>
</CenteredView>
)
}),
)
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: 100,
export const ProfileListScreenInner = observer(
function ProfileListScreenInnerImpl({
route,
listOwnerDid,
}: Props & {listOwnerDid: string}) {
const store = useStores()
const {rkey} = route.params
const feedSectionRef = React.useRef<SectionRef>(null)
const aboutSectionRef = React.useRef<SectionRef>(null)
const list: ListModel = useMemo(() => {
const model = new ListModel(
store,
`at://${listOwnerDid}/app.bsky.graph.list/${rkey}`,
)
return model
}, [store, listOwnerDid, rkey])
const feed = useMemo(
() => new PostsFeedModel(store, 'list', {list: list.uri}),
[store, list],
)
useSetTitle(list.data?.name)
useFocusEffect(
useCallback(() => {
store.shell.setMinimalShellMode(false)
list.loadMore(true).then(() => {
if (list.isCuratelist) {
feed.setup()
}
})
}, [store, list, feed]),
)
const onPressAddUser = useCallback(() => {
store.shell.openModal({
name: 'list-add-user',
list,
onAdd() {
if (list.isCuratelist) {
feed.refresh()
}
},
})
}, [store, list, feed])
const onCurrentPageSelected = React.useCallback(
(index: number) => {
if (index === 0) {
feedSectionRef.current?.scrollToTop()
}
if (index === 1) {
aboutSectionRef.current?.scrollToTop()
}
},
[feedSectionRef],
)
const renderHeader = useCallback(() => {
return <Header rkey={rkey} list={list} />
}, [rkey, list])
if (list.isCuratelist) {
return (
<View style={s.hContentRegion}>
<PagerWithHeader
items={SECTION_TITLES_CURATE}
renderHeader={renderHeader}
onCurrentPageSelected={onCurrentPageSelected}>
{({onScroll, headerHeight, isScrolledDown}) => (
<FeedSection
key="1"
ref={feedSectionRef}
feed={feed}
onScroll={onScroll}
headerHeight={headerHeight}
isScrolledDown={isScrolledDown}
/>
)}
{({onScroll, headerHeight, isScrolledDown}) => (
<AboutSection
key="2"
ref={aboutSectionRef}
list={list}
descriptionRT={list.descriptionRT}
creator={list.data ? list.data.creator : undefined}
isCurateList={list.isCuratelist}
isOwner={list.isOwner}
onPressAddUser={onPressAddUser}
onScroll={onScroll}
headerHeight={headerHeight}
isScrolledDown={isScrolledDown}
/>
)}
</PagerWithHeader>
<FAB
testID="composeFAB"
onPress={() => store.shell.openComposer({})}
icon={
<ComposeIcon2
strokeWidth={1.5}
size={29}
style={{color: 'white'}}
/>
}
accessibilityRole="button"
accessibilityLabel="New post"
accessibilityHint=""
/>
</View>
)
}
if (list.isModlist) {
return (
<View style={s.hContentRegion}>
<PagerWithHeader
items={SECTION_TITLES_MOD}
renderHeader={renderHeader}>
{({onScroll, headerHeight, isScrolledDown}) => (
<AboutSection
key="2"
list={list}
descriptionRT={list.descriptionRT}
creator={list.data ? list.data.creator : undefined}
isCurateList={list.isCuratelist}
isOwner={list.isOwner}
onPressAddUser={onPressAddUser}
onScroll={onScroll}
headerHeight={headerHeight}
isScrolledDown={isScrolledDown}
/>
)}
</PagerWithHeader>
<FAB
testID="composeFAB"
onPress={() => store.shell.openComposer({})}
icon={
<ComposeIcon2
strokeWidth={1.5}
size={29}
style={{color: 'white'}}
/>
}
accessibilityRole="button"
accessibilityLabel="New post"
accessibilityHint=""
/>
</View>
)
}
return <Header rkey={rkey} list={list} />
},
containerDesktop: {
borderLeftWidth: 1,
borderRightWidth: 1,
paddingBottom: 0,
)
const Header = observer(function HeaderImpl({
rkey,
list,
}: {
rkey: string
list: ListModel
}) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const onTogglePinned = useCallback(async () => {
Haptics.default()
list.togglePin().catch(e => {
Toast.show('There was an issue contacting the server')
store.log.error('Failed to toggle pinned list', {e})
})
}, [store, list])
const onSubscribeMute = useCallback(() => {
store.shell.openModal({
name: 'confirm',
title: 'Mute these accounts?',
message:
'Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.',
confirmBtnText: 'Mute this List',
async onPressConfirm() {
try {
await list.mute()
Toast.show('List muted')
} catch {
Toast.show(
'There was an issue. Please check your internet connection and try again.',
)
}
},
onPressCancel() {
store.shell.closeModal()
},
})
}, [store, list])
const onUnsubscribeMute = useCallback(async () => {
try {
await list.unmute()
Toast.show('List unmuted')
} catch {
Toast.show(
'There was an issue. Please check your internet connection and try again.',
)
}
}, [list])
const onSubscribeBlock = useCallback(() => {
store.shell.openModal({
name: 'confirm',
title: 'Block these accounts?',
message:
'Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.',
confirmBtnText: 'Block this List',
async onPressConfirm() {
try {
await list.block()
Toast.show('List blocked')
} catch {
Toast.show(
'There was an issue. Please check your internet connection and try again.',
)
}
},
onPressCancel() {
store.shell.closeModal()
},
})
}, [store, list])
const onUnsubscribeBlock = useCallback(async () => {
try {
await list.unblock()
Toast.show('List unblocked')
} catch {
Toast.show(
'There was an issue. Please check your internet connection and try again.',
)
}
}, [list])
const onPressEdit = useCallback(() => {
store.shell.openModal({
name: 'create-or-edit-list',
list,
onSave() {
list.refresh()
},
})
}, [store, list])
const onPressDelete = useCallback(() => {
store.shell.openModal({
name: 'confirm',
title: 'Delete List',
message: 'Are you sure?',
async onPressConfirm() {
await list.delete()
Toast.show('List deleted')
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
},
})
}, [store, list, navigation])
const onPressReport = useCallback(() => {
if (!list.data) return
store.shell.openModal({
name: 'report',
uri: list.uri,
cid: list.data.cid,
})
}, [store, list])
const onPressShare = useCallback(() => {
const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`)
shareUrl(url)
}, [list.creatorDid, rkey])
const dropdownItems: DropdownItem[] = useMemo(() => {
if (!list.hasLoaded) {
return []
}
let items: DropdownItem[] = [
{
testID: 'listHeaderDropdownShareBtn',
label: 'Share',
onPress: onPressShare,
icon: {
ios: {
name: 'square.and.arrow.up',
},
android: '',
web: 'share',
},
},
]
if (list.isOwner) {
items.push({label: 'separator'})
items.push({
testID: 'listHeaderDropdownEditBtn',
label: 'Edit List Details',
onPress: onPressEdit,
icon: {
ios: {
name: 'pencil',
},
android: '',
web: 'pen',
},
})
items.push({
testID: 'listHeaderDropdownDeleteBtn',
label: 'Delete List',
onPress: onPressDelete,
icon: {
ios: {
name: 'trash',
},
android: '',
web: ['far', 'trash-can'],
},
})
} else {
items.push({label: 'separator'})
items.push({
testID: 'listHeaderDropdownReportBtn',
label: 'Report List',
onPress: onPressReport,
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: '',
web: 'circle-exclamation',
},
})
}
return items
}, [
list.hasLoaded,
list.isOwner,
onPressShare,
onPressEdit,
onPressDelete,
onPressReport,
])
const subscribeDropdownItems: DropdownItem[] = useMemo(() => {
return [
{
testID: 'subscribeDropdownMuteBtn',
label: 'Mute accounts',
onPress: onSubscribeMute,
icon: {
ios: {
name: 'speaker.slash',
},
android: '',
web: 'user-slash',
},
},
{
testID: 'subscribeDropdownBlockBtn',
label: 'Block accounts',
onPress: onSubscribeBlock,
icon: {
ios: {
name: 'person.fill.xmark',
},
android: '',
web: 'ban',
},
},
]
}, [onSubscribeMute, onSubscribeBlock])
return (
<ProfileSubpageHeader
isLoading={!list.hasLoaded}
href={makeListLink(
list.data?.creator.handle || list.data?.creator.did || '',
rkey,
)}
title={list.data?.name || 'User list'}
avatar={list.data?.avatar}
isOwner={list.isOwner}
creator={list.data?.creator}
avatarType="list">
{list.isCuratelist ? (
<Button
testID={list.isPinned ? 'unpinBtn' : 'pinBtn'}
type={list.isPinned ? 'default' : 'inverted'}
label={list.isPinned ? 'Unpin' : 'Pin to home'}
onPress={onTogglePinned}
/>
) : list.isModlist ? (
list.isBlocking ? (
<Button
testID="unblockBtn"
type="default"
label="Unblock"
onPress={onUnsubscribeBlock}
/>
) : list.isMuting ? (
<Button
testID="unmuteBtn"
type="default"
label="Unmute"
onPress={onUnsubscribeMute}
/>
) : (
<NativeDropdown
testID="subscribeBtn"
items={subscribeDropdownItems}
accessibilityLabel="Subscribe to this list"
accessibilityHint="">
<View style={[palInverted.view, styles.btn]}>
<Text style={palInverted.text}>Subscribe</Text>
</View>
</NativeDropdown>
)
) : null}
<NativeDropdown
testID="headerDropdownBtn"
items={dropdownItems}
accessibilityLabel="More options"
accessibilityHint="">
<View style={[pal.viewLight, styles.btn]}>
<FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} />
</View>
</NativeDropdown>
</ProfileSubpageHeader>
)
})
interface FeedSectionProps {
feed: PostsFeedModel
onScroll: OnScrollCb
headerHeight: number
isScrolledDown: boolean
}
const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
function FeedSectionImpl(
{feed, onScroll, headerHeight, isScrolledDown},
ref,
) {
const hasNew = feed.hasNewLatest && !feed.isRefreshing
const scrollElRef = React.useRef<FlatList>(null)
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerHeight})
}, [scrollElRef, headerHeight])
const onPressLoadLatest = React.useCallback(() => {
onScrollToTop()
feed.refresh()
}, [feed, onScrollToTop])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
const renderPostsEmpty = useCallback(() => {
return <EmptyState icon="feed" message="This feed is empty!" />
}, [])
return (
<View>
<Feed
testID="listFeed"
feed={feed}
scrollElRef={scrollElRef}
onScroll={onScroll}
scrollEventThrottle={1}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onPressLoadLatest}
label="Load new posts"
showIndicator={hasNew}
/>
)}
</View>
)
},
)
interface AboutSectionProps {
list: ListModel
descriptionRT: RichTextAPI | null
creator: {did: string; handle: string} | undefined
isCurateList: boolean | undefined
isOwner: boolean | undefined
onPressAddUser: () => void
onScroll: OnScrollCb
headerHeight: number
isScrolledDown: boolean
}
const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
function AboutSectionImpl(
{
list,
descriptionRT,
creator,
isCurateList,
isOwner,
onPressAddUser,
onScroll,
headerHeight,
isScrolledDown,
},
ref,
) {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const scrollElRef = React.useRef<FlatList>(null)
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerHeight})
}, [scrollElRef, headerHeight])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
const renderHeader = React.useCallback(() => {
if (!list.data) {
return <View />
}
return (
<View>
<View
style={[
{
borderTopWidth: 1,
padding: isMobile ? 14 : 20,
gap: 12,
},
pal.border,
]}>
{descriptionRT ? (
<RichText
testID="listDescription"
type="lg"
style={pal.text}
richText={descriptionRT}
/>
) : (
<Text
testID="listDescriptionEmpty"
type="lg"
style={[{fontStyle: 'italic'}, pal.textLight]}>
No description
</Text>
)}
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{isCurateList ? 'User list' : 'Moderation list'} by{' '}
{isOwner ? (
'you'
) : (
<TextLink
text={sanitizeHandle(creator?.handle || '', '@')}
href={creator ? makeProfileLink(creator) : ''}
style={pal.textLight}
/>
)}
</Text>
</View>
<View
style={[
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: isMobile ? 14 : 20,
paddingBottom: isMobile ? 14 : 18,
},
]}>
<Text type="lg-bold">Users</Text>
{isOwner && (
<Pressable
testID="addUserBtn"
accessibilityRole="button"
accessibilityLabel="Add a user to this list"
accessibilityHint=""
onPress={onPressAddUser}
style={{flexDirection: 'row', alignItems: 'center', gap: 6}}>
<FontAwesomeIcon
icon="user-plus"
color={pal.colors.link}
size={16}
/>
<Text style={pal.link}>Add</Text>
</Pressable>
)}
</View>
</View>
)
}, [
pal,
list.data,
isMobile,
descriptionRT,
creator,
isCurateList,
isOwner,
onPressAddUser,
])
const renderEmptyState = useCallback(() => {
return (
<EmptyState
icon="users-slash"
message="This list is empty!"
style={{paddingTop: 40}}
/>
)
}, [])
return (
<View>
<ListItems
testID="listItems"
scrollElRef={scrollElRef}
renderHeader={renderHeader}
renderEmptyState={renderEmptyState}
list={list}
headerOffset={headerHeight}
onScroll={onScroll}
scrollEventThrottle={1}
/>
{isScrolledDown && (
<LoadLatestBtn
onPress={onScrollToTop}
label="Scroll to top"
showIndicator={false}
/>
)}
</View>
)
},
)
const styles = StyleSheet.create({
btn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 7,
paddingHorizontal: 14,
borderRadius: 50,
marginLeft: 6,
},
})

View file

@ -14,6 +14,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {CommonNavigatorParams} from 'lib/routes/types'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {SavedFeedsModel} from 'state/models/ui/saved-feeds'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from 'view/com/util/ViewHeader'
@ -25,9 +26,9 @@ import DraggableFlatList, {
ShadowDecorator,
ScaleDecorator,
} from 'react-native-draggable-flatlist'
import {CustomFeed} from 'view/com/feeds/CustomFeed'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {FeedSourceModel} from 'state/models/content/feed-source'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {CustomFeedModel} from 'state/models/feeds/custom-feed'
import * as Toast from 'view/com/util/Toast'
import {Haptics} from 'lib/haptics'
import {Link, TextLink} from 'view/com/util/Link'
@ -41,7 +42,11 @@ export const SavedFeeds = withAuthRequired(
const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
const {screen} = useAnalytics()
const savedFeeds = useMemo(() => store.me.savedFeeds, [store])
const savedFeeds = useMemo(() => {
const model = new SavedFeedsModel(store)
model.refresh()
return model
}, [store])
useFocusEffect(
useCallback(() => {
screen('SavedFeeds')
@ -102,7 +107,7 @@ export const SavedFeeds = withAuthRequired(
const onRefresh = useCallback(() => savedFeeds.refresh(), [savedFeeds])
const onDragEnd = useCallback(
async ({data}: {data: CustomFeedModel[]}) => {
async ({data}: {data: FeedSourceModel[]}) => {
try {
await savedFeeds.reorderPinnedFeeds(data)
} catch (e) {
@ -123,8 +128,8 @@ export const SavedFeeds = withAuthRequired(
<ViewHeader title="Edit My Feeds" showOnDesktop showBorder />
<DraggableFlatList
containerStyle={[isTabletOrDesktop ? s.hContentRegion : s.flex1]}
data={savedFeeds.all}
keyExtractor={item => item.data.uri}
data={savedFeeds.pinned.concat(savedFeeds.unpinned)}
keyExtractor={item => item.uri}
refreshing={savedFeeds.isRefreshing}
refreshControl={
<RefreshControl
@ -134,7 +139,9 @@ export const SavedFeeds = withAuthRequired(
titleColor={pal.colors.text}
/>
}
renderItem={({item, drag}) => <ListItem item={item} drag={drag} />}
renderItem={({item, drag}) => (
<ListItem savedFeeds={savedFeeds} item={item} drag={drag} />
)}
getItemLayout={(data, index) => ({
length: 77,
offset: 77 * index,
@ -152,24 +159,25 @@ export const SavedFeeds = withAuthRequired(
)
const ListItem = observer(function ListItemImpl({
savedFeeds,
item,
drag,
}: {
item: CustomFeedModel
savedFeeds: SavedFeedsModel
item: FeedSourceModel
drag: () => void
}) {
const pal = usePalette('default')
const store = useStores()
const savedFeeds = useMemo(() => store.me.savedFeeds, [store])
const isPinned = savedFeeds.isPinned(item)
const isPinned = item.isPinned
const onTogglePinned = useCallback(() => {
Haptics.default()
savedFeeds.togglePinnedFeed(item).catch(e => {
item.togglePin().catch(e => {
Toast.show('There was an issue contacting the server')
store.log.error('Failed to toggle pinned feed', {e})
})
}, [savedFeeds, item, store])
}, [item, store])
const onPressUp = useCallback(
() =>
savedFeeds.movePinnedFeed(item, 'up').catch(e => {
@ -222,8 +230,8 @@ const ListItem = observer(function ListItemImpl({
style={s.ml20}
/>
) : null}
<CustomFeed
key={item.data.uri}
<FeedSourceCard
key={item.uri}
item={item}
showSaveBtn
style={styles.noBorder}