Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
12 kB · 396 lines
TSX
at prod
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397import {useState} from 'react'import {View} from 'react-native'import {Trans, useLingui} from '@lingui/react/macro'import {useQueryClient} from '@tanstack/react-query'
import {isNetworkError} from '#/lib/strings/errors'import {matchXrpcError} from '#/lib/xrpc-error'import {logger} from '#/logger'import {type Shadow} from '#/state/cache/types'import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'import { createListMutualGroupsQueryKey, useListMutualGroupsQuery,} from '#/state/queries/messages/list-mutual-groups'import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group'import {useSession} from '#/state/session'import {atoms as a, native, useTheme, web} from '#/alf'import {AvatarBubbles} from '#/components/AvatarBubbles'import {Button, ButtonIcon, ButtonText} from '#/components/Button'import * as Dialog from '#/components/Dialog'import {type DialogControlProps} from '#/components/Dialog'import {parseConvoView} from '#/components/dms/util'import {Loader} from '#/components/Loader'import * as Toast from '#/components/Toast'import {Text} from '#/components/Typography'import {chat} from '#/lexicons'import {type AnyProfileView} from '#/types/bsky/profile'
type Item = chat.bsky.convo.defs.ConvoView
type BlockDialogProps = { control: DialogControlProps profile: Shadow<AnyProfileView> onBlock: () => Promise<void> currentConvoId?: string}
export function BlockDialog({ control, profile, onBlock, currentConvoId,}: BlockDialogProps) { return ( <Dialog.Outer control={control}> <View style={[a.relative]}> <Dialog.Handle /> <BlockDialogInner control={control} profile={profile} onBlock={onBlock} currentConvoId={currentConvoId} /> <Dialog.Close /> </View> </Dialog.Outer> )}
function BlockDialogInner({ control, profile, onBlock, currentConvoId,}: { control: DialogControlProps profile: Shadow<AnyProfileView> onBlock: () => Promise<void> currentConvoId?: string}) { const t = useTheme() const {t: l} = useLingui()
const [headerHeight, setHeaderHeight] = useState(0) const [footerHeight, setFooterHeight] = useState(0)
/* * Optimistically hide convos the viewer has left or removed the profile * from, before the query refetches. We don't expect many items here, so a * simple filter is fine. */ const [removedConvoIds, setRemovedConvoIds] = useState<Set<string>>( () => new Set(), ) const onOptimisticallyRemoveConvo = (convoId: string) => { setRemovedConvoIds(prev => { const next = new Set(prev) next.add(convoId) return next }) } const onRestoreConvo = (convoId: string) => { setRemovedConvoIds(prev => { const next = new Set(prev) next.delete(convoId) return next }) }
const {data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage} = useListMutualGroupsQuery({ subject: profile.did, enabled: !profile.viewer?.blocking, }) const items: Item[] = (data?.pages.flatMap(page => page.convos) ?? []).filter( item => !removedConvoIds.has(item.id), ) const hasMutualGroupChats = items.length > 0
const onEndReached = async () => { if (isFetchingNextPage || !hasNextPage) return try { await fetchNextPage() } catch (err) { logger.error('Failed to load more mutual group chats', {message: err}) } }
const renderItems = ({item}: {item: Item}) => { return ( <MutualGroupChat view={item} profileDid={profile.did} currentConvoId={currentConvoId} onOptimisticallyRemoveConvo={onOptimisticallyRemoveConvo} onRestoreConvo={onRestoreConvo} /> ) }
const listHeader = ( <View style={[t.atoms.bg]} onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}> <View style={[ hasMutualGroupChats && native([a.pt_2xl, a.px_2xl]), a.pb_lg, a.gap_sm, ]}> <Text style={[a.text_2xl, a.font_bold, t.atoms.text]}> {profile.viewer?.blocking ? ( <Trans>Unblock account?</Trans> ) : ( <Trans>Block account?</Trans> )} </Text> <Text style={[a.text_md, t.atoms.text_contrast_medium]}> {profile.viewer?.blocking ? ( <Trans> The account will be able to interact with you after unblocking. </Trans> ) : profile.associated?.labeler ? ( <Trans> Blocking will not prevent labels from being applied on your account, but it will stop this account from replying in your threads or interacting with you. </Trans> ) : ( <Trans> Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. </Trans> )} </Text> </View> {hasMutualGroupChats ? ( <View style={[web(a.pt_sm), native(a.px_2xl), a.pb_xs, t.atoms.bg]}> <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_high]}> <Trans>Mutual group chats</Trans> </Text> </View> ) : null} </View> )
const footer = ( <View style={[a.w_full, a.gap_sm, a.justify_end]}> <Button disabled={isLoading} color={profile.viewer?.blocking ? undefined : 'negative'} size="large" label={profile.viewer?.blocking ? l`Unblock` : l`Block`} onPress={() => control.close(() => void onBlock())}> <ButtonText> {profile.viewer?.blocking ? ( <Trans>Unblock</Trans> ) : ( <Trans>Block</Trans> )} </ButtonText> {isLoading ? <ButtonIcon icon={Loader} /> : null} </Button> <Button color="secondary" size="large" label={l`Close dialog`} onPress={() => control.close()}> <ButtonText> <Trans>Cancel</Trans> </ButtonText> </Button> </View> )
if (isLoading || !hasMutualGroupChats) { return ( <Dialog.ScrollableInner label={profile.viewer?.blocking ? l`Unblock` : l`Block`} style={[web([{maxWidth: 420}])]}> {listHeader} {footer} </Dialog.ScrollableInner> ) }
return ( <Dialog.InnerFlatList data={items} renderItem={renderItems} ListHeaderComponent={listHeader} stickyHeaderIndices={[0]} ListFooterComponent={ isFetchingNextPage ? ( <View style={[a.py_lg, a.align_center, a.justify_center]}> <Loader size="lg" /> </View> ) : undefined } footer={ <Dialog.FlatListFooter onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}> {footer} </Dialog.FlatListFooter> } contentContainerStyle={[a.gap_0, {paddingBottom: footerHeight}]} scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}} onEndReached={() => void onEndReached()} onEndReachedThreshold={0.5} style={[web([{height: '100vh', maxHeight: 600}])]} webInnerStyle={[{maxWidth: 420}]} /> )}
function MutualGroupChat({ view, profileDid, currentConvoId, onOptimisticallyRemoveConvo, onRestoreConvo,}: { view: chat.bsky.convo.defs.ConvoView profileDid: string currentConvoId?: string onOptimisticallyRemoveConvo: (convoId: string) => void onRestoreConvo: (convoId: string) => void}) { const t = useTheme() const {t: l} = useLingui() const {currentAccount} = useSession() const queryClient = useQueryClient()
const convo = parseConvoView(view, currentAccount?.did)
const {mutate: leaveConvo, isPending: isLeavePending} = useLeaveConvo( convo?.view.id, { onSuccess: () => { Toast.show(l`Left group chat.`) void queryClient.invalidateQueries({ queryKey: createListMutualGroupsQueryKey({subject: profileDid}), }) }, onError: error => { onRestoreConvo(view.id) logger.error('Error leaving group chat', {message: error}) let errorMessage = l`Could not leave chat.` if (isNetworkError(error)) { errorMessage = l`A network error occurred. Please check your internet connection.` } else { switch (matchXrpcError(error, chat.bsky.convo.leaveConvo)) { case 'InvalidConvo': errorMessage = l`Chat not found.` break case 'OwnerCannotLeave': errorMessage = l`Chat owners cannot leave a group chat.` break } } Toast.show(errorMessage, {type: 'error'}) }, }, )
const {mutate: removeMembers, isPending: isRemovePending} = useRemoveFromGroupChat(convo?.view.id, { onSuccess: () => { Toast.show(l`Member removed from group chat.`) void queryClient.invalidateQueries({ queryKey: createListMutualGroupsQueryKey({subject: profileDid}), }) }, onError: error => { onRestoreConvo(view.id) logger.error('Error removing group chat member', {message: error}) let errorMessage = l`Could not remove member.` if (isNetworkError(error)) { errorMessage = l`A network error occurred. Please check your internet connection.` } else { switch (matchXrpcError(error, chat.bsky.group.removeMembers)) { case 'InvalidConvo': errorMessage = l`Chat not found.` break case 'InsufficientRole': errorMessage = l`You must be a chat owner to remove a member.` break } } Toast.show(errorMessage, {type: 'error'}) }, })
if (!convo || convo.kind !== 'group') return null
const owner = convo.primaryMember const isViewerOwner = owner?.did != null && owner.did === currentAccount?.did const isProfileOwner = owner?.did != null && owner.did === profileDid const isCurrentConvo = view.id === currentConvoId
return ( <View style={[ a.flex_row, a.align_center, a.gap_sm, a.justify_between, a.py_sm, native(a.px_2xl), ]}> <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> <AvatarBubbles profiles={convo.members} size={40} /> <View style={[a.flex_1]}> <Text style={[a.text_md, a.font_semi_bold]} emoji numberOfLines={1}> {convo.details.name} </Text> {isViewerOwner ? ( <Text style={[a.text_xs, t.atoms.text_contrast_medium]}> <Trans>You own this chat</Trans> </Text> ) : isProfileOwner ? ( <Text style={[a.text_xs, t.atoms.text_contrast_medium]}> <Trans>They own this chat</Trans> </Text> ) : null} </View> </View> {isViewerOwner ? ( <Button color="negative_subtle" disabled={isRemovePending} label={l`Remove member`} size="small" onPress={() => { onOptimisticallyRemoveConvo(view.id) removeMembers({members: [profileDid]}) }}> <ButtonText> <Trans>Remove member</Trans> </ButtonText> {isRemovePending ? <ButtonIcon icon={Loader} /> : null} </Button> ) : isCurrentConvo ? ( <Text style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}> <Trans>Current chat</Trans> </Text> ) : ( <Button color="secondary" disabled={isLeavePending} label={l`Leave chat`} size="small" onPress={() => { onOptimisticallyRemoveConvo(view.id) leaveConvo() }}> <ButtonText> <Trans>Leave chat</Trans> </ButtonText> {isLeavePending ? <ButtonIcon icon={Loader} /> : null} </Button> )} </View> )}