mirror of
https://github.com/oxen-io/session-desktop.git
synced 2023-12-14 02:12:57 +01:00
Creating private group chats between friends
This commit is contained in:
parent
6c08852118
commit
0d19b708f9
16 changed files with 401 additions and 14 deletions
|
@ -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":
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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()}`
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
|
47
js/views/create_group_dialog_view.js
Normal file
47
js/views/create_group_dialog_view.js
Normal 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();
|
||||
},
|
||||
});
|
||||
})();
|
|
@ -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'
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
162
ts/components/conversation/CreateGroupDialog.tsx
Normal file
162
ts/components/conversation/CreateGroupDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue