diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7050831ae..9e1a9346c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2229,5 +2229,14 @@ }, "groupNamePlaceholder": { "message": "Group Name" + }, + "inviteFriends": { + "message": "Invite Friends" + }, + "groupInvitation": { + "message": "Group Invitation" + }, + "addingFriends": { + "message": "Adding friends to" } } diff --git a/background.html b/background.html index ad0d116ac..0f34c7317 100644 --- a/background.html +++ b/background.html @@ -820,6 +820,7 @@ + diff --git a/js/background.js b/js/background.js index a7fe870da..294bddfd6 100644 --- a/js/background.js +++ b/js/background.js @@ -799,6 +799,23 @@ appView.openConversation(groupId, {}); }; + window.sendGroupInvitations = (serverInfo, pubkeys) => { + pubkeys.forEach(async pubkey => { + const convo = await ConversationController.getOrCreateAndWait( + pubkey, + 'private' + ); + + if (convo) { + convo.sendMessage('', null, null, null, { + serverName: serverInfo.name, + channelId: serverInfo.channelId, + serverAddress: serverInfo.address, + }); + } + }); + }; + Whisper.events.on('createNewGroup', async () => { if (appView) { appView.showCreateGroup(); @@ -811,6 +828,52 @@ } }); + Whisper.events.on('inviteFriends', async groupConvo => { + if (appView) { + appView.showInviteFriendsDialog(groupConvo); + } + }); + + Whisper.events.on( + 'publicChatInvitationAccepted', + async (serverAddress, channelId) => { + // To some degree this has been copy-pasted + // form connection_to_server_dialog_view.js: + const rawServerUrl = serverAddress + .replace(/^https?:\/\//i, '') + .replace(/[/\\]+$/i, ''); + const sslServerUrl = `https://${rawServerUrl}`; + const conversationId = `publicChat:${channelId}@${rawServerUrl}`; + + const conversationExists = ConversationController.get(conversationId); + if (conversationExists) { + window.log.warn('We are already a member of this public chat'); + return; + } + + const serverAPI = await window.lokiPublicChatAPI.findOrCreateServer( + sslServerUrl + ); + if (!serverAPI) { + window.log.warn(`Could not connect to ${serverAddress}`); + return; + } + + const conversation = await ConversationController.getOrCreateAndWait( + conversationId, + 'group' + ); + + serverAPI.findOrCreateChannel(channelId, conversationId); + await conversation.setPublicSource(sslServerUrl, channelId); + await conversation.setFriendRequestStatus( + window.friends.friendRequestStatusEnum.friends + ); + + appView.openConversation(conversationId, {}); + } + ); + Whisper.events.on('leaveGroup', async groupConvo => { if (appView) { appView.showLeaveGroupDialog(groupConvo); diff --git a/js/models/conversations.js b/js/models/conversations.js index eb2e6cfc7..c3d230fbc 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -1418,7 +1418,13 @@ }; }, - async sendMessage(body, attachments, quote, preview) { + async sendMessage( + body, + attachments, + quote, + preview, + groupInvitation = null + ) { this.clearTypingTimers(); const destination = this.id; @@ -1510,6 +1516,7 @@ } const attributes = { ...messageWithSchema, + groupInvitation, id: window.getGuid(), }; @@ -1589,6 +1596,8 @@ options.publicSendData = await this.getPublicSendData(); } + options.groupInvitation = groupInvitation; + const groupNumbers = this.getRecipients(); const promise = (() => { diff --git a/js/models/messages.js b/js/models/messages.js index cd089398f..eaed842cf 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -104,6 +104,8 @@ this.propsForGroupNotification = this.getPropsForGroupNotification(); } else if (this.isFriendRequest()) { this.propsForFriendRequest = this.getPropsForFriendRequest(); + } else if (this.isGroupInvitation()) { + this.propsForGroupInvitation = this.getPropsForGroupInvitation(); } else { this.propsForSearchResult = this.getPropsForSearchResult(); this.propsForMessage = this.getPropsForMessage(); @@ -251,6 +253,9 @@ if (this.isIncoming() && this.hasErrors()) { return i18n('incomingError'); } + if (this.isGroupInvitation()) { + return `<${i18n('groupInvitation')}>`; + } return this.get('body'); }, isVerifiedChange() { @@ -262,6 +267,9 @@ isFriendRequest() { return this.get('type') === 'friend-request'; }, + isGroupInvitation() { + return !!this.get('groupInvitation'); + }, getNotificationText() { const description = this.getDescription(); if (description) { @@ -439,6 +447,27 @@ onRetrySend, }; }, + getPropsForGroupInvitation() { + const invitation = this.get('groupInvitation'); + + let direction = this.get('direction'); + if (!direction) { + direction = this.get('type') === 'outgoing' ? 'outgoing' : 'incoming'; + } + + return { + serverName: invitation.serverName, + serverAddress: invitation.serverAddress, + direction, + onClick: () => { + Whisper.events.trigger( + 'publicChatInvitationAccepted', + invitation.serverAddress, + invitation.channelId + ); + }, + }; + }, findContact(phoneNumber) { return ConversationController.get(phoneNumber); }, @@ -1920,6 +1949,7 @@ window.log.info( `Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversation.idForLogging()}` ); + const withQuoteReference = await this.copyFromQuotedMessage( initialMessage ); @@ -2002,6 +2032,10 @@ } } + if (initialMessage.groupInvitation) { + message.set({ groupInvitation: initialMessage.groupInvitation }); + } + const urls = window.Signal.LinkPreviews.findLinks(dataMessage.body); const incomingPreview = dataMessage.preview || []; const preview = incomingPreview.filter( @@ -2227,15 +2261,6 @@ } else if (message.get('type') !== 'outgoing') { // Ignore 'outgoing' messages because they are sync messages await sendingDeviceConversation.onFriendRequestAccepted(); - // We need to return for these types of messages because android struggles - if ( - !message.get('body') && - !message.get('attachments').length && - !message.get('preview').length && - !message.get('group_update') - ) { - return; - } } const id = await window.Signal.Data.saveMessage(message.attributes, { Message: Whisper.Message, diff --git a/js/modules/signal.js b/js/modules/signal.js index 91459bc65..3caa1cdca 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -52,6 +52,12 @@ const { EditProfileDialog } = require('../../ts/components/EditProfileDialog'); const { UpdateGroupDialog, } = require('../../ts/components/conversation/UpdateGroupDialog'); +const { + InviteFriendsDialog, +} = require('../../ts/components/conversation/InviteFriendsDialog'); +const { + GroupInvitation, +} = require('../../ts/components/conversation/GroupInvitation'); const { ConfirmDialog } = require('../../ts/components/ConfirmDialog'); const { MediaGallery, @@ -232,6 +238,8 @@ exports.setup = (options = {}) => { EditProfileDialog, ConfirmDialog, UpdateGroupDialog, + InviteFriendsDialog, + GroupInvitation, BulkEdit, MediaGallery, Message, diff --git a/js/views/app_view.js b/js/views/app_view.js index c78618f40..582c9d79d 100644 --- a/js/views/app_view.js +++ b/js/views/app_view.js @@ -254,5 +254,9 @@ const dialog = new Whisper.LeaveGroupDialogView(groupConvo); this.el.append(dialog.el); }, + showInviteFriendsDialog(groupConvo) { + const dialog = new Whisper.InviteFriendsDialogView(groupConvo); + this.el.append(dialog.el); + }, }); })(); diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 96332b17c..68b24d2b6 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -286,6 +286,10 @@ onLeaveGroup: () => { window.Whisper.events.trigger('leaveGroup', this.model); }, + + onInviteFriends: () => { + window.Whisper.events.trigger('inviteFriends', this.model); + }, }; }; this.titleView = new Whisper.ReactWrapperView({ diff --git a/js/views/invite_friends_dialog_view.js b/js/views/invite_friends_dialog_view.js new file mode 100644 index 000000000..06d59e9df --- /dev/null +++ b/js/views/invite_friends_dialog_view.js @@ -0,0 +1,58 @@ +/* global Whisper */ + +// eslint-disable-next-line func-names +(function() { + 'use strict'; + + window.Whisper = window.Whisper || {}; + + Whisper.InviteFriendsDialogView = Whisper.View.extend({ + className: 'loki-dialog modal', + initialize(convo) { + this.close = this.close.bind(this); + this.submit = this.submit.bind(this); + + const convos = window.getConversations().models; + + const friends = convos.filter( + d => !!d && d.isFriend() && d.isPrivate() && !d.isMe() + ); + + this.friends = friends; + this.chatName = convo.get('name'); + this.chatServer = convo.get('server'); + this.channelId = convo.get('channelId'); + + this.$el.focus(); + this.render(); + }, + render() { + const view = new Whisper.ReactWrapperView({ + className: 'invite-friends-dialog', + Component: window.Signal.Components.InviteFriendsDialog, + props: { + friendList: this.friends, + onSubmit: this.submit, + onClose: this.close, + chatName: this.chatName, + }, + }); + + this.$el.append(view.el); + return this; + }, + close() { + this.remove(); + }, + submit(pubkeys) { + window.sendGroupInvitations( + { + address: this.chatServer, + name: this.chatName, + channelId: this.channelId, + }, + pubkeys + ); + }, + }); +})(); diff --git a/js/views/message_view.js b/js/views/message_view.js index 6da1ffaec..32d87a78b 100644 --- a/js/views/message_view.js +++ b/js/views/message_view.js @@ -74,6 +74,11 @@ Component: Components.FriendRequest, props: this.model.propsForFriendRequest, }; + } else if (this.model.propsForGroupInvitation) { + return { + Component: Components.GroupInvitation, + props: this.model.propsForGroupInvitation, + }; } return { diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index 902d100e3..a021fd9df 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -27,6 +27,7 @@ function Message(options) { this.expireTimer = options.expireTimer; this.profileKey = options.profileKey; this.profile = options.profile; + this.groupInvitation = options.groupInvitation; if (!(this.recipients instanceof Array)) { throw new Error('Invalid recipient list'); @@ -160,6 +161,16 @@ Message.prototype = { proto.profile = profile; } + if (this.groupInvitation) { + proto.groupInvitation = new textsecure.protobuf.DataMessage.GroupInvitation( + { + serverAddress: this.groupInvitation.serverAddress, + channelId: this.groupInvitation.channelId, + serverName: this.groupInvitation.serverName, + } + ); + } + this.dataMessage = proto; return proto; }, @@ -404,7 +415,7 @@ MessageSender.prototype = { ); numbers.forEach(number => { - // Note: if we are sending a private group message, we make our best to + // Note: if we are sending a private group message, we do 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( @@ -941,6 +952,8 @@ MessageSender.prototype = { ? textsecure.protobuf.DataMessage.Flags.BACKGROUND_FRIEND_REQUEST : undefined; + const { groupInvitation } = options; + return this.sendMessage( { recipients: [number], @@ -954,6 +967,7 @@ MessageSender.prototype = { profileKey, profile, flags, + groupInvitation, }, options ); diff --git a/protos/SignalService.proto b/protos/SignalService.proto index cd57885b0..e76906d2b 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -201,6 +201,12 @@ message DataMessage { optional string avatar = 2; } + message GroupInvitation { + optional string serverAddress = 1; + optional uint32 channelId = 2; + optional string serverName = 3; + } + optional string body = 1; repeated AttachmentPointer attachments = 2; optional GroupContext group = 3; @@ -212,6 +218,7 @@ message DataMessage { repeated Contact contact = 9; repeated Preview preview = 10; optional LokiProfile profile = 101; // Loki: The profile of the current user + optional GroupInvitation groupInvitation = 102; // Loki: Invitation to a public chat } message NullMessage { diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index 8c5c8dd43..8008c2add 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -204,6 +204,80 @@ padding-top: 4px; } +.group-invitation-container { + display: flex; + flex-direction: column; +} + +.group-invitation { + background-color: #f4f4f0; + display: inline-block; + margin: 4px 16px; + padding: 4px; + + border: solid; + border-width: 0.5px; + border-radius: 4px; + border-color: #e0e0e0; + + align-self: flex-start; + + box-shadow: 2px 2px lightgrey; + + .title { + margin: 6px; + color: darkslategray; + font-variant-caps: all-small-caps; + user-select: none; + } + + .contents { + display: flex; + align-items: center; + margin: 6px; + + .invite-group-avatar { + height: 48px; + width: 48px; + } + + .group-details { + display: inline-flex; + flex-direction: column; + + padding: 8px; + + .group-name { + font-weight: lighter; + padding-bottom: 4px; + } + + .group-address { + color: grey; + } + } + + .join-btn { + background-color: #e0e0e0; + padding: 6px 10px; + margin-left: 6px; + border-radius: 6px; + box-shadow: 2px 2px 1px #c0c0c0; + color: #404040; + user-select: none; + cursor: pointer; + + &:hover { + background-color: #c7c7c7; + } + } + } +} + +.invitation-outgoing { + align-self: flex-end; +} + .message-selected { background-color: #60554060; } diff --git a/stylesheets/_mentions.scss b/stylesheets/_mentions.scss index b3d226c56..9eeea12f3 100644 --- a/stylesheets/_mentions.scss +++ b/stylesheets/_mentions.scss @@ -29,6 +29,7 @@ margin-left: 10px; } +.invite-friends-dialog, .create-group-dialog { .content { max-width: 100% !important; @@ -46,7 +47,9 @@ font-size: large; text-align: center; } +} +.create-group-dialog { .no-friends { text-align: center; } @@ -124,7 +127,8 @@ } .member-list-container, -.create-group-dialog { +.create-group-dialog, +.invite-friends-dialog { .member-item { padding: 4px; user-select: none; @@ -176,7 +180,8 @@ .dark-theme { .member-list-container, - .create-group-dialog { + .create-group-dialog, + .invite-friends-dialog { .member-item { &:hover:not(.member-selected) { background-color: $color-dark-55; diff --git a/test/index.html b/test/index.html index f8cae2a6e..cdeaea87e 100644 --- a/test/index.html +++ b/test/index.html @@ -576,7 +576,7 @@ - + diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index 4e14a7350..d26d1fe08 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -42,6 +42,7 @@ export type PropsData = { hasNickname?: boolean; isFriendItem?: boolean; isSecondary?: boolean; + isGroupInvitation?: boolean; }; type PropsHousekeeping = { diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index e79181f1a..062e0aee2 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -67,6 +67,8 @@ interface Props { onUpdateGroup: () => void; onLeaveGroup: () => void; + onInviteFriends: () => void; + i18n: LocalizerType; } @@ -230,6 +232,7 @@ export class ConversationHeader extends React.Component { onCopyPublicKey, onUpdateGroup, onLeaveGroup, + onInviteFriends, } = this.props; const isPrivateGroup = isGroup && !isPublic; @@ -248,6 +251,9 @@ export class ConversationHeader extends React.Component { {i18n('leaveGroup')} ) : null} {/* TODO: add delete group */} + {isGroup && isPublic ? ( + {i18n('inviteFriends')} + ) : null} {!isMe && isClosable && !isPrivateGroup ? ( !isPublic ? ( diff --git a/ts/components/conversation/GroupInvitation.tsx b/ts/components/conversation/GroupInvitation.tsx new file mode 100644 index 000000000..b8b276f9c --- /dev/null +++ b/ts/components/conversation/GroupInvitation.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import classNames from 'classnames'; + +interface Props { + serverName: string; + serverAddress: string; + direction: string; + onClick: any; +} + +export class GroupInvitation extends React.Component { + public render() { + const classes = ['group-invitation']; + + if (this.props.direction === 'outgoing') { + classes.push('invitation-outgoing'); + } + + return ( +
+
+
Group invitation
+
+ group-avatar + + {this.props.serverName} + {this.props.serverAddress} + + + Join + +
+
+
+ ); + } +} diff --git a/ts/components/conversation/InviteFriendsDialog.tsx b/ts/components/conversation/InviteFriendsDialog.tsx new file mode 100644 index 000000000..a624dc118 --- /dev/null +++ b/ts/components/conversation/InviteFriendsDialog.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { Contact, MemberList } from './MemberList'; + +interface Props { + friendList: Array; + chatName: string; + onSubmit: any; + onClose: any; +} + +declare global { + interface Window { + i18n: any; + } +} + +interface State { + friendList: Array; +} + +export class InviteFriendsDialog extends React.Component { + constructor(props: any) { + super(props); + + this.onMemberClicked = this.onMemberClicked.bind(this); + this.closeDialog = this.closeDialog.bind(this); + this.onClickOK = this.onClickOK.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + + let friends = this.props.friendList; + friends = friends.map(d => { + const lokiProfile = d.getLokiProfile(); + const name = lokiProfile ? lokiProfile.displayName : 'Anonymous'; + + // TODO: should take existing members into account + const existingMember = false; + + return { + id: d.id, + authorPhoneNumber: d.id, + authorProfileName: name, + selected: false, + authorName: name, + authorColor: d.getColor(), + checkmarked: false, + existingMember, + }; + }); + + this.state = { + friendList: friends, + }; + + window.addEventListener('keyup', this.onKeyUp); + } + + public render() { + const titleText = `${window.i18n('addingFriends')} ${this.props.chatName}`; + const cancelText = window.i18n('cancel'); + const okText = window.i18n('ok'); + + return ( +
+

{titleText}

+
+ +
+
+ + +
+
+ ); + } + + private onClickOK() { + const selectedFriends = this.state.friendList + .filter(d => d.checkmarked) + .map(d => d.id); + + if (selectedFriends.length > 0) { + this.props.onSubmit(selectedFriends); + } + + this.closeDialog(); + } + + 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) { + const updatedFriends = this.state.friendList.map(member => { + if (member.id === selected.id) { + return { ...member, checkmarked: !member.checkmarked }; + } else { + return member; + } + }); + + this.setState(state => { + return { + ...state, + friendList: updatedFriends, + }; + }); + } +}