import { isEmpty, isNil, uniq } from 'lodash'; import { PubkeyType, WithGroupPubkey } from 'libsession_util_nodejs'; import AbortController from 'abort-controller'; import { ConversationNotificationSettingType, READ_MESSAGE_STATE, } from '../models/conversationAttributes'; import { CallManager, PromiseUtils, SyncUtils, ToastUtils, UserUtils } from '../session/utils'; import { SessionButtonColor } from '../components/basic/SessionButton'; import { getCallMediaPermissionsSettings } from '../components/settings/SessionSettings'; import { Data } from '../data/data'; import { SettingsKey } from '../data/settings-key'; import { ConversationTypeEnum } from '../models/types'; import { uploadFileToFsWithOnionV4 } from '../session/apis/file_server_api/FileServerApi'; import { OpenGroupUtils } from '../session/apis/open_group_api/utils'; import { getSwarmPollingInstance } from '../session/apis/snode_api'; import { ConvoHub } from '../session/conversations'; import { getSodiumRenderer } from '../session/crypto'; import { DecryptedAttachmentsManager } from '../session/crypto/DecryptedAttachmentsManager'; import { DisappearingMessageConversationModeType } from '../session/disappearing_messages/types'; import { PubKey } from '../session/types'; import { perfEnd, perfStart } from '../session/utils/Performance'; import { sleepFor, timeoutWithAbort } from '../session/utils/Promise'; import { ed25519Str, fromHexToArray, toHex } from '../session/utils/String'; import { SessionUtilContact } from '../session/utils/libsession/libsession_utils_contacts'; import { conversationReset, quoteMessage, resetConversationExternal, } from '../state/ducks/conversations'; import { changeNickNameModal, updateAddModeratorsModal, updateBanOrUnbanUserModal, updateBlockOrUnblockModal, updateConfirmModal, updateGroupMembersModal, updateGroupNameModal, updateInviteContactModal, updateRemoveModeratorsModal, } from '../state/ducks/modalDialog'; import { MIME } from '../types'; import { IMAGE_JPEG } from '../types/MIME'; import { processNewAttachment } from '../types/MessageAttachment'; import { urlToBlob } from '../types/attachments/VisualAttachment'; import { encryptProfile } from '../util/crypto/profileEncrypter'; import { ReleasedFeatures } from '../util/releaseFeature'; import { Storage, setLastProfileUpdateTimestamp } from '../util/storage'; import { UserGroupsWrapperActions } from '../webworker/workers/browser/libsession_worker_interface'; import { ConversationInteractionStatus, ConversationInteractionType } from './types'; import { BlockedNumberController } from '../util'; import { sendInviteResponseToGroup } from '../session/sending/group/GroupInviteResponse'; import { NetworkTime } from '../util/NetworkTime'; import { ClosedGroup } from '../session'; import { GroupUpdateMessageFactory } from '../session/messages/message_factory/group/groupUpdateMessageFactory'; import { MessageSender } from '../session/sending'; import { StoreGroupRequestFactory } from '../session/apis/snode_api/factories/StoreGroupRequestFactory'; import { DURATION } from '../session/constants'; import type { LocalizerComponentPropsObject } from '../localization/localeTools'; import { GroupInvite } from '../session/utils/job_runners/jobs/GroupInviteJob'; export async function copyPublicKeyByConvoId(convoId: string) { if (OpenGroupUtils.isOpenGroupV2(convoId)) { const fromWrapper = await UserGroupsWrapperActions.getCommunityByFullUrl(convoId); if (!fromWrapper) { window.log.warn('opengroup to copy was not found in the UserGroupsWrapper'); return; } if (fromWrapper.fullUrlWithPubkey) { window.clipboard.writeText(fromWrapper.fullUrlWithPubkey); ToastUtils.pushCopiedToClipBoard(); } } else { window.clipboard.writeText(convoId); } } export async function blockConvoById(conversationId: string) { window.inboxStore?.dispatch( updateBlockOrUnblockModal({ action: 'block', pubkeys: [conversationId], }) ); } export async function unblockConvoById(conversationId: string) { window.inboxStore?.dispatch( updateBlockOrUnblockModal({ action: 'unblock', pubkeys: [conversationId], }) ); } /** * Accept if needed the message request from this user. * Note: approvalMessageTimestamp is provided to be able to insert the "You've accepted the message request" at the right place. * When accepting a message request by sending a message, we need to make sure the "You've accepted the message request" is before the * message we are sending to the user. * */ export const handleAcceptConversationRequest = async ({ convoId, approvalMessageTimestamp, }: { convoId: string; approvalMessageTimestamp: number; }) => { const convo = ConvoHub.use().get(convoId); if (!convo || convo.isApproved() || (!convo.isPrivate() && !convo.isClosedGroupV2())) { window?.log?.info('Conversation is already approved or not private/03group'); return null; } const previousIsApproved = convo.isApproved(); const previousDidApprovedMe = convo.didApproveMe(); // Note: we don't mark as approvedMe = true, as we do not know if they did send us a message yet. await convo.setIsApproved(true, false); await convo.commit(); if (convo.isPrivate()) { // we only need the approval message (and sending a reply) when we are accepting a message request. i.e. someone sent us a message already and we didn't accept it yet. if (!previousIsApproved && previousDidApprovedMe) { await convo.addOutgoingApprovalMessage(approvalMessageTimestamp); await convo.sendMessageRequestResponse(); } return null; } if (PubKey.is03Pubkey(convoId)) { const found = await UserGroupsWrapperActions.getGroup(convoId); if (!found) { window.log.warn('cannot approve a non existing group in user group'); return null; } // this updates the wrapper and refresh the redux slice await UserGroupsWrapperActions.setGroup({ ...found, invitePending: false }); // nothing else to do (and especially not wait for first poll) when the convo was already approved if (previousIsApproved) { return null; } const pollAndSendResponsePromise = new Promise(resolve => { getSwarmPollingInstance().addGroupId(convoId, async () => { // we need to do a first poll to fetch the keys etc before we can send our invite response // this is pretty hacky, but also an admin seeing a message from that user in the group will mark it as not pending anymore await sleepFor(2000); if (!previousIsApproved) { await sendInviteResponseToGroup({ groupPk: convoId }); } window.log.info( `handleAcceptConversationRequest: first poll for group ${ed25519Str(convoId)} happened, we should have encryption keys now` ); return resolve(true); }); }); // try at most 10s for the keys, and everything to come before continuing processing. // Note: this is important as otherwise the polling just hangs when sending a message to a group (as the cb in addGroupId() is never called back) const timeout = 10000; try { await PromiseUtils.timeout(pollAndSendResponsePromise, timeout); } catch (e) { window.log.warn( `handleAcceptConversationRequest: waited ${timeout}ms for first poll of group ${ed25519Str(convoId)} to happen, but timed out with: ${e.message}` ); } } return null; }; export async function declineConversationWithoutConfirm({ alsoBlock, conversationId, currentlySelectedConvo, conversationIdOrigin, }: { conversationId: string; currentlySelectedConvo: string | undefined; alsoBlock: boolean; conversationIdOrigin: string | null; }) { const conversationToDecline = ConvoHub.use().get(conversationId); if ( !conversationToDecline || (!conversationToDecline.isPrivate() && !conversationToDecline.isClosedGroupV2()) ) { window?.log?.info('No conversation to decline.'); return; } window.log.debug( `declineConversationWithoutConfirm of ${ed25519Str(conversationId)}, alsoBlock:${alsoBlock}, conversationIdOrigin:${conversationIdOrigin ? ed25519Str(conversationIdOrigin) : ''}` ); // Note: declining a message request just hides it. await conversationToDecline.setHidden(false); if (conversationToDecline.isClosedGroupV2()) { // this can only be done for groupv2 convos await conversationToDecline.setOriginConversationID('', false); } // this will update the value in the wrapper if needed but not remove the entry if we want it gone. The remove is done below with removeContactFromWrapper await conversationToDecline.commit(); if (alsoBlock) { if (PubKey.is03Pubkey(conversationId)) { // Note: if we do want to block this convo, we actually want to block the person who invited us, not the 03 pubkey itself. // Also, we don't want to show the block/unblock modal in this case // (we are on the WithoutConfirm function) if (conversationIdOrigin && !PubKey.is03Pubkey(conversationIdOrigin)) { // restoring from seed we can be missing the conversationIdOrigin, so we wouldn't be able to block the person who invited us await BlockedNumberController.block(conversationIdOrigin); } } else { await BlockedNumberController.block(conversationId); } } // when removing a message request, without blocking it, we actually have no need to store the conversation in the wrapper. So just remove the entry if ( conversationToDecline.isPrivate() && !SessionUtilContact.isContactToStoreInWrapper(conversationToDecline) ) { await SessionUtilContact.removeContactFromWrapper(conversationToDecline.id); } if (PubKey.is03Pubkey(conversationId)) { // when deleting a 03 group message request, we also need to remove the conversation altogether await ConvoHub.use().deleteGroup(conversationId, { deleteAllMessagesOnSwarm: false, deletionType: 'doNotKeep', forceDestroyForAllMembers: false, fromSyncMessage: false, sendLeaveMessage: false, clearFetchedHashes: false, }); } if (currentlySelectedConvo && currentlySelectedConvo === conversationId) { window?.inboxStore?.dispatch(resetConversationExternal()); } } export const declineConversationWithConfirm = ({ conversationId, alsoBlock, currentlySelectedConvo, conversationIdOrigin, }: { conversationId: string; currentlySelectedConvo: string | undefined; syncToDevices: boolean; alsoBlock: boolean; conversationIdOrigin: string | null; }) => { const isGroupV2 = PubKey.is03Pubkey(conversationId); // restoring from seed we might not have the sender of that invite, so we need to take care of not having one (and not block) const originNameToBlock = alsoBlock && !!conversationIdOrigin ? ConvoHub.use().get(conversationIdOrigin)?.getNicknameOrRealUsernameOrPlaceholder() : null; const convoName = ConvoHub.use().get(conversationId)?.getNicknameOrRealUsernameOrPlaceholder(); const i18nMessage: LocalizerComponentPropsObject = isGroupV2 ? alsoBlock && originNameToBlock ? { token: 'blockDescription', args: { name: originNameToBlock } } // groupv2, and blocking by sender name : { token: 'groupInviteDelete' } // groupv2, and no info about the sender, falling back to delete only : alsoBlock ? { token: 'blockDescription', args: { name: convoName } } : { token: 'messageRequestsDelete' }; window?.inboxStore?.dispatch( updateConfirmModal({ okText: alsoBlock ? window.i18n('block') : window.i18n('delete'), cancelText: window.i18n('cancel'), title: alsoBlock ? window.i18n('block') : window.i18n('delete'), i18nMessage, okTheme: SessionButtonColor.Danger, closeTheme: SessionButtonColor.Primary, onClickOk: async () => { await declineConversationWithoutConfirm({ conversationId, currentlySelectedConvo, alsoBlock, conversationIdOrigin, }); }, onClickCancel: () => { window?.inboxStore?.dispatch(updateConfirmModal(null)); }, onClickClose: () => { window?.inboxStore?.dispatch(updateConfirmModal(null)); }, }) ); }; export async function showUpdateGroupNameByConvoId(conversationId: string) { const conversation = ConvoHub.use().get(conversationId); if (conversation.isClosedGroup()) { // make sure all the members' convo exists so we can add or remove them await Promise.all( conversation .getGroupMembers() .map(m => ConvoHub.use().getOrCreateAndWait(m, ConversationTypeEnum.PRIVATE)) ); } window.inboxStore?.dispatch(updateGroupNameModal({ conversationId })); } export async function showUpdateGroupMembersByConvoId(conversationId: string) { const conversation = ConvoHub.use().get(conversationId); if (conversation.isClosedGroup()) { // make sure all the members' convo exists so we can add or remove them await Promise.all( conversation .getGroupMembers() .map(m => ConvoHub.use().getOrCreateAndWait(m, ConversationTypeEnum.PRIVATE)) ); } window.inboxStore?.dispatch(updateGroupMembersModal({ conversationId })); } export function showDeletePrivateConversationByConvoId(conversationId: string) { const conversation = ConvoHub.use().get(conversationId); const isMe = conversation.isMe(); if (!conversation.isPrivate()) { throw new Error('showLeavePrivateConversationDialog() called with a non private convo.'); } const onClickClose = () => { window?.inboxStore?.dispatch(updateConfirmModal(null)); }; const onClickOk = async () => { try { // no network calls are made when we hide/delete a private chat, so no need to have a // ConversationInteractionType state onClickClose(); await ConvoHub.use().delete1o1(conversationId, { fromSyncMessage: false, justHidePrivate: true, keepMessages: isMe, }); await clearConversationInteractionState({ conversationId }); } catch (err) { window.log.warn(`showDeletePrivateConversationByConvoId error: ${err}`); } }; window?.inboxStore?.dispatch( updateConfirmModal({ title: isMe ? window.i18n('noteToSelfHide') : window.i18n('conversationsDelete'), i18nMessage: isMe ? { token: 'noteToSelfHideDescription' } : { token: 'conversationsDeleteDescription', args: { name: conversation.getNicknameOrRealUsernameOrPlaceholder(), }, }, onClickOk, okText: isMe ? window.i18n('hide') : window.i18n('delete'), okTheme: SessionButtonColor.Danger, onClickClose, conversationId, }) ); } async function leaveGroupOrCommunityByConvoId({ conversationId, sendLeaveMessage, isPublic, onClickClose, }: { conversationId: string; isPublic: boolean; sendLeaveMessage: boolean; onClickClose?: () => void; }) { try { if (onClickClose) { onClickClose(); } if (isPublic) { await ConvoHub.use().deleteCommunity(conversationId); return; } // for groups, we have a "leaving..." state that we don't need for communities. // that's because communities can be left always, whereas for groups we need to send a leave message (and so have some encryption keypairs) await updateConversationInteractionState({ conversationId, type: ConversationInteractionType.Leave, status: ConversationInteractionStatus.Start, }); if (PubKey.is05Pubkey(conversationId)) { await ConvoHub.use().deleteLegacyGroup(conversationId, { fromSyncMessage: false, sendLeaveMessage, }); } else if (PubKey.is03Pubkey(conversationId)) { await ConvoHub.use().deleteGroup(conversationId, { fromSyncMessage: false, sendLeaveMessage, deleteAllMessagesOnSwarm: false, deletionType: 'doNotKeep', forceDestroyForAllMembers: false, clearFetchedHashes: true, }); } await clearConversationInteractionState({ conversationId }); } catch (err) { window.log.warn(`showLeaveGroupByConvoId error: ${err}`); await saveConversationInteractionErrorAsMessage({ conversationId, interactionType: ConversationInteractionType.Leave, }); } } export async function showLeaveGroupByConvoId(conversationId: string, name: string | undefined) { const conversation = ConvoHub.use().get(conversationId); if (!conversation.isGroup()) { throw new Error('showLeaveGroupDialog() called with a non group convo.'); } const isClosedGroup = conversation.isClosedGroup() || false; const isPublic = conversation.isPublic() || false; const admins = conversation.getGroupAdmins(); const isAdmin = admins.includes(UserUtils.getOurPubKeyStrFromCache()); const showOnlyGroupAdminWarning = isClosedGroup && isAdmin; const weAreLastAdmin = (PubKey.is05Pubkey(conversationId) || PubKey.is03Pubkey(conversationId)) && isAdmin && admins.length === 1; // if this is a community, or we legacy group are not admin, we can just show a confirmation dialog const onClickClose = () => { window?.inboxStore?.dispatch(updateConfirmModal(null)); }; const onClickOk = async () => { await leaveGroupOrCommunityByConvoId({ conversationId, isPublic, sendLeaveMessage: !weAreLastAdmin, // we don't need to send a leave message when we are the last admin: the group is removed. onClickClose, }); }; if (showOnlyGroupAdminWarning) { // NOTE For legacy closed groups window?.inboxStore?.dispatch( updateConfirmModal({ title: window.i18n('groupLeave'), i18nMessage: { token: 'groupDeleteDescription', args: { group_name: name || window.i18n('unknown') }, }, onClickOk, okText: window.i18n('leave'), okTheme: SessionButtonColor.Danger, onClickClose, conversationId, }) ); return; } if (isPublic || (isClosedGroup && !isAdmin)) { window?.inboxStore?.dispatch( updateConfirmModal({ title: isPublic ? window.i18n('communityLeave') : window.i18n('groupLeave'), i18nMessage: { token: 'groupLeaveDescription', args: { group_name: name ?? '' } }, onClickOk, okText: window.i18n('leave'), okTheme: SessionButtonColor.Danger, onClickClose, conversationId, }) ); } } /** * Can be used to show a dialog asking confirmation about deleting a group. * Communities are explicitly forbidden. * This function won't attempt to send a leave message. Use `showLeaveGroupByConvoId` for that purpose */ export async function showDeleteGroupByConvoId(conversationId: string, name: string | undefined) { const conversation = ConvoHub.use().get(conversationId); const isPublic = conversation.isPublic(); if (!conversation.isGroup() || isPublic) { throw new Error('showDeleteGroupByConvoId() called with a non group convo.'); } const onClickClose = () => { window?.inboxStore?.dispatch(updateConfirmModal(null)); }; const onClickOk = async () => { await leaveGroupOrCommunityByConvoId({ conversationId, isPublic, // we check for isPublic above, and throw if it's true sendLeaveMessage: false, onClickClose, }); }; window?.inboxStore?.dispatch( updateConfirmModal({ title: window.i18n('groupDelete'), i18nMessage: { token: 'groupDeleteDescriptionMember', args: { group_name: name ?? '' } }, onClickOk, okText: window.i18n('delete'), okTheme: SessionButtonColor.Danger, onClickClose, conversationId, }) ); } export function showInviteContactByConvoId(conversationId: string) { window.inboxStore?.dispatch(updateInviteContactModal({ conversationId })); } export function showAddModeratorsByConvoId(conversationId: string) { window.inboxStore?.dispatch(updateAddModeratorsModal({ conversationId })); } export function showRemoveModeratorsByConvoId(conversationId: string) { window.inboxStore?.dispatch(updateRemoveModeratorsModal({ conversationId })); } export function showBanUserByConvoId(conversationId: string, pubkey?: string) { window.inboxStore?.dispatch( updateBanOrUnbanUserModal({ banType: 'ban', conversationId, pubkey }) ); } export function showUnbanUserByConvoId(conversationId: string, pubkey?: string) { window.inboxStore?.dispatch( updateBanOrUnbanUserModal({ banType: 'unban', conversationId, pubkey }) ); } export async function markAllReadByConvoId(conversationId: string) { const conversation = ConvoHub.use().get(conversationId); perfStart(`markAllReadByConvoId-${conversationId}`); await conversation?.markAllAsRead(); perfEnd(`markAllReadByConvoId-${conversationId}`, 'markAllReadByConvoId'); } export async function setNotificationForConvoId( conversationId: string, selected: ConversationNotificationSettingType ) { const conversation = ConvoHub.use().get(conversationId); const existingSettings = conversation.getNotificationsFor(); if (existingSettings !== selected) { conversation.set({ triggerNotificationsFor: selected }); await conversation.commit(); } } export async function clearNickNameByConvoId(conversationId: string) { const conversation = ConvoHub.use().get(conversationId); await conversation.setNickname(null, true); } export function showChangeNickNameByConvoId(conversationId: string) { window.inboxStore?.dispatch(changeNickNameModal({ conversationId })); } export async function deleteAllMessagesByConvoIdNoConfirmation(conversationId: string) { const conversation = ConvoHub.use().get(conversationId); await Data.removeAllMessagesInConversation(conversationId); // destroy message keeps the active timestamp set so the // conversation still appears on the conversation list but is empty conversation.set({ lastMessage: null, lastMessageInteractionType: null, lastMessageInteractionStatus: null, }); await conversation.commit(); window.inboxStore?.dispatch(conversationReset(conversationId)); } export function deleteAllMessagesByConvoIdWithConfirmation(conversationId: string) { const onClickClose = () => { window?.inboxStore?.dispatch(updateConfirmModal(null)); }; const onClickOk = async () => { await deleteAllMessagesByConvoIdNoConfirmation(conversationId); onClickClose(); }; window?.inboxStore?.dispatch( updateConfirmModal({ title: window.i18n('deleteMessage', { count: 2 }), // count of 2 to get the plural "Messages Deleted" i18nMessage: { token: 'deleteAfterGroupPR3DeleteMessagesConfirmation' }, onClickOk, okTheme: SessionButtonColor.Danger, onClickClose, }) ); } export async function setDisappearingMessagesByConvoId( conversationId: string, expirationMode: DisappearingMessageConversationModeType, seconds?: number ) { const conversation = ConvoHub.use().get(conversationId); const canSetDisappearing = !conversation.isOutgoingRequest() && !conversation.isIncomingRequest(); if (!canSetDisappearing) { return; } if (!expirationMode || expirationMode === 'off' || !seconds || seconds <= 0) { await conversation.updateExpireTimer({ providedDisappearingMode: 'off', providedExpireTimer: 0, fromSync: false, fromCurrentDevice: true, fromConfigMessage: false, messageHash: null, }); } else { await conversation.updateExpireTimer({ providedDisappearingMode: expirationMode, providedExpireTimer: seconds, fromSync: false, fromCurrentDevice: true, fromConfigMessage: false, messageHash: null, }); } } /** * This function can be used for reupload our avatar to the file server or upload a new avatar. * * If this is a reupload, the old profileKey is used, otherwise a new one is generated */ export async function uploadOurAvatar(newAvatarDecrypted?: ArrayBuffer) { const ourConvo = ConvoHub.use().get(UserUtils.getOurPubKeyStrFromCache()); if (!ourConvo) { window.log.warn('ourConvo not found... This is not a valid case'); return null; } let profileKey: Uint8Array | null; let decryptedAvatarData; if (newAvatarDecrypted) { // Encrypt with a new key every time profileKey = (await getSodiumRenderer()).randombytes_buf(32); decryptedAvatarData = newAvatarDecrypted; } else { // this is a reupload. no need to generate a new profileKey const ourConvoProfileKey = ConvoHub.use().get(UserUtils.getOurPubKeyStrFromCache())?.getProfileKey() || null; profileKey = ourConvoProfileKey ? fromHexToArray(ourConvoProfileKey) : null; if (!profileKey) { window.log.info('our profileKey not found. Not reuploading our avatar'); return null; } const currentAttachmentPath = ourConvo.getAvatarPath(); if (!currentAttachmentPath) { window.log.warn('No attachment currently set for our convo.. Nothing to do.'); return null; } const decryptedAvatarUrl = await DecryptedAttachmentsManager.getDecryptedMediaUrl( currentAttachmentPath, IMAGE_JPEG, true ); if (!decryptedAvatarUrl) { window.log.warn('Could not decrypt avatar stored locally..'); return null; } const blob = await urlToBlob(decryptedAvatarUrl); decryptedAvatarData = await blob.arrayBuffer(); } if (!decryptedAvatarData?.byteLength) { window.log.warn('Could not read content of avatar ...'); return null; } const encryptedData = await encryptProfile(decryptedAvatarData, profileKey); const avatarPointer = await uploadFileToFsWithOnionV4(encryptedData); if (!avatarPointer) { window.log.warn('failed to upload avatar to file server'); return null; } const { fileUrl, fileId } = avatarPointer; ourConvo.set('avatarPointer', fileUrl); // this encrypts and save the new avatar and returns a new attachment path const upgraded = await processNewAttachment({ isRaw: true, data: decryptedAvatarData, contentType: MIME.IMAGE_UNKNOWN, // contentType is mostly used to generate previews and screenshot. We do not care for those in this case. }); // Replace our temporary image with the attachment pointer from the server: ourConvo.set('avatarInProfile', undefined); const displayName = ourConvo.getRealSessionUsername(); // write the profileKey even if it did not change ourConvo.set({ profileKey: toHex(profileKey) }); // Replace our temporary image with the attachment pointer from the server: // this commits already await ourConvo.setSessionProfile({ avatarPath: upgraded.path, displayName, avatarImageId: fileId, }); const newTimestampReupload = Date.now(); await Storage.put(SettingsKey.lastAvatarUploadTimestamp, newTimestampReupload); if (newAvatarDecrypted) { await setLastProfileUpdateTimestamp(Date.now()); const userConfigLibsession = await ReleasedFeatures.checkIsUserConfigFeatureReleased(); if (!userConfigLibsession) { await SyncUtils.forceSyncConfigurationNowIfNeeded(true); } } else { window.log.info( `Reuploading avatar finished at ${newTimestampReupload}, newAttachmentPointer ${fileUrl}` ); } return { avatarPointer: ourConvo.getAvatarPointer(), profileKey: ourConvo.getProfileKey(), }; } /** * This function can be used for clearing our avatar. */ export async function clearOurAvatar(commit: boolean = true) { const ourConvo = ConvoHub.use().get(UserUtils.getOurPubKeyStrFromCache()); if (!ourConvo) { window.log.warn('ourConvo not found... This is not a valid case'); return; } // return early if no change are needed at all if ( isNil(ourConvo.get('avatarPointer')) && isNil(ourConvo.get('avatarInProfile')) && isNil(ourConvo.get('profileKey')) ) { return; } ourConvo.set('avatarPointer', undefined); ourConvo.set('avatarInProfile', undefined); ourConvo.set('profileKey', undefined); await setLastProfileUpdateTimestamp(Date.now()); if (commit) { await ourConvo.commit(); await SyncUtils.forceSyncConfigurationNowIfNeeded(true); } } export async function replyToMessage(messageId: string) { const quotedMessageModel = await Data.getMessageById(messageId); if (!quotedMessageModel) { window.log.warn('Failed to find message to reply to'); return false; } const conversationModel = ConvoHub.use().getOrThrow(quotedMessageModel.get('conversationId')); const quotedMessageProps = await conversationModel.makeQuote(quotedMessageModel); if (quotedMessageProps) { window.inboxStore?.dispatch(quoteMessage(quotedMessageProps)); } else { window.inboxStore?.dispatch(quoteMessage(undefined)); } return true; } export async function resendMessage(messageId: string) { const foundMessageModel = await Data.getMessageById(messageId); if (!foundMessageModel) { window.log.warn('Failed to find message to resend'); return false; } await foundMessageModel.retrySend(); return true; } /** * Check if what is pasted is a URL and prompt confirmation for a setting change * @param e paste event */ export async function showLinkSharingConfirmationModalDialog(e: any) { const pastedText = e.clipboardData.getData('text'); if (isURL(pastedText) && !window.getSettingValue(SettingsKey.settingsLinkPreview, false)) { const alreadyDisplayedPopup = (await Data.getItemById(SettingsKey.hasLinkPreviewPopupBeenDisplayed))?.value || false; if (!alreadyDisplayedPopup) { window.inboxStore?.dispatch( updateConfirmModal({ title: window.i18n('linkPreviewsEnable'), i18nMessage: { token: 'linkPreviewsFirstDescription' }, okTheme: SessionButtonColor.Danger, onClickOk: async () => { await window.setSettingValue(SettingsKey.settingsLinkPreview, true); }, onClickClose: async () => { await Storage.put(SettingsKey.hasLinkPreviewPopupBeenDisplayed, true); }, okText: window.i18n('enable'), }) ); } } } /** * * @param str String to evaluate * @returns boolean if the string is true or false */ function isURL(str: string) { const urlRegex = '^(?!mailto:)(?:(?:http|https|ftp)://)(?:\\S+(?::\\S*)?@)?(?:(?:(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}(?:\\.(?:[0-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))|(?:(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)(?:\\.(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))|localhost)(?::\\d{2,5})?(?:(/|\\?|#)[^\\s]*)?$'; const url = new RegExp(urlRegex, 'i'); return str.length < 2083 && url.test(str); } export async function callRecipient(pubkey: string, canCall: boolean) { const convo = ConvoHub.use().get(pubkey); if (!canCall) { ToastUtils.pushUnableToCall(); return; } if (!getCallMediaPermissionsSettings()) { ToastUtils.pushVideoCallPermissionNeeded(); return; } if (convo && convo.isPrivate() && !convo.isMe()) { await CallManager.USER_callRecipient(convo.id); } } /** * Updates the interaction state for a conversation. Remember to run clearConversationInteractionState() when the interaction is complete and we don't want to show it in the UI anymore. * @param conversationId id of the conversation we want to interact with * @param type the type of conversation interaction we are doing * @param status the status of that interaction */ export async function updateConversationInteractionState({ conversationId, type, status, }: { conversationId: string; type: ConversationInteractionType; status: ConversationInteractionStatus; }) { const convo = ConvoHub.use().get(conversationId); if ( convo && (type !== convo.get('lastMessageInteractionType') || status !== convo.get('lastMessageInteractionStatus')) ) { convo.set('lastMessageInteractionType', type); convo.set('lastMessageInteractionStatus', status); await convo.commit(); window.log.debug( `updateConversationInteractionState for ${conversationId} to ${type} ${status}` ); } } /** * Clears the interaction state for a conversation. We would use this when we don't need to show anything in the UI once an action is complete. * @param conversationId id of the conversation whose interaction we want to clear */ export async function clearConversationInteractionState({ conversationId, }: { conversationId: string; }) { const convo = ConvoHub.use().get(conversationId); if ( convo && (convo.get('lastMessageInteractionType') || convo.get('lastMessageInteractionStatus')) ) { convo.set('lastMessageInteractionType', undefined); convo.set('lastMessageInteractionStatus', undefined); await convo.commit(); window.log.debug(`clearConversationInteractionState for ${conversationId}`); } } async function saveConversationInteractionErrorAsMessage({ conversationId, interactionType, }: { conversationId: string; interactionType: ConversationInteractionType; }) { const conversation = ConvoHub.use().get(conversationId); if (!conversation) { return; } const interactionStatus = ConversationInteractionStatus.Error; await updateConversationInteractionState({ conversationId, type: interactionType, status: interactionStatus, }); // NOTE at this time we don't have visible control messages in communities if (conversation.isPublic()) { return; } // Add an error message to the database so we can view it in the message history await conversation?.addSingleIncomingMessage({ source: NetworkTime.now().toString(), sent_at: Date.now(), interactionNotification: { interactionType, interactionStatus, }, unread: READ_MESSAGE_STATE.read, expireTimer: 0, }); conversation.updateLastMessage(); } export async function promoteUsersInGroup({ groupPk, toPromote, }: { toPromote: Array } & WithGroupPubkey) { if (!toPromote.length) { window.log.debug('promoteUsersInGroup: no users to promote'); return; } const convo = ConvoHub.use().get(groupPk); if (!convo) { window.log.debug('promoteUsersInGroup: group convo not found'); return; } const groupInWrapper = await UserGroupsWrapperActions.getGroup(groupPk); if (!groupInWrapper || !groupInWrapper.secretKey || isEmpty(groupInWrapper.secretKey)) { window.log.debug('promoteUsersInGroup: groupInWrapper not found or no secretkey'); return; } // push one group change message where initial members are added to the group const membersHex = uniq(toPromote); const sentAt = NetworkTime.now(); const us = UserUtils.getOurPubKeyStrFromCache(); const msgModel = await ClosedGroup.addUpdateMessage({ diff: { type: 'promoted', promoted: membersHex }, expireUpdate: null, sender: us, sentAt, convo, markAlreadySent: false, // the store below will mark the message as sent with dbMsgIdentifier messageHash: null, }); const groupMemberChange = await GroupUpdateMessageFactory.getPromotedControlMessage({ adminSecretKey: groupInWrapper.secretKey, convo, groupPk, promoted: membersHex, createAtNetworkTimestamp: sentAt, dbMsgIdentifier: msgModel.id, }); if (!groupMemberChange) { window.log.warn('promoteUsersInGroup: failed to build group change'); throw new Error('promoteUsersInGroup: failed to build group change'); } const storeRequests = await StoreGroupRequestFactory.makeGroupMessageSubRequest( [groupMemberChange], groupInWrapper ); const controller = new AbortController(); const result = await timeoutWithAbort( MessageSender.sendEncryptedDataToSnode({ destination: groupPk, method: 'batch', sortedSubRequests: storeRequests, abortSignal: controller.signal, allow401s: false, }), 2 * DURATION.MINUTES, controller ); if (result?.[0].code !== 200) { window.log.warn('promoteUsersInGroup: failed to store change'); throw new Error('promoteUsersInGroup: failed to store change'); } for (let index = 0; index < membersHex.length; index++) { const member = membersHex[index]; // eslint-disable-next-line no-await-in-loop await GroupInvite.addJob({ groupPk, member, inviteAsAdmin: true, forceUnrevoke: true }); } }