Implement Likes dialog
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user