Implement Likes dialog

This commit is contained in:
Paul Frazee
2024-03-05 20:46:39 -08:00
parent 2a7af0b8c0
commit a610ce875f
10 changed files with 192 additions and 18 deletions
+2
View File
@@ -2,6 +2,7 @@ import React, {useImperativeHandle} from 'react'
import {View, Dimensions} from 'react-native' import {View, Dimensions} from 'react-native'
import BottomSheet, { import BottomSheet, {
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetFlatList,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetTextInput, BottomSheetTextInput,
BottomSheetView, BottomSheetView,
@@ -23,6 +24,7 @@ export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export * from '#/components/Dialog/types' export * from '#/components/Dialog/types'
// @ts-ignore // @ts-ignore
export const Input = createInput(BottomSheetTextInput) export const Input = createInput(BottomSheetTextInput)
export const FlatList = BottomSheetFlatList
export function Outer({ export function Outer({
children, children,
+1
View File
@@ -16,6 +16,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
export {useDialogControl, useDialogContext} from '#/components/Dialog/context' export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export * from '#/components/Dialog/types' export * from '#/components/Dialog/types'
export {Input} from '#/components/forms/TextField' export {Input} from '#/components/forms/TextField'
export {FlatList} from 'react-native'
const stopPropagation = (e: any) => e.stopPropagation() const stopPropagation = (e: any) => e.stopPropagation()
+140
View File
@@ -0,0 +1,140 @@
import React, {useMemo, useCallback} from 'react'
import {ActivityIndicator, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {BottomSheetFlatList} from '@gorhom/bottom-sheet'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import * as Dialog from '#/components/Dialog'
import {Button} from '#/components/Button'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
interface LikesDialogProps {
control: Dialog.DialogOuterProps['control']
uri: string
}
export function LikesDialog(props: LikesDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<LikesDialogInner {...props} />
</Dialog.Outer>
)
}
export function LikesDialogInner({control, uri}: LikesDialogProps) {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {
data: resolvedUri,
error: resolveError,
isFetching: isFetchingResolvedUri,
} = useResolveUriQuery(uri)
const {
data,
isFetching,
isFetched,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isError,
error,
} = useLikedByQuery(resolvedUri?.uri)
const likes = useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.likes)
}
}, [data])
const onEndReached = useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more likes', {message: err})
}
}, [isFetching, hasNextPage, isError, fetchNextPage])
const renderItem = useCallback(
({item}: {item: GetLikes.Like}) => {
return (
<ProfileCardWithFollowBtn
key={item.actor.did}
profile={item.actor}
onPress={() => control.close()}
/>
)
},
[control],
)
return (
<Dialog.Inner
accessibilityLabelledBy="dialog-title"
accessibilityDescribedBy="">
<Text
nativeID="dialog-title"
style={[a.text_2xl, a.font_bold, a.pb_md, a.leading_tight]}>
<Trans>Liked by</Trans>
</Text>
{isFetchingResolvedUri || !isFetched ? (
<ActivityIndicator />
) : resolveError || isError ? (
<ErrorMessage message={cleanError(resolveError || error)} />
) : likes?.length === 0 ? (
<View style={[t.atoms.bg_contrast_50, a.px_md, a.py_xl, a.rounded_md]}>
<Text style={[a.text_center]}>
<Trans>
Nobody has liked this yet. Maybe you should be the first!
</Trans>
</Text>
</View>
) : (
<Dialog.FlatList
data={likes}
keyExtractor={item => item.actor.did}
onEndReached={onEndReached}
renderItem={renderItem}
initialNumToRender={15}
ListFooterComponent={
<ListFooterComponent isFetching={isFetchingNextPage} />
}
/>
)}
<View style={[gtMobile && [a.flex_row, a.justify_end], a.mt_md]}>
<Button
testID="doneBtn"
variant="outline"
color="primary"
size="small"
onPress={() => control.close()}
label={_(msg`Done`)}>
{_(msg`Done`)}
</Button>
</View>
</Dialog.Inner>
)
}
function ListFooterComponent({isFetching}: {isFetching: boolean}) {
if (isFetching) {
return (
<View style={a.pt_lg}>
<ActivityIndicator />
</View>
)
}
return null
}
@@ -32,7 +32,6 @@ export interface LabelsOnMeDialogProps {
} }
export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) { export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {subject, labels} = props const {subject, labels} = props
@@ -120,6 +119,7 @@ function Label({
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui()
const {labeler, strings} = useLabelInfo(label) const {labeler, strings} = useLabelInfo(label)
return ( return (
<View <View
@@ -148,7 +148,11 @@ function Label({
</InlineLink> </InlineLink>
</View> </View>
<View> <View>
<Button variant="solid" color="secondary" size="small"> <Button
variant="solid"
color="secondary"
size="small"
label={_(msg`Appeal`)}>
<ButtonText> <ButtonText>
<Trans>Appeal</Trans> <Trans>Appeal</Trans>
</ButtonText> </ButtonText>
+1 -1
View File
@@ -38,7 +38,7 @@ import {InlineLink, Link} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {getModerationServiceTitle} from '#/lib/moderation' import {getModerationServiceTitle} from '#/lib/moderation'
import * as ModerationServiceCard from '#/components/ModerationServiceCard' import * as ModerationServiceCard from '#/components/ModerationServiceCard'
import {GlobalModerationLabelPref} from '#/components/ModerationLabelPref/SimpleModerationLabelPref' import {GlobalModerationLabelPref} from '#/components/moderation/GlobalModerationLabelPref'
function ErrorState({error}: {error: string}) { function ErrorState({error}: {error: string}) {
const t = useTheme() const t = useTheme()
@@ -35,6 +35,8 @@ import {
Heart2_Stroke2_Corner0_Rounded as Heart, Heart2_Stroke2_Corner0_Rounded as Heart,
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
} from '#/components/icons/Heart2' } from '#/components/icons/Heart2'
import {LikesDialog} from '#/components/LikesDialog'
import * as Dialog from '#/components/Dialog'
interface Props { interface Props {
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
@@ -56,10 +58,11 @@ let ProfileHeaderLabeler = ({
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
useProfileShadow(profileUnshadowed) useProfileShadow(profileUnshadowed)
const t = useTheme() const t = useTheme()
const {currentAccount, hasSession} = useSession()
const {_} = useLingui() const {_} = useLingui()
const {currentAccount, hasSession} = useSession()
const {openModal} = useModalControls() const {openModal} = useModalControls()
const {track} = useAnalytics() const {track} = useAnalytics()
const likesControl = Dialog.useDialogControl()
const moderation = useMemo( const moderation = useMemo(
() => moderateProfile(profile, moderationOpts), () => moderateProfile(profile, moderationOpts),
[profile, moderationOpts], [profile, moderationOpts],
@@ -196,7 +199,7 @@ let ProfileHeaderLabeler = ({
/> />
</View> </View>
) : undefined} ) : undefined}
<View style={[a.flex_row, a.gap_md, a.align_center, a.pt_md]}> <View style={[a.flex_row, a.gap_xs, a.align_center, a.pt_md]}>
<Button <Button
testID="toggleLikeBtn" testID="toggleLikeBtn"
size="small" size="small"
@@ -214,19 +217,34 @@ let ProfileHeaderLabeler = ({
</Button> </Button>
{typeof labeler.likeCount === 'number' && ( {typeof labeler.likeCount === 'number' && (
<InlineLink <Button
to={'#todo'} variant="ghost"
style={[t.atoms.text_contrast_medium, a.font_bold]}> size="tiny"
<Trans> onPress={() => likesControl.open()}
Liked by {labeler.likeCount}{' '} label={_(
{pluralize(labeler.likeCount, 'user')} msg`Liked by ${labeler.likeCount} ${pluralize(
</Trans> labeler.likeCount,
</InlineLink> 'user',
)}`,
)}>
<ButtonText
style={[
t.atoms.text_contrast_medium,
a.font_bold,
a.text_sm,
]}>
<Trans>
Liked by {labeler.likeCount}{' '}
{pluralize(labeler.likeCount, 'user')}
</Trans>
</ButtonText>
</Button>
)} )}
</View> </View>
</> </>
)} )}
</View> </View>
<LikesDialog control={likesControl} uri={labeler.uri} />
</ProfileHeaderShell> </ProfileHeaderShell>
) )
} }
+2 -2
View File
@@ -12,9 +12,9 @@ const PAGE_SIZE = 30
type RQPageParam = string | undefined type RQPageParam = string | undefined
// TODO refactor invalidate on mutate? // TODO refactor invalidate on mutate?
export const RQKEY = (resolvedUri: string) => ['post-liked-by', resolvedUri] export const RQKEY = (resolvedUri: string) => ['liked-by', resolvedUri]
export function usePostLikedByQuery(resolvedUri: string | undefined) { export function useLikedByQuery(resolvedUri: string | undefined) {
return useInfiniteQuery< return useInfiniteQuery<
AppBskyFeedGetLikes.OutputSchema, AppBskyFeedGetLikes.OutputSchema,
Error, Error,
+2 -2
View File
@@ -8,7 +8,7 @@ import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {logger} from '#/logger' import {logger} from '#/logger'
import {LoadingScreen} from '../util/LoadingScreen' import {LoadingScreen} from '../util/LoadingScreen'
import {useResolveUriQuery} from '#/state/queries/resolve-uri' import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {usePostLikedByQuery} from '#/state/queries/post-liked-by' import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
export function PostLikedBy({uri}: {uri: string}) { export function PostLikedBy({uri}: {uri: string}) {
@@ -28,7 +28,7 @@ export function PostLikedBy({uri}: {uri: string}) {
isError, isError,
error, error,
refetch, refetch,
} = usePostLikedByQuery(resolvedUri?.uri) } = useLikedByQuery(resolvedUri?.uri)
const likes = useMemo(() => { const likes = useMemo(() => {
if (data?.pages) { if (data?.pages) {
return data.pages.flatMap(page => page.likes) return data.pages.flatMap(page => page.likes)
+6
View File
@@ -31,6 +31,7 @@ export function ProfileCard({
noBorder, noBorder,
followers, followers,
renderButton, renderButton,
onPress,
style, style,
}: { }: {
testID?: string testID?: string
@@ -42,6 +43,7 @@ export function ProfileCard({
renderButton?: ( renderButton?: (
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>, profile: Shadow<AppBskyActorDefs.ProfileViewBasic>,
) => React.ReactNode ) => React.ReactNode
onPress?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
@@ -68,6 +70,7 @@ export function ProfileCard({
]} ]}
href={makeProfileLink(profile)} href={makeProfileLink(profile)}
title={profile.handle} title={profile.handle}
onBeforePress={onPress}
asAnchor asAnchor
anchorNoUnderline> anchorNoUnderline>
<View style={styles.layout}> <View style={styles.layout}>
@@ -233,11 +236,13 @@ export function ProfileCardWithFollowBtn({
noBg, noBg,
noBorder, noBorder,
followers, followers,
onPress,
}: { }: {
profile: AppBskyActorDefs.ProfileViewBasic profile: AppBskyActorDefs.ProfileViewBasic
noBg?: boolean noBg?: boolean
noBorder?: boolean noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined followers?: AppBskyActorDefs.ProfileView[] | undefined
onPress?: () => void
}) { }) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const isMe = profile.did === currentAccount?.did const isMe = profile.did === currentAccount?.did
@@ -253,6 +258,7 @@ export function ProfileCardWithFollowBtn({
? undefined ? undefined
: profileShadow => <FollowButton profile={profileShadow} /> : profileShadow => <FollowButton profile={profileShadow} />
} }
onPress={onPress}
/> />
) )
} }
+4 -1
View File
@@ -49,6 +49,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> {
anchorNoUnderline?: boolean anchorNoUnderline?: boolean
navigationAction?: 'push' | 'replace' | 'navigate' navigationAction?: 'push' | 'replace' | 'navigate'
onPointerEnter?: () => void onPointerEnter?: () => void
onBeforePress?: () => void
} }
export const Link = memo(function Link({ export const Link = memo(function Link({
@@ -62,6 +63,7 @@ export const Link = memo(function Link({
accessible, accessible,
anchorNoUnderline, anchorNoUnderline,
navigationAction, navigationAction,
onBeforePress,
...props ...props
}: Props) { }: Props) {
const {closeModal} = useModalControls() const {closeModal} = useModalControls()
@@ -71,6 +73,7 @@ export const Link = memo(function Link({
const onPress = React.useCallback( const onPress = React.useCallback(
(e?: Event) => { (e?: Event) => {
onBeforePress?.()
if (typeof href === 'string') { if (typeof href === 'string') {
return onPressInner( return onPressInner(
closeModal, closeModal,
@@ -82,7 +85,7 @@ export const Link = memo(function Link({
) )
} }
}, },
[closeModal, navigation, navigationAction, href, openLink], [closeModal, navigation, navigationAction, href, openLink, onBeforePress],
) )
if (noFeedback) { if (noFeedback) {