Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

Select the types of activity you want to include in your feed.

at a876aae44ea07494ebea9727350aa060b81f317b 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/react/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}