From 8a4398608acac5f44e8d3c0ea8f6976b8ae1119a Mon Sep 17 00:00:00 2001
From: Samuel Newman <mozzius@protonmail.com>
Date: Fri, 29 Aug 2025 03:03:12 +0300
Subject: Close web mention suggestions popup on `Escape` (#8605)

* alf web typeahead

* fix type error

* fix escape behaviour

* change selection on hover

* rm React.

* undo random change
---
 .../com/composer/text-input/web/Autocomplete.tsx   | 313 +++++++++------------
 1 file changed, 139 insertions(+), 174 deletions(-)

(limited to 'src/view/com/composer/text-input/web/Autocomplete.tsx')

diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
index 94ecb53cc..1a95736c3 100644
--- a/src/view/com/composer/text-input/web/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/web/Autocomplete.tsx
@@ -1,6 +1,6 @@
 import {forwardRef, useEffect, useImperativeHandle, useState} from 'react'
-import {Pressable, StyleSheet, View} from 'react-native'
-import {type AppBskyActorDefs} from '@atproto/api'
+import {Pressable, View} from 'react-native'
+import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api'
 import {Trans} from '@lingui/macro'
 import {ReactRenderer} from '@tiptap/react'
 import {
@@ -10,25 +10,26 @@ import {
 } from '@tiptap/suggestion'
 import tippy, {type Instance as TippyInstance} from 'tippy.js'
 
-import {usePalette} from '#/lib/hooks/usePalette'
-import {sanitizeDisplayName} from '#/lib/strings/display-names'
-import {sanitizeHandle} from '#/lib/strings/handles'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {type ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
-import {Text} from '#/view/com/util/text/Text'
-import {UserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a} from '#/alf'
-import {useSimpleVerificationState} from '#/components/verification'
-import {VerificationCheck} from '#/components/verification/VerificationCheck'
-import {useGrapheme} from '../hooks/useGrapheme'
+import {atoms as a, useTheme} from '#/alf'
+import * as ProfileCard from '#/components/ProfileCard'
+import {Text} from '#/components/Typography'
 
 interface MentionListRef {
   onKeyDown: (props: SuggestionKeyDownProps) => boolean
 }
 
+export interface AutocompleteRef {
+  maybeClose: () => boolean
+}
+
 export function createSuggestion({
   autocomplete,
+  autocompleteRef,
 }: {
   autocomplete: ActorAutocompleteFn
+  autocompleteRef: React.Ref<AutocompleteRef>
 }): Omit<SuggestionOptions, 'editor'> {
   return {
     async items({query}) {
@@ -40,10 +41,15 @@ export function createSuggestion({
       let component: ReactRenderer<MentionListRef> | undefined
       let popup: TippyInstance[] | undefined
 
+      const hide = () => {
+        popup?.[0]?.destroy()
+        component?.destroy()
+      }
+
       return {
         onStart: props => {
           component = new ReactRenderer(MentionList, {
-            props,
+            props: {...props, autocompleteRef, hide},
             editor: props.editor,
           })
 
@@ -78,204 +84,163 @@ export function createSuggestion({
 
         onKeyDown(props) {
           if (props.event.key === 'Escape') {
-            popup?.[0]?.hide()
-
-            return true
+            return false
           }
 
           return component?.ref?.onKeyDown(props) || false
         },
 
         onExit() {
-          popup?.[0]?.destroy()
-          component?.destroy()
+          hide()
         },
       }
     },
   }
 }
 
-const MentionList = forwardRef<MentionListRef, SuggestionProps>(
-  function MentionListImpl(props: SuggestionProps, ref) {
-    const [selectedIndex, setSelectedIndex] = useState(0)
-    const pal = usePalette('default')
+const MentionList = forwardRef<
+  MentionListRef,
+  SuggestionProps & {
+    autocompleteRef: React.Ref<AutocompleteRef>
+    hide: () => void
+  }
+>(function MentionListImpl({items, command, hide, autocompleteRef}, ref) {
+  const [selectedIndex, setSelectedIndex] = useState(0)
+  const t = useTheme()
+  const moderationOpts = useModerationOpts()
 
-    const selectItem = (index: number) => {
-      const item = props.items[index]
+  const selectItem = (index: number) => {
+    const item = items[index]
 
-      if (item) {
-        props.command({id: item.handle})
-      }
+    if (item) {
+      command({id: item.handle})
     }
+  }
 
-    const upHandler = () => {
-      setSelectedIndex(
-        (selectedIndex + props.items.length - 1) % props.items.length,
-      )
-    }
+  const upHandler = () => {
+    setSelectedIndex((selectedIndex + items.length - 1) % items.length)
+  }
 
-    const downHandler = () => {
-      setSelectedIndex((selectedIndex + 1) % props.items.length)
-    }
+  const downHandler = () => {
+    setSelectedIndex((selectedIndex + 1) % items.length)
+  }
 
-    const enterHandler = () => {
-      selectItem(selectedIndex)
-    }
+  const enterHandler = () => {
+    selectItem(selectedIndex)
+  }
+
+  useEffect(() => setSelectedIndex(0), [items])
+
+  useImperativeHandle(autocompleteRef, () => ({
+    maybeClose: () => {
+      hide()
+      return true
+    },
+  }))
+
+  useImperativeHandle(ref, () => ({
+    onKeyDown: ({event}) => {
+      if (event.key === 'ArrowUp') {
+        upHandler()
+        return true
+      }
+
+      if (event.key === 'ArrowDown') {
+        downHandler()
+        return true
+      }
+
+      if (event.key === 'Enter' || event.key === 'Tab') {
+        enterHandler()
+        return true
+      }
+
+      return false
+    },
+  }))
 
-    useEffect(() => setSelectedIndex(0), [props.items])
-
-    useImperativeHandle(ref, () => ({
-      onKeyDown: ({event}) => {
-        if (event.key === 'ArrowUp') {
-          upHandler()
-          return true
-        }
-
-        if (event.key === 'ArrowDown') {
-          downHandler()
-          return true
-        }
-
-        if (event.key === 'Enter' || event.key === 'Tab') {
-          enterHandler()
-          return true
-        }
-
-        return false
-      },
-    }))
-
-    const {items} = props
-
-    return (
-      <div className="items">
-        <View style={[pal.borderDark, pal.view, styles.container]}>
-          {items.length > 0 ? (
-            items.map((item, index) => {
-              const isSelected = selectedIndex === index
-
-              return (
-                <AutocompleteProfileCard
-                  key={item.handle}
-                  profile={item}
-                  isSelected={isSelected}
-                  itemIndex={index}
-                  totalItems={items.length}
-                  onPress={() => {
-                    selectItem(index)
-                  }}
-                />
-              )
-            })
-          ) : (
-            <Text type="sm" style={[pal.text, styles.noResult]}>
-              <Trans>No result</Trans>
-            </Text>
-          )}
-        </View>
-      </div>
-    )
-  },
-)
+  if (!moderationOpts) return null
+
+  return (
+    <div className="items">
+      <View
+        style={[
+          t.atoms.border_contrast_low,
+          t.atoms.bg,
+          a.rounded_sm,
+          a.border,
+          a.p_xs,
+          {width: 300},
+        ]}>
+        {items.length > 0 ? (
+          items.map((item, index) => {
+            const isSelected = selectedIndex === index
+
+            return (
+              <AutocompleteProfileCard
+                key={item.handle}
+                profile={item}
+                isSelected={isSelected}
+                onPress={() => selectItem(index)}
+                onHover={() => setSelectedIndex(index)}
+                moderationOpts={moderationOpts}
+              />
+            )
+          })
+        ) : (
+          <Text style={[a.text_sm, a.px_md, a.py_md]}>
+            <Trans>No result</Trans>
+          </Text>
+        )}
+      </View>
+    </div>
+  )
+})
 
 function AutocompleteProfileCard({
   profile,
   isSelected,
-  itemIndex,
-  totalItems,
   onPress,
+  onHover,
+  moderationOpts,
 }: {
   profile: AppBskyActorDefs.ProfileViewBasic
   isSelected: boolean
-  itemIndex: number
-  totalItems: number
   onPress: () => void
+  onHover: () => void
+  moderationOpts: ModerationOpts
 }) {
-  const pal = usePalette('default')
-  const {getGraphemeString} = useGrapheme()
-  const {name: displayName} = getGraphemeString(
-    sanitizeDisplayName(profile.displayName || sanitizeHandle(profile.handle)),
-    30, // Heuristic value; can be modified
-  )
-  const state = useSimpleVerificationState({
-    profile,
-  })
+  const t = useTheme()
+
   return (
     <Pressable
       style={[
-        isSelected ? pal.viewLight : undefined,
-        pal.borderDark,
-        styles.mentionContainer,
-        itemIndex === 0
-          ? styles.firstMention
-          : itemIndex === totalItems - 1
-            ? styles.lastMention
-            : undefined,
+        isSelected && t.atoms.bg_contrast_25,
+        a.align_center,
+        a.justify_between,
+        a.flex_row,
+        a.px_md,
+        a.py_sm,
+        a.gap_2xl,
+        a.rounded_xs,
+        a.transition_color,
       ]}
       onPress={onPress}
+      onPointerEnter={onHover}
       accessibilityRole="button">
-      <View style={[styles.avatarAndDisplayName, a.flex_1]}>
-        <UserAvatar
-          avatar={profile.avatar ?? null}
-          size={26}
-          type={profile.associated?.labeler ? 'labeler' : 'user'}
-        />
-        <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
-          <Text emoji style={[pal.text]} numberOfLines={1}>
-            {displayName}
-          </Text>
-          {state.isVerified && (
-            <View>
-              <VerificationCheck
-                width={12}
-                verifier={state.role === 'verifier'}
-              />
-            </View>
-          )}
-        </View>
-      </View>
-      <View>
-        <Text type="xs" style={pal.textLight} numberOfLines={1}>
-          {sanitizeHandle(profile.handle, '@')}
-        </Text>
+      <View style={[a.flex_1]}>
+        <ProfileCard.Header>
+          <ProfileCard.Avatar
+            profile={profile}
+            moderationOpts={moderationOpts}
+            disabledPreview
+          />
+          <ProfileCard.NameAndHandle
+            profile={profile}
+            moderationOpts={moderationOpts}
+          />
+        </ProfileCard.Header>
       </View>
     </Pressable>
   )
 }
-
-const styles = StyleSheet.create({
-  container: {
-    width: 500,
-    borderRadius: 6,
-    borderWidth: 1,
-    borderStyle: 'solid',
-    padding: 4,
-  },
-  mentionContainer: {
-    display: 'flex',
-    alignItems: 'center',
-    justifyContent: 'space-between',
-    flexDirection: 'row',
-    paddingHorizontal: 12,
-    paddingVertical: 8,
-    gap: 16,
-  },
-  firstMention: {
-    borderTopLeftRadius: 2,
-    borderTopRightRadius: 2,
-  },
-  lastMention: {
-    borderBottomLeftRadius: 2,
-    borderBottomRightRadius: 2,
-  },
-  avatarAndDisplayName: {
-    display: 'flex',
-    flexDirection: 'row',
-    alignItems: 'center',
-    gap: 6,
-  },
-  noResult: {
-    paddingHorizontal: 12,
-    paddingVertical: 8,
-  },
-})
-- 
cgit 1.4.1

