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 {
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})),
)
}
}
+7
View File
@@ -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
-23
View File
@@ -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,
},
})
+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,
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',
+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 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 />
+3
View File
@@ -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') {
+15 -1
View File
@@ -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})
+1
View File
@@ -98,6 +98,7 @@ export const ModerationMuteListsScreen = withAuthRequired(({route}: Props) => {
/>
<ListsList
listsList={mutelists}
showAddBtns={isDesktopWeb}
renderEmptyState={renderEmptyState}
onPressCreateNew={onPressNewMuteList}
/>
+61 -15
View File
@@ -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,
},
})