import { isNil } from 'lodash'; import { ConversationNotificationSettingType, ConversationTypeEnum, } from '../models/conversationAttributes'; import { CallManager, 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 { uploadFileToFsWithOnionV4 } from '../session/apis/file_server_api/FileServerApi'; import { OpenGroupUtils } from '../session/apis/open_group_api/utils'; import { getConversationController } from '../session/conversations'; import { getSodiumRenderer } from '../session/crypto'; import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager'; import { DisappearingMessageConversationModeType } from '../session/disappearing_messages/types'; import { perfEnd, perfStart } from '../session/utils/Performance'; import { fromHexToArray, toHex } from '../session/utils/String'; import { ConfigurationSync } from '../session/utils/job_runners/jobs/ConfigurationSyncJob'; import { SessionUtilContact } from '../session/utils/libsession/libsession_utils_contacts'; import { forceSyncConfigurationNowIfNeeded } from '../session/utils/sync/syncUtils'; import { conversationReset, quoteMessage, resetConversationExternal, } from '../state/ducks/conversations'; import { adminLeaveClosedGroup, changeNickNameModal, updateAddModeratorsModal, updateBanOrUnbanUserModal, 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 { BlockedNumberController } from '../util/blockedNumberController'; 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'; 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) { const conversation = getConversationController().get(conversationId); if (!conversation.id || conversation.isPublic()) { return; } // I don't think we want to reset the approved fields when blocking a contact // if (conversation.isPrivate()) { // await conversation.setIsApproved(false); // } await BlockedNumberController.block(conversation.id); await conversation.commit(); ToastUtils.pushToastSuccess('blocked', window.i18n('blocked')); } export async function unblockConvoById(conversationId: string) { const conversation = getConversationController().get(conversationId); if (!conversation) { // we assume it's a block contact and not group. // this is to be able to unlock a contact we don't have a conversation with. await BlockedNumberController.unblockAll([conversationId]); ToastUtils.pushToastSuccess('unblocked', window.i18n('unblocked')); return; } if (!conversation.id || conversation.isPublic()) { return; } await BlockedNumberController.unblockAll([conversationId]); ToastUtils.pushToastSuccess('unblocked', window.i18n('unblocked')); await conversation.commit(); } /** * marks the conversation's approval fields, sends messageRequestResponse, syncs to linked devices */ export const approveConvoAndSendResponse = async ( conversationId: string, syncToDevices: boolean = true ) => { const convoToApprove = getConversationController().get(conversationId); if (!convoToApprove) { window?.log?.info('Conversation is already approved.'); return; } await convoToApprove.setIsApproved(true, false); await convoToApprove.commit(); await convoToApprove.sendMessageRequestResponse(); // Conversation was not approved before so a sync is needed if (syncToDevices) { await forceSyncConfigurationNowIfNeeded(); } }; export async function declineConversationWithoutConfirm({ blockContact, conversationId, currentlySelectedConvo, syncToDevices, }: { conversationId: string; currentlySelectedConvo: string | undefined; syncToDevices: boolean; blockContact: boolean; // if set to false, the contact will just be set to not approved }) { const conversationToDecline = getConversationController().get(conversationId); if (!conversationToDecline || !conversationToDecline.isPrivate()) { window?.log?.info('No conversation to decline.'); return; } // Note: do not set the active_at undefined as this would make that conversation not synced with the libsession wrapper await conversationToDecline.setIsApproved(false, false); await conversationToDecline.setDidApproveMe(false, 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 (blockContact) { await blockConvoById(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 (syncToDevices) { await forceSyncConfigurationNowIfNeeded(); } if (currentlySelectedConvo && currentlySelectedConvo === conversationId) { window?.inboxStore?.dispatch(resetConversationExternal()); } } export const declineConversationWithConfirm = ({ conversationId, syncToDevices, blockContact, currentlySelectedConvo, }: { conversationId: string; currentlySelectedConvo: string | undefined; syncToDevices: boolean; blockContact: boolean; // if set to false, the contact will just be set to not approved }) => { window?.inboxStore?.dispatch( updateConfirmModal({ okText: blockContact ? window.i18n('block') : window.i18n('decline'), cancelText: window.i18n('cancel'), message: window.i18n('declineRequestMessage'), onClickOk: async () => { await declineConversationWithoutConfirm({ conversationId, currentlySelectedConvo, blockContact, syncToDevices, }); }, onClickCancel: () => { window?.inboxStore?.dispatch(updateConfirmModal(null)); }, onClickClose: () => { window?.inboxStore?.dispatch(updateConfirmModal(null)); }, }) ); }; export async function showUpdateGroupNameByConvoId(conversationId: string) { const conversation = getConversationController().get(conversationId); if (conversation.isClosedGroup()) { // make sure all the members' convo exists so we can add or remove them await Promise.all( conversation .get('members') .map(m => getConversationController().getOrCreateAndWait(m, ConversationTypeEnum.PRIVATE)) ); } window.inboxStore?.dispatch(updateGroupNameModal({ conversationId })); } export async function showUpdateGroupMembersByConvoId(conversationId: string) { const conversation = getConversationController().get(conversationId); if (conversation.isClosedGroup()) { // make sure all the members' convo exists so we can add or remove them await Promise.all( conversation .get('members') .map(m => getConversationController().getOrCreateAndWait(m, ConversationTypeEnum.PRIVATE)) ); } window.inboxStore?.dispatch(updateGroupMembersModal({ conversationId })); } export function showLeaveGroupByConvoId(conversationId: string) { const conversation = getConversationController().get(conversationId); if (!conversation.isGroup()) { throw new Error('showLeaveGroupDialog() called with a non group convo.'); } const title = window.i18n('leaveGroup'); const message = window.i18n('leaveGroupConfirmation'); const isAdmin = (conversation.get('groupAdmins') || []).includes( UserUtils.getOurPubKeyStrFromCache() ); const isClosedGroup = conversation.isClosedGroup() || false; const isPublic = conversation.isPublic() || false; // if this is a community, or we legacy group are not admin, we can just show a confirmation dialog if (isPublic || (isClosedGroup && !isAdmin)) { const onClickClose = () => { window.inboxStore?.dispatch(updateConfirmModal(null)); }; window.inboxStore?.dispatch( updateConfirmModal({ title, message, onClickOk: async () => { if (isPublic) { await getConversationController().deleteCommunity(conversation.id, { fromSyncMessage: false, }); } else { await getConversationController().deleteClosedGroup(conversation.id, { fromSyncMessage: false, sendLeaveMessage: true, }); } onClickClose(); }, onClickClose, }) ); return; } window.inboxStore?.dispatch( adminLeaveClosedGroup({ 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 = getConversationController().get(conversationId); perfStart(`markAllReadByConvoId-${conversationId}`); await conversation?.markAllAsRead(); perfEnd(`markAllReadByConvoId-${conversationId}`, 'markAllReadByConvoId'); } export async function setNotificationForConvoId( conversationId: string, selected: ConversationNotificationSettingType ) { const conversation = getConversationController().get(conversationId); const existingSettings = conversation.get('triggerNotificationsFor'); if (existingSettings !== selected) { conversation.set({ triggerNotificationsFor: selected }); await conversation.commit(); } } export async function clearNickNameByConvoId(conversationId: string) { const conversation = getConversationController().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 = getConversationController().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, }); 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('deleteMessages'), message: window.i18n('deleteConversationConfirmation'), onClickOk, okTheme: SessionButtonColor.Danger, onClickClose, }) ); } export async function setDisappearingMessagesByConvoId( conversationId: string, expirationMode: DisappearingMessageConversationModeType, seconds?: number ) { const conversation = getConversationController().get(conversationId); const canSetDisappearing = !conversation.isOutgoingRequest() && !conversation.isIncomingRequest(); if (!canSetDisappearing) { ToastUtils.pushMustBeApproved(); return; } if (!expirationMode || expirationMode === 'off' || !seconds || seconds <= 0) { await conversation.updateExpireTimer({ providedDisappearingMode: 'off', providedExpireTimer: 0, }); } else { await conversation.updateExpireTimer({ providedDisappearingMode: expirationMode, providedExpireTimer: seconds, }); } } /** * This function can be used for reupload our avatar to the fileserver 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 = getConversationController().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 = getConversationController() .get(UserUtils.getOurPubKeyStrFromCache()) ?.get('profileKey') || 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 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 fileserver'); 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.get('displayNameInProfile'); // 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()); await ConfigurationSync.queueNewJobIfNeeded(); 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.get('avatarPointer'), profileKey: ourConvo.get('profileKey'), }; } /** * This function can be used for clearing our avatar. */ export async function clearOurAvatar(commit: boolean = true) { const ourConvo = getConversationController().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; } const conversationModel = getConversationController().getOrThrow( quotedMessageModel.get('conversationId') ); const quotedMessageProps = await conversationModel.makeQuote(quotedMessageModel); if (quotedMessageProps) { window.inboxStore?.dispatch(quoteMessage(quotedMessageProps)); } else { window.inboxStore?.dispatch(quoteMessage(undefined)); } } /** * 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({ shouldShowConfirm: !window.getSettingValue(SettingsKey.settingsLinkPreview) && !alreadyDisplayedPopup, title: window.i18n('linkPreviewsTitle'), message: window.i18n('linkPreviewsConfirmMessage'), okTheme: SessionButtonColor.Danger, onClickOk: async () => { await window.setSettingValue(SettingsKey.settingsLinkPreview, true); }, onClickClose: async () => { await Storage.put(SettingsKey.hasLinkPreviewPopupBeenDisplayed, true); }, }) ); } } } /** * * @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 = getConversationController().get(pubkey); if (!canCall) { ToastUtils.pushUnableToCall(); return; } if (!getCallMediaPermissionsSettings()) { ToastUtils.pushVideoCallPermissionNeeded(); return; } if (convo && convo.isPrivate() && !convo.isMe()) { await CallManager.USER_callRecipient(convo.id); } }