Bluesky app fork with some witchin' additions 馃挮
at readme-update 246 lines 6.0 kB view raw
1import {forwardRef, useEffect, useImperativeHandle, useState} from 'react' 2import {Pressable, View} from 'react-native' 3import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api' 4import {Trans} from '@lingui/macro' 5import {ReactRenderer} from '@tiptap/react' 6import { 7 type SuggestionKeyDownProps, 8 type SuggestionOptions, 9 type SuggestionProps, 10} from '@tiptap/suggestion' 11import tippy, {type Instance as TippyInstance} from 'tippy.js' 12 13import {useModerationOpts} from '#/state/preferences/moderation-opts' 14import {type ActorAutocompleteFn} from '#/state/queries/actor-autocomplete' 15import {atoms as a, useTheme} from '#/alf' 16import * as ProfileCard from '#/components/ProfileCard' 17import {Text} from '#/components/Typography' 18 19interface MentionListRef { 20 onKeyDown: (props: SuggestionKeyDownProps) => boolean 21} 22 23export interface AutocompleteRef { 24 maybeClose: () => boolean 25} 26 27export function createSuggestion({ 28 autocomplete, 29 autocompleteRef, 30}: { 31 autocomplete: ActorAutocompleteFn 32 autocompleteRef: React.Ref<AutocompleteRef> 33}): Omit<SuggestionOptions, 'editor'> { 34 return { 35 async items({query}) { 36 const suggestions = await autocomplete({query}) 37 return suggestions.slice(0, 8) 38 }, 39 40 render: () => { 41 let component: ReactRenderer<MentionListRef> | undefined 42 let popup: TippyInstance[] | undefined 43 44 const hide = () => { 45 popup?.[0]?.destroy() 46 component?.destroy() 47 } 48 49 return { 50 onStart: props => { 51 component = new ReactRenderer(MentionList, { 52 props: {...props, autocompleteRef, hide}, 53 editor: props.editor, 54 }) 55 56 if (!props.clientRect) { 57 return 58 } 59 60 // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf 61 popup = tippy('body', { 62 getReferenceClientRect: props.clientRect, 63 appendTo: () => document.body, 64 content: component.element, 65 showOnCreate: true, 66 interactive: true, 67 trigger: 'manual', 68 placement: 'bottom-start', 69 }) 70 }, 71 72 onUpdate(props) { 73 component?.updateProps(props) 74 75 if (!props.clientRect) { 76 return 77 } 78 79 popup?.[0]?.setProps({ 80 // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf 81 getReferenceClientRect: props.clientRect, 82 }) 83 }, 84 85 onKeyDown(props) { 86 if (props.event.key === 'Escape') { 87 return false 88 } 89 90 return component?.ref?.onKeyDown(props) || false 91 }, 92 93 onExit() { 94 hide() 95 }, 96 } 97 }, 98 } 99} 100 101const MentionList = forwardRef< 102 MentionListRef, 103 SuggestionProps & { 104 autocompleteRef: React.Ref<AutocompleteRef> 105 hide: () => void 106 } 107>(function MentionListImpl({items, command, hide, autocompleteRef}, ref) { 108 const [selectedIndex, setSelectedIndex] = useState(0) 109 const t = useTheme() 110 const moderationOpts = useModerationOpts() 111 112 const selectItem = (index: number) => { 113 const item = items[index] 114 115 if (item) { 116 command({id: item.handle}) 117 } 118 } 119 120 const upHandler = () => { 121 setSelectedIndex((selectedIndex + items.length - 1) % items.length) 122 } 123 124 const downHandler = () => { 125 setSelectedIndex((selectedIndex + 1) % items.length) 126 } 127 128 const enterHandler = () => { 129 selectItem(selectedIndex) 130 } 131 132 useEffect(() => setSelectedIndex(0), [items]) 133 134 useImperativeHandle(autocompleteRef, () => ({ 135 maybeClose: () => { 136 hide() 137 return true 138 }, 139 })) 140 141 useImperativeHandle(ref, () => ({ 142 onKeyDown: ({event}) => { 143 if (event.key === 'ArrowUp') { 144 upHandler() 145 return true 146 } 147 148 if (event.key === 'ArrowDown') { 149 downHandler() 150 return true 151 } 152 153 if (event.key === 'Enter' || event.key === 'Tab') { 154 enterHandler() 155 return true 156 } 157 158 return false 159 }, 160 })) 161 162 if (!moderationOpts) return null 163 164 return ( 165 <div className="items"> 166 <View 167 style={[ 168 t.atoms.border_contrast_low, 169 t.atoms.bg, 170 a.rounded_sm, 171 a.border, 172 a.p_xs, 173 {width: 300}, 174 ]}> 175 {items.length > 0 ? ( 176 items.map((item, index) => { 177 const isSelected = selectedIndex === index 178 179 return ( 180 <AutocompleteProfileCard 181 key={item.handle} 182 profile={item} 183 isSelected={isSelected} 184 onPress={() => selectItem(index)} 185 onHover={() => setSelectedIndex(index)} 186 moderationOpts={moderationOpts} 187 /> 188 ) 189 }) 190 ) : ( 191 <Text style={[a.text_sm, a.px_md, a.py_md]}> 192 <Trans>No result</Trans> 193 </Text> 194 )} 195 </View> 196 </div> 197 ) 198}) 199 200function AutocompleteProfileCard({ 201 profile, 202 isSelected, 203 onPress, 204 onHover, 205 moderationOpts, 206}: { 207 profile: AppBskyActorDefs.ProfileViewBasic 208 isSelected: boolean 209 onPress: () => void 210 onHover: () => void 211 moderationOpts: ModerationOpts 212}) { 213 const t = useTheme() 214 215 return ( 216 <Pressable 217 style={[ 218 isSelected && t.atoms.bg_contrast_25, 219 a.align_center, 220 a.justify_between, 221 a.flex_row, 222 a.px_md, 223 a.py_sm, 224 a.gap_2xl, 225 a.rounded_xs, 226 a.transition_color, 227 ]} 228 onPress={onPress} 229 onPointerEnter={onHover} 230 accessibilityRole="button"> 231 <View style={[a.flex_1]}> 232 <ProfileCard.Header> 233 <ProfileCard.Avatar 234 profile={profile} 235 moderationOpts={moderationOpts} 236 disabledPreview 237 /> 238 <ProfileCard.NameAndHandle 239 profile={profile} 240 moderationOpts={moderationOpts} 241 /> 242 </ProfileCard.Header> 243 </View> 244 </Pressable> 245 ) 246}