diff options
Diffstat (limited to 'src/view/com/modals')
-rw-r--r-- | src/view/com/modals/CreateOrEditList.tsx (renamed from src/view/com/modals/CreateOrEditMuteList.tsx) | 58 | ||||
-rw-r--r-- | src/view/com/modals/ListAddUser.tsx | 281 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 20 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 15 | ||||
-rw-r--r-- | src/view/com/modals/ModerationDetails.tsx | 21 | ||||
-rw-r--r-- | src/view/com/modals/UserAddRemoveLists.tsx (renamed from src/view/com/modals/ListAddRemoveUser.tsx) | 67 |
6 files changed, 394 insertions, 68 deletions
diff --git a/src/view/com/modals/CreateOrEditMuteList.tsx b/src/view/com/modals/CreateOrEditList.tsx index 4a440afeb..1ea12695f 100644 --- a/src/view/com/modals/CreateOrEditMuteList.tsx +++ b/src/view/com/modals/CreateOrEditList.tsx @@ -1,4 +1,4 @@ -import React, {useState, useCallback} from 'react' +import React, {useState, useCallback, useMemo} from 'react' import * as Toast from '../util/Toast' import { ActivityIndicator, @@ -31,9 +31,11 @@ const MAX_DESCRIPTION = 300 // todo export const snapPoints = ['fullscreen'] export function Component({ + purpose, onSave, list, }: { + purpose?: string onSave?: (uri: string) => void list?: ListModel }) { @@ -44,12 +46,24 @@ export function Component({ const theme = useTheme() const {track} = useAnalytics() + const activePurpose = useMemo(() => { + if (list?.data?.purpose) { + return list.data.purpose + } + if (purpose) { + return purpose + } + return 'app.bsky.graph.defs#curatelist' + }, [list, purpose]) + const isCurateList = activePurpose === 'app.bsky.graph.defs#curatelist' + const purposeLabel = isCurateList ? 'User' : 'Moderation' + const [isProcessing, setProcessing] = useState<boolean>(false) - const [name, setName] = useState<string>(list?.list?.name || '') + const [name, setName] = useState<string>(list?.data?.name || '') const [description, setDescription] = useState<string>( - list?.list?.description || '', + list?.data?.description || '', ) - const [avatar, setAvatar] = useState<string | undefined>(list?.list?.avatar) + const [avatar, setAvatar] = useState<string | undefined>(list?.data?.avatar) const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>() const onPressCancel = useCallback(() => { @@ -63,7 +77,7 @@ export function Component({ setAvatar(undefined) return } - track('CreateMuteList:AvatarSelected') + track('CreateList:AvatarSelected') try { const finalImg = await compressIfNeeded(img, 1000000) setNewAvatar(finalImg) @@ -76,7 +90,11 @@ export function Component({ ) const onPressSave = useCallback(async () => { - track('CreateMuteList:Save') + if (isCurateList) { + track('CreateList:SaveCurateList') + } else { + track('CreateList:SaveModList') + } const nameTrimmed = name.trim() if (!nameTrimmed) { setError('Name is required') @@ -93,22 +111,23 @@ export function Component({ description: description.trim(), avatar: newAvatar, }) - Toast.show('Mute list updated') + Toast.show(`${purposeLabel} list updated`) onSave?.(list.uri) } else { - const res = await ListModel.createModList(store, { + const res = await ListModel.createList(store, { + purpose: activePurpose, name, description, avatar: newAvatar, }) - Toast.show('Mute list created') + Toast.show(`${purposeLabel} list created`) onSave?.(res.uri) } store.shell.closeModal() } catch (e: any) { if (isNetworkError(e)) { setError( - 'Failed to create the mute list. Check your internet connection and try again.', + 'Failed to create the list. Check your internet connection and try again.', ) } else { setError(cleanError(e)) @@ -122,6 +141,9 @@ export function Component({ error, onSave, store, + activePurpose, + isCurateList, + purposeLabel, name, description, newAvatar, @@ -137,9 +159,9 @@ export function Component({ paddingHorizontal: isMobile ? 16 : 0, }, ]} - testID="createOrEditMuteListModal"> + testID="createOrEditListModal"> <Text style={[styles.title, pal.text]}> - {list ? 'Edit Mute List' : 'New Mute List'} + {list ? 'Edit' : 'New'} {purposeLabel} List </Text> {error !== '' && ( <View style={styles.errorContainer}> @@ -163,7 +185,9 @@ export function Component({ <TextInput testID="editNameInput" style={[styles.textInput, pal.border, pal.text]} - placeholder="e.g. spammers" + placeholder={ + isCurateList ? 'e.g. Great Posters' : 'e.g. Spammers' + } placeholderTextColor={colors.gray4} value={name} onChangeText={v => setName(enforceLen(v, MAX_NAME))} @@ -180,7 +204,11 @@ export function Component({ <TextInput testID="editDescriptionInput" style={[styles.textArea, pal.border, pal.text]} - placeholder="e.g. users that repeatedly reply with ads." + placeholder={ + isCurateList + ? 'e.g. The posters who never miss.' + : 'e.g. Users that repeatedly reply with ads.' + } placeholderTextColor={colors.gray4} keyboardAppearance={theme.colorScheme} multiline @@ -203,7 +231,7 @@ export function Component({ onPress={onPressSave} accessibilityRole="button" accessibilityLabel="Save" - accessibilityHint="Creates the mute list"> + accessibilityHint=""> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/modals/ListAddUser.tsx b/src/view/com/modals/ListAddUser.tsx new file mode 100644 index 000000000..6ee20ff13 --- /dev/null +++ b/src/view/com/modals/ListAddUser.tsx @@ -0,0 +1,281 @@ +import React, {useEffect, useCallback, useState, useMemo} from 'react' +import { + ActivityIndicator, + Pressable, + SafeAreaView, + StyleSheet, + View, +} from 'react-native' +import {AppBskyActorDefs} from '@atproto/api' +import {ScrollView, TextInput} from './util' +import {observer} from 'mobx-react-lite' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {UserAvatar} from '../util/UserAvatar' +import * as Toast from '../util/Toast' +import {useStores} from 'state/index' +import {ListModel} from 'state/models/content/list' +import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isWeb} from 'platform/detection' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {cleanError} from 'lib/strings/errors' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' + +export const snapPoints = ['90%'] + +export const Component = observer(function Component({ + list, + onAdd, +}: { + list: ListModel + onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void +}) { + const pal = usePalette('default') + const store = useStores() + const {isMobile} = useWebMediaQueries() + const [query, setQuery] = useState('') + const autocompleteView = useMemo<UserAutocompleteModel>( + () => new UserAutocompleteModel(store), + [store], + ) + + // initial setup + useEffect(() => { + autocompleteView.setup().then(() => { + autocompleteView.setPrefix('') + }) + autocompleteView.setActive(true) + list.loadAll() + }, [autocompleteView, list]) + + const onChangeQuery = useCallback( + (text: string) => { + setQuery(text) + autocompleteView.setPrefix(text) + }, + [setQuery, autocompleteView], + ) + + const onPressCancelSearch = useCallback( + () => onChangeQuery(''), + [onChangeQuery], + ) + + return ( + <SafeAreaView + testID="listAddUserModal" + style={[pal.view, isWeb ? styles.fixedHeight : s.flex1]}> + <View + style={[ + s.flex1, + isMobile && {paddingHorizontal: 18, paddingBottom: 40}, + ]}> + <View style={styles.titleSection}> + <Text type="title-lg" style={[pal.text, styles.title]}> + Add User to List + </Text> + </View> + <View style={[styles.searchContainer, pal.border]}> + <FontAwesomeIcon icon="search" size={16} /> + <TextInput + testID="searchInput" + style={[styles.searchInput, pal.border, pal.text]} + placeholder="Search for users" + placeholderTextColor={pal.colors.textLight} + value={query} + onChangeText={onChangeQuery} + accessible={true} + accessibilityLabel="Search" + accessibilityHint="" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + /> + {query ? ( + <Pressable + onPress={onPressCancelSearch} + accessibilityRole="button" + accessibilityLabel="Cancel search" + accessibilityHint="Exits inputting search query" + onAccessibilityEscape={onPressCancelSearch}> + <FontAwesomeIcon + icon="xmark" + size={16} + color={pal.colors.textLight} + /> + </Pressable> + ) : undefined} + </View> + <ScrollView style={[s.flex1]}> + {autocompleteView.suggestions.length ? ( + <> + {autocompleteView.suggestions.slice(0, 40).map((item, i) => ( + <UserResult + key={item.did} + list={list} + profile={item} + noBorder={i === 0} + onAdd={onAdd} + /> + ))} + </> + ) : ( + <Text + type="xl" + style={[ + pal.textLight, + {paddingHorizontal: 12, paddingVertical: 16}, + ]}> + No results found for {autocompleteView.prefix} + </Text> + )} + </ScrollView> + <View style={[styles.btnContainer]}> + <Button + testID="doneBtn" + type="primary" + onPress={() => store.shell.closeModal()} + accessibilityLabel="Done" + accessibilityHint="" + label="Done" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + </View> + </View> + </SafeAreaView> + ) +}) + +function UserResult({ + profile, + list, + noBorder, + onAdd, +}: { + profile: AppBskyActorDefs.ProfileViewBasic + list: ListModel + noBorder: boolean + onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void | undefined +}) { + const pal = usePalette('default') + const [isProcessing, setIsProcessing] = useState(false) + const [isAdded, setIsAdded] = useState(list.isMember(profile.did)) + + const onPressAdd = useCallback(async () => { + setIsProcessing(true) + try { + await list.addMember(profile) + Toast.show('Added to list') + setIsAdded(true) + onAdd?.(profile) + } catch (e) { + Toast.show(cleanError(e)) + } finally { + setIsProcessing(false) + } + }, [list, profile, setIsProcessing, setIsAdded, onAdd]) + + return ( + <View + style={[ + pal.border, + { + flexDirection: 'row', + alignItems: 'center', + borderTopWidth: noBorder ? 0 : 1, + paddingVertical: 8, + paddingHorizontal: 8, + }, + ]}> + <View + style={{ + alignSelf: 'baseline', + width: 54, + paddingLeft: 4, + paddingTop: 10, + }}> + <UserAvatar size={40} avatar={profile.avatar} /> + </View> + <View + style={{ + flex: 1, + paddingRight: 10, + paddingTop: 10, + paddingBottom: 10, + }}> + <Text + type="lg" + style={[s.bold, pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + )} + </Text> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + {sanitizeHandle(profile.handle, '@')} + </Text> + {!!profile.viewer?.followedBy && <View style={s.flexRow} />} + </View> + <View> + {isAdded ? ( + <FontAwesomeIcon icon="check" /> + ) : isProcessing ? ( + <ActivityIndicator /> + ) : ( + <Button + testID={`user-${profile.handle}-addBtn`} + type="default" + label="Add" + onPress={onPressAdd} + /> + )} + </View> + </View> + ) +} + +const styles = StyleSheet.create({ + fixedHeight: { + // @ts-ignore web only -prf + height: '80vh', + }, + titleSection: { + paddingTop: isWeb ? 0 : 4, + paddingBottom: isWeb ? 14 : 10, + }, + title: { + textAlign: 'center', + fontWeight: '600', + marginBottom: 5, + }, + searchContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + borderWidth: 1, + borderRadius: 24, + paddingHorizontal: 16, + paddingVertical: 10, + }, + searchInput: { + fontSize: 16, + flex: 1, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + padding: 14, + backgroundColor: colors.blue3, + }, + btnContainer: { + paddingTop: 20, + }, +}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 1fe1299d7..5aaa09e87 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -16,8 +16,9 @@ import * as ProfilePreviewModal from './ProfilePreview' import * as ServerInputModal from './ServerInput' import * as RepostModal from './Repost' import * as SelfLabelModal from './SelfLabel' -import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' -import * as ListAddRemoveUserModal from './ListAddRemoveUser' +import * as CreateOrEditListModal from './CreateOrEditList' +import * as UserAddRemoveListsModal from './UserAddRemoveLists' +import * as ListAddUserModal from './ListAddUser' import * as AltImageModal from './AltImage' import * as EditImageModal from './AltImage' import * as ReportModal from './report/Modal' @@ -101,12 +102,15 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'report') { snapPoints = ReportModal.snapPoints element = <ReportModal.Component {...activeModal} /> - } else if (activeModal?.name === 'create-or-edit-mute-list') { - snapPoints = CreateOrEditMuteListModal.snapPoints - element = <CreateOrEditMuteListModal.Component {...activeModal} /> - } else if (activeModal?.name === 'list-add-remove-user') { - snapPoints = ListAddRemoveUserModal.snapPoints - element = <ListAddRemoveUserModal.Component {...activeModal} /> + } else if (activeModal?.name === 'create-or-edit-list') { + snapPoints = CreateOrEditListModal.snapPoints + element = <CreateOrEditListModal.Component {...activeModal} /> + } else if (activeModal?.name === 'user-add-remove-lists') { + snapPoints = UserAddRemoveListsModal.snapPoints + element = <UserAddRemoveListsModal.Component {...activeModal} /> + } else if (activeModal?.name === 'list-add-user') { + snapPoints = ListAddUserModal.snapPoints + element = <ListAddUserModal.Component {...activeModal} /> } else if (activeModal?.name === 'delete-account') { snapPoints = DeleteAccountModal.snapPoints element = <DeleteAccountModal.Component /> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index ee778d17d..ede845378 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -11,8 +11,9 @@ import * as EditProfileModal from './EditProfile' import * as ProfilePreviewModal from './ProfilePreview' import * as ServerInputModal from './ServerInput' import * as ReportModal from './report/Modal' -import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' -import * as ListAddRemoveUserModal from './ListAddRemoveUser' +import * as CreateOrEditListModal from './CreateOrEditList' +import * as UserAddRemoveLists from './UserAddRemoveLists' +import * as ListAddUserModal from './ListAddUser' import * as DeleteAccountModal from './DeleteAccount' import * as RepostModal from './Repost' import * as SelfLabelModal from './SelfLabel' @@ -79,10 +80,12 @@ function Modal({modal}: {modal: ModalIface}) { element = <ServerInputModal.Component {...modal} /> } else if (modal.name === 'report') { element = <ReportModal.Component {...modal} /> - } else if (modal.name === 'create-or-edit-mute-list') { - element = <CreateOrEditMuteListModal.Component {...modal} /> - } else if (modal.name === 'list-add-remove-user') { - element = <ListAddRemoveUserModal.Component {...modal} /> + } else if (modal.name === 'create-or-edit-list') { + element = <CreateOrEditListModal.Component {...modal} /> + } else if (modal.name === 'user-add-remove-lists') { + element = <UserAddRemoveLists.Component {...modal} /> + } else if (modal.name === 'list-add-user') { + element = <ListAddUserModal.Component {...modal} /> } else if (modal.name === 'crop-image') { element = <CropImageModal.Component {...modal} /> } else if (modal.name === 'delete-account') { diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx index bd51845c6..c01312d69 100644 --- a/src/view/com/modals/ModerationDetails.tsx +++ b/src/view/com/modals/ModerationDetails.tsx @@ -31,8 +31,25 @@ export function Component({ description = 'Moderator has chosen to set a general warning on the content.' } else if (moderation.cause.type === 'blocking') { - name = 'User Blocked' - description = 'You have blocked this user. You cannot view their content.' + if (moderation.cause.source.type === 'list') { + const list = moderation.cause.source.list + name = 'User Blocked by List' + description = ( + <> + This user is included in the{' '} + <TextLink + type="2xl" + href={listUriToHref(list.uri)} + text={list.name} + style={pal.link} + />{' '} + list which you have blocked. + </> + ) + } else { + name = 'User Blocked' + description = 'You have blocked this user. You cannot view their content.' + } } else if (moderation.cause.type === 'blocked-by') { name = 'User Blocks You' description = 'This user has blocked you. You cannot view their content.' diff --git a/src/view/com/modals/ListAddRemoveUser.tsx b/src/view/com/modals/UserAddRemoveLists.tsx index 58d6a529c..ff048ca29 100644 --- a/src/view/com/modals/ListAddRemoveUser.tsx +++ b/src/view/com/modals/UserAddRemoveLists.tsx @@ -1,6 +1,6 @@ import React, {useCallback} from 'react' import {observer} from 'mobx-react-lite' -import {Pressable, StyleSheet, View, ActivityIndicator} from 'react-native' +import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' import { FontAwesomeIcon, @@ -11,7 +11,6 @@ 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 * as Toast from '../util/Toast' import {useStores} from 'state/index' @@ -24,14 +23,16 @@ import isEqual from 'lodash.isequal' export const snapPoints = ['fullscreen'] -export const Component = observer(function ListAddRemoveUserImpl({ +export const Component = observer(function UserAddRemoveListsImpl({ subject, displayName, - onUpdate, + onAdd, + onRemove, }: { subject: string displayName: string - onUpdate?: () => void + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void }) { const store = useStores() const pal = usePalette('default') @@ -71,25 +72,22 @@ export const Component = observer(function ListAddRemoveUserImpl({ }, [store]) const onPressSave = useCallback(async () => { + let changes try { - await memberships.updateTo(selected) + changes = await memberships.updateTo(selected) } catch (err) { store.log.error('Failed to update memberships', {err}) return } Toast.show('Lists updated') - onUpdate?.() + for (const uri of changes.added) { + onAdd?.(uri) + } + for (const uri of changes.removed) { + onRemove?.(uri) + } store.shell.closeModal() - }, [store, selected, memberships, onUpdate]) - - const onPressNewMuteList = useCallback(() => { - store.shell.openModal({ - name: 'create-or-edit-mute-list', - onSave: (_uri: string) => { - listsList.refresh() - }, - }) - }, [store, listsList]) + }, [store, selected, memberships, onAdd, onRemove]) const onToggleSelected = useCallback( (uri: string) => { @@ -103,7 +101,7 @@ export const Component = observer(function ListAddRemoveUserImpl({ ) const renderItem = useCallback( - (list: GraphDefs.ListView) => { + (list: GraphDefs.ListView, index: number) => { const isSelected = selected.includes(list.uri) return ( <Pressable @@ -111,7 +109,10 @@ export const Component = observer(function ListAddRemoveUserImpl({ style={[ styles.listItem, pal.border, - {opacity: membershipsLoaded ? 1 : 0.5}, + { + opacity: membershipsLoaded ? 1 : 0.5, + borderTopWidth: index === 0 ? 0 : 1, + }, ]} accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${ list.name @@ -131,7 +132,11 @@ export const Component = observer(function ListAddRemoveUserImpl({ {sanitizeDisplayName(list.name)} </Text> <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '} + {list.purpose === 'app.bsky.graph.defs#curatelist' && + 'User list '} + {list.purpose === 'app.bsky.graph.defs#modlist' && + 'Moderation list '} + by{' '} {list.creator.did === store.me.did ? 'you' : sanitizeHandle(list.creator.handle, '@')} @@ -166,30 +171,19 @@ export const Component = observer(function ListAddRemoveUserImpl({ ], ) - 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]) - // Only show changes button if there are some items on the list to choose from AND user has made changes in selection const canSaveChanges = !listsList.isEmpty && !isEqual(selected, originalSelections) return ( - <View testID="listAddRemoveUserModal" style={s.hContentRegion}> - <Text style={[styles.title, pal.text]}>Add {displayName} to Lists</Text> + <View testID="userAddRemoveListsModal" style={s.hContentRegion}> + <Text style={[styles.title, pal.text]}> + Update {displayName} in Lists + </Text> <ListsList listsList={listsList} - showAddBtns - onPressCreateNew={onPressNewMuteList} + inline renderItem={renderItem} - renderEmptyState={renderEmptyState} style={[styles.list, pal.border]} /> <View style={[styles.btns, pal.border]}> @@ -258,7 +252,6 @@ const styles = StyleSheet.create({ listItem: { flexDirection: 'row', alignItems: 'center', - borderTopWidth: 1, paddingHorizontal: 14, paddingVertical: 10, }, |