Bluesky app fork with some witchin' additions 💫

Use `SearchablePeopleList` for add user to list dialog, replace old modal (#8212)

* move to dialogs dir

* make searchable people list more generic

* new list-add-remove-users dialog

* update header text

* fix header on android

* delete old modal

* reduce spacing on items

authored by samuel.fm and committed by

GitHub 719d7b7a 4f316538

+378 -465
+33 -15
src/components/ProfileCard.tsx
··· 166 166 profile: bsky.profile.AnyProfileView 167 167 moderationOpts: ModerationOpts 168 168 }) { 169 - const t = useTheme() 169 + return ( 170 + <View style={[a.flex_1]}> 171 + <Name profile={profile} moderationOpts={moderationOpts} /> 172 + <Handle profile={profile} /> 173 + </View> 174 + ) 175 + } 176 + 177 + export function Name({ 178 + profile, 179 + moderationOpts, 180 + }: { 181 + profile: bsky.profile.AnyProfileView 182 + moderationOpts: ModerationOpts 183 + }) { 170 184 const moderation = moderateProfile(profile, moderationOpts) 171 185 const name = sanitizeDisplayName( 172 186 profile.displayName || sanitizeHandle(profile.handle), 173 187 moderation.ui('displayName'), 174 188 ) 189 + return ( 190 + <Text 191 + emoji 192 + style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]} 193 + numberOfLines={1}> 194 + {name} 195 + </Text> 196 + ) 197 + } 198 + 199 + export function Handle({profile}: {profile: bsky.profile.AnyProfileView}) { 200 + const t = useTheme() 175 201 const handle = sanitizeHandle(profile.handle, '@') 176 202 177 203 return ( 178 - <View style={[a.flex_1]}> 179 - <Text 180 - emoji 181 - style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]} 182 - numberOfLines={1}> 183 - {name} 184 - </Text> 185 - <Text 186 - emoji 187 - style={[a.leading_snug, t.atoms.text_contrast_medium]} 188 - numberOfLines={1}> 189 - {handle} 190 - </Text> 191 - </View> 204 + <Text 205 + emoji 206 + style={[a.leading_snug, t.atoms.text_contrast_medium]} 207 + numberOfLines={1}> 208 + {handle} 209 + </Text> 192 210 ) 193 211 } 194 212
+180
src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx
··· 1 + import {useCallback, useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import {type AppBskyGraphDefs, type ModerationOpts} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {cleanError} from '#/lib/strings/errors' 8 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 9 + import { 10 + getMembership, 11 + type ListMembersip, 12 + useDangerousListMembershipsQuery, 13 + useListMembershipAddMutation, 14 + useListMembershipRemoveMutation, 15 + } from '#/state/queries/list-memberships' 16 + import * as Toast from '#/view/com/util/Toast' 17 + import {atoms as a} from '#/alf' 18 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 + import * as Dialog from '#/components/Dialog' 20 + import { 21 + type ProfileItem, 22 + SearchablePeopleList, 23 + } from '#/components/dialogs/SearchablePeopleList' 24 + import {Loader} from '#/components/Loader' 25 + import * as ProfileCard from '#/components/ProfileCard' 26 + import type * as bsky from '#/types/bsky' 27 + 28 + export function ListAddRemoveUsersDialog({ 29 + control, 30 + list, 31 + onChange, 32 + }: { 33 + control: Dialog.DialogControlProps 34 + list: AppBskyGraphDefs.ListView 35 + onChange?: ( 36 + type: 'add' | 'remove', 37 + profile: bsky.profile.AnyProfileView, 38 + ) => void | undefined 39 + }) { 40 + return ( 41 + <Dialog.Outer control={control} testID="listAddRemoveUsersDialog"> 42 + <Dialog.Handle /> 43 + <DialogInner list={list} onChange={onChange} /> 44 + </Dialog.Outer> 45 + ) 46 + } 47 + 48 + function DialogInner({ 49 + list, 50 + onChange, 51 + }: { 52 + list: AppBskyGraphDefs.ListView 53 + onChange?: ( 54 + type: 'add' | 'remove', 55 + profile: bsky.profile.AnyProfileView, 56 + ) => void | undefined 57 + }) { 58 + const {_} = useLingui() 59 + const moderationOpts = useModerationOpts() 60 + const {data: memberships} = useDangerousListMembershipsQuery() 61 + 62 + const renderProfileCard = useCallback( 63 + (item: ProfileItem) => { 64 + return ( 65 + <UserResult 66 + profile={item.profile} 67 + onChange={onChange} 68 + memberships={memberships} 69 + list={list} 70 + moderationOpts={moderationOpts} 71 + /> 72 + ) 73 + }, 74 + [onChange, memberships, list, moderationOpts], 75 + ) 76 + 77 + return ( 78 + <SearchablePeopleList 79 + title={_(msg`Add people to list`)} 80 + renderProfileCard={renderProfileCard} 81 + /> 82 + ) 83 + } 84 + 85 + function UserResult({ 86 + profile, 87 + list, 88 + memberships, 89 + onChange, 90 + moderationOpts, 91 + }: { 92 + profile: bsky.profile.AnyProfileView 93 + list: AppBskyGraphDefs.ListView 94 + memberships: ListMembersip[] | undefined 95 + onChange?: ( 96 + type: 'add' | 'remove', 97 + profile: bsky.profile.AnyProfileView, 98 + ) => void | undefined 99 + moderationOpts?: ModerationOpts 100 + }) { 101 + const {_} = useLingui() 102 + const membership = useMemo( 103 + () => getMembership(memberships, list.uri, profile.did), 104 + [memberships, list.uri, profile.did], 105 + ) 106 + const {mutate: listMembershipAdd, isPending: isAddingPending} = 107 + useListMembershipAddMutation({ 108 + onSuccess: () => { 109 + Toast.show(_(msg`Added to list`)) 110 + onChange?.('add', profile) 111 + }, 112 + onError: e => Toast.show(cleanError(e), 'xmark'), 113 + }) 114 + const {mutate: listMembershipRemove, isPending: isRemovingPending} = 115 + useListMembershipRemoveMutation({ 116 + onSuccess: () => { 117 + Toast.show(_(msg`Removed from list`)) 118 + onChange?.('remove', profile) 119 + }, 120 + onError: e => Toast.show(cleanError(e), 'xmark'), 121 + }) 122 + const isMutating = isAddingPending || isRemovingPending 123 + 124 + const onToggleMembership = useCallback(() => { 125 + if (typeof membership === 'undefined') { 126 + return 127 + } 128 + if (membership === false) { 129 + listMembershipAdd({ 130 + listUri: list.uri, 131 + actorDid: profile.did, 132 + }) 133 + } else { 134 + listMembershipRemove({ 135 + listUri: list.uri, 136 + actorDid: profile.did, 137 + membershipUri: membership, 138 + }) 139 + } 140 + }, [list, profile, membership, listMembershipAdd, listMembershipRemove]) 141 + 142 + if (!moderationOpts) return null 143 + 144 + return ( 145 + <View style={[a.flex_1, a.py_sm, a.px_lg]}> 146 + <ProfileCard.Header> 147 + <ProfileCard.Avatar profile={profile} moderationOpts={moderationOpts} /> 148 + <View style={[a.flex_1]}> 149 + <ProfileCard.Name profile={profile} moderationOpts={moderationOpts} /> 150 + <ProfileCard.Handle profile={profile} /> 151 + </View> 152 + {membership !== undefined && ( 153 + <Button 154 + label={ 155 + membership === false 156 + ? _(msg`Add user to list`) 157 + : _(msg`Remove user from list`) 158 + } 159 + onPress={onToggleMembership} 160 + disabled={isMutating} 161 + size="small" 162 + variant="solid" 163 + color="secondary"> 164 + {isMutating ? ( 165 + <ButtonIcon icon={Loader} /> 166 + ) : ( 167 + <ButtonText> 168 + {membership === false ? ( 169 + <Trans>Add</Trans> 170 + ) : ( 171 + <Trans>Remove</Trans> 172 + )} 173 + </ButtonText> 174 + )} 175 + </Button> 176 + )} 177 + </ProfileCard.Header> 178 + </View> 179 + ) 180 + }
+2 -1
src/components/dms/dialogs/NewChatDialog.tsx
··· 11 11 import {useTheme} from '#/alf' 12 12 import * as Dialog from '#/components/Dialog' 13 13 import {useDialogControl} from '#/components/Dialog' 14 + import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' 14 15 import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' 15 16 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 16 - import {SearchablePeopleList} from './SearchablePeopleList' 17 17 18 18 export function NewChat({ 19 19 control, ··· 71 71 <SearchablePeopleList 72 72 title={_(msg`Start a new chat`)} 73 73 onSelectChat={onCreateChat} 74 + sortByMessageDeclaration 74 75 /> 75 76 </Dialog.Outer> 76 77
+108 -86
src/components/dms/dialogs/SearchablePeopleList.tsx src/components/dialogs/SearchablePeopleList.tsx
··· 1 - import React, { 1 + import { 2 + Fragment, 2 3 useCallback, 3 4 useLayoutEffect, 4 5 useMemo, ··· 6 7 useState, 7 8 } from 'react' 8 9 import {TextInput, View} from 'react-native' 9 - import {moderateProfile, ModerationOpts} from '@atproto/api' 10 + import {moderateProfile, type ModerationOpts} from '@atproto/api' 10 11 import {msg, Trans} from '@lingui/macro' 11 12 import {useLingui} from '@lingui/react' 12 13 ··· 18 19 import {useListConvosQuery} from '#/state/queries/messages/list-conversations' 19 20 import {useProfileFollowsQuery} from '#/state/queries/profile-follows' 20 21 import {useSession} from '#/state/session' 21 - import {ListMethods} from '#/view/com/util/List' 22 - import {UserAvatar} from '#/view/com/util/UserAvatar' 23 - import {atoms as a, native, useTheme, web} from '#/alf' 22 + import {type ListMethods} from '#/view/com/util/List' 23 + import {android, atoms as a, native, useTheme, web} from '#/alf' 24 24 import {Button, ButtonIcon} from '#/components/Button' 25 25 import * as Dialog from '#/components/Dialog' 26 26 import {canBeMessaged} from '#/components/dms/util' 27 27 import {useInteractionState} from '#/components/hooks/useInteractionState' 28 28 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 29 29 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 30 + import * as ProfileCard from '#/components/ProfileCard' 30 31 import {Text} from '#/components/Typography' 31 - import * as bsky from '#/types/bsky' 32 + import type * as bsky from '#/types/bsky' 33 + 34 + export type ProfileItem = { 35 + type: 'profile' 36 + key: string 37 + profile: bsky.profile.AnyProfileView 38 + } 39 + 40 + type EmptyItem = { 41 + type: 'empty' 42 + key: string 43 + message: string 44 + } 45 + 46 + type PlaceholderItem = { 47 + type: 'placeholder' 48 + key: string 49 + } 32 50 33 - type Item = 34 - | { 35 - type: 'profile' 36 - key: string 37 - enabled: boolean 38 - profile: bsky.profile.AnyProfileView 39 - } 40 - | { 41 - type: 'empty' 42 - key: string 43 - message: string 44 - } 45 - | { 46 - type: 'placeholder' 47 - key: string 48 - } 49 - | { 50 - type: 'error' 51 - key: string 52 - } 51 + type ErrorItem = { 52 + type: 'error' 53 + key: string 54 + } 55 + 56 + type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem 53 57 54 58 export function SearchablePeopleList({ 55 59 title, 56 - onSelectChat, 57 60 showRecentConvos, 61 + sortByMessageDeclaration, 62 + onSelectChat, 63 + renderProfileCard, 58 64 }: { 59 65 title: string 60 - onSelectChat: (did: string) => void 61 66 showRecentConvos?: boolean 62 - }) { 67 + sortByMessageDeclaration?: boolean 68 + } & ( 69 + | { 70 + renderProfileCard: (item: ProfileItem) => React.ReactNode 71 + onSelectChat?: undefined 72 + } 73 + | { 74 + onSelectChat: (did: string) => void 75 + renderProfileCard?: undefined 76 + } 77 + )) { 63 78 const t = useTheme() 64 79 const {_} = useLingui() 65 80 const moderationOpts = useModerationOpts() ··· 98 113 _items.push({ 99 114 type: 'profile', 100 115 key: profile.did, 101 - enabled: canBeMessaged(profile), 102 116 profile, 103 117 }) 104 118 } 105 119 106 - _items = _items.sort(item => { 107 - // @ts-ignore 108 - return item.enabled ? -1 : 1 109 - }) 120 + if (sortByMessageDeclaration) { 121 + _items = _items.sort(item => { 122 + return item.type === 'profile' && canBeMessaged(item.profile) 123 + ? -1 124 + : 1 125 + }) 126 + } 110 127 } 111 128 } else { 112 129 const placeholders: Item[] = Array(10) ··· 134 151 _items.push({ 135 152 type: 'profile', 136 153 key: profile.did, 137 - enabled: true, 138 154 profile, 139 155 }) 140 156 } 141 157 } 142 158 } 143 159 144 - let followsItems: typeof _items = [] 160 + let followsItems: ProfileItem[] = [] 145 161 146 162 for (const page of follows.pages) { 147 163 for (const profile of page.follows) { ··· 150 166 followsItems.push({ 151 167 type: 'profile', 152 168 key: profile.did, 153 - enabled: canBeMessaged(profile), 154 169 profile, 155 170 }) 156 171 } 157 172 } 158 173 159 - // only sort follows 160 - followsItems = followsItems.sort(item => { 161 - // @ts-ignore 162 - return item.enabled ? -1 : 1 163 - }) 174 + if (sortByMessageDeclaration) { 175 + // only sort follows 176 + followsItems = followsItems.sort(item => { 177 + return canBeMessaged(item.profile) ? -1 : 1 178 + }) 179 + } 164 180 165 181 // then append 166 182 _items.push(...followsItems) ··· 173 189 _items.push({ 174 190 type: 'profile', 175 191 key: profile.did, 176 - enabled: canBeMessaged(profile), 177 192 profile, 178 193 }) 179 194 } 180 195 } 181 196 182 - _items = _items.sort(item => { 183 - // @ts-ignore 184 - return item.enabled ? -1 : 1 185 - }) 197 + if (sortByMessageDeclaration) { 198 + _items = _items.sort(item => { 199 + return item.type === 'profile' && canBeMessaged(item.profile) 200 + ? -1 201 + : 1 202 + }) 203 + } 186 204 } else { 187 205 _items.push(...placeholders) 188 206 } ··· 198 216 follows, 199 217 convos, 200 218 showRecentConvos, 219 + sortByMessageDeclaration, 201 220 ]) 202 221 203 222 if (searchText && !isFetching && !items.length && !isError) { ··· 208 227 ({item}: {item: Item}) => { 209 228 switch (item.type) { 210 229 case 'profile': { 211 - return ( 212 - <ProfileCard 213 - key={item.key} 214 - enabled={item.enabled} 215 - profile={item.profile} 216 - moderationOpts={moderationOpts!} 217 - onPress={onSelectChat} 218 - /> 219 - ) 230 + if (renderProfileCard) { 231 + return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment> 232 + } else { 233 + return ( 234 + <DefaultProfileCard 235 + key={item.key} 236 + profile={item.profile} 237 + moderationOpts={moderationOpts!} 238 + onPress={onSelectChat} 239 + /> 240 + ) 241 + } 220 242 } 221 243 case 'placeholder': { 222 244 return <ProfileCardSkeleton key={item.key} /> ··· 228 250 return null 229 251 } 230 252 }, 231 - [moderationOpts, onSelectChat], 253 + [moderationOpts, onSelectChat, renderProfileCard], 232 254 ) 233 255 234 256 useLayoutEffect(() => { ··· 247 269 a.relative, 248 270 web(a.pt_lg), 249 271 native(a.pt_4xl), 272 + android({ 273 + borderTopLeftRadius: a.rounded_md.borderRadius, 274 + borderTopRightRadius: a.rounded_md.borderRadius, 275 + }), 250 276 a.pb_xs, 251 277 a.px_lg, 252 278 a.border_b, ··· 327 353 ) 328 354 } 329 355 330 - function ProfileCard({ 331 - enabled, 356 + function DefaultProfileCard({ 332 357 profile, 333 358 moderationOpts, 334 359 onPress, 335 360 }: { 336 - enabled: boolean 337 361 profile: bsky.profile.AnyProfileView 338 362 moderationOpts: ModerationOpts 339 363 onPress: (did: string) => void 340 364 }) { 341 365 const t = useTheme() 342 366 const {_} = useLingui() 367 + const enabled = canBeMessaged(profile) 343 368 const moderation = moderateProfile(profile, moderationOpts) 344 369 const handle = sanitizeHandle(profile.handle, '@') 345 370 const displayName = sanitizeDisplayName( ··· 360 385 <View 361 386 style={[ 362 387 a.flex_1, 363 - a.py_md, 388 + a.py_sm, 364 389 a.px_lg, 365 - a.gap_md, 366 - a.align_center, 367 - a.flex_row, 368 390 !enabled 369 391 ? {opacity: 0.5} 370 - : pressed || focused 392 + : pressed || focused || hovered 371 393 ? t.atoms.bg_contrast_25 372 - : hovered 373 - ? t.atoms.bg_contrast_50 374 394 : t.atoms.bg, 375 395 ]}> 376 - <UserAvatar 377 - size={42} 378 - avatar={profile.avatar} 379 - moderation={moderation.ui('avatar')} 380 - type={profile.associated?.labeler ? 'labeler' : 'user'} 381 - /> 382 - <View style={[a.flex_1, a.gap_2xs]}> 383 - <Text 384 - style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]} 385 - numberOfLines={1} 386 - emoji> 387 - {displayName} 388 - </Text> 389 - <Text 390 - style={[a.leading_tight, t.atoms.text_contrast_high]} 391 - numberOfLines={2}> 392 - {!enabled ? <Trans>{handle} can't be messaged</Trans> : handle} 393 - </Text> 394 - </View> 396 + <ProfileCard.Header> 397 + <ProfileCard.Avatar 398 + profile={profile} 399 + moderationOpts={moderationOpts} 400 + /> 401 + <View style={[a.flex_1]}> 402 + <ProfileCard.Name 403 + profile={profile} 404 + moderationOpts={moderationOpts} 405 + /> 406 + {enabled ? ( 407 + <ProfileCard.Handle profile={profile} /> 408 + ) : ( 409 + <Text 410 + style={[a.leading_snug, t.atoms.text_contrast_high]} 411 + numberOfLines={2}> 412 + <Trans>{handle} can't be messaged</Trans> 413 + </Text> 414 + )} 415 + </View> 416 + </ProfileCard.Header> 395 417 </View> 396 418 )} 397 419 </Button>
+2 -1
src/components/dms/dialogs/ShareViaChatDialog.tsx
··· 7 7 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 8 8 import * as Toast from '#/view/com/util/Toast' 9 9 import * as Dialog from '#/components/Dialog' 10 - import {SearchablePeopleList} from './SearchablePeopleList' 10 + import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' 11 11 12 12 export function SendViaChatDialog({ 13 13 control, ··· 62 62 title={_(msg`Send post to...`)} 63 63 onSelectChat={onCreateChat} 64 64 showRecentConvos 65 + sortByMessageDeclaration 65 66 /> 66 67 ) 67 68 }
+2 -12
src/state/modals/index.tsx
··· 1 1 import React from 'react' 2 - import {Image as RNImage} from 'react-native-image-crop-picker' 3 - import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' 2 + import {type Image as RNImage} from 'react-native-image-crop-picker' 3 + import {type AppBskyActorDefs, type AppBskyGraphDefs} from '@atproto/api' 4 4 5 5 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 6 6 ··· 24 24 displayName: string 25 25 onAdd?: (listUri: string) => void 26 26 onRemove?: (listUri: string) => void 27 - } 28 - 29 - export interface ListAddRemoveUsersModal { 30 - name: 'list-add-remove-users' 31 - list: AppBskyGraphDefs.ListView 32 - onChange?: ( 33 - type: 'add' | 'remove', 34 - profile: AppBskyActorDefs.ProfileViewBasic, 35 - ) => void 36 27 } 37 28 38 29 export interface CropImageModal { ··· 107 98 // Lists 108 99 | CreateOrEditListModal 109 100 | UserAddRemoveListsModal 110 - | ListAddRemoveUsersModal 111 101 112 102 // Posts 113 103 | CropImageModal
+20 -4
src/state/queries/list-memberships.ts
··· 90 90 return membership ? membership.membershipUri : false 91 91 } 92 92 93 - export function useListMembershipAddMutation() { 93 + export function useListMembershipAddMutation({ 94 + onSuccess, 95 + onError, 96 + }: { 97 + onSuccess?: (data: {uri: string; cid: string}) => void 98 + onError?: (error: Error) => void 99 + } = {}) { 94 100 const {currentAccount} = useSession() 95 101 const agent = useAgent() 96 102 const queryClient = useQueryClient() ··· 117 123 // -prf 118 124 return res 119 125 }, 120 - onSuccess(data, variables) { 126 + onSuccess: (data, variables) => { 121 127 // manually update the cache; a refetch is too expensive 122 128 let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY()) 123 129 if (memberships) { ··· 145 151 queryKey: LIST_MEMBERS_RQKEY(variables.listUri), 146 152 }) 147 153 }, 1e3) 154 + onSuccess?.(data) 148 155 }, 156 + onError, 149 157 }) 150 158 } 151 159 152 - export function useListMembershipRemoveMutation() { 160 + export function useListMembershipRemoveMutation({ 161 + onSuccess, 162 + onError, 163 + }: { 164 + onSuccess?: (data: void) => void 165 + onError?: (error: Error) => void 166 + } = {}) { 153 167 const {currentAccount} = useSession() 154 168 const agent = useAgent() 155 169 const queryClient = useQueryClient() ··· 172 186 // query for that, so we use a timeout below 173 187 // -prf 174 188 }, 175 - onSuccess(data, variables) { 189 + onSuccess: (data, variables) => { 176 190 // manually update the cache; a refetch is too expensive 177 191 let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY()) 178 192 if (memberships) { ··· 192 206 queryKey: LIST_MEMBERS_RQKEY(variables.listUri), 193 207 }) 194 208 }, 1e3) 209 + onSuccess?.(data) 195 210 }, 211 + onError, 196 212 }) 197 213 }
-316
src/view/com/modals/ListAddRemoveUsers.tsx
··· 1 - import React, {useCallback, useState} from 'react' 2 - import { 3 - ActivityIndicator, 4 - Pressable, 5 - SafeAreaView, 6 - StyleSheet, 7 - View, 8 - } from 'react-native' 9 - import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' 10 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 11 - import {msg, Trans} from '@lingui/macro' 12 - import {useLingui} from '@lingui/react' 13 - 14 - import {HITSLOP_20} from '#/lib/constants' 15 - import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' 16 - import {usePalette} from '#/lib/hooks/usePalette' 17 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 18 - import {sanitizeDisplayName} from '#/lib/strings/display-names' 19 - import {cleanError} from '#/lib/strings/errors' 20 - import {sanitizeHandle} from '#/lib/strings/handles' 21 - import {colors, s} from '#/lib/styles' 22 - import {isWeb} from '#/platform/detection' 23 - import {useModalControls} from '#/state/modals' 24 - import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 25 - import { 26 - getMembership, 27 - ListMembersip, 28 - useDangerousListMembershipsQuery, 29 - useListMembershipAddMutation, 30 - useListMembershipRemoveMutation, 31 - } from '#/state/queries/list-memberships' 32 - import {Button} from '../util/forms/Button' 33 - import {Text} from '../util/text/Text' 34 - import * as Toast from '../util/Toast' 35 - import {UserAvatar} from '../util/UserAvatar' 36 - import {ScrollView, TextInput} from './util' 37 - 38 - export const snapPoints = ['90%'] 39 - 40 - export function Component({ 41 - list, 42 - onChange, 43 - }: { 44 - list: AppBskyGraphDefs.ListView 45 - onChange?: ( 46 - type: 'add' | 'remove', 47 - profile: AppBskyActorDefs.ProfileViewBasic, 48 - ) => void 49 - }) { 50 - const pal = usePalette('default') 51 - const {_} = useLingui() 52 - const {closeModal} = useModalControls() 53 - const {isMobile} = useWebMediaQueries() 54 - const [query, setQuery] = useState('') 55 - const autocomplete = useActorAutocompleteQuery(query) 56 - const {data: memberships} = useDangerousListMembershipsQuery() 57 - const [isKeyboardVisible] = useIsKeyboardVisible() 58 - 59 - const onPressCancelSearch = useCallback(() => setQuery(''), [setQuery]) 60 - 61 - return ( 62 - <SafeAreaView 63 - testID="listAddUserModal" 64 - style={[pal.view, isWeb ? styles.fixedHeight : s.flex1]}> 65 - <View style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> 66 - <View style={[styles.searchContainer, pal.border]}> 67 - <FontAwesomeIcon icon="search" size={16} /> 68 - <TextInput 69 - testID="searchInput" 70 - style={[styles.searchInput, pal.border, pal.text]} 71 - placeholder={_(msg`Search for users`)} 72 - placeholderTextColor={pal.colors.textLight} 73 - value={query} 74 - onChangeText={setQuery} 75 - accessible={true} 76 - accessibilityLabel={_(msg`Search`)} 77 - accessibilityHint="" 78 - autoFocus 79 - autoCapitalize="none" 80 - autoComplete="off" 81 - autoCorrect={false} 82 - selectTextOnFocus 83 - /> 84 - {query ? ( 85 - <Pressable 86 - onPress={onPressCancelSearch} 87 - accessibilityRole="button" 88 - accessibilityLabel={_(msg`Cancel search`)} 89 - accessibilityHint={_(msg`Exits inputting search query`)} 90 - onAccessibilityEscape={onPressCancelSearch} 91 - hitSlop={HITSLOP_20}> 92 - <FontAwesomeIcon 93 - icon="xmark" 94 - size={16} 95 - color={pal.colors.textLight} 96 - /> 97 - </Pressable> 98 - ) : undefined} 99 - </View> 100 - <ScrollView 101 - style={[s.flex1]} 102 - keyboardDismissMode="none" 103 - keyboardShouldPersistTaps="always"> 104 - {autocomplete.isLoading ? ( 105 - <View style={{marginVertical: 20}}> 106 - <ActivityIndicator /> 107 - </View> 108 - ) : autocomplete.data?.length ? ( 109 - <> 110 - {autocomplete.data.slice(0, 40).map((item, i) => ( 111 - <UserResult 112 - key={item.did} 113 - list={list} 114 - profile={item} 115 - memberships={memberships} 116 - noBorder={i === 0} 117 - onChange={onChange} 118 - /> 119 - ))} 120 - </> 121 - ) : ( 122 - <Text 123 - type="xl" 124 - style={[ 125 - pal.textLight, 126 - {paddingHorizontal: 12, paddingVertical: 16}, 127 - ]}> 128 - <Trans>No results found for {query}</Trans> 129 - </Text> 130 - )} 131 - </ScrollView> 132 - <View 133 - style={[ 134 - styles.btnContainer, 135 - {paddingBottom: isKeyboardVisible ? 10 : 20}, 136 - ]}> 137 - <Button 138 - testID="doneBtn" 139 - type="default" 140 - onPress={() => { 141 - closeModal() 142 - }} 143 - accessibilityLabel={_(msg`Done`)} 144 - accessibilityHint="" 145 - label={_(msg({message: 'Done', context: 'action'}))} 146 - labelContainerStyle={{justifyContent: 'center', padding: 4}} 147 - labelStyle={[s.f18]} 148 - /> 149 - </View> 150 - </View> 151 - </SafeAreaView> 152 - ) 153 - } 154 - 155 - function UserResult({ 156 - profile, 157 - list, 158 - memberships, 159 - noBorder, 160 - onChange, 161 - }: { 162 - profile: AppBskyActorDefs.ProfileViewBasic 163 - list: AppBskyGraphDefs.ListView 164 - memberships: ListMembersip[] | undefined 165 - noBorder: boolean 166 - onChange?: ( 167 - type: 'add' | 'remove', 168 - profile: AppBskyActorDefs.ProfileViewBasic, 169 - ) => void | undefined 170 - }) { 171 - const pal = usePalette('default') 172 - const {_} = useLingui() 173 - const [isProcessing, setIsProcessing] = useState(false) 174 - const membership = React.useMemo( 175 - () => getMembership(memberships, list.uri, profile.did), 176 - [memberships, list.uri, profile.did], 177 - ) 178 - const listMembershipAddMutation = useListMembershipAddMutation() 179 - const listMembershipRemoveMutation = useListMembershipRemoveMutation() 180 - 181 - const onToggleMembership = useCallback(async () => { 182 - if (typeof membership === 'undefined') { 183 - return 184 - } 185 - setIsProcessing(true) 186 - try { 187 - if (membership === false) { 188 - await listMembershipAddMutation.mutateAsync({ 189 - listUri: list.uri, 190 - actorDid: profile.did, 191 - }) 192 - Toast.show(_(msg`Added to list`)) 193 - onChange?.('add', profile) 194 - } else { 195 - await listMembershipRemoveMutation.mutateAsync({ 196 - listUri: list.uri, 197 - actorDid: profile.did, 198 - membershipUri: membership, 199 - }) 200 - Toast.show(_(msg`Removed from list`)) 201 - onChange?.('remove', profile) 202 - } 203 - } catch (e) { 204 - Toast.show(cleanError(e), 'xmark') 205 - } finally { 206 - setIsProcessing(false) 207 - } 208 - }, [ 209 - _, 210 - list, 211 - profile, 212 - membership, 213 - setIsProcessing, 214 - onChange, 215 - listMembershipAddMutation, 216 - listMembershipRemoveMutation, 217 - ]) 218 - 219 - return ( 220 - <View 221 - style={[ 222 - pal.border, 223 - { 224 - flexDirection: 'row', 225 - alignItems: 'center', 226 - borderTopWidth: noBorder ? 0 : 1, 227 - paddingHorizontal: 8, 228 - }, 229 - ]}> 230 - <View 231 - style={{ 232 - width: 54, 233 - paddingLeft: 4, 234 - }}> 235 - <UserAvatar 236 - size={40} 237 - avatar={profile.avatar} 238 - type={profile.associated?.labeler ? 'labeler' : 'user'} 239 - /> 240 - </View> 241 - <View 242 - style={{ 243 - flex: 1, 244 - paddingRight: 10, 245 - paddingTop: 10, 246 - paddingBottom: 10, 247 - }}> 248 - <Text 249 - type="lg" 250 - style={[s.bold, pal.text]} 251 - numberOfLines={1} 252 - lineHeight={1.2}> 253 - {sanitizeDisplayName( 254 - profile.displayName || sanitizeHandle(profile.handle), 255 - )} 256 - </Text> 257 - <Text type="md" style={[pal.textLight]} numberOfLines={1}> 258 - {sanitizeHandle(profile.handle, '@')} 259 - </Text> 260 - {!!profile.viewer?.followedBy && <View style={s.flexRow} />} 261 - </View> 262 - <View> 263 - {isProcessing || typeof membership === 'undefined' ? ( 264 - <ActivityIndicator /> 265 - ) : ( 266 - <Button 267 - testID={`user-${profile.handle}-addBtn`} 268 - type="default" 269 - label={membership === false ? _(msg`Add`) : _(msg`Remove`)} 270 - onPress={onToggleMembership} 271 - /> 272 - )} 273 - </View> 274 - </View> 275 - ) 276 - } 277 - 278 - const styles = StyleSheet.create({ 279 - fixedHeight: { 280 - // @ts-ignore web only -prf 281 - height: '80vh', 282 - }, 283 - titleSection: { 284 - paddingTop: isWeb ? 0 : 4, 285 - paddingBottom: isWeb ? 14 : 10, 286 - }, 287 - title: { 288 - textAlign: 'center', 289 - fontWeight: '600', 290 - marginBottom: 5, 291 - }, 292 - searchContainer: { 293 - flexDirection: 'row', 294 - alignItems: 'center', 295 - gap: 8, 296 - borderWidth: 1, 297 - borderRadius: 24, 298 - paddingHorizontal: 16, 299 - paddingVertical: 10, 300 - }, 301 - searchInput: { 302 - fontSize: 16, 303 - flex: 1, 304 - }, 305 - btn: { 306 - flexDirection: 'row', 307 - alignItems: 'center', 308 - justifyContent: 'center', 309 - borderRadius: 32, 310 - padding: 14, 311 - backgroundColor: colors.blue3, 312 - }, 313 - btnContainer: { 314 - paddingTop: 10, 315 - }, 316 - })
-4
src/view/com/modals/Modal.tsx
··· 17 17 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 18 18 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' 19 19 import * as LinkWarningModal from './LinkWarning' 20 - import * as ListAddUserModal from './ListAddRemoveUsers' 21 20 import * as UserAddRemoveListsModal from './UserAddRemoveLists' 22 21 import * as VerifyEmailModal from './VerifyEmail' 23 22 ··· 61 60 } else if (activeModal?.name === 'user-add-remove-lists') { 62 61 snapPoints = UserAddRemoveListsModal.snapPoints 63 62 element = <UserAddRemoveListsModal.Component {...activeModal} /> 64 - } else if (activeModal?.name === 'list-add-remove-users') { 65 - snapPoints = ListAddUserModal.snapPoints 66 - element = <ListAddUserModal.Component {...activeModal} /> 67 63 } else if (activeModal?.name === 'delete-account') { 68 64 snapPoints = DeleteAccountModal.snapPoints 69 65 element = <DeleteAccountModal.Component />
+1 -4
src/view/com/modals/Modal.web.tsx
··· 4 4 5 5 import {usePalette} from '#/lib/hooks/usePalette' 6 6 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 7 - import type {Modal as ModalIface} from '#/state/modals' 7 + import {type Modal as ModalIface} from '#/state/modals' 8 8 import {useModalControls, useModals} from '#/state/modals' 9 9 import * as ChangeEmailModal from './ChangeEmail' 10 10 import * as ChangePasswordModal from './ChangePassword' ··· 16 16 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 17 17 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' 18 18 import * as LinkWarningModal from './LinkWarning' 19 - import * as ListAddUserModal from './ListAddRemoveUsers' 20 19 import * as UserAddRemoveLists from './UserAddRemoveLists' 21 20 import * as VerifyEmailModal from './VerifyEmail' 22 21 ··· 65 64 element = <CreateOrEditListModal.Component {...modal} /> 66 65 } else if (modal.name === 'user-add-remove-lists') { 67 66 element = <UserAddRemoveLists.Component {...modal} /> 68 - } else if (modal.name === 'list-add-remove-users') { 69 - element = <ListAddUserModal.Component {...modal} /> 70 67 } else if (modal.name === 'crop-image') { 71 68 element = <CropImageModal.Component {...modal} /> 72 69 } else if (modal.name === 'delete-account') {
+30 -22
src/view/screens/ProfileList.tsx
··· 5 5 AppBskyGraphDefs, 6 6 AtUri, 7 7 moderateUserList, 8 - ModerationOpts, 8 + type ModerationOpts, 9 9 RichText as RichTextAPI, 10 10 } from '@atproto/api' 11 11 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' ··· 21 21 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 22 22 import {ComposeIcon2} from '#/lib/icons' 23 23 import {makeListLink} from '#/lib/routes/links' 24 - import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 25 - import {NavigationProp} from '#/lib/routes/types' 24 + import { 25 + type CommonNavigatorParams, 26 + type NativeStackScreenProps, 27 + } from '#/lib/routes/types' 28 + import {type NavigationProp} from '#/lib/routes/types' 26 29 import {shareUrl} from '#/lib/sharing' 27 30 import {cleanError} from '#/lib/strings/errors' 28 31 import {toShareUrl} from '#/lib/strings/url-helpers' ··· 38 41 useListMuteMutation, 39 42 useListQuery, 40 43 } from '#/state/queries/list' 41 - import {FeedDescriptor} from '#/state/queries/post-feed' 44 + import {type FeedDescriptor} from '#/state/queries/post-feed' 42 45 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 43 46 import { 44 47 useAddSavedFeedsMutation, 45 48 usePreferencesQuery, 46 - UsePreferencesQueryResponse, 49 + type UsePreferencesQueryResponse, 47 50 useRemoveFeedMutation, 48 51 useUpdateSavedFeedsMutation, 49 52 } from '#/state/queries/preferences' ··· 60 63 import {FAB} from '#/view/com/util/fab/FAB' 61 64 import {Button} from '#/view/com/util/forms/Button' 62 65 import { 63 - DropdownItem, 66 + type DropdownItem, 64 67 NativeDropdown, 65 68 } from '#/view/com/util/forms/NativeDropdown' 66 - import {ListRef} from '#/view/com/util/List' 69 + import {type ListRef} from '#/view/com/util/List' 67 70 import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' 68 71 import {LoadingScreen} from '#/view/com/util/LoadingScreen' 69 72 import {Text} from '#/view/com/util/text/Text' ··· 72 75 import {atoms as a} from '#/alf' 73 76 import {Button as NewButton, ButtonIcon, ButtonText} from '#/components/Button' 74 77 import {useDialogControl} from '#/components/Dialog' 78 + import {ListAddRemoveUsersDialog} from '#/components/dialogs/lists/ListAddRemoveUsersDialog' 75 79 import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' 76 80 import * as Layout from '#/components/Layout' 77 81 import * as Hider from '#/components/moderation/Hider' ··· 157 161 const {rkey} = route.params 158 162 const feedSectionRef = React.useRef<SectionRef>(null) 159 163 const aboutSectionRef = React.useRef<SectionRef>(null) 160 - const {openModal} = useModalControls() 161 164 const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST 162 165 const isScreenFocused = useIsFocused() 163 166 const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1 164 167 const isOwner = currentAccount?.did === list.creator.did 165 168 const scrollElRef = useAnimatedRef() 169 + const addUserDialogControl = useDialogControl() 166 170 const sectionTitlesCurate = [_(msg`Posts`), _(msg`People`)] 167 171 168 172 const moderation = React.useMemo(() => { ··· 177 181 }, [setMinimalShellMode]), 178 182 ) 179 183 180 - const onPressAddUser = useCallback(() => { 181 - openModal({ 182 - name: 'list-add-remove-users', 183 - list, 184 - onChange() { 185 - if (isCurateList) { 186 - truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`)) 187 - } 188 - }, 189 - }) 190 - }, [openModal, list, isCurateList, queryClient]) 184 + const onChangeMembers = useCallback(() => { 185 + if (isCurateList) { 186 + truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`)) 187 + } 188 + }, [list.uri, isCurateList, queryClient]) 191 189 192 190 const onCurrentPageSelected = React.useCallback( 193 191 (index: number) => { ··· 225 223 headerHeight={headerHeight} 226 224 isFocused={isScreenFocused && isFocused} 227 225 isOwner={isOwner} 228 - onPressAddUser={onPressAddUser} 226 + onPressAddUser={addUserDialogControl.open} 229 227 /> 230 228 )} 231 229 {({headerHeight, scrollElRef}) => ( ··· 233 231 ref={aboutSectionRef} 234 232 scrollElRef={scrollElRef as ListRef} 235 233 list={list} 236 - onPressAddUser={onPressAddUser} 234 + onPressAddUser={addUserDialogControl.open} 237 235 headerHeight={headerHeight} 238 236 /> 239 237 )} ··· 253 251 accessibilityHint="" 254 252 /> 255 253 </View> 254 + <ListAddRemoveUsersDialog 255 + control={addUserDialogControl} 256 + list={list} 257 + onChange={onChangeMembers} 258 + /> 256 259 </Hider.Content> 257 260 </Hider.Outer> 258 261 ) ··· 268 271 <AboutSection 269 272 list={list} 270 273 scrollElRef={scrollElRef as ListRef} 271 - onPressAddUser={onPressAddUser} 274 + onPressAddUser={addUserDialogControl.open} 272 275 headerHeight={0} 273 276 /> 274 277 <FAB ··· 286 289 accessibilityHint="" 287 290 /> 288 291 </View> 292 + <ListAddRemoveUsersDialog 293 + control={addUserDialogControl} 294 + list={list} 295 + onChange={onChangeMembers} 296 + /> 289 297 </Hider.Content> 290 298 </Hider.Outer> 291 299 )