Add 'add to list' tool

This commit is contained in:
Paul Frazee
2023-05-07 14:29:58 -05:00
parent 1befc04dcb
commit 01c79acffe
12 changed files with 706 additions and 104 deletions
+112
View File
@@ -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)
}
}
}
}
+59 -51
View File
@@ -1,16 +1,16 @@
import {makeAutoObservable} from 'mobx' import {makeAutoObservable} from 'mobx'
import { import {
AppBskyGraphGetList as GetList, AppBskyGraphGetList as GetList,
AppBskyActorDefs, AppBskyGraphDefs as GraphDefs,
AppBskyGraphDefs,
AppBskyGraphList, AppBskyGraphList,
AppBskyRichtextFacet,
RichText,
} from '@atproto/api' } from '@atproto/api'
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import * as apilib from 'lib/api/index' import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
const PAGE_SIZE = 30
export class ListModel { export class ListModel {
// state // state
@@ -18,21 +18,13 @@ export class ListModel {
isRefreshing = false isRefreshing = false
hasLoaded = false hasLoaded = false
error = '' error = ''
params: GetList.QueryParams loadMoreError = ''
hasMore = true
loadMoreCursor?: string
// data // data
uri: string list: GraphDefs.ListView | null = null
creator: AppBskyActorDefs.ProfileView items: GraphDefs.ListItemView[] = []
name: string
purpose: AppBskyGraphDefs.ListPurpose
description?: string
descriptionFacets?: AppBskyRichtextFacet.Main[]
avatar?: string
viewer?: AppBskyGraphDefs.ListViewerState
indexedAt?: string
// added data
descriptionRichText?: RichText = new RichText({text: ''})
static async createModList( static async createModList(
rootStore: RootStoreModel, rootStore: RootStoreModel,
@@ -65,20 +57,18 @@ export class ListModel {
) )
} }
constructor(public rootStore: RootStoreModel, params: GetList.QueryParams) { constructor(public rootStore: RootStoreModel, public uri: string) {
makeAutoObservable( makeAutoObservable(
this, this,
{ {
rootStore: false, rootStore: false,
params: false,
}, },
{autoBind: true}, {autoBind: true},
) )
this.params = params
} }
get hasContent() { get hasContent() {
return this.uri !== '' return this.items.length > 0
} }
get hasError() { get hasError() {
@@ -92,12 +82,39 @@ export class ListModel {
// public api // public api
// = // =
async setup() { async refresh() {
await this._load() return this.loadMore(true)
} }
async refresh() { loadMore = bundleAsync(async (replace: boolean = false) => {
await this._load(true) 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 // state transitions
@@ -109,43 +126,34 @@ export class ListModel {
this.error = '' this.error = ''
} }
_xIdle(err?: any) { _xIdle(err?: any, loadMoreErr?: any) {
this.isLoading = false this.isLoading = false
this.isRefreshing = false this.isRefreshing = false
this.hasLoaded = true this.hasLoaded = true
this.error = cleanError(err) this.error = cleanError(err)
this.loadMoreError = cleanError(loadMoreErr)
if (err) { 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) { _replaceAll(res: GetList.Response) {
this._xLoading(isRefreshing) this.items = []
try { this._appendAll(res)
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) { _appendAll(res: GetList.Response) {
this.uri = res.data.list.uri this.loadMoreCursor = res.data.cursor
this.creator = res.data.list.creator this.hasMore = !!this.loadMoreCursor
this.name = res.data.list.name this.list = res.data.list
this.purpose = res.data.list.purpose this.items = this.items.concat(
this.description = res.data.list.description res.data.items.map(item => ({...item, _reactKey: item.subject})),
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,
})
} }
} }
+7
View File
@@ -42,6 +42,12 @@ export interface CreateMuteListModal {
onCreate?: (uri: string) => void onCreate?: (uri: string) => void
} }
export interface ListAddUserModal {
name: 'list-add-user'
subject: string
displayName: string
}
export interface CropImageModal { export interface CropImageModal {
name: 'crop-image' name: 'crop-image'
uri: string uri: string
@@ -104,6 +110,7 @@ export type Modal =
| ReportAccountModal | ReportAccountModal
| ReportPostModal | ReportPostModal
| CreateMuteListModal | CreateMuteListModal
| ListAddUserModal
// Posts // Posts
| AltTextImageModal | AltTextImageModal
-23
View File
@@ -152,27 +152,4 @@ const styles = StyleSheet.create({
marginLeft: 6, marginLeft: 6,
paddingHorizontal: 14, 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,
},
}) })
+181
View File
@@ -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},
})
+22 -14
View File
@@ -12,6 +12,7 @@ import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
import {FlatList} from '../util/Views' import {FlatList} from '../util/Views'
import {ListCard} from './ListCard' import {ListCard} from './ListCard'
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
@@ -23,7 +24,6 @@ import {ListsListModel} from 'state/models/lists/lists-list'
import {useAnalytics} from 'lib/analytics' import {useAnalytics} from 'lib/analytics'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {isDesktopWeb} from 'platform/detection'
const LOADING_ITEM = {_reactKey: '__loading__'} const LOADING_ITEM = {_reactKey: '__loading__'}
const CREATENEW_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( export const ListsList = observer(
({ ({
listsList, listsList,
showAddBtns,
style, style,
scrollElRef, scrollElRef,
onPressTryAgain, onPressTryAgain,
onPressCreateNew, onPressCreateNew,
renderItem,
renderEmptyState, renderEmptyState,
testID, testID,
headerOffset = 0, headerOffset = 0,
}: { }: {
listsList: ListsListModel listsList: ListsListModel
showAddBtns?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | null> scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressCreateNew: () => void onPressCreateNew: () => void
onPressTryAgain?: () => void onPressTryAgain?: () => void
renderItem?: (list: GraphDefs.ListView) => JSX.Element
renderEmptyState?: () => JSX.Element renderEmptyState?: () => JSX.Element
testID?: string testID?: string
headerOffset?: number headerOffset?: number
@@ -64,7 +68,7 @@ export const ListsList = observer(
if (listsList.isEmpty) { if (listsList.isEmpty) {
items = items.concat([EMPTY_ITEM]) items = items.concat([EMPTY_ITEM])
} else { } else {
if (isDesktopWeb) { if (showAddBtns) {
items = items.concat([CREATENEW_ITEM]) items = items.concat([CREATENEW_ITEM])
} }
items = items.concat(listsList.lists) items = items.concat(listsList.lists)
@@ -115,7 +119,7 @@ export const ListsList = observer(
// rendering // rendering
// = // =
const renderItem = React.useCallback( const renderItemInner = React.useCallback(
({item}: {item: any}) => { ({item}: {item: any}) => {
if (item === EMPTY_ITEM) { if (item === EMPTY_ITEM) {
if (renderEmptyState) { if (renderEmptyState) {
@@ -141,9 +145,15 @@ export const ListsList = observer(
} else if (item === LOADING_ITEM) { } else if (item === LOADING_ITEM) {
return <ProfileCardFeedLoadingPlaceholder /> 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( const Footer = React.useCallback(
@@ -166,7 +176,7 @@ export const ListsList = observer(
ref={scrollElRef} ref={scrollElRef}
data={data} data={data}
keyExtractor={item => item._reactKey} keyExtractor={item => item._reactKey}
renderItem={renderItem} renderItem={renderItemInner}
ListFooterComponent={Footer} ListFooterComponent={Footer}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
@@ -193,16 +203,13 @@ export const ListsList = observer(
) )
function CreateNewItem({onPress}: {onPress: () => void}) { function CreateNewItem({onPress}: {onPress: () => void}) {
const palInverted = usePalette('inverted') const pal = usePalette('default')
return ( return (
<View style={[styles.createNewContainer]}> <View style={[styles.createNewContainer]}>
<Button type="inverted" onPress={onPress} style={styles.createNewButton}> <Button type="default" onPress={onPress} style={styles.createNewButton}>
<FontAwesomeIcon <FontAwesomeIcon icon="plus" style={pal.text as FontAwesomeIconStyle} />
icon="plus" <Text type="button" style={pal.text}>
style={palInverted.text as FontAwesomeIconStyle}
/>
<Text type="button" style={palInverted.text}>
New Mute-list New Mute-list
</Text> </Text>
</Button> </Button>
@@ -214,8 +221,9 @@ const styles = StyleSheet.create({
createNewContainer: { createNewContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 18, paddingHorizontal: 18,
paddingTop: 18,
paddingBottom: 16,
}, },
createNewButton: { createNewButton: {
flexDirection: 'row', flexDirection: 'row',
+241
View File
@@ -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,
},
})
+4
View File
@@ -13,6 +13,7 @@ import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './ReportPost' import * as ReportPostModal from './ReportPost'
import * as RepostModal from './Repost' import * as RepostModal from './Repost'
import * as CreateMuteListModal from './CreateMuteList' import * as CreateMuteListModal from './CreateMuteList'
import * as ListAddRemoveUserModal from './ListAddUser'
import * as AltImageModal from './AltImage' import * as AltImageModal from './AltImage'
import * as ReportAccountModal from './ReportAccount' import * as ReportAccountModal from './ReportAccount'
import * as DeleteAccountModal from './DeleteAccount' import * as DeleteAccountModal from './DeleteAccount'
@@ -70,6 +71,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'create-mute-list') { } else if (activeModal?.name === 'create-mute-list') {
snapPoints = CreateMuteListModal.snapPoints snapPoints = CreateMuteListModal.snapPoints
element = <CreateMuteListModal.Component {...activeModal} /> element = <CreateMuteListModal.Component {...activeModal} />
} else if (activeModal?.name === 'list-add-user') {
snapPoints = ListAddRemoveUserModal.snapPoints
element = <ListAddRemoveUserModal.Component {...activeModal} />
} else if (activeModal?.name === 'delete-account') { } else if (activeModal?.name === 'delete-account') {
snapPoints = DeleteAccountModal.snapPoints snapPoints = DeleteAccountModal.snapPoints
element = <DeleteAccountModal.Component /> element = <DeleteAccountModal.Component />
+3
View File
@@ -12,6 +12,7 @@ import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './ReportPost' import * as ReportPostModal from './ReportPost'
import * as ReportAccountModal from './ReportAccount' import * as ReportAccountModal from './ReportAccount'
import * as CreateMuteListModal from './CreateMuteList' import * as CreateMuteListModal from './CreateMuteList'
import * as ListAddRemoveUserModal from './ListAddUser'
import * as DeleteAccountModal from './DeleteAccount' import * as DeleteAccountModal from './DeleteAccount'
import * as RepostModal from './Repost' import * as RepostModal from './Repost'
import * as CropImageModal from './crop-image/CropImage.web' import * as CropImageModal from './crop-image/CropImage.web'
@@ -71,6 +72,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ReportAccountModal.Component {...modal} /> element = <ReportAccountModal.Component {...modal} />
} else if (modal.name === 'create-mute-list') { } else if (modal.name === 'create-mute-list') {
element = <CreateMuteListModal.Component {...modal} /> element = <CreateMuteListModal.Component {...modal} />
} else if (modal.name === 'list-add-user') {
element = <ListAddRemoveUserModal.Component {...modal} />
} else if (modal.name === 'crop-image') { } else if (modal.name === 'crop-image') {
element = <CropImageModal.Component {...modal} /> element = <CropImageModal.Component {...modal} />
} else if (modal.name === 'delete-account') { } else if (modal.name === 'delete-account') {
+15 -1
View File
@@ -146,12 +146,21 @@ const ProfileHeaderLoaded = observer(
navigation.push('ProfileFollows', {name: view.handle}) navigation.push('ProfileFollows', {name: view.handle})
}, [track, navigation, view]) }, [track, navigation, view])
const onPressShare = React.useCallback(async () => { const onPressShare = React.useCallback(() => {
track('ProfileHeader:ShareButtonClicked') track('ProfileHeader:ShareButtonClicked')
const url = toShareUrl(`/profile/${view.handle}`) const url = toShareUrl(`/profile/${view.handle}`)
shareUrl(url) shareUrl(url)
}, [track, view]) }, [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 () => { const onPressMuteAccount = React.useCallback(async () => {
track('ProfileHeader:MuteAccountButtonClicked') track('ProfileHeader:MuteAccountButtonClicked')
try { try {
@@ -233,6 +242,11 @@ const ProfileHeaderLoaded = observer(
label: 'Share', label: 'Share',
onPress: onPressShare, onPress: onPressShare,
}, },
{
testID: 'profileHeaderDropdownListAddRemoveBtn',
label: 'Add to lists',
onPress: onPressAddToLists,
},
] ]
if (!isMe) { if (!isMe) {
items.push({sep: true}) items.push({sep: true})
+1
View File
@@ -98,6 +98,7 @@ export const ModerationMuteListsScreen = withAuthRequired(({route}: Props) => {
/> />
<ListsList <ListsList
listsList={mutelists} listsList={mutelists}
showAddBtns={isDesktopWeb}
renderEmptyState={renderEmptyState} renderEmptyState={renderEmptyState}
onPressCreateNew={onPressNewMuteList} onPressCreateNew={onPressNewMuteList}
/> />
+61 -15
View File
@@ -1,25 +1,71 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {StyleSheet} from 'react-native'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {observer} from 'mobx-react-lite'
import {withAuthRequired} from 'view/com/auth/withAuthRequired' 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 {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {isDesktopWeb} from 'platform/detection'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
export const ProfileListScreen = withAuthRequired(({route}: Props) => { export const ProfileListScreen = withAuthRequired(
const store = useStores() observer(({route}: Props) => {
const {name, rkey} = route.params const store = useStores()
const pal = usePalette('default')
const {name, rkey} = route.params
useFocusEffect( const list: ListModel = React.useMemo(() => {
React.useCallback(() => { const model = new ListModel(
store.shell.setMinimalShellMode(false) store,
}, [store]), `at://${name}/app.bsky.graph.list/${rkey}`,
) )
return model
}, [store, name, rkey])
return ( useFocusEffect(
<View> React.useCallback(() => {
<ViewHeader title="List" /> store.shell.setMinimalShellMode(false)
</View> 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,
},
}) })