Add 'add to list' tool
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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<ViewStyle>
|
||||
scrollElRef?: MutableRefObject<FlatList<any> | 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 <View />
|
||||
} else if (item === ERROR_ITEM) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={list.error}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
)
|
||||
} else if (item === LOAD_MORE_ERROR_ITEM) {
|
||||
return (
|
||||
<LoadMoreRetryBtn
|
||||
label="There was an issue fetching the list. Tap here to try again."
|
||||
onPress={onPressRetryLoadMore}
|
||||
/>
|
||||
)
|
||||
} else if (item === LOADING_ITEM) {
|
||||
return <ProfileCardFeedLoadingPlaceholder />
|
||||
}
|
||||
return <View /> // TODO
|
||||
},
|
||||
[list, onPressTryAgain, onPressRetryLoadMore],
|
||||
)
|
||||
|
||||
const Footer = React.useCallback(
|
||||
() =>
|
||||
list.isLoading ? (
|
||||
<View style={styles.feedFooter}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<View />
|
||||
),
|
||||
[list],
|
||||
)
|
||||
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
{data.length > 0 && (
|
||||
<FlatList
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
ref={scrollElRef}
|
||||
data={data}
|
||||
keyExtractor={item => item._reactKey}
|
||||
renderItem={renderItem}
|
||||
ListFooterComponent={Footer}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
progressViewOffset={headerOffset}
|
||||
/>
|
||||
}
|
||||
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
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
feedFooter: {paddingTop: 20},
|
||||
})
|
||||
|
||||
@@ -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<ViewStyle>
|
||||
scrollElRef?: MutableRefObject<FlatList<any> | 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 <ProfileCardFeedLoadingPlaceholder />
|
||||
}
|
||||
return <ListCard list={item} />
|
||||
return renderItem ? renderItem(item) : <ListCard list={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={
|
||||
<RefreshControl
|
||||
@@ -193,16 +203,13 @@ export const ListsList = observer(
|
||||
)
|
||||
|
||||
function CreateNewItem({onPress}: {onPress: () => void}) {
|
||||
const palInverted = usePalette('inverted')
|
||||
const pal = usePalette('default')
|
||||
|
||||
return (
|
||||
<View style={[styles.createNewContainer]}>
|
||||
<Button type="inverted" onPress={onPress} style={styles.createNewButton}>
|
||||
<FontAwesomeIcon
|
||||
icon="plus"
|
||||
style={palInverted.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
<Text type="button" style={palInverted.text}>
|
||||
<Button type="default" onPress={onPress} style={styles.createNewButton}>
|
||||
<FontAwesomeIcon icon="plus" style={pal.text as FontAwesomeIconStyle} />
|
||||
<Text type="button" style={pal.text}>
|
||||
New Mute-list
|
||||
</Text>
|
||||
</Button>
|
||||
@@ -214,8 +221,9 @@ const styles = StyleSheet.create({
|
||||
createNewContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 18,
|
||||
paddingTop: 18,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
createNewButton: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
style={[styles.listItem, pal.border]}
|
||||
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#modlist' && 'Mute list'} by{' '}
|
||||
{list.creator.did === store.me.did
|
||||
? 'you'
|
||||
: `@${list.creator.handle}`}
|
||||
</Text>
|
||||
</View>
|
||||
<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],
|
||||
)
|
||||
|
||||
const renderEmptyState = React.useCallback(() => {
|
||||
return (
|
||||
<EmptyStateWithButton
|
||||
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])
|
||||
|
||||
return (
|
||||
<View testID="listAddRemoveUserModal" style={s.hContentRegion}>
|
||||
<Text style={[styles.title, pal.text]}>Add {displayName} to lists</Text>
|
||||
<ListsList
|
||||
listsList={listsList}
|
||||
showAddBtns
|
||||
onPressCreateNew={onPressNewMuteList}
|
||||
renderItem={renderItem}
|
||||
renderEmptyState={renderEmptyState}
|
||||
style={[styles.list, pal.border]}
|
||||
/>
|
||||
<View style={[styles.btns, pal.border]}>
|
||||
<Button
|
||||
testID="cancelBtn"
|
||||
type="default"
|
||||
onPress={onPressCancel}
|
||||
style={styles.footerBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel this modal"
|
||||
accessibilityHint=""
|
||||
onAccessibilityEscape={onPressCancel}
|
||||
label="Cancel"
|
||||
/>
|
||||
<Button
|
||||
testID="saveBtn"
|
||||
type="primary"
|
||||
onPress={onPressSave}
|
||||
style={styles.footerBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Save these changes"
|
||||
accessibilityHint=""
|
||||
onAccessibilityEscape={onPressSave}
|
||||
label="Save Changes"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: isDesktopWeb ? 0 : 16,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 24,
|
||||
marginBottom: 10,
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
btns: {
|
||||
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',
|
||||
borderTopWidth: 1,
|
||||
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,
|
||||
},
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import * as ServerInputModal from './ServerInput'
|
||||
import * as ReportPostModal from './ReportPost'
|
||||
import * as RepostModal from './Repost'
|
||||
import * as CreateMuteListModal from './CreateMuteList'
|
||||
import * as ListAddRemoveUserModal from './ListAddUser'
|
||||
import * as AltImageModal from './AltImage'
|
||||
import * as ReportAccountModal from './ReportAccount'
|
||||
import * as DeleteAccountModal from './DeleteAccount'
|
||||
@@ -70,6 +71,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
|
||||
} else if (activeModal?.name === 'create-mute-list') {
|
||||
snapPoints = CreateMuteListModal.snapPoints
|
||||
element = <CreateMuteListModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'list-add-user') {
|
||||
snapPoints = ListAddRemoveUserModal.snapPoints
|
||||
element = <ListAddRemoveUserModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'delete-account') {
|
||||
snapPoints = DeleteAccountModal.snapPoints
|
||||
element = <DeleteAccountModal.Component />
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as ServerInputModal from './ServerInput'
|
||||
import * as ReportPostModal from './ReportPost'
|
||||
import * as ReportAccountModal from './ReportAccount'
|
||||
import * as CreateMuteListModal from './CreateMuteList'
|
||||
import * as ListAddRemoveUserModal from './ListAddUser'
|
||||
import * as DeleteAccountModal from './DeleteAccount'
|
||||
import * as RepostModal from './Repost'
|
||||
import * as CropImageModal from './crop-image/CropImage.web'
|
||||
@@ -71,6 +72,8 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||
element = <ReportAccountModal.Component {...modal} />
|
||||
} else if (modal.name === 'create-mute-list') {
|
||||
element = <CreateMuteListModal.Component {...modal} />
|
||||
} else if (modal.name === 'list-add-user') {
|
||||
element = <ListAddRemoveUserModal.Component {...modal} />
|
||||
} else if (modal.name === 'crop-image') {
|
||||
element = <CropImageModal.Component {...modal} />
|
||||
} else if (modal.name === 'delete-account') {
|
||||
|
||||
@@ -146,12 +146,21 @@ const ProfileHeaderLoaded = observer(
|
||||
navigation.push('ProfileFollows', {name: view.handle})
|
||||
}, [track, navigation, view])
|
||||
|
||||
const onPressShare = React.useCallback(async () => {
|
||||
const onPressShare = React.useCallback(() => {
|
||||
track('ProfileHeader:ShareButtonClicked')
|
||||
const url = toShareUrl(`/profile/${view.handle}`)
|
||||
shareUrl(url)
|
||||
}, [track, view])
|
||||
|
||||
const onPressAddToLists = React.useCallback(() => {
|
||||
track('ProfileHeader:AddToListsButtonClicked')
|
||||
store.shell.openModal({
|
||||
name: 'list-add-user',
|
||||
subject: view.did,
|
||||
displayName: view.displayName || view.handle,
|
||||
})
|
||||
})
|
||||
|
||||
const onPressMuteAccount = React.useCallback(async () => {
|
||||
track('ProfileHeader:MuteAccountButtonClicked')
|
||||
try {
|
||||
@@ -233,6 +242,11 @@ const ProfileHeaderLoaded = observer(
|
||||
label: 'Share',
|
||||
onPress: onPressShare,
|
||||
},
|
||||
{
|
||||
testID: 'profileHeaderDropdownListAddRemoveBtn',
|
||||
label: 'Add to lists',
|
||||
onPress: onPressAddToLists,
|
||||
},
|
||||
]
|
||||
if (!isMe) {
|
||||
items.push({sep: true})
|
||||
|
||||
@@ -98,6 +98,7 @@ export const ModerationMuteListsScreen = withAuthRequired(({route}: Props) => {
|
||||
/>
|
||||
<ListsList
|
||||
listsList={mutelists}
|
||||
showAddBtns={isDesktopWeb}
|
||||
renderEmptyState={renderEmptyState}
|
||||
onPressCreateNew={onPressNewMuteList}
|
||||
/>
|
||||
|
||||
@@ -1,25 +1,71 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||
import {ViewHeader} from '../com/util/ViewHeader'
|
||||
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {ListItems} from 'view/com/lists/ListItems'
|
||||
import {EmptyState} from 'view/com/util/EmptyState'
|
||||
import {ListModel} from 'state/models/content/list'
|
||||
import {useStores} from 'state/index'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
|
||||
export const ProfileListScreen = withAuthRequired(({route}: Props) => {
|
||||
const store = useStores()
|
||||
const {name, rkey} = route.params
|
||||
export const ProfileListScreen = withAuthRequired(
|
||||
observer(({route}: Props) => {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const {name, rkey} = route.params
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
store.shell.setMinimalShellMode(false)
|
||||
}, [store]),
|
||||
)
|
||||
const list: ListModel = React.useMemo(() => {
|
||||
const model = new ListModel(
|
||||
store,
|
||||
`at://${name}/app.bsky.graph.list/${rkey}`,
|
||||
)
|
||||
return model
|
||||
}, [store, name, rkey])
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ViewHeader title="List" />
|
||||
</View>
|
||||
)
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
store.shell.setMinimalShellMode(false)
|
||||
list.loadMore(true)
|
||||
}, [store]),
|
||||
)
|
||||
|
||||
const renderEmptyState = React.useCallback(() => {
|
||||
return <EmptyState icon="users-slash" message="This list is empty!" />
|
||||
}, [])
|
||||
|
||||
console.log('render')
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
styles.container,
|
||||
isDesktopWeb && styles.containerDesktop,
|
||||
pal.view,
|
||||
pal.border,
|
||||
]}
|
||||
testID="moderationMutelistsScreen">
|
||||
<ViewHeader
|
||||
title={`List by ${list.list?.creator.handle}`}
|
||||
showOnDesktop
|
||||
/>
|
||||
<ListItems list={list} renderEmptyState={renderEmptyState} />
|
||||
</CenteredView>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingBottom: isDesktopWeb ? 0 : 100,
|
||||
},
|
||||
containerDesktop: {
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user