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..731c6dfbd --- /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).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..9b7d71406 --- /dev/null +++ b/data/helpers/messages/handleGroupUpdatedMessage.ts @@ -0,0 +1,43 @@ +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); + } + } + // Admin Update + if ( + content.membersAdded.length === 0 && + content.membersRemoved.length === 0 && + content.metadataFieldsChanged.length === 0 + ) { + invalidateGroupMembersQuery(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); };