Adding new group members; establishing sessions between non-friends

pull/565/head
Maxim Shishmarev 6 years ago
parent 0d19b708f9
commit cf18572049

@ -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":

@ -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, {

@ -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 () => {

@ -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,

@ -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);
},
});
})();

@ -268,6 +268,10 @@
onMoveToInbox: () => {
this.model.setArchived(false);
},
onUpdateGroup: () => {
window.Whisper.events.trigger('updateGroup', this.model);
},
};
};
this.titleView = new Whisper.ReactWrapperView({

@ -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();
},

@ -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');
}

@ -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);

@ -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 {

@ -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;
}
}

@ -62,6 +62,8 @@ interface Props {
onCopyPublicKey: () => void;
onUpdateGroup: () => void;
i18n: LocalizerType;
}
@ -222,6 +224,7 @@ export class ConversationHeader extends React.Component<Props> {
onDeleteMessages,
onDeleteContact,
onCopyPublicKey,
onUpdateGroup,
} = this.props;
return (
@ -229,6 +232,7 @@ export class ConversationHeader extends React.Component<Props> {
{this.renderPublicMenuItems()}
<MenuItem onClick={onCopyPublicKey}>{i18n('copyPublicKey')}</MenuItem>
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem>
<MenuItem onClick={onUpdateGroup}>{i18n('updateGroup')}</MenuItem>
{!isMe && isClosable ? (
!isPublic ? (
<MenuItem onClick={onDeleteContact}>

@ -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<any>;
i18n: any;
onClose: any;
}
interface State {
friendList: Contact[];
friendList: Array<Contact>;
groupName: string;
}
@ -58,50 +58,14 @@ export class CreateGroupDialog extends React.Component<Props, State> {
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<Props, State> {
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<Props, State> {
value={this.state.groupName}
onChange={this.onGroupNameChanged}
tabIndex={0}
required
autoFocus
required={true}
autoFocus={true}
aria-required={true}
/>
<div className="friend-selection-list">
<MemberList
@ -149,14 +103,61 @@ export class CreateGroupDialog extends React.Component<Props, State> {
/>
</div>
<div className="buttons">
<button className="cancel" tabIndex={2} onClick={this.closeDialog}>
<button className="cancel" tabIndex={0} onClick={this.closeDialog}>
{cancelText}
</button>
<button className="ok" tabIndex={1} onClick={this.onClickOK}>
<button className="ok" tabIndex={0} onClick={this.onClickOK}>
{okText}
</button>
</div>
</div>
);
}
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,
};
});
}
}

@ -33,7 +33,7 @@ class MemberItem extends React.Component<MemberItemProps> {
const checkMarkClass = this.props.checkmarked
? 'check-mark'
: classNames('check-mark', 'hidden');
: classNames('check-mark', 'invisible');
return (
<div
@ -73,7 +73,7 @@ class MemberItem extends React.Component<MemberItemProps> {
}
interface MemberListProps {
members: Contact[];
members: Array<Contact>;
selected: any;
onMemberClicked: any;
i18n: any;

@ -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<any>;
i18n: any;
onSubmit: any;
onClose: any;
}
interface State {
friendList: Array<Contact>;
groupName: string;
}
export class UpdateGroupDialog extends React.Component<Props, State> {
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 (
<div className="content">
<p className="titleText">{titleText}</p>
<input
type="text"
id="group-name"
className="group-name"
placeholder="Group Name"
value={this.state.groupName}
onChange={this.onGroupNameChanged}
tabIndex={0}
required={true}
aria-required={true}
autoFocus={true}
/>
<div className="friend-selection-list">
<MemberList
members={this.state.friendList}
selected={{}}
i18n={this.props.i18n}
onMemberClicked={this.onMemberClicked}
/>
</div>
<p className={noFriendsClasses}>(no friends to add)</p>
<div className="buttons">
<button className="cancel" tabIndex={0} onClick={this.closeDialog}>
{cancelText}
</button>
<button className="ok" tabIndex={0} onClick={this.onClickOK}>
{okText}
</button>
</div>
</div>
);
}
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,
};
});
}
}

@ -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,

Loading…
Cancel
Save