diff --git a/features/conversation/conversation-chat/conversation-composer/conversation-composer-text-input.tsx b/features/conversation/conversation-chat/conversation-composer/conversation-composer-text-input.tsx index 7f1dfe0c..e6cae74d 100644 --- a/features/conversation/conversation-chat/conversation-composer/conversation-composer-text-input.tsx +++ b/features/conversation/conversation-chat/conversation-composer/conversation-composer-text-input.tsx @@ -34,11 +34,6 @@ export const ConversationComposerTextInput = memo(function ConversationComposerT onSubmitEditing() }, [onSubmitEditing]) - // Focus the input when appropriate - const focusInput = useCallback(() => { - inputRef.current?.focus() - }, []) - return ( ({ - inboxId, - caller: "ChatGroupMemberLeft" as const, - }), [inboxId]); - + + const displayInfoParams = useMemo( + () => ({ + inboxId, + caller: "ChatGroupMemberLeft" as const, + }), + [inboxId], + ) + const { displayName, avatarUrl } = usePreferredDisplayInfo(displayInfoParams) const handlePress = useCallback(() => { @@ -78,10 +88,7 @@ const ChatGroupMemberLeft = memo(function ChatGroupMemberLeft({ inboxId }: IChat return ( - + {displayName ?? ""} @@ -96,28 +103,35 @@ type IChatGroupMemberJoinedProps = { xmtpConversationId: IXmtpConversationId } -const ChatGroupMemberJoined = memo(function ChatGroupMemberJoined({ - inboxId, +const ChatGroupMemberJoined = memo(function ChatGroupMemberJoined({ + inboxId, initiatedByInboxId, - xmtpConversationId + xmtpConversationId, }: IChatGroupMemberJoinedProps) { const { themed, theme } = useAppTheme() const currentSender = useSafeCurrentSender() - - const memberDisplayInfoParams = useMemo(() => ({ - inboxId, - caller: "ChatGroupMemberJoined" as const, - }), [inboxId]); - + + const memberDisplayInfoParams = useMemo( + () => ({ + inboxId, + caller: "ChatGroupMemberJoined" as const, + }), + [inboxId], + ) + const { displayName, avatarUrl } = usePreferredDisplayInfo(memberDisplayInfoParams) - const initiatorDisplayInfoParams = useMemo(() => ({ - inboxId: initiatedByInboxId, - caller: "ChatGroupMemberJoined" as const, - }), [initiatedByInboxId]); - - const { displayName: initiatorDisplayName, avatarUrl: initiatorAvatarUrl } = usePreferredDisplayInfo(initiatorDisplayInfoParams) + const initiatorDisplayInfoParams = useMemo( + () => ({ + inboxId: initiatedByInboxId, + caller: "ChatGroupMemberJoined" as const, + }), + [initiatedByInboxId], + ) + + const { displayName: initiatorDisplayName, avatarUrl: initiatorAvatarUrl } = + usePreferredDisplayInfo(initiatorDisplayInfoParams) const handleMemberPress = useCallback(() => { navigate("Profile", { inboxId }) @@ -138,10 +152,7 @@ const ChatGroupMemberJoined = memo(function ChatGroupMemberJoined({ return ( - + {displayName ?? ""} @@ -149,12 +160,13 @@ const ChatGroupMemberJoined = memo(function ChatGroupMemberJoined({ {/* Show inviter if their displayName is available and it's not a DM */} {!isDm && initiatorDisplayName && ( - + by - + {initiatorDisplayName ?? ""} )} @@ -172,12 +184,15 @@ const ChatGroupMetadataUpdate = memo(function ChatGroupMetadataUpdate({ initiatorInboxId, }: IChatGroupMetadataUpdateProps) { const { themed, theme } = useAppTheme() - - const displayInfoParams = useMemo(() => ({ - inboxId: initiatorInboxId, - caller: "ChatGroupMetadataUpdate" as const, - }), [initiatorInboxId]); - + + const displayInfoParams = useMemo( + () => ({ + inboxId: initiatorInboxId, + caller: "ChatGroupMetadataUpdate" as const, + }), + [initiatorInboxId], + ) + const { displayName, avatarUrl } = usePreferredDisplayInfo(displayInfoParams) const handlePress = useCallback(() => { @@ -193,15 +208,21 @@ const ChatGroupMetadataUpdate = memo(function ChatGroupMetadataUpdate({ case "description": return `changed group description to ${metadataEntry.newValue}` case "message_disappear_in_ns": { + const oldValue = parseInt(metadataEntry.oldValue, 10) const newValue = parseInt(metadataEntry.newValue, 10) const newTime = getFormattedDisappearingDuration(newValue) - if (newValue === 0) { - return `disabled disappearing messages` - } else if (metadataEntry.oldValue === "0") { + if (newValue === CLEAR_CHAT_RETENTION_DURATION_NS) { + // transition to cleared chat + return "cleared the chat" + } else if (oldValue === CLEAR_CHAT_RETENTION_DURATION_NS && newValue > 0) { + // transition from cleared chat + return `set messages to disappear in ${newTime}` + } else if (newValue === 0) { + return "turned off disappearing messages" + } else if (oldValue === 0) { return `set messages to disappear in ${newTime}` } else { - const oldValue = parseInt(metadataEntry.oldValue, 10) const oldTime = getFormattedDisappearingDuration(oldValue) return `changed disappearing messages from ${oldTime} to ${newTime}` } @@ -222,10 +243,7 @@ const ChatGroupMetadataUpdate = memo(function ChatGroupMetadataUpdate({ return ( - + {displayName ?? ""} diff --git a/features/conversation/conversation-chat/conversation-messages.tsx b/features/conversation/conversation-chat/conversation-messages.tsx index ede85e2a..27282fc3 100644 --- a/features/conversation/conversation-chat/conversation-messages.tsx +++ b/features/conversation/conversation-chat/conversation-messages.tsx @@ -48,7 +48,10 @@ import { useConversationQuery } from "@/features/conversation/queries/conversati import { isConversationAllowed } from "@/features/conversation/utils/is-conversation-allowed" import { isConversationDm } from "@/features/conversation/utils/is-conversation-dm" import { isTmpConversation } from "@/features/conversation/utils/tmp-conversation" -import { useDisappearingMessageSettings } from "@/features/disappearing-messages/disappearing-message-settings.query" +import { + invalidateDisappearingMessageSettings, + useDisappearingMessageSettings, +} from "@/features/disappearing-messages/disappearing-message-settings.query" import { refetchGroupQuery } from "@/features/groups/queries/group.query" import { IXmtpMessageId } from "@/features/xmtp/xmtp.types" import { useEffectOnce } from "@/hooks/use-effect-once" @@ -376,6 +379,12 @@ function useHandleSrolling(props: { isRefreshingRef.current = true + invalidateDisappearingMessageSettings({ + clientInboxId: currentSender.inboxId, + conversationId: xmtpConversationId, + caller: "Conversation Messages refetch on scroll past bottom", + }).catch(captureError) + logger.debug("Refetching newest messages because we're scrolled past the bottom...") refetchConversationMessagesInfiniteQuery({ clientInboxId: currentSender.inboxId, diff --git a/features/current-user/current-user.query.ts b/features/current-user/current-user.query.ts index c2ce0751..af8ad7dd 100644 --- a/features/current-user/current-user.query.ts +++ b/features/current-user/current-user.query.ts @@ -2,6 +2,7 @@ import { queryOptions, useQuery } from "@tanstack/react-query" import { useAuthenticationStore } from "@/features/authentication/authentication.store" import { IConvosCurrentUser } from "@/features/current-user/current-user.types" import { reactQueryClient } from "@/utils/react-query/react-query.client" +import { ensureQueryDataBetter } from "@/utils/react-query/react-query.helpers" import { getReactQueryKey } from "@/utils/react-query/react-query.utils" import { fetchCurrentUser } from "./current-user.api" @@ -15,7 +16,9 @@ export function getCurrentUserQueryOptions(args: { caller?: string }) { queryKey: getReactQueryKey({ baseStr: "current-user", }), - queryFn: () => fetchCurrentUser(), + queryFn: () => { + return fetchCurrentUser() + }, }) } @@ -44,5 +47,5 @@ export function getCurrentUserQueryData() { export function ensureCurrentUserQueryData(args: { caller: string }) { const { caller } = args - return reactQueryClient.ensureQueryData(getCurrentUserQueryOptions({ caller })) + return ensureQueryDataBetter(getCurrentUserQueryOptions({ caller })) } diff --git a/features/disappearing-messages/disappearing-messages-header-action.tsx b/features/disappearing-messages/disappearing-messages-header-action.tsx index a07b91ad..00c3df60 100644 --- a/features/disappearing-messages/disappearing-messages-header-action.tsx +++ b/features/disappearing-messages/disappearing-messages-header-action.tsx @@ -1,11 +1,13 @@ import React, { useCallback, useMemo } from "react" -import { ViewStyle } from "react-native" +import { Alert, ViewStyle } from "react-native" import { DropdownMenu, IDropdownMenuAction } from "@/design-system/dropdown-menu/dropdown-menu" import { HeaderAction } from "@/design-system/Header/HeaderAction" import { useSafeCurrentSender } from "@/features/authentication/multi-inbox.store" +import { refetchConversationMessagesInfiniteQuery } from "@/features/conversation/conversation-chat/conversation-messages.query" import { useClearDisappearingMessageSettings } from "@/features/disappearing-messages/clear-disappearing-message-settings.mutation" import { useDisappearingMessageSettings } from "@/features/disappearing-messages/disappearing-message-settings.query" import { + CLEAR_CHAT_RETENTION_DURATION_NS, DisappearingMessageDuration, IDisappearingMessageDuration, } from "@/features/disappearing-messages/disappearing-messages.constants" @@ -19,7 +21,7 @@ type IDisappearingMessageOptionId = | "header" | "off" | IDisappearingMessageDuration - | "clear" + | "clear_chat" | "how_it_works" type IDisappearingMessageOptionItem = Omit & { @@ -47,6 +49,10 @@ const DISAPPEARING_MESSAGE_OPTIONS: IDisappearingMessageOptionItem[] = [ title: "Off", }, ...createDurationOptions(), + { + id: "clear_chat", + title: "Clear Chat", + }, ] type DisappearingMessagesHeaderActionProps = { @@ -88,8 +94,73 @@ export const DisappearingMessagesHeaderAction = ({ return } - if (option.id === "clear") { - // TODO: Implement clear chat + if (option.id === "clear_chat") { + Alert.alert( + "Clear Chat for Everyone", + "This action is irreversible and will clear chat history for everyone. All participants will permanently lose access to the chat history.", + [ + { + text: "Cancel", + style: "cancel", + }, + { + text: "Clear", + style: "destructive", + onPress: async () => { + try { + // Save current settings if they exist + const previousRetentionDurationInNs = settings?.retentionDurationInNs + + // Step 1: Clear all existing messages by setting minimal retention duration (1ns) + // and a special flag that uses earliest possible timestamp in XMTP protocol + await updateSettingsMutateAsync({ + clientInboxId: currentSender.inboxId, + conversationId: xmtpConversationId, + retentionDurationInNs: CLEAR_CHAT_RETENTION_DURATION_NS, + clearChat: true, + }) + + // Step 2: Restore previous settings if they existed, otherwise turn disappearing messages off + // This ensures we keep user's preferences for new messages after clearing chat history + if (previousRetentionDurationInNs && previousRetentionDurationInNs > 0) { + await updateSettingsMutateAsync({ + clientInboxId: currentSender.inboxId, + conversationId: xmtpConversationId, + retentionDurationInNs: previousRetentionDurationInNs, + }) + } else { + await clearSettingsMutateAsync({ + clientInboxId: currentSender.inboxId, + conversationId: xmtpConversationId, + }) + } + + // Refresh the conversation messages to immediately show cleared state + await refetchConversationMessagesInfiniteQuery({ + clientInboxId: currentSender.inboxId, + xmtpConversationId, + caller: "ClearChatAction", + }) + + Alert.alert( + "Chat Cleared", + "Chat history has been cleared for everyone in the conversation", + ) + } catch (error) { + captureErrorWithToast( + new GenericError({ + error, + additionalMessage: "Failed to clear chat", + }), + { + message: "Failed to clear chat", + }, + ) + } + }, + }, + ], + ) return } @@ -124,6 +195,7 @@ export const DisappearingMessagesHeaderAction = ({ xmtpConversationId, updateSettingsMutateAsync, clearSettingsMutateAsync, + settings?.retentionDurationInNs, ], ) diff --git a/features/disappearing-messages/disappearing-messages.constants.ts b/features/disappearing-messages/disappearing-messages.constants.ts index f61e30c1..cd9950cb 100644 --- a/features/disappearing-messages/disappearing-messages.constants.ts +++ b/features/disappearing-messages/disappearing-messages.constants.ts @@ -1,6 +1,9 @@ import { getTodayNs } from "@/utils/date" import { TimeUtils } from "@/utils/time.utils" +// Retention duration in nanoseconds used when clearing a chat +export const CLEAR_CHAT_RETENTION_DURATION_NS = 1 + // Define the duration configurations export const DisappearingMessageDuration = { ONE_SECOND: { value: TimeUtils.seconds(1).toNanoseconds(), text: "1 second" }, @@ -31,7 +34,7 @@ export const defaultConversationDisappearingMessageSettings: IDisappearingMessag */ export function getFormattedDisappearingDuration(nanoseconds: number | undefined): string { if (!nanoseconds || nanoseconds === 0) { - return "off"; + return "off" } // First check if this matches one of our predefined durations diff --git a/features/disappearing-messages/update-disappearing-message-settings.mutation.ts b/features/disappearing-messages/update-disappearing-message-settings.mutation.ts index f26bd3e4..c3de9dbb 100644 --- a/features/disappearing-messages/update-disappearing-message-settings.mutation.ts +++ b/features/disappearing-messages/update-disappearing-message-settings.mutation.ts @@ -9,17 +9,18 @@ export const useUpdateDisappearingMessageSettings = () => { clientInboxId: IXmtpInboxId conversationId: IXmtpConversationId retentionDurationInNs: number + clearChat?: boolean }) => { - const { clientInboxId, conversationId, retentionDurationInNs } = args + const { clientInboxId, conversationId, retentionDurationInNs, clearChat } = args return updateXmtpDisappearingMessageSettings({ clientInboxId, conversationId, retentionDurationInNs, + clearChat, }) }, onSettled: (data, error, variables) => { - console.log("goo") invalidateDisappearingMessageSettings({ clientInboxId: variables.clientInboxId, conversationId: variables.conversationId, diff --git a/features/streams/stream-messages.ts b/features/streams/stream-messages.ts index b039e252..a4e3e054 100644 --- a/features/streams/stream-messages.ts +++ b/features/streams/stream-messages.ts @@ -4,7 +4,10 @@ import { isGroupUpdatedMessage, isReactionMessage, } from "@/features/conversation/conversation-chat/conversation-message/utils/conversation-message-assertions" -import { addMessagesToConversationMessagesInfiniteQueryData } from "@/features/conversation/conversation-chat/conversation-messages.query" +import { + refetchConversationMessagesInfiniteQuery, + addMessagesToConversationMessagesInfiniteQueryData +} from "@/features/conversation/conversation-chat/conversation-messages.query" import { invalidateDisappearingMessageSettings } from "@/features/disappearing-messages/disappearing-message-settings.query" import { IGroup } from "@/features/groups/group.types" import { @@ -23,6 +26,8 @@ import { IGroupUpdatedMetadataEntryFieldName, } from "../conversation/conversation-chat/conversation-message/conversation-message.types" import { convertXmtpMessageToConvosMessage } from "../conversation/conversation-chat/conversation-message/utils/convert-xmtp-message-to-convos-message" +import { MIN_RETENTION_DURATION_NS } from "@/features/disappearing-messages/disappearing-messages.constants" +import { streamLogger } from "@/utils/logger/logger" export async function startMessageStreaming(args: { clientInboxId: IXmtpInboxId }) { const { clientInboxId } = args @@ -160,6 +165,27 @@ function handleNewGroupUpdatedMessage(args: { conversationId: message.xmtpConversationId, caller: "handleNewGroupUpdatedMessage", }).catch(captureError) + + // Check if this is a clear chat operation - look for value of 1 + const clearChatField = disappearingMessageFields.find( + field => parseInt(field.newValue, 10) === MIN_RETENTION_DURATION_NS + ) + + // TODO: This code is only run when a new group update message is received while the app is running and streaming. + // TODO: We should also handle the case where a clear chat event happens while the app is not running (and streaming is stopped/resumed) + if (clearChatField) { + streamLogger.debug(`[STREAM] Clear chat event detected, refreshing messages`) + refetchConversationMessagesInfiniteQuery({ + clientInboxId: inboxId, + xmtpConversationId: message.xmtpConversationId, + caller: "ClearChatEvent", + }).catch(error => + captureError(new StreamError({ + error, + additionalMessage: "Failed to refresh messages after clear chat event", + })) + ) + } } const groupUpdateFields = message.content.metadataFieldsChanged.filter( diff --git a/features/xmtp/xmtp-disappearing-messages/xmtp-disappearing-messages.ts b/features/xmtp/xmtp-disappearing-messages/xmtp-disappearing-messages.ts index 7d13419a..d43e7598 100644 --- a/features/xmtp/xmtp-disappearing-messages/xmtp-disappearing-messages.ts +++ b/features/xmtp/xmtp-disappearing-messages/xmtp-disappearing-messages.ts @@ -10,6 +10,8 @@ import { IXmtpConversationId, IXmtpInboxId } from "@/features/xmtp/xmtp.types" import { getTodayNs } from "@/utils/date" import { XMTPError } from "@/utils/error" +const CLEAR_CHAT_TIMESTAMP = 1 + /** * Get the current disappearing message settings for a conversation */ @@ -69,8 +71,9 @@ export async function updateXmtpDisappearingMessageSettings(args: { clientInboxId: IXmtpInboxId conversationId: IXmtpConversationId retentionDurationInNs: number + clearChat?: boolean }) { - const { clientInboxId, conversationId, retentionDurationInNs } = args + const { clientInboxId, conversationId, retentionDurationInNs, clearChat } = args try { const client = await getXmtpClientByInboxId({ @@ -78,10 +81,14 @@ export async function updateXmtpDisappearingMessageSettings(args: { }) await wrapXmtpCallWithDuration("updateDisappearingMessageSettings", async () => { + const timestamp = clearChat + ? CLEAR_CHAT_TIMESTAMP // Use earliest possible timestamp (1) to clear all messages + : getTodayNs() + return updateDisappearingMessageSettings( client.installationId, conversationId, - getTodayNs(), + timestamp, retentionDurationInNs, ) }) diff --git a/stores/app-state-store/app-state-store.service.ts b/stores/app-state-store/app-state-store.service.ts index 56c30244..9ed67374 100644 --- a/stores/app-state-store/app-state-store.service.ts +++ b/stores/app-state-store/app-state-store.service.ts @@ -1,13 +1,10 @@ import { focusManager as reactQueryFocusManager } from "@tanstack/react-query" import { useEffect } from "react" import { AppStateStatus } from "react-native" +import { useAuthenticationStore } from "@/features/authentication/authentication.store" import { getAllSenders, getCurrentSender } from "@/features/authentication/multi-inbox.store" -import { - getAllowedConsentConversationsQueryData, - invalidateAllowedConsentConversationsQuery, -} from "@/features/conversation/conversation-list/conversations-allowed-consent.query" +import { invalidateAllowedConsentConversationsQuery } from "@/features/conversation/conversation-list/conversations-allowed-consent.query" import { invalidateUnknownConsentConversationsQuery } from "@/features/conversation/conversation-requests-list/conversations-unknown-consent.query" -import { invalidateConversationQuery } from "@/features/conversation/queries/conversation.query" import { fetchOrRefetchNotificationsPermissions } from "@/features/notifications/notifications-permissions.query" import { registerPushNotifications } from "@/features/notifications/notifications-register" import { startStreaming, stopStreaming } from "@/features/streams/streams" @@ -108,6 +105,14 @@ export function startListeningToAppStateStore() { const senders = getAllSenders() const currentSender = getCurrentSender() + const authStatus = useAuthenticationStore.getState().status + const isSignedIn = authStatus === "signedIn" + + // For now all actions requires to be signed in + if (!isSignedIn) { + return + } + if (isOpenFromClosed || isOpenFromBackground) { // Tell react query we're now on "window focused" state reactQueryFocusManager.setFocused(true) diff --git a/utils/react-query/react-query.helpers.ts b/utils/react-query/react-query.helpers.ts index 7f98d06a..b86de01c 100644 --- a/utils/react-query/react-query.helpers.ts +++ b/utils/react-query/react-query.helpers.ts @@ -96,7 +96,7 @@ export async function fetchWithoutDuplicatesQuery(args: QueryOptions): Pro * Prefetches a query if it's enabled */ export function prefetchReactQueryBetter(args: UseQueryOptions) { - if (!args.enabled) { + if ("enabled" in args && !args.enabled) { queryLogger.debug(`Skipping prefetch for ${args.queryKey} because it's disabled`) return } @@ -107,7 +107,7 @@ export function prefetchReactQueryBetter(args: UseQueryOptions) { * Refetches a query if it's enabled */ export function refetchReactQueryBetter(args: UseQueryOptions) { - if (!args.enabled) { + if ("enabled" in args && !args.enabled) { queryLogger.debug(`Skipping refetch for ${args.queryKey} because it's disabled`) return Promise.resolve() } @@ -115,10 +115,11 @@ export function refetchReactQueryBetter(args: UseQueryOptions) { } export function ensureQueryDataBetter(args: UseQueryOptions) { - if (!args.enabled) { - queryLogger.debug(`Skipping ensureQueryData for ${args.queryKey} because it's disabled`) + if ("enabled" in args && !args.enabled) { throw new ReactQueryError({ - error: new Error(`Can't call ensureQueryData because query ${args.queryKey} is disabled`), + error: new Error( + `Can't call ensureQueryData because query key "${args.queryKey}" is disabled`, + ), }) } return reactQueryClient.ensureQueryData(args) diff --git a/utils/react-query/react-query.utils.ts b/utils/react-query/react-query.utils.ts index d1682bb8..4b393875 100644 --- a/utils/react-query/react-query.utils.ts +++ b/utils/react-query/react-query.utils.ts @@ -2,15 +2,17 @@ import { queryLogger } from "@/utils/logger/logger" import { reactQueryClient } from "@/utils/react-query/react-query.client" import { reactQueryPersistingStorage } from "../storage/storages" -// Doing this because we onced added caller to the query key and we should never do that. -type DisallowedKey = "caller" - -export function getReactQueryKey>( - args: { baseStr: string } & { [K in Exclude]?: string }, +export function getReactQueryKey( + args: ArgType & + // We don't want people to pass in a "caller" key. + ("caller" extends keyof ArgType ? never : {}), ): string[] { - const { baseStr, ...rest } = args + const { baseStr, ...rest } = args as { baseStr: string; [key: string]: unknown } + + // Make sure caller isn't in the rest of the keys. const filteredEntries = Object.entries(rest).filter(([key]) => key !== "caller") - return [baseStr, ...filteredEntries.map(([key, value]) => `${key}: ${value}`)] + + return [baseStr, ...filteredEntries.map(([key, value]) => `${key}: ${String(value)}`)] } export function clearReacyQueryQueriesAndCache() {