diff --git a/package-lock.json b/package-lock.json index 28dbfcc2..deb3f032 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "hasInstallScript": true, "dependencies": { - "@youfoundation/js-lib": "0.0.3-alpha.17", + "@youfoundation/js-lib": "0.0.4-alpha.2", "axios": "1.6.7", "patch-package": "8.0.0", "react": "18.2.0", @@ -6242,9 +6242,9 @@ } }, "node_modules/@youfoundation/js-lib": { - "version": "0.0.3-alpha.17", - "resolved": "https://npm.pkg.github.com/download/@youfoundation/js-lib/0.0.3-alpha.17/138f2393a1be62ae0d0c8d5460692f603288cd30", - "integrity": "sha512-HW/pzy1dWAdkcbKmz3yJQNRHYg+il7/UUPMXZmGuiplXoWhOo5Wb0ZKdf2mOaWUHp1Q1+/2wVGM82R4q0uFGNw==", + "version": "0.0.4-alpha.2", + "resolved": "https://npm.pkg.github.com/download/@youfoundation/js-lib/0.0.4-alpha.2/b7de72e7a098c2d2ed1d01d34562998a1d76ad8c", + "integrity": "sha512-+jB8vTefFBPP4pdKN1QPzuPz6YXrx51bHd3yfRYOx1DLRHjfzn6hqd+EU3mvMuLwEm/mpMnZro23PQjfCdy8Jg==", "dependencies": { "guid-typescript": "^1.0.9" }, @@ -20513,7 +20513,7 @@ }, "packages/mobile": { "name": "homebase-feed-mobile", - "version": "0.0.15", + "version": "0.0.16", "dependencies": { "@bam.tech/react-native-image-resizer": "^3.0.10", "@gorhom/bottom-sheet": "^4.6.0", diff --git a/package.json b/package.json index 79481fdd..e69c56ae 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "react": "18.2.0", "react-native": "0.73.6", "patch-package": "8.0.0", - "@youfoundation/js-lib": "0.0.3-alpha.17" + "@youfoundation/js-lib": "0.0.4-alpha.2" }, "overrides": { "browserify-sign": "4.2.2", diff --git a/packages/common/src/hooks/index.ts b/packages/common/src/hooks/index.ts index ce06c66a..57619f28 100644 --- a/packages/common/src/hooks/index.ts +++ b/packages/common/src/hooks/index.ts @@ -2,3 +2,4 @@ export * from './auth/useDotYouClientContext'; export * from './contacts/useAllContacts'; export * from './contacts/useAllConnections'; export * from './contacts/useIsConnected'; +export * from './permissions/useMissingPermissions'; diff --git a/packages/common/src/hooks/permissions/useMissingPermissions.ts b/packages/common/src/hooks/permissions/useMissingPermissions.ts new file mode 100644 index 00000000..a0847e90 --- /dev/null +++ b/packages/common/src/hooks/permissions/useMissingPermissions.ts @@ -0,0 +1,107 @@ +import { + stringifyToQueryParams, + getUniqueDrivesWithHighestPermission, + stringGuidsEqual, +} from '@youfoundation/js-lib/helpers'; +import { AppPermissionType } from '@youfoundation/js-lib/network'; +import { getExtendAppRegistrationParams } from '@youfoundation/js-lib/auth'; +import { useDotYouClientContext } from '../auth/useDotYouClientContext'; +import { useSecurityContext } from './useSecurityContext'; + +const getExtendAppRegistrationUrl = ( + host: string, + appId: string, + drives: { a: string; t: string; n: string; d: string; p: number }[], + circleDrives: { a: string; t: string; n: string; d: string; p: number }[] | undefined, + permissionKeys: number[], + needsAllConnected: boolean, + returnUrl: string +) => { + const params = getExtendAppRegistrationParams( + appId, + drives, + circleDrives, + permissionKeys, + needsAllConnected, + returnUrl + ); + + return `${host}/owner/appupdate?${stringifyToQueryParams(params)}`; +}; + +export const useMissingPermissions = ({ + appId, + drives, + circleDrives, + permissions, + needsAllConnected, +}: { + appId: string; + drives: { + a: string; + t: string; + n: string; + d: string; + p: number; + }[]; + circleDrives?: + | { + a: string; + t: string; + n: string; + d: string; + p: number; + }[] + | undefined; + permissions: AppPermissionType[]; + needsAllConnected?: boolean; +}) => { + const { data: context } = useSecurityContext().fetch; + const host = useDotYouClientContext().getRoot(); + + if (!context || !host) return; + + const driveGrants = context?.permissionContext.permissionGroups.flatMap( + (group) => group.driveGrants + ); + const uniqueDriveGrants = driveGrants ? getUniqueDrivesWithHighestPermission(driveGrants) : []; + + const permissionKeys = context?.permissionContext.permissionGroups.flatMap( + (group) => group.permissionSet.keys + ); + + const missingDrives = drives.filter((drive) => { + const matchingGrants = uniqueDriveGrants.filter( + (grant) => + stringGuidsEqual(grant.permissionedDrive.drive.alias, drive.a) && + stringGuidsEqual(grant.permissionedDrive.drive.type, drive.t) + ); + + const hasAccess = matchingGrants.some((grant) => { + const allPermissions = grant.permissionedDrive.permission.reduce((a, b) => a + b, 0); + return allPermissions >= drive.p; + }); + + return !hasAccess; + }); + + const missingPermissions = permissions?.filter((key) => permissionKeys?.indexOf(key) === -1); + + const hasAllConnectedCircle = context?.caller.isGrantedConnectedIdentitiesSystemCircle; + const missingAllConnectedCircle = (needsAllConnected && !hasAllConnectedCircle) || false; + + if (missingDrives.length === 0 && missingPermissions.length === 0 && !missingAllConnectedCircle) + return; + + const extendPermissionUrl = getExtendAppRegistrationUrl( + host, + appId, + missingDrives, + circleDrives, + missingPermissions, + missingAllConnectedCircle, + 'homebase-fchat://' + ); + + return extendPermissionUrl; +}; diff --git a/packages/common/src/hooks/permissions/useSecurityContext.ts b/packages/common/src/hooks/permissions/useSecurityContext.ts new file mode 100644 index 00000000..dc470611 --- /dev/null +++ b/packages/common/src/hooks/permissions/useSecurityContext.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query'; +import { + ApiType, + getSecurityContext, + getSecurityContextOverPeer, +} from '@youfoundation/js-lib/core'; +import { useDotYouClientContext } from '../auth/useDotYouClientContext'; + +export const useSecurityContext = (odinId?: string, isEnabled?: boolean) => { + const dotYouClient = useDotYouClientContext(); + + const fetch = async (odinId?: string) => { + if ( + !odinId || + odinId === window.location.hostname || + (dotYouClient.getType() === ApiType.App && odinId === dotYouClient.getIdentity()) + ) { + return await getSecurityContext(dotYouClient); + } else return await getSecurityContextOverPeer(dotYouClient, odinId); + }; + + return { + fetch: useQuery({ + queryKey: ['security-context', odinId], + queryFn: () => fetch(odinId), + refetchOnMount: false, + refetchOnWindowFocus: false, + staleTime: 24 * 60 * 60 * 1000, // 24 hours + enabled: isEnabled === undefined ? true : isEnabled, + }), + }; +}; diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 4468d196..2e1a0d05 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "homebase-feed-mobile", - "version": "0.0.15", + "version": "0.0.16", "private": true, "scripts": { "android": "react-native run-android", diff --git a/packages/mobile/src/app/App.tsx b/packages/mobile/src/app/App.tsx index 9886d548..962cdc23 100644 --- a/packages/mobile/src/app/App.tsx +++ b/packages/mobile/src/app/App.tsx @@ -7,7 +7,13 @@ import { import { createNativeStackNavigator } from '@react-navigation/native-stack'; import CodePush from 'react-native-code-push'; -import { useAuth, useValidTokenCheck } from '../hooks/auth/useAuth'; +import { + circleDrives, + drives, + permissions, + useAuth, + useValidTokenCheck, +} from '../hooks/auth/useAuth'; import { DotYouClientProvider } from '../components/Auth/DotYouClientProvider'; import { Platform } from 'react-native'; @@ -44,6 +50,9 @@ import { ChatStack } from './ChatStack'; import { ProfileStack } from './ProfileStack'; import BootSplash from 'react-native-bootsplash'; import BubbleColorProvider from '../components/BubbleContext/BubbleContext'; +import { ExtendPermissionDialog } from '../components/Permissions/ExtendPermissionDialog'; +import { t } from 'feed-app-common'; +import { CHAT_APP_ID, FEED_CHAT_APP_ID } from './constants'; export type AuthStackParamList = { Login: undefined; @@ -127,6 +136,14 @@ const AuthenticatedRoot = memo(() => { + diff --git a/packages/mobile/src/app/OdinQueryClient.tsx b/packages/mobile/src/app/OdinQueryClient.tsx index c097b9e9..a79f6007 100644 --- a/packages/mobile/src/app/OdinQueryClient.tsx +++ b/packages/mobile/src/app/OdinQueryClient.tsx @@ -65,6 +65,7 @@ const INCLUDED_QUERY_KEYS = [ 'conversations-with-recent-message', 'image', 'process-inbox', + 'security-context', ]; const persistOptions: Omit = { buster: '20240524', diff --git a/packages/mobile/src/components/Chat/Chat-Reaction.tsx b/packages/mobile/src/components/Chat/Chat-Reaction.tsx index 24a40644..55b7932e 100644 --- a/packages/mobile/src/components/Chat/Chat-Reaction.tsx +++ b/packages/mobile/src/components/Chat/Chat-Reaction.tsx @@ -11,12 +11,13 @@ import Animated, { } from 'react-native-reanimated'; import { useChatReaction } from '../../hooks/chat/useChatReaction'; import { useConversation } from '../../hooks/chat/useConversation'; -import { HomebaseFile } from '@youfoundation/js-lib/core'; +import { HomebaseFile, ReactionFile } from '@youfoundation/js-lib/core'; import { ChatMessage } from '../../provider/chat/ChatProvider'; import { Colors } from '../../app/Colors'; import { useDarkMode } from '../../hooks/useDarkMode'; import { ErrorNotification } from '../ui/Alert/ErrorNotification'; import { SelectedMessageState } from '../../pages/chat/chat-page'; +import { useDotYouClientContext } from 'feed-app-common'; const ChatReaction = memo( ({ @@ -83,16 +84,20 @@ const ChatReaction = memo( }; }); + const hasReactions = + selectedMessage?.selectedMessage?.fileMetadata.reactionPreview?.reactions && + Object.keys(selectedMessage?.selectedMessage?.fileMetadata.reactionPreview?.reactions).length; + const { add, get, remove } = useChatReaction({ - conversationId: message?.fileMetadata.appData.groupId, - messageId: message?.fileMetadata.appData.uniqueId, + messageFileId: hasReactions ? selectedMessage?.selectedMessage?.fileId : undefined, + messageGlobalTransitId: selectedMessage?.selectedMessage?.fileMetadata.globalTransitId, }); + const identity = useDotYouClientContext().getIdentity(); const { data: reactions } = get; - - const filteredReactions = useMemo( - () => reactions?.filter((reaction) => reaction.fileMetadata.senderOdinId === '') || [], - [reactions] + const myReactions = useMemo( + () => reactions?.filter((reaction) => reaction?.authorOdinId === identity) || [], + [identity, reactions] ); const { mutate: addReaction, error: reactionError } = add; @@ -102,7 +107,7 @@ const ChatReaction = memo( conversationId: message?.fileMetadata.appData.groupId, }).single.data; - const sendReaction = useCallback( + const toggleReaction = useCallback( (reaction: string, index: number) => { if (!selectedMessage) { return; @@ -111,22 +116,23 @@ const ChatReaction = memo( return; } else { if (!conversation) return; - const reactionStrings = filteredReactions.map( - (reac) => reac.fileMetadata.appData.content.message + const matchedReaction = myReactions.find( + (myReaction) => myReaction.body.includes(reaction) || myReaction.body === reaction ); - const matchedReaction = reactionStrings.indexOf(reaction); - if (matchedReaction !== -1) { + if (matchedReaction) { + console.log(matchedReaction); removeReaction({ conversation, - reaction: filteredReactions[matchedReaction], + reaction: matchedReaction, + message: message as HomebaseFile, + }); + } else { + addReaction({ + conversation: conversation, message: message as HomebaseFile, + reaction, }); } - addReaction({ - conversation: conversation, - message: message as HomebaseFile, - reaction, - }); } afterSendReaction(); }, @@ -134,7 +140,7 @@ const ChatReaction = memo( addReaction, afterSendReaction, conversation, - filteredReactions, + myReactions, message, openEmojiPicker, removeReaction, @@ -159,7 +165,22 @@ const ChatReaction = memo( ]} > {initialReactions.map((reaction, index) => ( - sendReaction(reaction, index)}> + toggleReaction(reaction, index)} + style={ + myReactions.some( + (myReaction) => myReaction.body.includes(reaction) || myReaction.body === reaction + ) + ? { + backgroundColor: isDarkMode ? Colors.slate[600] : Colors.slate[300], + borderRadius: 50, + margin: -5, + padding: 5, + } + : {} + } + > {reaction} ))} diff --git a/packages/mobile/src/components/Chat/ChatDetail.tsx b/packages/mobile/src/components/Chat/ChatDetail.tsx index dbe23d10..6fc69a74 100644 --- a/packages/mobile/src/components/Chat/ChatDetail.tsx +++ b/packages/mobile/src/components/Chat/ChatDetail.tsx @@ -903,8 +903,8 @@ const RenderBubble = memo( const isReply = !!content?.replyId; const showBackground = !isEmojiOnly || isReply; const { data: reactions } = useChatReaction({ - conversationId: message?.fileMetadata.appData.groupId, - messageId: message?.fileMetadata.appData.uniqueId, + messageFileId: message?.fileId, + messageGlobalTransitId: message?.fileMetadata.globalTransitId, }).get; const onRetryOpen = useCallback(() => { @@ -912,8 +912,11 @@ const RenderBubble = memo( }, [message, props]); const hasReactions = (reactions && reactions?.length > 0) || false; - const flatReactions = useMemo( - () => reactions?.flatMap((val) => val.fileMetadata.appData.content.message), + const filteredEmojis = useMemo( + () => + reactions?.filter((reaction) => + reactions?.some((reactionFile) => reactionFile?.body === reaction?.body) + ) || [], [reactions] ); // has pauload and no text but no audio payload @@ -999,7 +1002,7 @@ const RenderBubble = memo( backgroundColor: isDarkMode ? Colors.gray[800] : Colors.gray[100], }} > - {flatReactions?.slice(0, maxVisible).map((reaction, index) => { + {filteredEmojis?.slice(0, maxVisible).map((reaction, index) => { return ( - {reaction} + {reaction.body} ); })} diff --git a/packages/mobile/src/components/Chat/Reactions/Emoji-Picker/Emoji-Picker-Modal.tsx b/packages/mobile/src/components/Chat/Reactions/Emoji-Picker/Emoji-Picker-Modal.tsx index c128e2fc..32ebdec4 100644 --- a/packages/mobile/src/components/Chat/Reactions/Emoji-Picker/Emoji-Picker-Modal.tsx +++ b/packages/mobile/src/components/Chat/Reactions/Emoji-Picker/Emoji-Picker-Modal.tsx @@ -28,10 +28,14 @@ export const EmojiPickerModal = forwardRef( ) => { const { isDarkMode } = useDarkMode(); + const hasReactions = + selectedMessage?.fileMetadata.reactionPreview?.reactions && + Object.keys(selectedMessage?.fileMetadata.reactionPreview?.reactions).length; const { mutate: addReaction } = useChatReaction({ - conversationId: selectedMessage?.fileMetadata.appData.groupId, - messageId: selectedMessage?.fileMetadata.appData.uniqueId, + messageFileId: hasReactions ? selectedMessage?.fileId : undefined, + messageGlobalTransitId: selectedMessage?.fileMetadata.globalTransitId, }).add; + const conversation = useConversation({ conversationId: selectedMessage?.fileMetadata.appData.groupId, }).single.data; diff --git a/packages/mobile/src/components/Chat/Reactions/Modal/ReactionsModal.tsx b/packages/mobile/src/components/Chat/Reactions/Modal/ReactionsModal.tsx index 597b7b90..98b25dd4 100644 --- a/packages/mobile/src/components/Chat/Reactions/Modal/ReactionsModal.tsx +++ b/packages/mobile/src/components/Chat/Reactions/Modal/ReactionsModal.tsx @@ -11,8 +11,7 @@ import { useDarkMode } from '../../../../hooks/useDarkMode'; import { ChatMessageIMessage } from '../../ChatDetail'; import { useChatReaction } from '../../../../hooks/chat/useChatReaction'; -import { HomebaseFile } from '@youfoundation/js-lib/core'; -import { ChatReaction } from '../../../../provider/chat/ChatReactionProvider'; +import { ReactionFile } from '@youfoundation/js-lib/core'; import { Avatar, OwnerAvatar } from '../../../ui/Avatars/Avatar'; import { AuthorName } from '../../../ui/Name'; import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; @@ -24,8 +23,8 @@ export const ReactionsModal = forwardRef( ) => { const { isDarkMode } = useDarkMode(); const { data: reactions } = useChatReaction({ - messageId: message?.fileMetadata.appData.uniqueId, - conversationId: message?.fileMetadata.appData.groupId, + messageFileId: message?.fileId, + messageGlobalTransitId: message?.fileMetadata.globalTransitId, }).get; const renderBackdrop = useCallback( @@ -68,7 +67,9 @@ export const ReactionsModal = forwardRef( Reactions - {reactions?.map((prop) => )} + {reactions?.map((prop) => ( + + ))} @@ -76,9 +77,9 @@ export const ReactionsModal = forwardRef( } ); -const ReactionTile = (prop: HomebaseFile) => { - const reaction = prop.fileMetadata.appData.content.message; - const senderOdinId = prop.fileMetadata.senderOdinId; +const ReactionTile = (prop: ReactionFile) => { + const reaction = prop.body; + const senderOdinId = prop.authorOdinId; const { isDarkMode } = useDarkMode(); return ( { + const extendPermissionUrl = useMissingPermissions({ + appId, + drives, + circleDrives, + permissions, + needsAllConnected, + }); + + useEffect(() => { + if (extendPermissionUrl) { + console.log('extendPermissionUrl', extendPermissionUrl); + Alert.alert( + t('Missing permissions'), + t( + `The ${appName} app is missing permissions. Without the necessary permissions the functionality of ${appName} will be limited` + ), + [ + { + text: 'Extend permissions', + onPress: async () => { + if (await InAppBrowser.isAvailable()) { + await InAppBrowser.open(extendPermissionUrl, { + enableUrlBarHiding: false, + enableDefaultShare: false, + }); + } else Linking.openURL(extendPermissionUrl); + }, + }, + { + text: 'Cancel', + onPress: () => console.log('Cancel Pressed'), + style: 'cancel', + }, + ] + ); + } + }, [appName, extendPermissionUrl]); + + return null; +}; diff --git a/packages/mobile/src/hooks/auth/useAuth.ts b/packages/mobile/src/hooks/auth/useAuth.ts index 571affcc..3fb4f0e5 100644 --- a/packages/mobile/src/hooks/auth/useAuth.ts +++ b/packages/mobile/src/hooks/auth/useAuth.ts @@ -49,7 +49,7 @@ export const drives = [ t: ChatConfig.ChatDrive.type, n: ChatConfig.name, d: 'Drive which contains all the chat messages', - p: DrivePermissionType.Read + DrivePermissionType.Write, + p: DrivePermissionType.Read + DrivePermissionType.Write + DrivePermissionType.React, }, { // Standard profile Info @@ -88,7 +88,7 @@ export const drives = [ DrivePermissionType.Comment, }, ]; -export const permissionKeys = [ +export const permissions = [ AppPermissionType.ReadConnections, AppPermissionType.ReadConnectionRequests, AppPermissionType.ReadCircleMembers, @@ -101,13 +101,13 @@ export const permissionKeys = [ AppPermissionType.SendPushNotifications, ]; -const circleDrives = [ +export const circleDrives = [ { a: ChatConfig.ChatDrive.alias, t: ChatConfig.ChatDrive.type, n: ChatConfig.name, d: '', - p: DrivePermissionType.Write, + p: DrivePermissionType.Write + DrivePermissionType.React, }, ]; export const appName = 'Homebase - Feed & Chat'; @@ -120,7 +120,6 @@ export const useValidTokenCheck = () => { const dotYouClient = getDotYouClient(); const { data: hasValidToken, isFetchedAfterMount } = useVerifyToken(dotYouClient); - useEffect(() => { if (isFetchedAfterMount && hasValidToken === false) { console.log('Token is invalid, logging out..'); @@ -188,7 +187,16 @@ export const useAuth = () => { setIdentity(''); queryClient.clear(); - }, [getDotYouClient, identity, queryClient, setAuthToken, setIdentity, setLastLoggedOutIdentity, setPrivateKey, setSharedSecret]); + }, [ + getDotYouClient, + identity, + queryClient, + setAuthToken, + setIdentity, + setLastLoggedOutIdentity, + setPrivateKey, + setSharedSecret, + ]); return { logout, @@ -218,14 +226,15 @@ export const useYouAuthAuthorization = () => { 'homebase-fchat://auth/finalize/', appName, appId, - permissionKeys, + permissions, undefined, drives, circleDrives, [ALL_CONNECTIONS_CIRCLE_ID], uint8ArrayToBase64(stringToUint8Array(JSON.stringify(publicKeyJwk))), corsHost, - `${Platform.OS === 'ios' ? 'iOS' : Platform.OS === 'android' ? 'Android' : Platform.OS} | ${Platform.Version + `${Platform.OS === 'ios' ? 'iOS' : Platform.OS === 'android' ? 'Android' : Platform.OS} | ${ + Platform.Version }` ); }, [setPrivateKey]); diff --git a/packages/mobile/src/hooks/chat/useChatReaction.ts b/packages/mobile/src/hooks/chat/useChatReaction.ts index 66262d00..50da216e 100644 --- a/packages/mobile/src/hooks/chat/useChatReaction.ts +++ b/packages/mobile/src/hooks/chat/useChatReaction.ts @@ -5,19 +5,20 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query'; -import { HomebaseFile, NewHomebaseFile, SecurityGroupType } from '@youfoundation/js-lib/core'; -import { stringGuidsEqual } from '@youfoundation/js-lib/helpers'; -import { t, useDotYouClientContext } from 'feed-app-common'; import { - ChatReaction, - deleteReaction, - getReactions, - uploadReaction, -} from '../../provider/chat/ChatReactionProvider'; -import { UnifiedConversation } from '../../provider/chat/ConversationProvider'; + deleteGroupReaction, + getGroupReactions, + GroupEmojiReaction, + HomebaseFile, + ReactionFile, + uploadGroupReaction, +} from '@youfoundation/js-lib/core'; +import { t, useDotYouClientContext } from 'feed-app-common'; +import { ChatDrive, UnifiedConversation } from '../../provider/chat/ConversationProvider'; import { ChatMessage } from '../../provider/chat/ChatProvider'; import { getSynchronousDotYouClient } from './getSynchronousDotYouClient'; import { addError } from '../errors/useErrors'; +import { tryJsonParse } from '@youfoundation/js-lib/helpers'; const addReaction = async ({ conversation, @@ -29,30 +30,21 @@ const addReaction = async ({ reaction: string; }) => { const dotYouClient = await getSynchronousDotYouClient(); - - const conversationId = conversation.fileMetadata.appData.uniqueId as string; const conversationContent = conversation.fileMetadata.appData.content; const identity = dotYouClient.getIdentity(); const recipients = conversationContent.recipients.filter((recipient) => recipient !== identity); - const newReaction: NewHomebaseFile = { - fileMetadata: { - appData: { - groupId: message.fileMetadata.appData.uniqueId, - tags: [conversationId], - content: { - message: reaction, - }, - }, - }, - serverMetadata: { - accessControlList: { - requiredSecurityGroup: SecurityGroupType.Connected, - }, - }, - }; + if (!message.fileMetadata.globalTransitId) { + throw new Error('Message does not have a global transit id'); + } - return await uploadReaction(dotYouClient, conversationId, newReaction, recipients); + return await uploadGroupReaction( + dotYouClient, + ChatDrive, + message.fileMetadata.globalTransitId, + reaction, + recipients + ); }; export const getAddReactionMutationOptions: (queryClient: QueryClient) => UseMutationOptions< @@ -69,54 +61,49 @@ export const getAddReactionMutationOptions: (queryClient: QueryClient) => UseMut onMutate: async (variables) => { const { message } = variables; const previousReactions = - queryClient.getQueryData[]>([ - 'chat-reaction', - message.fileMetadata.appData.uniqueId, - ]) || []; - - // if (!previousReactions) return; - const newReaction: NewHomebaseFile = { - fileMetadata: { - appData: { - content: { - message: variables.reaction, - }, - }, - }, - serverMetadata: { - accessControlList: { requiredSecurityGroup: SecurityGroupType.Connected }, - }, + queryClient.getQueryData(['chat-reaction', message.fileId]) || []; + + const newReaction: ReactionFile = { + authorOdinId: (await getSynchronousDotYouClient()).getIdentity(), + body: variables.reaction, }; queryClient.setQueryData( - ['chat-reaction', message.fileMetadata.appData.uniqueId], + ['chat-reaction', message.fileId], [...previousReactions, newReaction] ); }, onSettled: (data, error, variables) => { queryClient.invalidateQueries({ - queryKey: ['chat-reaction', variables.message.fileMetadata.appData.uniqueId], + queryKey: ['chat-reaction', variables.message.fileId], }); }, onError: (err) => addError(queryClient, err, t('Failed to add reaction')), }); -const removeReaction = async ({ +const removeReactionOnServer = async ({ conversation, - + message, reaction, }: { conversation: HomebaseFile; message: HomebaseFile; - reaction: HomebaseFile; + reaction: ReactionFile; }) => { const dotYouClient = await getSynchronousDotYouClient(); - const conversationContent = conversation.fileMetadata.appData.content; const identity = dotYouClient.getIdentity(); const recipients = conversationContent.recipients.filter((recipient) => recipient !== identity); - return await deleteReaction(dotYouClient, reaction, recipients); + if (!message.fileMetadata.globalTransitId) { + throw new Error('Message does not have a global transit id'); + } + + return await deleteGroupReaction(dotYouClient, ChatDrive, recipients, reaction, { + fileId: message.fileId, + globalTransitId: message.fileMetadata.globalTransitId, + targetDrive: ChatDrive, + }); }; export const getRemoveReactionMutationOptions: (queryClient: QueryClient) => UseMutationOptions< @@ -125,28 +112,34 @@ export const getRemoveReactionMutationOptions: (queryClient: QueryClient) => Use { conversation: HomebaseFile; message: HomebaseFile; - reaction: HomebaseFile; + reaction: ReactionFile; } > = (queryClient) => ({ mutationKey: ['remove-reaction'], - mutationFn: removeReaction, + mutationFn: removeReactionOnServer, onMutate: async (variables) => { const { message, reaction } = variables; - const previousReactions = queryClient.getQueryData[]>([ + const previousReactions = queryClient.getQueryData([ 'chat-reaction', - message.fileMetadata.appData.uniqueId, + message.fileId, ]); if (!previousReactions) return; queryClient.setQueryData( - ['chat-reaction', message.fileMetadata.appData.uniqueId], - [...previousReactions.filter((r) => !stringGuidsEqual(r.fileId, reaction.fileId))] + ['chat-reaction', message.fileId], + [ + ...previousReactions.filter( + (existingReaction) => + existingReaction.authorOdinId !== reaction.authorOdinId || + existingReaction.body !== reaction.body + ), + ] ); }, onSettled: (data, error, variables) => { queryClient.invalidateQueries({ - queryKey: ['chat-reaction', variables.message.fileMetadata.appData.uniqueId], + queryKey: ['chat-reaction', variables.message.fileId], }); }, @@ -154,26 +147,99 @@ export const getRemoveReactionMutationOptions: (queryClient: QueryClient) => Use }); export const useChatReaction = (props?: { - conversationId: string | undefined; - messageId: string | undefined; + messageGlobalTransitId: string | undefined; + messageFileId: string | undefined; }) => { - const { conversationId, messageId } = props || {}; + const { messageGlobalTransitId, messageFileId } = props || {}; const dotYouClient = useDotYouClientContext(); const queryClient = useQueryClient(); - const getReactionsByMessageUniqueId = (conversationId: string, messageId: string) => async () => { - return (await getReactions(dotYouClient, conversationId, messageId))?.searchResults || []; + const getReactionsByMessageGlobalTransitId = (messageGlobalTransitId: string) => async () => { + const reactions = + ( + await getGroupReactions(dotYouClient, { + target: { + globalTransitId: messageGlobalTransitId, + targetDrive: ChatDrive, + }, + }) + )?.reactions || []; + + return reactions; }; return { get: useQuery({ - queryKey: ['chat-reaction', messageId], - queryFn: getReactionsByMessageUniqueId(conversationId as string, messageId as string), - enabled: !!conversationId && !!messageId, + queryKey: ['chat-reaction', messageFileId], + queryFn: getReactionsByMessageGlobalTransitId(messageGlobalTransitId as string), + enabled: !!messageGlobalTransitId && !!messageFileId, staleTime: 1000 * 60 * 10, // 10 min }), add: useMutation(getAddReactionMutationOptions(queryClient)), remove: useMutation(getRemoveReactionMutationOptions(queryClient)), }; }; + +export const insertNewReaction = ( + queryClient: QueryClient, + messageLocalFileId: string, + newReaction: GroupEmojiReaction +) => { + const currentReactions = queryClient.getQueryData([ + 'chat-reaction', + messageLocalFileId, + ]); + + if (!currentReactions) { + queryClient.invalidateQueries({ queryKey: ['chat-reaction', messageLocalFileId] }); + return; + } + + const reactionAsReactionFile: ReactionFile = { + authorOdinId: newReaction.odinId, + body: tryJsonParse<{ emoji: string }>(newReaction.reactionContent).emoji, + }; + + queryClient.setQueryData( + ['chat-reaction', messageLocalFileId], + [ + ...currentReactions.filter( + (reaction) => + reaction.authorOdinId !== reactionAsReactionFile.authorOdinId || + reaction.body !== reactionAsReactionFile.body + ), + reactionAsReactionFile, + ] + ); +}; + +export const removeReaction = ( + queryClient: QueryClient, + messageLocalFileId: string, + removedReaction: GroupEmojiReaction +) => { + const currentReactions = queryClient.getQueryData([ + 'chat-reaction', + messageLocalFileId, + ]); + + if (!currentReactions) { + queryClient.invalidateQueries({ queryKey: ['chat-reaction', messageLocalFileId] }); + return; + } + + const reactionAsReactionFile: ReactionFile = { + authorOdinId: removedReaction.odinId, + body: tryJsonParse<{ emoji: string }>(removedReaction.reactionContent).emoji, + }; + + queryClient.setQueryData( + ['chat-reaction', messageLocalFileId], + currentReactions.filter( + (reaction) => + reaction.authorOdinId !== reactionAsReactionFile.authorOdinId || + reaction.body !== reactionAsReactionFile.body + ) + ); +}; diff --git a/packages/mobile/src/hooks/chat/useLiveChatProcessor.ts b/packages/mobile/src/hooks/chat/useLiveChatProcessor.ts index bf2b4631..26df9d0f 100644 --- a/packages/mobile/src/hooks/chat/useLiveChatProcessor.ts +++ b/packages/mobile/src/hooks/chat/useLiveChatProcessor.ts @@ -6,6 +6,7 @@ import { FileQueryParams, HomebaseFile, PushNotification, + ReactionNotification, TypedConnectionNotification, queryBatch, queryModified, @@ -41,6 +42,7 @@ import { insertNewConversation } from './useConversations'; import { useWebSocketContext } from '../../components/WebSocketContext/useWebSocketContext'; import { insertNewConversationMetadata } from './useConversationMetadata'; import { incrementAppIdNotificationCount } from '../notifications/usePushNotifications'; +import { insertNewReaction, removeReaction } from './useChatReaction'; const MINUTE_IN_MS = 60000; const isDebug = false; // The babel plugin to remove console logs would remove any if they get to production @@ -147,7 +149,8 @@ const useChatWebsocket = (isEnabled: boolean) => { if ( (notification.notificationType === 'fileAdded' || - notification.notificationType === 'fileModified') && + notification.notificationType === 'fileModified' || + notification.notificationType === 'statisticsChanged') && stringGuidsEqual(notification.targetDrive?.alias, ChatDrive.alias) && stringGuidsEqual(notification.targetDrive?.type, ChatDrive.type) ) { @@ -256,6 +259,25 @@ const useChatWebsocket = (isEnabled: boolean) => { } incrementAppIdNotificationCount(queryClient, clientNotification.options.appId); } + + if ( + notification.notificationType === 'reactionContentAdded' || + notification.notificationType === 'reactionContentDeleted' + ) { + if (notification.notificationType === 'reactionContentAdded') { + insertNewReaction( + queryClient, + notification.fileId.fileId, + notification as ReactionNotification + ); + } else if (notification.notificationType === 'reactionContentDeleted') { + removeReaction( + queryClient, + notification.fileId.fileId, + notification as ReactionNotification + ); + } + } }, []); const chatMessagesQueueTunnel = useRef[]>([]); @@ -300,7 +322,14 @@ const useChatWebsocket = (isEnabled: boolean) => { return useNotificationSubscriber( isEnabled ? handler : undefined, - ['fileAdded', 'fileModified'], + [ + 'fileAdded', + 'fileModified', + 'reactionContentAdded', + 'reactionContentDeleted', + 'statisticsChanged', + 'appNotificationAdded', + ], [ChatDrive], () => { queryClient.invalidateQueries({ queryKey: ['process-inbox'] }); diff --git a/packages/mobile/src/pages/chat/message-info-page.tsx b/packages/mobile/src/pages/chat/message-info-page.tsx index aa10c612..2adaf852 100644 --- a/packages/mobile/src/pages/chat/message-info-page.tsx +++ b/packages/mobile/src/pages/chat/message-info-page.tsx @@ -12,6 +12,7 @@ import { useDotYouClientContext } from 'feed-app-common'; import { ChatStackParamList } from '../../app/ChatStack'; import { Avatar, OwnerAvatar } from '../../components/ui/Avatars/Avatar'; import { SafeAreaView } from '../../components/ui/SafeAreaView/SafeAreaView'; +import { ReactionFile } from '@youfoundation/js-lib/core'; export type MessageInfoProp = NativeStackScreenProps; @@ -37,8 +38,8 @@ export const MessageInfoPage = ({ route }: MessageInfoProp) => { message.fileMetadata.senderOdinId === identity || !message.fileMetadata.senderOdinId; const { data: reactions } = useChatReaction({ - conversationId: message.fileMetadata.appData.groupId, - messageId: message.fileMetadata.appData.uniqueId, + messageFileId: message.fileId, + messageGlobalTransitId: message.fileMetadata.globalTransitId, }).get; function renderDetails() { @@ -124,10 +125,10 @@ export const MessageInfoPage = ({ route }: MessageInfoProp) => { return (
- {reactions.map((reaction) => { + {reactions.map((reaction: ReactionFile, index: number) => { return ( { padding: 10, }} > - + - + - {reaction.fileMetadata.appData.content.message} + {reaction.body} ); })}