diff --git a/lib/components/debug-logs-context.js b/lib/components/debug-logs-context.js --- a/lib/components/debug-logs-context.js +++ b/lib/components/debug-logs-context.js @@ -130,6 +130,14 @@ +operation: 'uploadOneTimeKeys', +success: boolean, +resultDescription: string, + } + | { + +operation: 'resendPeerToPeerMessages', + +deviceID: string, + +newDeviceID?: ?string, + +messageCount: number, + +success: boolean, + +resultDescription: string, }; function useOlmDebugLogs(): (olmLog: OlmDebugLog) => mixed { diff --git a/lib/hooks/peer-list-hooks.js b/lib/hooks/peer-list-hooks.js --- a/lib/hooks/peer-list-hooks.js +++ b/lib/hooks/peer-list-hooks.js @@ -4,6 +4,7 @@ import * as React from 'react'; import { setPeerDeviceListsActionType } from '../actions/aux-user-actions.js'; +import { usePeerOlmSessionsCreatorContext } from '../components/peer-olm-session-creator-provider.react.js'; import { getAllPeerUserIDAndDeviceIDs, getPeersPrimaryDeviceIDs, @@ -11,6 +12,7 @@ import { IdentityClientContext } from '../shared/identity-client-context.js'; import { usePeerToPeerCommunication } from '../tunnelbroker/peer-to-peer-context.js'; import { useTunnelbroker } from '../tunnelbroker/tunnelbroker-context.js'; +import { useResendPeerToPeerMessages } from '../tunnelbroker/use-resend-peer-to-peer-messages.js'; import type { UsersRawDeviceLists, UsersDevicesPlatformDetails, @@ -300,10 +302,58 @@ }, [getAuthMetadata, identityClient]); } +function useResetRatchetState(): (userID: ?string) => Promise { + const { createOlmSessionsWithUser } = usePeerOlmSessionsCreatorContext(); + const resendPeerToPeerMessages = useResendPeerToPeerMessages(); + const getAndUpdateDeviceListsForUsers = useGetAndUpdateDeviceListsForUsers(); + + return React.useCallback( + async (userID: ?string) => { + if (!userID) { + return; + } + + const deviceLists = await getAndUpdateDeviceListsForUsers([userID]); + const deviceList = deviceLists?.[userID]; + const deviceIDs = deviceList?.devices; + + if (!deviceIDs) { + return; + } + + const sessionCreationPromises = deviceIDs.map(deviceID => + createOlmSessionsWithUser( + userID, + [ + { + deviceID, + sessionCreationOptions: { overwriteContentSession: true }, + }, + ], + 'session_reset', + ), + ); + + await Promise.all(sessionCreationPromises); + + const messageResetPromises = deviceIDs.map(deviceID => + resendPeerToPeerMessages(deviceID), + ); + await Promise.all(messageResetPromises); + }, + [ + createOlmSessionsWithUser, + getAndUpdateDeviceListsForUsers, + resendPeerToPeerMessages, + ], + ); +} + export { useGetDeviceListsForUsers, useBroadcastDeviceListUpdates, useGetAndUpdateDeviceListsForUsers, useBroadcastAccountDeletion, useCurrentIdentityUserState, + useResetRatchetState, }; diff --git a/lib/tunnelbroker/use-resend-peer-to-peer-messages.js b/lib/tunnelbroker/use-resend-peer-to-peer-messages.js --- a/lib/tunnelbroker/use-resend-peer-to-peer-messages.js +++ b/lib/tunnelbroker/use-resend-peer-to-peer-messages.js @@ -3,6 +3,7 @@ import * as React from 'react'; import { usePeerToPeerCommunication } from './peer-to-peer-context.js'; +import { useOlmDebugLogs } from '../components/debug-logs-context.js'; import type { PrimaryDeviceChange } from '../hooks/peer-list-hooks.js'; import { getConfig } from '../utils/config.js'; @@ -12,16 +13,39 @@ ) => Promise { const { sqliteAPI } = getConfig(); const { processOutboundMessages } = usePeerToPeerCommunication(); + const olmDebugLog = useOlmDebugLogs(); return React.useCallback( async (deviceID: string, newDeviceID?: ?string) => { - const messageIDs = await sqliteAPI.resetOutboundP2PMessagesForDevice( - deviceID, - newDeviceID, - ); - processOutboundMessages(messageIDs); + try { + const messageIDs = await sqliteAPI.resetOutboundP2PMessagesForDevice( + deviceID, + newDeviceID, + ); + + olmDebugLog({ + operation: 'resendPeerToPeerMessages', + deviceID, + newDeviceID, + messageCount: messageIDs.length, + success: true, + resultDescription: `Resent ${messageIDs.length} messages for device ${deviceID}`, + }); + + processOutboundMessages(messageIDs); + } catch (error) { + olmDebugLog({ + operation: 'resendPeerToPeerMessages', + deviceID, + newDeviceID, + messageCount: 0, + success: false, + resultDescription: `Failed to resent messages for device ${deviceID}: ${error.message}`, + }); + throw error; + } }, - [processOutboundMessages, sqliteAPI], + [processOutboundMessages, sqliteAPI, olmDebugLog], ); } diff --git a/native/profile/user-relationship-tooltip-modal.react.js b/native/profile/user-relationship-tooltip-modal.react.js --- a/native/profile/user-relationship-tooltip-modal.react.js +++ b/native/profile/user-relationship-tooltip-modal.react.js @@ -4,6 +4,7 @@ import { TouchableOpacity } from 'react-native'; import { updateRelationshipsActionTypes } from 'lib/actions/relationship-actions.js'; +import { useResetRatchetState } from 'lib/hooks/peer-list-hooks.js'; import { useUpdateRelationships } from 'lib/hooks/relationship-hooks.js'; import { stringForUser } from 'lib/shared/user-utils.js'; import type { RelativeUserInfo } from 'lib/types/user-types.js'; @@ -115,6 +116,12 @@ action: 'unblock', }); + const reset = useResetRatchetState(); + const resetRatchetStateAction = React.useCallback( + () => reset(route.params.relativeUserInfo.id), + [reset, route.params.relativeUserInfo.id], + ); + return ( <> + ); } diff --git a/native/user-profile/user-profile-menu-button.react.js b/native/user-profile/user-profile-menu-button.react.js --- a/native/user-profile/user-profile-menu-button.react.js +++ b/native/user-profile/user-profile-menu-button.react.js @@ -6,9 +6,11 @@ import { TouchableOpacity, View } from 'react-native'; import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js'; +import { useIsCurrentUserStaff } from 'lib/shared/staff-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from 'lib/types/relationship-types.js'; import type { UserInfo } from 'lib/types/user-types'; +import { isDev } from 'lib/utils/dev-utils.js'; import { userProfileMenuButtonHeight } from './user-profile-constants.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; @@ -43,6 +45,8 @@ const menuButtonRef = React.useRef>(); + const isCurrentUserStaff = useIsCurrentUserStaff(); + const visibleTooltipActionEntryIDs = React.useMemo(() => { const result = []; @@ -60,8 +64,12 @@ result.push('block'); } + if (isCurrentUserStaff || isDev) { + result.push('reset-ratchet'); + } + return result; - }, [otherUserInfo?.relationshipStatus]); + }, [isCurrentUserStaff, otherUserInfo?.relationshipStatus]); const onPressMenuButton = React.useCallback(() => { invariant( diff --git a/web/modals/user-profile/user-profile-menu.react.js b/web/modals/user-profile/user-profile-menu.react.js --- a/web/modals/user-profile/user-profile-menu.react.js +++ b/web/modals/user-profile/user-profile-menu.react.js @@ -1,13 +1,20 @@ // @flow -import { faUserMinus, faUserShield } from '@fortawesome/free-solid-svg-icons'; +import { + faEraser, + faUserMinus, + faUserShield, +} from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import * as React from 'react'; import SWMansionIcon from 'lib/components/swmansion-icon.react.js'; +import { useResetRatchetState } from 'lib/hooks/peer-list-hooks.js'; import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js'; +import { useIsCurrentUserStaff } from 'lib/shared/staff-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from 'lib/types/relationship-types.js'; +import { isDev } from 'lib/utils/dev-utils.js'; import MenuItem from '../../components/menu-item.react.js'; import Menu from '../../components/menu.react.js'; @@ -20,6 +27,8 @@ const unblockIcon = ; +const resetIcon = ; + type Props = { +threadInfo: ThreadInfo, }; @@ -27,6 +36,8 @@ function UserProfileMenu(props: Props): React.Node { const { threadInfo } = props; + const isCurrentUserStaff = useIsCurrentUserStaff(); + const { otherUserInfo, callbacks: { unfriendUser, blockUser, unblockUser }, @@ -69,6 +80,24 @@ [unblockUser], ); + const reset = useResetRatchetState(); + const resetRatchetStateAction = React.useCallback( + () => reset(otherUserInfo?.id), + [otherUserInfo?.id, reset], + ); + const resetRatchetState = React.useMemo( + () => ( + + ), + [resetRatchetStateAction], + ); + const menuItems = React.useMemo(() => { const items = []; if (otherUserInfo?.relationshipStatus === userRelationshipStatus.FRIEND) { @@ -85,10 +114,16 @@ items.push(blockMenuItem); } + if (isCurrentUserStaff || isDev) { + items.push(resetRatchetState); + } + return items; }, [ blockMenuItem, + isCurrentUserStaff, otherUserInfo?.relationshipStatus, + resetRatchetState, unblockMenuItem, unfriendMenuIcon, ]);