diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/components/ProfileCard.tsx | 48 | ||||
-rw-r--r-- | src/components/dialogs/SearchablePeopleList.tsx (renamed from src/components/dms/dialogs/SearchablePeopleList.tsx) | 194 | ||||
-rw-r--r-- | src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx | 180 | ||||
-rw-r--r-- | src/components/dms/dialogs/NewChatDialog.tsx | 3 | ||||
-rw-r--r-- | src/components/dms/dialogs/ShareViaChatDialog.tsx | 3 | ||||
-rw-r--r-- | src/state/modals/index.tsx | 14 | ||||
-rw-r--r-- | src/state/queries/list-memberships.ts | 24 | ||||
-rw-r--r-- | src/view/com/modals/ListAddRemoveUsers.tsx | 316 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 4 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 5 | ||||
-rw-r--r-- | src/view/screens/ProfileList.tsx | 52 |
11 files changed, 378 insertions, 465 deletions
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 394ff9946..1a64c51d5 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -166,29 +166,47 @@ export function NameAndHandle({ profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts }) { - const t = useTheme() + return ( + <View style={[a.flex_1]}> + <Name profile={profile} moderationOpts={moderationOpts} /> + <Handle profile={profile} /> + </View> + ) +} + +export function Name({ + profile, + moderationOpts, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts +}) { const moderation = moderateProfile(profile, moderationOpts) const name = sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), moderation.ui('displayName'), ) + return ( + <Text + emoji + style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]} + numberOfLines={1}> + {name} + </Text> + ) +} + +export function Handle({profile}: {profile: bsky.profile.AnyProfileView}) { + const t = useTheme() const handle = sanitizeHandle(profile.handle, '@') return ( - <View style={[a.flex_1]}> - <Text - emoji - style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]} - numberOfLines={1}> - {name} - </Text> - <Text - emoji - style={[a.leading_snug, t.atoms.text_contrast_medium]} - numberOfLines={1}> - {handle} - </Text> - </View> + <Text + emoji + style={[a.leading_snug, t.atoms.text_contrast_medium]} + numberOfLines={1}> + {handle} + </Text> ) } diff --git a/src/components/dms/dialogs/SearchablePeopleList.tsx b/src/components/dialogs/SearchablePeopleList.tsx index 05d6f723e..26e20db57 100644 --- a/src/components/dms/dialogs/SearchablePeopleList.tsx +++ b/src/components/dialogs/SearchablePeopleList.tsx @@ -1,4 +1,5 @@ -import React, { +import { + Fragment, useCallback, useLayoutEffect, useMemo, @@ -6,7 +7,7 @@ import React, { useState, } from 'react' import {TextInput, View} from 'react-native' -import {moderateProfile, ModerationOpts} from '@atproto/api' +import {moderateProfile, type ModerationOpts} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -18,48 +19,62 @@ import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useListConvosQuery} from '#/state/queries/messages/list-conversations' import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useSession} from '#/state/session' -import {ListMethods} from '#/view/com/util/List' -import {UserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, native, useTheme, web} from '#/alf' +import {type ListMethods} from '#/view/com/util/List' +import {android, atoms as a, native, useTheme, web} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {canBeMessaged} from '#/components/dms/util' import {useInteractionState} from '#/components/hooks/useInteractionState' import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' -import * as bsky from '#/types/bsky' +import type * as bsky from '#/types/bsky' -type Item = - | { - type: 'profile' - key: string - enabled: boolean - profile: bsky.profile.AnyProfileView - } - | { - type: 'empty' - key: string - message: string - } - | { - type: 'placeholder' - key: string - } - | { - type: 'error' - key: string - } +export type ProfileItem = { + type: 'profile' + key: string + profile: bsky.profile.AnyProfileView +} + +type EmptyItem = { + type: 'empty' + key: string + message: string +} + +type PlaceholderItem = { + type: 'placeholder' + key: string +} + +type ErrorItem = { + type: 'error' + key: string +} + +type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem export function SearchablePeopleList({ title, - onSelectChat, showRecentConvos, + sortByMessageDeclaration, + onSelectChat, + renderProfileCard, }: { title: string - onSelectChat: (did: string) => void showRecentConvos?: boolean -}) { + sortByMessageDeclaration?: boolean +} & ( + | { + renderProfileCard: (item: ProfileItem) => React.ReactNode + onSelectChat?: undefined + } + | { + onSelectChat: (did: string) => void + renderProfileCard?: undefined + } +)) { const t = useTheme() const {_} = useLingui() const moderationOpts = useModerationOpts() @@ -98,15 +113,17 @@ export function SearchablePeopleList({ _items.push({ type: 'profile', key: profile.did, - enabled: canBeMessaged(profile), profile, }) } - _items = _items.sort(item => { - // @ts-ignore - return item.enabled ? -1 : 1 - }) + if (sortByMessageDeclaration) { + _items = _items.sort(item => { + return item.type === 'profile' && canBeMessaged(item.profile) + ? -1 + : 1 + }) + } } } else { const placeholders: Item[] = Array(10) @@ -134,14 +151,13 @@ export function SearchablePeopleList({ _items.push({ type: 'profile', key: profile.did, - enabled: true, profile, }) } } } - let followsItems: typeof _items = [] + let followsItems: ProfileItem[] = [] for (const page of follows.pages) { for (const profile of page.follows) { @@ -150,17 +166,17 @@ export function SearchablePeopleList({ followsItems.push({ type: 'profile', key: profile.did, - enabled: canBeMessaged(profile), profile, }) } } - // only sort follows - followsItems = followsItems.sort(item => { - // @ts-ignore - return item.enabled ? -1 : 1 - }) + if (sortByMessageDeclaration) { + // only sort follows + followsItems = followsItems.sort(item => { + return canBeMessaged(item.profile) ? -1 : 1 + }) + } // then append _items.push(...followsItems) @@ -173,16 +189,18 @@ export function SearchablePeopleList({ _items.push({ type: 'profile', key: profile.did, - enabled: canBeMessaged(profile), profile, }) } } - _items = _items.sort(item => { - // @ts-ignore - return item.enabled ? -1 : 1 - }) + if (sortByMessageDeclaration) { + _items = _items.sort(item => { + return item.type === 'profile' && canBeMessaged(item.profile) + ? -1 + : 1 + }) + } } else { _items.push(...placeholders) } @@ -198,6 +216,7 @@ export function SearchablePeopleList({ follows, convos, showRecentConvos, + sortByMessageDeclaration, ]) if (searchText && !isFetching && !items.length && !isError) { @@ -208,15 +227,18 @@ export function SearchablePeopleList({ ({item}: {item: Item}) => { switch (item.type) { case 'profile': { - return ( - <ProfileCard - key={item.key} - enabled={item.enabled} - profile={item.profile} - moderationOpts={moderationOpts!} - onPress={onSelectChat} - /> - ) + if (renderProfileCard) { + return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment> + } else { + return ( + <DefaultProfileCard + key={item.key} + profile={item.profile} + moderationOpts={moderationOpts!} + onPress={onSelectChat} + /> + ) + } } case 'placeholder': { return <ProfileCardSkeleton key={item.key} /> @@ -228,7 +250,7 @@ export function SearchablePeopleList({ return null } }, - [moderationOpts, onSelectChat], + [moderationOpts, onSelectChat, renderProfileCard], ) useLayoutEffect(() => { @@ -247,6 +269,10 @@ export function SearchablePeopleList({ a.relative, web(a.pt_lg), native(a.pt_4xl), + android({ + borderTopLeftRadius: a.rounded_md.borderRadius, + borderTopRightRadius: a.rounded_md.borderRadius, + }), a.pb_xs, a.px_lg, a.border_b, @@ -327,19 +353,18 @@ export function SearchablePeopleList({ ) } -function ProfileCard({ - enabled, +function DefaultProfileCard({ profile, moderationOpts, onPress, }: { - enabled: boolean profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts onPress: (did: string) => void }) { const t = useTheme() const {_} = useLingui() + const enabled = canBeMessaged(profile) const moderation = moderateProfile(profile, moderationOpts) const handle = sanitizeHandle(profile.handle, '@') const displayName = sanitizeDisplayName( @@ -360,38 +385,35 @@ function ProfileCard({ <View style={[ a.flex_1, - a.py_md, + a.py_sm, a.px_lg, - a.gap_md, - a.align_center, - a.flex_row, !enabled ? {opacity: 0.5} - : pressed || focused + : pressed || focused || hovered ? t.atoms.bg_contrast_25 - : hovered - ? t.atoms.bg_contrast_50 : t.atoms.bg, ]}> - <UserAvatar - size={42} - avatar={profile.avatar} - moderation={moderation.ui('avatar')} - type={profile.associated?.labeler ? 'labeler' : 'user'} - /> - <View style={[a.flex_1, a.gap_2xs]}> - <Text - style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]} - numberOfLines={1} - emoji> - {displayName} - </Text> - <Text - style={[a.leading_tight, t.atoms.text_contrast_high]} - numberOfLines={2}> - {!enabled ? <Trans>{handle} can't be messaged</Trans> : handle} - </Text> - </View> + <ProfileCard.Header> + <ProfileCard.Avatar + profile={profile} + moderationOpts={moderationOpts} + /> + <View style={[a.flex_1]}> + <ProfileCard.Name + profile={profile} + moderationOpts={moderationOpts} + /> + {enabled ? ( + <ProfileCard.Handle profile={profile} /> + ) : ( + <Text + style={[a.leading_snug, t.atoms.text_contrast_high]} + numberOfLines={2}> + <Trans>{handle} can't be messaged</Trans> + </Text> + )} + </View> + </ProfileCard.Header> </View> )} </Button> diff --git a/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx b/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx new file mode 100644 index 000000000..d975c89ed --- /dev/null +++ b/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx @@ -0,0 +1,180 @@ +import {useCallback, useMemo} from 'react' +import {View} from 'react-native' +import {type AppBskyGraphDefs, type ModerationOpts} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {cleanError} from '#/lib/strings/errors' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import { + getMembership, + type ListMembersip, + useDangerousListMembershipsQuery, + useListMembershipAddMutation, + useListMembershipRemoveMutation, +} from '#/state/queries/list-memberships' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import { + type ProfileItem, + SearchablePeopleList, +} from '#/components/dialogs/SearchablePeopleList' +import {Loader} from '#/components/Loader' +import * as ProfileCard from '#/components/ProfileCard' +import type * as bsky from '#/types/bsky' + +export function ListAddRemoveUsersDialog({ + control, + list, + onChange, +}: { + control: Dialog.DialogControlProps + list: AppBskyGraphDefs.ListView + onChange?: ( + type: 'add' | 'remove', + profile: bsky.profile.AnyProfileView, + ) => void | undefined +}) { + return ( + <Dialog.Outer control={control} testID="listAddRemoveUsersDialog"> + <Dialog.Handle /> + <DialogInner list={list} onChange={onChange} /> + </Dialog.Outer> + ) +} + +function DialogInner({ + list, + onChange, +}: { + list: AppBskyGraphDefs.ListView + onChange?: ( + type: 'add' | 'remove', + profile: bsky.profile.AnyProfileView, + ) => void | undefined +}) { + const {_} = useLingui() + const moderationOpts = useModerationOpts() + const {data: memberships} = useDangerousListMembershipsQuery() + + const renderProfileCard = useCallback( + (item: ProfileItem) => { + return ( + <UserResult + profile={item.profile} + onChange={onChange} + memberships={memberships} + list={list} + moderationOpts={moderationOpts} + /> + ) + }, + [onChange, memberships, list, moderationOpts], + ) + + return ( + <SearchablePeopleList + title={_(msg`Add people to list`)} + renderProfileCard={renderProfileCard} + /> + ) +} + +function UserResult({ + profile, + list, + memberships, + onChange, + moderationOpts, +}: { + profile: bsky.profile.AnyProfileView + list: AppBskyGraphDefs.ListView + memberships: ListMembersip[] | undefined + onChange?: ( + type: 'add' | 'remove', + profile: bsky.profile.AnyProfileView, + ) => void | undefined + moderationOpts?: ModerationOpts +}) { + const {_} = useLingui() + const membership = useMemo( + () => getMembership(memberships, list.uri, profile.did), + [memberships, list.uri, profile.did], + ) + const {mutate: listMembershipAdd, isPending: isAddingPending} = + useListMembershipAddMutation({ + onSuccess: () => { + Toast.show(_(msg`Added to list`)) + onChange?.('add', profile) + }, + onError: e => Toast.show(cleanError(e), 'xmark'), + }) + const {mutate: listMembershipRemove, isPending: isRemovingPending} = + useListMembershipRemoveMutation({ + onSuccess: () => { + Toast.show(_(msg`Removed from list`)) + onChange?.('remove', profile) + }, + onError: e => Toast.show(cleanError(e), 'xmark'), + }) + const isMutating = isAddingPending || isRemovingPending + + const onToggleMembership = useCallback(() => { + if (typeof membership === 'undefined') { + return + } + if (membership === false) { + listMembershipAdd({ + listUri: list.uri, + actorDid: profile.did, + }) + } else { + listMembershipRemove({ + listUri: list.uri, + actorDid: profile.did, + membershipUri: membership, + }) + } + }, [list, profile, membership, listMembershipAdd, listMembershipRemove]) + + if (!moderationOpts) return null + + return ( + <View style={[a.flex_1, a.py_sm, a.px_lg]}> + <ProfileCard.Header> + <ProfileCard.Avatar profile={profile} moderationOpts={moderationOpts} /> + <View style={[a.flex_1]}> + <ProfileCard.Name profile={profile} moderationOpts={moderationOpts} /> + <ProfileCard.Handle profile={profile} /> + </View> + {membership !== undefined && ( + <Button + label={ + membership === false + ? _(msg`Add user to list`) + : _(msg`Remove user from list`) + } + onPress={onToggleMembership} + disabled={isMutating} + size="small" + variant="solid" + color="secondary"> + {isMutating ? ( + <ButtonIcon icon={Loader} /> + ) : ( + <ButtonText> + {membership === false ? ( + <Trans>Add</Trans> + ) : ( + <Trans>Remove</Trans> + )} + </ButtonText> + )} + </Button> + )} + </ProfileCard.Header> + </View> + ) +} diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index c7fedb488..a5ba793fb 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -11,9 +11,9 @@ import * as Toast from '#/view/com/util/Toast' import {useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog' +import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' -import {SearchablePeopleList} from './SearchablePeopleList' export function NewChat({ control, @@ -71,6 +71,7 @@ export function NewChat({ <SearchablePeopleList title={_(msg`Start a new chat`)} onSelectChat={onCreateChat} + sortByMessageDeclaration /> </Dialog.Outer> diff --git a/src/components/dms/dialogs/ShareViaChatDialog.tsx b/src/components/dms/dialogs/ShareViaChatDialog.tsx index 4bb27ae69..97897bc28 100644 --- a/src/components/dms/dialogs/ShareViaChatDialog.tsx +++ b/src/components/dms/dialogs/ShareViaChatDialog.tsx @@ -7,7 +7,7 @@ import {logger} from '#/logger' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' import * as Toast from '#/view/com/util/Toast' import * as Dialog from '#/components/Dialog' -import {SearchablePeopleList} from './SearchablePeopleList' +import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' export function SendViaChatDialog({ control, @@ -62,6 +62,7 @@ function SendViaChatDialogInner({ title={_(msg`Send post to...`)} onSelectChat={onCreateChat} showRecentConvos + sortByMessageDeclaration /> ) } diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 483de99e4..1709f0288 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -1,6 +1,6 @@ import React from 'react' -import {Image as RNImage} from 'react-native-image-crop-picker' -import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' +import {type Image as RNImage} from 'react-native-image-crop-picker' +import {type AppBskyActorDefs, type AppBskyGraphDefs} from '@atproto/api' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -26,15 +26,6 @@ export interface UserAddRemoveListsModal { onRemove?: (listUri: string) => void } -export interface ListAddRemoveUsersModal { - name: 'list-add-remove-users' - list: AppBskyGraphDefs.ListView - onChange?: ( - type: 'add' | 'remove', - profile: AppBskyActorDefs.ProfileViewBasic, - ) => void -} - export interface CropImageModal { name: 'crop-image' uri: string @@ -107,7 +98,6 @@ export type Modal = // Lists | CreateOrEditListModal | UserAddRemoveListsModal - | ListAddRemoveUsersModal // Posts | CropImageModal diff --git a/src/state/queries/list-memberships.ts b/src/state/queries/list-memberships.ts index b93dc059d..410a613ad 100644 --- a/src/state/queries/list-memberships.ts +++ b/src/state/queries/list-memberships.ts @@ -90,7 +90,13 @@ export function getMembership( return membership ? membership.membershipUri : false } -export function useListMembershipAddMutation() { +export function useListMembershipAddMutation({ + onSuccess, + onError, +}: { + onSuccess?: (data: {uri: string; cid: string}) => void + onError?: (error: Error) => void +} = {}) { const {currentAccount} = useSession() const agent = useAgent() const queryClient = useQueryClient() @@ -117,7 +123,7 @@ export function useListMembershipAddMutation() { // -prf return res }, - onSuccess(data, variables) { + onSuccess: (data, variables) => { // manually update the cache; a refetch is too expensive let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY()) if (memberships) { @@ -145,11 +151,19 @@ export function useListMembershipAddMutation() { queryKey: LIST_MEMBERS_RQKEY(variables.listUri), }) }, 1e3) + onSuccess?.(data) }, + onError, }) } -export function useListMembershipRemoveMutation() { +export function useListMembershipRemoveMutation({ + onSuccess, + onError, +}: { + onSuccess?: (data: void) => void + onError?: (error: Error) => void +} = {}) { const {currentAccount} = useSession() const agent = useAgent() const queryClient = useQueryClient() @@ -172,7 +186,7 @@ export function useListMembershipRemoveMutation() { // query for that, so we use a timeout below // -prf }, - onSuccess(data, variables) { + onSuccess: (data, variables) => { // manually update the cache; a refetch is too expensive let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY()) if (memberships) { @@ -192,6 +206,8 @@ export function useListMembershipRemoveMutation() { queryKey: LIST_MEMBERS_RQKEY(variables.listUri), }) }, 1e3) + onSuccess?.(data) }, + onError, }) } diff --git a/src/view/com/modals/ListAddRemoveUsers.tsx b/src/view/com/modals/ListAddRemoveUsers.tsx deleted file mode 100644 index 5285d4a15..000000000 --- a/src/view/com/modals/ListAddRemoveUsers.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import React, {useCallback, useState} from 'react' -import { - ActivityIndicator, - Pressable, - SafeAreaView, - StyleSheet, - View, -} from 'react-native' -import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {HITSLOP_20} from '#/lib/constants' -import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {cleanError} from '#/lib/strings/errors' -import {sanitizeHandle} from '#/lib/strings/handles' -import {colors, s} from '#/lib/styles' -import {isWeb} from '#/platform/detection' -import {useModalControls} from '#/state/modals' -import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' -import { - getMembership, - ListMembersip, - useDangerousListMembershipsQuery, - useListMembershipAddMutation, - useListMembershipRemoveMutation, -} from '#/state/queries/list-memberships' -import {Button} from '../util/forms/Button' -import {Text} from '../util/text/Text' -import * as Toast from '../util/Toast' -import {UserAvatar} from '../util/UserAvatar' -import {ScrollView, TextInput} from './util' - -export const snapPoints = ['90%'] - -export function Component({ - list, - onChange, -}: { - list: AppBskyGraphDefs.ListView - onChange?: ( - type: 'add' | 'remove', - profile: AppBskyActorDefs.ProfileViewBasic, - ) => void -}) { - const pal = usePalette('default') - const {_} = useLingui() - const {closeModal} = useModalControls() - const {isMobile} = useWebMediaQueries() - const [query, setQuery] = useState('') - const autocomplete = useActorAutocompleteQuery(query) - const {data: memberships} = useDangerousListMembershipsQuery() - const [isKeyboardVisible] = useIsKeyboardVisible() - - const onPressCancelSearch = useCallback(() => setQuery(''), [setQuery]) - - return ( - <SafeAreaView - testID="listAddUserModal" - style={[pal.view, isWeb ? styles.fixedHeight : s.flex1]}> - <View style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> - <View style={[styles.searchContainer, pal.border]}> - <FontAwesomeIcon icon="search" size={16} /> - <TextInput - testID="searchInput" - style={[styles.searchInput, pal.border, pal.text]} - placeholder={_(msg`Search for users`)} - placeholderTextColor={pal.colors.textLight} - value={query} - onChangeText={setQuery} - accessible={true} - accessibilityLabel={_(msg`Search`)} - accessibilityHint="" - autoFocus - autoCapitalize="none" - autoComplete="off" - autoCorrect={false} - selectTextOnFocus - /> - {query ? ( - <Pressable - onPress={onPressCancelSearch} - accessibilityRole="button" - accessibilityLabel={_(msg`Cancel search`)} - accessibilityHint={_(msg`Exits inputting search query`)} - onAccessibilityEscape={onPressCancelSearch} - hitSlop={HITSLOP_20}> - <FontAwesomeIcon - icon="xmark" - size={16} - color={pal.colors.textLight} - /> - </Pressable> - ) : undefined} - </View> - <ScrollView - style={[s.flex1]} - keyboardDismissMode="none" - keyboardShouldPersistTaps="always"> - {autocomplete.isLoading ? ( - <View style={{marginVertical: 20}}> - <ActivityIndicator /> - </View> - ) : autocomplete.data?.length ? ( - <> - {autocomplete.data.slice(0, 40).map((item, i) => ( - <UserResult - key={item.did} - list={list} - profile={item} - memberships={memberships} - noBorder={i === 0} - onChange={onChange} - /> - ))} - </> - ) : ( - <Text - type="xl" - style={[ - pal.textLight, - {paddingHorizontal: 12, paddingVertical: 16}, - ]}> - <Trans>No results found for {query}</Trans> - </Text> - )} - </ScrollView> - <View - style={[ - styles.btnContainer, - {paddingBottom: isKeyboardVisible ? 10 : 20}, - ]}> - <Button - testID="doneBtn" - type="default" - onPress={() => { - closeModal() - }} - accessibilityLabel={_(msg`Done`)} - accessibilityHint="" - label={_(msg({message: 'Done', context: 'action'}))} - labelContainerStyle={{justifyContent: 'center', padding: 4}} - labelStyle={[s.f18]} - /> - </View> - </View> - </SafeAreaView> - ) -} - -function UserResult({ - profile, - list, - memberships, - noBorder, - onChange, -}: { - profile: AppBskyActorDefs.ProfileViewBasic - list: AppBskyGraphDefs.ListView - memberships: ListMembersip[] | undefined - noBorder: boolean - onChange?: ( - type: 'add' | 'remove', - profile: AppBskyActorDefs.ProfileViewBasic, - ) => void | undefined -}) { - const pal = usePalette('default') - const {_} = useLingui() - const [isProcessing, setIsProcessing] = useState(false) - const membership = React.useMemo( - () => getMembership(memberships, list.uri, profile.did), - [memberships, list.uri, profile.did], - ) - const listMembershipAddMutation = useListMembershipAddMutation() - const listMembershipRemoveMutation = useListMembershipRemoveMutation() - - const onToggleMembership = useCallback(async () => { - if (typeof membership === 'undefined') { - return - } - setIsProcessing(true) - try { - if (membership === false) { - await listMembershipAddMutation.mutateAsync({ - listUri: list.uri, - actorDid: profile.did, - }) - Toast.show(_(msg`Added to list`)) - onChange?.('add', profile) - } else { - await listMembershipRemoveMutation.mutateAsync({ - listUri: list.uri, - actorDid: profile.did, - membershipUri: membership, - }) - Toast.show(_(msg`Removed from list`)) - onChange?.('remove', profile) - } - } catch (e) { - Toast.show(cleanError(e), 'xmark') - } finally { - setIsProcessing(false) - } - }, [ - _, - list, - profile, - membership, - setIsProcessing, - onChange, - listMembershipAddMutation, - listMembershipRemoveMutation, - ]) - - return ( - <View - style={[ - pal.border, - { - flexDirection: 'row', - alignItems: 'center', - borderTopWidth: noBorder ? 0 : 1, - paddingHorizontal: 8, - }, - ]}> - <View - style={{ - width: 54, - paddingLeft: 4, - }}> - <UserAvatar - size={40} - avatar={profile.avatar} - type={profile.associated?.labeler ? 'labeler' : 'user'} - /> - </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> - {isProcessing || typeof membership === 'undefined' ? ( - <ActivityIndicator /> - ) : ( - <Button - testID={`user-${profile.handle}-addBtn`} - type="default" - label={membership === false ? _(msg`Add`) : _(msg`Remove`)} - onPress={onToggleMembership} - /> - )} - </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: 10, - }, -}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 9ad651b4f..b4572172c 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -17,7 +17,6 @@ import * as InviteCodesModal from './InviteCodes' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as LinkWarningModal from './LinkWarning' -import * as ListAddUserModal from './ListAddRemoveUsers' import * as UserAddRemoveListsModal from './UserAddRemoveLists' import * as VerifyEmailModal from './VerifyEmail' @@ -61,9 +60,6 @@ export function ModalsContainer() { } else if (activeModal?.name === 'user-add-remove-lists') { snapPoints = UserAddRemoveListsModal.snapPoints element = <UserAddRemoveListsModal.Component {...activeModal} /> - } else if (activeModal?.name === 'list-add-remove-users') { - 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 0c49c8771..74ee7c210 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -4,7 +4,7 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import type {Modal as ModalIface} from '#/state/modals' +import {type Modal as ModalIface} from '#/state/modals' import {useModalControls, useModals} from '#/state/modals' import * as ChangeEmailModal from './ChangeEmail' import * as ChangePasswordModal from './ChangePassword' @@ -16,7 +16,6 @@ import * as InviteCodesModal from './InviteCodes' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as LinkWarningModal from './LinkWarning' -import * as ListAddUserModal from './ListAddRemoveUsers' import * as UserAddRemoveLists from './UserAddRemoveLists' import * as VerifyEmailModal from './VerifyEmail' @@ -65,8 +64,6 @@ function Modal({modal}: {modal: ModalIface}) { element = <CreateOrEditListModal.Component {...modal} /> } else if (modal.name === 'user-add-remove-lists') { element = <UserAddRemoveLists.Component {...modal} /> - } else if (modal.name === 'list-add-remove-users') { - 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/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 966534d97..61f1eb745 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -5,7 +5,7 @@ import { AppBskyGraphDefs, AtUri, moderateUserList, - ModerationOpts, + type ModerationOpts, RichText as RichTextAPI, } from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' @@ -21,8 +21,11 @@ import {useSetTitle} from '#/lib/hooks/useSetTitle' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {ComposeIcon2} from '#/lib/icons' import {makeListLink} from '#/lib/routes/links' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {NavigationProp} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {type NavigationProp} from '#/lib/routes/types' import {shareUrl} from '#/lib/sharing' import {cleanError} from '#/lib/strings/errors' import {toShareUrl} from '#/lib/strings/url-helpers' @@ -38,12 +41,12 @@ import { useListMuteMutation, useListQuery, } from '#/state/queries/list' -import {FeedDescriptor} from '#/state/queries/post-feed' +import {type FeedDescriptor} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import { useAddSavedFeedsMutation, usePreferencesQuery, - UsePreferencesQueryResponse, + type UsePreferencesQueryResponse, useRemoveFeedMutation, useUpdateSavedFeedsMutation, } from '#/state/queries/preferences' @@ -60,10 +63,10 @@ import {EmptyState} from '#/view/com/util/EmptyState' import {FAB} from '#/view/com/util/fab/FAB' import {Button} from '#/view/com/util/forms/Button' import { - DropdownItem, + type DropdownItem, NativeDropdown, } from '#/view/com/util/forms/NativeDropdown' -import {ListRef} from '#/view/com/util/List' +import {type ListRef} from '#/view/com/util/List' import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' import {LoadingScreen} from '#/view/com/util/LoadingScreen' import {Text} from '#/view/com/util/text/Text' @@ -72,6 +75,7 @@ import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen' import {atoms as a} from '#/alf' import {Button as NewButton, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' +import {ListAddRemoveUsersDialog} from '#/components/dialogs/lists/ListAddRemoveUsersDialog' import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' import * as Layout from '#/components/Layout' import * as Hider from '#/components/moderation/Hider' @@ -157,12 +161,12 @@ function ProfileListScreenLoaded({ const {rkey} = route.params const feedSectionRef = React.useRef<SectionRef>(null) const aboutSectionRef = React.useRef<SectionRef>(null) - const {openModal} = useModalControls() const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST const isScreenFocused = useIsFocused() const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1 const isOwner = currentAccount?.did === list.creator.did const scrollElRef = useAnimatedRef() + const addUserDialogControl = useDialogControl() const sectionTitlesCurate = [_(msg`Posts`), _(msg`People`)] const moderation = React.useMemo(() => { @@ -177,17 +181,11 @@ function ProfileListScreenLoaded({ }, [setMinimalShellMode]), ) - const onPressAddUser = useCallback(() => { - openModal({ - name: 'list-add-remove-users', - list, - onChange() { - if (isCurateList) { - truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`)) - } - }, - }) - }, [openModal, list, isCurateList, queryClient]) + const onChangeMembers = useCallback(() => { + if (isCurateList) { + truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`)) + } + }, [list.uri, isCurateList, queryClient]) const onCurrentPageSelected = React.useCallback( (index: number) => { @@ -225,7 +223,7 @@ function ProfileListScreenLoaded({ headerHeight={headerHeight} isFocused={isScreenFocused && isFocused} isOwner={isOwner} - onPressAddUser={onPressAddUser} + onPressAddUser={addUserDialogControl.open} /> )} {({headerHeight, scrollElRef}) => ( @@ -233,7 +231,7 @@ function ProfileListScreenLoaded({ ref={aboutSectionRef} scrollElRef={scrollElRef as ListRef} list={list} - onPressAddUser={onPressAddUser} + onPressAddUser={addUserDialogControl.open} headerHeight={headerHeight} /> )} @@ -253,6 +251,11 @@ function ProfileListScreenLoaded({ accessibilityHint="" /> </View> + <ListAddRemoveUsersDialog + control={addUserDialogControl} + list={list} + onChange={onChangeMembers} + /> </Hider.Content> </Hider.Outer> ) @@ -268,7 +271,7 @@ function ProfileListScreenLoaded({ <AboutSection list={list} scrollElRef={scrollElRef as ListRef} - onPressAddUser={onPressAddUser} + onPressAddUser={addUserDialogControl.open} headerHeight={0} /> <FAB @@ -286,6 +289,11 @@ function ProfileListScreenLoaded({ accessibilityHint="" /> </View> + <ListAddRemoveUsersDialog + control={addUserDialogControl} + list={list} + onChange={onChangeMembers} + /> </Hider.Content> </Hider.Outer> ) |