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:
parent
f9944b55e2
commit
f57a8cf8ba
87 changed files with 4090 additions and 1988 deletions
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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}
|
||||
|
|
92
src/view/screens/Lists.tsx
Normal file
92
src/view/screens/Lists.tsx
Normal 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>
|
||||
)
|
||||
}),
|
||||
)
|
|
@ -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
|
||||
|
|
92
src/view/screens/ModerationModlists.tsx
Normal file
92
src/view/screens/ModerationModlists.tsx
Normal 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>
|
||||
)
|
||||
}),
|
||||
)
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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
|
||||
|
|
535
src/view/screens/ProfileFeed.tsx
Normal file
535
src/view/screens/ProfileFeed.tsx
Normal 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,
|
||||
},
|
||||
})
|
|
@ -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)
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue