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
287
src/view/com/modals/UserAddRemoveLists.tsx
Normal file
287
src/view/com/modals/UserAddRemoveLists.tsx
Normal file
|
@ -0,0 +1,287 @@
|
|||
import React, {useCallback} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native'
|
||||
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {ListsList} from '../lists/ListsList'
|
||||
import {ListsListModel} from 'state/models/lists/lists-list'
|
||||
import {ListMembershipModel} from 'state/models/content/list-membership'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {useStores} from 'state/index'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb, isAndroid} from 'platform/detection'
|
||||
import isEqual from 'lodash.isequal'
|
||||
|
||||
export const snapPoints = ['fullscreen']
|
||||
|
||||
export const Component = observer(function UserAddRemoveListsImpl({
|
||||
subject,
|
||||
displayName,
|
||||
onAdd,
|
||||
onRemove,
|
||||
}: {
|
||||
subject: string
|
||||
displayName: string
|
||||
onAdd?: (listUri: string) => void
|
||||
onRemove?: (listUri: string) => void
|
||||
}) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const palPrimary = usePalette('primary')
|
||||
const palInverted = usePalette('inverted')
|
||||
const [originalSelections, setOriginalSelections] = React.useState<string[]>(
|
||||
[],
|
||||
)
|
||||
const [selected, setSelected] = React.useState<string[]>([])
|
||||
const [membershipsLoaded, setMembershipsLoaded] = React.useState(false)
|
||||
|
||||
const listsList: ListsListModel = React.useMemo(
|
||||
() => new ListsListModel(store, store.me.did),
|
||||
[store],
|
||||
)
|
||||
const memberships: ListMembershipModel = React.useMemo(
|
||||
() => new ListMembershipModel(store, subject),
|
||||
[store, subject],
|
||||
)
|
||||
React.useEffect(() => {
|
||||
listsList.refresh()
|
||||
memberships.fetch().then(
|
||||
() => {
|
||||
const ids = memberships.memberships.map(m => m.value.list)
|
||||
setOriginalSelections(ids)
|
||||
setSelected(ids)
|
||||
setMembershipsLoaded(true)
|
||||
},
|
||||
err => {
|
||||
store.log.error('Failed to fetch memberships', {err})
|
||||
},
|
||||
)
|
||||
}, [memberships, listsList, store, setSelected, setMembershipsLoaded])
|
||||
|
||||
const onPressCancel = useCallback(() => {
|
||||
store.shell.closeModal()
|
||||
}, [store])
|
||||
|
||||
const onPressSave = useCallback(async () => {
|
||||
let changes
|
||||
try {
|
||||
changes = await memberships.updateTo(selected)
|
||||
} catch (err) {
|
||||
store.log.error('Failed to update memberships', {err})
|
||||
return
|
||||
}
|
||||
Toast.show('Lists updated')
|
||||
for (const uri of changes.added) {
|
||||
onAdd?.(uri)
|
||||
}
|
||||
for (const uri of changes.removed) {
|
||||
onRemove?.(uri)
|
||||
}
|
||||
store.shell.closeModal()
|
||||
}, [store, selected, memberships, onAdd, onRemove])
|
||||
|
||||
const onToggleSelected = useCallback(
|
||||
(uri: string) => {
|
||||
if (selected.includes(uri)) {
|
||||
setSelected(selected.filter(uri2 => uri2 !== uri))
|
||||
} else {
|
||||
setSelected([...selected, uri])
|
||||
}
|
||||
},
|
||||
[selected, setSelected],
|
||||
)
|
||||
|
||||
const renderItem = useCallback(
|
||||
(list: GraphDefs.ListView, index: number) => {
|
||||
const isSelected = selected.includes(list.uri)
|
||||
return (
|
||||
<Pressable
|
||||
testID={`toggleBtn-${list.name}`}
|
||||
style={[
|
||||
styles.listItem,
|
||||
pal.border,
|
||||
{
|
||||
opacity: membershipsLoaded ? 1 : 0.5,
|
||||
borderTopWidth: index === 0 ? 0 : 1,
|
||||
},
|
||||
]}
|
||||
accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
|
||||
list.name
|
||||
}`}
|
||||
accessibilityHint=""
|
||||
disabled={!membershipsLoaded}
|
||||
onPress={() => onToggleSelected(list.uri)}>
|
||||
<View style={styles.listItemAvi}>
|
||||
<UserAvatar size={40} avatar={list.avatar} />
|
||||
</View>
|
||||
<View style={styles.listItemContent}>
|
||||
<Text
|
||||
type="lg"
|
||||
style={[s.bold, pal.text]}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}>
|
||||
{sanitizeDisplayName(list.name)}
|
||||
</Text>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
{list.purpose === 'app.bsky.graph.defs#curatelist' &&
|
||||
'User list '}
|
||||
{list.purpose === 'app.bsky.graph.defs#modlist' &&
|
||||
'Moderation list '}
|
||||
by{' '}
|
||||
{list.creator.did === store.me.did
|
||||
? 'you'
|
||||
: sanitizeHandle(list.creator.handle, '@')}
|
||||
</Text>
|
||||
</View>
|
||||
{membershipsLoaded && (
|
||||
<View
|
||||
style={
|
||||
isSelected
|
||||
? [styles.checkbox, palPrimary.border, palPrimary.view]
|
||||
: [styles.checkbox, pal.borderDark]
|
||||
}>
|
||||
{isSelected && (
|
||||
<FontAwesomeIcon
|
||||
icon="check"
|
||||
style={palInverted.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
)
|
||||
},
|
||||
[
|
||||
pal,
|
||||
palPrimary,
|
||||
palInverted,
|
||||
onToggleSelected,
|
||||
selected,
|
||||
store.me.did,
|
||||
membershipsLoaded,
|
||||
],
|
||||
)
|
||||
|
||||
// Only show changes button if there are some items on the list to choose from AND user has made changes in selection
|
||||
const canSaveChanges =
|
||||
!listsList.isEmpty && !isEqual(selected, originalSelections)
|
||||
|
||||
return (
|
||||
<View testID="userAddRemoveListsModal" style={s.hContentRegion}>
|
||||
<Text style={[styles.title, pal.text]}>
|
||||
Update {displayName} in Lists
|
||||
</Text>
|
||||
<ListsList
|
||||
listsList={listsList}
|
||||
inline
|
||||
renderItem={renderItem}
|
||||
style={[styles.list, pal.border]}
|
||||
/>
|
||||
<View style={[styles.btns, pal.border]}>
|
||||
<Button
|
||||
testID="cancelBtn"
|
||||
type="default"
|
||||
onPress={onPressCancel}
|
||||
style={styles.footerBtn}
|
||||
accessibilityLabel="Cancel"
|
||||
accessibilityHint=""
|
||||
onAccessibilityEscape={onPressCancel}
|
||||
label="Cancel"
|
||||
/>
|
||||
{canSaveChanges && (
|
||||
<Button
|
||||
testID="saveBtn"
|
||||
type="primary"
|
||||
onPress={onPressSave}
|
||||
style={styles.footerBtn}
|
||||
accessibilityLabel="Save changes"
|
||||
accessibilityHint=""
|
||||
onAccessibilityEscape={onPressSave}
|
||||
label="Save Changes"
|
||||
/>
|
||||
)}
|
||||
|
||||
{(listsList.isLoading || !membershipsLoaded) && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: isWeb ? 0 : 16,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 24,
|
||||
marginBottom: 10,
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
btns: {
|
||||
position: 'relative',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: isAndroid ? 10 : 0,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
footerBtn: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
|
||||
listItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
listItemAvi: {
|
||||
width: 54,
|
||||
paddingLeft: 4,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
listItemContent: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
checkbox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
marginRight: 8,
|
||||
},
|
||||
loadingContainer: {
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue