Creating private group chats between friends

This commit is contained in:
Maxim Shishmarev 2019-10-14 16:44:14 +11:00
parent 6c08852118
commit 0d19b708f9
16 changed files with 401 additions and 14 deletions

View file

@ -1929,6 +1929,12 @@
"description":
"Button action that the user can click to edit their display name"
},
"createGroupDialogTitle": {
"message": "Creating a Private Group Chat",
"description": "Title for the dialog box used to create a new private group"
},
"showSeed": {
"message": "Show seed",
"description":

View file

@ -725,6 +725,7 @@
<script type='text/javascript' src='js/views/app_view.js'></script>
<script type='text/javascript' src='js/views/import_view.js'></script>
<script type='text/javascript' src='js/views/clear_data_view.js'></script>
<script type='text/javascript' src='js/views/create_group_dialog_view.js'></script>
<script type='text/javascript' src='js/wall_clock_listener.js'></script>
<script type='text/javascript' src='js/rotate_signed_prekey_listener.js'></script>

View file

@ -8,6 +8,8 @@
storage,
textsecure,
Whisper,
libsignal,
StringView,
BlockedNumberController
*/
@ -680,6 +682,54 @@
}
});
window.doCreateGroup = async (groupName, members) => {
const keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
const groupId = StringView.arrayBufferToHex(keypair.pubKey);
const ev = new Event('group');
const ourKey = textsecure.storage.user.getNumber();
ev.groupDetails = {
id: groupId,
name: groupName,
members: [ourKey, ...members],
active: true,
expireTimer: 0,
};
ev.confirm = () => {};
await onGroupReceived(ev);
const convo = await ConversationController.getOrCreateAndWait(
groupId,
'group'
);
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 () => {
if (appView) {
appView.showCreateGroup();
}
});
Whisper.events.on('deleteConversation', async conversation => {
await conversation.destroyMessages();
await window.Signal.Data.removeConversation(conversation.id, {
@ -916,6 +966,7 @@
messageReceiver.addEventListener('delivery', onDeliveryReceipt);
messageReceiver.addEventListener('contact', onContactReceived);
messageReceiver.addEventListener('group', onGroupReceived);
window.addEventListener('group', onGroupReceived);
messageReceiver.addEventListener('sent', onSentMessage);
messageReceiver.addEventListener('readSync', onReadSync);
messageReceiver.addEventListener('read', onReadReceipt);

View file

@ -1699,6 +1699,14 @@
const GROUP_TYPES = textsecure.protobuf.GroupContext.Type;
const conversation = ConversationController.get(conversationId);
if (initialMessage.group) {
// TODO: call this only once!
conversation.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
}
return conversation.queueJob(async () => {
window.log.info(
`Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversation.idForLogging()}`

View file

@ -44,6 +44,9 @@ const { Lightbox } = require('../../ts/components/Lightbox');
const { LightboxGallery } = require('../../ts/components/LightboxGallery');
const { MainHeader } = require('../../ts/components/MainHeader');
const { MemberList } = require('../../ts/components/conversation/MemberList');
const {
CreateGroupDialog,
} = require('../../ts/components/conversation/CreateGroupDialog');
const {
MediaGallery,
} = require('../../ts/components/conversation/media-gallery/MediaGallery');
@ -219,6 +222,7 @@ exports.setup = (options = {}) => {
LightboxGallery,
MainHeader,
MemberList,
CreateGroupDialog,
MediaGallery,
Message,
MessageBody,

View file

@ -204,5 +204,12 @@
const dialog = new Whisper.AddServerDialogView();
this.el.append(dialog.el);
},
showCreateGroup() {
// TODO: make it impossible to open 2 dialogs as once
// Curretnly, if the button is in focus, it is possible to
// create a new dialog by pressing 'Enter'
const dialog = new Whisper.CreateGroupDialogView();
this.el.append(dialog.el);
},
});
})();

View file

@ -0,0 +1,47 @@
/* global Whisper, i18n, getInboxCollection _ */
// eslint-disable-next-line func-names
(function() {
'use strict';
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);
this.$el.focus();
this.render();
},
render() {
const convos = getInboxCollection().models;
let allMembers = convos.filter(d => !!d);
allMembers = allMembers.filter(d => d.isFriend());
allMembers = allMembers.filter(d => d.isPrivate());
allMembers = _.uniq(allMembers, true, d => d.id);
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: allMembers,
onClose: this.close,
},
});
this.$el.append(this.dialogView.el);
return this;
},
close() {
this.remove();
},
});
})();

View file

@ -293,6 +293,8 @@
},
async openConversation(id, messageId) {
const conversationExists = await ConversationController.get(id);
// why does this have to be 'private'???
const conversation = await ConversationController.getOrCreateAndWait(
id,
'private'

View file

@ -1009,15 +1009,11 @@ MessageSender.prototype = {
proto.group.members = targetNumbers;
proto.group.name = name;
return this.makeAttachmentPointer(avatar).then(attachment => {
proto.group.avatar = attachment;
return this.sendGroupProto(
targetNumbers,
proto,
Date.now(),
options
).then(() => proto.group.id);
});
// 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) {

View file

@ -1,3 +1,47 @@
.create-group-dialog {
.content {
max-width: 100% !important;
}
.buttons {
margin: 8px;
}
.group-name {
font-size: larger;
}
.titleText {
font-size: large;
text-align: center;
}
}
.friend-selection-list {
max-height: 240px;
overflow-y: scroll;
.check-mark {
float: right;
text-align: center;
color: darkslategrey;
margin: 4px;
min-width: 20px;
}
.hidden {
visibility: hidden;
}
}
.dark-theme {
.friend-selection-list {
.check-mark {
color: rgb(230, 230, 230);
}
}
}
.member-list-container {
margin: 0;
padding: 0;
@ -5,6 +49,13 @@
max-height: 240px;
overflow-y: scroll;
.check-mark {
display: none;
}
}
.member-list-container,
.create-group-dialog {
.member-item {
padding: 4px;
user-select: none;
@ -55,7 +106,8 @@
}
.dark-theme {
.member-list-container {
.member-list-container,
.create-group-dialog {
.member-item {
&:hover:not(.member-selected) {
background-color: $color-dark-55;

View file

@ -8,6 +8,17 @@
overflow-x: hidden;
}
.create-group-button {
background-color: #383c46;
color: #ffffff;
margin: 4px;
padding: 4px;
}
.create-group-button:focus {
outline: 0;
}
.module-contact-name span {
text-overflow: ellipsis;
overflow-x: hidden;

View file

@ -574,6 +574,7 @@
<script type='text/javascript' src='../js/views/scroll_down_button_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/banner_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/clear_data_view.js'></script>
<script type='text/javascript' src='../js/views/create_group_dialog_view.js'></script>
<script type='text/javascript' src='../js/views/beta_release_disclaimer_view.js'></script>

View file

@ -26,6 +26,9 @@ export interface Props {
query: string,
options: { regionCode: string }
) => void;
createNewGroup: () => void;
openConversationInternal: (id: string, messageId?: string) => void;
showArchivedConversations: () => void;
showInbox: () => void;
@ -262,6 +265,12 @@ export class LeftPane extends React.Component<Props, any> {
<div className="module-left-pane__header">
{showArchived ? this.renderArchivedHeader() : renderMainHeader()}
</div>
<input
className="create-group-button"
type="button"
value="Create Group"
onClick={this.props.createNewGroup}
/>
{this.renderList()}
</div>
);

View file

@ -0,0 +1,162 @@
import React from 'react';
import { MemberList, Contact } from './MemberList';
declare global {
interface Window {
Lodash: any;
doCreateGroup: any;
}
}
interface Props {
titleText: string;
okText: string;
cancelText: string;
friendList: any[];
i18n: any;
onClose: any;
}
interface State {
friendList: Contact[];
groupName: string;
}
export class CreateGroupDialog extends React.Component<Props, State> {
constructor(props: any) {
super(props);
// const _ = window.Lodash;
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: '',
};
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!');
return;
}
window.doCreateGroup(this.state.groupName, members);
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;
const cancelText = this.props.cancelText;
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
autoFocus
/>
<div className="friend-selection-list">
<MemberList
members={this.state.friendList}
selected={{}}
i18n={this.props.i18n}
onMemberClicked={this.onMemberClicked}
/>
</div>
<div className="buttons">
<button className="cancel" tabIndex={2} onClick={this.closeDialog}>
{cancelText}
</button>
<button className="ok" tabIndex={1} onClick={this.onClickOK}>
{okText}
</button>
</div>
</div>
);
}
}

View file

@ -2,10 +2,22 @@ import React from 'react';
import classNames from 'classnames';
import { Avatar } from '../Avatar';
export interface Contact {
id: string;
selected: boolean;
authorProfileName: string;
authorPhoneNumber: string;
authorName: string;
authorColor: any;
authorAvatarPath: string;
checkmarked: boolean;
}
interface MemberItemProps {
member: any;
selected: Boolean;
member: Contact;
selected: boolean;
onClicked: any;
i18n: any;
checkmarked: boolean;
}
class MemberItem extends React.Component<MemberItemProps> {
@ -19,6 +31,10 @@ class MemberItem extends React.Component<MemberItemProps> {
const pubkey = this.props.member.authorPhoneNumber;
const selected = this.props.selected;
const checkMarkClass = this.props.checkmarked
? 'check-mark'
: classNames('check-mark', 'hidden');
return (
<div
role="button"
@ -31,6 +47,7 @@ class MemberItem extends React.Component<MemberItemProps> {
{this.renderAvatar()}
<span className="name-part">{name}</span>
<span className="pubkey-part">{pubkey}</span>
<span className={checkMarkClass}></span>
</div>
);
}
@ -45,7 +62,7 @@ class MemberItem extends React.Component<MemberItemProps> {
avatarPath={this.props.member.authorAvatarPath}
color={this.props.member.authorColor}
conversationType="direct"
i18n={this.props.member.i18n}
i18n={this.props.i18n}
name={this.props.member.authorName}
phoneNumber={this.props.member.authorPhoneNumber}
profileName={this.props.member.authorProfileName}
@ -56,9 +73,10 @@ class MemberItem extends React.Component<MemberItemProps> {
}
interface MemberListProps {
members: [any];
members: Contact[];
selected: any;
onMemberClicked: any;
i18n: any;
}
export class MemberList extends React.Component<MemberListProps> {
@ -79,6 +97,8 @@ export class MemberList extends React.Component<MemberListProps> {
key={item.id}
member={item}
selected={selected}
checkmarked={item.checkmarked}
i18n={this.props.i18n}
onClicked={this.handleMemberClicked}
/>
);

View file

@ -137,6 +137,7 @@ export const actions = {
openConversationExternal,
showInbox,
showArchivedConversations,
createNewGroup,
};
function conversationAdded(
@ -231,6 +232,15 @@ function showArchivedConversations() {
};
}
function createNewGroup() {
// Not sure how much of this is necessary:
trigger('createNewGroup');
return {
type: 'CREATE_NEW_GROUP',
payload: null,
};
}
// Reducer
function getEmptyState(): ConversationsStateType {