Merge pull request #645 from msgmaxim/group-invites

Public chat invitations
This commit is contained in:
Maxim Shishmarev 2019-11-25 16:29:02 +11:00 committed by GitHub
commit 2ab0d084f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 484 additions and 14 deletions

View File

@ -2229,5 +2229,14 @@
},
"groupNamePlaceholder": {
"message": "Group Name"
},
"inviteFriends": {
"message": "Invite Friends"
},
"groupInvitation": {
"message": "Group Invitation"
},
"addingFriends": {
"message": "Adding friends to"
}
}

View File

@ -820,6 +820,7 @@
<script type='text/javascript' src='js/views/device_pairing_words_dialog_view.js'></script>
<script type='text/javascript' src='js/views/create_group_dialog_view.js'></script>
<script type='text/javascript' src='js/views/edit_profile_dialog_view.js'></script>
<script type='text/javascript' src='js/views/invite_friends_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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -576,7 +576,7 @@
<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/edit_profile_dialog_view.js'></script>
<script type='text/javascript' src='../js/views/invite_friends_dialog_view.js'></script>
<script type='text/javascript' src='../js/views/beta_release_disclaimer_view.js'></script>

View File

@ -42,6 +42,7 @@ export type PropsData = {
hasNickname?: boolean;
isFriendItem?: boolean;
isSecondary?: boolean;
isGroupInvitation?: boolean;
};
type PropsHousekeeping = {

View File

@ -67,6 +67,8 @@ interface Props {
onUpdateGroup: () => void;
onLeaveGroup: () => void;
onInviteFriends: () => void;
i18n: LocalizerType;
}
@ -230,6 +232,7 @@ export class ConversationHeader extends React.Component<Props> {
onCopyPublicKey,
onUpdateGroup,
onLeaveGroup,
onInviteFriends,
} = this.props;
const isPrivateGroup = isGroup && !isPublic;
@ -248,6 +251,9 @@ export class ConversationHeader extends React.Component<Props> {
<MenuItem onClick={onLeaveGroup}>{i18n('leaveGroup')}</MenuItem>
) : null}
{/* TODO: add delete group */}
{isGroup && isPublic ? (
<MenuItem onClick={onInviteFriends}>{i18n('inviteFriends')}</MenuItem>
) : null}
{!isMe && isClosable && !isPrivateGroup ? (
!isPublic ? (
<MenuItem onClick={onDeleteContact}>

View File

@ -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<Props> {
public render() {
const classes = ['group-invitation'];
if (this.props.direction === 'outgoing') {
classes.push('invitation-outgoing');
}
return (
<div className={'group-invitation-container'}>
<div className={classNames(classes)}>
<div className="title">Group invitation</div>
<div className="contents">
<img
alt="group-avatar"
src="images/loki/loki_icon.png"
className="invite-group-avatar"
/>
<span className="group-details">
<span className="group-name">{this.props.serverName}</span>
<span className="group-address">{this.props.serverAddress}</span>
</span>
<span
role="button"
className="join-btn"
onClick={this.props.onClick}
>
Join
</span>
</div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,132 @@
import React from 'react';
import { Contact, MemberList } from './MemberList';
interface Props {
friendList: Array<any>;
chatName: string;
onSubmit: any;
onClose: any;
}
declare global {
interface Window {
i18n: any;
}
}
interface State {
friendList: Array<Contact>;
}
export class InviteFriendsDialog extends React.Component<Props, State> {
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 (
<div className="content">
<p className="titleText">{titleText}</p>
<div className="friend-selection-list">
<MemberList
members={this.state.friendList}
selected={{}}
i18n={window.i18n}
onMemberClicked={this.onMemberClicked}
/>
</div>
<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 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,
};
});
}
}