diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts new file mode 100644 index 0000000000..b4af4472b3 --- /dev/null +++ b/src/state/models/content/list-membership.ts @@ -0,0 +1,112 @@ +import {makeAutoObservable} from 'mobx' +import {AtUri, AppBskyGraphListitem} from '@atproto/api' +import {runInAction} from 'mobx' +import {RootStoreModel} from '../root-store' + +const PAGE_SIZE = 100 +interface Membership { + uri: string + value: AppBskyGraphListitem.Record +} + +export class ListMembershipModel { + // data + memberships: Membership[] = [] + + constructor(public rootStore: RootStoreModel, public subject: string) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + // public api + // = + + async fetch() { + // NOTE + // this approach to determining list membership is too inefficient to work at any scale + // it needs to be replaced with server side list membership queries + // -prf + let cursor + let records = [] + for (let i = 0; i < 100; i++) { + const res = await this.rootStore.agent.app.bsky.graph.listitem.list({ + repo: this.rootStore.me.did, + cursor, + limit: PAGE_SIZE, + }) + records = records.concat( + res.records.filter(record => record.value.subject === this.subject), + ) + cursor = res.cursor + if (!cursor) { + break + } + } + runInAction(() => { + this.memberships = records + }) + } + + getMembership(listUri: string) { + return this.memberships.find(m => m.value.list === listUri) + } + + isMember(listUri: string) { + return !!this.getMembership(listUri) + } + + async add(listUri: string) { + if (this.isMember(listUri)) { + return + } + const res = await this.rootStore.agent.app.bsky.graph.listitem.create( + { + repo: this.rootStore.me.did, + }, + { + subject: this.subject, + list: listUri, + createdAt: new Date().toISOString(), + }, + ) + const {rkey} = new AtUri(res.uri) + const record = await this.rootStore.agent.app.bsky.graph.listitem.get({ + repo: this.rootStore.me.did, + rkey, + }) + runInAction(() => { + this.memberships = this.memberships.concat([record]) + }) + } + + async remove(listUri: string) { + const membership = this.getMembership(listUri) + if (!membership) { + return + } + const {rkey} = new AtUri(membership.uri) + await this.rootStore.agent.app.bsky.graph.listitem.delete({ + repo: this.rootStore.me.did, + rkey, + }) + runInAction(() => { + this.memberships = this.memberships.filter(m => m.value.list !== listUri) + }) + } + + async updateTo(uris: string) { + for (const uri of uris) { + await this.add(uri) + } + for (const membership of this.memberships) { + if (!uris.includes(membership.value.list)) { + await this.remove(membership.value.list) + } + } + } +} diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts index eccea5dce7..3d771ac928 100644 --- a/src/state/models/content/list.ts +++ b/src/state/models/content/list.ts @@ -1,16 +1,16 @@ import {makeAutoObservable} from 'mobx' import { AppBskyGraphGetList as GetList, - AppBskyActorDefs, - AppBskyGraphDefs, + AppBskyGraphDefs as GraphDefs, AppBskyGraphList, - AppBskyRichtextFacet, - RichText, } from '@atproto/api' import {Image as RNImage} from 'react-native-image-crop-picker' import {RootStoreModel} from '../root-store' import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' +import {bundleAsync} from 'lib/async/bundle' + +const PAGE_SIZE = 30 export class ListModel { // state @@ -18,21 +18,13 @@ export class ListModel { isRefreshing = false hasLoaded = false error = '' - params: GetList.QueryParams + loadMoreError = '' + hasMore = true + loadMoreCursor?: string // data - uri: string - creator: AppBskyActorDefs.ProfileView - name: string - purpose: AppBskyGraphDefs.ListPurpose - description?: string - descriptionFacets?: AppBskyRichtextFacet.Main[] - avatar?: string - viewer?: AppBskyGraphDefs.ListViewerState - indexedAt?: string - - // added data - descriptionRichText?: RichText = new RichText({text: ''}) + list: GraphDefs.ListView | null = null + items: GraphDefs.ListItemView[] = [] static async createModList( rootStore: RootStoreModel, @@ -65,20 +57,18 @@ export class ListModel { ) } - constructor(public rootStore: RootStoreModel, params: GetList.QueryParams) { + constructor(public rootStore: RootStoreModel, public uri: string) { makeAutoObservable( this, { rootStore: false, - params: false, }, {autoBind: true}, ) - this.params = params } get hasContent() { - return this.uri !== '' + return this.items.length > 0 } get hasError() { @@ -92,12 +82,39 @@ export class ListModel { // public api // = - async setup() { - await this._load() + async refresh() { + return this.loadMore(true) } - async refresh() { - await this._load(true) + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + const res = await this.rootStore.agent.app.bsky.graph.getList({ + list: this.uri, + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(replace ? e : undefined, !replace ? e : undefined) + } + }) + + /** + * Attempt to load more again after a failure + */ + async retryLoadMore() { + this.loadMoreError = '' + this.hasMore = true + return this.loadMore() } // state transitions @@ -109,43 +126,34 @@ export class ListModel { this.error = '' } - _xIdle(err?: any) { + _xIdle(err?: any, loadMoreErr?: any) { this.isLoading = false this.isRefreshing = false this.hasLoaded = true this.error = cleanError(err) + this.loadMoreError = cleanError(loadMoreErr) if (err) { - this.rootStore.log.error('Failed to fetch profile', err) + this.rootStore.log.error('Failed to fetch user items', err) + } + if (loadMoreErr) { + this.rootStore.log.error('Failed to fetch user items', loadMoreErr) } } - // loader functions + // helper functions // = - async _load(isRefreshing = false) { - this._xLoading(isRefreshing) - try { - const res = await this.rootStore.agent.app.bsky.graph.getList(this.params) - this._replaceAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } + _replaceAll(res: GetList.Response) { + this.items = [] + this._appendAll(res) } - _replaceAll(res: GetList.Response) { - this.uri = res.data.list.uri - this.creator = res.data.list.creator - this.name = res.data.list.name - this.purpose = res.data.list.purpose - this.description = res.data.list.description - this.descriptionFacets = res.data.list.descriptionFacets - this.avatar = res.data.list.avatar - this.viewer = res.data.list.viewer - this.indexedAt = res.data.list.indexedAt - this.descriptionRichText = new RichText({ - text: this.description || '', - facets: this.descriptionFacets, - }) + _appendAll(res: GetList.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + this.list = res.data.list + this.items = this.items.concat( + res.data.items.map(item => ({...item, _reactKey: item.subject})), + ) } } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 97605d4d14..a6ccb4e50b 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -42,6 +42,12 @@ export interface CreateMuteListModal { onCreate?: (uri: string) => void } +export interface ListAddUserModal { + name: 'list-add-user' + subject: string + displayName: string +} + export interface CropImageModal { name: 'crop-image' uri: string @@ -104,6 +110,7 @@ export type Modal = | ReportAccountModal | ReportPostModal | CreateMuteListModal + | ListAddUserModal // Posts | AltTextImageModal diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx index f203ee3a21..7cbdaaf648 100644 --- a/src/view/com/lists/ListCard.tsx +++ b/src/view/com/lists/ListCard.tsx @@ -152,27 +152,4 @@ const styles = StyleSheet.create({ marginLeft: 6, paddingHorizontal: 14, }, - - followedBy: { - flexDirection: 'row', - alignItems: 'center', - paddingLeft: 54, - paddingRight: 20, - marginBottom: 10, - marginTop: -6, - }, - followedByAviContainer: { - width: 24, - height: 36, - }, - followedByAvi: { - width: 36, - height: 36, - borderRadius: 18, - padding: 2, - }, - followsByDesc: { - flex: 1, - paddingRight: 10, - }, }) diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx index e69de29bb2..2a0b64a1c1 100644 --- a/src/view/com/lists/ListItems.tsx +++ b/src/view/com/lists/ListItems.tsx @@ -0,0 +1,181 @@ +import React, {MutableRefObject} from 'react' +import { + ActivityIndicator, + RefreshControl, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +import {observer} from 'mobx-react-lite' +import {FlatList} from '../util/Views' +import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' +import {ListModel} from 'state/models/content/list' +import {useAnalytics} from 'lib/analytics' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' + +const LOADING_ITEM = {_reactKey: '__loading__'} +const EMPTY_ITEM = {_reactKey: '__empty__'} +const ERROR_ITEM = {_reactKey: '__error__'} +const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} + +export const ListItems = observer( + ({ + list, + style, + scrollElRef, + onPressTryAgain, + renderEmptyState, + testID, + headerOffset = 0, + }: { + list: ListModel + style?: StyleProp + scrollElRef?: MutableRefObject | null> + onPressTryAgain?: () => void + renderEmptyState?: () => JSX.Element + testID?: string + headerOffset?: number + }) => { + const pal = usePalette('default') + const {track} = useAnalytics() + const [isRefreshing, setIsRefreshing] = React.useState(false) + + const data = React.useMemo(() => { + let items: any[] = [] + if (list.hasLoaded) { + if (list.hasError) { + items = items.concat([ERROR_ITEM]) + } + if (list.isEmpty) { + items = items.concat([EMPTY_ITEM]) + } else { + items = items.concat(list.items) + } + if (list.loadMoreError) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) + } + } else if (list.isLoading) { + items = items.concat([LOADING_ITEM]) + } + return items + }, [ + list.hasError, + list.hasLoaded, + list.isLoading, + list.isEmpty, + list.items, + list.loadMoreError, + ]) + + // events + // = + + const onRefresh = React.useCallback(async () => { + track('Lists:onRefresh') + setIsRefreshing(true) + try { + await list.refresh() + } catch (err) { + list.rootStore.log.error('Failed to refresh lists', err) + } + setIsRefreshing(false) + }, [list, track, setIsRefreshing]) + + const onEndReached = React.useCallback(async () => { + track('Lists:onEndReached') + try { + await list.loadMore() + } catch (err) { + list.rootStore.log.error('Failed to load more lists', err) + } + }, [list, track]) + + const onPressRetryLoadMore = React.useCallback(() => { + list.retryLoadMore() + }, [list]) + + // rendering + // = + + const renderItem = React.useCallback( + ({item}: {item: any}) => { + if (item === EMPTY_ITEM) { + if (renderEmptyState) { + return renderEmptyState() + } + return + } else if (item === ERROR_ITEM) { + return ( + + ) + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + + ) + } else if (item === LOADING_ITEM) { + return + } + return // TODO + }, + [list, onPressTryAgain, onPressRetryLoadMore], + ) + + const Footer = React.useCallback( + () => + list.isLoading ? ( + + + + ) : ( + + ), + [list], + ) + + return ( + + {data.length > 0 && ( + item._reactKey} + renderItem={renderItem} + ListFooterComponent={Footer} + refreshControl={ + + } + contentContainerStyle={s.contentContainer} + style={{paddingTop: headerOffset}} + onEndReached={onEndReached} + onEndReachedThreshold={0.6} + removeClippedSubviews={true} + contentOffset={{x: 0, y: headerOffset * -1}} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + + ) + }, +) + +const styles = StyleSheet.create({ + feedFooter: {paddingTop: 20}, +}) diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/ListsList.tsx index a6eed0ff4c..d4b05a7c36 100644 --- a/src/view/com/lists/ListsList.tsx +++ b/src/view/com/lists/ListsList.tsx @@ -12,6 +12,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' import {FlatList} from '../util/Views' import {ListCard} from './ListCard' import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' @@ -23,7 +24,6 @@ import {ListsListModel} from 'state/models/lists/lists-list' import {useAnalytics} from 'lib/analytics' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' -import {isDesktopWeb} from 'platform/detection' const LOADING_ITEM = {_reactKey: '__loading__'} const CREATENEW_ITEM = {_reactKey: '__loading__'} @@ -34,19 +34,23 @@ const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} export const ListsList = observer( ({ listsList, + showAddBtns, style, scrollElRef, onPressTryAgain, onPressCreateNew, + renderItem, renderEmptyState, testID, headerOffset = 0, }: { listsList: ListsListModel + showAddBtns?: boolean style?: StyleProp scrollElRef?: MutableRefObject | null> onPressCreateNew: () => void onPressTryAgain?: () => void + renderItem?: (list: GraphDefs.ListView) => JSX.Element renderEmptyState?: () => JSX.Element testID?: string headerOffset?: number @@ -64,7 +68,7 @@ export const ListsList = observer( if (listsList.isEmpty) { items = items.concat([EMPTY_ITEM]) } else { - if (isDesktopWeb) { + if (showAddBtns) { items = items.concat([CREATENEW_ITEM]) } items = items.concat(listsList.lists) @@ -115,7 +119,7 @@ export const ListsList = observer( // rendering // = - const renderItem = React.useCallback( + const renderItemInner = React.useCallback( ({item}: {item: any}) => { if (item === EMPTY_ITEM) { if (renderEmptyState) { @@ -141,9 +145,15 @@ export const ListsList = observer( } else if (item === LOADING_ITEM) { return } - return + return renderItem ? renderItem(item) : }, - [listsList, onPressTryAgain, onPressRetryLoadMore, onPressCreateNew], + [ + listsList, + onPressTryAgain, + onPressRetryLoadMore, + onPressCreateNew, + renderItem, + ], ) const Footer = React.useCallback( @@ -166,7 +176,7 @@ export const ListsList = observer( ref={scrollElRef} data={data} keyExtractor={item => item._reactKey} - renderItem={renderItem} + renderItem={renderItemInner} ListFooterComponent={Footer} refreshControl={ void}) { - const palInverted = usePalette('inverted') + const pal = usePalette('default') return ( - @@ -214,8 +221,9 @@ const styles = StyleSheet.create({ createNewContainer: { flexDirection: 'row', alignItems: 'center', - paddingVertical: 16, paddingHorizontal: 18, + paddingTop: 18, + paddingBottom: 16, }, createNewButton: { flexDirection: 'row', diff --git a/src/view/com/modals/ListAddUser.tsx b/src/view/com/modals/ListAddUser.tsx new file mode 100644 index 0000000000..50dffd8994 --- /dev/null +++ b/src/view/com/modals/ListAddUser.tsx @@ -0,0 +1,241 @@ +import React, {useCallback} from 'react' +import * as Toast from '../util/Toast' +import {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 {EmptyStateWithButton} from '../util/EmptyStateWithButton' +import {Button} from '../util/forms/Button' +import {useStores} from 'state/index' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb, isAndroid} from 'platform/detection' + +export const snapPoints = ['fullscreen'] + +export function Component({ + subject, + displayName, +}: { + subject: string + displayName: string +}) { + const store = useStores() + const pal = usePalette('default') + const palPrimary = usePalette('primary') + const palInverted = usePalette('inverted') + const [selected, setSelected] = React.useState([]) + + 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( + () => { + setSelected(memberships.memberships.map(m => m.value.list)) + }, + err => { + store.log.error('Failed to fetch memberships', {err}) + }, + ) + }, [listsList]) + + const onPressCancel = useCallback(() => { + store.shell.closeModal() + }, [store]) + + const onPressSave = useCallback(async () => { + try { + await memberships.updateTo(selected) + store.shell.closeModal() + } catch (err) { + store.log.error('Failed to update memberships', {err}) + } + }, [store, selected]) + + const onPressNewMuteList = useCallback(() => { + store.shell.openModal({ + name: 'create-mute-list', + onCreate: (uri: string) => { + listsList.refresh() + }, + }) + }, [store, listsList]) + + 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) => { + const isSelected = selected.includes(list.uri) + return ( + onToggleSelected(list.uri)}> + + + + + + {sanitizeDisplayName(list.name)} + + + {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '} + {list.creator.did === store.me.did + ? 'you' + : `@${list.creator.handle}`} + + + + {isSelected && ( + + )} + + + ) + }, + [pal, palPrimary, palInverted, onToggleSelected], + ) + + const renderEmptyState = React.useCallback(() => { + return ( + + ) + }, [onPressNewMuteList]) + + return ( + + Add {displayName} to lists + + +