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/AccountList.tsx15
-rw-r--r--src/components/LabelingServiceCard/index.tsx7
-rw-r--r--src/components/ProfileHoverCard/index.web.tsx16
-rw-r--r--src/components/Prompt.tsx10
-rw-r--r--src/components/dialogs/MutedWords.tsx4
-rw-r--r--src/components/dialogs/SwitchAccount.tsx3
-rw-r--r--src/components/dms/ActionsWrapper.tsx83
-rw-r--r--src/components/dms/ActionsWrapper.web.tsx86
-rw-r--r--src/components/dms/ConvoMenu.tsx11
-rw-r--r--src/components/dms/MessageItem.tsx198
-rw-r--r--src/components/dms/MessageMenu.tsx141
-rw-r--r--src/components/dms/NewChat.tsx47
-rw-r--r--src/components/hooks/useRefreshOnFocus.ts17
-rw-r--r--src/components/moderation/LabelsOnMe.tsx18
14 files changed, 614 insertions, 42 deletions
diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx
index 169e7b84f..7d696801e 100644
--- a/src/components/AccountList.tsx
+++ b/src/components/AccountList.tsx
@@ -16,12 +16,14 @@ export function AccountList({
   onSelectAccount,
   onSelectOther,
   otherLabel,
+  pendingDid,
 }: {
   onSelectAccount: (account: SessionAccount) => void
   onSelectOther: () => void
   otherLabel?: string
+  pendingDid: string | null
 }) {
-  const {isSwitchingAccounts, currentAccount, accounts} = useSession()
+  const {currentAccount, accounts} = useSession()
   const t = useTheme()
   const {_} = useLingui()
 
@@ -31,6 +33,7 @@ export function AccountList({
 
   return (
     <View
+      pointerEvents={pendingDid ? 'none' : 'auto'}
       style={[
         a.rounded_md,
         a.overflow_hidden,
@@ -43,6 +46,7 @@ export function AccountList({
             account={account}
             onSelect={onSelectAccount}
             isCurrentAccount={account.did === currentAccount?.did}
+            isPendingAccount={account.did === pendingDid}
           />
           <View style={[a.border_b, t.atoms.border_contrast_low]} />
         </React.Fragment>
@@ -50,7 +54,7 @@ export function AccountList({
       <Button
         testID="chooseAddAccountBtn"
         style={[a.flex_1]}
-        onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
+        onPress={pendingDid ? undefined : onPressAddAccount}
         label={_(msg`Login to account that is not listed`)}>
         {({hovered, pressed}) => (
           <View
@@ -59,8 +63,7 @@ export function AccountList({
               a.flex_row,
               a.align_center,
               {height: 48},
-              (hovered || pressed || isSwitchingAccounts) &&
-                t.atoms.bg_contrast_25,
+              (hovered || pressed) && t.atoms.bg_contrast_25,
             ]}>
             <Text
               style={[
@@ -84,10 +87,12 @@ function AccountItem({
   account,
   onSelect,
   isCurrentAccount,
+  isPendingAccount,
 }: {
   account: SessionAccount
   onSelect: (account: SessionAccount) => void
   isCurrentAccount: boolean
+  isPendingAccount: boolean
 }) {
   const t = useTheme()
   const {_} = useLingui()
@@ -115,7 +120,7 @@ function AccountItem({
             a.flex_row,
             a.align_center,
             {height: 48},
-            (hovered || pressed) && t.atoms.bg_contrast_25,
+            (hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
           ]}>
           <View style={a.p_md}>
             <UserAvatar avatar={profile?.avatar} size={24} />
diff --git a/src/components/LabelingServiceCard/index.tsx b/src/components/LabelingServiceCard/index.tsx
index f924f0f59..2bb7ed59c 100644
--- a/src/components/LabelingServiceCard/index.tsx
+++ b/src/components/LabelingServiceCard/index.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {View} from 'react-native'
-import {msg, Trans} from '@lingui/macro'
+import {msg, Plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {AppBskyLabelerDefs} from '@atproto/api'
 
@@ -13,7 +13,6 @@ import {RichText} from '#/components/RichText'
 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {sanitizeHandle} from '#/lib/strings/handles'
-import {pluralize} from '#/lib/strings/helpers'
 
 type LabelingServiceProps = {
   labeler: AppBskyLabelerDefs.LabelerViewDetailed
@@ -69,9 +68,7 @@ export function LikeCount({count}: {count: number}) {
         t.atoms.text_contrast_medium,
         {fontWeight: '500'},
       ]}>
-      <Trans>
-        Liked by {count} {pluralize(count, 'user')}
-      </Trans>
+      <Plural value={count} one="Liked by # user" other="Liked by # users" />
     </Text>
   )
 }
diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx
index a22436879..305327d8b 100644
--- a/src/components/ProfileHoverCard/index.web.tsx
+++ b/src/components/ProfileHoverCard/index.web.tsx
@@ -2,13 +2,12 @@ import React from 'react'
 import {View} from 'react-native'
 import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
 import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
-import {msg, Trans} from '@lingui/macro'
+import {msg, plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {makeProfileLink} from '#/lib/routes/links'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
-import {pluralize} from '#/lib/strings/helpers'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile'
 import {useSession} from '#/state/session'
@@ -371,7 +370,14 @@ function Inner({
   const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
   const following = formatCount(profile.followsCount || 0)
   const followers = formatCount(profile.followersCount || 0)
-  const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
+  const pluralizedFollowers = plural(profile.followersCount || 0, {
+    one: 'follower',
+    other: 'followers',
+  })
+  const pluralizedFollowings = plural(profile.followsCount || 0, {
+    one: 'following',
+    other: 'following',
+  })
   const profileURL = makeProfileLink({
     did: profile.did,
     handle: profile.handle,
@@ -448,7 +454,9 @@ function Inner({
               onPress={hide}>
               <Trans>
                 <Text style={[a.text_md, a.font_bold]}>{following} </Text>
-                <Text style={[t.atoms.text_contrast_medium]}>following</Text>
+                <Text style={[t.atoms.text_contrast_medium]}>
+                  {pluralizedFollowings}
+                </Text>
               </Trans>
             </InlineLinkText>
           </View>
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 0a171674d..92e848e8e 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -43,7 +43,9 @@ export function Outer({
         <Dialog.ScrollableInner
           accessibilityLabelledBy={titleId}
           accessibilityDescribedBy={descriptionId}
-          style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
+          style={[
+            gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
+          ]}>
           {children}
         </Dialog.ScrollableInner>
       </Context.Provider>
@@ -60,12 +62,16 @@ export function TitleText({children}: React.PropsWithChildren<{}>) {
   )
 }
 
-export function DescriptionText({children}: React.PropsWithChildren<{}>) {
+export function DescriptionText({
+  children,
+  selectable,
+}: React.PropsWithChildren<{selectable?: boolean}>) {
   const t = useTheme()
   const {descriptionId} = React.useContext(Context)
   return (
     <Text
       nativeID={descriptionId}
+      selectable={selectable}
       style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high, a.pb_lg]}>
       {children}
     </Text>
diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx
index 0eced11e3..534263422 100644
--- a/src/components/dialogs/MutedWords.tsx
+++ b/src/components/dialogs/MutedWords.tsx
@@ -37,12 +37,12 @@ export function MutedWordsDialog() {
   return (
     <Dialog.Outer control={control}>
       <Dialog.Handle />
-      <MutedWordsInner control={control} />
+      <MutedWordsInner />
     </Dialog.Outer>
   )
 }
 
-function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
+function MutedWordsInner() {
   const t = useTheme()
   const {_} = useLingui()
   const {gtMobile} = useBreakpoints()
diff --git a/src/components/dialogs/SwitchAccount.tsx b/src/components/dialogs/SwitchAccount.tsx
index 55628a790..0bd4bcb8c 100644
--- a/src/components/dialogs/SwitchAccount.tsx
+++ b/src/components/dialogs/SwitchAccount.tsx
@@ -18,7 +18,7 @@ export function SwitchAccountDialog({
 }) {
   const {_} = useLingui()
   const {currentAccount} = useSession()
-  const {onPressSwitchAccount} = useAccountSwitcher()
+  const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
   const {setShowLoggedOut} = useLoggedOutViewControls()
 
   const onSelectAccount = useCallback(
@@ -54,6 +54,7 @@ export function SwitchAccountDialog({
             onSelectAccount={onSelectAccount}
             onSelectOther={onPressAddAccount}
             otherLabel={_(msg`Add account`)}
+            pendingDid={pendingDid}
           />
         </View>
       </Dialog.ScrollableInner>
diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx
new file mode 100644
index 000000000..19a3e0424
--- /dev/null
+++ b/src/components/dms/ActionsWrapper.tsx
@@ -0,0 +1,83 @@
+import React, {useCallback} from 'react'
+import {Keyboard, Pressable, View} from 'react-native'
+import Animated, {
+  cancelAnimation,
+  runOnJS,
+  useAnimatedStyle,
+  useSharedValue,
+  withTiming,
+} from 'react-native-reanimated'
+import {ChatBskyConvoDefs} from '@atproto-labs/api'
+
+import {useHaptics} from 'lib/haptics'
+import {atoms as a} from '#/alf'
+import {MessageMenu} from '#/components/dms/MessageMenu'
+import {useMenuControl} from '#/components/Menu'
+
+const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
+
+export function ActionsWrapper({
+  message,
+  isFromSelf,
+  children,
+}: {
+  message: ChatBskyConvoDefs.MessageView
+  isFromSelf: boolean
+  children: React.ReactNode
+}) {
+  const playHaptic = useHaptics()
+  const menuControl = useMenuControl()
+
+  const scale = useSharedValue(1)
+  const animationDidComplete = useSharedValue(false)
+
+  const animatedStyle = useAnimatedStyle(() => ({
+    transform: [{scale: scale.value}],
+  }))
+
+  // Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this
+  // function
+  const open = useCallback(() => {
+    Keyboard.dismiss()
+    menuControl.open()
+  }, [menuControl])
+
+  const shrink = useCallback(() => {
+    'worklet'
+    cancelAnimation(scale)
+    scale.value = withTiming(1, {duration: 200}, () => {
+      animationDidComplete.value = false
+    })
+  }, [animationDidComplete, scale])
+
+  const grow = React.useCallback(() => {
+    'worklet'
+    scale.value = withTiming(1.05, {duration: 750}, finished => {
+      if (!finished) return
+      animationDidComplete.value = true
+      runOnJS(playHaptic)()
+      runOnJS(open)()
+
+      shrink()
+    })
+  }, [scale, animationDidComplete, playHaptic, shrink, open])
+
+  return (
+    <View
+      style={[
+        {
+          maxWidth: '65%',
+        },
+        isFromSelf ? a.self_end : a.self_start,
+      ]}>
+      <AnimatedPressable
+        style={animatedStyle}
+        unstable_pressDelay={200}
+        onPressIn={grow}
+        onTouchEnd={shrink}>
+        {children}
+      </AnimatedPressable>
+      <MessageMenu message={message} control={menuControl} hideTrigger={true} />
+    </View>
+  )
+}
diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx
new file mode 100644
index 000000000..f4c85ab94
--- /dev/null
+++ b/src/components/dms/ActionsWrapper.web.tsx
@@ -0,0 +1,86 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {ChatBskyConvoDefs} from '@atproto-labs/api'
+
+import {atoms as a} from '#/alf'
+import {MessageMenu} from '#/components/dms/MessageMenu'
+import {useMenuControl} from '#/components/Menu'
+
+export function ActionsWrapper({
+  message,
+  isFromSelf,
+  children,
+}: {
+  message: ChatBskyConvoDefs.MessageView
+  isFromSelf: boolean
+  children: React.ReactNode
+}) {
+  const menuControl = useMenuControl()
+  const viewRef = React.useRef(null)
+
+  const [showActions, setShowActions] = React.useState(false)
+
+  const onMouseEnter = React.useCallback(() => {
+    setShowActions(true)
+  }, [])
+
+  const onMouseLeave = React.useCallback(() => {
+    setShowActions(false)
+  }, [])
+
+  // We need to handle the `onFocus` separately because we want to know if there is a related target (the element
+  // that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed.
+  const onFocus = React.useCallback<React.FocusEventHandler>(e => {
+    if (e.nativeEvent.relatedTarget == null) return
+    setShowActions(true)
+  }, [])
+
+  return (
+    <View
+      // @ts-expect-error web only
+      onMouseEnter={onMouseEnter}
+      onMouseLeave={onMouseLeave}
+      onFocus={onFocus}
+      onBlur={onMouseLeave}
+      style={StyleSheet.flatten([a.flex_1, a.flex_row])}
+      ref={viewRef}>
+      {isFromSelf && (
+        <View
+          style={[
+            a.mr_xl,
+            a.justify_center,
+            {
+              marginLeft: 'auto',
+            },
+          ]}>
+          <MessageMenu
+            message={message}
+            control={menuControl}
+            triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
+            onTriggerPress={onMouseEnter}
+            // @ts-expect-error web only
+            onMouseLeave={onMouseLeave}
+          />
+        </View>
+      )}
+      <View
+        style={{
+          maxWidth: '65%',
+        }}>
+        {children}
+      </View>
+      {!isFromSelf && (
+        <View style={[a.flex_row, a.align_center, a.ml_xl]}>
+          <MessageMenu
+            message={message}
+            control={menuControl}
+            triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
+            onTriggerPress={onMouseEnter}
+            // @ts-expect-error web only
+            onMouseLeave={onMouseLeave}
+          />
+        </View>
+      )}
+    </View>
+  )
+}
diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx
index 777d6c086..16306bb57 100644
--- a/src/components/dms/ConvoMenu.tsx
+++ b/src/components/dms/ConvoMenu.tsx
@@ -1,5 +1,5 @@
 import React, {useCallback} from 'react'
-import {Pressable} from 'react-native'
+import {Keyboard, Pressable} from 'react-native'
 import {AppBskyActorDefs} from '@atproto/api'
 import {ChatBskyConvoDefs} from '@atproto-labs/api'
 import {msg, Trans} from '@lingui/macro'
@@ -72,7 +72,7 @@ let ConvoMenu = ({
   const {mutate: leaveConvo} = useLeaveConvo(convo.id, {
     onSuccess: () => {
       if (currentScreen === 'conversation') {
-        navigation.replace('MessagesList')
+        navigation.replace('Messages')
       }
     },
     onError: () => {
@@ -88,6 +88,11 @@ let ConvoMenu = ({
             {({props, state}) => (
               <Pressable
                 {...props}
+                onPress={() => {
+                  Keyboard.dismiss()
+                  // eslint-disable-next-line react/prop-types -- eslint is confused by the name `props`
+                  props.onPress()
+                }}
                 style={[
                   a.p_sm,
                   a.rounded_sm,
@@ -123,6 +128,7 @@ let ConvoMenu = ({
               <Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
             </Menu.Item>
           </Menu.Group>
+          <Menu.Divider />
           {/* TODO(samuel): implement these */}
           <Menu.Group>
             <Menu.Item
@@ -146,6 +152,7 @@ let ConvoMenu = ({
               <Menu.ItemIcon icon={Flag} />
             </Menu.Item>
           </Menu.Group>
+          <Menu.Divider />
           <Menu.Group>
             <Menu.Item
               label={_(msg`Leave conversation`)}
diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx
new file mode 100644
index 000000000..f8f5197ca
--- /dev/null
+++ b/src/components/dms/MessageItem.tsx
@@ -0,0 +1,198 @@
+import React, {useCallback, useMemo, useRef} from 'react'
+import {LayoutAnimation, StyleProp, TextStyle, View} from 'react-native'
+import {ChatBskyConvoDefs} from '@atproto-labs/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useSession} from '#/state/session'
+import {TimeElapsed} from 'view/com/util/TimeElapsed'
+import {atoms as a, useTheme} from '#/alf'
+import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
+import {Text} from '#/components/Typography'
+
+export let MessageItem = ({
+  item,
+  next,
+  pending,
+}: {
+  item: ChatBskyConvoDefs.MessageView
+  next:
+    | ChatBskyConvoDefs.MessageView
+    | ChatBskyConvoDefs.DeletedMessageView
+    | null
+  pending?: boolean
+}): React.ReactNode => {
+  const t = useTheme()
+  const {currentAccount} = useSession()
+
+  const isFromSelf = item.sender?.did === currentAccount?.did
+
+  const isNextFromSelf =
+    ChatBskyConvoDefs.isMessageView(next) &&
+    next.sender?.did === currentAccount?.did
+
+  const isLastInGroup = useMemo(() => {
+    // TODO this means it's a placeholder. Let's figure out the right way to do this though!
+    if (item.id.length > 13) {
+      return false
+    }
+
+    // if the next message is from a different sender, then it's the last in the group
+    if (isFromSelf ? !isNextFromSelf : isNextFromSelf) {
+      return true
+    }
+
+    // or, if there's a 3 minute gap between this message and the next
+    if (ChatBskyConvoDefs.isMessageView(next)) {
+      const thisDate = new Date(item.sentAt)
+      const nextDate = new Date(next.sentAt)
+
+      const diff = nextDate.getTime() - thisDate.getTime()
+
+      // 3 minutes
+      return diff > 3 * 60 * 1000
+    }
+
+    return true
+  }, [item, next, isFromSelf, isNextFromSelf])
+
+  const lastInGroupRef = useRef(isLastInGroup)
+  if (lastInGroupRef.current !== isLastInGroup) {
+    LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+    lastInGroupRef.current = isLastInGroup
+  }
+
+  const pendingColor =
+    t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800
+
+  return (
+    <View>
+      <ActionsWrapper isFromSelf={isFromSelf} message={item}>
+        <View
+          style={[
+            a.py_sm,
+            a.my_2xs,
+            a.rounded_md,
+            {
+              paddingLeft: 14,
+              paddingRight: 14,
+              backgroundColor: isFromSelf
+                ? pending
+                  ? pendingColor
+                  : t.palette.primary_500
+                : t.palette.contrast_50,
+              borderRadius: 17,
+            },
+            isFromSelf
+              ? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
+              : {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
+          ]}>
+          <Text
+            style={[
+              a.text_md,
+              a.leading_snug,
+              isFromSelf && {color: t.palette.white},
+              pending && t.name !== 'light' && {color: t.palette.primary_300},
+            ]}>
+            {item.text}
+          </Text>
+        </View>
+      </ActionsWrapper>
+      <MessageItemMetadata
+        message={item}
+        isLastInGroup={isLastInGroup}
+        style={isFromSelf ? a.text_right : a.text_left}
+      />
+    </View>
+  )
+}
+
+MessageItem = React.memo(MessageItem)
+
+let MessageItemMetadata = ({
+  message,
+  isLastInGroup,
+  style,
+}: {
+  message: ChatBskyConvoDefs.MessageView
+  isLastInGroup: boolean
+  style: StyleProp<TextStyle>
+}): React.ReactNode => {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  const relativeTimestamp = useCallback(
+    (timestamp: string) => {
+      const date = new Date(timestamp)
+      const now = new Date()
+
+      const time = new Intl.DateTimeFormat(undefined, {
+        hour: 'numeric',
+        minute: 'numeric',
+        hour12: true,
+      }).format(date)
+
+      const diff = now.getTime() - date.getTime()
+
+      // if under 1 minute
+      if (diff < 1000 * 60) {
+        return _(msg`Now`)
+      }
+
+      // if in the last day
+      if (localDateString(now) === localDateString(date)) {
+        return time
+      }
+
+      // if yesterday
+      const yesterday = new Date(now)
+      yesterday.setDate(yesterday.getDate() - 1)
+
+      if (localDateString(yesterday) === localDateString(date)) {
+        return _(msg`Yesterday, ${time}`)
+      }
+
+      return new Intl.DateTimeFormat(undefined, {
+        hour: 'numeric',
+        minute: 'numeric',
+        hour12: true,
+        day: 'numeric',
+        month: 'numeric',
+        year: 'numeric',
+      }).format(date)
+    },
+    [_],
+  )
+
+  if (!isLastInGroup) {
+    return null
+  }
+
+  return (
+    <TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
+      {({timeElapsed}) => (
+        <Text
+          style={[
+            t.atoms.text_contrast_medium,
+            a.text_xs,
+            a.mt_2xs,
+            a.mb_lg,
+            style,
+          ]}>
+          {timeElapsed}
+        </Text>
+      )}
+    </TimeElapsed>
+  )
+}
+
+MessageItemMetadata = React.memo(MessageItemMetadata)
+
+function localDateString(date: Date) {
+  // can't use toISOString because it should be in local time
+  const mm = date.getMonth()
+  const dd = date.getDate()
+  const yyyy = date.getFullYear()
+  // not padding with 0s because it's not necessary, it's just used for comparison
+  return `${yyyy}-${mm}-${dd}`
+}
diff --git a/src/components/dms/MessageMenu.tsx b/src/components/dms/MessageMenu.tsx
new file mode 100644
index 000000000..d2a7d147d
--- /dev/null
+++ b/src/components/dms/MessageMenu.tsx
@@ -0,0 +1,141 @@
+import React from 'react'
+import {LayoutAnimation, Pressable, View} from 'react-native'
+import * as Clipboard from 'expo-clipboard'
+import {ChatBskyConvoDefs} from '@atproto-labs/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useChat} from 'state/messages'
+import {ConvoStatus} from 'state/messages/convo'
+import {useSession} from 'state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useTheme} from '#/alf'
+import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
+import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import * as Menu from '#/components/Menu'
+import * as Prompt from '#/components/Prompt'
+import {usePromptControl} from '#/components/Prompt'
+import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard'
+
+export let MessageMenu = ({
+  message,
+  control,
+  hideTrigger,
+  triggerOpacity,
+}: {
+  hideTrigger?: boolean
+  triggerOpacity?: number
+  onTriggerPress?: () => void
+  message: ChatBskyConvoDefs.MessageView
+  control: Menu.MenuControlProps
+}): React.ReactNode => {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {currentAccount} = useSession()
+  const chat = useChat()
+  const deleteControl = usePromptControl()
+  const retryDeleteControl = usePromptControl()
+
+  const isFromSelf = message.sender?.did === currentAccount?.did
+
+  const onCopyPostText = React.useCallback(() => {
+    // use when we have rich text
+    // const str = richTextToString(richText, true)
+
+    Clipboard.setStringAsync(message.text)
+    Toast.show(_(msg`Copied to clipboard`))
+  }, [_, message.text])
+
+  const onDelete = React.useCallback(() => {
+    if (chat.status !== ConvoStatus.Ready) return
+
+    LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+    chat
+      .deleteMessage(message.id)
+      .then(() => Toast.show(_(msg`Message deleted`)))
+      .catch(() => retryDeleteControl.open())
+  }, [_, chat, message.id, retryDeleteControl])
+
+  const onReport = React.useCallback(() => {
+    // TODO report the message
+  }, [])
+
+  return (
+    <>
+      <Menu.Root control={control}>
+        {!hideTrigger && (
+          <View style={{opacity: triggerOpacity}}>
+            <Menu.Trigger label={_(msg`Chat settings`)}>
+              {({props, state}) => (
+                <Pressable
+                  {...props}
+                  style={[
+                    a.p_sm,
+                    a.rounded_full,
+                    (state.hovered || state.pressed) && t.atoms.bg_contrast_25,
+                  ]}>
+                  <DotsHorizontal size="sm" style={t.atoms.text} />
+                </Pressable>
+              )}
+            </Menu.Trigger>
+          </View>
+        )}
+
+        <Menu.Outer>
+          <Menu.Group>
+            <Menu.Item
+              testID="messageDropdownCopyBtn"
+              label={_(msg`Copy message text`)}
+              onPress={onCopyPostText}>
+              <Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
+              <Menu.ItemIcon icon={ClipboardIcon} position="right" />
+            </Menu.Item>
+          </Menu.Group>
+          <Menu.Divider />
+          <Menu.Group>
+            <Menu.Item
+              testID="messageDropdownDeleteBtn"
+              label={_(msg`Delete message for me`)}
+              onPress={deleteControl.open}>
+              <Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
+              <Menu.ItemIcon icon={Trash} position="right" />
+            </Menu.Item>
+            {!isFromSelf && (
+              <Menu.Item
+                testID="messageDropdownReportBtn"
+                label={_(msg`Report message`)}
+                onPress={onReport}>
+                <Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
+                <Menu.ItemIcon icon={Warning} position="right" />
+              </Menu.Item>
+            )}
+          </Menu.Group>
+        </Menu.Outer>
+      </Menu.Root>
+
+      <Prompt.Basic
+        control={deleteControl}
+        title={_(msg`Delete message`)}
+        description={_(
+          msg`Are you sure you want to delete this message? The message will be deleted for you, but not for other participants.`,
+        )}
+        confirmButtonCta={_(msg`Delete`)}
+        confirmButtonColor="negative"
+        onConfirm={onDelete}
+      />
+
+      <Prompt.Basic
+        control={retryDeleteControl}
+        title={_(msg`Failed to delete message`)}
+        description={_(
+          msg`An error occurred while trying to delete the message. Please try again.`,
+        )}
+        confirmButtonCta={_(msg`Retry`)}
+        confirmButtonColor="negative"
+        onConfirm={onDelete}
+      />
+    </>
+  )
+}
+MessageMenu = React.memo(MessageMenu)
diff --git a/src/components/dms/NewChat.tsx b/src/components/dms/NewChat.tsx
index a6b5d5632..5dde8628c 100644
--- a/src/components/dms/NewChat.tsx
+++ b/src/components/dms/NewChat.tsx
@@ -20,6 +20,7 @@ import * as TextField from '#/components/forms/TextField'
 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
 import {Button} from '../Button'
+import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope'
 import {ListMaybePlaceholder} from '../Lists'
 import {Text} from '../Typography'
 
@@ -178,7 +179,7 @@ function SearchablePeopleList({
         </Text>
         <TextField.Root>
           <TextField.Icon icon={Search} />
-          <TextField.Input
+          <Dialog.Input
             label={_(msg`Search profiles`)}
             placeholder={_(msg`Search`)}
             value={searchText}
@@ -197,6 +198,7 @@ function SearchablePeopleList({
             autoCorrect={false}
             autoComplete="off"
             autoCapitalize="none"
+            autoFocus
           />
         </TextField.Root>
       </View>
@@ -211,20 +213,35 @@ function SearchablePeopleList({
       ListHeaderComponent={
         <>
           {listHeader}
-          {searchText.length > 0 && !actorAutocompleteData?.length && (
-            <ListMaybePlaceholder
-              isLoading={isFetching}
-              isError={isError}
-              onRetry={refetch}
-              hideBackButton={true}
-              emptyType="results"
-              sideBorders={false}
-              emptyMessage={
-                isError
-                  ? _(msg`No search results found for "${searchText}".`)
-                  : _(msg`Could not load profiles. Please try again later.`)
-              }
-            />
+          {searchText.length === 0 ? (
+            <View style={[a.pt_4xl, a.align_center, a.px_lg]}>
+              <Envelope width={64} fill={t.palette.contrast_200} />
+              <Text
+                style={[
+                  a.text_lg,
+                  a.text_center,
+                  a.mt_md,
+                  t.atoms.text_contrast_low,
+                ]}>
+                <Trans>Search for someone to start a conversation with.</Trans>
+              </Text>
+            </View>
+          ) : (
+            !actorAutocompleteData?.length && (
+              <ListMaybePlaceholder
+                isLoading={isFetching}
+                isError={isError}
+                onRetry={refetch}
+                hideBackButton={true}
+                emptyType="results"
+                sideBorders={false}
+                emptyMessage={
+                  isError
+                    ? _(msg`No search results found for "${searchText}".`)
+                    : _(msg`Could not load profiles. Please try again later.`)
+                }
+              />
+            )
           )}
         </>
       }
diff --git a/src/components/hooks/useRefreshOnFocus.ts b/src/components/hooks/useRefreshOnFocus.ts
new file mode 100644
index 000000000..6bf7ac8b1
--- /dev/null
+++ b/src/components/hooks/useRefreshOnFocus.ts
@@ -0,0 +1,17 @@
+import {useCallback, useRef} from 'react'
+import {useFocusEffect} from '@react-navigation/native'
+
+export function useRefreshOnFocus<T>(refetch: () => Promise<T>) {
+  const firstTimeRef = useRef(true)
+
+  useFocusEffect(
+    useCallback(() => {
+      if (firstTimeRef.current) {
+        firstTimeRef.current = false
+        return
+      }
+
+      refetch()
+    }, [refetch]),
+  )
+}
diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx
index 099769fa7..46825d761 100644
--- a/src/components/moderation/LabelsOnMe.tsx
+++ b/src/components/moderation/LabelsOnMe.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
 import {StyleProp, View, ViewStyle} from 'react-native'
 import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api'
-import {msg, Trans} from '@lingui/macro'
+import {msg, Plural} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useSession} from '#/state/session'
 
@@ -39,7 +39,6 @@ export function LabelsOnMe({
     return null
   }
 
-  const labelTarget = isAccount ? _(msg`account`) : _(msg`content`)
   return (
     <View style={[a.flex_row, style]}>
       <LabelsOnMeDialog control={control} subject={details} labels={labels} />
@@ -54,11 +53,18 @@ export function LabelsOnMe({
         }}>
         <ButtonIcon position="left" icon={CircleInfo} />
         <ButtonText style={[a.leading_snug]}>
-          {labels.length}{' '}
-          {labels.length === 1 ? (
-            <Trans>label has been placed on this {labelTarget}</Trans>
+          {isAccount ? (
+            <Plural
+              value={labels.length}
+              one="# label has been placed on this account"
+              other="# labels has been placed on this account"
+            />
           ) : (
-            <Trans>labels have been placed on this {labelTarget}</Trans>
+            <Plural
+              value={labels.length}
+              one="# label has been placed on this content"
+              other="# labels has been placed on this content"
+            />
           )}
         </ButtonText>
       </Button>