Files
bsky-social-app/src/view/com/composer/text-input/web/Autocomplete.tsx
T
Samuel Newman 8da13e7511 drop the casts the dual-world layer needed
With every producer on the generated lexicons, the branded-string assertions at
these 30 sites are no-ops, and oxlint flags them. Also sorts the imports the
type flip disturbed and prunes the suppressions that no longer occur.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 22:13:50 +03:00

248 lines
5.9 KiB
TypeScript

import {forwardRef, useEffect, useImperativeHandle, useState} from 'react'
import {Pressable, View} from 'react-native'
import {type ModerationOpts} from '@bsky.app/sdk/moderation'
import {Trans} from '@lingui/react/macro'
import {ReactRenderer} from '@tiptap/react'
import {
type SuggestionKeyDownProps,
type SuggestionOptions,
type SuggestionProps,
} from '@tiptap/suggestion'
import tippy, {type Instance as TippyInstance} from 'tippy.js'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {type ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {atoms as a, useTheme} from '#/alf'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {app} from '#/lexicons'
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}) {
const suggestions = await autocomplete({query})
return suggestions.slice(0, 8)
},
render: () => {
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, autocompleteRef, hide},
editor: props.editor,
})
if (!props.clientRect) {
return
}
// @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
},
onUpdate(props) {
component?.updateProps(props)
if (!props.clientRect) {
return
}
popup?.[0]?.setProps({
// @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
getReferenceClientRect: props.clientRect,
})
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
return false
}
return component?.ref?.onKeyDown(props) || false
},
onExit() {
hide()
},
}
},
}
}
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 = items[index]
if (item) {
command({id: item.handle})
}
}
const upHandler = () => {
setSelectedIndex((selectedIndex + items.length - 1) % items.length)
}
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % items.length)
}
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
},
}))
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,
onPress,
onHover,
moderationOpts,
}: {
profile: app.bsky.actor.defs.ProfileViewBasic
isSelected: boolean
onPress: () => void
onHover: () => void
moderationOpts: ModerationOpts
}) {
const t = useTheme()
return (
<Pressable
style={[
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={[a.flex_1]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
</View>
</Pressable>
)
}