about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/ProfileCard.tsx48
-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.tsx180
-rw-r--r--src/components/dms/dialogs/NewChatDialog.tsx3
-rw-r--r--src/components/dms/dialogs/ShareViaChatDialog.tsx3
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
     />
   )
 }