Merge branch 'clearnet' into prep-merge

This commit is contained in:
Beaudan Brown 2019-11-15 12:31:40 +11:00
commit 685ada5cc5
53 changed files with 2386 additions and 351 deletions

View File

@ -49,7 +49,7 @@ module.exports = {
quotes: [
'error',
'single',
{ avoidEscape: true, allowTemplateLiterals: false },
{ avoidEscape: true, allowTemplateLiterals: true },
],
// Prettier overrides:
@ -65,6 +65,7 @@ module.exports = {
// We still want to limit comments as before:
comments: 90,
ignoreUrls: true,
ignoreRegExpLiterals: true,
},
],
},

View File

@ -309,6 +309,11 @@
"description":
"Displayed when a user can't send a message because they have left the group"
},
"youGotKickedFromGroup": {
"message": "You were removed from the group",
"description":
"Displayed when a user can't send a message because they have left the group"
},
"scrollDown": {
"message": "Scroll to bottom of conversation",
"description":
@ -928,6 +933,9 @@
"ok": {
"message": "OK"
},
"yes": {
"message": "Yes"
},
"cancel": {
"message": "Cancel"
},
@ -993,10 +1001,18 @@
"message":
"Are you sure? Clicking 'delete' will permanently remove this message for everyone in this channel."
},
"deleteMultiplePublicWarning": {
"message":
"Are you sure? Clicking 'delete' will permanently remove these messages for everyone in this channel."
},
"deleteWarning": {
"message":
"Are you sure? Clicking 'delete' will permanently remove this message from this device only."
},
"deleteMultipleWarning": {
"message":
"Are you sure? Clicking 'delete' will permanently remove these messages from this device only."
},
"deleteThisMessage": {
"message": "Delete this message"
},
@ -1042,6 +1058,9 @@
"description":
"Placeholder text in the message entry field when it is the first message sent to that contact"
},
"sendMessageLeftGroup": {
"message": "You left this group"
},
"groupMembers": {
"message": "Group members"
},
@ -1101,7 +1120,7 @@
"Confirmation dialog text that asks the user if they really wish to delete the public channel messages locally. Answer buttons use the strings 'ok' and 'cancel'. The deletion is permanent, i.e. it cannot be undone."
},
"deleteConversationConfirmation": {
"message": "Permanently delete this conversation?",
"message": "Permanently delete the messages in this conversation?",
"description":
"Confirmation dialog text that asks the user if they really wish to delete the conversation. Answer buttons use the strings 'ok' and 'cancel'. The deletion is permanent, i.e. it cannot be undone."
},
@ -1275,14 +1294,8 @@
"description":
"Option to control creation and send of link previews in setting screen"
},
"linkPreviewsDescription": {
"message":
"Previews are supported for Imgur, Instagram, Reddit, and YouTube links.",
"description":
"Additional detail provided for Link Previews option in settings screen"
},
"linkPreviewsSettingDescription": {
"message": "Enable local link previews",
"message": "Enable link previews",
"description": "Description shown for the Link Preview option "
},
"spellCheckDescription": {
@ -1887,6 +1900,28 @@
}
}
},
"kickedFromTheGroup": {
"message": "$name$ was removed from the group",
"description":
"Shown in the conversation history when a single person is removed from the group",
"placeholders": {
"name": {
"content": "$1",
"example": "Alice"
}
}
},
"multipleKickedFromTheGroup": {
"message": "$names$ were removed from the group",
"description":
"Shown in the conversation history when more than one person is removed from the group",
"placeholders": {
"names": {
"content": "$1",
"example": "Alice, Bob"
}
}
},
"friendRequestPending": {
"message": "Friend request",
"description":
@ -1951,8 +1986,7 @@
"Shown in the settings page as the heading for the blocked user settings"
},
"editProfileTitle": {
"message":
"Change your own display name (alphanumeric characters and underscores only)",
"message": "Change your own display name",
"description": "The title shown when user edits their own profile"
},
"editProfileDisplayNameWarning": {
@ -1965,6 +1999,28 @@
"description":
"Button action that the user can click to copy their public keys"
},
"copyChatId": {
"message": "Copy Chat ID"
},
"updateGroup": {
"message": "Update Group",
"description":
"Button action that the user can click to rename the group or add a new member"
},
"leaveGroup": {
"message": "Leave Group",
"description": "Button action that the user can click to leave the group"
},
"leaveGroupDialogTitle": {
"message": "Are you sure you want to leave this group?",
"description":
"Title shown to the user to confirm they want to leave the group"
},
"copiedPublicKey": {
"message": "Copied public key",
"description": "A toast message telling the user that the key was copied"
@ -1974,6 +2030,10 @@
"description":
"Button action that the user can click to copy their public keys"
},
"selectMessage": {
"message": "Select message",
"description": "Button action that the user can click to select the message"
},
"copiedMessage": {
"message": "Copied message text",
"description":
@ -1984,6 +2044,18 @@
"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"
},
"updateGroupDialogTitle": {
"message": "Updating a Private Group Chat",
"description":
"Title for the dialog box used to update an existing private group"
},
"showSeed": {
"message": "Show seed",
"description":
@ -2004,6 +2076,12 @@
"Title for the dialog box used to connect to a new public server"
},
"createPrivateGroup": {
"message": "Create Private Group",
"description":
"Button action that the user can click to show a dialog for creating a new private group chat"
},
"seedViewTitle": {
"message":
"Please save the seed below in a safe location. They can be used to restore your account if you lose access or migrate to a new device.",
@ -2122,5 +2200,26 @@
"friendsTab": {
"message": "Friends",
"description": "friend tab title"
},
"pending": {
"message": "pending",
"description": "Indicates that a friend request is pending"
},
"notFriends": {
"message": "not friends",
"description": "Indicates that a conversation is not friends with us"
},
"emptyGroupNameError": {
"message": "Group Name cannot be empty",
"description": "Error message displayed on empty group name"
},
"maxGroupMembersError": {
"message": "Max number of members for small group chats is: "
},
"nonAdminDeleteMember": {
"message": "Only group admin can remove members!"
},
"groupNamePlaceholder": {
"message": "Group Name"
}
}

View File

@ -108,10 +108,12 @@ module.exports = {
updateConversation,
removeConversation,
getAllConversations,
getPubKeysWithFriendStatus,
getConversationsWithFriendStatus,
getAllRssFeedConversations,
getAllPublicConversations,
getPublicConversationsByServer,
getPubkeysInPublicConversation,
getAllConversationIds,
getAllPrivateConversations,
getAllGroupsInvolvingId,
@ -1836,6 +1838,19 @@ async function getAllConversations() {
return map(rows, row => jsonToObject(row.json));
}
async function getPubKeysWithFriendStatus(status) {
const rows = await db.all(
`SELECT id FROM ${CONVERSATIONS_TABLE} WHERE
friendRequestStatus = $status
AND type = 'private'
ORDER BY id ASC;`,
{
$status: status,
}
);
return map(rows, row => row.id);
}
async function getConversationsWithFriendStatus(status) {
const rows = await db.all(
`SELECT * FROM ${CONVERSATIONS_TABLE} WHERE
@ -1901,6 +1916,19 @@ async function getPublicConversationsByServer(server) {
return map(rows, row => jsonToObject(row.json));
}
async function getPubkeysInPublicConversation(id) {
const rows = await db.all(
`SELECT DISTINCT source FROM messages WHERE
conversationId = $conversationId
ORDER BY id ASC;`,
{
$conversationId: id,
}
);
return map(rows, row => row.source);
}
async function getAllGroupsInvolvingId(id) {
const rows = await db.all(
`SELECT json FROM ${CONVERSATIONS_TABLE} WHERE

View File

@ -127,18 +127,19 @@
<div class='bottom-bar' id='footer'>
<div class='emoji-panel-container'></div>
<div class='member-list-container'></div>
<div id='bulk-edit-view'></div>
<div class='attachment-list'></div>
<div class='compose'>
<form class='send clearfix file-input'>
<div class='flex'>
<button class='emoji' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></button>
<textarea maxlength='2000' class='send-message' placeholder='{{ send-message }}' rows='1' dir='auto' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></textarea>
<div class='capture-audio hide'>
<div class='capture-audio'>
<button class='microphone' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></button>
</div>
<div id='choose-file' class='choose-file'>
<button class='paperclip thumbnail' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></button>
<input type='file' class='file-input' multiple='multiple' accept='video/* image/*'>
<input type='file' class='file-input' multiple='multiple'>
</div>
</div>
</form>
@ -196,7 +197,6 @@
<div class='message'>{{ message }}</div>
{{ /message }}
<div class='buttons'>
<button class='clear' tabindex='3'>{{ clear }}</button>
<button class='cancel' tabindex='2'>{{ cancel }}</button>
<button class='ok' tabindex='1'>{{ ok }}</button>
</div>
@ -731,8 +731,8 @@
<!-- Profile -->
<div class='page'>
<div class='display-name-input'>
<div class='input-header'>Enter your public display name (alphanumeric characters and underscores only)</div>
<input class='form-control' type='text' id='display-name' placeholder='Display Name (optional)' autocomplete='off' spellcheck='false' maxlength='25'>
<div class='input-header'>Enter your public display name</div>
<input class='form-control' type='text' id='display-name' placeholder='Display Name' autocomplete='off' spellcheck='false' maxlength='25'>
</div>
<div class='password-inputs'>
<div class='input-header'>Type an optional password for added security</div>
@ -741,8 +741,8 @@
<div class='error'></div>
</div>
<div class='buttons'>
<a class='button grey' id='back-button'>Back</a>
<a class='button' id='save-button' data-loading-text='Please wait...'>Save</a>
<button class='button grey' id='back-button' tabindex='2'>Back</button>
<button class='button' id='save-button' tabindex='1'>Save</button>
</div>
</div>
@ -791,6 +791,7 @@
<script type='text/javascript' src='js/views/key_verification_view.js'></script>
<script type='text/javascript' src='js/views/message_list_view.js'></script>
<script type='text/javascript' src='js/views/member_list_view.js'></script>
<script type='text/javascript' src='js/views/bulk_edit_view.js'></script>
<script type='text/javascript' src='js/views/group_member_list_view.js'></script>
<script type='text/javascript' src='js/views/recorder_view.js'></script>
<script type='text/javascript' src='js/views/conversation_view.js'></script>
@ -814,6 +815,7 @@
<script type='text/javascript' src='js/views/clear_data_view.js'></script>
<script type='text/javascript' src='js/views/device_pairing_dialog_view.js'></script>
<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/wall_clock_listener.js'></script>
<script type='text/javascript' src='js/rotate_signed_prekey_listener.js'></script>

View File

@ -5,5 +5,7 @@
<!-- Mac distribution -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

View File

@ -9,6 +9,8 @@
textsecure,
Whisper,
libloki,
libsignal,
StringView,
BlockedNumberController
*/
@ -706,6 +708,106 @@
}
});
window.doUpdateGroup = async (groupId, groupName, members) => {
const ourKey = textsecure.storage.user.getNumber();
const ev = new Event('message');
ev.confirm = () => {};
ev.data = {
source: ourKey,
message: {
group: {
id: groupId,
type: textsecure.protobuf.GroupContext.Type.UPDATE,
name: groupName,
members,
avatar: null, // TODO
},
},
};
const convo = await ConversationController.getOrCreateAndWait(
groupId,
'group'
);
const avatar = '';
const options = {};
const recipients = _.union(convo.get('members'), members);
await onMessageReceived(ev);
convo.updateGroup({
groupId,
groupName,
avatar,
recipients,
members,
options,
});
};
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();
const allMembers = [ourKey, ...members];
ev.groupDetails = {
id: groupId,
name: groupName,
members: allMembers,
recipients: allMembers,
active: true,
expireTimer: 0,
avatar: '',
};
ev.confirm = () => {};
await onGroupReceived(ev);
const convo = await ConversationController.getOrCreateAndWait(
groupId,
'group'
);
convo.updateGroup(ev.groupDetails);
// Group conversations are automatically 'friends'
// so that we can skip the friend request logic
convo.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
convo.updateGroupAdmins([ourKey]);
appView.openConversation(groupId, {});
};
Whisper.events.on('createNewGroup', async () => {
if (appView) {
appView.showCreateGroup();
}
});
Whisper.events.on('updateGroup', async groupConvo => {
if (appView) {
appView.showUpdateGroupDialog(groupConvo);
}
});
Whisper.events.on('leaveGroup', async groupConvo => {
if (appView) {
appView.showLeaveGroupDialog(groupConvo);
}
});
Whisper.events.on('deleteConversation', async conversation => {
await conversation.destroyMessages();
await window.Signal.Data.removeConversation(conversation.id, {
@ -1006,6 +1108,10 @@
messageReceiver.addEventListener('configuration', onConfiguration);
messageReceiver.addEventListener('typing', onTyping);
Whisper.events.on('endSession', source => {
messageReceiver.handleEndSession(source);
});
window.Signal.AttachmentDownloads.start({
getMessageReceiver: () => messageReceiver,
logger: window.log,

View File

@ -84,6 +84,8 @@
unlockTimestamp: null, // Timestamp used for expiring friend requests.
sessionResetStatus: SessionResetEnum.none,
swarmNodes: [],
groupAdmins: [],
isKickedFromGroup: false,
isOnline: false,
};
},
@ -179,6 +181,8 @@
this.messageSendQueue = new JobQueue();
this.selectedMessages = new Set();
// Keep props ready
const generateProps = () => {
this.cachedProps = this.getProps();
@ -217,6 +221,43 @@
this.messageCollection.forEach(m => m.trigger('change'));
},
addMessageSelection(id) {
// If the selection is empty, then we chage the mode to
// multiple selection by making it non-empty
const modeChanged = this.selectedMessages.size === 0;
this.selectedMessages.add(id);
if (modeChanged) {
this.messageCollection.forEach(m => m.trigger('change'));
}
this.trigger('message-selection-changed');
},
removeMessageSelection(id) {
this.selectedMessages.delete(id);
// If the selection is empty after the deletion then we
// must have unselected the last one (we assume the id is valid)
const modeChanged = this.selectedMessages.size === 0;
if (modeChanged) {
this.messageCollection.forEach(m => m.trigger('change'));
}
this.trigger('message-selection-changed');
},
resetMessageSelection() {
this.selectedMessages.clear();
this.messageCollection.forEach(m => {
// eslint-disable-next-line no-param-reassign
m.selected = false;
m.trigger('change');
});
this.trigger('message-selection-changed');
},
async bumpTyping() {
// We don't send typing messages if the setting is disabled or we aren't friends
const hasFriendDevice = await this.isFriendWithAnyDevice();
@ -484,6 +525,8 @@
hasNickname: !!this.getNickname(),
isFriend: !!this.isFriendWithAnyCache,
selectedMessages: this.selectedMessages,
onClick: () => this.trigger('select', this),
onBlockContact: () => this.block(),
onUnblockContact: () => this.unblock(),
@ -743,6 +786,15 @@
}
const allDeviceStatus = await this.getAnyDeviceFriendRequestStatus();
if (this.get('isKickedFromGroup')) {
this.trigger('disable:input', true);
return;
}
if (!this.isPrivate() && this.get('left')) {
this.trigger('disable:input', true);
this.trigger('change:placeholder', 'left-group');
return;
}
switch (allDeviceStatus) {
case FriendRequestStatusEnum.none:
case FriendRequestStatusEnum.requestExpired:
@ -804,6 +856,12 @@
}
}
},
async updateGroupAdmins(groupAdmins) {
this.set({ groupAdmins });
await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation,
});
},
async respondToAllFriendRequests(options) {
const { response, status, direction = null } = options;
// Ignore if no response supplied
@ -886,7 +944,7 @@
response: 'accepted',
status: ['pending', 'expired'],
});
window.libloki.api.sendOnlineBroadcastMessage(this.id);
window.libloki.api.sendBackgroundMessage(this.id);
return true;
}
return false;
@ -1929,7 +1987,7 @@
await this.setSessionResetStatus(SessionResetEnum.request_received);
// send empty message, this will trigger the new session to propagate
// to the reset initiator.
await window.libloki.api.sendBackgroundMessage(this.id);
window.libloki.api.sendBackgroundMessage(this.id);
},
isSessionResetReceived() {
@ -1965,7 +2023,7 @@
async onNewSessionAdopted() {
if (this.get('sessionResetStatus') === SessionResetEnum.initiated) {
// send empty message to confirm that we have adopted the new session
await window.libloki.api.sendBackgroundMessage(this.id);
window.libloki.api.sendBackgroundMessage(this.id);
}
await this.createAndStoreEndSessionMessage({
type: 'incoming',
@ -2035,6 +2093,7 @@
this.get('name'),
this.get('avatar'),
this.get('members'),
groupUpdate.recipients,
options
)
)
@ -2069,6 +2128,8 @@
textsecure.messaging.leaveGroup(this.id, groupNumbers, options)
)
);
this.updateTextInputState();
}
},
@ -2122,7 +2183,9 @@
);
const ourNumber = textsecure.storage.user.getNumber();
return !stillUnread.some(
m => m.propsForMessage.text.indexOf(`@${ourNumber}`) !== -1
m =>
m.propsForMessage.text &&
m.propsForMessage.text.indexOf(`@${ourNumber}`) !== -1
);
})();
@ -2525,16 +2588,38 @@
});
},
async deletePublicMessage(message) {
const channelAPI = this.getPublicSendData();
async deletePublicMessages(messages) {
const channelAPI = await this.getPublicSendData();
if (!channelAPI) {
return false;
}
const success = await channelAPI.deleteMessage(message.getServerId());
if (success) {
this.removeMessage(message.id);
const invalidMessages = messages.filter(m => !m.getServerId());
const pendingMessages = messages.filter(m => m.getServerId());
let deletedServerIds = [];
let ignoredServerIds = [];
if (pendingMessages.length > 0) {
const result = await channelAPI.deleteMessages(
pendingMessages.map(m => m.getServerId())
);
deletedServerIds = result.deletedIds;
ignoredServerIds = result.ignoredIds;
}
return success;
const toDeleteLocallyServerIds = _.union(
deletedServerIds,
ignoredServerIds
);
let toDeleteLocally = messages.filter(m =>
toDeleteLocallyServerIds.includes(m.getServerId())
);
toDeleteLocally = _.union(toDeleteLocally, invalidMessages);
toDeleteLocally.forEach(m => this.removeMessage(m.id));
return toDeleteLocally;
},
removeMessage(messageId) {
@ -2548,6 +2633,7 @@
},
deleteMessages() {
this.resetMessageSelection();
if (this.isPublic()) {
Whisper.events.trigger('showConfirmationDialog', {
message: i18n('deletePublicConversationConfirmation'),
@ -2555,8 +2641,8 @@
});
} else {
Whisper.events.trigger('showConfirmationDialog', {
message: i18n('deleteContactConfirmation'),
onOk: () => ConversationController.deleteContact(this.id),
message: i18n('deleteConversationConfirmation'),
onOk: () => this.destroyMessages(),
});
}
},
@ -2799,6 +2885,23 @@
return;
}
// For groups, block typing messages from non-members (e.g. from kicked members)
if (this.get('type') === 'group') {
const knownMembers = this.get('members');
if (knownMembers) {
const fromMember = knownMembers.includes(sender);
if (!fromMember) {
window.log.warn(
'Blocking typing messages from a non-member: ',
sender
);
return;
}
}
}
const identifier = `${sender}.${senderDevice}`;
this.contactTypingTimers = this.contactTypingTimers || {};

View File

@ -126,6 +126,8 @@
);
}
this.selected = false;
generateProps();
},
idForLogging() {
@ -205,8 +207,12 @@
return i18n('leftTheGroup', this.getNameForNumber(groupUpdate.left));
}
if (groupUpdate.kicked === 'You') {
return i18n('youGotKickedFromGroup');
}
const messages = [];
if (!groupUpdate.name && !groupUpdate.joined) {
if (!groupUpdate.name && !groupUpdate.joined && !groupUpdate.kicked) {
messages.push(i18n('updatedTheGroup'));
}
if (groupUpdate.name) {
@ -224,6 +230,18 @@
}
}
if (groupUpdate.kicked && groupUpdate.kicked.length) {
const names = _.map(
groupUpdate.kicked,
this.getNameForNumber.bind(this)
);
if (names.length > 1) {
messages.push(i18n('multipleKickedFromTheGroup', names.join(', ')));
} else {
messages.push(i18n('kickedFromTheGroup', names[0]));
}
}
return messages.join(', ');
}
if (this.isEndSession()) {
@ -463,6 +481,23 @@
});
}
if (groupUpdate.kicked === 'You') {
changes.push({
type: 'kicked',
isMe: true,
});
} else if (groupUpdate.kicked) {
changes.push({
type: 'kicked',
contacts: _.map(
Array.isArray(groupUpdate.kicked)
? groupUpdate.kicked
: [groupUpdate.kicked],
phoneNumber => this.findAndFormatContact(phoneNumber)
),
});
}
if (groupUpdate.left === 'You') {
changes.push({
type: 'remove',
@ -574,8 +609,12 @@
? expireTimerStart + expirationLength
: null;
// TODO: investigate why conversation is undefined
// for the public group chat
const conversation = this.getConversation();
const isGroup = conversation && !conversation.isPrivate();
const convoId = conversation ? conversation.id : undefined;
const isGroup = !!conversation && !conversation.isPrivate();
const attachments = this.get('attachments') || [];
const firstAttachment = attachments[0];
@ -593,6 +632,7 @@
authorProfileName: contact.profileName,
authorPhoneNumber: contact.phoneNumber,
conversationType: isGroup ? 'group' : 'direct',
convoId,
attachments: attachments
.filter(attachment => !attachment.error)
.map(attachment => this.getPropsForAttachment(attachment)),
@ -602,6 +642,8 @@
isExpired: this.hasExpired,
expirationLength,
expirationTimestamp,
selected: this.selected,
multiSelectMode: conversation && conversation.selectedMessages.size > 0,
isP2p: !!this.get('isP2p'),
isPublic: !!this.get('isPublic'),
isRss: !!this.get('isRss'),
@ -614,6 +656,7 @@
this.getSource() === this.OUR_NUMBER,
onCopyText: () => this.copyText(),
onSelectMessage: () => this.selectMessage(),
onCopyPubKey: () => this.copyPubKey(),
onReply: () => this.trigger('reply', this),
onRetrySend: () => this.retrySend(),
@ -922,6 +965,20 @@
});
},
selectMessage() {
this.selected = !this.selected;
const convo = this.getConversation();
if (this.selected) {
convo.addMessageSelection(this);
} else {
convo.removeMessageSelection(this);
}
this.trigger('change');
},
copyText() {
clipboard.writeText(this.get('body'));
window.Whisper.events.trigger('showToast', {
@ -1732,6 +1789,121 @@
const GROUP_TYPES = textsecure.protobuf.GroupContext.Type;
const conversation = ConversationController.get(conversationId);
// NOTE: we use friends status to tell if this is
// the creation of the group (initial update)
const newGroup = !conversation.isFriend();
const knownMembers = conversation.get('members');
if (!newGroup && knownMembers) {
const fromMember = knownMembers.includes(source);
if (!fromMember) {
window.log.warn(`Ignoring group message from non-member: ${source}`);
confirm();
return null;
}
}
if (
initialMessage.group &&
initialMessage.group.members &&
initialMessage.group.type === GROUP_TYPES.UPDATE
) {
if (newGroup) {
conversation.updateGroupAdmins(initialMessage.group.admins);
conversation.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
}
const fromAdmin = conversation.get('groupAdmins').includes(source);
if (!fromAdmin) {
// Make sure the message is not removing members / renaming the group
const nameChanged =
conversation.get('name') !== initialMessage.group.name;
if (nameChanged) {
window.log.warn(
'Non-admin attempts to change the name of the group'
);
}
const membersMissing =
_.difference(
conversation.get('members'),
initialMessage.group.members
).length > 0;
if (membersMissing) {
window.log.warn('Non-admin attempts to remove group members');
}
const messageAllowed = !nameChanged && !membersMissing;
if (!messageAllowed) {
confirm();
return null;
}
}
// For every member, see if we need to establish a session:
initialMessage.group.members.forEach(memberPubKey => {
const haveSession = _.some(
textsecure.storage.protocol.sessions,
s => s.number === memberPubKey
);
const ourPubKey = textsecure.storage.user.getNumber();
if (!haveSession && memberPubKey !== ourPubKey) {
ConversationController.getOrCreateAndWait(
memberPubKey,
'private'
).then(() => {
textsecure.messaging.sendMessageToNumber(
memberPubKey,
'(If you see this message, you must be using an out-of-date client)',
[],
undefined,
[],
Date.now(),
undefined,
undefined,
{ messageType: 'friend-request', backgroundFriendReq: true }
);
});
}
});
}
const backgroundFrReq =
initialMessage.flags ===
textsecure.protobuf.DataMessage.Flags.BACKGROUND_FRIEND_REQUEST;
if (message.isFriendRequest() && backgroundFrReq) {
// Check if the contact is a member in one of our private groups:
const groupMember = window
.getConversations()
.models.filter(c => c.get('members'))
.reduce((acc, x) => window.Lodash.concat(acc, x.get('members')), [])
.includes(source);
if (groupMember) {
window.log.info(
`Auto accepting a 'group' friend request for a known group member: ${groupMember}`
);
window.libloki.api.sendBackgroundMessage(message.get('source'));
confirm();
}
// Wether or not we accepted the FR, we exit early so background friend requests
// cannot be used for establishing regular private conversations
return null;
}
return conversation.queueJob(async () => {
window.log.info(
`Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversation.idForLogging()}`
@ -1757,10 +1929,7 @@
attributes = {
...attributes,
name: dataMessage.group.name,
members: _.union(
dataMessage.group.members,
conversation.get('members')
),
members: dataMessage.group.members,
};
groupUpdate =
@ -1768,17 +1937,41 @@
_.pick(dataMessage.group, 'name', 'avatar')
) || {};
const difference = _.difference(
const addedMembers = _.difference(
attributes.members,
conversation.get('members')
);
if (difference.length > 0) {
groupUpdate.joined = difference;
if (addedMembers.length > 0) {
groupUpdate.joined = addedMembers;
}
if (conversation.get('left')) {
// TODO: Maybe we shouldn't assume this message adds us:
// we could maybe still get this message by mistake
window.log.warn('re-added to a left group');
attributes.left = false;
}
if (attributes.isKickedFromGroup) {
// Assume somebody re-invited us since we received this update
attributes.isKickedFromGroup = false;
}
// Check if anyone got kicked:
const removedMembers = _.difference(
conversation.get('members'),
attributes.members
);
if (removedMembers.length > 0) {
if (
removedMembers.includes(textsecure.storage.user.getNumber())
) {
groupUpdate.kicked = 'You';
attributes.isKickedFromGroup = true;
} else {
groupUpdate.kicked = removedMembers;
}
}
} else if (dataMessage.group.type === GROUP_TYPES.QUIT) {
if (source === textsecure.storage.user.getNumber()) {
attributes.left = true;
@ -1845,6 +2038,9 @@
attributes.active_at = now;
conversation.set(attributes);
// Re-enable typing if re-joined the group
conversation.updateTextInputState();
if (message.isExpirationTimerUpdate()) {
message.set({
expirationTimerUpdate: {
@ -2003,13 +2199,17 @@
- We sent the user a friend request and that user sent us a friend request.
- We are friends with the user, and that user just sent us a friend request.
*/
if (
sendingDeviceConversation.hasSentFriendRequest() ||
sendingDeviceConversation.isFriend()
) {
// Automatically accept incoming friend requests if we have send one already
autoAccept = true;
const isFriend = sendingDeviceConversation.isFriend();
const hasSentFriendRequest = sendingDeviceConversation.hasSentFriendRequest();
autoAccept = isFriend || hasSentFriendRequest;
if (autoAccept) {
message.set({ friendStatus: 'accepted' });
}
if (isFriend) {
window.Whisper.events.trigger('endSession', source);
} else if (hasSentFriendRequest) {
await sendingDeviceConversation.onFriendRequestAccepted();
} else {
await sendingDeviceConversation.onFriendRequestReceived();
@ -2021,7 +2221,8 @@
if (
!message.get('body') &&
!message.get('attachments').length &&
!message.get('preview').length
!message.get('preview').length &&
!message.get('group_update')
) {
return;
}

View File

@ -1,7 +1,9 @@
/* global window, setTimeout, IDBKeyRange, dcodeIO */
/* global window, setTimeout, clearTimeout, IDBKeyRange, dcodeIO */
const electron = require('electron');
// TODO: this results in poor readability, would be
// much better to explicitly call with `_`.
const {
cloneDeep,
forEach,
@ -9,12 +11,13 @@ const {
isFunction,
isObject,
map,
merge,
set,
omit,
isArrayBuffer,
} = require('lodash');
const _ = require('lodash');
const { base64ToArrayBuffer, arrayBufferToBase64 } = require('./crypto');
const MessageType = require('./types/message');
@ -132,6 +135,7 @@ module.exports = {
getAllRssFeedConversations,
getAllPublicConversations,
getPublicConversationsByServer,
getPubkeysInPublicConversation,
savePublicServerToken,
getPublicServerTokenByServerUrl,
getAllGroupsInvolvingId,
@ -296,13 +300,13 @@ function _updateJob(id, data) {
...data,
resolve: value => {
_removeJob(id);
const end = Date.now();
const delta = end - start;
if (delta > 10) {
window.log.debug(
`SQL channel job ${id} (${fnName}) succeeded in ${end - start}ms`
);
}
// const end = Date.now();
// const delta = end - start;
// if (delta > 10) {
// window.log.debug(
// `SQL channel job ${id} (${fnName}) succeeded in ${end - start}ms`
// );
// }
return resolve(value);
},
reject: error => {
@ -322,6 +326,11 @@ function _removeJob(id) {
return;
}
if (_jobs[id].timer) {
clearTimeout(_jobs[id].timer);
_jobs[id].timer = null;
}
delete _jobs[id];
if (_shutdownCallback) {
@ -373,7 +382,7 @@ function makeChannel(fnName) {
args: _DEBUG ? args : null,
});
setTimeout(
_jobs[jobId].timer = setTimeout(
() =>
reject(new Error(`SQL channel job ${jobId} (${fnName}) timed out`)),
DATABASE_UPDATE_TIMEOUT
@ -732,17 +741,6 @@ async function getAllSessions(id) {
// Conversation
function setifyProperty(data, propertyName) {
if (!data) {
return data;
}
const returnData = { ...data };
if (Array.isArray(returnData[propertyName])) {
returnData[propertyName] = new Set(returnData[propertyName]);
}
return returnData;
}
async function getSwarmNodesByPubkey(pubkey) {
return channels.getSwarmNodesByPubkey(pubkey);
}
@ -771,13 +769,14 @@ async function updateConversation(id, data, { Conversation }) {
if (!existing) {
throw new Error(`Conversation ${id} does not exist!`);
}
const setData = setifyProperty(data, 'swarmNodes');
const setExisting = setifyProperty(existing.attributes, 'swarmNodes');
const merged = merge({}, setExisting, setData);
if (merged.swarmNodes instanceof Set) {
merged.swarmNodes = Array.from(merged.swarmNodes);
}
const merged = _.merge({}, existing.attributes, data);
// Merging is a really bad idea and not what we want here, e.g.
// it will take a union of old and new members and that's not
// what we want for member deletion, so:
merged.members = data.members;
merged.swarmNodes = data.swarmNodes;
// Don't save the online status of the object
const cleaned = omit(merged, 'isOnline');
@ -853,6 +852,10 @@ async function getAllPrivateConversations({ ConversationCollection }) {
return collection;
}
async function getPubkeysInPublicConversation(id) {
return channels.getPubkeysInPublicConversation(id);
}
async function savePublicServerToken(data) {
await channels.savePublicServerToken(data);
}

View File

@ -24,7 +24,7 @@ class LokiAppDotNetAPI extends EventEmitter {
this.ourKey = ourKey;
this.servers = [];
this.myPrivateKey = false;
this.allMembers = [];
// Multidevice states
this.slavePrimaryMap = {};
this.primaryUserProfileName = {};
@ -568,26 +568,48 @@ class LokiPublicChannelAPI {
await this.conversation.setModerators(moderators || []);
}
// delete a message on the server
async deleteMessage(serverId, canThrow = false) {
// delete messages on the server
async deleteMessages(serverIds, canThrow = false) {
const res = await this.serverRequest(
this.modStatus
? `loki/v1/moderation/message/${serverId}`
: `${this.baseChannelUrl}/messages/${serverId}`,
{ method: 'DELETE' }
this.modStatus ? `loki/v1/moderation/messages` : `loki/v1/messages`,
{ method: 'DELETE', params: { ids: serverIds } }
);
if (!res.err && res.response) {
log.info(`deleted ${serverId} on ${this.baseChannelUrl}`);
return true;
if (!res.err) {
const deletedIds = res.response.data
.filter(d => d.is_deleted)
.map(d => d.id);
if (deletedIds.length > 0) {
log.info(`deleted ${serverIds} on ${this.baseChannelUrl}`);
}
const failedIds = res.response.data
.filter(d => !d.is_deleted)
.map(d => d.id);
if (failedIds.length > 0) {
log.warn(`failed to delete ${failedIds} on ${this.baseChannelUrl}`);
}
// Note: if there is no entry for message, we assume it wasn't found
// on the server, so it is not treated as explicitly failed
const ignoredIds = _.difference(
serverIds,
_.union(failedIds, deletedIds)
);
if (ignoredIds.length > 0) {
log.warn(`No response for ${ignoredIds} on ${this.baseChannelUrl}`);
}
return { deletedIds, ignoredIds };
}
// fire an alert
log.warn(`failed to delete ${serverId} on ${this.baseChannelUrl}`);
if (canThrow) {
throw new textsecure.PublicChatError(
'Failed to delete public chat message'
);
}
return false;
return { deletedIds: [], ignoredIds: [] };
}
// used for sending messages
@ -812,8 +834,8 @@ class LokiPublicChannelAPI {
log.warn(`Error while polling for public chat messages: ${e}`);
}
if (this.running) {
setTimeout(() => {
this.timers.message = this.pollForMessages();
this.timers.message = setTimeout(() => {
this.pollForMessages();
}, PUBLICCHAT_MSG_POLL_EVERY);
}
}
@ -1083,7 +1105,16 @@ class LokiPublicChannelAPI {
}
static getAnnotationFromAttachment(attachment) {
const type = attachment.contentType.match(/^image/) ? 'photo' : 'video';
let type;
if (attachment.contentType.match(/^image/)) {
type = 'photo';
} else if (attachment.contentType.match(/^video/)) {
type = 'video';
} else if (attachment.contentType.match(/^audio/)) {
type = 'audio';
} else {
type = 'other';
}
const annotation = {
type: ATTACHMENT_TYPE,
value: {

View File

@ -44,6 +44,14 @@ 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 { BulkEdit } = require('../../ts/components/conversation/BulkEdit');
const {
CreateGroupDialog,
} = require('../../ts/components/conversation/CreateGroupDialog');
const {
UpdateGroupDialog,
} = require('../../ts/components/conversation/UpdateGroupDialog');
const { ConfirmDialog } = require('../../ts/components/ConfirmDialog');
const {
MediaGallery,
} = require('../../ts/components/conversation/media-gallery/MediaGallery');
@ -219,6 +227,10 @@ exports.setup = (options = {}) => {
LightboxGallery,
MainHeader,
MemberList,
CreateGroupDialog,
ConfirmDialog,
UpdateGroupDialog,
BulkEdit,
MediaGallery,
Message,
MessageBody,

View File

@ -232,5 +232,20 @@
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);
},
showUpdateGroupDialog(groupConvo) {
const dialog = new Whisper.UpdateGroupDialogView(groupConvo);
this.el.append(dialog.el);
},
showLeaveGroupDialog(groupConvo) {
const dialog = new Whisper.LeaveGroupDialogView(groupConvo);
this.el.append(dialog.el);
},
});
})();

View File

@ -0,0 +1,37 @@
/* global Whisper, */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.BulkEditView = Whisper.View.extend({
initialize(options) {
this.memberView = null;
this.props = {
onCancel: options.onCancel,
onDelete: options.onDelete,
messageCount: 0,
};
},
render() {
if (this.memberView) {
this.memberView.update(this.props);
return;
}
this.memberView = new Whisper.ReactWrapperView({
className: 'bulk-edit-view',
Component: window.Signal.Components.BulkEdit,
props: this.props,
});
this.$el.append(this.memberView.el);
},
update(selectionSize) {
this.props.messageCount = selectionSize;
this.render();
},
});
})();

View File

@ -147,6 +147,11 @@
'show-message-detail',
this.showMessageDetail
);
this.listenTo(
this.model,
'message-selection-changed',
this.onMessageSelectionChanged
);
this.listenTo(this.model.messageCollection, 'navigate-to', url => {
window.location = url;
});
@ -189,6 +194,9 @@
this.removeLinkPreview();
}
});
Whisper.events.on('mediaPermissionsChanged', () =>
this.toggleMicrophone()
);
const getHeaderProps = () => {
const expireTimer = this.model.get('expireTimer');
@ -196,6 +204,8 @@
? Whisper.ExpirationTimerOptions.getName(expireTimer || 0)
: null;
const members = this.model.get('members') || [];
return {
id: this.model.id,
name: this.model.getName(),
@ -203,9 +213,9 @@
profileName: this.model.getProfileName(),
color: this.model.getColor(),
avatarPath: this.model.getAvatarPath(),
isVerified: this.model.isVerified(),
isKeysPending: !this.model.isFriend(),
isFriendRequestPending: this.model.isPendingFriendRequest(),
isFriend: this.model.isFriend(),
isMe: this.model.isMe(),
isClosable: this.model.isClosable(),
isBlocked: this.model.isBlocked(),
@ -213,7 +223,7 @@
isOnline: this.model.isOnline(),
isArchived: this.model.get('isArchived'),
isPublic: this.model.isPublic(),
members,
expirationSettingName,
showBackButton: Boolean(this.panels && this.panels.length),
timerOptions: Whisper.ExpirationTimerOptions.map(item => ({
@ -268,6 +278,14 @@
onMoveToInbox: () => {
this.model.setArchived(false);
},
onUpdateGroup: () => {
window.Whisper.events.trigger('updateGroup', this.model);
},
onLeaveGroup: () => {
window.Whisper.events.trigger('leaveGroup', this.model);
},
};
};
this.titleView = new Whisper.ReactWrapperView({
@ -293,6 +311,12 @@
this.memberView.render();
this.bulkEditView = new Whisper.BulkEditView({
el: this.$('#bulk-edit-view'),
onCancel: this.resetMessageSelection.bind(this),
onDelete: this.deleteSelectedMessages.bind(this),
});
this.$messageField = this.$('.send-message');
this.onResize = this.forceUpdateMessageFieldSize.bind(this);
@ -320,16 +344,24 @@
this.selectMember = this.selectMember.bind(this);
const updateMemberList = async () => {
const maxToFetch = 1000;
const allMessages = await window.Signal.Data.getMessagesByConversation(
this.model.id,
{
limit: maxToFetch,
MessageCollection: Whisper.MessageCollection,
}
const allPubKeys = await window.Signal.Data.getPubkeysInPublicConversation(
this.model.id
);
const allMembers = allMessages.models.map(d => d.propsForMessage);
const allMembers = await Promise.all(
allPubKeys.map(async pubKey => {
const conv = ConversationController.get(pubKey);
let profileName = 'Anonymous';
if (conv) {
profileName = await conv.getProfileName();
}
return {
id: pubKey,
authorPhoneNumber: pubKey,
authorProfileName: profileName,
};
})
);
window.lokiPublicChatAPI.setListOfMembers(allMembers);
};
@ -439,6 +471,9 @@
case 'secondary':
placeholder = i18n('sendMessageDisabledSecondary');
break;
case 'left-group':
placeholder = i18n('sendMessageLeftGroup');
break;
default:
placeholder = i18n('sendMessage');
break;
@ -638,12 +673,10 @@
}
},
toggleMicrophone() {
// ALWAYS HIDE until we support audio
this.$('.capture-audio').hide();
/*
async toggleMicrophone() {
const allowMicrophone = await window.getMediaPermissions();
if (
!allowMicrophone ||
this.$('.send-message').val().length > 0 ||
this.fileInput.hasFiles()
) {
@ -651,7 +684,6 @@
} else {
this.$('.capture-audio').show();
}
*/
},
captureAudio(e) {
e.preventDefault();
@ -739,6 +771,17 @@
this.lastActivity = Date.now();
this.model.updateLastMessage();
this.model.resetMessageSelection();
if (this.model.isRss()) {
$('.compose').hide();
$('.conversation-stack').removeClass('conversation-stack-no-border');
$('.conversation-stack').addClass('conversation-stack-border');
} else {
$('.compose').show();
$('.conversation-stack').removeClass('conversation-stack-border');
$('.conversation-stack').addClass('conversation-stack-no-border');
}
// const statusPromise = this.throttledGetProfiles();
// // eslint-disable-next-line more/no-then
@ -1338,37 +1381,78 @@
});
},
deleteMessage(message) {
const warningMessage = this.model.isPublic()
? i18n('deletePublicWarning')
: i18n('deleteWarning');
deleteSelectedMessages() {
const msgArray = Array.from(this.model.selectedMessages);
this.deleteMessages(msgArray, () => {
this.resetMessageSelection();
});
},
deleteMessages(messages, onSuccess) {
const multiple = messages.length > 1;
const warningMessage = (() => {
if (this.model.isPublic()) {
return multiple
? i18n('deleteMultiplePublicWarning')
: i18n('deletePublicWarning');
}
return multiple ? i18n('deleteMultipleWarning') : i18n('deleteWarning');
})();
const doDelete = async () => {
let toDeleteLocally;
if (this.model.isPublic()) {
toDeleteLocally = await this.model.deletePublicMessages(messages);
if (toDeleteLocally.length === 0) {
// Message failed to delete from server, show error?
return;
}
} else {
messages.forEach(m => this.model.messageCollection.remove(m.id));
toDeleteLocally = messages;
}
await Promise.all(
toDeleteLocally.map(async m => {
await window.Signal.Data.removeMessage(m.id, {
Message: Whisper.Message,
});
m.trigger('unload');
})
);
this.resetPanel();
this.updateHeader();
if (onSuccess) {
onSuccess();
}
};
// Only show a warning when at least one messages was successfully
// saved in on the server
if (!messages.some(m => !m.hasErrors())) {
doDelete();
return;
}
const dialog = new Whisper.ConfirmationDialogView({
message: warningMessage,
okText: i18n('delete'),
resolve: async () => {
if (this.model.isPublic()) {
const success = await this.model.deletePublicMessage(message);
if (!success) {
// Message failed to delete from server, show error?
return;
}
} else {
this.model.messageCollection.remove(message.id);
}
await window.Signal.Data.removeMessage(message.id, {
Message: Whisper.Message,
});
message.trigger('unload');
this.resetPanel();
this.updateHeader();
},
resolve: doDelete,
});
this.$el.prepend(dialog.el);
dialog.focusCancel();
},
deleteMessage(message) {
this.deleteMessages([message]);
},
showLightbox({ attachment, message }) {
const { contentType, path } = attachment;
@ -1712,6 +1796,22 @@
}
},
onMessageSelectionChanged() {
const selectionSize = this.model.selectedMessages.size;
if (selectionSize > 0) {
$('.compose').hide();
} else {
$('.compose').show();
}
this.bulkEditView.update(selectionSize);
},
resetMessageSelection() {
this.model.resetMessageSelection();
},
toggleEmojiPanel(e) {
e.preventDefault();
if (!this.emojiPanel) {
@ -1724,6 +1824,9 @@
if (event.key !== 'Escape') {
return;
}
// TODO: this view is not always in focus (e.g. after I've selected a message),
// so need to make Esc more robust
this.model.resetMessageSelection();
this.closeEmojiPanel();
},
openEmojiPanel() {
@ -2375,7 +2478,7 @@
// Note: schedule the member list handler shortly afterwards, so
// that the input element has time to update its cursor position to
// what the user would expect
if (this.model.isPublic()) {
if (this.model.get('type') === 'group') {
window.requestAnimationFrame(this.maybeShowMembers.bind(this, event));
}
@ -2507,10 +2610,33 @@
return query;
};
let allMembers = window.lokiPublicChatAPI.getListOfMembers();
allMembers = allMembers.filter(d => !!d);
allMembers = allMembers.filter(d => d.authorProfileName !== 'Anonymous');
allMembers = _.uniq(allMembers, true, d => d.authorPhoneNumber);
let allMembers;
if (this.model.isPublic()) {
const members = window.lokiPublicChatAPI
.getListOfMembers()
.filter(d => !!d)
.filter(d => d.authorProfileName !== 'Anonymous');
allMembers = _.uniq(members, true, d => d.authorPhoneNumber);
} else {
const members = this.model.get('members');
if (!members || members.length === 0) {
return;
}
const privateConvos = window
.getConversations()
.models.filter(d => d.isPrivate());
const memberConvos = members
.map(m => privateConvos.find(c => c.id === m))
.filter(c => !!c && c.getLokiProfile());
allMembers = memberConvos.map(c => ({
id: c.id,
authorPhoneNumber: c.id,
authorProfileName: c.getLokiProfile().displayName,
}));
}
const cursorPos = event.target.selectionStart;

View File

@ -0,0 +1,156 @@
/* global Whisper, i18n, textsecure, _ */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.CreateGroupDialogView = Whisper.View.extend({
className: 'loki-dialog modal',
initialize() {
this.titleText = i18n('createGroupDialogTitle');
this.okText = i18n('ok');
this.cancelText = i18n('cancel');
this.close = this.close.bind(this);
const convos = window.getConversations().models;
let allMembers = convos.filter(
d => !!d && d.isFriend() && d.isPrivate() && !d.isMe()
);
allMembers = _.uniq(allMembers, true, d => d.id);
this.membersToShow = allMembers;
this.$el.focus();
this.render();
},
render() {
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: this.membersToShow,
onClose: this.close,
},
});
this.$el.append(this.dialogView.el);
return this;
},
close() {
this.remove();
},
});
Whisper.LeaveGroupDialogView = Whisper.View.extend({
className: 'loki-dialog modal',
initialize(groupConvo) {
this.groupConvo = groupConvo;
this.titleText = groupConvo.get('name');
this.messageText = i18n('leaveGroupDialogTitle');
this.okText = i18n('yes');
this.cancelText = i18n('cancel');
this.close = this.close.bind(this);
this.confirm = this.confirm.bind(this);
this.$el.focus();
this.render();
},
render() {
this.dialogView = new Whisper.ReactWrapperView({
className: 'leave-group-dialog',
Component: window.Signal.Components.ConfirmDialog,
props: {
titleText: this.titleText,
messageText: this.messageText,
okText: this.okText,
cancelText: this.cancelText,
onConfirm: this.confirm,
onClose: this.close,
},
});
this.$el.append(this.dialogView.el);
return this;
},
async confirm() {
await this.groupConvo.leaveGroup();
this.close();
},
close() {
this.remove();
},
});
Whisper.UpdateGroupDialogView = Whisper.View.extend({
className: 'loki-dialog modal',
initialize(groupConvo) {
this.groupName = groupConvo.get('name');
this.conversation = groupConvo;
this.titleText = `${i18n('updateGroupDialogTitle')}: ${this.groupName}`;
this.okText = i18n('ok');
this.cancelText = i18n('cancel');
this.close = this.close.bind(this);
this.onSubmit = this.onSubmit.bind(this);
const ourPK = textsecure.storage.user.getNumber();
this.isAdmin = groupConvo.get('groupAdmins').includes(ourPK);
const convos = window.getConversations().models;
let allMembers = convos.filter(d => !!d);
allMembers = allMembers.filter(
d => d.isFriend() && d.isPrivate() && !d.isMe()
);
allMembers = _.uniq(allMembers, true, d => d.id);
this.friendList = allMembers;
// only give members that are not already in the group
const existingMembers = groupConvo.get('members');
this.existingMembers = existingMembers;
this.$el.focus();
this.render();
},
render() {
this.dialogView = new Whisper.ReactWrapperView({
className: 'create-group-dialog',
Component: window.Signal.Components.UpdateGroupDialog,
props: {
titleText: this.titleText,
groupName: this.groupName,
okText: this.okText,
cancelText: this.cancelText,
existingMembers: this.existingMembers,
friendList: this.friendList,
isAdmin: this.isAdmin,
onClose: this.close,
onSubmit: this.onSubmit,
},
});
this.$el.append(this.dialogView.el);
return this;
},
onSubmit(newGroupName, 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);
},
close() {
this.remove();
},
});
})();

View File

@ -6,6 +6,7 @@
window.Whisper = window.Whisper || {};
// TODO: remove this as unused?
Whisper.GroupUpdateView = Backbone.View.extend({
tagName: 'div',
className: 'group-update',

View File

@ -296,6 +296,9 @@
},
async openConversation(id, messageId) {
const conversationExists = await ConversationController.get(id);
// If we call this to create a new conversation, it can only be private
// (group conversations are created elsewhere)
const conversation = await ConversationController.getOrCreateAndWait(
id,
'private'

View File

@ -1,4 +1,4 @@
/* global Whisper, i18n, _ */
/* global Whisper, i18n, _, displayNameRegex */
// eslint-disable-next-line func-names
(function() {
@ -19,9 +19,6 @@
this.reject = options.reject;
this.cancelText = options.cancelText || i18n('cancel');
this.clear = options.clear;
this.clearText = options.clearText || i18n('clear');
this.title = options.title;
this.render();
@ -34,7 +31,7 @@
const sanitiseNameInput = () => {
const oldVal = this.$input.val();
this.$input.val(oldVal.replace(/[^a-zA-Z0-9_]/g, ''));
this.$input.val(oldVal.replace(displayNameRegex, ''));
};
this.$input[0].oninput = () => {
@ -52,17 +49,17 @@
keyup: 'onKeyup',
'click .ok': 'ok',
'click .cancel': 'cancel',
'click .clear': 'clear',
change: 'validateNickname',
},
validateNickname() {
const nickname = this.$input.val();
if (_.isEmpty(nickname)) {
this.$('.clear').hide();
} else {
this.$('.clear').show();
this.$('.ok').attr('disabled', 'disabled');
return false;
}
this.$('.ok').removeAttr('disabled');
return true;
},
render_attributes() {
return {
@ -70,7 +67,6 @@
showCancel: !this.hideCancel,
cancel: this.cancelText,
ok: this.okText,
clear: this.clearText,
title: this.title,
};
},
@ -88,14 +84,13 @@
this.reject();
}
},
clear() {
this.$input.val('').trigger('change');
},
onKeyup(event) {
this.validateNickname();
const valid = this.validateNickname();
switch (event.key) {
case 'Enter':
this.ok();
if (valid) {
this.ok();
}
break;
case 'Escape':
case 'Esc':

View File

@ -250,7 +250,6 @@
spellCheckDescription: i18n('spellCheckDescription'),
blockedHeader: 'Blocked Users',
linkPreviews: i18n('linkPreviews'),
linkPreviewsDescription: i18n('linkPreviewsDescription'),
linkPreviewsSettingDescription: i18n('linkPreviewsSettingDescription'),
};
},

View File

@ -6,7 +6,8 @@
i18n,
passwordUtil,
_,
setTimeout
setTimeout,
displayNameRegex
*/
/* eslint-disable more/no-then */
@ -73,21 +74,18 @@
this.onSecondaryDeviceRegistered = this.onSecondaryDeviceRegistered.bind(
this
);
const sanitiseNameInput = () => {
const oldVal = this.$('#display-name').val();
this.$('#display-name').val(oldVal.replace(/[^a-zA-Z0-9_]/g, ''));
};
this.$('#display-name').get(0).oninput = () => {
sanitiseNameInput();
this.sanitiseNameInput();
};
this.$('#display-name').get(0).onpaste = () => {
// Sanitise data immediately after paste because it's easier
setTimeout(() => {
sanitiseNameInput();
this.sanitiseNameInput();
});
};
this.sanitiseNameInput();
},
events: {
keyup: 'onKeyup',
@ -109,6 +107,17 @@
'keyup #password': 'onValidatePassword',
'keyup #password-confirmation': 'onValidatePassword',
},
sanitiseNameInput() {
const oldVal = this.$('#display-name').val();
const newVal = oldVal.replace(displayNameRegex, '');
this.$('#display-name').val(newVal);
if (_.isEmpty(newVal)) {
this.$('#save-button').attr('disabled', 'disabled');
return false;
}
this.$('#save-button').removeAttr('disabled');
return true;
},
async showPage(pageIndex) {
// eslint-disable-next-line func-names
this.$pages.each(function(index) {
@ -141,9 +150,12 @@
return;
}
const validName = this.sanitiseNameInput();
switch (event.key) {
case 'Enter':
this.onSaveProfile();
if (validName) {
this.onSaveProfile();
}
break;
case 'Escape':
case 'Esc':
@ -171,7 +183,7 @@
await this.accountManager.registerSingleDevice(
mnemonic,
language,
this.$('#display-name').val()
this.trim(this.$('#display-name').val())
);
this.$el.trigger('openInbox');
} catch (e) {

View File

@ -1261,10 +1261,9 @@ MessageReceiver.prototype.extend({
let profile = null;
if (message.profile) {
profile = JSON.parse(message.profile.encodeJSON());
// Update the conversation
await conversation.setLokiProfile(profile);
}
// Update the conversation
await conversation.setLokiProfile(profile);
}
if (friendRequest) {
@ -1693,11 +1692,6 @@ MessageReceiver.prototype.extend({
window.log.error('Error getting conversation: ', number);
}
// Bail early if a session reset is already ongoing
if (conversation.isSessionResetOngoing()) {
return;
}
await Promise.all(
deviceIds.map(async deviceId => {
const address = new libsignal.SignalProtocolAddress(number, deviceId);
@ -1755,6 +1749,8 @@ MessageReceiver.prototype.extend({
} else if (decrypted.flags & FLAGS.PROFILE_KEY_UPDATE) {
decrypted.body = null;
decrypted.attachments = [];
} else if (decrypted.flags & FLAGS.BACKGROUND_FRIEND_REQUEST) {
// do nothing
} else if (decrypted.flags !== 0) {
throw new Error('Unknown flags in message');
}
@ -1881,6 +1877,9 @@ textsecure.MessageReceiver = function MessageReceiverWrapper(
messageReceiver
);
this.getStatus = messageReceiver.getStatus.bind(messageReceiver);
this.handleEndSession = messageReceiver.handleEndSession.bind(
messageReceiver
);
this.close = messageReceiver.close.bind(messageReceiver);
this.savePreKeyBundleMessage = messageReceiver.savePreKeyBundleMessage.bind(
messageReceiver

View File

@ -203,7 +203,13 @@ MessageSender.prototype = {
({ server } = this);
}
const { url, id } = await server.putAttachment(attachmentData);
const result = await server.putAttachment(attachmentData);
if (!result) {
return Promise.reject(
new Error('Failed to upload data to attachment fileserver')
);
}
const { url, id } = result;
proto.id = id;
proto.url = url;
proto.contentType = attachment.contentType;
@ -376,7 +382,23 @@ MessageSender.prototype = {
);
numbers.forEach(number => {
this.queueJobForNumber(number, () => outgoing.sendToNumber(number));
// Note: if we are sending a private group message, we make 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(
textsecure.storage.protocol.sessions,
s => s.number === number
);
if (
haveSession ||
options.isPublic ||
options.messageType === 'friend-request'
) {
this.queueJobForNumber(number, () => outgoing.sendToNumber(number));
} else {
window.log.error(`No session for number: ${number}`);
}
});
},
@ -887,6 +909,11 @@ MessageSender.prototype = {
options
) {
const profile = this.getOurProfile();
const flags = options.backgroundFriendReq
? textsecure.protobuf.DataMessage.Flags.BACKGROUND_FRIEND_REQUEST
: undefined;
return this.sendMessage(
{
recipients: [number],
@ -899,6 +926,7 @@ MessageSender.prototype = {
expireTimer,
profileKey,
profile,
flags,
},
options
);
@ -1033,43 +1061,25 @@ MessageSender.prototype = {
return this.sendMessage(attrs, options);
},
createGroup(targetNumbers, id, name, avatar, options) {
const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(id);
proto.group.type = textsecure.protobuf.GroupContext.Type.UPDATE;
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);
});
},
updateGroup(groupId, name, avatar, targetNumbers, options) {
updateGroup(groupId, name, avatar, members, recipients, options) {
const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId);
proto.group.type = textsecure.protobuf.GroupContext.Type.UPDATE;
proto.group.name = name;
proto.group.members = targetNumbers;
proto.group.members = members;
const ourPK = textsecure.storage.user.getNumber();
proto.group.admins = [ourPK];
return this.makeAttachmentPointer(avatar).then(attachment => {
proto.group.avatar = attachment;
return this.sendGroupProto(
targetNumbers,
proto,
Date.now(),
options
).then(() => proto.group.id);
// TODO: re-enable this once we have attachments
proto.group.avatar = null;
return this.sendGroupProto(recipients, proto, Date.now(), options).then(
() => proto.group.id
);
});
},
@ -1201,7 +1211,6 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) {
this.resetSession = sender.resetSession.bind(sender);
this.sendMessageToGroup = sender.sendMessageToGroup.bind(sender);
this.sendTypingMessage = sender.sendTypingMessage.bind(sender);
this.createGroup = sender.createGroup.bind(sender);
this.updateGroup = sender.updateGroup.bind(sender);
this.addNumberToGroup = sender.addNumberToGroup.bind(sender);
this.setGroupName = sender.setGroupName.bind(sender);

View File

@ -1140,6 +1140,9 @@ ipc.on('set-media-permissions', (event, value) => {
installPermissionsHandler({ session, userConfig });
event.sender.send('set-success-media-permissions', null);
if (mainWindow && mainWindow.webContents) {
mainWindow.webContents.send('mediaPermissionsChanged');
}
});
ipc.on('on-unblock-number', (event, number) => {

View File

@ -3,7 +3,7 @@
"productName": "Loki Messenger",
"description": "Private messaging from your desktop",
"repository": "https://github.com/loki-project/loki-messenger.git",
"version": "1.0.0-beta6",
"version": "1.0.0-beta7",
"license": "GPL-3.0",
"author": {
"name": "Loki Project",
@ -157,7 +157,7 @@
"electron": "4.1.2",
"electron-builder": "21.2.0",
"electron-icon-maker": "0.0.3",
"electron-notarize": "^0.1.1",
"electron-notarize": "^0.2.0",
"eslint": "4.14.0",
"eslint-config-airbnb-base": "12.1.0",
"eslint-config-prettier": "2.9.0",

View File

@ -24,6 +24,9 @@ if (config.appInstance) {
window.Lodash = require('lodash');
// Regex to match all characters which are *not* supported in display names
window.displayNameRegex = /[^\u0041-\u005A\u0061-\u007A\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC _0-9]*/g;
window.platform = process.platform;
window.getDefaultPoWDifficulty = () => config.defaultPoWDifficulty;
window.getTitle = () => title;
@ -122,6 +125,10 @@ window.restart = () => {
// collection on the main window from the settings window.
window.onUnblockNumber = number => ipc.send('on-unblock-number', number);
ipc.on('mediaPermissionsChanged', () => {
Whisper.events.trigger('mediaPermissionsChanged');
});
ipc.on('on-unblock-number', (event, number) => {
// Unblock the number
if (window.BlockedNumberController) {
@ -458,6 +465,10 @@ window.shortenPubkey = pubkey => `(...${pubkey.substring(pubkey.length - 6)})`;
window.pubkeyPattern = /@[a-fA-F0-9]{64,66}\b/g;
// Limited due to the proof-of-work requirement
window.SMALL_GROUP_SIZE_LIMIT = 10;
window.lokiFeatureFlags = {
multiDeviceUnpairing: false,
privateGroupChats: true,
};

View File

@ -102,9 +102,10 @@ message CallMessage {
message DataMessage {
enum Flags {
END_SESSION = 1;
EXPIRATION_TIMER_UPDATE = 2;
PROFILE_KEY_UPDATE = 4;
END_SESSION = 1;
EXPIRATION_TIMER_UPDATE = 2;
PROFILE_KEY_UPDATE = 4;
BACKGROUND_FRIEND_REQUEST = 256;
}
message Quote {
@ -345,6 +346,7 @@ message GroupContext {
optional string name = 3;
repeated string members = 4;
optional AttachmentPointer avatar = 5;
repeated string admins = 6;
}
message ContactDetails {

View File

@ -100,15 +100,11 @@
<input type='checkbox' name='spell-check-setting' id='spell-check-setting' />
<label for='spell-check-setting'>{{ spellCheckDescription }}</label>
</div>
<div class='link-preview-setting'>
<input type='checkbox' name='link-preview-setting' id='link-preview-setting' />
<label for='link-preview-setting'>{{ linkPreviewsSettingDescription }}</label>
</div>
<hr>
<h3>{{ linkPreviews }}</h3>
<div>{{ linkPreviewsDescription }}<div>
</br>
<div class='link-preview-setting'>
<input type='checkbox' name='link-preview-setting' id='link-preview-setting' />
<label for='link-preview-setting'>{{ linkPreviewsSettingDescription }}</label>
</div>
<hr>
<div class='permissions-setting'>
<h3>{{ permissions }}</h3>

View File

@ -136,13 +136,8 @@
.message-list {
list-style: none;
.message-wrapper {
margin-left: 16px;
margin-right: 16px;
}
li {
margin-bottom: 10px;
margin-bottom: 2px;
&::after {
visibility: hidden;
@ -155,12 +150,92 @@
}
}
.group {
.message-container,
.message-list {
.message-wrapper {
margin-left: 44px;
}
.module-message__check-box {
color: rgb(97, 97, 97);
font-size: 20px;
padding: 4px;
user-select: none;
display: inline;
}
.check-box-container {
// background-color: blue;
align-items: center;
flex-direction: row;
display: inline-flex;
}
.check-box-visible {
transition-duration: 200ms;
opacity: 0.1;
width: 40px;
}
.check-box-invisible {
transition-duration: 200ms;
opacity: 0;
width: 0px;
}
.check-box-selected {
opacity: 1;
}
.loki-message-wrapper {
.react-contextmenu-wrapper {
display: inline-flex;
width: 100%;
}
}
.loki-message-wrapper {
padding-left: 16px;
padding-right: 16px;
}
.loki-message-wrapper {
display: flow-root;
padding-bottom: 4px;
padding-top: 4px;
}
.message-selected {
background-color: #60554060;
}
.bulk-edit-container {
display: flex;
border-top: solid;
border-width: 0.8px;
border-color: #80808090;
&.hidden {
display: none;
}
.delete-button {
color: orangered;
padding: 18px;
// This makes sure the message counter is right in the center
width: 80px;
margin-right: -80px;
user-select: none;
}
.cancel-button {
padding: 18px;
width: 80px;
margin-left: -80px;
user-select: none;
}
.message-counter {
color: darkgrey;
display: flex;
align-items: center;
user-select: none;
margin-left: auto;
margin-right: auto;
}
}
@ -190,6 +265,22 @@
margin-bottom: -5px;
}
.conversation-stack-border {
border-bottom: solid;
border-color: white;
border-width: 4px;
}
.conversation-stack-no-border {
border-bottom: none;
}
.dark-theme {
.conversation-stack-border {
border-color: black;
}
}
.bottom-bar .preview-wrapper {
margin-top: 3px;
margin-left: 37px;

View File

@ -69,6 +69,15 @@ audio {
button {
cursor: pointer;
font-size: inherit;
&[disabled='disabled'] {
&,
&:hover {
opacity: 0.5;
box-shadow: none;
cursor: default;
}
}
}
button.grey {
border-radius: $border-radius;

View File

@ -1,3 +1,111 @@
.leave-group-dialog {
.content {
max-width: 100% !important;
}
.titleText {
font-size: large;
text-align: center;
margin: 2px;
}
.ok {
background-color: orangered;
min-width: 70px;
border: none;
&:hover {
background-color: red;
}
}
.cancel {
border: none;
min-width: 70px;
}
}
.member-preview {
margin-left: 10px;
}
.create-group-dialog {
.content {
max-width: 100% !important;
}
.buttons {
margin: 8px;
}
.group-name {
font-size: larger;
}
.titleText {
font-size: large;
text-align: center;
}
.no-friends {
text-align: center;
}
.hidden {
display: none;
}
.error-message {
text-align: center;
color: red;
margin-bottom: 0.5em;
}
.error-faded {
opacity: 0;
transition: all 100ms linear;
}
.error-shown {
opacity: 1;
transition: all 250ms linear;
}
}
.friend-selection-list {
max-height: 240px;
overflow-y: scroll;
margin: 4px;
.check-mark {
float: right;
text-align: center;
color: darkslategrey;
margin: 4px;
min-width: 20px;
}
.invisible {
visibility: hidden;
}
.existing-member {
color: green;
}
.existing-member-kicked {
color: red;
}
}
.dark-theme {
.friend-selection-list {
.check-mark {
color: rgb(230, 230, 230);
}
}
}
.member-list-container {
margin: 0;
padding: 0;
@ -5,6 +113,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 +170,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;
@ -88,8 +99,8 @@
}
.module-message__buttons__download {
height: 24px;
width: 24px;
min-height: 24px;
min-width: 24px;
display: inline-block;
cursor: pointer;
@include color-svg('../images/download.svg', $color-light-45);
@ -106,8 +117,8 @@
}
.module-message__buttons__reply {
height: 24px;
width: 24px;
min-height: 24px;
min-width: 24px;
display: inline-block;
cursor: pointer;
@include color-svg('../images/reply.svg', $color-light-45);
@ -520,6 +531,7 @@
letter-spacing: 0.3px;
color: $color-gray-60;
text-transform: uppercase;
user-select: none;
}
.module-message__metadata__badge {
@ -603,9 +615,9 @@
}
.module-message__author-avatar {
position: absolute;
bottom: 0px;
right: calc(100% + 4px);
flex-direction: column-reverse;
display: inline-flex;
padding-right: 4px;
}
.module-message__typing-container {
@ -1432,6 +1444,11 @@
height: 48px;
}
.module-conversation-header__title-text {
color: darkgrey;
margin-left: 1em;
}
.module-conversation-header__title-flex {
margin-left: auto;
margin-right: auto;
@ -2058,9 +2075,9 @@
}
.module-avatar__icon--crown-wrapper {
position: absolute;
bottom: 0;
right: 0;
position: relative;
bottom: -38px;
right: -16px;
height: 21px;
width: 21px;
transform: translate(25%, 25%);

View File

@ -574,6 +574,8 @@ describe('Backup', () => {
'profileAvatar',
'swarmNodes',
'friendRequestStatus',
'groupAdmins',
'isKickedFromGroup',
'unlockTimestamp',
'sessionResetStatus',
'isOnline',

View File

@ -556,6 +556,7 @@
<script type='text/javascript' src='../js/views/key_verification_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/message_list_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/member_list_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/bulk_edit_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/group_member_list_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/recorder_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/conversation_view.js' data-cover></script>
@ -574,6 +575,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

@ -0,0 +1,33 @@
import React from 'react';
interface Props {
titleText: string;
messageText: string;
okText: string;
cancelText: string;
onConfirm: any;
onClose: any;
}
export class ConfirmDialog extends React.Component<Props> {
constructor(props: any) {
super(props);
}
public render() {
return (
<div className="content">
<p className="titleText">{this.props.titleText}</p>
<p className="messageText">{this.props.messageText}</p>
<div className="buttons">
<button className="cancel" tabIndex={0} onClick={this.props.onClose}>
{this.props.cancelText}
</button>
<button className="ok" tabIndex={0} onClick={this.props.onConfirm}>
{this.props.okText}
</button>
</div>
</div>
);
}
}

View File

@ -26,6 +26,7 @@ export interface Props {
query: string,
options: { regionCode: string }
) => void;
openConversationInternal: (id: string, messageId?: string) => void;
showArchivedConversations: () => void;
showInbox: () => void;

View File

@ -18,6 +18,12 @@ import { clipboard } from 'electron';
import { validateNumber } from '../types/PhoneNumber';
declare global {
interface Window {
lokiFeatureFlags: any;
}
}
interface MenuItem {
id: string;
name: string;
@ -61,6 +67,7 @@ export class MainHeader extends React.Component<Props, any> {
private readonly setFocusBound: () => void;
private readonly inputRef: React.RefObject<HTMLInputElement>;
private readonly debouncedSearch: (searchTerm: string) => void;
private readonly timerId: any;
constructor(props: Props) {
super(props);
@ -81,7 +88,7 @@ export class MainHeader extends React.Component<Props, any> {
this.debouncedSearch = debounce(this.search.bind(this), 20);
setInterval(() => {
this.timerId = setInterval(() => {
const clipboardText = clipboard.readText();
if (this.state.clipboardText !== clipboardText) {
this.setState({ clipboardText });
@ -94,6 +101,11 @@ export class MainHeader extends React.Component<Props, any> {
this.updateHasPass();
}
public componentWillUnmount() {
// tslint:disable-next-line
clearInterval(this.timerId);
}
public componentDidUpdate(_prevProps: Props, prevState: any) {
if (
prevState.hasPass !== this.state.hasPass ||
@ -351,6 +363,16 @@ export class MainHeader extends React.Component<Props, any> {
},
];
if (window.lokiFeatureFlags.privateGroupChats) {
menuItems.push({
id: 'createPrivateGroup',
name: i18n('createPrivateGroup'),
onClick: () => {
trigger('createNewGroup');
},
});
}
const passItem = (type: string) => ({
id: `${type}Password`,
name: i18n(`${type}Password`),

View File

@ -8,12 +8,14 @@ declare global {
lokiPublicChatAPI: any;
shortenPubkey: any;
pubkeyPattern: any;
getConversations: any;
}
}
interface MentionProps {
key: number;
text: string;
convoId: string;
}
interface MentionState {
@ -78,13 +80,52 @@ class Mention extends React.Component<MentionProps, MentionState> {
}
private findMember(pubkey: String) {
const members = window.lokiPublicChatAPI.getListOfMembers();
if (!members) {
return null;
}
const filtered = members.filter((m: any) => !!m);
let groupMembers;
return filtered.find(
const groupConvos = window.getConversations().models.filter((d: any) => {
return !d.isPrivate();
});
const thisConvo = groupConvos.find((d: any) => {
return d.id === this.props.convoId;
});
if (!thisConvo) {
// If this gets triggered, is is likely because we deleted the conversation
this.clearOurInterval();
return;
}
if (thisConvo.isPublic()) {
// TODO: make this work for other public chats as well
groupMembers = window.lokiPublicChatAPI
.getListOfMembers()
.filter((m: any) => !!m);
} else {
const privateConvos = window
.getConversations()
.models.filter((d: any) => d.isPrivate());
const members = thisConvo.attributes.members;
if (!members) {
return null;
}
const memberConversations = members
.map((m: any) => privateConvos.find((c: any) => c.id === m))
.filter((c: any) => !!c);
groupMembers = memberConversations.map((m: any) => {
const name = m.getLokiProfile()
? m.getLokiProfile().displayName
: m.attributes.displayName;
return {
id: m.id,
authorPhoneNumber: m.id,
authorProfileName: name,
};
});
}
return groupMembers.find(
({ authorPhoneNumber: pn }: any) => pn && pn === pubkey
);
}
@ -93,6 +134,7 @@ class Mention extends React.Component<MentionProps, MentionState> {
interface Props {
text: string;
renderOther?: RenderTextCallbackType;
convoId: string;
}
export class AddMentions extends React.Component<Props> {
@ -101,7 +143,7 @@ export class AddMentions extends React.Component<Props> {
};
public render() {
const { text, renderOther } = this.props;
const { text, renderOther, convoId } = this.props;
const results: Array<any> = [];
const FIND_MENTIONS = window.pubkeyPattern;
@ -126,7 +168,7 @@ export class AddMentions extends React.Component<Props> {
}
const pubkey = text.slice(match.index, FIND_MENTIONS.lastIndex);
results.push(<Mention text={pubkey} key={count++} />);
results.push(<Mention text={pubkey} key={count++} convoId={convoId} />);
// @ts-ignore
last = FIND_MENTIONS.lastIndex;

View File

@ -6,6 +6,7 @@ interface Props {
text: string;
/** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
renderNonNewLine?: RenderTextCallbackType;
convoId: string;
}
export class AddNewLines extends React.Component<Props> {
@ -14,7 +15,7 @@ export class AddNewLines extends React.Component<Props> {
};
public render() {
const { text, renderNonNewLine } = this.props;
const { text, renderNonNewLine, convoId } = this.props;
const results: Array<any> = [];
const FIND_NEWLINES = /\n/g;
@ -29,14 +30,14 @@ export class AddNewLines extends React.Component<Props> {
let count = 1;
if (!match) {
return renderNonNewLine({ text, key: 0 });
return renderNonNewLine({ text, key: 0, convoId });
}
while (match) {
if (last < match.index) {
const textWithNoNewline = text.slice(last, match.index);
results.push(
renderNonNewLine({ text: textWithNoNewline, key: count++ })
renderNonNewLine({ text: textWithNoNewline, key: count++, convoId })
);
}
@ -48,7 +49,9 @@ export class AddNewLines extends React.Component<Props> {
}
if (last < text.length) {
results.push(renderNonNewLine({ text: text.slice(last), key: count++ }));
results.push(
renderNonNewLine({ text: text.slice(last), key: count++, convoId })
);
}
return results;

View File

@ -0,0 +1,44 @@
import React from 'react';
import classNames from 'classnames';
interface Props {
messageCount: number;
onCancel: any;
onDelete: any;
}
export class BulkEdit extends React.Component<Props> {
constructor(props: any) {
super(props);
}
public render() {
const classes = ['bulk-edit-container'];
if (this.props.messageCount === 0) {
classes.push('hidden');
}
return (
<div className={classNames(classes)}>
<span
className="delete-button"
role="button"
onClick={this.props.onDelete}
>
Delete
</span>
<span className="message-counter">
Messages selected: {this.props.messageCount}
</span>
<span
className="cancel-button"
role="button"
onClick={this.props.onCancel}
>
Cancel
</span>
</div>
);
}
}

View File

@ -31,13 +31,16 @@ interface Props {
isArchived: boolean;
isPublic: boolean;
members: Array<any>;
expirationSettingName?: string;
showBackButton: boolean;
timerOptions: Array<TimerOption>;
hasNickname?: boolean;
isBlocked: boolean;
isKeysPending: boolean;
isFriend: boolean;
isFriendRequestPending: boolean;
isOnline?: boolean;
onSetDisappearingMessages: (seconds: number) => void;
@ -61,6 +64,9 @@ interface Props {
onCopyPublicKey: () => void;
onUpdateGroup: () => void;
onLeaveGroup: () => void;
i18n: LocalizerType;
}
@ -102,7 +108,9 @@ export class ConversationHeader extends React.Component<Props> {
phoneNumber,
i18n,
profileName,
isKeysPending,
isFriend,
isGroup,
isFriendRequestPending,
isMe,
name,
} = this.props;
@ -115,6 +123,18 @@ export class ConversationHeader extends React.Component<Props> {
);
}
let text = '';
if (isFriendRequestPending) {
text = `(${i18n('pending')})`;
} else if (!isFriend && !isGroup) {
text = `(${i18n('notFriends')})`;
}
const textEl =
text === '' ? null : (
<span className="module-conversation-header__title-text">{text}</span>
);
return (
<div className="module-conversation-header__title">
<ContactName
@ -123,7 +143,7 @@ export class ConversationHeader extends React.Component<Props> {
name={name}
i18n={i18n}
/>
{isKeysPending ? '(pending)' : null}
{textEl}
</div>
);
}
@ -204,17 +224,31 @@ export class ConversationHeader extends React.Component<Props> {
isMe,
isClosable,
isPublic,
isGroup,
onDeleteMessages,
onDeleteContact,
onCopyPublicKey,
onUpdateGroup,
onLeaveGroup,
} = this.props;
const isPrivateGroup = isGroup && !isPublic;
const copyIdLabel = isGroup ? i18n('copyChatId') : i18n('copyPublicKey');
return (
<ContextMenu id={triggerId}>
{this.renderPublicMenuItems()}
<MenuItem onClick={onCopyPublicKey}>{i18n('copyPublicKey')}</MenuItem>
<MenuItem onClick={onCopyPublicKey}>{copyIdLabel}</MenuItem>
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem>
{!isMe && isClosable ? (
{isPrivateGroup ? (
<MenuItem onClick={onUpdateGroup}>{i18n('updateGroup')}</MenuItem>
) : null}
{isPrivateGroup ? (
<MenuItem onClick={onLeaveGroup}>{i18n('leaveGroup')}</MenuItem>
) : null}
{/* TODO: add delete group */}
{!isMe && isClosable && !isPrivateGroup ? (
!isPublic ? (
<MenuItem onClick={onDeleteContact}>
{i18n('deleteContact')}
@ -230,9 +264,11 @@ export class ConversationHeader extends React.Component<Props> {
}
public render() {
const { id } = this.props;
const { id, isGroup, isPublic } = this.props;
const triggerId = `conversation-${id}`;
const isPrivateGroup = isGroup && !isPublic;
return (
<div className="module-conversation-header">
{this.renderBackButton()}
@ -240,6 +276,7 @@ export class ConversationHeader extends React.Component<Props> {
<div className="module-conversation-header__title-flex">
{this.renderAvatar()}
{this.renderTitle()}
{isPrivateGroup ? this.renderMemberCount() : null}
</div>
</div>
{this.renderExpirationLength()}
@ -249,6 +286,20 @@ export class ConversationHeader extends React.Component<Props> {
);
}
private renderMemberCount() {
const memberCount = this.props.members.length;
if (memberCount === 0) {
return null;
}
const wordForm = memberCount === 1 ? 'member' : 'members';
return (
<span className="member-preview">{`(${memberCount} ${wordForm})`}</span>
);
}
private renderPublicMenuItems() {
const {
i18n,
@ -307,13 +358,14 @@ export class ConversationHeader extends React.Component<Props> {
const resetSessionMenuItem = !isGroup && (
<MenuItem onClick={onResetSession}>{i18n('resetSession')}</MenuItem>
);
const blockHandlerMenuItem = !isMe && (
<MenuItem onClick={blockHandler}>{blockTitle}</MenuItem>
);
const changeNicknameMenuItem = !isMe && (
<MenuItem onClick={onChangeNickname}>{i18n('changeNickname')}</MenuItem>
);
const blockHandlerMenuItem = !isMe &&
!isGroup && <MenuItem onClick={blockHandler}>{blockTitle}</MenuItem>;
const changeNicknameMenuItem = !isMe &&
!isGroup && (
<MenuItem onClick={onChangeNickname}>{i18n('changeNickname')}</MenuItem>
);
const clearNicknameMenuItem = !isMe &&
!isGroup &&
hasNickname && (
<MenuItem onClick={onClearNickname}>{i18n('clearNickname')}</MenuItem>
);

View File

@ -0,0 +1,210 @@
import React from 'react';
import classNames from 'classnames';
import { Contact, MemberList } from './MemberList';
declare global {
interface Window {
Lodash: any;
doCreateGroup: any;
SMALL_GROUP_SIZE_LIMIT: number;
}
}
interface Props {
titleText: string;
okText: string;
cancelText: string;
friendList: Array<any>;
i18n: any;
onClose: any;
}
interface State {
friendList: Array<Contact>;
groupName: string;
errorDisplayed: boolean;
errorMessage: string;
}
export class CreateGroupDialog extends React.Component<Props, State> {
constructor(props: any) {
super(props);
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: '',
errorDisplayed: false,
// if empty, the initial height is 0, which is not desirable
errorMessage: 'placeholder',
};
window.addEventListener('keyup', this.onKeyUp);
}
public onClickOK() {
const members = this.state.friendList
.filter(d => d.checkmarked)
.map(d => d.id);
if (!this.state.groupName.trim()) {
this.onShowError(this.props.i18n('emptyGroupNameError'));
return;
}
window.doCreateGroup(this.state.groupName, members);
this.closeDialog();
}
public render() {
const checkMarkedCount = this.getMemberCount();
const titleText = `${this.props.titleText} (Members: ${checkMarkedCount})`;
const okText = this.props.okText;
const cancelText = this.props.cancelText;
const errorMessageClasses = classNames(
'error-message',
this.state.errorDisplayed ? 'error-shown' : 'error-faded'
);
return (
<div className="content">
<p className="titleText">{titleText}</p>
<p className={errorMessageClasses}>{this.state.errorMessage}</p>
<input
type="text"
id="group-name"
className="group-name"
placeholder={this.props.i18n('groupNamePlaceholder')}
value={this.state.groupName}
onChange={this.onGroupNameChanged}
tabIndex={0}
required={true}
autoFocus={true}
aria-required={true}
/>
<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={0} onClick={this.closeDialog}>
{cancelText}
</button>
<button className="ok" tabIndex={0} onClick={this.onClickOK}>
{okText}
</button>
</div>
</div>
);
}
private onShowError(msg: string) {
if (this.state.errorDisplayed) {
return;
}
this.setState({
errorDisplayed: true,
errorMessage: msg,
});
setTimeout(() => {
this.setState({
errorDisplayed: false,
});
}, 3000);
}
private onGroupNameChanged(event: any) {
event.persist();
this.setState(state => {
return {
...state,
groupName: event.target.value,
};
});
}
private onKeyUp(event: any) {
switch (event.key) {
case 'Enter':
this.onClickOK();
break;
case 'Esc':
case 'Escape':
this.closeDialog();
break;
default:
}
}
private getMemberCount() {
// Add 1 to include yourself
return this.state.friendList.filter(d => d.checkmarked).length + 1;
}
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;
}
});
if (
updatedFriends.filter(d => d.checkmarked).length >
window.SMALL_GROUP_SIZE_LIMIT - 1
) {
const msg = `${this.props.i18n('maxGroupMembersError')} ${
window.SMALL_GROUP_SIZE_LIMIT
}`;
this.onShowError(msg);
return;
}
this.setState(state => {
return {
...state,
friendList: updatedFriends,
};
});
}
}

View File

@ -56,17 +56,25 @@ interface Props {
/** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
renderNonEmoji?: RenderTextCallbackType;
i18n: LocalizerType;
isPublic?: boolean;
isGroup?: boolean;
convoId: string;
}
export class Emojify extends React.Component<Props> {
public static defaultProps: Partial<Props> = {
renderNonEmoji: ({ text }) => text || '',
isPublic: false,
isGroup: false,
};
public render() {
const { text, sizeClass, renderNonEmoji, i18n, isPublic } = this.props;
const {
text,
sizeClass,
renderNonEmoji,
i18n,
isGroup,
convoId,
} = this.props;
const results: Array<any> = [];
const regex = getRegex();
@ -81,14 +89,19 @@ export class Emojify extends React.Component<Props> {
let count = 1;
if (!match) {
return renderNonEmoji({ text, key: 0, isPublic });
return renderNonEmoji({ text, key: 0, isGroup, convoId });
}
while (match) {
if (last < match.index) {
const textWithNoEmoji = text.slice(last, match.index);
results.push(
renderNonEmoji({ text: textWithNoEmoji, key: count++, isPublic })
renderNonEmoji({
text: textWithNoEmoji,
key: count++,
isGroup,
convoId,
})
);
}
@ -100,7 +113,12 @@ export class Emojify extends React.Component<Props> {
if (last < text.length) {
results.push(
renderNonEmoji({ text: text.slice(last), key: count++, isPublic })
renderNonEmoji({
text: text.slice(last),
key: count++,
isGroup,
convoId,
})
);
}

View File

@ -15,7 +15,7 @@ interface Contact {
}
interface Change {
type: 'add' | 'remove' | 'name' | 'general';
type: 'add' | 'remove' | 'name' | 'general' | 'kicked';
isMe: boolean;
newName?: string;
contacts?: Array<Contact>;
@ -78,6 +78,21 @@ export class GroupNotification extends React.Component<Props> {
contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup';
return <Intl i18n={i18n} id={leftKey} components={[people]} />;
case 'kicked':
if (isMe) {
return i18n('youGotKickedFromGroup');
}
if (!contacts || !contacts.length) {
throw new Error('Group update is missing contacts');
}
const kickedKey =
contacts.length > 1
? 'multipleKickedFromTheGroup'
: 'kickedFromTheGroup';
return <Intl i18n={i18n} id={kickedKey} components={[people]} />;
case 'general':
return i18n('updatedTheGroup');
default:

View File

@ -67,8 +67,9 @@ export class Image extends React.Component<Props> {
return (
<div
role={role}
onClick={() => {
onClick={(e: any) => {
if (canClick && onClick) {
e.stopPropagation();
onClick(attachment);
}
}}

View File

@ -2,10 +2,24 @@ 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;
existingMember: boolean;
}
interface MemberItemProps {
member: any;
selected: Boolean;
member: Contact;
selected: boolean;
existingMember: boolean;
onClicked: any;
i18n: any;
checkmarked: boolean;
}
class MemberItem extends React.Component<MemberItemProps> {
@ -18,6 +32,41 @@ class MemberItem extends React.Component<MemberItemProps> {
const name = this.props.member.authorProfileName;
const pubkey = this.props.member.authorPhoneNumber;
const selected = this.props.selected;
const existingMember = this.props.existingMember;
let markType: 'none' | 'kicked' | 'added' | 'existing' = 'none';
if (this.props.checkmarked) {
if (existingMember) {
markType = 'kicked';
} else {
markType = 'added';
}
} else {
if (existingMember) {
markType = 'existing';
} else {
markType = 'none';
}
}
const markClasses = ['check-mark'];
switch (markType) {
case 'none':
markClasses.push('invisible');
break;
case 'existing':
markClasses.push('existing-member');
break;
case 'kicked':
markClasses.push('existing-member-kicked');
break;
default:
// do nothing
}
const mark = markType === 'kicked' ? '✘' : '✔';
return (
<div
@ -31,6 +80,7 @@ class MemberItem extends React.Component<MemberItemProps> {
{this.renderAvatar()}
<span className="name-part">{name}</span>
<span className="pubkey-part">{pubkey}</span>
<span className={classNames(markClasses)}>{mark}</span>
</div>
);
}
@ -45,7 +95,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 +106,10 @@ class MemberItem extends React.Component<MemberItemProps> {
}
interface MemberListProps {
members: [any];
members: Array<Contact>;
selected: any;
onMemberClicked: any;
i18n: any;
}
export class MemberList extends React.Component<MemberListProps> {
@ -79,6 +130,9 @@ export class MemberList extends React.Component<MemberListProps> {
key={item.id}
member={item}
selected={selected}
checkmarked={item.checkmarked}
existingMember={item.existingMember}
i18n={this.props.i18n}
onClicked={this.handleMemberClicked}
/>
);

View File

@ -93,13 +93,18 @@ export interface Props {
isExpired: boolean;
expirationLength?: number;
expirationTimestamp?: number;
convoId: string;
isP2p?: boolean;
isPublic?: boolean;
isRss?: boolean;
selected: boolean;
// whether or not to show check boxes
multiSelectMode: boolean;
onClickAttachment?: (attachment: AttachmentType) => void;
onClickLinkPreview?: (url: string) => void;
onCopyText?: () => void;
onSelectMessage: () => void;
onReply?: () => void;
onRetrySend?: () => void;
onDownload?: (isDangerous: boolean) => void;
@ -312,42 +317,6 @@ export class Message extends React.PureComponent<Props, State> {
);
}
public renderAuthor() {
const {
authorName,
authorPhoneNumber,
authorProfileName,
conversationType,
direction,
i18n,
} = this.props;
const title = authorName ? authorName : authorPhoneNumber;
if (direction !== 'incoming' || conversationType !== 'group' || !title) {
return null;
}
const shortenedPubkey = window.shortenPubkey(authorPhoneNumber);
const displayedPubkey = authorProfileName
? shortenedPubkey
: authorPhoneNumber;
return (
<div className="module-message__author">
<ContactName
phoneNumber={displayedPubkey}
name={authorName}
profileName={authorProfileName}
module="module-message__author"
i18n={i18n}
boldProfileName={true}
/>
</div>
);
}
// tslint:disable-next-line max-func-body-length cyclomatic-complexity
public renderAttachment() {
const {
@ -590,6 +559,7 @@ export class Message extends React.PureComponent<Props, State> {
i18n,
quote,
isPublic,
convoId,
} = this.props;
if (!quote) {
@ -614,6 +584,8 @@ export class Message extends React.PureComponent<Props, State> {
text={quote.text}
attachment={quote.attachment}
isIncoming={direction === 'incoming'}
conversationType={conversationType}
convoId={convoId}
isPublic={isPublic}
authorPhoneNumber={displayedPubkey}
authorProfileName={quote.authorProfileName}
@ -718,7 +690,16 @@ export class Message extends React.PureComponent<Props, State> {
}
public renderText() {
const { text, textPending, i18n, direction, status, isRss } = this.props;
const {
text,
textPending,
i18n,
direction,
status,
isRss,
conversationType,
convoId,
} = this.props;
const contents =
direction === 'incoming' && status === 'error'
@ -745,7 +726,8 @@ export class Message extends React.PureComponent<Props, State> {
isRss={isRss}
i18n={i18n}
textPending={textPending}
isPublic={this.props.isPublic}
isGroup={conversationType === 'group'}
convoId={convoId}
/>
</div>
);
@ -801,10 +783,11 @@ export class Message extends React.PureComponent<Props, State> {
const downloadButton =
!multipleAttachments && firstAttachment && !firstAttachment.pending ? (
<div
onClick={() => {
onClick={(e: any) => {
if (onDownload) {
onDownload(isDangerous);
}
e.stopPropagation();
}}
role="button"
className={classNames(
@ -816,7 +799,12 @@ export class Message extends React.PureComponent<Props, State> {
const replyButton = (
<div
onClick={onReply}
onClick={(e: any) => {
if (onReply) {
onReply();
}
e.stopPropagation();
}}
role="button"
className={classNames(
'module-message__buttons__reply',
@ -859,6 +847,7 @@ export class Message extends React.PureComponent<Props, State> {
const {
attachments,
onCopyText,
onSelectMessage,
direction,
status,
isDeletable,
@ -878,6 +867,15 @@ export class Message extends React.PureComponent<Props, State> {
const isDangerous = isFileDangerous(fileName || '');
const multipleAttachments = attachments && attachments.length > 1;
// Wraps a function to prevent event propagation, thus preventing
// message selection whenever any of the menu buttons are pressed.
const wrap = (f: any) => (event: Event) => {
event.stopPropagation();
if (f) {
f();
}
};
return (
<ContextMenu id={triggerId}>
{!multipleAttachments && attachments && attachments[0] ? (
@ -885,7 +883,8 @@ export class Message extends React.PureComponent<Props, State> {
attributes={{
className: 'module-message__context__download',
}}
onClick={() => {
onClick={(e: Event) => {
e.stopPropagation();
if (onDownload) {
onDownload(isDangerous);
}
@ -894,12 +893,16 @@ export class Message extends React.PureComponent<Props, State> {
{i18n('downloadAttachment')}
</MenuItem>
) : null}
<MenuItem onClick={onCopyText}>{i18n('copyMessage')}</MenuItem>
<MenuItem onClick={wrap(onCopyText)}>{i18n('copyMessage')}</MenuItem>
<MenuItem onClick={wrap(onSelectMessage)}>
{i18n('selectMessage')}
</MenuItem>
<MenuItem
attributes={{
className: 'module-message__context__reply',
}}
onClick={onReply}
onClick={wrap(onReply)}
>
{i18n('replyToMessage')}
</MenuItem>
@ -907,7 +910,7 @@ export class Message extends React.PureComponent<Props, State> {
attributes={{
className: 'module-message__context__more-info',
}}
onClick={onShowDetail}
onClick={wrap(onShowDetail)}
>
{i18n('moreInfo')}
</MenuItem>
@ -916,7 +919,7 @@ export class Message extends React.PureComponent<Props, State> {
attributes={{
className: 'module-message__context__retry-send',
}}
onClick={onRetrySend}
onClick={wrap(onRetrySend)}
>
{i18n('retrySend')}
</MenuItem>
@ -926,13 +929,15 @@ export class Message extends React.PureComponent<Props, State> {
attributes={{
className: 'module-message__context__delete-message',
}}
onClick={onDelete}
onClick={wrap(onDelete)}
>
{i18n('deleteMessage')}
</MenuItem>
) : null}
{isPublic ? (
<MenuItem onClick={onCopyPubKey}>{i18n('copyPublicKey')}</MenuItem>
<MenuItem onClick={wrap(onCopyPubKey)}>
{i18n('copyPublicKey')}
</MenuItem>
) : null}
</ContextMenu>
);
@ -1009,7 +1014,10 @@ export class Message extends React.PureComponent<Props, State> {
authorColor,
direction,
id,
isRss,
timestamp,
selected,
multiSelectMode,
} = this.props;
const { expired, expiring } = this.state;
@ -1034,13 +1042,32 @@ export class Message extends React.PureComponent<Props, State> {
const mentionMe =
mentions &&
mentions.some(m => m.slice(1) === window.lokiPublicChatAPI.ourKey);
const shouldHightlight =
mentionMe && direction === 'incoming' && this.props.isPublic;
const divClass = shouldHightlight ? 'message-highlighted' : '';
const isIncoming = direction === 'incoming';
const shouldHightlight = mentionMe && isIncoming && this.props.isPublic;
const divClasses = ['loki-message-wrapper'];
if (shouldHightlight) {
divClasses.push('message-highlighted');
}
if (selected) {
divClasses.push('message-selected');
}
return (
<div className={divClass}>
<div
className={classNames(divClasses)}
role="button"
onClick={() => {
const selection = window.getSelection();
if (selection && selection.type === 'Range') {
return;
}
this.props.onSelectMessage();
}}
>
<ContextMenuTrigger id={rightClickTriggerId}>
{this.renderCheckBox()}
{this.renderAvatar()}
<div
className={classNames(
'module-message',
@ -1048,13 +1075,13 @@ export class Message extends React.PureComponent<Props, State> {
expiring ? 'module-message--expired' : null
)}
>
{this.renderError(direction === 'incoming')}
{this.renderMenu(direction === 'outgoing', triggerId)}
{this.renderError(isIncoming)}
{isRss ? null : this.renderMenu(!isIncoming, triggerId)}
<div
className={classNames(
'module-message__container',
`module-message__container--${direction}`,
direction === 'incoming'
isIncoming
? `module-message__container--incoming-${authorColor}`
: null
)}
@ -1070,15 +1097,74 @@ export class Message extends React.PureComponent<Props, State> {
{this.renderText()}
{this.renderMetadata()}
{this.renderSendMessageButton()}
{this.renderAvatar()}
</div>
{this.renderError(direction === 'outgoing')}
{this.renderMenu(direction === 'incoming', triggerId)}
{this.renderContextMenu(triggerId)}
{this.renderContextMenu(rightClickTriggerId)}
{this.renderError(!isIncoming)}
{isRss || multiSelectMode
? null
: this.renderMenu(isIncoming, triggerId)}
{multiSelectMode ? null : this.renderContextMenu(triggerId)}
{multiSelectMode
? null
: this.renderContextMenu(rightClickTriggerId)}
</div>
</ContextMenuTrigger>
</div>
);
}
private renderCheckBox() {
const classes = ['check-box-container'];
if (this.props.multiSelectMode) {
classes.push('check-box-visible');
} else {
classes.push('check-box-invisible');
}
if (this.props.selected) {
classes.push('check-box-selected');
}
return (
<div className={classNames(classes)}>
<span className="module-message__check-box"></span>
</div>
);
}
private renderAuthor() {
const {
authorName,
authorPhoneNumber,
authorProfileName,
conversationType,
direction,
i18n,
} = this.props;
const title = authorName ? authorName : authorPhoneNumber;
if (direction !== 'incoming' || conversationType !== 'group' || !title) {
return null;
}
const shortenedPubkey = window.shortenPubkey(authorPhoneNumber);
const displayedPubkey = authorProfileName
? shortenedPubkey
: authorPhoneNumber;
return (
<div className="module-message__author">
<ContactName
phoneNumber={displayedPubkey}
name={authorName}
profileName={authorProfileName}
module="module-message__author"
i18n={i18n}
boldProfileName={true}
/>
</div>
);
}
}

View File

@ -16,12 +16,13 @@ interface Props {
disableJumbomoji?: boolean;
/** If set, links will be left alone instead of turned into clickable `<a>` tags. */
disableLinks?: boolean;
isPublic?: boolean;
isGroup?: boolean;
i18n: LocalizerType;
convoId: string;
}
const renderMentions: RenderTextCallbackType = ({ text, key }) => (
<AddMentions key={key} text={text} />
const renderMentions: RenderTextCallbackType = ({ text, key, convoId }) => (
<AddMentions key={key} text={text} convoId={convoId} />
);
const renderDefault: RenderTextCallbackType = ({ text }) => text;
@ -29,15 +30,17 @@ const renderDefault: RenderTextCallbackType = ({ text }) => text;
const renderNewLines: RenderTextCallbackType = ({
text: textWithNewLines,
key,
isPublic,
isGroup,
convoId,
}) => {
const renderOther = isPublic ? renderMentions : renderDefault;
const renderOther = isGroup ? renderMentions : renderDefault;
return (
<AddNewLines
key={key}
text={textWithNewLines}
renderNonNewLine={renderOther}
convoId={convoId}
/>
);
};
@ -48,14 +51,16 @@ const renderEmoji = ({
key,
sizeClass,
renderNonEmoji,
isPublic,
isGroup,
convoId,
}: {
i18n: LocalizerType;
text: string;
key: number;
sizeClass?: SizeClassType;
renderNonEmoji: RenderTextCallbackType;
isPublic?: boolean;
isGroup?: boolean;
convoId?: string;
}) => (
<Emojify
i18n={i18n}
@ -63,7 +68,8 @@ const renderEmoji = ({
text={text}
sizeClass={sizeClass}
renderNonEmoji={renderNonEmoji}
isPublic={isPublic}
isGroup={isGroup}
convoId={convoId}
/>
);
@ -75,7 +81,7 @@ const renderEmoji = ({
*/
export class MessageBody extends React.Component<Props> {
public static defaultProps: Partial<Props> = {
isPublic: false,
isGroup: false,
};
public addDownloading(jsx: JSX.Element): JSX.Element {
@ -102,7 +108,8 @@ export class MessageBody extends React.Component<Props> {
disableLinks,
isRss,
i18n,
isPublic,
isGroup,
convoId,
} = this.props;
const sizeClass = disableJumbomoji ? undefined : getSizeClass(text);
const textWithPending = textPending ? `${text}...` : text;
@ -115,7 +122,8 @@ export class MessageBody extends React.Component<Props> {
sizeClass,
key: 0,
renderNonEmoji: renderNewLines,
isPublic,
isGroup,
convoId,
})
);
}
@ -131,7 +139,8 @@ export class MessageBody extends React.Component<Props> {
sizeClass,
key,
renderNonEmoji: renderNewLines,
isPublic,
isGroup,
convoId,
});
}}
/>

View File

@ -19,6 +19,8 @@ interface Props {
i18n: LocalizerType;
isFromMe: boolean;
isIncoming: boolean;
conversationType: 'group' | 'direct';
convoId: string;
isPublic?: boolean;
withContentAbove: boolean;
onClick?: () => void;
@ -215,7 +217,14 @@ export class Quote extends React.Component<Props, State> {
}
public renderText() {
const { i18n, text, attachment, isIncoming, isPublic } = this.props;
const {
i18n,
text,
attachment,
isIncoming,
conversationType,
convoId,
} = this.props;
if (text) {
return (
@ -227,7 +236,8 @@ export class Quote extends React.Component<Props, State> {
)}
>
<MessageBody
isPublic={isPublic}
isGroup={conversationType === 'group'}
convoId={convoId}
text={text}
disableLinks={true}
i18n={i18n}

View File

@ -0,0 +1,241 @@
import React from 'react';
import classNames from 'classnames';
import { Contact, MemberList } from './MemberList';
declare global {
interface Window {
SMALL_GROUP_SIZE_LIMIT: number;
Lodash: any;
}
}
interface Props {
titleText: string;
groupName: string;
okText: string;
cancelText: string;
// friends not in the group
friendList: Array<any>;
isAdmin: boolean;
existingMembers: Array<any>;
i18n: any;
onSubmit: any;
onClose: any;
}
interface State {
friendList: Array<Contact>;
groupName: string;
errorDisplayed: boolean;
errorMessage: string;
}
export class UpdateGroupDialog extends React.Component<Props, State> {
constructor(props: any) {
super(props);
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';
const existingMember = this.props.existingMembers.includes(d.id);
return {
id: d.id,
authorPhoneNumber: d.id,
authorProfileName: name,
selected: false,
authorName: name, // different from ProfileName?
authorColor: d.getColor(),
checkmarked: false,
existingMember,
};
});
this.state = {
friendList: friends,
groupName: this.props.groupName,
errorDisplayed: false,
errorMessage: 'placeholder',
};
window.addEventListener('keyup', this.onKeyUp);
}
public onClickOK() {
const members = this.getWouldBeMembers(this.state.friendList).map(
d => d.id
);
if (!this.state.groupName.trim()) {
this.onShowError(this.props.i18n('emptyGroupNameError'));
return;
}
this.props.onSubmit(this.state.groupName, members);
this.closeDialog();
}
public render() {
const checkMarkedCount = this.getMemberCount(this.state.friendList);
const titleText = `${this.props.titleText} (Members: ${checkMarkedCount})`;
const okText = this.props.okText;
const cancelText = this.props.cancelText;
const noFriendsClasses =
this.state.friendList.length === 0
? 'no-friends'
: classNames('no-friends', 'hidden');
const errorMsg = this.state.errorMessage;
const errorMessageClasses = classNames(
'error-message',
this.state.errorDisplayed ? 'error-shown' : 'error-faded'
);
return (
<div className="content">
<p className="titleText">{titleText}</p>
<p className={errorMessageClasses}>{errorMsg}</p>
<input
type="text"
id="group-name"
className="group-name"
placeholder={this.props.i18n('groupNamePlaceholder')}
value={this.state.groupName}
disabled={!this.props.isAdmin}
onChange={this.onGroupNameChanged}
tabIndex={0}
required={true}
aria-required={true}
autoFocus={true}
/>
<div className="friend-selection-list">
<MemberList
members={this.state.friendList}
selected={{}}
i18n={this.props.i18n}
onMemberClicked={this.onMemberClicked}
/>
</div>
<p className={noFriendsClasses}>(no friends to add)</p>
<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 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:
}
}
// Return members that would comprise the group given the
// current state in `users`
private getWouldBeMembers(users: Array<Contact>) {
return users.filter(d => {
return (
(d.existingMember && !d.checkmarked) ||
(!d.existingMember && d.checkmarked)
);
});
}
private getMemberCount(users: Array<Contact>) {
// Adding one to include ourselves
return this.getWouldBeMembers(users).length + 1;
}
private closeDialog() {
window.removeEventListener('keyup', this.onKeyUp);
this.props.onClose();
}
private onMemberClicked(selected: any) {
if (selected.existingMember && !this.props.isAdmin) {
this.onShowError(this.props.i18n('nonAdminDeleteMember'));
return;
}
const updatedFriends = this.state.friendList.map(member => {
if (member.id === selected.id) {
return { ...member, checkmarked: !member.checkmarked };
} else {
return member;
}
});
const newMemberCount = this.getMemberCount(updatedFriends);
if (newMemberCount > window.SMALL_GROUP_SIZE_LIMIT) {
const msg = `${this.props.i18n('maxGroupMembersError')} ${
window.SMALL_GROUP_SIZE_LIMIT
}`;
this.onShowError(msg);
return;
}
this.setState(state => {
return {
...state,
friendList: updatedFriends,
};
});
}
private onGroupNameChanged(event: any) {
event.persist();
this.setState(state => {
return {
...state,
groupName: event.target.value,
};
});
}
}

View File

@ -25,7 +25,8 @@ const logger = createLogger({
});
// Exclude logger if we're in production mode
const middlewareList = env === 'production' ? [promise] : [promise, logger];
const disableLogging = env === 'production' || true; // ALWAYS TURNED OFF
const middlewareList = disableLogging ? [promise] : [promise, logger];
const enhancer = applyMiddleware.apply(null, middlewareList);

View File

@ -2,7 +2,8 @@ export type RenderTextCallbackType = (
options: {
text: string;
key: number;
isPublic?: boolean;
isGroup?: boolean;
convoId?: string;
}
) => JSX.Element | string;

View File

@ -145,7 +145,10 @@
"method": "render",
"comment": "Usage has been approved by Ryan Tharp on 2019-07-22"
}
]
],
// Reasonable functions can exceed the default of 100 lines
// due to auto-formatting
"max-func-body-length": [true, 150]
},
"rulesDirectory": ["node_modules/tslint-microsoft-contrib"]
}

View File

@ -2729,13 +2729,13 @@ electron-is-dev@^1.0.1:
resolved "https://registry.yarnpkg.com/electron-is-dev/-/electron-is-dev-1.0.1.tgz#6e0a184736fe7aea77d18210b0b0f6a02402c4bc"
integrity sha512-iwM3EotA9HTXqMGpQRkR/kT8OZqBbdfHTnlwcxsjSLYqY8svvsq0MuujsWCn3/vtgRmDv/PC/gKUUpoZvi5C1w==
electron-notarize@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/electron-notarize/-/electron-notarize-0.1.1.tgz#c3563d70c5e7b3315f44e8495b30050a8c408b91"
integrity sha512-TpKfJcz4LXl5jiGvZTs5fbEx+wUFXV5u8voeG5WCHWfY/cdgdD8lDZIZRqLVOtR3VO+drgJ9aiSHIO9TYn/fKg==
electron-notarize@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/electron-notarize/-/electron-notarize-0.2.0.tgz#676c71688ee84149bab27b22426d0a9452e7e262"
integrity sha512-u3KdEMOEcGMF9yCML8ej4ZF+O29VmGYIjrs/DoOi23neTWOMiIc5YCeFs4vxq3JG496omcw7Y5pimPm0sH9A7g==
dependencies:
debug "^4.1.1"
fs-extra "^8.0.1"
fs-extra "^8.1.0"
electron-publish@21.2.0:
version "21.2.0"
@ -3602,7 +3602,7 @@ fs-extra@^2.0.0:
graceful-fs "^4.1.2"
jsonfile "^2.1.0"
fs-extra@^8.0.1, fs-extra@^8.1.0:
fs-extra@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==