Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫
Something went wrong. Try again.
9.2 kB · 331 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332import {View} from 'react-native'import {Trans, useLingui} from '@lingui/react/macro'import {useQueryClient} from '@tanstack/react-query'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'import {sanitizeDisplayName} from '#/lib/strings/display-names'import {isNetworkError} from '#/lib/strings/errors'import {sanitizeHandle} from '#/lib/strings/handles'import {logger} from '#/logger'import { useListMembershipAddMutation, useListMembershipRemoveMutation,} from '#/state/queries/list-memberships'import { type ListWithMembership, removeListMembershipOptimistically, updateListMembershipOptimistically, useListsWithMembershipQuery,} from '#/state/queries/lists-with-membership'import {useSession} from '#/state/session'import {UserAvatar} from '#/view/com/util/UserAvatar'import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf'import {Button, ButtonIcon, ButtonText} from '#/components/Button'import * as Dialog from '#/components/Dialog'import {BulletList_Stroke2_Corner0_Rounded as ListIcon} from '#/components/icons/BulletList'import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'import {Loader} from '#/components/Loader'import * as Toast from '#/components/Toast'import {Text} from '#/components/Typography'import type * as bsky from '#/types/bsky'
export type UserAddRemoveListsDialogProps = { control: Dialog.DialogControlProps profile: bsky.profile.AnyProfileView | undefined onAdd?: (listUri: string) => void onRemove?: (listUri: string) => void}
export function UserAddRemoveListsDialog({ control, profile, onAdd, onRemove,}: UserAddRemoveListsDialogProps) { return ( <Dialog.Outer control={control} testID="userAddRemoveListsDialog" nativeOptions={{fullHeight: true}}> <Dialog.Handle /> <ListsContent profile={profile} onAdd={onAdd} onRemove={onRemove} /> </Dialog.Outer> )}
function Empty() { const t = useTheme()
return ( <View style={[ a.gap_2xl, platform({web: {paddingTop: 100}, native: {paddingTop: 64}}), ]}> <View style={[a.gap_xs, a.align_center]}> <ListIcon size="xl" style={{color: t.atoms.border_contrast_medium.borderColor}} /> <Text style={[a.text_center]}> <Trans>You have no lists.</Trans> </Text> </View> </View> )}
function ListsContent({ profile, onAdd, onRemove,}: { profile: bsky.profile.AnyProfileView | undefined onAdd?: (listUri: string) => void onRemove?: (listUri: string) => void}) { const t = useTheme() const control = Dialog.useDialogContext() const {t: l} = useLingui() const {gtMobile} = useBreakpoints()
const { data, isError, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage, } = useListsWithMembershipQuery({actor: profile?.did})
const listItems = data?.pages.flatMap(page => page.listsWithMembership) || []
const onEndReached = async () => { if (isFetchingNextPage || !hasNextPage || isError) return try { await fetchNextPage() } catch (err) { // Error handling is optional since this is just pagination } }
const renderItem = ({item}: {item: ListWithMembership}) => profile ? ( <ListItem listWithMembership={item} profile={profile} onAdd={onAdd} onRemove={onRemove} /> ) : null
const onClose = () => { control.close() }
const listHeader = ( <View style={[ a.justify_between, a.align_center, a.flex_row, a.pb_lg, t.atoms.bg, gtMobile ? a.pt_2xl : a.pt_xl, ]}> <Text style={[a.text_lg, a.font_semi_bold]}> {profile ? ( <Trans> Update {createSanitizedDisplayName(profile, true)} in Lists </Trans> ) : ( <Trans>Update in Lists</Trans> )} </Text> <Button testID="doneBtn" label={l`Close`} onPress={onClose} variant="ghost" color="secondary" size="small" shape="round" style={{margin: -8}}> <ButtonIcon icon={XIcon} /> </Button> </View> )
return ( <Dialog.InnerFlatList data={isLoading ? [{}] : listItems} renderItem={ isLoading ? () => ( <View style={[a.align_center, a.py_2xl]}> <Loader size="xl" /> </View> ) : renderItem } keyExtractor={ isLoading ? () => 'lists_dialog_loader' : (item: ListWithMembership) => item.list.uri } onEndReached={() => void onEndReached()} onEndReachedThreshold={0.1} stickyHeaderIndices={web([0])} ListHeaderComponent={listHeader} ListFooterComponent={ isFetchingNextPage ? ( <View style={[a.align_center, a.py_lg]}> <Loader size="lg" /> </View> ) : null } ListEmptyComponent={!isLoading && data ? <Empty /> : null} webInnerContentContainerStyle={[a.py_0]} style={platform({ web: [a.px_2xl, a.pb_md], native: [a.px_2xl, a.pt_lg], })} /> )}
function ListItem({ listWithMembership, profile, onAdd, onRemove,}: { listWithMembership: ListWithMembership profile: bsky.profile.AnyProfileView onAdd?: (listUri: string) => void onRemove?: (listUri: string) => void}) { const {t: l} = useLingui() const t = useTheme() const queryClient = useQueryClient() const {currentAccount} = useSession()
const list = listWithMembership.list const listItem = listWithMembership.listItem const isMember = !!listItem
const {mutate: addMembership, isPending: isPendingAdd} = useListMembershipAddMutation({ subject: profile, onSuccess: data => { Toast.show(l`Added to list`) onAdd?.(list.uri) updateListMembershipOptimistically({ queryClient, actor: profile.did, listUri: list.uri, membershipUri: data.uri, subject: { did: profile.did, handle: profile.handle, displayName: profile.displayName, }, }) }, onError: err => { if (!isNetworkError(err)) { logger.error('Failed to add to list', {safeMessage: err}) } Toast.show(l`Failed to add to list`, {type: 'error'}) }, })
const {mutate: removeMembership, isPending: isPendingRemove} = useListMembershipRemoveMutation({ onSuccess: () => { Toast.show(l`Removed from list`) onRemove?.(list.uri) removeListMembershipOptimistically({ queryClient, actor: profile.did, listUri: list.uri, }) }, onError: err => { if (!isNetworkError(err)) { logger.error('Failed to remove from list', {safeMessage: err}) } Toast.show(l`Failed to remove from list`, {type: 'error'}) }, })
const isPending = isPendingAdd || isPendingRemove
const handleToggleMembership = () => { if (isPending) return
if (!isMember) { addMembership({ listUri: list.uri, actorDid: profile.did, }) } else { if (!listItem?.uri) { logger.error('Cannot remove: missing membership URI') return } removeMembership({ listUri: list.uri, actorDid: profile.did, membershipUri: listItem.uri, }) } }
return ( <View testID={`toggleBtn-${list.name}`} style={[a.flex_row, a.align_center, a.py_md, a.gap_md]}> <UserAvatar size={40} avatar={list.avatar} type="list" /> <View style={[a.flex_1]}> <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]} numberOfLines={1}> {sanitizeDisplayName(list.name)} </Text> <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]} numberOfLines={1}> {list.purpose === 'app.bsky.graph.defs#curatelist' && (list.creator.did === currentAccount?.did ? ( <Trans>User list by you</Trans> ) : ( <Trans> User list by {sanitizeHandle(list.creator.handle, '@')} </Trans> ))} {list.purpose === 'app.bsky.graph.defs#modlist' && (list.creator.did === currentAccount?.did ? ( <Trans>Moderation list by you</Trans> ) : ( <Trans> Moderation list by {sanitizeHandle(list.creator.handle, '@')} </Trans> ))} </Text> </View> <Button testID={`user-${profile.handle}-addBtn`} label={isMember ? l`Remove` : l`Add`} onPress={handleToggleMembership} disabled={isPending} size="tiny" color={isMember ? 'secondary' : 'primary_subtle'}> {isPending && <ButtonIcon icon={Loader} />} <ButtonText> {isMember ? <Trans>Remove</Trans> : <Trans>Add</Trans>} </ButtonText> </Button> </View> )}