From ad071f90c1b7ca8377032cffa9e8a3462859d36e Mon Sep 17 00:00:00 2001 From: Bishwajeet Parhi <62933155+2002Bishwajeet@users.noreply.github.com> Date: Tue, 1 Oct 2024 16:37:19 +0530 Subject: [PATCH] Post detail page (#215) * fix bad copy * add systemFileType prop * missin props in local video * remove logs * whitespace * fix width and no clicks in more than 4 payloads * update version * remove redudancy * getSaveMutationoptions is back * fix clickable on empty space * initial post detail page * don't call use can react * wip * refactors * feat: Post Detail Page * animation * add comment summary * move to useCallback * add check for postContent not undefined --- packages/mobile/src/app/FeedStack.tsx | 24 +- packages/mobile/src/app/OdinQueryClient.tsx | 2 +- .../Dashboard/NotificationsOverview.tsx | 12 +- .../src/components/Feed/Body/PostMedia.tsx | 4 +- .../components/Feed/Detail/PostDetailCard.tsx | 136 +++++++ .../Interacts/Comments/CommentComposer.tsx | 48 ++- .../Feed/Interacts/Comments/CommentsModal.tsx | 37 +- .../Feed/Interacts/Comments/EmptyComment.tsx | 30 ++ .../Feed/Interacts/PostActionModal.tsx | 1 - .../Feed/Interacts/PostInteracts.tsx | 89 ++++- .../MainContent/PostDetailMainContent.tsx | 184 +++++++++ .../mobile/src/components/Feed/Meta/Meta.tsx | 2 +- .../src/components/Feed/PostTeaserCard.tsx | 110 +++--- .../ui/Alert/NotificationToaster.tsx | 4 +- .../src/hooks/feed/post/useManagePost.ts | 294 +++++++++----- .../mobile/src/hooks/feed/post/usePost.ts | 374 +++++------------- .../src/hooks/feed/post/usePostComposer.ts | 4 +- .../useInitialPushNotification.ts | 4 +- .../src/hooks/reactions/useCanReact.tsx | 6 +- .../src/pages/feed/post-detail-page.tsx | 122 ++++++ .../src/provider/feed/RNPostUploadProvider.ts | 120 +++--- 21 files changed, 1065 insertions(+), 542 deletions(-) create mode 100644 packages/mobile/src/components/Feed/Detail/PostDetailCard.tsx create mode 100644 packages/mobile/src/components/Feed/Interacts/Comments/EmptyComment.tsx create mode 100644 packages/mobile/src/components/Feed/MainContent/PostDetailMainContent.tsx create mode 100644 packages/mobile/src/pages/feed/post-detail-page.tsx diff --git a/packages/mobile/src/app/FeedStack.tsx b/packages/mobile/src/app/FeedStack.tsx index ac8edc26..c5532a08 100644 --- a/packages/mobile/src/app/FeedStack.tsx +++ b/packages/mobile/src/app/FeedStack.tsx @@ -1,4 +1,10 @@ -import { EmbeddedThumb, PayloadDescriptor, TargetDrive } from '@homebase-id/js-lib/core'; +import { + EmbeddedThumb, + HomebaseFile, + NewHomebaseFile, + PayloadDescriptor, + TargetDrive, +} from '@homebase-id/js-lib/core'; import { createNativeStackNavigator, NativeStackNavigationOptions, @@ -12,11 +18,17 @@ import { Colors } from './Colors'; import { FeedPage } from '../pages/feed/feed-page'; import { PreviewMedia } from '../pages/media-preview-page'; import { PostComposer } from '../pages/feed/post-composer'; +import { PostDetailPage } from '../pages/feed/post-detail-page'; +import { ChannelDefinition, PostContent } from '@homebase-id/js-lib/public'; export type FeedStackParamList = { Home: undefined; Post: { - postId: string; + postKey: string; + channelKey: string; + odinId: string; + postFile?: HomebaseFile; + channel?: HomebaseFile | NewHomebaseFile; }; Compose: undefined; PreviewMedia: { @@ -73,6 +85,14 @@ export const FeedStack = (_props: NativeStackScreenProps + ); }; diff --git a/packages/mobile/src/app/OdinQueryClient.tsx b/packages/mobile/src/app/OdinQueryClient.tsx index 3a6ad01f..1cfadd0b 100644 --- a/packages/mobile/src/app/OdinQueryClient.tsx +++ b/packages/mobile/src/app/OdinQueryClient.tsx @@ -14,7 +14,7 @@ import { getAddReactionMutationOptions, getRemoveReactionMutationOptions, } from '../hooks/chat/useChatReaction'; -import { getSavePostMutationOptions } from '../hooks/feed/post/usePost'; +import { getSavePostMutationOptions } from '../hooks/feed/post/useManagePost'; const queryClient = new QueryClient({ defaultOptions: { diff --git a/packages/mobile/src/components/Dashboard/NotificationsOverview.tsx b/packages/mobile/src/components/Dashboard/NotificationsOverview.tsx index 09d3c0e7..96b23bdd 100644 --- a/packages/mobile/src/components/Dashboard/NotificationsOverview.tsx +++ b/packages/mobile/src/components/Dashboard/NotificationsOverview.tsx @@ -23,9 +23,8 @@ import { openURL } from '../../utils/utils'; import { Text } from '../ui/Text/Text'; import Toast from 'react-native-toast-message'; import Clipboard from '@react-native-clipboard/clipboard'; - -import { TabStackParamList } from '../../app/App'; import { Avatar } from '../ui/Avatars/Avatar'; +import { FeedStackParamList } from '../../app/FeedStack'; export const NotificationDay = memo( ({ day, notifications }: { day: Date; notifications: PushNotification[] }) => { @@ -117,7 +116,7 @@ const NotificationGroup = ({ const identity = useDotYouClientContext().getIdentity(); const chatNavigator = useNavigation>(); - const feedNavigator = useNavigation>(); + const feedNavigator = useNavigation>(); return ( , - feedNavigator: NavigationProp + feedNavigator: NavigationProp ) => { if (notification.options.appId === OWNER_APP_ID) { // Based on type, we show different messages @@ -347,7 +346,10 @@ export const navigateOnNotification = ( // Navigate to owner console: openURL(`https://${identity}/apps/mail/inbox/${notification.options.typeId}`); } else if (notification.options.appId === FEED_APP_ID) { - feedNavigator.navigate('Feed'); + // if([FEED_NEW_COMMENT_TYPE_ID,FEED_NEW_REACTION_TYPE_ID].includes(notification.options.typeId)){ + // feedNavigator.navigate('Post', { postGlobalTransitId : notification.options. }); + // } + feedNavigator.navigate('Home'); } else if (notification.options.appId === COMMUNITY_APP_ID) { openURL(`https://${identity}/apps/community/${notification.options.typeId}`); } else { diff --git a/packages/mobile/src/components/Feed/Body/PostMedia.tsx b/packages/mobile/src/components/Feed/Body/PostMedia.tsx index a0d93ca2..6195aef9 100644 --- a/packages/mobile/src/components/Feed/Body/PostMedia.tsx +++ b/packages/mobile/src/components/Feed/Body/PostMedia.tsx @@ -1,4 +1,4 @@ -import { HomebaseFile } from '@homebase-id/js-lib/core'; +import { DEFAULT_PAYLOAD_KEY, HomebaseFile } from '@homebase-id/js-lib/core'; import { getChannelDrive, PostContent } from '@homebase-id/js-lib/public'; import { memo } from 'react'; import { calculateScaledDimensions } from '../../../utils/utils'; @@ -14,7 +14,7 @@ type PostMediaProps = { }; export const PostMedia = memo(({ post, doubleTapRef }: PostMediaProps) => { - const payloads = post.fileMetadata.payloads; + const payloads = post?.fileMetadata.payloads?.filter((p) => p.key !== DEFAULT_PAYLOAD_KEY); const fileId = post.fileId; const previewThumbnail = post.fileMetadata.appData.previewThumbnail; const odinId = post.fileMetadata.senderOdinId; diff --git a/packages/mobile/src/components/Feed/Detail/PostDetailCard.tsx b/packages/mobile/src/components/Feed/Detail/PostDetailCard.tsx new file mode 100644 index 00000000..b86406b6 --- /dev/null +++ b/packages/mobile/src/components/Feed/Detail/PostDetailCard.tsx @@ -0,0 +1,136 @@ +import { HomebaseFile, NewHomebaseFile, SecurityGroupType } from '@homebase-id/js-lib/core'; +import { ChannelDefinition, PostContent, ReactionContext } from '@homebase-id/js-lib/public'; +import { memo, useRef } from 'react'; +import { ActivityIndicator, Animated, View } from 'react-native'; +import { GestureType } from 'react-native-gesture-handler'; +import { useDarkMode } from '../../../hooks/useDarkMode'; +import { IconButton } from '../../ui/Buttons'; +import { DoubleTapHeart } from '../../ui/DoubleTapHeart'; +import { Ellipsis } from '../../ui/Icons/icons'; +import { AuthorName } from '../../ui/Name'; +import { Text } from '../../ui/Text/Text'; +import { PostBody } from '../Body/PostBody'; +import { PostMedia } from '../Body/PostMedia'; +import { PostInteracts } from '../Interacts/PostInteracts'; +import { ShareContext } from '../Interacts/Share/ShareModal'; +import { PostMeta, ToGroupBlock } from '../Meta/Meta'; +import { postTeaserCardStyle } from '../PostTeaserCard'; +import { Colors } from '../../../app/Colors'; +import { Avatar } from '../../ui/Avatars/Avatar'; +import { PostActionProps } from '../Interacts/PostActionModal'; +import { useDotYouClientContext } from 'feed-app-common'; + +export const PostDetailCard = memo( + ({ + odinId, + channel, + postFile, + onReactionPress, + onSharePress, + onMorePress, + onEmojiModalOpen, + }: { + odinId?: string; + channel?: HomebaseFile | NewHomebaseFile; + postFile?: HomebaseFile; + onReactionPress: (context: ReactionContext) => void; + onSharePress: (context: ShareContext) => void; + onMorePress: (context: PostActionProps) => void; + onEmojiModalOpen: (context: ReactionContext) => void; + }) => { + const { isDarkMode } = useDarkMode(); + const doubleTapRef = useRef(); + const identity = useDotYouClientContext().getIdentity(); + + if (!postFile) return ; + const post = postFile.fileMetadata.appData.content; + const authorOdinId = post.authorOdinId || odinId; + const isPublic = + channel?.serverMetadata?.accessControlList?.requiredSecurityGroup === + SecurityGroupType.Anonymous || + channel?.serverMetadata?.accessControlList?.requiredSecurityGroup === + SecurityGroupType.Authenticated; + const groupPost = authorOdinId !== (odinId || identity) && (odinId || identity) && authorOdinId; + + const onPostActionPress = () => { + onMorePress?.({ + odinId: odinId || postFile.fileMetadata.senderOdinId, + postFile, + channel, + isGroupPost: !!groupPost, + isAuthor: authorOdinId === identity, + }); + }; + + return ( + + + + + + + + + + + + + } onPress={onPostActionPress} /> + + + + + + + + ); + } +); diff --git a/packages/mobile/src/components/Feed/Interacts/Comments/CommentComposer.tsx b/packages/mobile/src/components/Feed/Interacts/Comments/CommentComposer.tsx index 5ff80e9c..d0166d4e 100644 --- a/packages/mobile/src/components/Feed/Interacts/Comments/CommentComposer.tsx +++ b/packages/mobile/src/components/Feed/Interacts/Comments/CommentComposer.tsx @@ -19,6 +19,7 @@ import { Asset, launchImageLibrary } from 'react-native-image-picker'; import { IconButton } from '../../../ui/Buttons'; import { FileOverview } from '../../../Files/FileOverview'; import TextButton from '../../../ui/Text/Text-Button'; +import { TextInput } from 'react-native-gesture-handler'; export const CommentComposer = memo( ({ @@ -27,12 +28,14 @@ export const CommentComposer = memo( canReact, onReplyCancel, replyOdinId, + isBottomSheet = true, }: { context: ReactionContext; replyThreadId?: string; canReact?: CanReactInfo; onReplyCancel?: () => void; replyOdinId?: string; + isBottomSheet?: boolean; }) => { const { mutateAsync: postComment, @@ -171,20 +174,37 @@ export const CommentComposer = memo( alignItems: 'center', }} > - + {isBottomSheet ? ( + + ) : ( + + )} } onPress={handleImageIconPress} /> diff --git a/packages/mobile/src/components/Feed/Interacts/Comments/CommentsModal.tsx b/packages/mobile/src/components/Feed/Interacts/Comments/CommentsModal.tsx index 08dd42d7..9372d8b7 100644 --- a/packages/mobile/src/components/Feed/Interacts/Comments/CommentsModal.tsx +++ b/packages/mobile/src/components/Feed/Interacts/Comments/CommentsModal.tsx @@ -3,7 +3,6 @@ import { BottomSheetFooter, BottomSheetFooterProps, BottomSheetModal, - BottomSheetView, } from '@gorhom/bottom-sheet'; import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; import { @@ -29,8 +28,10 @@ import { CommentComposer } from './CommentComposer'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { HomebaseFile, ReactionFile } from '@homebase-id/js-lib/core'; import { t } from 'feed-app-common'; +import { EmptyComment } from './EmptyComment'; import { useBottomSheetBackHandler } from '../../../../hooks/useBottomSheetBackHandler'; + export interface CommentModalMethods { setContext: (context: ReactionContext & CanReactInfo) => void; dismiss: () => void; @@ -166,7 +167,7 @@ export const CommentsModal = memo( fetchNextPage(); } }} - ListEmptyComponent={EmptyComponent} + ListEmptyComponent={EmptyComment} onEndReachedThreshold={0.3} renderItem={renderItem} ListFooterComponent={listFooter} @@ -178,41 +179,15 @@ export const CommentsModal = memo( }) ); -const EmptyComponent = () => { - return ( - - - {t('No Comments yet')} - - - Be the first to comment on this post. - - - ); -}; - -const CommentsLoader = () => { +export const CommentsLoader = () => { const { isDarkMode } = useDarkMode(); return ( - + - + ); }; diff --git a/packages/mobile/src/components/Feed/Interacts/Comments/EmptyComment.tsx b/packages/mobile/src/components/Feed/Interacts/Comments/EmptyComment.tsx new file mode 100644 index 00000000..7f6f2c8a --- /dev/null +++ b/packages/mobile/src/components/Feed/Interacts/Comments/EmptyComment.tsx @@ -0,0 +1,30 @@ +import { View } from 'react-native'; +import { Text } from '../../../ui/Text/Text'; +import { t } from 'feed-app-common'; +import { Colors } from '../../../../app/Colors'; + +export const EmptyComment = () => { + return ( + + + {t('No Comments yet')} + + + Be the first to comment on this post. + + + ); +}; diff --git a/packages/mobile/src/components/Feed/Interacts/PostActionModal.tsx b/packages/mobile/src/components/Feed/Interacts/PostActionModal.tsx index 392a00ad..41dbc53c 100644 --- a/packages/mobile/src/components/Feed/Interacts/PostActionModal.tsx +++ b/packages/mobile/src/components/Feed/Interacts/PostActionModal.tsx @@ -10,7 +10,6 @@ import { Colors } from '../../../app/Colors'; import { Platform, View } from 'react-native'; import { OwnerActions } from '../Meta/OwnerAction'; import { ExternalActions, GroupChannelActions } from '../Meta/Actions'; -import EditGroupPage from '../../../pages/chat/edit-group-page'; import { EditPostModal } from '../EditPost/EditPostModal'; import { useSharedValue } from 'react-native-reanimated'; import { useBottomSheetBackHandler } from '../../../hooks/useBottomSheetBackHandler'; diff --git a/packages/mobile/src/components/Feed/Interacts/PostInteracts.tsx b/packages/mobile/src/components/Feed/Interacts/PostInteracts.tsx index 341b1867..c4784fc2 100644 --- a/packages/mobile/src/components/Feed/Interacts/PostInteracts.tsx +++ b/packages/mobile/src/components/Feed/Interacts/PostInteracts.tsx @@ -1,15 +1,21 @@ import { ApiType, DotYouClient, HomebaseFile } from '@homebase-id/js-lib/core'; -import { parseReactionPreview, PostContent, ReactionContext } from '@homebase-id/js-lib/public'; +import { + CommentsReactionSummary, + parseReactionPreview, + PostContent, + ReactionContext, +} from '@homebase-id/js-lib/public'; import { memo, useCallback, useMemo, useState } from 'react'; import { GestureResponderEvent, View } from 'react-native'; import { OpenHeart, Comment, ShareNode } from '../../ui/Icons/icons'; import { CanReactInfo, useCanReact, + useCommentSummary, useMyEmojiReactions, useReaction, } from '../../../hooks/reactions'; -import { useDotYouClientContext } from 'feed-app-common'; +import { t, useDotYouClientContext } from 'feed-app-common'; import { EmojiSummary } from './EmojiSummary'; import { CommentTeaserList } from './CommentsTeaserList'; import { ShareContext } from './Share/ShareModal'; @@ -17,6 +23,9 @@ import { ErrorNotification } from '../../ui/Alert/ErrorNotification'; import { PostReactionBar } from './Reactions/PostReactionBar'; import { IconButton } from '../../ui/Buttons'; +import { Text } from '../../ui/Text/Text'; +import { Colors } from '../../../app/Colors'; +import { useDarkMode } from '../../../hooks/useDarkMode'; export const PostInteracts = memo( ({ @@ -26,6 +35,8 @@ export const PostInteracts = memo( onSharePress, onEmojiModalOpen, isPublic, + showCommentPreview = true, + showSummary, }: { postFile: HomebaseFile; isPublic?: boolean; @@ -33,6 +44,8 @@ export const PostInteracts = memo( onReactionPress?: (context: ReactionContext) => void; onSharePress?: (context: ShareContext) => void; onEmojiModalOpen?: (context: ReactionContext) => void; + showCommentPreview?: boolean; + showSummary?: boolean; }) => { const postContent = postFile.fileMetadata.appData.content; const owner = useDotYouClientContext().getIdentity(); @@ -133,16 +146,38 @@ export const PostInteracts = memo( justifyContent: 'flex-end', }} > - {isPublic && } onPress={onSharePressHandler} />} + {isPublic && ( + } + onPress={onSharePressHandler} + touchableProps={{ + disabled: !onSharePress, + }} + /> + )} {!postDisabledComment && ( - } onPress={onCommentPressHandler} /> + } + onPress={onCommentPressHandler} + touchableProps={{ + disabled: !onCommentPress, + }} + /> )} + {showSummary ? ( + + ) : null} - + {showCommentPreview && ( + + )} ); } @@ -225,3 +260,41 @@ export const LikeButton = memo( ); } ); + +export const CommentSummary = ({ + context, + reactionPreview, +}: { + context: ReactionContext; + reactionPreview?: CommentsReactionSummary; +}) => { + const { data: totalCount } = useCommentSummary({ + authorOdinId: context.authorOdinId, + channelId: context.channelId, + postGlobalTransitId: context.target.globalTransitId, + reactionPreview: reactionPreview, + }).fetch; + const { isDarkMode } = useDarkMode(); + + return totalCount ? ( + <> + + ยท{' '} + + {totalCount} {(totalCount || 0) > 1 ? t('comments') : t('comment')} + + + + ) : null; +}; diff --git a/packages/mobile/src/components/Feed/MainContent/PostDetailMainContent.tsx b/packages/mobile/src/components/Feed/MainContent/PostDetailMainContent.tsx new file mode 100644 index 00000000..283653db --- /dev/null +++ b/packages/mobile/src/components/Feed/MainContent/PostDetailMainContent.tsx @@ -0,0 +1,184 @@ +import { ChannelDefinition, PostContent, ReactionContext } from '@homebase-id/js-lib/public'; +import { PostActionProps } from '../Interacts/PostActionModal'; +import { HomebaseFile, NewHomebaseFile, ReactionFile } from '@homebase-id/js-lib/core'; +import Animated, { + KeyboardState, + useAnimatedKeyboard, + useAnimatedStyle, + withTiming, +} from 'react-native-reanimated'; +import { PostDetailCard } from '../Detail/PostDetailCard'; +import { CommentsLoader } from '../Interacts/Comments/CommentsModal'; +import { ShareContext } from '../Interacts/Share/ShareModal'; +import { useCallback, useMemo, useState } from 'react'; +import { useDotYouClientContext } from 'feed-app-common'; +import { useCanReact, useComments } from '../../../hooks/reactions'; +import { ListRenderItemInfo, Platform } from 'react-native'; +import { Comment } from '../Interacts/Comments/Comment'; +import { EmptyComment } from '../Interacts/Comments/EmptyComment'; +import { CommentComposer } from '../Interacts/Comments/CommentComposer'; + +export type NewType = ReactionContext; + +export const PostDetailMainContent = ({ + channel, + odinId, + postFile, + onEmojiModalOpen, + onMorePress, + onReactionPress, + onSharePress, +}: { + postFile: HomebaseFile; + odinId?: string; + channel?: HomebaseFile | NewHomebaseFile; + onEmojiModalOpen: (context: NewType) => void; + onMorePress: (context: PostActionProps) => void; + onReactionPress: (context: ReactionContext) => void; + onSharePress: (context: ShareContext) => void; +}) => { + const [replyTo, setReplyThread] = useState< + | { + replyThreadId: string | undefined; + authorOdinId: string; + } + | undefined + >(); + const owner = useDotYouClientContext().getIdentity(); + const authorOdinId = postFile.fileMetadata.senderOdinId || owner; + const postContent = postFile.fileMetadata.appData.content; + + const { data: canReact } = useCanReact({ + authorOdinId, + channelId: postContent?.channelId, + postContent: postContent, + isEnabled: postContent?.channelId ? true : false, + isAuthenticated: true, + isOwner: false, + }); + + const reactionContext: ReactionContext | undefined = useMemo(() => { + return { + authorOdinId: authorOdinId, + channelId: postContent.channelId, + target: { + globalTransitId: postFile.fileMetadata.globalTransitId || '', + fileId: postFile.fileId, + isEncrypted: postFile.fileMetadata.isEncrypted || false, + }, + }; + }, [authorOdinId, postContent, postFile]); + + const { + data: comments, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + isLoading, + } = useComments({ context: reactionContext }).fetch; + const flattenedComments = comments?.pages.flatMap((page) => page.comments).reverse(); + + const renderItem = useCallback( + ({ item }: ListRenderItemInfo>) => { + return ( + { + setReplyThread({ + replyThreadId: commentFile.fileMetadata.globalTransitId, + authorOdinId: commentFile.fileMetadata.appData.content.authorOdinId, + }); + }} + /> + ); + }, + [canReact, reactionContext] + ); + + const listFooter = useMemo(() => { + if (isFetchingNextPage) return ; + return <>; + }, [isFetchingNextPage]); + + const { height, state } = useAnimatedKeyboard(); + + const paddingBottom = Platform.select({ + ios: 28, + android: 12, + default: 0, + }); + + const animatedStyles = useAnimatedStyle(() => { + function calculateHeight() { + if (height.value > 0) { + return -height.value + paddingBottom; + } else { + return -height.value; + } + } + if ([KeyboardState.OPEN, KeyboardState.OPENING].includes(state.value)) { + return { + transform: [{ translateY: calculateHeight() }], + }; + } + return { + transform: [ + { + translateY: withTiming(0, { + duration: 250, + }), + }, + ], + }; + }); + + const onEndReached = useCallback(() => { + if (hasNextPage) { + fetchNextPage(); + } + }, [fetchNextPage, hasNextPage]); + + return ( + <> + + } + keyExtractor={(item) => item.fileId} + renderItem={renderItem} + ListFooterComponent={listFooter} + ListEmptyComponent={EmptyComment} + onEndReached={onEndReached} + onEndReachedThreshold={0.5} + /> + {isLoading && } + + setReplyThread(undefined)} + /> + + + ); +}; diff --git a/packages/mobile/src/components/Feed/Meta/Meta.tsx b/packages/mobile/src/components/Feed/Meta/Meta.tsx index a53d7a3b..1c17ebb2 100644 --- a/packages/mobile/src/components/Feed/Meta/Meta.tsx +++ b/packages/mobile/src/components/Feed/Meta/Meta.tsx @@ -87,7 +87,7 @@ export const PostMeta = memo( {channel ? ( channelLink && openURL(channelLink)} - style={[styles.pressableContainer, { flex: 1 }]} + style={[styles.pressableContainer, { flexShrink: 1 }]} > {postFile?.fileMetadata.isEncrypted && } diff --git a/packages/mobile/src/components/Feed/PostTeaserCard.tsx b/packages/mobile/src/components/Feed/PostTeaserCard.tsx index e1276508..5cf5a1c6 100644 --- a/packages/mobile/src/components/Feed/PostTeaserCard.tsx +++ b/packages/mobile/src/components/Feed/PostTeaserCard.tsx @@ -1,4 +1,4 @@ -import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; +import { Pressable, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; import { PostMedia } from './Body/PostMedia'; import { Text } from '../ui/Text/Text'; import { AuthorName } from '../ui/Name'; @@ -23,6 +23,8 @@ import { PostBody } from './Body/PostBody'; import { IconButton } from '../ui/Buttons'; import { DoubleTapHeart } from '../ui/DoubleTapHeart'; import { GestureType } from 'react-native-gesture-handler'; +import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { FeedStackParamList } from '../../app/FeedStack'; export const PostTeaserCard = memo( ({ @@ -123,7 +125,7 @@ export const InnerPostTeaserCard = memo( const post = postFile.fileMetadata.appData.content; const odinId = postFile.fileMetadata.senderOdinId; const authorOdinId = post.authorOdinId || odinId; - + const navigation = useNavigation>(); const viewStyle = useMemo(() => { return { padding: 10, @@ -142,60 +144,76 @@ export const InnerPostTeaserCard = memo( return ( - - - - - - - - { + navigation.navigate('Post', { + postKey: post.slug || post.id, + channelKey: post.channelId, + odinId: postFile.fileMetadata.senderOdinId, + postFile, + channel: channel || undefined, + }); + }} + > + + + + + + + + + + - + } onPress={onPostActionPress} /> - } onPress={onPostActionPress} /> - - - - - - + + + + + + ); } ); -const styles = StyleSheet.create({ +export const postTeaserCardStyle = StyleSheet.create({ header: { display: 'flex', flexDirection: 'row', diff --git a/packages/mobile/src/components/ui/Alert/NotificationToaster.tsx b/packages/mobile/src/components/ui/Alert/NotificationToaster.tsx index 51cea5f8..c3f4e910 100644 --- a/packages/mobile/src/components/ui/Alert/NotificationToaster.tsx +++ b/packages/mobile/src/components/ui/Alert/NotificationToaster.tsx @@ -1,7 +1,6 @@ import { useDotYouClientContext } from 'feed-app-common'; import { useRouteContext } from '../../RouteContext/RouteContext'; import { NavigationProp, useNavigation } from '@react-navigation/native'; -import { TabStackParamList } from '../../../app/App'; import { ChatStackParamList } from '../../../app/ChatStack'; import { useNotification } from '../../../hooks/notifications/useNotification'; import { stringGuidsEqual } from '@homebase-id/js-lib/helpers'; @@ -17,13 +16,14 @@ import Toast from 'react-native-toast-message'; import { useCallback, useEffect } from 'react'; import { getContactByOdinId } from '@homebase-id/js-lib/network'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { FeedStackParamList } from '../../../app/FeedStack'; export const NotificationToaster = () => { const { route } = useRouteContext(); const dotYouClient = useDotYouClientContext(); const identity = useDotYouClientContext().getIdentity(); const chatNavigator = useNavigation>(); - const feedNavigator = useNavigation>(); + const feedNavigator = useNavigation>(); const isConversationScreen = route?.name === 'Conversation' && !route.params; const isChatScreen = route?.name === 'ChatScreen' && route.params; const isFeedScreen = route?.name === 'Home'; diff --git a/packages/mobile/src/hooks/feed/post/useManagePost.ts b/packages/mobile/src/hooks/feed/post/useManagePost.ts index 21245835..f19b945f 100644 --- a/packages/mobile/src/hooks/feed/post/useManagePost.ts +++ b/packages/mobile/src/hooks/feed/post/useManagePost.ts @@ -1,13 +1,13 @@ -import { InfiniteData, useMutation, useQueryClient } from '@tanstack/react-query'; +import { InfiniteData, MutationOptions, QueryClient, useMutation, useQueryClient } from '@tanstack/react-query'; import { PostContent, - savePost as savePostFile, getPost, removePost, } from '@homebase-id/js-lib/public'; -import { NewMediaFile, MediaFile } from '@homebase-id/js-lib/core'; +import { savePost as savePostFile } from '../../../provider/feed/RNPostUploadProvider'; import { HomebaseFile, + MediaFile, MultiRequestCursoredResult, NewHomebaseFile, UploadResult, @@ -15,84 +15,190 @@ import { import { TransitUploadResult } from '@homebase-id/js-lib/peer'; import { LinkPreview } from '@homebase-id/js-lib/media'; -import { useDotYouClientContext } from 'feed-app-common'; - -export const useManagePost = () => { - const dotYouClient = useDotYouClientContext(); - const queryClient = useQueryClient(); +import { getRichTextFromString, t, useDotYouClientContext } from 'feed-app-common'; +import { ImageSource } from '../../../provider/image/RNImageProvider'; +import { getSynchronousDotYouClient } from '../../chat/getSynchronousDotYouClient'; +import { addError } from '../../errors/useErrors'; + +const savePost = async ({ + postFile, + odinId, + channelId, + mediaFiles, + linkPreviews, + onUpdate, +}: { + postFile: NewHomebaseFile | HomebaseFile; + odinId?: string; + channelId: string; + mediaFiles?: (ImageSource | MediaFile)[]; + linkPreviews?: LinkPreview[]; + onUpdate?: (phase: string, progress: number) => void; +}) => { + const dotYouClient = await getSynchronousDotYouClient(); + const onVersionConflict = odinId ? undefined : async (): Promise => { + const serverPost = await getPost( + dotYouClient, + channelId, + postFile.fileMetadata.appData.content.id + ); + if (!serverPost) return; + + const newPost: HomebaseFile = { + ...serverPost, + fileMetadata: { + ...serverPost.fileMetadata, + appData: { + ...serverPost.fileMetadata.appData, + content: { + ...serverPost.fileMetadata.appData.content, + ...postFile.fileMetadata.appData.content, + }, + }, + }, + }; + return savePostFile(dotYouClient, newPost, odinId, channelId, mediaFiles, linkPreviews, onVersionConflict); + }; + postFile.fileMetadata.appData.content.captionAsRichText = getRichTextFromString( + postFile.fileMetadata.appData.content.caption.trim() + ); + return savePostFile(dotYouClient, postFile, odinId, channelId, mediaFiles, linkPreviews, onVersionConflict, onUpdate); +}; - const savePost = async ({ - postFile, - odinId, - channelId, - mediaFiles, - linkPreviews, - onUpdate, - }: { +export const getSavePostMutationOptions: (queryClient: QueryClient) => MutationOptions< + UploadResult | TransitUploadResult, + unknown, + { postFile: NewHomebaseFile | HomebaseFile; - odinId?: string; channelId: string; - mediaFiles?: (NewMediaFile | MediaFile)[]; + mediaFiles?: (ImageSource | MediaFile)[]; linkPreviews?: LinkPreview[]; - onUpdate?: (progress: number) => void; - }) => { - return new Promise((resolve, reject) => { - const onVersionConflict = odinId - ? undefined - : async () => { - const serverPost = await getPost( - dotYouClient, - channelId, - postFile.fileMetadata.appData.content.id - ); - if (!serverPost) return; - - const newPost: HomebaseFile = { - ...serverPost, - fileMetadata: { - ...serverPost.fileMetadata, - appData: { - ...serverPost.fileMetadata.appData, - content: { - ...serverPost.fileMetadata.appData.content, - ...postFile.fileMetadata.appData.content, - }, - }, + onUpdate?: (phase: string, progress: number) => void; + }, + { + newPost: { + postFile: NewHomebaseFile | HomebaseFile; + channelId: string; + mediaFiles?: (ImageSource | MediaFile)[] | undefined; + linkPreviews?: LinkPreview[] | undefined; + onUpdate?: ((phase: string, progress: number) => void) | undefined; + }; + previousFeed: + | InfiniteData[]>, unknown> + | undefined; + } +> = (queryClient) => ({ + mutationKey: ['save-post'], + mutationFn: savePost, + onSuccess: (_data, variables) => { + if (variables.postFile.fileMetadata.appData.content.slug) { + queryClient.invalidateQueries({ + queryKey: ['blog', variables.postFile.fileMetadata.appData.content.slug], + }); + } else { + queryClient.invalidateQueries({ queryKey: ['blog'] }); + } + + // Too many invalidates, but during article creation, the slug is not known + queryClient.invalidateQueries({ queryKey: ['blog', variables.postFile.fileId] }); + queryClient.invalidateQueries({ + queryKey: ['blog', variables.postFile.fileMetadata.appData.content.id], + }); + queryClient.invalidateQueries({ + queryKey: ['blog', variables.postFile.fileMetadata.appData.content.id?.replaceAll('-', '')], + }); + + queryClient.invalidateQueries({ + queryKey: ['blogs', variables.postFile.fileMetadata.appData.content.channelId || '', ''], + }); + queryClient.invalidateQueries({ + queryKey: ['blogs', '', ''], + }); + + // Update versionTag of post in social feeds cache + const previousFeed: + | InfiniteData[]>> + | undefined = queryClient.getQueryData(['social-feeds']); + + if (previousFeed) { + const newFeed = { ...previousFeed }; + newFeed.pages[0].results = newFeed.pages[0].results.map((post) => + post.fileMetadata.appData.content.id === variables.postFile.fileMetadata.appData.content.id + ? { + ...post, + fileMetadata: { + ...post.fileMetadata, + versionTag: (_data as UploadResult).newVersionTag || post.fileMetadata.versionTag, + }, + } + : post + ); + + queryClient.setQueryData(['social-feeds'], newFeed); + } + }, + onMutate: async (newPost) => { + await queryClient.cancelQueries({ queryKey: ['social-feeds'] }); + + // Update section attributes + const previousFeed: + | InfiniteData[]>> + | undefined = queryClient.getQueryData(['social-feeds']); + + if (previousFeed) { + const newPostFile: HomebaseFile = { + ...newPost.postFile, + fileMetadata: { + ...newPost.postFile.fileMetadata, + appData: { + ...newPost.postFile.fileMetadata.appData, + content: { + ...newPost.postFile.fileMetadata.appData.content, + + primaryMediaFile: { + fileKey: (newPost.mediaFiles?.[0] as MediaFile)?.key, + type: (newPost.mediaFiles?.[0] as MediaFile)?.contentType, }, - }; - savePostFile( - dotYouClient, - newPost, - odinId, - channelId, - mediaFiles, - linkPreviews, - onVersionConflict - ).then((result) => { - if (result) resolve(result); - }); + }, + }, + }, + } as HomebaseFile; + + const newFeed: InfiniteData[]>> = { + ...previousFeed, + pages: previousFeed.pages.map((page, index) => { + return { + ...page, + results: [...(index === 0 ? [newPostFile] : []), ...page.results], }; - //TODO: RichText - // postFile.fileMetadata.appData.content.captionAsRichText = getRichTextFromString( - // postFile.fileMetadata.appData.content.caption.trim() - // ); - - savePostFile( - dotYouClient, - postFile, - odinId, - channelId, - mediaFiles, - linkPreviews, - onVersionConflict, - onUpdate - ) - .then((result) => { - if (result) resolve(result); - }) - .catch((err) => reject(err)); - }); - }; + }), + }; + + queryClient.setQueryData(['social-feeds'], newFeed); + } + + return { newPost, previousFeed }; + }, + onError: (err, _newCircle, context) => { + addError(queryClient, err, t('Failed to save post')); + + // Revert local caches to what they were, + + queryClient.setQueryData(['social-feeds'], context?.previousFeed); + }, + onSettled: () => { + // Invalidate with a small delay to allow the server to update + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ['social-feeds'] }); + }, 1000); + }, +}); + +export const useManagePost = () => { + const dotYouClient = useDotYouClientContext(); + const queryClient = useQueryClient(); + + // const duplicatePost = async ({ // toDuplicatePostFile, @@ -214,15 +320,15 @@ export const useManagePost = () => { const newFeed = { ...previousFeed }; newFeed.pages[0].results = newFeed.pages[0].results.map((post) => post.fileMetadata.appData.content.id === - variables.postFile.fileMetadata.appData.content.id + variables.postFile.fileMetadata.appData.content.id ? { - ...post, - fileMetadata: { - ...post.fileMetadata, - versionTag: - (_data as UploadResult).newVersionTag || post.fileMetadata.versionTag, - }, - } + ...post, + fileMetadata: { + ...post.fileMetadata, + versionTag: + (_data as UploadResult).newVersionTag || post.fileMetadata.versionTag, + }, + } : post ); @@ -249,9 +355,9 @@ export const useManagePost = () => { ...newPost.postFile.fileMetadata.appData.content, primaryMediaFile: newPost.mediaFiles?.[0] ? { - fileKey: newPost.mediaFiles?.[0].key, - type: (newPost.mediaFiles?.[0] as MediaFile)?.contentType, - } + fileKey: newPost.mediaFiles?.[0].key, + type: (newPost.mediaFiles?.[0] as MediaFile)?.contentType, + } : undefined, }, }, @@ -329,15 +435,15 @@ export const useManagePost = () => { const newFeed = { ...previousFeed }; newFeed.pages[0].results = newFeed.pages[0].results.map((post) => post.fileMetadata.appData.content.id === - variables.postFile.fileMetadata.appData.content.id + variables.postFile.fileMetadata.appData.content.id ? { - ...post, - fileMetadata: { - ...post.fileMetadata, - versionTag: - (_data as UploadResult).newVersionTag || post.fileMetadata.versionTag, - }, - } + ...post, + fileMetadata: { + ...post.fileMetadata, + versionTag: + (_data as UploadResult).newVersionTag || post.fileMetadata.versionTag, + }, + } : post ); diff --git a/packages/mobile/src/hooks/feed/post/usePost.ts b/packages/mobile/src/hooks/feed/post/usePost.ts index 761e67d5..428d16a9 100644 --- a/packages/mobile/src/hooks/feed/post/usePost.ts +++ b/packages/mobile/src/hooks/feed/post/usePost.ts @@ -1,298 +1,106 @@ -import { - InfiniteData, - MutationOptions, - QueryClient, - useMutation, - useQueryClient, -} from '@tanstack/react-query'; -import { PostContent, getPost, removePost } from '@homebase-id/js-lib/public'; -import { MediaFile } from '@homebase-id/js-lib/core'; -import { savePost as savePostFile } from '../../../provider/feed/RNPostUploadProvider'; +import { InfiniteData, useQuery, useQueryClient } from '@tanstack/react-query'; +import { getPost, getPostBySlug, PostContent } from '@homebase-id/js-lib/public'; +import { usePostsInfiniteReturn } from './usePostsInfinite'; +import { HomebaseFile } from '@homebase-id/js-lib/core'; +import { useChannel } from '../channels/useChannel'; +import { stringGuidsEqual } from '@homebase-id/js-lib/helpers'; import { - HomebaseFile, - MultiRequestCursoredResult, - NewHomebaseFile, - UploadResult, -} from '@homebase-id/js-lib/core'; -import { getRichTextFromString, t, useDotYouClientContext } from 'feed-app-common'; -import { ImageSource } from '../../../provider/image/RNImageProvider'; -import { getSynchronousDotYouClient } from '../../chat/getSynchronousDotYouClient'; -import { addError } from '../../errors/useErrors'; -import { LinkPreview } from '@homebase-id/js-lib/media'; - -const savePost = async ({ - postFile, - channelId, - mediaFiles, - linkPreviews, - onUpdate, -}: { - postFile: NewHomebaseFile | HomebaseFile; - channelId: string; - mediaFiles?: (ImageSource | MediaFile)[]; - linkPreviews?: LinkPreview[]; - onUpdate?: (phase: string, progress: number) => void; -}) => { - const dotYouClient = await getSynchronousDotYouClient(); - - const onVersionConflict = async (): Promise => { - const serverPost = await getPost( - dotYouClient, - channelId, - postFile.fileMetadata.appData.content.id - ); - if (!serverPost) return; - - const newPost: HomebaseFile = { - ...serverPost, - fileMetadata: { - ...serverPost.fileMetadata, - appData: { - ...serverPost.fileMetadata.appData, - content: { - ...serverPost.fileMetadata.appData.content, - ...postFile.fileMetadata.appData.content, - }, - }, - }, - }; - return savePostFile(dotYouClient, newPost, channelId, mediaFiles, linkPreviews, onVersionConflict); - }; - - postFile.fileMetadata.appData.content.captionAsRichText = getRichTextFromString( - postFile.fileMetadata.appData.content.caption.trim() - ); - return savePostFile(dotYouClient, postFile, channelId, mediaFiles, linkPreviews, onVersionConflict, onUpdate); + getPostBySlugOverPeer, + getPostOverPeer, + RecentsFromConnectionsReturn, +} from '@homebase-id/js-lib/peer'; +import { useDotYouClientContext } from 'feed-app-common'; + +type usePostProps = { + odinId?: string; + channelKey?: string; + postKey?: string; }; -export const getSavePostMutationOptions: (queryClient: QueryClient) => MutationOptions< - UploadResult, - unknown, - { - postFile: NewHomebaseFile | HomebaseFile; - channelId: string; - mediaFiles?: (ImageSource | MediaFile)[]; - linkPreviews?: LinkPreview[]; - onUpdate?: (phase: string, progress: number) => void; - }, - { - newPost: { - postFile: NewHomebaseFile | HomebaseFile; - channelId: string; - mediaFiles?: (ImageSource | MediaFile)[] | undefined; - linkPreviews?: LinkPreview[] | undefined; - onUpdate?: ((phase: string, progress: number) => void) | undefined; - }; - previousFeed: - | InfiniteData[]>, unknown> - | undefined; - } -> = (queryClient) => ({ - mutationKey: ['save-post'], - mutationFn: savePost, - onSuccess: (_data, variables) => { - if (variables.postFile.fileMetadata.appData.content.slug) { - queryClient.invalidateQueries({ - queryKey: ['blog', variables.postFile.fileMetadata.appData.content.slug], - }); - } else { - queryClient.invalidateQueries({ queryKey: ['blog'] }); - } - - // Too many invalidates, but during article creation, the slug is not known - queryClient.invalidateQueries({ queryKey: ['blog', variables.postFile.fileId] }); - queryClient.invalidateQueries({ - queryKey: ['blog', variables.postFile.fileMetadata.appData.content.id], - }); - queryClient.invalidateQueries({ - queryKey: ['blog', variables.postFile.fileMetadata.appData.content.id?.replaceAll('-', '')], - }); - - queryClient.invalidateQueries({ - queryKey: ['blogs', variables.postFile.fileMetadata.appData.content.channelId || '', ''], - }); - queryClient.invalidateQueries({ - queryKey: ['blogs', '', ''], - }); - - // Update versionTag of post in social feeds cache - const previousFeed: - | InfiniteData[]>> - | undefined = queryClient.getQueryData(['social-feeds']); - - if (previousFeed) { - const newFeed = { ...previousFeed }; - newFeed.pages[0].results = newFeed.pages[0].results.map((post) => - post.fileMetadata.appData.content.id === variables.postFile.fileMetadata.appData.content.id - ? { - ...post, - fileMetadata: { - ...post.fileMetadata, - versionTag: _data.newVersionTag, - }, - } - : post - ); - - queryClient.setQueryData(['social-feeds'], newFeed); - } - }, - onMutate: async (newPost) => { - await queryClient.cancelQueries({ queryKey: ['social-feeds'] }); - - // Update section attributes - const previousFeed: - | InfiniteData[]>> - | undefined = queryClient.getQueryData(['social-feeds']); - - if (previousFeed) { - const newPostFile: HomebaseFile = { - ...newPost.postFile, - fileMetadata: { - ...newPost.postFile.fileMetadata, - appData: { - ...newPost.postFile.fileMetadata.appData, - content: { - ...newPost.postFile.fileMetadata.appData.content, - - primaryMediaFile: { - fileKey: (newPost.mediaFiles?.[0] as MediaFile)?.key, - type: (newPost.mediaFiles?.[0] as MediaFile)?.contentType, - }, - }, - }, - }, - } as HomebaseFile; - - const newFeed: InfiniteData[]>> = { - ...previousFeed, - pages: previousFeed.pages.map((page, index) => { - return { - ...page, - results: [...(index === 0 ? [newPostFile] : []), ...page.results], - }; - }), - }; +export const usePost = ({ odinId, channelKey, postKey }: usePostProps = {}) => { + const { data: channel, isFetched: channelFetched } = useChannel({ + odinId, + channelKey, + }).fetch; - queryClient.setQueryData(['social-feeds'], newFeed); - } - - return { newPost, previousFeed }; - }, - onError: (err, _newCircle, context) => { - addError(queryClient, err, t('Failed to save post')); - - // Revert local caches to what they were, - queryClient.setQueryData(['social-feeds'], context?.previousFeed); - }, - onSettled: () => { - // Invalidate with a small delay to allow the server to update - setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ['social-feeds'] }); - }, 1000); - }, -}); - -export const usePost = () => { const dotYouClient = useDotYouClientContext(); const queryClient = useQueryClient(); - // slug property is need to clear the cache later, but not for the actual removeData - const removeData = async ({ - postFile, - channelId, - }: { - postFile: HomebaseFile; - channelId: string; - }) => { - if (postFile) return await removePost(dotYouClient, postFile, channelId); - }; - - return { - save: useMutation(getSavePostMutationOptions(queryClient)), - - update: useMutation({ - mutationFn: savePost, - onSuccess: (_data, variables) => { - if (variables.postFile.fileMetadata.appData.content.slug) { - queryClient.invalidateQueries({ - queryKey: ['blog', variables.postFile.fileMetadata.appData.content.slug], - }); - } else { - queryClient.invalidateQueries({ queryKey: ['blog'] }); - } - - // Too many invalidates, but during article creation, the slug is not known - queryClient.invalidateQueries({ queryKey: ['blog', variables.postFile.fileId] }); - queryClient.invalidateQueries({ - queryKey: ['blog', variables.postFile.fileMetadata.appData.content.id], - }); - queryClient.invalidateQueries({ - queryKey: [ - 'blog', - variables.postFile.fileMetadata.appData.content.id?.replaceAll('-', ''), - ], - }); - - queryClient.invalidateQueries({ - queryKey: ['blogs', variables.postFile.fileMetadata.appData.content.channelId || '', ''], - }); - queryClient.invalidateQueries({ - queryKey: ['blogs', '', ''], - }); + const getLocalCachedBlogs = (channelId?: string) => { + const infinite = + queryClient.getQueryData>(['blogs', channelId]) || + queryClient.getQueryData>(['blogs', undefined]); + if (infinite) return infinite.pages.flatMap((page) => page.results); - // Update versionTag of post in social feeds cache - const previousFeed: - | InfiniteData[]>> - | undefined = queryClient.getQueryData(['social-feeds']); + return ( + queryClient.getQueryData[]>(['blog-recents', channelId]) || + queryClient.getQueryData[]>(['blog-recents', undefined]) + ); + }; - if (previousFeed) { - const newFeed = { ...previousFeed }; - newFeed.pages[0].results = newFeed.pages[0].results.map((post) => - post.fileMetadata.appData.content.id === - variables.postFile.fileMetadata.appData.content.id - ? { - ...post, - fileMetadata: { - ...post.fileMetadata, - versionTag: _data.newVersionTag, - }, - } - : post + const fetchBlog = async ({ postKey }: usePostProps) => { + if (!channel || !postKey) return null; + + if (!odinId) { + // Search in cache + const localBlogs = getLocalCachedBlogs(channel.fileMetadata.appData.uniqueId); + if (localBlogs) { + const foundBlog = localBlogs.find( + (blog) => + blog.fileMetadata.appData.content?.slug === postKey || + stringGuidsEqual(blog.fileMetadata.appData.content.id, postKey) + ); + if (foundBlog) return foundBlog; + } + + const postFile = + (await getPostBySlug( + dotYouClient, + channel.fileMetadata.appData.uniqueId as string, + postKey + )) || + (await getPost(dotYouClient, channel.fileMetadata.appData.uniqueId as string, postKey)); + + return postFile; + } else { + // Search in social feed cache + const socialFeedCache = queryClient.getQueryData>([ + 'social-feeds', + ]); + if (socialFeedCache) { + for (let i = 0; socialFeedCache && i < socialFeedCache.pages.length; i++) { + const page = socialFeedCache.pages[i]; + const post = page.results.find( + (x) => + x.fileMetadata.appData.content?.slug === postKey || + stringGuidsEqual(x.fileMetadata.appData.content.id, postKey) ); - - queryClient.setQueryData(['social-feeds'], newFeed); - } - }, - onError: (err) => { - console.error(err); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['social-feeds'] }); - }, - }), - remove: useMutation({ - mutationFn: removeData, - onSuccess: (_data, variables) => { - queryClient.invalidateQueries({ queryKey: ['social-feeds'] }); - - if (variables && variables.postFile.fileMetadata.appData.content.slug) { - queryClient.invalidateQueries({ - queryKey: ['blog', variables.postFile.fileMetadata.appData.content.slug], - }); - } else { - queryClient.invalidateQueries({ queryKey: ['blog'] }); + if (post) return post; } - - queryClient.invalidateQueries({ - queryKey: ['blogs', variables.postFile.fileMetadata.appData.content.channelId || '', ''], - }); - queryClient.invalidateQueries({ - queryKey: ['blogs', '', ''], - }); - }, - }), + } + return ( + (await getPostBySlugOverPeer( + dotYouClient, + odinId, + channel.fileMetadata.appData.uniqueId as string, + postKey + )) || + (await getPostOverPeer( + dotYouClient, + odinId, + channel.fileMetadata.appData.uniqueId as string, + postKey + )) + ); + } }; + + return useQuery({ + queryKey: ['post', odinId || dotYouClient.getIdentity(), channelKey, postKey], + queryFn: () => fetchBlog({ postKey }), + refetchOnMount: false, + enabled: channelFetched && !!postKey, + }); }; diff --git a/packages/mobile/src/hooks/feed/post/usePostComposer.ts b/packages/mobile/src/hooks/feed/post/usePostComposer.ts index 717d8381..c51f4f63 100644 --- a/packages/mobile/src/hooks/feed/post/usePostComposer.ts +++ b/packages/mobile/src/hooks/feed/post/usePostComposer.ts @@ -14,10 +14,10 @@ import { } from '@homebase-id/js-lib/public'; import { getNewId, stringGuidsEqual } from '@homebase-id/js-lib/helpers'; import { useState } from 'react'; -import { usePost } from './usePost'; import { useDotYouClientContext } from 'feed-app-common'; import { ImageSource } from '../../../provider/image/RNImageProvider'; import { LinkPreview } from '@homebase-id/js-lib/media'; +import { useManagePost } from './useManagePost'; type CollaborativeChannelDefinition = ChannelDefinition & { acl: AccessControlList }; @@ -27,7 +27,7 @@ export const usePostComposer = () => { >(undefined); const dotYouClient = useDotYouClientContext(); const loggedInIdentity = dotYouClient.getIdentity(); - const { mutateAsync: savePostFile } = usePost().save; + const { mutateAsync: savePostFile } = useManagePost().save; const [postError, setPostError] = useState(); const savePost = async ( diff --git a/packages/mobile/src/hooks/push-notification/useInitialPushNotification.ts b/packages/mobile/src/hooks/push-notification/useInitialPushNotification.ts index 4bd90bab..8f5b0db0 100644 --- a/packages/mobile/src/hooks/push-notification/useInitialPushNotification.ts +++ b/packages/mobile/src/hooks/push-notification/useInitialPushNotification.ts @@ -2,18 +2,18 @@ import messaging from '@react-native-firebase/messaging'; import { NavigationProp, useNavigation } from '@react-navigation/native'; import { tryJsonParse } from '@homebase-id/js-lib/helpers'; import { navigateOnNotification } from '../../components/Dashboard/NotificationsOverview'; -import { TabStackParamList } from '../../app/App'; import { PushNotification } from '@homebase-id/js-lib/core'; import { useDotYouClientContext } from 'feed-app-common'; import { useCallback, useEffect } from 'react'; import { ChatStackParamList } from '../../app/ChatStack'; import notifee, { Event, EventType } from '@notifee/react-native'; import { AppState, Platform } from 'react-native'; +import { FeedStackParamList } from '../../app/FeedStack'; export const useInitialPushNotification = () => { const identity = useDotYouClientContext().getIdentity(); const chatNavigator = useNavigation>(); - const feedNavigator = useNavigation>(); + const feedNavigator = useNavigation>(); const handleInitialNotification = useCallback( async (stringifiedData: string) => { diff --git a/packages/mobile/src/hooks/reactions/useCanReact.tsx b/packages/mobile/src/hooks/reactions/useCanReact.tsx index 31e6c24c..4849b036 100644 --- a/packages/mobile/src/hooks/reactions/useCanReact.tsx +++ b/packages/mobile/src/hooks/reactions/useCanReact.tsx @@ -9,7 +9,7 @@ import { useSecurityContext } from '../securityContext/useSecurityContext'; interface UseCanReactProps { authorOdinId: string; channelId: string; - postContent: PostContent; + postContent?: PostContent | undefined; isEnabled: boolean; isOwner: boolean; isAuthenticated: boolean; @@ -84,11 +84,11 @@ export const useCanReact = ({ }; return useQuery({ - queryKey: ['can-react', authorOdinId, channelId, postContent.id], + queryKey: ['can-react', authorOdinId, channelId, postContent?.id], queryFn: isCanReact, refetchOnMount: false, refetchOnWindowFocus: false, staleTime: Infinity, - enabled: isEnabled && securityFetched, + enabled: isEnabled && securityFetched && postContent !== undefined, }); }; diff --git a/packages/mobile/src/pages/feed/post-detail-page.tsx b/packages/mobile/src/pages/feed/post-detail-page.tsx new file mode 100644 index 00000000..13a9575c --- /dev/null +++ b/packages/mobile/src/pages/feed/post-detail-page.tsx @@ -0,0 +1,122 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { FeedStackParamList } from '../../app/FeedStack'; +import { Text } from '../../components/ui/Text/Text'; +import { SafeAreaView } from '../../components/ui/SafeAreaView/SafeAreaView'; +import { useChannel } from '../../hooks/feed/channels/useChannel'; +import { usePost } from '../../hooks/feed/post/usePost'; +import { PostContent, ReactionContext } from '@homebase-id/js-lib/public'; +import { ActivityIndicator } from 'react-native'; +import { Host } from 'react-native-portalize'; +import { useCallback, useRef } from 'react'; +import { + PostReactionModal, + ReactionModalMethods, +} from '../../components/Feed/Interacts/Reactions/PostReactionModal'; +import { + ShareContext, + ShareModal, + ShareModalMethods, +} from '../../components/Feed/Interacts/Share/ShareModal'; +import { + PostActionMethods, + PostActionProps, + PostModalAction, +} from '../../components/Feed/Interacts/PostActionModal'; +import { + PostEmojiPickerModal, + PostEmojiPickerModalMethods, +} from '../../components/Feed/Interacts/Reactions/PostEmojiPickerModal'; +import { PostDetailMainContent } from '../../components/Feed/MainContent/PostDetailMainContent'; +import { HomebaseFile } from '@homebase-id/js-lib/core'; + +type PostDetailPageProps = NativeStackScreenProps; + +export const PostDetailPage = ({ route: { params } }: PostDetailPageProps) => { + const { postKey, channelKey, odinId, postFile, channel } = params; + + const reactionRef = useRef(null); + const shareRef = useRef(null); + const postActionRef = useRef(null); + const postEmojiPickerRef = useRef(null); + + const onSharePress = useCallback((context: ShareContext) => { + shareRef.current?.setShareContext(context); + }, []); + + const onReactionPress = useCallback((context: ReactionContext) => { + reactionRef.current?.setContext(context); + }, []); + + const onMorePress = useCallback((context: PostActionProps) => { + postActionRef.current?.setContext(context); + }, []); + + const onEmojiModalOpen = useCallback((context: ReactionContext) => { + postEmojiPickerRef.current?.setContext(context); + }, []); + + // We don't call them if we have postFile and channel with us + const { data: channelData } = useChannel({ channelKey: !channel ? channelKey : undefined }).fetch; + const { data: postData, isLoading: postDataLoading } = usePost({ + channelKey: !channel ? channelKey : undefined, + postKey: !postFile ? postKey : undefined, + odinId, + }); + + if (postDataLoading) { + return ( + + + + ); + } + + if ((!postFile && !postData) || postData === null) { + return ( + + + Post not found + + + ); + } + + const post = (postFile || postData) as HomebaseFile; + + return ( + + + + + + + + + + + ); +}; diff --git a/packages/mobile/src/provider/feed/RNPostUploadProvider.ts b/packages/mobile/src/provider/feed/RNPostUploadProvider.ts index a24e6b13..0636ddb6 100644 --- a/packages/mobile/src/provider/feed/RNPostUploadProvider.ts +++ b/packages/mobile/src/provider/feed/RNPostUploadProvider.ts @@ -22,7 +22,6 @@ import { UploadResult, ImageContentType, PriorityOptions, - KeyHeader, } from '@homebase-id/js-lib/core'; import { toGuidId, @@ -47,24 +46,31 @@ import { createThumbnails } from '../image/RNThumbnailProvider'; import { processVideo } from '../video/RNVideoProcessor'; import { AxiosRequestConfig } from 'axios'; import { LinkPreview, LinkPreviewDescriptor } from '@homebase-id/js-lib/media'; +import { TransitInstructionSet, TransitUploadResult, uploadFileOverPeer } from '@homebase-id/js-lib/peer'; const POST_MEDIA_PAYLOAD_KEY = 'pst_mdi'; export const savePost = async ( dotYouClient: DotYouClient, file: HomebaseFile | NewHomebaseFile, + odinId: string | undefined, channelId: string, toSaveFiles?: (ImageSource | MediaFile)[] | ImageSource[], linkPreviews?: LinkPreview[], onVersionConflict?: () => void, onUpdate?: (phase: string, progress: number) => void -): Promise => { +): Promise => { + if (odinId && file.fileId) { + throw new Error( + '[PostUploadProvider] Editing a post to a group channel is not supported (yet)' + ); + } if (!file.fileMetadata.appData.content.id) { // The content id is set once, and then never updated to keep the permalinks correct at all times; Even when the slug changes file.fileMetadata.appData.content.id = file.fileMetadata.appData.content.slug ? toGuidId(file.fileMetadata.appData.content.slug) : getNewId(); - } else if (!file.fileId) { + } else if (!file.fileId && !odinId) { // Check if fileMetadata.appData.content.id exists and with which fileId file.fileId = (await getPost(dotYouClient, channelId, file.fileMetadata.appData.content.id))?.fileId ?? @@ -72,7 +78,7 @@ export const savePost = async ( } if (file.fileId) { - return await updatePost(dotYouClient, file as HomebaseFile, channelId, toSaveFiles); + return await updatePost(dotYouClient, odinId, file as HomebaseFile, channelId, toSaveFiles); } else { if (toSaveFiles?.some((file) => 'fileKey' in file)) { throw new Error( @@ -95,7 +101,7 @@ export const savePost = async ( const encrypt = !( file.serverMetadata?.accessControlList?.requiredSecurityGroup === SecurityGroupType.Anonymous || file.serverMetadata?.accessControlList?.requiredSecurityGroup === - SecurityGroupType.Authenticated + SecurityGroupType.Authenticated ); const targetDrive = GetTargetDriveFromChannelId(channelId); @@ -122,10 +128,10 @@ export const savePost = async ( const imageSource: ImageSource | undefined = linkPreviewWithImage ? { - height: linkPreviewWithImage.imageHeight || 0, - width: linkPreviewWithImage.imageWidth || 0, - uri: linkPreviewWithImage.imageUrl, - } + height: linkPreviewWithImage.imageHeight || 0, + width: linkPreviewWithImage.imageWidth || 0, + uri: linkPreviewWithImage.imageUrl, + } : undefined; const { tinyThumb } = imageSource @@ -199,10 +205,10 @@ export const savePost = async ( if (file.fileMetadata.appData.content.type !== 'Article') { file.fileMetadata.appData.content.primaryMediaFile = payloads[0] ? { - fileId: undefined, - fileKey: payloads[0].key, - type: payloads[0].payload.type, - } + fileId: undefined, + fileKey: payloads[0].key, + type: payloads[0].payload.type, + } : undefined; } @@ -215,6 +221,7 @@ export const savePost = async ( return await uploadPost( dotYouClient, + odinId, file, payloads, thumbnails, @@ -233,6 +240,7 @@ export const savePost = async ( const uploadPost = async ( dotYouClient: DotYouClient, + odinId: string | undefined, file: HomebaseFile | NewHomebaseFile, payloads: PayloadFile[], thumbnails: ThumbnailFile[], @@ -248,7 +256,7 @@ const uploadPost = async ( const encrypt = !( file.serverMetadata?.accessControlList?.requiredSecurityGroup === SecurityGroupType.Anonymous || file.serverMetadata?.accessControlList?.requiredSecurityGroup === - SecurityGroupType.Authenticated + SecurityGroupType.Authenticated ); const instructionSet: UploadInstructionSet = { @@ -265,22 +273,22 @@ const uploadPost = async ( }, }; - const existingPostWithThisSlug = await getPostBySlug( - dotYouClient, - channelId, - file.fileMetadata.appData.content.slug ?? file.fileMetadata.appData.content.id - ); + if (!odinId) { + const existingPostWithThisSlug = await getPostBySlug( + dotYouClient, + channelId, + file.fileMetadata.appData.content.slug ?? file.fileMetadata.appData.content.id + ); - if ( - existingPostWithThisSlug && - !stringGuidsEqual(existingPostWithThisSlug?.fileId, file.fileId) - ) { - // There is clash with an existing slug - file.fileMetadata.appData.content.slug = `${ - file.fileMetadata.appData.content.slug - }-${new Date().getTime()}`; + if ( + existingPostWithThisSlug && + !stringGuidsEqual(existingPostWithThisSlug?.fileId, file.fileId) + ) { + // There is clash with an existing slug + file.fileMetadata.appData.content.slug = `${file.fileMetadata.appData.content.slug + }-${new Date().getTime()}`; + } } - const uniqueId = file.fileMetadata.appData.content.slug ? toGuidId(file.fileMetadata.appData.content.slug) : file.fileMetadata.appData.content.id; @@ -304,7 +312,7 @@ const uploadPost = async ( const isDraft = file.fileMetadata.appData.fileType === BlogConfig.DraftPostFileType; const metadata: UploadFileMetadata = { versionTag: file?.fileMetadata.versionTag ?? undefined, - allowDistribution: !isDraft, + allowDistribution: !isDraft || !!odinId, appData: { tags: [file.fileMetadata.appData.content.id], uniqueId: uniqueId, @@ -351,19 +359,41 @@ const uploadPost = async ( ); } - const result = await uploadFile( - dotYouClient, - instructionSet, - metadata, - payloads, - thumbnails, - encrypt, - onVersionConflict, - options - ); - if (!result) throw new Error('Upload failed'); + if (!odinId) { + const result = await uploadFile( + dotYouClient, + instructionSet, + metadata, + payloads, + thumbnails, + encrypt, + onVersionConflict, + options + ); + if (!result) throw new Error('Upload failed'); - return result; + return result; + } else { + const transitInstructionSet: TransitInstructionSet = { + transferIv: getRandom16ByteArray(), + remoteTargetDrive: targetDrive, + schedule: ScheduleOptions.SendLater, + priority: PriorityOptions.Medium, + recipients: [odinId], + }; + + const result: TransitUploadResult = await uploadFileOverPeer( + dotYouClient, + transitInstructionSet, + metadata, + payloads, + thumbnails, + encrypt + ); + + if (!result) throw new Error('Upload over peer failed'); + return result; + } }; const uploadPostHeader = async ( @@ -395,12 +425,11 @@ const uploadPostHeader = async ( if ( existingPostWithThisSlug && existingPostWithThisSlug?.fileMetadata.appData.content.id !== - file.fileMetadata.appData.content.id + file.fileMetadata.appData.content.id ) { // There is clash with an existing slug - file.fileMetadata.appData.content.slug = `${ - file.fileMetadata.appData.content.slug - }-${new Date().getTime()}`; + file.fileMetadata.appData.content.slug = `${file.fileMetadata.appData.content.slug + }-${new Date().getTime()}`; } const uniqueId = file.fileMetadata.appData.content.slug @@ -484,6 +513,7 @@ const uploadPostHeader = async ( const updatePost = async ( dotYouClient: DotYouClient, + odinId: string | undefined, file: HomebaseFile, channelId: string, existingAndNewMediaFiles?: (ImageSource | MediaFile)[]