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

Configure Feed

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

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