From ff10637a5a1e9aa1b4ec658c32119a8ee1b3c9db Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 13 Feb 2020 11:51:55 +1100 Subject: [PATCH] separate update group name and group members dialog (add and remove members are separated) --- README.md | 1 - _locales/en/messages.json | 11 +- js/background.js | 9 +- js/modules/signal.js | 16 +- js/views/app_view.js | 11 +- js/views/conversation_view.js | 25 +-- js/views/create_group_dialog_view.js | 93 ++++++++++- js/views/invite_friends_dialog_view.js | 62 ++++++-- stylesheets/_session.scss | 9 ++ .../conversation/ConversationHeader.tsx | 8 - ...ialog.tsx => UpdateGroupMembersDialog.tsx} | 50 ++---- .../conversation/UpdateGroupNameDialog.tsx | 148 ++++++++++++++++++ ...lSettings.tsx => SessionGroupSettings.tsx} | 44 +++++- 13 files changed, 399 insertions(+), 88 deletions(-) rename ts/components/conversation/{UpdateGroupDialog.tsx => UpdateGroupMembersDialog.tsx} (81%) create mode 100644 ts/components/conversation/UpdateGroupNameDialog.tsx rename ts/components/session/{SessionChannelSettings.tsx => SessionGroupSettings.tsx} (88%) diff --git a/README.md b/README.md index aad912514..c543257f7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ Session integrates directly with [Loki Service Nodes](https://lokidocs.com/ServiceNodes/SNOverview/), which are a set of distributed, decentralized and Sybil resistant nodes. Service Nodes act as servers which store messages offline, and a set of nodes which allow for onion routing functionality obfuscating users IP Addresses. For a full understanding of how Session works, read the [Session Whitepaper](https://getsession.org/whitepaper). ![DesktopSession](https://i.imgur.com/ZnHvYjo.jpg) - ## Want to Contribute? Found a Bug or Have a feature request? Please search for any [existing issues](https://github.com/loki-project/session-desktop/issues) that describe your bugs in order to avoid duplicate submissions. Submissions can be made by making a pull request to our development branch. If you don't know where to start contributing , try reading the Github issues page for ideas. diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 28d8c6db2..a20a1800d 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2204,12 +2204,16 @@ "message": "Edit Profile", "description": "Button action that the user can click to edit their profile" }, + "editGroupName": { + "message": "Edit group name", + "description": "Button action that the user can click to edit a group name" + }, "createGroupDialogTitle": { - "message": "Creating a Private Group Chat", + "message": "Creating a Closed Group", "description": "Title for the dialog box used to create a new private group" }, "updateGroupDialogTitle": { - "message": "Updating a Private Group Chat", + "message": "Updating a Closed Group", "description": "Title for the dialog box used to update an existing private group" }, @@ -2529,6 +2533,9 @@ "noFriendsToAdd": { "message": "No friends to add" }, + "noMembersInThisGroup": { + "message": "No other members in this group" + }, "noModeratorsToRemove": { "message": "no moderators to remove" }, diff --git a/js/background.js b/js/background.js index 2522db37e..859909b61 100644 --- a/js/background.js +++ b/js/background.js @@ -1135,9 +1135,14 @@ } }); - Whisper.events.on('updateGroup', async groupConvo => { + Whisper.events.on('updateGroupName', async groupConvo => { if (appView) { - appView.showUpdateGroupDialog(groupConvo); + appView.showUpdateGroupNameDialog(groupConvo); + } + }); + Whisper.events.on('updateGroupMembers', async groupConvo => { + if (appView) { + appView.showUpdateGroupMembersDialog(groupConvo); } }); diff --git a/js/modules/signal.js b/js/modules/signal.js index f30e42cbc..f5ddc198b 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -34,8 +34,8 @@ const { ConversationHeader, } = require('../../ts/components/conversation/ConversationHeader'); const { - SessionChannelSettings, -} = require('../../ts/components/session/SessionChannelSettings'); + SessionGroupSettings, +} = require('../../ts/components/session/SessionGroupSettings'); const { EmbeddedContact, } = require('../../ts/components/conversation/EmbeddedContact'); @@ -93,8 +93,11 @@ const { } = require('../../ts/components/session/SessionRegistrationView'); const { - UpdateGroupDialog, -} = require('../../ts/components/conversation/UpdateGroupDialog'); + UpdateGroupNameDialog, +} = require('../../ts/components/conversation/UpdateGroupNameDialog'); +const { + UpdateGroupMembersDialog, +} = require('../../ts/components/conversation/UpdateGroupMembersDialog'); const { InviteFriendsDialog, } = require('../../ts/components/conversation/InviteFriendsDialog'); @@ -278,7 +281,7 @@ exports.setup = (options = {}) => { ContactListItem, ContactName, ConversationHeader, - SessionChannelSettings, + SessionGroupSettings, SettingsView, EmbeddedContact, Emojify, @@ -293,7 +296,8 @@ exports.setup = (options = {}) => { DevicePairingDialog, SessionRegistrationView, ConfirmDialog, - UpdateGroupDialog, + UpdateGroupNameDialog, + UpdateGroupMembersDialog, InviteFriendsDialog, AddModeratorsDialog, RemoveModeratorsDialog, diff --git a/js/views/app_view.js b/js/views/app_view.js index ee5f45126..1940fa7ff 100644 --- a/js/views/app_view.js +++ b/js/views/app_view.js @@ -228,10 +228,15 @@ const dialog = new Whisper.CreateGroupDialogView(); this.el.append(dialog.el); }, - showUpdateGroupDialog(groupConvo) { - const dialog = new Whisper.UpdateGroupDialogView(groupConvo); - this.el.prepend(dialog.el); + showUpdateGroupNameDialog(groupConvo) { + const dialog = new Whisper.UpdateGroupNameDialogView(groupConvo); + this.el.append(dialog.el); }, + showUpdateGroupMembersDialog(groupConvo) { + const dialog = new Whisper.UpdateGroupMembersDialogView(groupConvo); + this.el.append(dialog.el); + }, + showSessionRestoreConfirmation(options) { const dialog = new Whisper.ConfirmSessionResetView(options); this.el.append(dialog.el); diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 4db8212ef..784159e7a 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -244,11 +244,6 @@ onMoveToInbox: () => { this.model.setArchived(false); }, - - onUpdateGroup: () => { - window.Whisper.events.trigger('updateGroup', this.model); - }, - onLeaveGroup: () => { window.Whisper.events.trigger('leaveGroup', this.model); }, @@ -276,7 +271,8 @@ }, }; }; - const getGroupSettingsProp = () => { + const getGroupSettingsProps = () => { + const ourPK = window.textsecure.storage.user.getNumber(); const members = this.model.get('members') || []; return { @@ -288,6 +284,7 @@ avatarPath: this.model.getAvatarPath(), isGroup: !this.model.isPrivate(), isPublic: this.model.isPublic(), + isAdmin: this.model.get('groupAdmins').includes(ourPK), isRss: this.model.isRss(), memberCount: members.length, @@ -303,8 +300,11 @@ this.$('.conversation-content-right').hide(); }, - onUpdateGroup: () => { - window.Whisper.events.trigger('updateGroup', this.model); + onUpdateGroupName: () => { + window.Whisper.events.trigger('updateGroupName', this.model); + }, + onUpdateGroupMembers: () => { + window.Whisper.events.trigger('updateGroupMembers', this.model); }, onLeaveGroup: () => { @@ -344,12 +344,15 @@ if (!this.groupSettings) { this.groupSettings = new Whisper.ReactWrapperView({ className: 'group-settings', - Component: window.Signal.Components.SessionChannelSettings, - props: getGroupSettingsProp(this.model), + Component: window.Signal.Components.SessionGroupSettings, + props: getGroupSettingsProps(this.model), }); this.$('.conversation-content-right').append(this.groupSettings.el); + this.updateGroupSettingsPanel = () => + this.groupSettings.update(getGroupSettingsProps(this.model)); + this.listenTo(this.model, 'change', this.updateGroupSettingsPanel); } else { - this.groupSettings.update(getGroupSettingsProp(this.model)); + this.groupSettings.update(getGroupSettingsProps(this.model)); } this.$('.conversation-content-right').show(); }; diff --git a/js/views/create_group_dialog_view.js b/js/views/create_group_dialog_view.js index c70b4f381..befbf3b3b 100644 --- a/js/views/create_group_dialog_view.js +++ b/js/views/create_group_dialog_view.js @@ -47,13 +47,13 @@ }, }); - Whisper.UpdateGroupDialogView = Whisper.View.extend({ + Whisper.UpdateGroupNameDialogView = Whisper.View.extend({ className: 'loki-dialog modal', initialize(groupConvo) { this.groupName = groupConvo.get('name'); this.conversation = groupConvo; - this.titleText = `${i18n('updateGroupDialogTitle')}: ${this.groupName}`; + this.titleText = i18n('updateGroupDialogTitle'); this.okText = i18n('ok'); this.cancelText = i18n('cancel'); this.close = this.close.bind(this); @@ -106,7 +106,90 @@ render() { this.dialogView = new Whisper.ReactWrapperView({ className: 'create-group-dialog', - Component: window.Signal.Components.UpdateGroupDialog, + Component: window.Signal.Components.UpdateGroupNameDialog, + props: { + titleText: this.titleText, + groupName: this.groupName, + okText: this.okText, + isPublic: this.isPublic, + cancelText: this.cancelText, + existingMembers: this.existingMembers, + isAdmin: this.isAdmin, + onClose: this.close, + onSubmit: this.onSubmit, + }, + }); + + this.$el.append(this.dialogView.el); + return this; + }, + onSubmit(newGroupName, members) { + const groupId = this.conversation.get('id'); + + window.doUpdateGroup(groupId, newGroupName, members); + }, + close() { + this.remove(); + }, + }); + + Whisper.UpdateGroupMembersDialogView = Whisper.View.extend({ + className: 'loki-dialog modal', + initialize(groupConvo) { + this.groupName = groupConvo.get('name'); + + this.conversation = groupConvo; + this.titleText = i18n('updateGroupDialogTitle'); + this.okText = i18n('ok'); + this.cancelText = i18n('cancel'); + this.close = this.close.bind(this); + this.onSubmit = this.onSubmit.bind(this); + this.isPublic = groupConvo.isPublic(); + + const ourPK = textsecure.storage.user.getNumber(); + + this.isAdmin = groupConvo.get('groupAdmins').includes(ourPK); + + const convos = window.getConversations().models.filter(d => !!d); + + let existingMembers = groupConvo.get('members') || []; + + // Show a contact if they are our friend or if they are a member + const friendsAndMembers = convos.filter( + d => existingMembers.includes(d.id) && d.isPrivate() && !d.isMe() + ); + this.friendsAndMembers = _.uniq(friendsAndMembers, true, d => d.id); + + // at least make sure it's an array + if (!Array.isArray(existingMembers)) { + existingMembers = []; + } + + this.existingMembers = existingMembers; + + // public chat settings overrides + if (this.isPublic) { + // fix the title + this.titleText = `${i18n('updatePublicGroupDialogTitle')}: ${ + this.groupName + }`; + // I'd much prefer to integrate mods with groupAdmins + // but lets discuss first... + this.isAdmin = groupConvo.isModerator( + window.storage.get('primaryDevicePubKey') + ); + // zero out friendList for now + this.friendsAndMembers = []; + this.existingMembers = []; + } + + this.$el.focus(); + this.render(); + }, + render() { + this.dialogView = new Whisper.ReactWrapperView({ + className: 'create-group-dialog', + Component: window.Signal.Components.UpdateGroupMembersDialog, props: { titleText: this.titleText, groupName: this.groupName, @@ -124,12 +207,12 @@ this.$el.append(this.dialogView.el); return this; }, - onSubmit(newGroupName, newMembers) { + onSubmit(groupName, newMembers) { const ourPK = textsecure.storage.user.getNumber(); const allMembers = window.Lodash.concat(newMembers, [ourPK]); const groupId = this.conversation.get('id'); - window.doUpdateGroup(groupId, newGroupName, allMembers); + window.doUpdateGroup(groupId, groupName, allMembers); }, close() { this.remove(); diff --git a/js/views/invite_friends_dialog_view.js b/js/views/invite_friends_dialog_view.js index 06d59e9df..09ab91d81 100644 --- a/js/views/invite_friends_dialog_view.js +++ b/js/views/invite_friends_dialog_view.js @@ -1,4 +1,4 @@ -/* global Whisper */ +/* global Whisper, _ */ // eslint-disable-next-line func-names (function() { @@ -22,6 +22,8 @@ this.chatName = convo.get('name'); this.chatServer = convo.get('server'); this.channelId = convo.get('channelId'); + this.isPublic = !!convo.cachedProps.isPublic; + this.convo = convo; this.$el.focus(); this.render(); @@ -45,14 +47,56 @@ this.remove(); }, submit(pubkeys) { - window.sendGroupInvitations( - { - address: this.chatServer, - name: this.chatName, - channelId: this.channelId, - }, - pubkeys - ); + // public group chats + if (this.isPublic) { + window.sendGroupInvitations( + { + address: this.chatServer, + name: this.chatName, + channelId: this.channelId, + }, + pubkeys + ); + } else { + // private group chats + const ourPK = window.textsecure.storage.user.getNumber(); + let existingMembers = this.convo.get('members') || []; + // at least make sure it's an array + if (!Array.isArray(existingMembers)) { + existingMembers = []; + } + existingMembers = existingMembers.filter(d => !!d); + const newMembers = pubkeys.filter(d => !existingMembers.includes(d)); + + if (newMembers.length > 0) { + // Do not trigger an update if there is too many members + if ( + newMembers.length + existingMembers.length > + window.SMALL_GROUP_SIZE_LIMIT + ) { + const msg = `${window.i18n('maxGroupMembersError')} ${ + window.SMALL_GROUP_SIZE_LIMIT + }`; + + window.pushToast({ + title: msg, + type: 'error', + id: 'tooManyMembers', + }); + return; + } + + const allMembers = window.Lodash.concat(existingMembers, newMembers, [ + ourPK, + ]); + const uniqMembers = _.uniq(allMembers, true, d => d); + + const groupId = this.convo.get('id'); + const groupName = this.convo.get('name'); + + window.doUpdateGroup(groupId, groupName, uniqMembers); + } + } }, }); })(); diff --git a/stylesheets/_session.scss b/stylesheets/_session.scss index 5c3497ba8..436def719 100644 --- a/stylesheets/_session.scss +++ b/stylesheets/_session.scss @@ -794,6 +794,15 @@ label { } } +.create-group-dialog .session-modal__body { + display: flex; + flex-direction: column; + + .friend-selection-list { + width: unset; + } +} + .session-confirm { &-wrapper { .session-modal__body .session-modal__centered { diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index a25367cc5..63b8fe6ae 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -32,7 +32,6 @@ interface Props { phoneNumber: string; profileName?: string; - color: string; avatarPath?: string; isVerified: boolean; @@ -88,7 +87,6 @@ interface Props { onCopyPublicKey: () => void; - onUpdateGroup: () => void; onLeaveGroup: () => void; onAddModerators: () => void; onRemoveModerators: () => void; @@ -206,7 +204,6 @@ export class ConversationHeader extends React.Component { public renderAvatar() { const { avatarPath, - color, i18n, isGroup, isMe, @@ -223,7 +220,6 @@ export class ConversationHeader extends React.Component { { onDeleteMessages, onDeleteContact, onCopyPublicKey, - onUpdateGroup, onLeaveGroup, onAddModerators, onRemoveModerators, @@ -323,9 +318,6 @@ export class ConversationHeader extends React.Component { {copyIdLabel} ) : null} {i18n('deleteMessages')} - {isPrivateGroup || amMod ? ( - {i18n('updateGroup')} - ) : null} {amMod ? ( {i18n('addModerators')} ) : null} diff --git a/ts/components/conversation/UpdateGroupDialog.tsx b/ts/components/conversation/UpdateGroupMembersDialog.tsx similarity index 81% rename from ts/components/conversation/UpdateGroupDialog.tsx rename to ts/components/conversation/UpdateGroupMembersDialog.tsx index c11cf3384..8af584530 100644 --- a/ts/components/conversation/UpdateGroupDialog.tsx +++ b/ts/components/conversation/UpdateGroupMembersDialog.tsx @@ -2,7 +2,8 @@ import React from 'react'; import classNames from 'classnames'; import { Contact, MemberList } from './MemberList'; -import { SessionModal } from './../session/SessionModal'; +import { SessionModal } from '../session/SessionModal'; +import { SessionButton } from '../session/SessionButton'; interface Props { titleText: string; @@ -21,12 +22,11 @@ interface Props { interface State { friendList: Array; - groupName: string; errorDisplayed: boolean; errorMessage: string; } -export class UpdateGroupDialog extends React.Component { +export class UpdateGroupMembersDialog extends React.Component { constructor(props: any) { super(props); @@ -57,7 +57,6 @@ export class UpdateGroupDialog extends React.Component { this.state = { friendList: friends, - groupName: this.props.groupName, errorDisplayed: false, errorMessage: 'placeholder', }; @@ -70,13 +69,7 @@ export class UpdateGroupDialog extends React.Component { d => d.id ); - if (!this.state.groupName.trim()) { - this.onShowError(this.props.i18n('emptyGroupNameError')); - - return; - } - - this.props.onSubmit(this.state.groupName, members); + this.props.onSubmit(this.props.groupName, members); this.closeDialog(); } @@ -111,25 +104,16 @@ export class UpdateGroupDialog extends React.Component { ); return ( - null} onOk={() => null}> + this.closeDialog()} + onOk={() => null} + >

{errorMsg}

- -
{ />

{`(${this.props.i18n( - 'noFriendsToAdd' + 'noMembersInThisGroup' )})`}

-
- - + +
+ + +
); diff --git a/ts/components/conversation/UpdateGroupNameDialog.tsx b/ts/components/conversation/UpdateGroupNameDialog.tsx new file mode 100644 index 000000000..ba44a2497 --- /dev/null +++ b/ts/components/conversation/UpdateGroupNameDialog.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import classNames from 'classnames'; + +import { SessionModal } from '../session/SessionModal'; +import { SessionButton } from '../session/SessionButton'; + +interface Props { + titleText: string; + groupName: string; + okText: string; + cancelText: string; + isAdmin: boolean; + i18n: any; + onSubmit: any; + onClose: any; + existingMembers: Array; +} + +interface State { + groupName: string; + errorDisplayed: boolean; + errorMessage: string; +} + +export class UpdateGroupNameDialog extends React.Component { + constructor(props: any) { + super(props); + + this.onClickOK = this.onClickOK.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + this.closeDialog = this.closeDialog.bind(this); + this.onGroupNameChanged = this.onGroupNameChanged.bind(this); + + this.state = { + groupName: this.props.groupName, + errorDisplayed: false, + errorMessage: 'placeholder', + }; + + window.addEventListener('keyup', this.onKeyUp); + } + + public onClickOK() { + if (!this.state.groupName.trim()) { + this.onShowError(this.props.i18n('emptyGroupNameError')); + + return; + } + + this.props.onSubmit(this.state.groupName, this.props.existingMembers); + + this.closeDialog(); + } + + public render() { + const okText = this.props.okText; + const cancelText = this.props.cancelText; + + let titleText; + + titleText = `${this.props.titleText}`; + + const errorMsg = this.state.errorMessage; + const errorMessageClasses = classNames( + 'error-message', + this.state.errorDisplayed ? 'error-shown' : 'error-faded' + ); + + return ( + this.closeDialog()} + onOk={() => null} + > +
+

{errorMsg}

+
+ + + +
+ + + +
+ + ); + } + + private onShowError(msg: string) { + if (this.state.errorDisplayed) { + return; + } + + this.setState({ + errorDisplayed: true, + errorMessage: msg, + }); + + setTimeout(() => { + this.setState({ + errorDisplayed: false, + }); + }, 3000); + } + + 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 onGroupNameChanged(event: any) { + event.persist(); + + this.setState(state => { + return { + ...state, + groupName: event.target.value, + }; + }); + } +} diff --git a/ts/components/session/SessionChannelSettings.tsx b/ts/components/session/SessionGroupSettings.tsx similarity index 88% rename from ts/components/session/SessionChannelSettings.tsx rename to ts/components/session/SessionGroupSettings.tsx index 353becb2f..5882e05b1 100644 --- a/ts/components/session/SessionChannelSettings.tsx +++ b/ts/components/session/SessionGroupSettings.tsx @@ -19,15 +19,18 @@ interface Props { avatarPath: string; timerOptions: Array; isPublic: boolean; + isAdmin: boolean; onGoBack: () => void; onInviteFriends: () => void; onLeaveGroup: () => void; + onUpdateGroupName: () => void; + onUpdateGroupMembers: () => void; onShowLightBox: (options: any) => void; onSetDisappearingMessages: (seconds: number) => void; } -export class SessionChannelSettings extends React.Component { +export class SessionGroupSettings extends React.Component { public constructor(props: Props) { super(props); @@ -207,6 +210,7 @@ export class SessionChannelSettings extends React.Component { timerOptions, onLeaveGroup, isPublic, + isAdmin, } = this.props; const { documents, media, onItemClick } = this.state; const showMemberCount = !!(memberCount && memberCount > 0); @@ -231,7 +235,7 @@ export class SessionChannelSettings extends React.Component { {showMemberCount && ( <>
-
+
{window.i18n('members', memberCount)}
@@ -241,7 +245,26 @@ export class SessionChannelSettings extends React.Component { className="description" placeholder={window.i18n('description')} /> - + {!isPublic && ( + <> + {isAdmin && ( +
+ {window.i18n('editGroupName')} +
+ )} +
+ {window.i18n('showMembers')} +
+ + )}
{window.i18n('notifications')}
@@ -269,8 +292,16 @@ export class SessionChannelSettings extends React.Component { } private renderHeader() { - const { id, onGoBack, onInviteFriends, avatarPath } = this.props; - const shouldShowInviteFriends = !this.props.isPublic; + const { + id, + onGoBack, + onInviteFriends, + avatarPath, + isAdmin, + isPublic, + } = this.props; + + const showInviteFriends = isPublic || isAdmin; return (
@@ -286,9 +317,8 @@ export class SessionChannelSettings extends React.Component { conversationType="group" size={80} /> -
- {shouldShowInviteFriends && ( + {showInviteFriends && (