diff options
Diffstat (limited to 'src/components')
-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 |
5 files changed, 325 insertions, 103 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 /> ) } |