diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 3b5a1fb78..31681bb6d 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -262,6 +262,7 @@ "blockUser": "Block", "unblockUser": "Unblock", "unblocked": "Unblocked", + "blocked": "Blocked", "blockedSettingsTitle": "Blocked contacts", "unbanUser": "Unban User", "unbanUserConfirm": "Are you sure you want to unban this user?", diff --git a/ts/components/EditProfileDialog.tsx b/ts/components/EditProfileDialog.tsx index 32120a42e..52997c890 100644 --- a/ts/components/EditProfileDialog.tsx +++ b/ts/components/EditProfileDialog.tsx @@ -19,6 +19,7 @@ import { ConversationController } from '../session/conversations'; import { SpacerLG, SpacerMD } from './basic/Text'; import autoBind from 'auto-bind'; import { editProfileModal } from '../state/ducks/modalDialog'; +import { uploadOurAvatar } from '../interactions/conversationInteractions'; interface State { profileName: string; @@ -52,41 +53,6 @@ export class EditProfileDialog extends React.Component<{}, State> { window.addEventListener('keyup', this.onKeyUp); } - public async componentDidMount() { - const ourNumber = window.storage.get('primaryDevicePubKey'); - const conversation = await ConversationController.getInstance().getOrCreateAndWait( - ourNumber, - ConversationTypeEnum.PRIVATE - ); - - const readFile = async (attachment: any) => - new Promise((resolve, reject) => { - const fileReader = new FileReader(); - fileReader.onload = (e: any) => { - const data = e.target.result; - resolve({ - ...attachment, - data, - size: data.byteLength, - }); - }; - fileReader.onerror = reject; - fileReader.onabort = reject; - fileReader.readAsArrayBuffer(attachment.file); - }); - - const avatarPath = conversation.getAvatarPath(); - const profile = conversation.getLokiProfile(); - const displayName = profile && profile.displayName; - - this.setState({ - ...this.state, - profileName: profile.profileName, - avatar: avatarPath || '', - setProfileName: profile.profileName, - }); - } - public render() { const i18n = window.i18n; @@ -179,9 +145,9 @@ export class EditProfileDialog extends React.Component<{}, State> { { - this.setState({ mode: 'qr' }); + this.setState(state => ({ ...state, mode: 'qr' })); }} /> @@ -192,16 +158,19 @@ export class EditProfileDialog extends React.Component<{}, State> { } private fireInputEvent() { - this.setState({ mode: 'edit' }, () => { - const el = this.inputEl.current; - if (el) { - el.click(); + this.setState( + state => ({ ...state, mode: 'edit' }), + () => { + const el = this.inputEl.current; + if (el) { + el.click(); + } } - }); + ); } private renderDefaultView() { - const name = this.state.setProfileName ? this.state.setProfileName : this.state.profileName; + const name = this.state.setProfileName || this.state.profileName; return ( <> {this.renderProfileHeader()} @@ -274,7 +243,6 @@ export class EditProfileDialog extends React.Component<{}, State> { private onNameEdited(event: any) { const newName = event.target.value.replace(window.displayNameRegex, ''); - this.setState(state => { return { ...state, @@ -350,8 +318,6 @@ export class EditProfileDialog extends React.Component<{}, State> { ConversationTypeEnum.PRIVATE ); - const url: any = null; - let profileKey: any = null; if (avatar) { const data = await AttachmentUtil.readFile({ file: avatar }); // Ensure that this file is either small enough or is resized to meet our @@ -375,67 +341,26 @@ export class EditProfileDialog extends React.Component<{}, State> { // others, which means we need to wait for the database response. // To avoid the wait, we create a temporary url for the local image // and use it until we the the response from the server - const tempUrl = window.URL.createObjectURL(avatar); - await conversation.setLokiProfile({ displayName: newName }); - conversation.set('avatar', tempUrl); - - // Encrypt with a new key every time - profileKey = window.libsignal.crypto.getRandomBytes(32); - const encryptedData = await window.textsecure.crypto.encryptProfile( - dataResized, - profileKey - ); + // const tempUrl = window.URL.createObjectURL(avatar); + // await conversation.setLokiProfile({ displayName: newName }); + // conversation.set('avatar', tempUrl); - throw new Error('uploadAvatarV1 to move to v2'); - - // const avatarPointer = await AttachmentUtils.uploadAvatarV1({ - // ...dataResized, - // data: encryptedData, - // size: encryptedData.byteLength, - // }); - - // url = avatarPointer ? avatarPointer.url : null; - // window.storage.put('profileKey', profileKey); - // conversation.set('avatarPointer', url); - - // const upgraded = await window.Signal.Migrations.processNewAttachment({ - // isRaw: true, - // data: data.data, - // url, - // }); - // newAvatarPath = upgraded.path; - // // Replace our temporary image with the attachment pointer from the server: - // conversation.set('avatar', null); - // await conversation.setLokiProfile({ - // displayName: newName, - // avatar: newAvatarPath, - // }); - - // await conversation.commit(); - // UserUtils.setLastProfileUpdateTimestamp(Date.now()); - // await SyncUtils.forceSyncConfigurationNowIfNeeded(true); + await uploadOurAvatar(dataResized); } catch (error) { window.log.error( 'showEditProfileDialog Error ensuring that image is properly sized:', error && error.stack ? error.stack : error ); } - } else { - // do not update the avatar if it did not change - await conversation.setLokiProfile({ - displayName: newName, - }); - // might be good to not trigger a sync if the name did not change - await conversation.commit(); - UserUtils.setLastProfileUpdateTimestamp(Date.now()); - await SyncUtils.forceSyncConfigurationNowIfNeeded(true); - } - - if (avatar) { - ConversationController.getInstance() - .getConversations() - .filter(convo => convo.isPublic()) - .forEach(convo => convo.trigger('ourAvatarChanged', { url, profileKey })); + return; } + // do not update the avatar if it did not change + await conversation.setLokiProfile({ + displayName: newName, + }); + // might be good to not trigger a sync if the name did not change + await conversation.commit(); + UserUtils.setLastProfileUpdateTimestamp(Date.now()); + await SyncUtils.forceSyncConfigurationNowIfNeeded(true); } } diff --git a/ts/components/OnionStatusDialog.tsx b/ts/components/OnionStatusDialog.tsx index 6cadfbb83..bee29d826 100644 --- a/ts/components/OnionStatusDialog.tsx +++ b/ts/components/OnionStatusDialog.tsx @@ -170,7 +170,7 @@ export const ActionPanelOnionStatusLight = (props: { ); }; -export const OnionPathModal = (props: OnionPathModalType) => { +export const OnionPathModal = () => { const onConfirm = () => { void shell.openExternal('https://getsession.org/faq/#onion-routing'); }; diff --git a/ts/components/session/ActionsPanel.tsx b/ts/components/session/ActionsPanel.tsx index 270762464..725cb8244 100644 --- a/ts/components/session/ActionsPanel.tsx +++ b/ts/components/session/ActionsPanel.tsx @@ -72,6 +72,7 @@ import { SessionNicknameDialog } from './SessionNicknameDialog'; import { editProfileModal, onionPathModal } from '../../state/ducks/modalDialog'; import { SessionSeedModal } from './SessionSeedModal'; import { AdminLeaveClosedGroupDialog } from '../conversation/AdminLeaveClosedGroupDialog'; +import { uploadOurAvatar } from '../../interactions/conversationInteractions'; // tslint:disable-next-line: no-import-side-effect no-submodule-imports @@ -239,72 +240,7 @@ const triggerAvatarReUploadIfNeeded = async () => { if (Date.now() - lastTimeStampAvatarUpload > DURATION.DAYS * 14) { window.log.info('Reuploading avatar...'); // reupload the avatar - const ourConvo = ConversationController.getInstance().get(UserUtils.getOurPubKeyStrFromCache()); - if (!ourConvo) { - window.log.warn('ourConvo not found... This is not a valid case'); - return; - } - const profileKey = window.textsecure.storage.get('profileKey'); - if (!profileKey) { - window.log.warn('our profileKey not found... This is not a valid case'); - return; - } - - const currentAttachmentPath = ourConvo.getAvatarPath(); - - if (!currentAttachmentPath) { - window.log.warn('No attachment currently set for our convo.. Nothing to do.'); - return; - } - - const decryptedAvatarUrl = await getDecryptedMediaUrl(currentAttachmentPath, IMAGE_JPEG); - - if (!decryptedAvatarUrl) { - window.log.warn('Could not decrypt avatar stored locally..'); - return; - } - const response = await fetch(decryptedAvatarUrl); - const blob = await response.blob(); - const decryptedAvatarData = await blob.arrayBuffer(); - - if (!decryptedAvatarData?.byteLength) { - window.log.warn('Could not read blob of avatar locally..'); - return; - } - - const encryptedData = await window.textsecure.crypto.encryptProfile( - decryptedAvatarData, - profileKey - ); - - const avatarPointer = await FSv2.uploadFileToFsV2(encryptedData); - let fileUrl; - if (!avatarPointer) { - window.log.warn('failed to reupload avatar to fsv2'); - return; - } - ({ fileUrl } = avatarPointer); - - ourConvo.set('avatarPointer', fileUrl); - - // this encrypts and save the new avatar and returns a new attachment path - const upgraded = await window.Signal.Migrations.processNewAttachment({ - isRaw: true, - data: decryptedAvatarData, - url: fileUrl, - }); - const newAvatarPath = upgraded.path; - // Replace our temporary image with the attachment pointer from the server: - ourConvo.set('avatar', null); - const existingHash = ourConvo.get('avatarHash'); - const displayName = ourConvo.get('profileName'); - // this commits already - await ourConvo.setLokiProfile({ avatar: newAvatarPath, displayName, avatarHash: existingHash }); - const newTimestampReupload = Date.now(); - await createOrUpdateItem({ id: lastAvatarUploadTimestamp, value: newTimestampReupload }); - window.log.info( - `Reuploading avatar finished at ${newTimestampReupload}, newAttachmentPointer ${fileUrl}` - ); + await uploadOurAvatar(); } }; diff --git a/ts/components/session/SessionSeedModal.tsx b/ts/components/session/SessionSeedModal.tsx index 0f4875a36..d6e32d0bc 100644 --- a/ts/components/session/SessionSeedModal.tsx +++ b/ts/components/session/SessionSeedModal.tsx @@ -111,6 +111,7 @@ class SessionSeedModalInner extends React.Component<{}, State> { const fgColor = '#1B1B1B'; const hexEncodedSeed = mn_decode(this.state.recoveryPhrase, 'english'); + const onClose = () => window.inboxStore?.dispatch(recoveryPhraseModal(null)); return ( <> @@ -132,6 +133,7 @@ class SessionSeedModalInner extends React.Component<{}, State> { this.copyRecoveryPhrase(this.state.recoveryPhrase); }} /> + ); diff --git a/ts/components/session/conversation/SessionRightPanel.tsx b/ts/components/session/conversation/SessionRightPanel.tsx index 84431d046..2f510191f 100644 --- a/ts/components/session/conversation/SessionRightPanel.tsx +++ b/ts/components/session/conversation/SessionRightPanel.tsx @@ -29,6 +29,7 @@ import { showInviteContactByConvoId, showLeaveGroupByConvoId, showRemoveModeratorsByConvoId, + showUpdateGroupMembersByConvoId, showUpdateGroupNameByConvoId, } from '../../../interactions/conversationInteractions'; @@ -326,7 +327,7 @@ class SessionRightPanel extends React.Component { className="group-settings-item" role="button" onClick={async () => { - await showUpdateGroupNameByConvoId(id); + await showUpdateGroupMembersByConvoId(id); }} > {window.i18n('groupMembers')} diff --git a/ts/components/session/menu/Menu.tsx b/ts/components/session/menu/Menu.tsx index 18e222924..09519cf9c 100644 --- a/ts/components/session/menu/Menu.tsx +++ b/ts/components/session/menu/Menu.tsx @@ -11,8 +11,6 @@ import { } from '../../../state/ducks/modalDialog'; import { ConversationController } from '../../../session/conversations'; import { UserUtils } from '../../../session/utils'; -import { AdminLeaveClosedGroupDialog } from '../../conversation/AdminLeaveClosedGroupDialog'; -import { useTheme } from 'styled-components'; import { blockConvoById, clearNickNameByConvoId, @@ -23,6 +21,7 @@ import { setNotificationForConvoId, showAddModeratorsByConvoId, showInviteContactByConvoId, + showLeaveGroupByConvoId, showRemoveModeratorsByConvoId, showUpdateGroupNameByConvoId, unblockConvoById, @@ -187,49 +186,17 @@ export function getLeaveGroupMenuItem( if ( showLeaveGroup(Boolean(isKickedFromGroup), Boolean(left), Boolean(isGroup), Boolean(isPublic)) ) { - const dispatch = useDispatch(); - const theme = useTheme(); - const conversation = ConversationController.getInstance().get(conversationId); - - const onClickClose = () => { - dispatch(updateConfirmModal(null)); - }; - - const openConfirmationModal = () => { - if (!conversation.isGroup()) { - throw new Error('showLeaveGroupDialog() called with a non group convo.'); - } - - const title = window.i18n('leaveGroup'); - const message = window.i18n('leaveGroupConfirmation'); - const ourPK = UserUtils.getOurPubKeyStrFromCache(); - const isAdmin = (conversation.get('groupAdmins') || []).includes(ourPK); - const isClosedGroup = conversation.get('is_medium_group') || false; - - // if this is not a closed group, or we are not admin, we can just show a confirmation dialog - if (!isClosedGroup || (isClosedGroup && !isAdmin)) { - dispatch( - updateConfirmModal({ - title, - message, - onClickOk: () => { - void conversation.leaveClosedGroup(); - onClickClose(); - }, - onClickClose, - }) - ); - } else { - dispatch( - adminLeaveClosedGroup({ - conversationId, - }) - ); - } - }; - - return {window.i18n('leaveGroup')}; + return ( + { + showLeaveGroupByConvoId(conversationId); + }} + > + {window.i18n('leaveGroup')} + + ); } + return null; } diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index 19bb60fb7..14e21d5f3 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -4,7 +4,7 @@ import { openGroupV2ConversationIdRegex, } from '../opengroup/utils/OpenGroupUtils'; import { getV2OpenGroupRoom } from '../data/opengroups'; -import { ToastUtils } from '../session/utils'; +import { SyncUtils, ToastUtils, UserUtils } from '../session/utils'; import { ConversationModel, ConversationNotificationSettingType, @@ -17,6 +17,7 @@ import _ from 'lodash'; import { ConversationController } from '../session/conversations'; import { BlockedNumberController } from '../util/blockedNumberController'; import { + adminLeaveClosedGroup, changeNickNameModal, updateAddModeratorsModal, updateConfirmModal, @@ -25,8 +26,16 @@ import { updateInviteContactModal, updateRemoveModeratorsModal, } from '../state/ducks/modalDialog'; -import { removeAllMessagesInConversation } from '../data/data'; +import { + createOrUpdateItem, + lastAvatarUploadTimestamp, + removeAllMessagesInConversation, +} from '../data/data'; import { conversationReset } from '../state/ducks/conversations'; +import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager'; +import { IMAGE_JPEG } from '../types/MIME'; +import { FSv2 } from '../fileserver'; +import { fromBase64ToArray, toHex } from '../session/utils/String'; export const getCompleteUrlForV2ConvoId = async (convoId: string) => { if (convoId.match(openGroupV2ConversationIdRegex)) { @@ -194,8 +203,40 @@ export async function showUpdateGroupMembersByConvoId(conversationId: string) { export function showLeaveGroupByConvoId(conversationId: string) { const conversation = ConversationController.getInstance().get(conversationId); - throw new Error('Audric TODO'); - window.Whisper.events.trigger('leaveClosedGroup', conversation); + + if (!conversation.isGroup()) { + throw new Error('showLeaveGroupDialog() called with a non group convo.'); + } + + const title = window.i18n('leaveGroup'); + const message = window.i18n('leaveGroupConfirmation'); + const ourPK = UserUtils.getOurPubKeyStrFromCache(); + const isAdmin = (conversation.get('groupAdmins') || []).includes(ourPK); + const isClosedGroup = conversation.get('is_medium_group') || false; + + // if this is not a closed group, or we are not admin, we can just show a confirmation dialog + if (!isClosedGroup || (isClosedGroup && !isAdmin)) { + const onClickClose = () => { + window.inboxStore?.dispatch(updateConfirmModal(null)); + }; + window.inboxStore?.dispatch( + updateConfirmModal({ + title, + message, + onClickOk: () => { + void conversation.leaveClosedGroup(); + onClickClose(); + }, + onClickClose, + }) + ); + } else { + window.inboxStore?.dispatch( + adminLeaveClosedGroup({ + conversationId, + }) + ); + } } export function showInviteContactByConvoId(conversationId: string) { window.inboxStore?.dispatch(updateInviteContactModal({ conversationId })); @@ -292,3 +333,98 @@ export async function setDisappearingMessagesByConvoId( await conversation.updateExpirationTimer(seconds); } } + +/** + * This function can be used for reupload our avatar to the fsv2 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 = ConversationController.getInstance().get(UserUtils.getOurPubKeyStrFromCache()); + if (!ourConvo) { + window.log.warn('ourConvo not found... This is not a valid case'); + return; + } + + let profileKey; + let decryptedAvatarData; + if (newAvatarDecrypted) { + // Encrypt with a new key every time + profileKey = window.libsignal.crypto.getRandomBytes(32); + decryptedAvatarData = newAvatarDecrypted; + } else { + // this is a reupload. no need to generate a new profileKey + profileKey = window.textsecure.storage.get('profileKey'); + if (!profileKey) { + window.log.warn('our profileKey not found'); + return; + } + const currentAttachmentPath = ourConvo.getAvatarPath(); + + if (!currentAttachmentPath) { + window.log.warn('No attachment currently set for our convo.. Nothing to do.'); + return; + } + + const decryptedAvatarUrl = await getDecryptedMediaUrl(currentAttachmentPath, IMAGE_JPEG); + + if (!decryptedAvatarUrl) { + window.log.warn('Could not decrypt avatar stored locally..'); + return; + } + const response = await fetch(decryptedAvatarUrl); + const blob = await response.blob(); + decryptedAvatarData = await blob.arrayBuffer(); + } + + if (!decryptedAvatarData?.byteLength) { + window.log.warn('Could not read content of avatar ...'); + return; + } + + const encryptedData = await window.textsecure.crypto.encryptProfile( + decryptedAvatarData, + profileKey + ); + + const avatarPointer = await FSv2.uploadFileToFsV2(encryptedData); + let fileUrl; + if (!avatarPointer) { + window.log.warn('failed to upload avatar to fsv2'); + return; + } + ({ fileUrl } = avatarPointer); + + ourConvo.set('avatarPointer', fileUrl); + + // this encrypts and save the new avatar and returns a new attachment path + const upgraded = await window.Signal.Migrations.processNewAttachment({ + isRaw: true, + data: decryptedAvatarData, + url: fileUrl, + }); + // Replace our temporary image with the attachment pointer from the server: + ourConvo.set('avatar', null); + const displayName = ourConvo.get('profileName'); + + // write the profileKey even if it did not change + window.storage.put('profileKey', profileKey); + ourConvo.set({ profileKey: toHex(profileKey) }); + // Replace our temporary image with the attachment pointer from the server: + // this commits already + await ourConvo.setLokiProfile({ + avatar: upgraded.path, + displayName, + }); + const newTimestampReupload = Date.now(); + await createOrUpdateItem({ id: lastAvatarUploadTimestamp, value: newTimestampReupload }); + + if (newAvatarDecrypted) { + UserUtils.setLastProfileUpdateTimestamp(Date.now()); + await SyncUtils.forceSyncConfigurationNowIfNeeded(true); + } else { + window.log.info( + `Reuploading avatar finished at ${newTimestampReupload}, newAttachmentPointer ${fileUrl}` + ); + } +} diff --git a/ts/receiver/closedGroups.ts b/ts/receiver/closedGroups.ts index 312045500..7bc5fd668 100644 --- a/ts/receiver/closedGroups.ts +++ b/ts/receiver/closedGroups.ts @@ -971,8 +971,6 @@ async function sendToGroupMembers( const inviteResults = await Promise.all(promises); const allInvitesSent = _.every(inviteResults, Boolean); - throw new Error('audric: TODEBUG'); - if (allInvitesSent) { // if (true) { if (isRetry) { diff --git a/ts/state/ducks/modalDialog.tsx b/ts/state/ducks/modalDialog.tsx index a7279afcf..9fba3bfa8 100644 --- a/ts/state/ducks/modalDialog.tsx +++ b/ts/state/ducks/modalDialog.tsx @@ -84,7 +84,7 @@ const ModalSlice = createSlice({ return { ...state, onionPathModal: action.payload }; }, recoveryPhraseModal(state, action: PayloadAction) { - return { ...state, onionPathModal: action.payload }; + return { ...state, recoveryPhraseModal: action.payload }; }, adminLeaveClosedGroup(state, action: PayloadAction) { return { ...state, adminLeaveClosedGroup: action.payload };