From e1b55a92482a8c3b74832d5e0e96b612497dff89 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Thu, 20 Jun 2024 13:48:06 -0600 Subject: [PATCH 1/2] feat: Invalidate Queries when changes happen to the group Removed unused conversations code Moved add member to mutation Added helper functions for queries Added handling for group stream to manage invalidating specific queries Added handling to display metadata changes --- app.json | 4 +- components/Chat/ChatGroupUpdatedMessage.tsx | 17 ++ .../handleGroupUpdatedMessage.test.ts | 118 ++++++++++++++ .../messages/handleGroupUpdatedMessage.ts | 35 ++++ data/helpers/messages/index.ts | 2 + hooks/useGroupMembers.ts | 4 + queries/useAddToGroupMutation.ts | 6 +- queries/useGroupMembersQuery.ts | 6 + queries/useGroupNameQuery.ts | 9 ++ queries/useGroupPhotoQuery.ts | 9 ++ screens/NewConversation/NewConversation.tsx | 23 +-- utils/xmtpRN/conversations.ts | 149 ------------------ utils/xmtpRN/messages.ts | 9 +- 13 files changed, 229 insertions(+), 162 deletions(-) create mode 100644 data/helpers/messages/handleGroupUpdatedMessage.test.ts create mode 100644 data/helpers/messages/handleGroupUpdatedMessage.ts diff --git a/app.json b/app.json index 060139a2b..dd8dd4ea4 100644 --- a/app.json +++ b/app.json @@ -2,11 +2,11 @@ "expo": { "ios": { "version": "1.4.8", - "buildNumber": "204" + "buildNumber": "205" }, "android": { "version": "1.4.8", - "versionCode": 134 + "versionCode": 137 } } } diff --git a/components/Chat/ChatGroupUpdatedMessage.tsx b/components/Chat/ChatGroupUpdatedMessage.tsx index 86c6bd45a..d9992cdb7 100644 --- a/components/Chat/ChatGroupUpdatedMessage.tsx +++ b/components/Chat/ChatGroupUpdatedMessage.tsx @@ -22,6 +22,12 @@ export default function ChatGroupUpdatedMessage({ const textMessages: string[] = []; const profiles = getProfilesStore(currentAccount()).getState().profiles; const byInboxId = getInboxIdStore(currentAccount()).getState().byInboxId; + // TODO: Feat: handle multiple members + const initiatedByAddress = byInboxId[content.initiatedByInboxId]?.[0]; + const initiatedByReadableName = getPreferredName( + profiles[initiatedByAddress]?.socials, + initiatedByAddress + ); content.membersAdded.forEach((m) => { // TODO: Feat: handle multiple members const firstAddress = byInboxId[m.inboxId]?.[0]; @@ -40,6 +46,17 @@ export default function ChatGroupUpdatedMessage({ ); textMessages.push(`${readableName} left the conversation`); }); + content.metadataFieldsChanged.forEach((f) => { + if (f.fieldName === "group_name") { + textMessages.push( + `The group name was changed to ${f.newValue} by ${initiatedByReadableName}` + ); + } else if (f.fieldName === "group_image_url_square") { + textMessages.push( + `The group photo was changed by ${initiatedByReadableName}` + ); + } + }); return textMessages; }, [message.content]); return ( diff --git a/data/helpers/messages/handleGroupUpdatedMessage.test.ts b/data/helpers/messages/handleGroupUpdatedMessage.test.ts new file mode 100644 index 000000000..58528d0c8 --- /dev/null +++ b/data/helpers/messages/handleGroupUpdatedMessage.test.ts @@ -0,0 +1,118 @@ +import { invalidateGroupMembersQuery } from "../../../queries/useGroupMembersQuery"; +import { invalidateGroupNameQuery } from "../../../queries/useGroupNameQuery"; +import { invalidateGroupPhotoQuery } from "../../../queries/useGroupPhotoQuery"; +import { DecodedMessageWithCodecsType } from "../../../utils/xmtpRN/client"; +import { handleGroupUpdatedMessage } from "./handleGroupUpdatedMessage"; + +jest.mock("../../../queries/useGroupMembersQuery", () => ({ + invalidateGroupMembersQuery: jest.fn(), +})); + +jest.mock("../../../queries/useGroupNameQuery", () => ({ + invalidateGroupNameQuery: jest.fn(), +})); + +jest.mock("../../../queries/useGroupPhotoQuery", () => ({ + invalidateGroupPhotoQuery: jest.fn(), +})); + +describe("handleGroupUpdatedMessage", () => { + const account = "testAccount"; + const topic = "testTopic"; + + const createMessage = (contentTypeId: string, content: any) => + ({ + contentTypeId, + content: () => content, + }) as unknown as DecodedMessageWithCodecsType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should not proceed if contentTypeId does not include "group_updated"', () => { + const message = createMessage("text", {}); + + handleGroupUpdatedMessage(account, topic, message); + + expect(invalidateGroupMembersQuery).not.toHaveBeenCalled(); + expect(invalidateGroupNameQuery).not.toHaveBeenCalled(); + expect(invalidateGroupPhotoQuery).not.toHaveBeenCalled(); + }); + + it("should invalidate group members query if members are added or removed", () => { + const content = { + membersAdded: ["member1"], + membersRemoved: [], + metadataFieldsChanged: [], + }; + const message = createMessage("group_updated", content); + + handleGroupUpdatedMessage(account, topic, message); + + expect(invalidateGroupMembersQuery).toHaveBeenCalledWith(account, topic); + }); + + it("should invalidate group name query if group name is changed", () => { + const content = { + membersAdded: [], + membersRemoved: [], + metadataFieldsChanged: [ + { fieldName: "group_name", newValue: "New Group Name" }, + ], + }; + const message = createMessage("group_updated", content); + + handleGroupUpdatedMessage(account, topic, message); + + expect(invalidateGroupNameQuery).toHaveBeenCalledWith(account, topic); + }); + + it("should invalidate group photo query if group photo is changed", () => { + const content = { + membersAdded: [], + membersRemoved: [], + metadataFieldsChanged: [ + { fieldName: "group_image_url_square", newValue: "New Photo URL" }, + ], + }; + const message = createMessage("group_updated", content); + + handleGroupUpdatedMessage(account, topic, message); + + expect(invalidateGroupPhotoQuery).toHaveBeenCalledWith(account, topic); + }); + + it("should invalidate all relevant queries if multiple changes occur", () => { + const content = { + membersAdded: ["member1"], + membersRemoved: ["member2"], + metadataFieldsChanged: [ + { fieldName: "group_name", newValue: "New Group Name" }, + { fieldName: "group_image_url_square", newValue: "New Photo URL" }, + ], + }; + const message = createMessage("group_updated", content); + + handleGroupUpdatedMessage(account, topic, message); + + expect(invalidateGroupMembersQuery).toHaveBeenCalledWith(account, topic); + expect(invalidateGroupNameQuery).toHaveBeenCalledWith(account, topic); + expect(invalidateGroupPhotoQuery).toHaveBeenCalledWith(account, topic); + }); + + it("should handle empty metadataFieldsChanged array", () => { + const content = { + membersAdded: [], + membersRemoved: [], + metadataFieldsChanged: [], + }; + const message = createMessage("group_updated", content); + + handleGroupUpdatedMessage(account, topic, message); + + expect(invalidateGroupMembersQuery).not.toHaveBeenCalled(); + expect(invalidateGroupNameQuery).not.toHaveBeenCalled(); + expect(invalidateGroupPhotoQuery).not.toHaveBeenCalled(); + }); +}); diff --git a/data/helpers/messages/handleGroupUpdatedMessage.ts b/data/helpers/messages/handleGroupUpdatedMessage.ts new file mode 100644 index 000000000..47be1fdf3 --- /dev/null +++ b/data/helpers/messages/handleGroupUpdatedMessage.ts @@ -0,0 +1,35 @@ +import { GroupUpdatedContent } from "@xmtp/react-native-sdk"; + +import { invalidateGroupMembersQuery } from "../../../queries/useGroupMembersQuery"; +import { invalidateGroupNameQuery } from "../../../queries/useGroupNameQuery"; +import { invalidateGroupPhotoQuery } from "../../../queries/useGroupPhotoQuery"; +import { DecodedMessageWithCodecsType } from "../../../utils/xmtpRN/client"; + +export const handleGroupUpdatedMessage = ( + account: string, + topic: string, + message: DecodedMessageWithCodecsType +) => { + if (!message.contentTypeId.includes("group_updated")) return; + const content = message.content() as GroupUpdatedContent; + if (content.membersAdded.length > 0 || content.membersRemoved.length > 0) { + invalidateGroupMembersQuery(account, topic); + } + if (content.metadataFieldsChanged.length > 0) { + let groupNameChanged = false; + let groupPhotoChanged = false; + for (const field of content.metadataFieldsChanged) { + if (field.fieldName === "group_name") { + groupNameChanged = true; + } else if (field.fieldName === "group_image_url_square") { + groupPhotoChanged = true; + } + } + if (groupNameChanged) { + invalidateGroupNameQuery(account, topic); + } + if (groupPhotoChanged) { + invalidateGroupPhotoQuery(account, topic); + } + } +}; diff --git a/data/helpers/messages/index.ts b/data/helpers/messages/index.ts index d82019cd0..986200bf5 100644 --- a/data/helpers/messages/index.ts +++ b/data/helpers/messages/index.ts @@ -12,6 +12,8 @@ import { xmtpMessageFromDb, xmtpMessageToDb } from "../../mappers"; import { getChatStore } from "../../store/accountsStore"; import { XmtpMessage } from "../../store/chatStore"; +export { handleGroupUpdatedMessage } from "./handleGroupUpdatedMessage"; + export const saveMessages = async ( account: string, messages: XmtpMessage[] diff --git a/hooks/useGroupMembers.ts b/hooks/useGroupMembers.ts index a953852ee..e60f72d79 100644 --- a/hooks/useGroupMembers.ts +++ b/hooks/useGroupMembers.ts @@ -1,4 +1,5 @@ import { currentAccount } from "../data/store/accountsStore"; +import { useAddToGroupMutation } from "../queries/useAddToGroupMutation"; import { useGroupMembersQuery } from "../queries/useGroupMembersQuery"; import { usePromoteToAdminMutation } from "../queries/usePromoteToAdminMutation"; import { usePromoteToSuperAdminMutation } from "../queries/usePromoteToSuperAdminMutation"; @@ -34,6 +35,8 @@ export const useGroupMembers = (topic: string) => { topic ); + const { mutateAsync: addMembers } = useAddToGroupMutation(account, topic); + return { members, isLoading, @@ -43,5 +46,6 @@ export const useGroupMembers = (topic: string) => { revokeSuperAdmin, revokeAdmin, removeMember, + addMembers, }; }; diff --git a/queries/useAddToGroupMutation.ts b/queries/useAddToGroupMutation.ts index c4d49b737..e27d4ff2e 100644 --- a/queries/useAddToGroupMutation.ts +++ b/queries/useAddToGroupMutation.ts @@ -2,7 +2,10 @@ import { useMutation } from "@tanstack/react-query"; import { refreshGroup } from "../utils/xmtpRN/conversations"; import { addMemberMutationKey } from "./MutationKeys"; -import { cancelGroupMembersQuery } from "./useGroupMembersQuery"; +import { + cancelGroupMembersQuery, + invalidateGroupMembersQuery, +} from "./useGroupMembersQuery"; import { useGroupQuery } from "./useGroupQuery"; export const useAddToGroupMutation = (account: string, topic: string) => { @@ -25,6 +28,7 @@ export const useAddToGroupMutation = (account: string, topic: string) => { }, onSuccess: (_data, _variables, _context) => { console.log("onSuccess useAddToGroupMutation"); + invalidateGroupMembersQuery(account, topic); refreshGroup(account, topic); }, }); diff --git a/queries/useGroupMembersQuery.ts b/queries/useGroupMembersQuery.ts index cc9541841..edca8a6c2 100644 --- a/queries/useGroupMembersQuery.ts +++ b/queries/useGroupMembersQuery.ts @@ -62,3 +62,9 @@ export const cancelGroupMembersQuery = async ( queryKey: groupMembersQueryKey(account, topic), }); }; + +export const invalidateGroupMembersQuery = (account: string, topic: string) => { + return queryClient.invalidateQueries({ + queryKey: groupMembersQueryKey(account, topic), + }); +}; diff --git a/queries/useGroupNameQuery.ts b/queries/useGroupNameQuery.ts index 08f2ca089..3c9c0e95f 100644 --- a/queries/useGroupNameQuery.ts +++ b/queries/useGroupNameQuery.ts @@ -37,3 +37,12 @@ export const cancelGroupNameQuery = async (account: string, topic: string) => { queryKey: groupNameQueryKey(account, topic), }); }; + +export const invalidateGroupNameQuery = async ( + account: string, + topic: string +) => { + return queryClient.invalidateQueries({ + queryKey: groupNameQueryKey(account, topic), + }); +}; diff --git a/queries/useGroupPhotoQuery.ts b/queries/useGroupPhotoQuery.ts index 96321e335..d01e86521 100644 --- a/queries/useGroupPhotoQuery.ts +++ b/queries/useGroupPhotoQuery.ts @@ -37,3 +37,12 @@ export const cancelGroupPhotoQuery = async (account: string, topic: string) => { queryKey: groupPhotoQueryKey(account, topic), }); }; + +export const invalidateGroupPhotoQuery = async ( + account: string, + topic: string +) => { + return queryClient.invalidateQueries({ + queryKey: groupPhotoQueryKey(account, topic), + }); +}; diff --git a/screens/NewConversation/NewConversation.tsx b/screens/NewConversation/NewConversation.tsx index 566eef6a7..5d7a6fb7d 100644 --- a/screens/NewConversation/NewConversation.tsx +++ b/screens/NewConversation/NewConversation.tsx @@ -1,15 +1,15 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import React, { useEffect, useRef, useState } from "react"; import { + Alert, Button, + Platform, ScrollView, StyleSheet, Text, TextInput, View, useColorScheme, - Platform, - Alert, } from "react-native"; import ActivityIndicator from "../../components/ActivityIndicator/ActivityIndicator"; @@ -28,6 +28,7 @@ import { } from "../../data/store/accountsStore"; import { ProfileSocials } from "../../data/store/profilesStore"; import { useSelect } from "../../data/store/storeHelpers"; +import { useGroupMembers } from "../../hooks/useGroupMembers"; import { searchProfiles } from "../../utils/api"; import { backgroundColor, @@ -45,7 +46,6 @@ import { navigate } from "../../utils/navigation"; import { isEmptyObject } from "../../utils/objects"; import { getPreferredName } from "../../utils/profile"; import { isOnXmtp } from "../../utils/xmtpRN/client"; -import { addMembersToGroup } from "../../utils/xmtpRN/conversations"; import { NewConversationModalParams } from "./NewConversationModal"; export default function NewConversation({ @@ -60,6 +60,9 @@ export default function NewConversation({ enabled: !!route.params?.addingToGroupTopic, members: [] as (ProfileSocials & { address: string })[], }); + const { addMembers } = useGroupMembers( + route.params?.addingToGroupTopic ?? "" + ); const existingGroup = useChatStore((s) => route.params?.addingToGroupTopic ? s.conversations[route.params.addingToGroupTopic] @@ -97,11 +100,7 @@ export default function NewConversation({ if (route.params?.addingToGroupTopic) { setLoading(true); try { - await addMembersToGroup( - currentAccount(), - route.params?.addingToGroupTopic, - group.members.map((m) => m.address) - ); + await addMembers(group.members.map((m) => m.address)); navigation.goBack(); } catch (e) { setLoading(false); @@ -121,7 +120,13 @@ export default function NewConversation({ return undefined; }, }); - }, [group, loading, navigation, route.params?.addingToGroupTopic]); + }, [ + group, + loading, + navigation, + route.params?.addingToGroupTopic, + addMembers, + ]); const [value, setValue] = useState(route.params?.peer || ""); const searchingForValue = useRef(""); diff --git a/utils/xmtpRN/conversations.ts b/utils/xmtpRN/conversations.ts index a51ca5bdf..955ec717b 100644 --- a/utils/xmtpRN/conversations.ts +++ b/utils/xmtpRN/conversations.ts @@ -510,155 +510,6 @@ export const refreshGroup = async (account: string, topic: string) => { ); }; -export const removeMembersFromGroup = async ( - account: string, - topic: string, - members: string[] -) => { - const group = await getConversationWithTopic(account, topic); - if (!group || (group as any).peerAddress) { - throw new Error( - `Conversation with topic ${topic} does not exist or is not a group` - ); - } - await (group as GroupWithCodecsType).removeMembers(members); - refreshGroup(account, topic); -}; - -export const addMembersToGroup = async ( - account: string, - topic: string, - members: string[] -) => { - const group = await getConversationWithTopic(account, topic); - if (!group || (group as any).peerAddress) { - throw new Error( - `Conversation with topic ${topic} does not exist or is not a group` - ); - } - await (group as GroupWithCodecsType).addMembers(members); - refreshGroup(account, topic); -}; - -export const updateGroupName = async ( - account: string, - topic: string, - groupName: string -) => { - const group = await getConversationWithTopic(account, topic); - if (!group || (group as any).peerAddress) { - throw new Error( - `Conversation with topic ${topic} does not exist or is not a group` - ); - } - await (group as GroupWithCodecsType).updateGroupName(groupName); - refreshGroup(account, topic); -}; - -export const promoteMemberToAdmin = async ( - account: string, - topic: string, - memberAddress: string -) => { - const group = (await getConversationWithTopic( - account, - topic - )) as GroupWithCodecsType; - if (!group || (group as any).peerAddress) { - throw new Error( - `Conversation with topic ${topic} does not exist or is not a group` - ); - } - const members = await group.members(); - saveMemberInboxIds(account, members); - const inboxId = members.find((m) => m.addresses[0] === memberAddress) - ?.inboxId; - if (!inboxId) { - throw new Error(`Member ${memberAddress} not found in group ${topic}`); - } - await (group as GroupWithCodecsType).addAdmin(inboxId); - console.log(`Added ${inboxId} as admin to group ${topic}`); - refreshGroup(account, topic); -}; - -export const revokeAdminAccess = async ( - account: string, - topic: string, - memberAddress: string -) => { - const group = (await getConversationWithTopic( - account, - topic - )) as GroupWithCodecsType; - if (!group || (group as any).peerAddress) { - throw new Error( - `Conversation with topic ${topic} does not exist or is not a group` - ); - } - const members = await group.members(); - saveMemberInboxIds(account, members); - const inboxId = members.find((m) => m.addresses[0] === memberAddress) - ?.inboxId; - if (!inboxId) { - throw new Error(`Member ${memberAddress} not found in group ${topic}`); - } - await (group as GroupWithCodecsType).removeAdmin(inboxId); - console.log(`Added ${inboxId} as admin to group ${topic}`); - refreshGroup(account, topic); -}; - -export const promoteMemberToSuperAdmin = async ( - account: string, - topic: string, - memberAddress: string -) => { - const group = (await getConversationWithTopic( - account, - topic - )) as GroupWithCodecsType; - if (!group || (group as any).peerAddress) { - throw new Error( - `Conversation with topic ${topic} does not exist or is not a group` - ); - } - const members = await group.members(); - saveMemberInboxIds(account, members); - const inboxId = members.find((m) => m.addresses[0] === memberAddress) - ?.inboxId; - if (!inboxId) { - throw new Error(`Member ${memberAddress} not found in group ${topic}`); - } - await (group as GroupWithCodecsType).addSuperAdmin(inboxId); - console.log(`Added ${inboxId} as admin to group ${topic}`); - refreshGroup(account, topic); -}; - -export const revokeSuperAdminAccess = async ( - account: string, - topic: string, - memberAddress: string -) => { - const group = (await getConversationWithTopic( - account, - topic - )) as GroupWithCodecsType; - if (!group || (group as any).peerAddress) { - throw new Error( - `Conversation with topic ${topic} does not exist or is not a group` - ); - } - const members = await group.members(); - saveMemberInboxIds(account, members); - const inboxId = members.find((m) => m.addresses[0] === memberAddress) - ?.inboxId; - if (!inboxId) { - throw new Error(`Member ${memberAddress} not found in group ${topic}`); - } - await (group as GroupWithCodecsType).removeSuperAdmin(inboxId); - console.log(`Added ${inboxId} as admin to group ${topic}`); - refreshGroup(account, topic); -}; - export const loadConversationsHmacKeys = async (account: string) => { const client = (await getXmtpClient(account)) as ConverseXmtpClientType; diff --git a/utils/xmtpRN/messages.ts b/utils/xmtpRN/messages.ts index 05e1cbcb7..8e5312b85 100644 --- a/utils/xmtpRN/messages.ts +++ b/utils/xmtpRN/messages.ts @@ -10,7 +10,11 @@ import { } from "@xmtp/react-native-sdk"; import { saveMemberInboxIds } from "../../data/helpers/inboxId/saveInboxIds"; -import { getOrderedMessages, saveMessages } from "../../data/helpers/messages"; +import { + getOrderedMessages, + handleGroupUpdatedMessage, + saveMessages, +} from "../../data/helpers/messages"; import { xmtpMessageFromDb } from "../../data/mappers"; import { getChatStore } from "../../data/store/accountsStore"; import { XmtpMessage } from "../../data/store/chatStore"; @@ -171,6 +175,9 @@ export const streamAllMessages = async (account: string) => { topic: message.topic, }); saveMessages(client.address, protocolMessagesToStateMessages([message])); + if (message.contentTypeId.includes("group_updated")) { + handleGroupUpdatedMessage(client.address, message.topic, message); + } }, true); }; From 8b494a06d73df84c823088dfde36b455be1cce44 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Thu, 20 Jun 2024 16:11:53 -0600 Subject: [PATCH 2/2] Handle admin permission changes --- data/helpers/messages/handleGroupUpdatedMessage.test.ts | 2 +- data/helpers/messages/handleGroupUpdatedMessage.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/data/helpers/messages/handleGroupUpdatedMessage.test.ts b/data/helpers/messages/handleGroupUpdatedMessage.test.ts index 58528d0c8..731c6dfbd 100644 --- a/data/helpers/messages/handleGroupUpdatedMessage.test.ts +++ b/data/helpers/messages/handleGroupUpdatedMessage.test.ts @@ -111,7 +111,7 @@ describe("handleGroupUpdatedMessage", () => { handleGroupUpdatedMessage(account, topic, message); - expect(invalidateGroupMembersQuery).not.toHaveBeenCalled(); + expect(invalidateGroupMembersQuery).toHaveBeenCalled(); expect(invalidateGroupNameQuery).not.toHaveBeenCalled(); expect(invalidateGroupPhotoQuery).not.toHaveBeenCalled(); }); diff --git a/data/helpers/messages/handleGroupUpdatedMessage.ts b/data/helpers/messages/handleGroupUpdatedMessage.ts index 47be1fdf3..9b7d71406 100644 --- a/data/helpers/messages/handleGroupUpdatedMessage.ts +++ b/data/helpers/messages/handleGroupUpdatedMessage.ts @@ -32,4 +32,12 @@ export const handleGroupUpdatedMessage = ( invalidateGroupPhotoQuery(account, topic); } } + // Admin Update + if ( + content.membersAdded.length === 0 && + content.membersRemoved.length === 0 && + content.metadataFieldsChanged.length === 0 + ) { + invalidateGroupMembersQuery(account, topic); + } };