From cf1857204971e7965dec378ec1e6ad12be8804ca Mon Sep 17 00:00:00 2001 From: Maxim Shishmarev Date: Thu, 17 Oct 2019 16:57:40 +1100 Subject: [PATCH] Adding new group members; establishing sessions between non-friends --- _locales/en/messages.json | 13 ++ js/background.js | 53 ++++-- js/models/messages.js | 64 ++++++- js/modules/signal.js | 4 + js/views/app_view.js | 4 + js/views/conversation_view.js | 4 + js/views/create_group_dialog_view.js | 68 +++++++- libtextsecure/message_receiver.js | 7 +- libtextsecure/sendmessage.js | 43 +++-- protos/SignalService.proto | 7 +- stylesheets/_mentions.scss | 10 +- .../conversation/ConversationHeader.tsx | 4 + .../conversation/CreateGroupDialog.tsx | 113 ++++++------ ts/components/conversation/MemberList.tsx | 4 +- .../conversation/UpdateGroupDialog.tsx | 163 ++++++++++++++++++ ts/state/ducks/conversations.ts | 1 + 16 files changed, 464 insertions(+), 98 deletions(-) create mode 100644 ts/components/conversation/UpdateGroupDialog.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index af01a85a9..18968b1af 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1910,6 +1910,13 @@ "description": "Button action that the user can click to copy their public keys" }, + + "updateGroup": { + "message": "Update Group", + "description": + "Button action that the user can click to rename the group or add a new member" + }, + "copiedPublicKey": { "message": "Copied public key", "description": "A toast message telling the user that the key was copied" @@ -1935,6 +1942,12 @@ "description": "Title for the dialog box used to create a new private group" }, + "updateGroupDialogTitle": { + "message": "Updating a Private Group Chat", + "description": + "Title for the dialog box used to update an existing private group" + }, + "showSeed": { "message": "Show seed", "description": diff --git a/js/background.js b/js/background.js index 9ccf8e555..ef707a912 100644 --- a/js/background.js +++ b/js/background.js @@ -682,6 +682,38 @@ } }); + window.doUpdateGroup = async (groupId, groupName, members) => { + const ourKey = textsecure.storage.user.getNumber(); + + const ev = new Event('message'); + ev.confirm = () => {}; + + ev.data = { + source: ourKey, + message: { + group: { + id: groupId, + type: textsecure.protobuf.GroupContext.Type.UPDATE, + name: groupName, + members, + avatar: null, // TODO + }, + }, + }; + + await onMessageReceived(ev); + + const avatar = ''; + const options = {}; + textsecure.messaging.updateGroup( + groupId, + groupName, + avatar, + members, + options + ); + }; + window.doCreateGroup = async (groupName, members) => { const keypair = await libsignal.KeyHelper.generateIdentityKeyPair(); const groupId = StringView.arrayBufferToHex(keypair.pubKey); @@ -696,6 +728,7 @@ members: [ourKey, ...members], active: true, expireTimer: 0, + avatar: '', }; ev.confirm = () => {}; @@ -707,21 +740,15 @@ 'group' ); + convo.updateGroup(ev.groupDetails); + + // Group conversations are automatically 'friends' + // so that we can skip the friend request logic convo.setFriendRequestStatus( window.friends.friendRequestStatusEnum.friends ); - convo.set({ active_at: Date.now() }); appView.openConversation(groupId, {}); - - // Tell all group participants about this group - textsecure.messaging.createGroup( - ev.groupDetails.members, - groupId, - ev.groupDetails.name, - {}, - {} - ); }; Whisper.events.on('createNewGroup', async () => { @@ -730,6 +757,12 @@ } }); + Whisper.events.on('updateGroup', async groupConvo => { + if (appView) { + appView.showUpdateGroupDialog(groupConvo); + } + }); + Whisper.events.on('deleteConversation', async conversation => { await conversation.destroyMessages(); await window.Signal.Data.removeConversation(conversation.id, { diff --git a/js/models/messages.js b/js/models/messages.js index c4eb0c138..347fbb340 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -1700,11 +1700,71 @@ const conversation = ConversationController.get(conversationId); - if (initialMessage.group) { - // TODO: call this only once! + if ( + initialMessage.group && + initialMessage.group.members && + initialMessage.group.type === GROUP_TYPES.UPDATE + ) { + // Note: this might be called more times than necessary conversation.setFriendRequestStatus( window.friends.friendRequestStatusEnum.friends ); + + // For every member, see if we need to establish a session: + initialMessage.group.members.forEach(memberPubKey => { + const haveSession = _.some( + textsecure.storage.protocol.sessions, + s => s.number === memberPubKey + ); + + const ourPubKey = textsecure.storage.user.getNumber(); + if (!haveSession && memberPubKey !== ourPubKey) { + ConversationController.getOrCreateAndWait( + memberPubKey, + 'private' + ).then(() => { + textsecure.messaging.sendMessageToNumber( + memberPubKey, + '(If you see this message, you must be using an out-of-date client)', + [], + undefined, + [], + Date.now(), + undefined, + undefined, + { messageType: 'friend-request', backgroundFriendReq: true } + ); + }); + } + }); + } + + const backgroundFrReq = + initialMessage.flags === + textsecure.protobuf.DataMessage.Flags.BACKGROUND_FRIEND_REQUEST; + + if (message.isFriendRequest() && backgroundFrReq) { + // Check if the contact is a member in one of our private groups: + const groupMember = + window + .getConversations() + .models.filter(c => c.get('members')) + .reduce((acc, x) => window.Lodash.concat(acc, x.get('members')), []) + .indexOf(source) !== -1; + + if (groupMember) { + window.log.info( + `Auto accepting a 'group' friend request for a known group member: ${groupMember}` + ); + + window.libloki.api.sendBackgroundMessage(message.get('source')); + + confirm(); + } + + // Wether or not we accepted the FR, we exit early so background friend requests + // cannot be used for establishing regular private conversations + return null; } return conversation.queueJob(async () => { diff --git a/js/modules/signal.js b/js/modules/signal.js index 4c1b36afb..a33373f10 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -47,6 +47,9 @@ const { MemberList } = require('../../ts/components/conversation/MemberList'); const { CreateGroupDialog, } = require('../../ts/components/conversation/CreateGroupDialog'); +const { + UpdateGroupDialog, +} = require('../../ts/components/conversation/UpdateGroupDialog'); const { MediaGallery, } = require('../../ts/components/conversation/media-gallery/MediaGallery'); @@ -223,6 +226,7 @@ exports.setup = (options = {}) => { MainHeader, MemberList, CreateGroupDialog, + UpdateGroupDialog, MediaGallery, Message, MessageBody, diff --git a/js/views/app_view.js b/js/views/app_view.js index 4fcf93aea..f40e1af1f 100644 --- a/js/views/app_view.js +++ b/js/views/app_view.js @@ -211,5 +211,9 @@ const dialog = new Whisper.CreateGroupDialogView(); this.el.append(dialog.el); }, + showUpdateGroupDialog(groupConvo) { + const dialog = new Whisper.UpdateGroupDialogView(groupConvo); + this.el.append(dialog.el); + }, }); })(); diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index d07490c79..3aca56024 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -268,6 +268,10 @@ onMoveToInbox: () => { this.model.setArchived(false); }, + + onUpdateGroup: () => { + window.Whisper.events.trigger('updateGroup', this.model); + }, }; }; this.titleView = new Whisper.ReactWrapperView({ diff --git a/js/views/create_group_dialog_view.js b/js/views/create_group_dialog_view.js index 26432eb96..f34d03844 100644 --- a/js/views/create_group_dialog_view.js +++ b/js/views/create_group_dialog_view.js @@ -7,17 +7,58 @@ window.Whisper = window.Whisper || {}; Whisper.CreateGroupDialogView = Whisper.View.extend({ - templateName: 'group-creation-template', className: 'loki-dialog modal', initialize() { this.titleText = i18n('createGroupDialogTitle'); this.okText = i18n('ok'); this.cancelText = i18n('cancel'); this.close = this.close.bind(this); + + const convos = window.getConversations().models; + + let allMembers = convos.filter(d => !!d); + allMembers = allMembers.filter(d => d.isFriend() && d.isPrivate()); + allMembers = _.uniq(allMembers, true, d => d.id); + + this.membersToShow = allMembers; + this.$el.focus(); this.render(); }, render() { + this.dialogView = new Whisper.ReactWrapperView({ + className: 'create-group-dialog', + Component: window.Signal.Components.CreateGroupDialog, + props: { + titleText: this.titleText, + okText: this.okText, + cancelText: this.cancelText, + friendList: this.membersToShow, + onClose: this.close, + }, + }); + + this.$el.append(this.dialogView.el); + return this; + }, + close() { + this.remove(); + }, + }); + + Whisper.UpdateGroupDialogView = Whisper.View.extend({ + templateName: 'group-creation-template', + className: 'loki-dialog modal', + initialize(groupConvo) { + this.groupName = groupConvo.get('name'); + + this.conversation = groupConvo; + this.titleText = `${i18n('updateGroupDialogTitle')}: ${this.groupName}`; + this.okText = i18n('ok'); + this.cancelText = i18n('cancel'); + this.close = this.close.bind(this); + this.onSubmit = this.onSubmit.bind(this); + const convos = getInboxCollection().models; let allMembers = convos.filter(d => !!d); @@ -25,21 +66,42 @@ allMembers = allMembers.filter(d => d.isPrivate()); allMembers = _.uniq(allMembers, true, d => d.id); + // only give members that are not already in the group + const existingMembers = groupConvo.get('members'); + this.membersToShow = allMembers.filter( + d => !_.some(existingMembers, x => x === d.id) + ); + + this.$el.focus(); + this.render(); + }, + render() { this.dialogView = new Whisper.ReactWrapperView({ className: 'create-group-dialog', - Component: window.Signal.Components.CreateGroupDialog, + Component: window.Signal.Components.UpdateGroupDialog, props: { titleText: this.titleText, + groupName: this.groupName, okText: this.okText, cancelText: this.cancelText, - friendList: allMembers, + friendList: this.membersToShow, onClose: this.close, + onSubmit: this.onSubmit, }, }); this.$el.append(this.dialogView.el); return this; }, + onSubmit(newGroupName, newMembers) { + const allMembers = window.Lodash.concat( + newMembers, + this.conversation.get('members') + ); + const groupId = this.conversation.get('id'); + + window.doUpdateGroup(groupId, newGroupName, allMembers); + }, close() { this.remove(); }, diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index ea2539c8c..b382255ce 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -1081,10 +1081,9 @@ MessageReceiver.prototype.extend({ let profile = null; if (message.profile) { profile = JSON.parse(message.profile.encodeJSON()); + // Update the conversation + await conversation.setLokiProfile(profile); } - - // Update the conversation - await conversation.setLokiProfile(profile); } if (friendRequest && isMe) { @@ -1542,6 +1541,8 @@ MessageReceiver.prototype.extend({ } else if (decrypted.flags & FLAGS.PROFILE_KEY_UPDATE) { decrypted.body = null; decrypted.attachments = []; + } else if (decrypted.flags & FLAGS.BACKGROUND_FRIEND_REQUEST) { + // do nothing } else if (decrypted.flags !== 0) { throw new Error('Unknown flags in message'); } diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index 00f47b552..dc69157f0 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -382,7 +382,23 @@ MessageSender.prototype = { ); numbers.forEach(number => { - this.queueJobForNumber(number, () => outgoing.sendToNumber(number)); + // Note: if we are sending a private group message, we make our best to + // ensure we have signal protocol sessions with every member, but if we + // fail, let's at least send messages to those members with which we do: + const haveSession = _.some( + textsecure.storage.protocol.sessions, + s => s.number === number + ); + + if ( + haveSession || + options.isPublic || + options.messageType === 'friend-request' + ) { + this.queueJobForNumber(number, () => outgoing.sendToNumber(number)); + } else { + window.log.error(`No session for number: ${number}`); + } }); }, @@ -854,6 +870,11 @@ MessageSender.prototype = { options ) { const profile = this.getOurProfile(); + + const flags = options.backgroundFriendReq + ? textsecure.protobuf.DataMessage.Flags.BACKGROUND_FRIEND_REQUEST + : undefined; + return this.sendMessage( { recipients: [number], @@ -866,6 +887,7 @@ MessageSender.prototype = { expireTimer, profileKey, profile, + flags, }, options ); @@ -1000,22 +1022,6 @@ MessageSender.prototype = { return this.sendMessage(attrs, options); }, - createGroup(targetNumbers, id, name, avatar, options) { - const proto = new textsecure.protobuf.DataMessage(); - proto.group = new textsecure.protobuf.GroupContext(); - proto.group.id = stringToArrayBuffer(id); - - proto.group.type = textsecure.protobuf.GroupContext.Type.UPDATE; - proto.group.members = targetNumbers; - proto.group.name = name; - - // TODO: Add adding attachmentPointer once we support avatars - // (see git history) - return this.sendGroupProto(targetNumbers, proto, Date.now(), options).then( - () => proto.group.id - ); - }, - updateGroup(groupId, name, avatar, targetNumbers, options) { const proto = new textsecure.protobuf.DataMessage(); proto.group = new textsecure.protobuf.GroupContext(); @@ -1027,6 +1033,8 @@ MessageSender.prototype = { return this.makeAttachmentPointer(avatar).then(attachment => { proto.group.avatar = attachment; + // TODO: re-enable this once we have attachments + proto.group.avatar = null; return this.sendGroupProto( targetNumbers, proto, @@ -1163,7 +1171,6 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) { this.resetSession = sender.resetSession.bind(sender); this.sendMessageToGroup = sender.sendMessageToGroup.bind(sender); this.sendTypingMessage = sender.sendTypingMessage.bind(sender); - this.createGroup = sender.createGroup.bind(sender); this.updateGroup = sender.updateGroup.bind(sender); this.addNumberToGroup = sender.addNumberToGroup.bind(sender); this.setGroupName = sender.setGroupName.bind(sender); diff --git a/protos/SignalService.proto b/protos/SignalService.proto index f9495b919..d18fa80b9 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -94,9 +94,10 @@ message CallMessage { message DataMessage { enum Flags { - END_SESSION = 1; - EXPIRATION_TIMER_UPDATE = 2; - PROFILE_KEY_UPDATE = 4; + END_SESSION = 1; + EXPIRATION_TIMER_UPDATE = 2; + PROFILE_KEY_UPDATE = 4; + BACKGROUND_FRIEND_REQUEST = 256; } message Quote { diff --git a/stylesheets/_mentions.scss b/stylesheets/_mentions.scss index 89f7220b8..d111a21ac 100644 --- a/stylesheets/_mentions.scss +++ b/stylesheets/_mentions.scss @@ -15,6 +15,14 @@ font-size: large; text-align: center; } + + .no-friends { + text-align: center; + } + + .hidden { + display: none; + } } .friend-selection-list { @@ -29,7 +37,7 @@ min-width: 20px; } - .hidden { + .invisible { visibility: hidden; } } diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index fc195218e..f97c42ffe 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -62,6 +62,8 @@ interface Props { onCopyPublicKey: () => void; + onUpdateGroup: () => void; + i18n: LocalizerType; } @@ -222,6 +224,7 @@ export class ConversationHeader extends React.Component { onDeleteMessages, onDeleteContact, onCopyPublicKey, + onUpdateGroup, } = this.props; return ( @@ -229,6 +232,7 @@ export class ConversationHeader extends React.Component { {this.renderPublicMenuItems()} {i18n('copyPublicKey')} {i18n('deleteMessages')} + {i18n('updateGroup')} {!isMe && isClosable ? ( !isPublic ? ( diff --git a/ts/components/conversation/CreateGroupDialog.tsx b/ts/components/conversation/CreateGroupDialog.tsx index d7837bd12..632d1c281 100644 --- a/ts/components/conversation/CreateGroupDialog.tsx +++ b/ts/components/conversation/CreateGroupDialog.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { MemberList, Contact } from './MemberList'; +import { Contact, MemberList } from './MemberList'; declare global { interface Window { @@ -12,13 +12,13 @@ interface Props { titleText: string; okText: string; cancelText: string; - friendList: any[]; + friendList: Array; i18n: any; onClose: any; } interface State { - friendList: Contact[]; + friendList: Array; groupName: string; } @@ -58,50 +58,14 @@ export class CreateGroupDialog extends React.Component { window.addEventListener('keyup', this.onKeyUp); } - private onKeyUp(event: any) { - switch (event.key) { - case 'Enter': - this.onClickOK(); - break; - case 'Esc': - case 'Escape': - this.closeDialog(); - break; - default: - break; - } - } - - private closeDialog() { - window.removeEventListener('keyup', this.onKeyUp); - - this.props.onClose(); - } - - private onMemberClicked(selected: any) { - this.setState(state => { - const updatedFriends = this.state.friendList.map(member => { - if (member.id === selected.id) { - return { ...member, checkmarked: !member.checkmarked }; - } else { - return member; - } - }); - - return { - ...state, - friendList: updatedFriends, - }; - }); - } - public onClickOK() { const members = this.state.friendList .filter(d => d.checkmarked) .map(d => d.id); if (!this.state.groupName.trim()) { - console.error('Group name cannot be empty!'); + // TODO: show error message + // console.error('Group name cannot be empty!'); return; } @@ -110,17 +74,6 @@ export class CreateGroupDialog extends React.Component { this.closeDialog(); } - private onGroupNameChanged(event: any) { - event.persist(); - - this.setState(state => { - return { - ...state, - groupName: event.target.value, - }; - }); - } - public render() { const titleText = this.props.titleText; const okText = this.props.okText; @@ -137,8 +90,9 @@ export class CreateGroupDialog extends React.Component { value={this.state.groupName} onChange={this.onGroupNameChanged} tabIndex={0} - required - autoFocus + required={true} + autoFocus={true} + aria-required={true} />
{ />
- -
); } + + private onGroupNameChanged(event: any) { + event.persist(); + + this.setState(state => { + return { + ...state, + groupName: event.target.value, + }; + }); + } + + private onKeyUp(event: any) { + switch (event.key) { + case 'Enter': + this.onClickOK(); + break; + case 'Esc': + case 'Escape': + this.closeDialog(); + break; + default: + } + } + + private closeDialog() { + window.removeEventListener('keyup', this.onKeyUp); + + this.props.onClose(); + } + + private onMemberClicked(selected: any) { + this.setState(state => { + const updatedFriends = this.state.friendList.map(member => { + if (member.id === selected.id) { + return { ...member, checkmarked: !member.checkmarked }; + } else { + return member; + } + }); + + return { + ...state, + friendList: updatedFriends, + }; + }); + } } diff --git a/ts/components/conversation/MemberList.tsx b/ts/components/conversation/MemberList.tsx index 8361fda4d..0639447ed 100644 --- a/ts/components/conversation/MemberList.tsx +++ b/ts/components/conversation/MemberList.tsx @@ -33,7 +33,7 @@ class MemberItem extends React.Component { const checkMarkClass = this.props.checkmarked ? 'check-mark' - : classNames('check-mark', 'hidden'); + : classNames('check-mark', 'invisible'); return (
{ } interface MemberListProps { - members: Contact[]; + members: Array; selected: any; onMemberClicked: any; i18n: any; diff --git a/ts/components/conversation/UpdateGroupDialog.tsx b/ts/components/conversation/UpdateGroupDialog.tsx new file mode 100644 index 000000000..df3158f42 --- /dev/null +++ b/ts/components/conversation/UpdateGroupDialog.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Contact, MemberList } from './MemberList'; + +interface Props { + titleText: string; + groupName: string; + okText: string; + cancelText: string; + friendList: Array; + i18n: any; + onSubmit: any; + onClose: any; +} + +interface State { + friendList: Array; + groupName: string; +} + +export class UpdateGroupDialog extends React.Component { + constructor(props: any) { + super(props); + + this.onMemberClicked = this.onMemberClicked.bind(this); + this.onClickOK = this.onClickOK.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + this.closeDialog = this.closeDialog.bind(this); + this.onGroupNameChanged = this.onGroupNameChanged.bind(this); + + let friends = this.props.friendList; + friends = friends.map(d => { + const lokiProfile = d.getLokiProfile(); + const name = lokiProfile ? lokiProfile.displayName : 'Anonymous'; + + return { + id: d.id, + authorPhoneNumber: d.id, + authorProfileName: name, + selected: false, + authorName: name, // different from ProfileName? + authorColor: d.getColor(), + checkmarked: false, + }; + }); + + this.state = { + friendList: friends, + groupName: this.props.groupName, + }; + + window.addEventListener('keyup', this.onKeyUp); + } + + public onClickOK() { + const members = this.state.friendList + .filter(d => d.checkmarked) + .map(d => d.id); + + if (!this.state.groupName.trim()) { + // TODO: show error message + // window.log.error('Group name cannot be empty!'); + return; + } + + this.props.onSubmit(this.state.groupName, members); + + this.closeDialog(); + } + + public render() { + const titleText = this.props.titleText; + const okText = this.props.okText; + const cancelText = this.props.cancelText; + + const noFriendsClasses = + this.state.friendList.length === 0 + ? 'no-friends' + : classNames('no-friends', 'hidden'); + + return ( +
+

{titleText}

+ +
+ +
+

(no friends to add)

+
+ + +
+
+ ); + } + + private onKeyUp(event: any) { + switch (event.key) { + case 'Enter': + this.onClickOK(); + break; + case 'Esc': + case 'Escape': + this.closeDialog(); + break; + default: + } + } + + private closeDialog() { + window.removeEventListener('keyup', this.onKeyUp); + + this.props.onClose(); + } + + private onMemberClicked(selected: any) { + this.setState(state => { + const updatedFriends = this.state.friendList.map(member => { + if (member.id === selected.id) { + return { ...member, checkmarked: !member.checkmarked }; + } else { + return member; + } + }); + + return { + ...state, + friendList: updatedFriends, + }; + }); + } + + private onGroupNameChanged(event: any) { + event.persist(); + + this.setState(state => { + return { + ...state, + groupName: event.target.value, + }; + }); + } +} diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 3a617f771..54706604a 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -235,6 +235,7 @@ function showArchivedConversations() { function createNewGroup() { // Not sure how much of this is necessary: trigger('createNewGroup'); + return { type: 'CREATE_NEW_GROUP', payload: null,