Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at main 470 lines 13 kB view raw
1import { 2 useCallback, 3 useLayoutEffect, 4 useMemo, 5 useReducer, 6 useRef, 7 useState, 8} from 'react' 9import {LayoutAnimation, type TextInput, View} from 'react-native' 10import {Trans, useLingui} from '@lingui/react/macro' 11 12import {useModerationOpts} from '#/state/preferences/moderation-opts' 13import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 14import {useProfileFollowsQuery} from '#/state/queries/profile-follows' 15import {useSession} from '#/state/session' 16import {type ListMethods} from '#/view/com/util/List' 17import {android, atoms as a, native, useTheme, web} from '#/alf' 18import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19import * as Dialog from '#/components/Dialog' 20import {canBeMessaged} from '#/components/dms/util' 21import * as Toggle from '#/components/forms/Toggle' 22import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' 23import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 24import {Text} from '#/components/Typography' 25import {IS_NATIVE, IS_WEB} from '#/env' 26import type * as bsky from '#/types/bsky' 27import {ChatProfileTabs} from './ChatProfileTabs' 28import {EmptyMemberList} from './components/EmptyMemberList' 29import {GroupChatProfileCard} from './components/GroupChatProfileCard' 30import {ProfileCardSkeleton} from './components/ProfileCardSkeleton' 31import {UserLabel} from './components/UserLabel' 32import {UserSearchInput} from './components/UserSearchInput' 33 34type LabelItem = { 35 type: 'label' 36 key: string 37 message: string 38} 39 40type ProfileItem = { 41 type: 'profile' 42 key: string 43 profile: bsky.profile.AnyProfileView 44} 45 46type EmptyItem = { 47 type: 'empty' 48 key: string 49 message: string 50} 51 52type PlaceholderItem = { 53 type: 'placeholder' 54 key: string 55} 56 57type ErrorItem = { 58 type: 'error' 59 key: string 60} 61 62type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | ErrorItem 63 64export type State = { 65 groupChatDids: string[] 66 groupChatProfiles: bsky.profile.AnyProfileView[] 67} 68 69export type Action = 70 | { 71 type: 'setDids' 72 groupChatDids: string[] 73 groupChatProfiles: bsky.profile.AnyProfileView[] 74 } 75 | { 76 type: 'removeDids' 77 groupChatDids: string[] 78 groupChatProfiles: bsky.profile.AnyProfileView[] 79 } 80 81function reducer(state: State, action: Action): State { 82 switch (action.type) { 83 case 'setDids': { 84 return { 85 ...state, 86 groupChatDids: action.groupChatDids, 87 groupChatProfiles: action.groupChatProfiles, 88 } 89 } 90 case 'removeDids': { 91 return { 92 ...state, 93 groupChatDids: action.groupChatDids, 94 groupChatProfiles: action.groupChatProfiles, 95 } 96 } 97 } 98} 99 100export function AddMembersFlow({ 101 title, 102 onAddMembers, 103}: { 104 title: string 105 onAddMembers: (dids: string[]) => void 106}) { 107 const t = useTheme() 108 const {t: l} = useLingui() 109 const moderationOpts = useModerationOpts() 110 const control = Dialog.useDialogContext() 111 const [headerHeight, setHeaderHeight] = useState(0) 112 const [footerHeight, setFooterHeight] = useState(0) 113 const listRef = useRef<ListMethods>(null) 114 const {currentAccount} = useSession() 115 const inputRef = useRef<TextInput>(null) 116 117 const [searchText, setSearchText] = useState('') 118 119 const { 120 data: results, 121 isError, 122 isFetching, 123 } = useActorAutocompleteQuery(searchText, true, 12) 124 const {data: follows} = useProfileFollowsQuery(currentAccount?.did) 125 126 const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, { 127 groupChatDids: [], 128 groupChatProfiles: [], 129 }) 130 131 const onRemoveDid = useCallback( 132 (did: string) => { 133 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 134 dispatch({ 135 type: 'removeDids', 136 groupChatDids: groupChatDids.filter(d => d !== did), 137 groupChatProfiles: groupChatProfiles.filter( 138 profile => profile.did !== did, 139 ), 140 }) 141 }, 142 [groupChatDids, groupChatProfiles], 143 ) 144 145 const items = useMemo(() => { 146 let _items: Item[] = [] 147 148 if (isError) { 149 _items.push({ 150 type: 'empty', 151 key: 'empty', 152 message: l`We鈥檙e having network issues, try again`, 153 }) 154 } else if (searchText.length) { 155 if (results?.length) { 156 for (const profile of results) { 157 if (profile.did === currentAccount?.did) continue 158 _items.push({ 159 type: 'profile', 160 key: profile.did, 161 profile, 162 }) 163 } 164 165 _items = _items.sort(item => { 166 return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 167 }) 168 } 169 } else { 170 const placeholders: Item[] = Array(10) 171 .fill(0) 172 .map((__, i) => ({ 173 type: 'placeholder', 174 key: i + '', 175 })) 176 177 if (follows) { 178 for (const page of follows.pages) { 179 for (const profile of page.follows) { 180 _items.push({ 181 type: 'profile', 182 key: profile.did, 183 profile, 184 }) 185 } 186 } 187 188 _items = _items.sort(item => { 189 return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 190 }) 191 } else { 192 _items.push(...placeholders) 193 } 194 } 195 196 if (searchText === '') { 197 _items.unshift({ 198 type: 'label', 199 key: 'suggested', 200 message: l`Suggested`, 201 }) 202 } 203 204 return _items 205 }, [isError, searchText, l, results, currentAccount?.did, follows]) 206 207 if (searchText && !isFetching && !items.length && !isError) { 208 items.push({type: 'empty', key: 'empty', message: l`No results`}) 209 } 210 211 const handlePressBack = useCallback(() => { 212 control.close() 213 }, [control]) 214 215 const handlePressAdd = useCallback(() => { 216 onAddMembers(groupChatDids) 217 }, [groupChatDids, onAddMembers]) 218 219 const renderItems = useCallback( 220 ({item}: {item: Item}) => { 221 switch (item.type) { 222 case 'label': { 223 return <UserLabel key={item.key} message={item.message} /> 224 } 225 case 'profile': { 226 return ( 227 <GroupChatProfileCard 228 key={item.key} 229 profile={item.profile} 230 moderationOpts={moderationOpts!} 231 /> 232 ) 233 } 234 case 'placeholder': { 235 return <ProfileCardSkeleton key={item.key} /> 236 } 237 case 'empty': { 238 return <EmptyMemberList key={item.key} message={item.message} /> 239 } 240 default: 241 return null 242 } 243 }, 244 [moderationOpts], 245 ) 246 247 useLayoutEffect(() => { 248 if (IS_WEB) { 249 setImmediate(() => { 250 inputRef?.current?.focus() 251 }) 252 } 253 }, []) 254 255 let buttonLabel = l`Continue to group name` 256 let buttonText = l`Next` 257 let showButton = groupChatProfiles.length > 0 258 let isButtonDisabled = !showButton 259 260 const showChatProfileTabs = groupChatProfiles.length > 0 261 262 const listHeader = useMemo( 263 () => ( 264 <View onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}> 265 <View 266 style={[ 267 a.relative, 268 web(a.pt_lg), 269 native(a.pt_4xl), 270 android({ 271 borderTopLeftRadius: a.rounded_md.borderRadius, 272 borderTopRightRadius: a.rounded_md.borderRadius, 273 }), 274 a.px_lg, 275 a.border_b, 276 t.atoms.border_contrast_low, 277 t.atoms.bg, 278 ]}> 279 <View 280 style={[ 281 a.flex_row, 282 a.gap_sm, 283 a.relative, 284 a.align_center, 285 a.justify_between, 286 web(a.pb_lg), 287 ]}> 288 {IS_NATIVE ? ( 289 <Button 290 label={l`Back`} 291 size="large" 292 shape="round" 293 variant="ghost" 294 color="secondary" 295 style={[native([a.absolute, a.z_20])]} 296 onPress={handlePressBack}> 297 <ButtonIcon icon={ArrowLeftIcon} size="lg" /> 298 </Button> 299 ) : null} 300 <Text 301 style={[ 302 a.flex_grow, 303 a.z_10, 304 a.text_lg, 305 a.font_bold, 306 a.leading_tight, 307 t.atoms.text_contrast_high, 308 a.text_center, 309 a.px_5xl, 310 ]}> 311 {title} 312 </Text> 313 {IS_WEB ? ( 314 <Button 315 label={l`Close`} 316 size="small" 317 shape="round" 318 variant="ghost" 319 color="secondary" 320 style={[a.absolute, a.z_20, {right: -4}]} 321 onPress={() => control.close()}> 322 <ButtonIcon icon={XIcon} size="lg" /> 323 </Button> 324 ) : showButton ? ( 325 <Button 326 label={buttonLabel} 327 size="small" 328 color="primary" 329 style={[ 330 native([ 331 a.absolute, 332 a.z_20, 333 { 334 right: 8, 335 }, 336 ]), 337 ]} 338 disabled={isButtonDisabled} 339 onPress={handlePressAdd}> 340 <ButtonText> 341 <Trans>Add</Trans> 342 </ButtonText> 343 </Button> 344 ) : null} 345 </View> 346 <View style={[web(a.pt_xs), native(a.pt_md)]}> 347 <UserSearchInput 348 inputRef={inputRef} 349 value={searchText} 350 onChangeText={text => { 351 setSearchText(text) 352 listRef.current?.scrollToOffset({offset: 0, animated: false}) 353 }} 354 onEscape={control.close} 355 /> 356 </View> 357 </View> 358 {showChatProfileTabs ? ( 359 <View style={[a.pb_sm, a.pt_md, t.atoms.bg]}> 360 <ChatProfileTabs 361 testID="newGroupChatMembers" 362 profiles={groupChatProfiles} 363 onRemove={onRemoveDid} 364 /> 365 </View> 366 ) : null} 367 </View> 368 ), 369 [ 370 buttonLabel, 371 control, 372 groupChatProfiles, 373 handlePressAdd, 374 handlePressBack, 375 isButtonDisabled, 376 l, 377 onRemoveDid, 378 searchText, 379 showButton, 380 showChatProfileTabs, 381 t.atoms.bg, 382 t.atoms.border_contrast_low, 383 t.atoms.text_contrast_high, 384 title, 385 ], 386 ) 387 388 const setGroupChatMembers = (dids: string[]) => { 389 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 390 391 const added = dids.filter(d => !groupChatDids.includes(d)) 392 const removed = groupChatDids.filter(d => !dids.includes(d)) 393 const newDids = [ 394 ...groupChatDids.filter(d => !removed.includes(d)), 395 ...added, 396 ] 397 398 const kept = groupChatProfiles.filter(p => dids.includes(p.did)) 399 const keptDids = new Set(kept.map(p => p.did)) 400 const addedProfiles = items 401 .filter( 402 (item): item is ProfileItem => 403 item.type === 'profile' && 404 dids.includes(item.profile.did) && 405 !keptDids.has(item.profile.did), 406 ) 407 .map(item => item.profile) 408 .sort((a, b) => dids.indexOf(a.did) - dids.indexOf(b.did)) 409 410 dispatch({ 411 type: 'setDids', 412 groupChatDids: newDids, 413 groupChatProfiles: [...kept, ...addedProfiles], 414 }) 415 } 416 417 return ( 418 <Toggle.Group 419 values={groupChatDids} 420 onChange={setGroupChatMembers} 421 type="checkbox" 422 label={l`Add group chat members`} 423 style={web([a.contents])}> 424 <Dialog.InnerFlatList 425 ref={listRef} 426 data={items} 427 renderItem={renderItems} 428 ListHeaderComponent={listHeader} 429 stickyHeaderIndices={[0]} 430 keyExtractor={(item: Item) => item.key} 431 style={[ 432 web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), 433 native({height: '100%'}), 434 ]} 435 webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]} 436 webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} 437 scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}} 438 keyboardDismissMode="on-drag" 439 footer={ 440 IS_WEB ? ( 441 <Dialog.FlatListFooter 442 onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}> 443 <View style={[a.flex_row, a.align_center, a.justify_between]}> 444 <Button 445 label={l`Back`} 446 size="small" 447 color="secondary" 448 onPress={handlePressBack}> 449 <ButtonIcon icon={ArrowLeftIcon} size="md" /> 450 <ButtonText> 451 {' '} 452 <Trans>Back</Trans> 453 </ButtonText> 454 </Button> 455 <Button 456 label={buttonLabel} 457 size="small" 458 color="primary" 459 disabled={isButtonDisabled} 460 onPress={handlePressAdd}> 461 <ButtonText>{buttonText} </ButtonText> 462 </Button> 463 </View> 464 </Dialog.FlatListFooter> 465 ) : null 466 } 467 /> 468 </Toggle.Group> 469 ) 470}