Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫
Something went wrong. Try again.
13 kB · 470 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471import { useCallback, useLayoutEffect, useMemo, useReducer, useRef, useState,} from 'react'import {LayoutAnimation, type TextInput, View} from 'react-native'import {Trans, useLingui} from '@lingui/react/macro'
import {useModerationOpts} from '#/state/preferences/moderation-opts'import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'import {useProfileFollowsQuery} from '#/state/queries/profile-follows'import {useSession} from '#/state/session'import {type ListMethods} from '#/view/com/util/List'import {android, atoms as a, native, useTheme, web} from '#/alf'import {Button, ButtonIcon, ButtonText} from '#/components/Button'import * as Dialog from '#/components/Dialog'import {canBeMessaged} from '#/components/dms/util'import * as Toggle from '#/components/forms/Toggle'import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'import {Text} from '#/components/Typography'import {IS_NATIVE, IS_WEB} from '#/env'import type * as bsky from '#/types/bsky'import {ChatProfileTabs} from './ChatProfileTabs'import {EmptyMemberList} from './components/EmptyMemberList'import {GroupChatProfileCard} from './components/GroupChatProfileCard'import {ProfileCardSkeleton} from './components/ProfileCardSkeleton'import {UserLabel} from './components/UserLabel'import {UserSearchInput} from './components/UserSearchInput'
type LabelItem = { type: 'label' key: string message: string}
type ProfileItem = { type: 'profile' key: string profile: bsky.profile.AnyProfileView}
type EmptyItem = { type: 'empty' key: string message: string}
type PlaceholderItem = { type: 'placeholder' key: string}
type ErrorItem = { type: 'error' key: string}
type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | ErrorItem
export type State = { groupChatDids: string[] groupChatProfiles: bsky.profile.AnyProfileView[]}
export type Action = | { type: 'setDids' groupChatDids: string[] groupChatProfiles: bsky.profile.AnyProfileView[] } | { type: 'removeDids' groupChatDids: string[] groupChatProfiles: bsky.profile.AnyProfileView[] }
function reducer(state: State, action: Action): State { switch (action.type) { case 'setDids': { return { ...state, groupChatDids: action.groupChatDids, groupChatProfiles: action.groupChatProfiles, } } case 'removeDids': { return { ...state, groupChatDids: action.groupChatDids, groupChatProfiles: action.groupChatProfiles, } } }}
export function AddMembersFlow({ title, onAddMembers,}: { title: string onAddMembers: (dids: string[]) => void}) { const t = useTheme() const {t: l} = useLingui() const moderationOpts = useModerationOpts() const control = Dialog.useDialogContext() const [headerHeight, setHeaderHeight] = useState(0) const [footerHeight, setFooterHeight] = useState(0) const listRef = useRef<ListMethods>(null) const {currentAccount} = useSession() const inputRef = useRef<TextInput>(null)
const [searchText, setSearchText] = useState('')
const { data: results, isError, isFetching, } = useActorAutocompleteQuery(searchText, true, 12) const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, { groupChatDids: [], groupChatProfiles: [], })
const onRemoveDid = useCallback( (did: string) => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) dispatch({ type: 'removeDids', groupChatDids: groupChatDids.filter(d => d !== did), groupChatProfiles: groupChatProfiles.filter( profile => profile.did !== did, ), }) }, [groupChatDids, groupChatProfiles], )
const items = useMemo(() => { let _items: Item[] = []
if (isError) { _items.push({ type: 'empty', key: 'empty', message: l`We’re having network issues, try again`, }) } else if (searchText.length) { if (results?.length) { for (const profile of results) { if (profile.did === currentAccount?.did) continue _items.push({ type: 'profile', key: profile.did, profile, }) }
_items = _items.sort(item => { return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 }) } } else { const placeholders: Item[] = Array(10) .fill(0) .map((__, i) => ({ type: 'placeholder', key: i + '', }))
if (follows) { for (const page of follows.pages) { for (const profile of page.follows) { _items.push({ type: 'profile', key: profile.did, profile, }) } }
_items = _items.sort(item => { return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 }) } else { _items.push(...placeholders) } }
if (searchText === '') { _items.unshift({ type: 'label', key: 'suggested', message: l`Suggested`, }) }
return _items }, [isError, searchText, l, results, currentAccount?.did, follows])
if (searchText && !isFetching && !items.length && !isError) { items.push({type: 'empty', key: 'empty', message: l`No results`}) }
const handlePressBack = useCallback(() => { control.close() }, [control])
const handlePressAdd = useCallback(() => { onAddMembers(groupChatDids) }, [groupChatDids, onAddMembers])
const renderItems = useCallback( ({item}: {item: Item}) => { switch (item.type) { case 'label': { return <UserLabel key={item.key} message={item.message} /> } case 'profile': { return ( <GroupChatProfileCard key={item.key} profile={item.profile} moderationOpts={moderationOpts!} /> ) } case 'placeholder': { return <ProfileCardSkeleton key={item.key} /> } case 'empty': { return <EmptyMemberList key={item.key} message={item.message} /> } default: return null } }, [moderationOpts], )
useLayoutEffect(() => { if (IS_WEB) { setImmediate(() => { inputRef?.current?.focus() }) } }, [])
let buttonLabel = l`Continue to group name` let buttonText = l`Next` let showButton = groupChatProfiles.length > 0 let isButtonDisabled = !showButton
const showChatProfileTabs = groupChatProfiles.length > 0
const listHeader = useMemo( () => ( <View onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}> <View style={[ a.relative, web(a.pt_lg), native(a.pt_4xl), android({ borderTopLeftRadius: a.rounded_md.borderRadius, borderTopRightRadius: a.rounded_md.borderRadius, }), a.px_lg, a.border_b, t.atoms.border_contrast_low, t.atoms.bg, ]}> <View style={[ a.flex_row, a.gap_sm, a.relative, a.align_center, a.justify_between, web(a.pb_lg), ]}> {IS_NATIVE ? ( <Button label={l`Back`} size="large" shape="round" variant="ghost" color="secondary" style={[native([a.absolute, a.z_20])]} onPress={handlePressBack}> <ButtonIcon icon={ArrowLeftIcon} size="lg" /> </Button> ) : null} <Text style={[ a.flex_grow, a.z_10, a.text_lg, a.font_bold, a.leading_tight, t.atoms.text_contrast_high, a.text_center, a.px_5xl, ]}> {title} </Text> {IS_WEB ? ( <Button label={l`Close`} size="small" shape="round" variant="ghost" color="secondary" style={[a.absolute, a.z_20, {right: -4}]} onPress={() => control.close()}> <ButtonIcon icon={XIcon} size="lg" /> </Button> ) : showButton ? ( <Button label={buttonLabel} size="small" color="primary" style={[ native([ a.absolute, a.z_20, { right: 8, }, ]), ]} disabled={isButtonDisabled} onPress={handlePressAdd}> <ButtonText> <Trans>Add</Trans> </ButtonText> </Button> ) : null} </View> <View style={[web(a.pt_xs), native(a.pt_md)]}> <UserSearchInput inputRef={inputRef} value={searchText} onChangeText={text => { setSearchText(text) listRef.current?.scrollToOffset({offset: 0, animated: false}) }} onEscape={control.close} /> </View> </View> {showChatProfileTabs ? ( <View style={[a.pb_sm, a.pt_md, t.atoms.bg]}> <ChatProfileTabs testID="newGroupChatMembers" profiles={groupChatProfiles} onRemove={onRemoveDid} /> </View> ) : null} </View> ), [ buttonLabel, control, groupChatProfiles, handlePressAdd, handlePressBack, isButtonDisabled, l, onRemoveDid, searchText, showButton, showChatProfileTabs, t.atoms.bg, t.atoms.border_contrast_low, t.atoms.text_contrast_high, title, ], )
const setGroupChatMembers = (dids: string[]) => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
const added = dids.filter(d => !groupChatDids.includes(d)) const removed = groupChatDids.filter(d => !dids.includes(d)) const newDids = [ ...groupChatDids.filter(d => !removed.includes(d)), ...added, ]
const kept = groupChatProfiles.filter(p => dids.includes(p.did)) const keptDids = new Set(kept.map(p => p.did)) const addedProfiles = items .filter( (item): item is ProfileItem => item.type === 'profile' && dids.includes(item.profile.did) && !keptDids.has(item.profile.did), ) .map(item => item.profile) .sort((a, b) => dids.indexOf(a.did) - dids.indexOf(b.did))
dispatch({ type: 'setDids', groupChatDids: newDids, groupChatProfiles: [...kept, ...addedProfiles], }) }
return ( <Toggle.Group values={groupChatDids} onChange={setGroupChatMembers} type="checkbox" label={l`Add group chat members`} style={web([a.contents])}> <Dialog.InnerFlatList ref={listRef} data={items} renderItem={renderItems} ListHeaderComponent={listHeader} stickyHeaderIndices={[0]} keyExtractor={(item: Item) => item.key} style={[ web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), native({height: '100%'}), ]} webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}} keyboardDismissMode="on-drag" footer={ IS_WEB ? ( <Dialog.FlatListFooter onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}> <View style={[a.flex_row, a.align_center, a.justify_between]}> <Button label={l`Back`} size="small" color="secondary" onPress={handlePressBack}> <ButtonIcon icon={ArrowLeftIcon} size="md" /> <ButtonText> {' '} <Trans>Back</Trans> </ButtonText> </Button> <Button label={buttonLabel} size="small" color="primary" disabled={isButtonDisabled} onPress={handlePressAdd}> <ButtonText>{buttonText} </ButtonText> </Button> </View> </Dialog.FlatListFooter> ) : null } /> </Toggle.Group> )}