Message Requests

This commit is contained in:
Ken Powers 2020-05-27 17:37:06 -04:00 committed by Scott Nonnenberg
parent 4d4b7a26a5
commit 83574eb067
60 changed files with 2566 additions and 216 deletions

View file

@ -1583,6 +1583,10 @@
"message": "Note to Self",
"description": "Name for the conversation with your own phone number"
},
"noteToSelfHero": {
"message": "You can add notes for yourself in this conversation. If your account has any linked devices, new notes will be synced.",
"description": "Description for the Note to Self conversation"
},
"hideMenuBar": {
"message": "Hide menu bar",
"description": "Label text for menu bar visibility setting"
@ -2315,5 +2319,219 @@
"ReactionsViewer--all": {
"message": "All",
"description": "Shown in reaction viewer as the title for the 'all' category"
},
"MessageRequests--message-direct": {
"message": "Do you want to let $name$ message you? They won't know you've seen their message until you accept.",
"description": "Shown as the message for a message request in a direct message",
"placeholders": {
"name": {
"content": "$1",
"example": "Cayce Pollard"
}
}
},
"MessageRequests--message-direct-blocked": {
"message": "Unblock $name$ to message and call each other.",
"description": "Shown as the message for a message request in a direct message with a blocked account",
"placeholders": {
"name": {
"content": "$1",
"example": "Cayce Pollard"
}
}
},
"MessageRequests--message-group": {
"message": "Do you want to join $group$? They won't know you've seen their message until you accept.",
"description": "Shown as the message for a message request in a group",
"placeholders": {
"name": {
"content": "$1",
"example": "Cayce Pollard"
}
}
},
"MessageRequests--message-group-blocked": {
"message": "Unblock to allow group members to add you to this group again.",
"description": "Shown as the message for a message request in a blocked group"
},
"MessageRequests--block": {
"message": "Block",
"description": "Shown as a button to let the user block a message request"
},
"MessageRequests--unblock": {
"message": "Unblock",
"description": "Shown as a button to let the user unblock a message request"
},
"MessageRequests--unblock-confirm-title": {
"message": "Unblock $name$?",
"description": "Shown as a button to let the user unblock a message request",
"placeholders": {
"name": {
"content": "$1",
"example": "Cayce Pollard"
}
}
},
"MessageRequests--unblock-direct-confirm-body": {
"message": "You will be able to message and call each other.",
"description": "Shown as the body in the confirmation modal for unblocking a private message request",
"placeholders": {
"name": {
"content": "$1",
"example": "Cayce Pollard"
}
}
},
"MessageRequests--unblock-group-confirm-body": {
"message": "Group members will be able to add your to this group again.",
"description": "Shown as the body in the confirmation modal for unblocking a group message request",
"placeholders": {
"name": {
"content": "$1",
"example": "Cayce Pollard"
}
}
},
"MessageRequests--block-and-delete": {
"message": "Block and Delete",
"description": "Shown as a button to let the user block and delete a message request"
},
"MessageRequests--block-direct-confirm-title": {
"message": "Block $name$?",
"description": "Shown as the title in the confirmation modal for blocking a private message request",
"placeholders": {
"name": {
"content": "$1",
"example": "Cayce Pollard"
}
}
},
"MessageRequests--block-direct-confirm-body": {
"message": "Blocked people won't be able to call you or send you mesages.",
"description": "Shown as the body in the confirmation modal for blocking a private message request"
},
"MessageRequests--block-group-confirm-title": {
"message": "Block and Leave $group$?",
"description": "Shown as the title in the confirmation modal for blocking a group message request",
"placeholders": {
"group": {
"content": "$1",
"example": "Friends 🌿"
}
}
},
"MessageRequests--block-group-confirm-body": {
"message": "You will no longer receive messages or updates from this group and members won't be able to add you to this group again.",
"description": "Shown as the body in the confirmation modal for blocking a group message request"
},
"MessageRequests--delete": {
"message": "Delete",
"description": "Shown as a button to let the user delete any message request"
},
"MessageRequests--delete-direct-confirm-title": {
"message": "Delete conversation?",
"description": "Shown as the title in the confirmation modal for deleting a private message request"
},
"MessageRequests--delete-direct-confirm-body": {
"message": "This conversation will be deleted from all of your devices.",
"description": "Shown as the body in the confirmation modal for deleting a private message request"
},
"MessageRequests--delete-group-confirm-title": {
"message": "Delete and Leave $group$?",
"description": "Shown as the title in the confirmation modal for deleting a group message request",
"placeholders": {
"group": {
"content": "$1",
"example": "Friends 🌿"
}
}
},
"MessageRequests--delete-direct": {
"message": "Delete",
"description": "Shown as a button to let the user delete a direct message request"
},
"MessageRequests--delete-group": {
"message": "Delete and Leave",
"description": "Shown as a button to let the user delete a group message request"
},
"MessageRequests--delete-group-confirm-body": {
"message": "You will leave this group, and it will be deleted from all your devices.",
"description": "Shown as the body in the confirmation modal for deleting a group message request"
},
"MessageRequests--accept": {
"message": "Accept",
"description": "Shown as a button to let the user accept a message request"
},
"ConversationHero--members": {
"message": "$count$ members",
"description": "Specifies the number of members in a group conversation",
"placeholders": {
"count": {
"content": "$1",
"example": "22"
}
}
},
"ConversationHero--members-1": {
"message": "1 member",
"description": "Specifies the number of members in a group conversation when there is one member",
"placeholders": {
"count": {
"content": "$1",
"example": "22"
}
}
},
"ConversationHero--membership-1": {
"message": "Member of $group$.",
"description": "Shown in the conversation hero to indicate this user is a member of a mutual group",
"placeholders": {
"group": {
"content": "$1",
"example": "NYC Rock Climbers"
}
}
},
"ConversationHero--membership-2": {
"message": "Member of $group1$ and $group2$.",
"description": "Shown in the conversation hero to indicate this user is a member of at least two mutual groups",
"placeholders": {
"group1": {
"content": "$1",
"example": "NYC Rock Climbers"
},
"group2": {
"content": "$2",
"example": "Dinner Party"
}
}
},
"ConversationHero--membership-3": {
"message": "Member of $group1$, $group2$, and $group3$.",
"description": "Shown in the conversation hero to indicate this user is a member of at least three mutual groups",
"placeholders": {
"group1": {
"content": "$1",
"example": "NYC Rock Climbers"
},
"group2": {
"content": "$2",
"example": "Dinner Party"
},
"group3": {
"content": "$3",
"example": "Friends 🌿"
}
}
},
"ConversationHero--membership-added": {
"message": "$name$ added you to the group.",
"description": "Shown Indicates that you were added to a group by a given individual.",
"placeholders": {
"name": {
"content": "$1",
"example": "Jeff Smith"
}
}
}
}

View file

@ -355,6 +355,7 @@
<script type='text/javascript' src='js/read_receipts.js'></script>
<script type='text/javascript' src='js/read_syncs.js'></script>
<script type='text/javascript' src='js/view_syncs.js'></script>
<script type='text/javascript' src='js/message_requests.js'></script>
<script type='text/javascript' src='js/reactions.js'></script>
<script type='text/javascript' src='js/deletes.js'></script>
<script type='text/javascript' src='js/libphonenumber-util.js'></script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -1479,6 +1479,34 @@
});
}
});
window.Signal.RemoteConfig.initRemoteConfig();
// Maybe refresh remote configuration when we become active
window.registerForActive(async () => {
await window.Signal.RemoteConfig.maybeRefreshRemoteConfig();
});
// Listen for changes to the `desktop.messageRequests` remote configuration flag
const removeMessageRequestListener = window.Signal.RemoteConfig.onChange(
'desktop.messageRequests',
({ enabled }) => {
if (!enabled) {
return;
}
const conversations = window.getConversations();
conversations.forEach(conversation => {
conversation.set({
messageCountBeforeMessageRequests:
conversation.get('messageCount') || 0,
});
window.Signal.Data.updateConversation(conversation.attributes);
});
removeMessageRequestListener();
}
);
}
window.getSyncRequest = () =>
@ -1629,6 +1657,8 @@
addQueuedEventListener('typing', onTyping);
addQueuedEventListener('sticker-pack', onStickerPack);
addQueuedEventListener('viewSync', onViewSync);
addQueuedEventListener('messageRequestResponse', onMessageRequestResponse);
addQueuedEventListener('profileKeyUpdate', onProfileKeyUpdate);
window.Signal.AttachmentDownloads.start({
getMessageReceiver: () => messageReceiver,
@ -2258,7 +2288,10 @@
const result = ConversationController.getOrCreate(
messageDescriptor.id,
messageDescriptor.type
messageDescriptor.type,
messageDescriptor.type === 'group'
? { addedBy: message.getContact().get('id') }
: undefined
);
if (messageDescriptor.type === 'private') {
@ -2306,6 +2339,41 @@
return Promise.resolve();
}
async function onProfileKeyUpdate({ data, confirm }) {
const conversation = ConversationController.get(
data.source || data.sourceUuid
);
if (!conversation) {
window.log.error(
'onProfileKeyUpdate: could not find conversation',
data.source,
data.sourceUuid
);
confirm();
return;
}
if (!data.profileKey) {
window.log.error(
'onProfileKeyUpdate: missing profileKey',
data.profileKey
);
confirm();
return;
}
window.log.info(
'onProfileKeyUpdate: updating profileKey',
data.source,
data.sourceUuid
);
await conversation.setProfileKey(data.profileKey);
confirm();
}
async function handleMessageSentProfileUpdate({
data,
confirm,
@ -2318,7 +2386,7 @@
type
);
conversation.set({ profileSharing: true });
conversation.enableProfileSharing();
window.Signal.Data.updateConversation(conversation.attributes);
// Then we update our own profileKey if it's different from what we have
@ -2635,6 +2703,25 @@
Whisper.ViewSyncs.onSync(sync);
}
async function onMessageRequestResponse(ev) {
ev.confirm();
const { threadE164, threadUuid, groupId, messageRequestResponseType } = ev;
const args = {
threadE164,
threadUuid,
groupId,
type: messageRequestResponseType,
};
window.log.info('message request response', args);
const sync = Whisper.MessageRequests.add(args);
Whisper.MessageRequests.onResponse(sync);
}
function onReadReceipt(ev) {
const readAt = ev.timestamp;
const { timestamp } = ev.read;

91
js/message_requests.js Normal file
View file

@ -0,0 +1,91 @@
/* global
Backbone,
Whisper,
ConversationController,
*/
/* eslint-disable more/no-then */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.MessageRequests = new (Backbone.Collection.extend({
forConversation(conversation) {
if (conversation.get('e164')) {
const syncByE164 = this.findWhere({
e164: conversation.get('e164'),
});
if (syncByE164) {
window.log.info(
`Found early message request response for E164 ${conversation.get(
'e164'
)}`
);
this.remove(syncByE164);
return syncByE164;
}
}
if (conversation.get('uuid')) {
const syncByUuid = this.findWhere({
uuid: conversation.get('uuid'),
});
if (syncByUuid) {
window.log.info(
`Found early message request response for UUID ${conversation.get(
'uuid'
)}`
);
this.remove(syncByUuid);
return syncByUuid;
}
}
if (conversation.get('groupId')) {
const syncByGroupId = this.findWhere({
uuid: conversation.get('groupId'),
});
if (syncByGroupId) {
window.log.info(
`Found early message request response for GROUP ID ${conversation.get(
'groupId'
)}`
);
this.remove(syncByGroupId);
return syncByGroupId;
}
}
return null;
},
async onResponse(sync) {
try {
const threadE164 = sync.get('threadE164');
const threadUuid = sync.get('threadUuid');
const groupId = sync.get('groupId');
const identifier = threadE164 || threadUuid || groupId;
const conversation = ConversationController.get(identifier);
if (!conversation) {
window.log(
`Received message request response for unknown conversation: ${identifier}`
);
return;
}
conversation.applyMessageRequestResponse(sync.get('type'), {
fromSync: true,
});
this.remove(sync);
} catch (error) {
window.log.error(
'MessageRequests.onResponse error:',
error && error.stack ? error.stack : error
);
}
},
}))();
})();

View file

@ -1,4 +1,4 @@
/* global storage, _ */
/* global storage, _, ConversationController */
// eslint-disable-next-line func-names
(function() {
@ -79,4 +79,48 @@
window.log.info(`removing group(${groupId} from blocked list`);
storage.put(BLOCKED_GROUPS_ID, _.without(groupIds, groupId));
};
/**
* Optimistically adds a conversation to our local block list.
* @param {string} id
*/
storage.blockIdentifier = id => {
const conv = ConversationController.get(id);
if (conv) {
const uuid = conv.get('uuid');
if (uuid) {
storage.addBlockedUuid(uuid);
}
const e164 = conv.get('e164');
if (e164) {
storage.addBlockedNumber(e164);
}
const groupId = conv.get('groupId');
if (groupId) {
storage.addBlockedGroup(groupId);
}
}
};
/**
* Optimistically removes a conversation from our local block list.
* @param {string} id
*/
storage.unblockIdentifier = id => {
const conv = ConversationController.get(id);
if (conv) {
const uuid = conv.get('uuid');
if (uuid) {
storage.removeBlockedUuid(uuid);
}
const e164 = conv.get('e164');
if (e164) {
storage.removeBlockedNumber(e164);
}
const groupId = conv.get('groupId');
if (groupId) {
storage.removeBlockedGroup(groupId);
}
}
};
})();

View file

@ -8,7 +8,8 @@
libsignal,
storage,
textsecure,
Whisper
Whisper,
Signal
*/
/* eslint-disable more/no-then */
@ -60,6 +61,8 @@
return {
unreadCount: 0,
verified: textsecure.storage.protocol.VerifiedStatus.DEFAULT,
messageCount: 0,
sentMessageCount: 0,
};
},
@ -97,6 +100,8 @@
this.ourNumber = textsecure.storage.user.getNumber();
this.ourUuid = textsecure.storage.user.getUuid();
this.verifiedEnum = textsecure.storage.protocol.VerifiedStatus;
this.messageRequestEnum =
textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
// This may be overridden by ConversationController.getOrCreate, and signify
// our first save to the database. Or first fetch from the database.
@ -164,6 +169,52 @@
);
},
isBlocked() {
const uuid = this.get('uuid');
if (uuid) {
return window.storage.isUuidBlocked(uuid);
}
const e164 = this.get('e164');
if (e164) {
return window.storage.isBlocked(e164);
}
const groupId = this.get('groupId');
if (groupId) {
return window.storage.isGroupBlocked(groupId);
}
return false;
},
unblock() {
const uuid = this.get('uuid');
if (uuid) {
return window.storage.removeBlockedUuid(uuid);
}
const e164 = this.get('e164');
if (e164) {
return window.storage.removeBlockedNumber(e164);
}
const groupId = this.get('groupId');
if (groupId) {
return window.storage.removeBlockedGroup(groupId);
}
return false;
},
enableProfileSharing() {
this.set({ profileSharing: true });
},
disableProfileSharing() {
this.set({ profileSharing: false });
},
hasDraft() {
const draftAttachments = this.get('draftAttachments') || [];
return (
@ -320,6 +371,10 @@
this.messageCollection.remove(id);
existing.trigger('expired');
existing.cleanup();
// An expired message only counts as decrementing the message count, not
// the sent message count
this.decrementMessageCount();
};
// If a fetch is in progress, then we need to wait until that's complete to
@ -390,11 +445,15 @@
const shouldShowDraft =
this.hasDraft() && draftTimestamp && draftTimestamp >= timestamp;
const inboxPosition = this.get('inbox_position');
const messageRequestsEnabled = Signal.RemoteConfig.isEnabled(
'desktop.messageRequests'
);
const result = {
id: this.id,
isArchived: this.get('isArchived'),
isBlocked: this.isBlocked(),
activeAt: this.get('active_at'),
avatarPath: this.getAvatarPath(),
color,
@ -414,11 +473,17 @@
draftText,
phoneNumber: this.getNumber(),
membersCount: this.isPrivate()
? undefined
: (this.get('members') || []).length,
lastMessage: {
status: this.get('lastMessageStatus'),
text: this.get('lastMessage'),
deletedForEveryone: this.get('lastMessageDeletedForEveryone'),
},
acceptedMessageRequest: this.getAccepted(),
messageRequestsEnabled,
};
return result;
@ -449,6 +514,143 @@
}
},
incrementMessageCount() {
this.set({
messageCount: (this.get('messageCount') || 0) + 1,
});
window.Signal.Data.updateConversation(this.attributes);
},
decrementMessageCount() {
this.set({
messageCount: Math.max((this.get('messageCount') || 0) - 1, 0),
});
window.Signal.Data.updateConversation(this.attributes);
},
incrementSentMessageCount() {
this.set({
messageCount: (this.get('messageCount') || 0) + 1,
sentMessageCount: (this.get('sentMessageCount') || 0) + 1,
});
window.Signal.Data.updateConversation(this.attributes);
},
decrementSentMessageCount() {
this.set({
messageCount: Math.max((this.get('messageCount') || 0) - 1, 0),
sentMessageCount: Math.max((this.get('sentMessageCount') || 0) - 1, 0),
});
window.Signal.Data.updateConversation(this.attributes);
},
/**
* This function is called when a message request is accepted in order to
* handle sending read receipts and download any pending attachments.
*/
async handleReadAndDownloadAttachments() {
let messages;
do {
// eslint-disable-next-line no-await-in-loop
messages = await window.Signal.Data.getOlderMessagesByConversation(
this.get('id'),
{
MessageCollection: Whisper.MessageCollection,
limit: 100,
receivedAt: messages
? messages.first().get('received_at')
: undefined,
}
);
if (!messages.length) {
return;
}
const readMessages = messages.filter(
m => !m.hasErrors() && m.isIncoming()
);
const receiptSpecs = readMessages.map(m => ({
sender: m.get('source') || m.get('sourceUuid'),
timestamp: m.get('sent_at'),
hasErrors: m.hasErrors(),
}));
// eslint-disable-next-line no-await-in-loop
await this.sendReadReceiptsFor(receiptSpecs);
// eslint-disable-next-line no-await-in-loop
await Promise.all(readMessages.map(m => m.queueAttachmentDownloads()));
} while (messages.length > 0);
},
async applyMessageRequestResponse(response, { fromSync = false } = {}) {
// Apply message request response locally
this.set({
messageRequestResponseType: response,
});
window.Signal.Data.updateConversation(this.attributes);
if (response === this.messageRequestEnum.ACCEPT) {
this.unblock();
this.enableProfileSharing();
this.sendProfileKeyUpdate();
if (!fromSync) {
// Locally accepted
await this.handleReadAndDownloadAttachments();
}
} else if (response === this.messageRequestEnum.BLOCK) {
// Block locally, other devices should block upon receiving the sync message
window.storage.blockIdentifier(this.get('id'));
this.disableProfileSharing();
} else if (response === this.messageRequestEnum.DELETE) {
// Delete messages locally, other devices should delete upon receiving
// the sync message
this.destroyMessages();
this.disableProfileSharing();
this.updateLastMessage();
if (!fromSync) {
this.trigger('unload', 'deleted from message request');
}
} else if (response === this.messageRequestEnum.BLOCK_AND_DELETE) {
// Delete messages locally, other devices should delete upon receiving
// the sync message
this.destroyMessages();
this.disableProfileSharing();
this.updateLastMessage();
// Block locally, other devices should block upon receiving the sync message
window.storage.blockIdentifier(this.get('id'));
// Leave group if this was a local action
if (!fromSync) {
this.leaveGroup();
this.trigger('unload', 'blocked and deleted from message request');
}
}
},
async syncMessageRequestResponse(response) {
// Let this run, no await
this.applyMessageRequestResponse(response);
const { ourNumber, ourUuid } = this;
const { wrap, sendOptions } = ConversationController.prepareForSend(
ourNumber || ourUuid,
{
syncMessage: true,
}
);
await wrap(
textsecure.messaging.syncMessageRequestResponse(
{
threadE164: this.get('e164'),
threadUuid: this.get('uuid'),
groupId: this.get('groupId'),
type: response,
},
sendOptions
)
);
},
onMessageError() {
this.updateVerified();
},
@ -687,6 +889,52 @@
);
});
},
getSentMessageCount() {
return this.get('sentMessageCount') || 0;
},
getMessageRequestResponseType() {
return this.get('messageRequestResponseType') || 0;
},
/**
* Determine if this conversation should be considered "accepted" in terms
* of message requests
*/
getAccepted() {
const messageRequestsEnabled = Signal.RemoteConfig.isEnabled(
'desktop.messageRequests'
);
if (!messageRequestsEnabled) {
return true;
}
if (this.isMe()) {
return true;
}
if (
this.getMessageRequestResponseType() === this.messageRequestEnum.ACCEPT
) {
return true;
}
const fromContact = this.getIsAddedByContact();
const hasSentMessages = this.getSentMessageCount() > 0;
const hasMessagesBeforeMessageRequests =
(this.get('messageCountBeforeMessageRequests') || 0) > 0;
const hasNoMessages = (this.get('messageCount') || 0) === 0;
return (
fromContact ||
hasSentMessages ||
hasMessagesBeforeMessageRequests ||
hasNoMessages
);
},
onMemberVerifiedChange() {
// If the verified state of a member changes, our aggregate state changes.
// We trigger both events to replicate the behavior of Backbone.Model.set()
@ -1159,6 +1407,33 @@
});
},
async sendProfileKeyUpdate() {
const id = this.get('id');
const recipients = this.isPrivate()
? [this.get('uuid') || this.get('e164')]
: this.getRecipients();
if (!this.get('profileSharing')) {
window.log.error(
'Attempted to send profileKeyUpdate to conversation without profileSharing enabled',
id,
recipients
);
return;
}
window.log.info(
'Sending profileKeyUpdate to conversation',
id,
recipients
);
const profileKey = storage.get('profileKey');
await textsecure.messaging.sendProfileKeyUpdate(
profileKey,
recipients,
this.getSendOptions(),
this.get('groupId')
);
},
sendMessage(body, attachments, quote, preview, sticker) {
this.clearTypingTimers();
@ -1226,6 +1501,7 @@
draft: null,
draftTimestamp: null,
});
this.incrementSentMessageCount();
window.Signal.Data.updateConversation(this.attributes);
// We're offline!
@ -1487,6 +1763,32 @@
};
},
getIsContact() {
if (this.isPrivate()) {
return Boolean(this.get('name'));
}
return false;
},
getIsAddedByContact() {
if (this.isPrivate()) {
return this.getIsContact();
}
const addedBy = this.get('addedBy');
if (!addedBy) {
return false;
}
const conv = ConversationController.get(addedBy);
if (!conv) {
return false;
}
return conv.getIsContact();
},
async updateLastMessage() {
if (!this.id) {
return;
@ -1793,7 +2095,7 @@
async leaveGroup() {
const now = Date.now();
if (this.get('type') === 'group') {
const groupNumbers = this.getRecipients();
const groupIdentifiers = this.getRecipients();
this.set({ left: true });
window.Signal.Data.updateConversation(this.attributes);
@ -1816,7 +2118,7 @@
const options = this.getSendOptions();
message.send(
this.wrapSend(
textsecure.messaging.leaveGroup(this.id, groupNumbers, options)
textsecure.messaging.leaveGroup(this.id, groupIdentifiers, options)
)
);
}
@ -1845,11 +2147,10 @@
// Note that this will update the message in the database
await m.markRead(options.readAt);
const errors = m.get('errors');
return {
sender: m.get('source'),
sender: m.get('source') || m.get('sourceUuid'),
timestamp: m.get('sent_at'),
hasErrors: Boolean(errors && errors.length),
hasErrors: m.hasErrors(),
};
})
);
@ -1870,7 +2171,7 @@
read = read.filter(item => !item.hasErrors);
if (read.length && options.sendReadReceipts) {
window.log.info(`Sending ${read.length} read receipts`);
window.log.info(`Sending ${read.length} read syncs`);
// Because syncReadMessages sends to our other devices, and sendReadReceipts goes
// to a contact, we need accessKeys for both.
const { sendOptions } = ConversationController.prepareForSend(
@ -1880,25 +2181,31 @@
await this.wrapSend(
textsecure.messaging.syncReadMessages(read, sendOptions)
);
await this.sendReadReceiptsFor(read);
}
},
if (storage.get('read-receipt-setting')) {
const convoSendOptions = this.getSendOptions();
async sendReadReceiptsFor(items) {
// Only send read receipts for accepted conversations
if (storage.get('read-receipt-setting') && this.getAccepted()) {
window.log.info(`Sending ${items.length} read receipts`);
const convoSendOptions = this.getSendOptions();
const receiptsBySender = _.groupBy(items, 'sender');
await Promise.all(
_.map(_.groupBy(read, 'sender'), async (receipts, identifier) => {
const timestamps = _.map(receipts, 'timestamp');
const c = ConversationController.get(identifier);
await this.wrapSend(
textsecure.messaging.sendReadReceipts(
c.get('e164'),
c.get('uuid'),
timestamps,
convoSendOptions
)
);
})
);
}
await Promise.all(
_.map(receiptsBySender, async (receipts, identifier) => {
const timestamps = _.map(receipts, 'timestamp');
const c = ConversationController.get(identifier);
await this.wrapSend(
textsecure.messaging.sendReadReceipts(
c.get('e164'),
c.get('uuid'),
timestamps,
convoSendOptions
)
);
})
);
}
},

View file

@ -543,6 +543,9 @@
const conversation = this.getConversation();
const isGroup = conversation && !conversation.isPrivate();
const conversationAccepted = Boolean(
conversation && conversation.getAccepted()
);
const sticker = this.get('sticker');
const isTapToView = this.isTapToView();
@ -577,6 +580,7 @@
textPending: this.get('bodyPending'),
id: this.id,
conversationId: this.get('conversationId'),
conversationAccepted,
isSticker: Boolean(sticker),
direction: this.isIncoming() ? 'incoming' : 'outgoing',
timestamp: this.get('sent_at'),
@ -1145,6 +1149,28 @@
});
}
},
isEmpty() {
const body = this.get('body');
const hasAttachment = (this.get('attachments') || []).length > 0;
const quote = this.get('quote');
const hasContact = (this.get('contact') || []).length > 0;
const sticker = this.get('sticker');
const hasPreview = (this.get('preview') || []).length > 0;
const groupUpdate = this.get('group_update');
const expirationTimerUpdate = this.get('expirationTimerUpdate');
const notEmpty = Boolean(
body ||
hasAttachment ||
quote ||
hasContact ||
sticker ||
hasPreview ||
groupUpdate ||
expirationTimerUpdate
);
return !notEmpty;
},
unload() {
if (this.quotedMessage) {
this.quotedMessage = null;
@ -1454,26 +1480,32 @@
);
},
canReply() {
const isAccepted = this.getConversation().getAccepted();
const errors = this.get('errors');
const isOutgoing = this.get('type') === 'outgoing';
const numDelivered = this.get('delivered');
// Case 1: We cannot reply if this message is deleted for everyone
// Case 1: We cannot reply if we have accepted the message request
if (!isAccepted) {
return false;
}
// Case 2: We cannot reply if this message is deleted for everyone
if (this.get('deletedForEveryone')) {
return false;
}
// Case 2: We can reply if this is outgoing and delievered to at least one recipient
// Case 3: We can reply if this is outgoing and delievered to at least one recipient
if (isOutgoing && numDelivered > 0) {
return true;
}
// Case 3: We can reply if there are no errors
// Case 4: We can reply if there are no errors
if (!errors || (errors && errors.length === 0)) {
return true;
}
// Otherwise we cannot reply
// Case 5: default
return false;
},
@ -2171,10 +2203,12 @@
}
// Send delivery receipts, but only for incoming sealed sender messages
// and not for messages from unaccepted conversations
if (
type === 'incoming' &&
this.get('unidentifiedDeliveryReceived') &&
!this.hasErrors()
!this.hasErrors() &&
conversation.getAccepted()
) {
// Note: We both queue and batch because we want to wait until we are done
// processing incoming messages to start sending outgoing delivery receipts.
@ -2344,6 +2378,7 @@
if (conversation.get('left')) {
window.log.warn('re-added to a left group');
attributes.left = false;
conversation.set({ addedBy: message.getContact().get('id') });
}
} else if (dataMessage.group.type === GROUP_TYPES.QUIT) {
const sender = ConversationController.get(source || sourceUuid);
@ -2549,6 +2584,17 @@
}
}
// Drop empty messages. This needs to happen after the initial
// message.set call to make sure all possible properties are set
// before we determine that a message is empty.
if (message.isEmpty()) {
window.log.info(
`Dropping empty datamessage ${message.idForLogging()} in conversation ${conversation.idForLogging()}`
);
confirm();
return;
}
const conversationTimestamp = conversation.get('timestamp');
if (
!conversationTimestamp ||
@ -2561,9 +2607,14 @@
}
MessageController.register(message.id, message);
conversation.incrementMessageCount();
window.Signal.Data.updateConversation(conversation.attributes);
await message.queueAttachmentDownloads();
// Only queue attachments for downloads if this is an outgoing message
// or we've accepted the conversation
if (this.getConversation().getAccepted() || message.isOutgoing()) {
await message.queueAttachmentDownloads();
}
// Does this message have any pending, previously-received associated reactions?
const reactions = Whisper.Reactions.forMessage(message);
@ -2589,6 +2640,11 @@
await conversation.notify(message);
}
// Increment the sent message count if this is an outgoing message
if (type === 'outgoing') {
conversation.incrementSentMessageCount();
}
Whisper.events.trigger('incrementProgress');
confirm();
} catch (error) {

View file

@ -11,6 +11,7 @@ const Notifications = require('../../ts/notifications');
const OS = require('../../ts/OS');
const Stickers = require('./stickers');
const Settings = require('./settings');
const RemoteConfig = require('../../ts/RemoteConfig');
const Util = require('../../ts/util');
const Metadata = require('./metadata/SecretSessionCipher');
const RefreshSenderCertificate = require('./refresh_sender_certificate');
@ -350,6 +351,7 @@ exports.setup = (options = {}) => {
Notifications,
OS,
RefreshSenderCertificate,
RemoteConfig,
Settings,
Services,
State,

View file

@ -367,6 +367,7 @@
color: this.model.getColor(),
avatarPath: this.model.getAvatarPath(),
isAccepted: this.model.getAccepted(),
isVerified: this.model.isVerified(),
isMe: this.model.isMe(),
isGroup: !this.model.isPrivate(),
@ -447,6 +448,9 @@
</div>
`)[0];
const messageRequestEnum =
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
const props = {
id: this.model.id,
compositionApi,
@ -462,6 +466,26 @@
clearQuotedMessage: () => this.setQuoteMessage(null),
micCellEl,
attachmentListEl,
onAccept: this.model.syncMessageRequestResponse.bind(
this.model,
messageRequestEnum.ACCEPT
),
onBlock: this.model.syncMessageRequestResponse.bind(
this.model,
messageRequestEnum.BLOCK
),
onUnblock: this.model.syncMessageRequestResponse.bind(
this.model,
messageRequestEnum.ACCEPT
),
onDelete: this.model.syncMessageRequestResponse.bind(
this.model,
messageRequestEnum.DELETE
),
onBlockAndDelete: this.model.syncMessageRequestResponse.bind(
this.model,
messageRequestEnum.BLOCK_AND_DELETE
),
};
this.compositionAreaView = new Whisper.ReactWrapperView({
@ -1223,7 +1247,13 @@
}
return {
..._.pick(attachment, ['contentType', 'fileName', 'size', 'caption']),
..._.pick(attachment, [
'contentType',
'fileName',
'size',
'caption',
'blurHash',
]),
data,
};
},
@ -1433,6 +1463,7 @@
},
async handleImageAttachment(file) {
const blurHash = await window.imageToBlurHash(file);
if (MIME.isJPEG(file.type)) {
const rotatedDataUrl = await window.autoOrientImage(file);
const rotatedBlob = window.dataURLToBlobSync(rotatedDataUrl);
@ -1454,6 +1485,7 @@
contentType,
data,
size: data.byteLength,
blurHash,
};
}
@ -1470,6 +1502,7 @@
contentType,
data,
size: data.byteLength,
blurHash,
};
},
@ -2168,6 +2201,11 @@
});
message.trigger('unload');
this.model.messageCollection.remove(message.id);
if (message.isOutgoing()) {
this.model.decrementSentMessageCount();
} else {
this.model.decrementMessageCount();
}
this.resetPanel();
},
});

View file

@ -27,6 +27,7 @@
<script type="text/javascript" src="../../js/components.js" data-cover></script>
<script type="text/javascript" src="../../js/signal_protocol_store.js" data-cover></script>
<script type="text/javascript" src="../../js/storage.js" data-cover></script>
<script type="text/javascript" src="../../js/models/blockedNumbers.js" data-cover></script>
<script type="text/javascript" src="../../js/models/messages.js" data-cover></script>
<script type="text/javascript" src="../../js/models/conversations.js" data-cover></script>
<script type="text/javascript" src="../../js/conversation_controller.js" data-cover></script>

View file

@ -71,6 +71,7 @@
"blob-util": "1.3.0",
"blueimp-canvas-to-blob": "3.14.0",
"blueimp-load-image": "2.18.0",
"blurhash": "1.1.3",
"bunyan": "1.8.12",
"classnames": "2.2.5",
"config": "1.28.1",
@ -111,6 +112,7 @@
"proxy-agent": "3.1.1",
"qs": "6.5.1",
"react": "16.8.3",
"react-blurhash": "0.1.2",
"react-contextmenu": "2.11.0",
"react-dom": "16.8.3",
"react-dropzone": "10.1.7",
@ -157,6 +159,7 @@
"@storybook/react": "5.1.11",
"@types/agent-base": "4.2.0",
"@types/backbone": "1.4.3",
"@types/blueimp-load-image": "2.23.6",
"@types/chai": "4.1.2",
"@types/classnames": "2.2.3",
"@types/config": "0.0.34",

View file

@ -0,0 +1,45 @@
diff --git a/node_modules/react-blurhash/lib/Blurhash.js b/node_modules/react-blurhash/lib/Blurhash.js
index db3115d..746c877 100644
--- a/node_modules/react-blurhash/lib/Blurhash.js
+++ b/node_modules/react-blurhash/lib/Blurhash.js
@@ -61,8 +61,8 @@ var Blurhash = /** @class */ (function (_super) {
};
Blurhash.prototype.render = function () {
var _a = this.props, hash = _a.hash, height = _a.height, width = _a.width, punch = _a.punch, resolutionX = _a.resolutionX, resolutionY = _a.resolutionY, style = _a.style, rest = __rest(_a, ["hash", "height", "width", "punch", "resolutionX", "resolutionY", "style"]);
- return (react_1.default.createElement("div", __assign({}, rest, { style: __assign({ display: 'inline-block', height: height, width: width }, style, { position: 'relative' }) }),
- react_1.default.createElement(BlurhashCanvas_1.default, { hash: hash, height: resolutionY, width: resolutionX, punch: punch, style: canvasStyle })));
+ return (react_1.createElement("div", __assign({}, rest, { style: __assign({ display: 'inline-block', height: height, width: width }, style, { position: 'relative' }) }),
+ react_1.createElement(BlurhashCanvas_1.default, { hash: hash, height: resolutionY, width: resolutionX, punch: punch, style: canvasStyle })));
};
Blurhash.defaultProps = {
height: 128,
@@ -71,6 +71,6 @@ var Blurhash = /** @class */ (function (_super) {
resolutionY: 32,
};
return Blurhash;
-}(react_1.default.PureComponent));
+}(react_1.PureComponent));
exports.default = Blurhash;
//# sourceMappingURL=Blurhash.js.map
\ No newline at end of file
diff --git a/node_modules/react-blurhash/lib/BlurhashCanvas.js b/node_modules/react-blurhash/lib/BlurhashCanvas.js
index b2833dc..d666520 100644
--- a/node_modules/react-blurhash/lib/BlurhashCanvas.js
+++ b/node_modules/react-blurhash/lib/BlurhashCanvas.js
@@ -63,13 +63,13 @@ var BlurhashCanvas = /** @class */ (function (_super) {
};
BlurhashCanvas.prototype.render = function () {
var _a = this.props, hash = _a.hash, height = _a.height, width = _a.width, rest = __rest(_a, ["hash", "height", "width"]);
- return react_1.default.createElement("canvas", __assign({}, rest, { height: height, width: width, ref: this.handleRef }));
+ return react_1.createElement("canvas", __assign({}, rest, { height: height, width: width, ref: this.handleRef }));
};
BlurhashCanvas.defaultProps = {
height: 128,
width: 128,
};
return BlurhashCanvas;
-}(react_1.default.PureComponent));
+}(react_1.PureComponent));
exports.default = BlurhashCanvas;
//# sourceMappingURL=BlurhashCanvas.js.map
\ No newline at end of file

View file

@ -243,9 +243,11 @@ try {
}, 1000);
const { autoOrientImage } = require('./js/modules/auto_orient_image');
const { imageToBlurHash } = require('./ts/util/imageToBlurHash');
window.autoOrientImage = autoOrientImage;
window.dataURLToBlobSync = require('blueimp-canvas-to-blob');
window.imageToBlurHash = imageToBlurHash;
window.emojiData = require('emoji-datasource');
window.filesize = require('filesize');
window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInstance();

View file

@ -325,17 +325,33 @@ message SyncMessage {
optional uint64 timestamp = 2;
}
optional Sent sent = 1;
optional Contacts contacts = 2;
optional Groups groups = 3;
optional Request request = 4;
repeated Read read = 5;
optional Blocked blocked = 6;
optional Verified verified = 7;
optional Configuration configuration = 9;
optional bytes padding = 8;
repeated StickerPackOperation stickerPackOperation = 10;
optional ViewOnceOpen viewOnceOpen = 11;
message MessageRequestResponse {
enum Type {
UNKNOWN = 0;
ACCEPT = 1;
DELETE = 2;
BLOCK = 3;
BLOCK_AND_DELETE = 4;
}
optional string threadE164 = 1;
optional string threadUuid = 2;
optional bytes groupId = 3;
optional Type type = 4;
}
optional Sent sent = 1;
optional Contacts contacts = 2;
optional Groups groups = 3;
optional Request request = 4;
repeated Read read = 5;
optional Blocked blocked = 6;
optional Verified verified = 7;
optional Configuration configuration = 9;
optional bytes padding = 8;
repeated StickerPackOperation stickerPackOperation = 10;
optional ViewOnceOpen viewOnceOpen = 11;
optional MessageRequestResponse messageRequestResponse = 14;
}
message AttachmentPointer {

View file

@ -619,7 +619,7 @@
left: -12px;
right: -12px;
top: -10px;
bottom: -10px;
bottom: -15px;
}
border-radius: 16px;
@ -643,6 +643,10 @@
border-top-left-radius: 0px;
border-top-right-radius: 0px;
}
&--with-collapsed-metadata {
margin-bottom: -10px;
}
}
.module-message__sticker-container {
@ -708,6 +712,11 @@
padding-top: 4px;
}
.module-message__generic-attachment--not-active {
cursor: default;
pointer-events: none;
}
.module-message__generic-attachment__icon-container {
position: relative;
}
@ -3217,6 +3226,138 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
color: $color-gray-45;
}
// Module: Conversation Hero
.module-conversation-hero {
padding: 32px 0 28px 0;
text-align: center;
&__avatar {
margin-bottom: 12px;
}
&__profile-name {
@include font-title-2;
margin-bottom: 2px;
@include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-05;
}
}
&__with {
@include font-body-2;
margin-bottom: 16px;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
}
&__membership {
@include font-body-2;
padding: 0 16px;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
&__name {
@include font-body-2-bold;
}
}
}
// Module: Message Request Actions
.module-message-request-actions {
padding: 8px 16px 12px 16px;
@include light-theme {
background: $color-white;
}
@include dark-theme {
background: $color-gray-95;
}
&__message {
@include font-body-2;
text-align: center;
margin-bottom: 12px;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
&__name {
@include font-body-2-bold;
}
}
&__buttons {
display: flex;
flex-direction: row;
justify-content: center;
&__button {
border: none;
border-radius: 4px;
min-width: 80px;
height: 36px;
padding: 0 14px;
text-align: center;
&:focus {
outline: none;
@include keyboard-mode {
box-shadow: 0px 0px 0px 2px $ultramarine-ui-light;
}
}
@include font-body-1-bold;
@include light-theme {
background-color: $color-gray-05;
}
@include dark-theme {
background-color: $color-gray-75;
}
&:not(:last-of-type) {
margin-right: 8px;
}
&--deny {
color: $color-accent-red;
}
&--accept {
color: $color-accent-blue;
}
}
}
}
// Module: Conversation List Item
.module-conversation-list-item {
@ -3696,6 +3837,31 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
width: 62px;
}
.module-avatar--112 {
height: 112px;
width: 112px;
img {
height: 112px;
width: 112px;
}
}
.module-avatar__label--112 {
width: 112px;
font-size: 56px;
line-height: 112px;
}
.module-avatar__icon--112 {
height: 81px;
width: 81px;
}
.module-avatar__icon--112.module-avatar__icon--direct {
height: 87px;
width: 87px;
}
.module-avatar__icon--note-to-self {
width: 70%;
height: 70%;
@ -3900,6 +4066,7 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
display: inline-block;
margin: 1px;
vertical-align: middle;
overflow: hidden;
}
.module-image--with-background {
@ -6471,8 +6638,13 @@ button.module-image__border-overlay:focus {
color: $color-gray-05;
}
&__title {
@include font-body-1-bold;
}
&__content {
margin-bottom: 20px;
@include font-body-1;
margin-bottom: 22px;
}
&__buttons {

View file

@ -545,6 +545,8 @@ describe('Backup', () => {
timestamp: 1524185933350,
type: 'private',
unreadCount: 0,
messageCount: 0,
sentMessageCount: 0,
verified: 0,
sealedSender: 0,
version: 2,

94
ts/RemoteConfig.ts Normal file
View file

@ -0,0 +1,94 @@
// tslint:disable: no-backbone-get-set-outside-model
import { get, throttle } from 'lodash';
import { WebAPIType } from './textsecure/WebAPI';
type ConfigKeyType = 'desktop.messageRequests';
type ConfigValueType = {
name: ConfigKeyType;
enabled: boolean;
enabledAt?: number;
};
type ConfigMapType = { [key: string]: ConfigValueType };
type ConfigListenerType = (value: ConfigValueType) => unknown;
type ConfigListenersMapType = {
[key: string]: Array<ConfigListenerType>;
};
function getServer(): WebAPIType {
const OLD_USERNAME = window.storage.get<string>('number_id');
const USERNAME = window.storage.get<string>('uuid_id');
const PASSWORD = window.storage.get<string>('password');
return window.WebAPI.connect({
username: (USERNAME || OLD_USERNAME) as string,
password: PASSWORD as string,
});
}
let config: ConfigMapType = {};
const listeners: ConfigListenersMapType = {};
export async function initRemoteConfig() {
config = window.storage.get('remoteConfig') || {};
await maybeRefreshRemoteConfig();
}
export function onChange(key: ConfigKeyType, fn: ConfigListenerType) {
const keyListeners: Array<ConfigListenerType> = get(listeners, key, []);
keyListeners.push(fn);
listeners[key] = keyListeners;
return () => {
listeners[key] = listeners[key].filter(l => l !== fn);
};
}
const refreshRemoteConfig = async () => {
const now = Date.now();
const server = getServer();
const newConfig = await server.getConfig();
// Process new configuration in light of the old configuration
// The old configuration is not set as the initial value in reduce because
// flags may have been deleted
const oldConfig = config;
config = newConfig.reduce((previous, { name, enabled }) => {
const previouslyEnabled: boolean = get(oldConfig, [name, 'enabled'], false);
// If a flag was previously not enabled and is now enabled, record the time it was enabled
const enabledAt: number | undefined =
previouslyEnabled && enabled ? now : get(oldConfig, [name, 'enabledAt']);
const value = {
name: name as ConfigKeyType,
enabled,
enabledAt,
};
// If enablement changes at all, notify listeners
const currentListeners = listeners[name] || [];
if (previouslyEnabled !== enabled) {
currentListeners.forEach(listener => {
listener(value);
});
}
// Return new configuration object
return {
...previous,
[name]: value,
};
}, {});
window.storage.put('remoteConfig', config);
};
export const maybeRefreshRemoteConfig = throttle(
refreshRemoteConfig,
// Only fetch remote configuration if the last fetch was more than two hours ago
2 * 60 * 60 * 1000,
{ trailing: false }
);
export function isEnabled(name: ConfigKeyType): boolean {
return get(config, [name, 'enabled'], false);
}

View file

@ -2,6 +2,13 @@
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<Avatar
size={112}
color="blue"
avatarPath={util.gifObjectUrl}
conversationType="direct"
i18n={util.i18n}
/>
<Avatar
size={80}
color="blue"
@ -90,6 +97,13 @@
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<Avatar
size={112}
color="blue"
name="One"
conversationType="direct"
i18n={util.i18n}
/>
<Avatar
size={80}
color="blue"
@ -143,6 +157,14 @@
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<Avatar
size={112}
color="pink"
noteToSelf={true}
phoneNumber="(555) 353-3433"
conversationType="direct"
i18n={util.i18n}
/>
<Avatar
size={80}
color="pink"
@ -174,6 +196,7 @@
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<Avatar size={112} color="blue" conversationType="group" i18n={util.i18n} />
<Avatar size={80} color="blue" conversationType="group" i18n={util.i18n} />
<Avatar size={52} color="blue" conversationType="group" i18n={util.i18n} />
<Avatar size={28} color="blue" conversationType="group" i18n={util.i18n} />
@ -184,6 +207,7 @@
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<Avatar size={112} color="blue" conversationType="direct" i18n={util.i18n} />
<Avatar size={80} color="blue" conversationType="direct" i18n={util.i18n} />
<Avatar size={52} color="blue" conversationType="direct" i18n={util.i18n} />
<Avatar size={28} color="blue" conversationType="direct" i18n={util.i18n} />
@ -361,6 +385,43 @@
</util.ConversationContext>
```
### 112px
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<Avatar
size={112}
color="teal"
name="John Smith"
avatarPath={util.gifObjectUrl}
conversationType="direct"
i18n={util.i18n}
/>
<Avatar
size={112}
color="teal"
name="John"
conversationType="direct"
i18n={util.i18n}
/>
<Avatar
size={112}
color="teal"
name="John Smith"
conversationType="direct"
i18n={util.i18n}
/>
<Avatar size={112} color="teal" conversationType="direct" i18n={util.i18n} />
<Avatar
size={112}
color="teal"
name="Pupplies"
conversationType="group"
i18n={util.i18n}
/>
</util.ConversationContext>
```
### Broken color
```jsx

View file

@ -1,10 +1,10 @@
import React from 'react';
import * as React from 'react';
import classNames from 'classnames';
import { getInitials } from '../util/getInitials';
import { ColorType, LocalizerType } from '../types/Util';
export interface Props {
export type Props = {
avatarPath?: string;
color?: ColorType;
@ -13,7 +13,7 @@ export interface Props {
name?: string;
phoneNumber?: string;
profileName?: string;
size: 28 | 32 | 52 | 80;
size: 28 | 32 | 52 | 80 | 112;
onClick?: () => unknown;
@ -21,7 +21,7 @@ export interface Props {
innerRef?: React.Ref<HTMLDivElement>;
i18n: LocalizerType;
}
} & Pick<React.HTMLProps<HTMLDivElement>, 'className'>;
interface State {
imageBroken: boolean;
@ -139,12 +139,13 @@ export class Avatar extends React.Component<Props, State> {
noteToSelf,
onClick,
size,
className,
} = this.props;
const { imageBroken } = this.state;
const hasImage = !noteToSelf && avatarPath && !imageBroken;
if (![28, 32, 52, 80].includes(size)) {
if (![28, 32, 52, 80, 112].includes(size)) {
throw new Error(`Size ${size} is not supported!`);
}
@ -166,7 +167,8 @@ export class Avatar extends React.Component<Props, State> {
'module-avatar',
`module-avatar--${size}`,
hasImage ? 'module-avatar--with-image' : 'module-avatar--no-image',
!hasImage ? `module-avatar--${color}` : null
!hasImage ? `module-avatar--${color}` : null,
className
)}
ref={innerRef}
>

View file

@ -16,11 +16,17 @@ import {
InputApi,
Props as CompositionInputProps,
} from './CompositionInput';
import {
MessageRequestActions,
Props as MessageRequestActionsProps,
} from './conversation/MessageRequestActions';
import { countStickers } from './stickers/lib';
import { LocalizerType } from '../types/Util';
export type OwnProps = {
readonly i18n: LocalizerType;
readonly messageRequestsEnabled?: boolean;
readonly acceptedMessageRequest?: boolean;
readonly compositionApi?: React.MutableRefObject<{
focusInput: () => void;
isDirty: () => boolean;
@ -66,6 +72,7 @@ export type Props = Pick<
| 'showPickerHint'
| 'clearShowPickerHint'
> &
MessageRequestActionsProps &
OwnProps;
const emptyElement = (el: HTMLElement) => {
@ -73,7 +80,7 @@ const emptyElement = (el: HTMLElement) => {
el.innerHTML = '';
};
// tslint:disable-next-line max-func-body-length
// tslint:disable-next-line max-func-body-length cyclomatic-complexity
export const CompositionArea = ({
i18n,
attachmentListEl,
@ -86,6 +93,8 @@ export const CompositionArea = ({
onEditorStateChange,
onTextTooLong,
startingText,
clearQuotedMessage,
getQuotedMessage,
// EmojiButton
onPickEmoji,
onSetSkinTone,
@ -104,8 +113,19 @@ export const CompositionArea = ({
clearShowIntroduction,
showPickerHint,
clearShowPickerHint,
clearQuotedMessage,
getQuotedMessage,
// Message Requests
messageRequestsEnabled,
acceptedMessageRequest,
conversationType,
isBlocked,
name,
onAccept,
onBlock,
onBlockAndDelete,
onUnblock,
onDelete,
profileName,
phoneNumber,
}: Props) => {
const [disabled, setDisabled] = React.useState(false);
const [showMic, setShowMic] = React.useState(!startingText);
@ -299,6 +319,24 @@ export const CompositionArea = ({
};
}, [setLarge]);
if ((!acceptedMessageRequest || isBlocked) && messageRequestsEnabled) {
return (
<MessageRequestActions
i18n={i18n}
conversationType={conversationType}
isBlocked={isBlocked}
onBlock={onBlock}
onBlockAndDelete={onBlockAndDelete}
onUnblock={onUnblock}
onDelete={onDelete}
onAccept={onAccept}
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
/>
);
}
return (
<div className="module-composition-area">
<div className="module-composition-area__toggle-large">

View file

@ -1,16 +0,0 @@
#### All Options
```jsx
<util.ConversationContext theme={util.theme}>
<ConfirmationDialog
i18n={util.i18n}
onClose={() => console.log('onClose')}
onAffirmative={() => console.log('onAffirmative')}
affirmativeText="Affirm"
onNegative={() => console.log('onNegative')}
negativeText="Negate"
>
asdf child
</ConfirmationDialog>
</util.ConversationContext>
```

View file

@ -0,0 +1,40 @@
import * as React from 'react';
import { ConfirmationDialog } from './ConfirmationDialog';
// @ts-ignore
import { setup as setupI18n } from '../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../_locales/en/messages.json';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { text } from '@storybook/addon-knobs';
const i18n = setupI18n('en', enMessages);
storiesOf('Components/ConfirmationDialog', module).add(
'ConfirmationDialog',
() => {
return (
<ConfirmationDialog
i18n={i18n}
onClose={action('onClose')}
title={text('Title', 'Foo bar banana baz?')}
actions={[
{
text: 'Negate',
style: 'negative',
action: action('negative'),
},
{
text: 'Affirm',
style: 'affirmative',
action: action('affirmative'),
},
]}
>
{text('Child text', 'asdf blip')}
</ConfirmationDialog>
);
}
);

View file

@ -2,14 +2,18 @@ import * as React from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../types/Util';
export type ActionSpec = {
text: string;
action: () => unknown;
style?: 'affirmative' | 'negative';
};
export type OwnProps = {
readonly i18n: LocalizerType;
readonly children: React.ReactNode;
readonly affirmativeText?: string;
readonly onAffirmative?: () => unknown;
readonly title?: string | React.ReactNode;
readonly actions: Array<ActionSpec>;
readonly onClose: () => unknown;
readonly negativeText?: string;
readonly onNegative?: () => unknown;
};
export type Props = OwnProps;
@ -21,15 +25,7 @@ function focusRef(el: HTMLElement | null) {
}
export const ConfirmationDialog = React.memo(
({
i18n,
onClose,
children,
onAffirmative,
onNegative,
affirmativeText,
negativeText,
}: Props) => {
({ i18n, onClose, children, title, actions }: Props) => {
React.useEffect(() => {
const handler = ({ key }: KeyboardEvent) => {
if (key === 'Escape') {
@ -52,22 +48,25 @@ export const ConfirmationDialog = React.memo(
[onClose]
);
const handleNegative = React.useCallback(() => {
onClose();
if (onNegative) {
onNegative();
}
}, [onClose, onNegative]);
const handleAffirmative = React.useCallback(() => {
onClose();
if (onAffirmative) {
onAffirmative();
}
}, [onClose, onAffirmative]);
const handleAction = React.useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
onClose();
if (e.currentTarget.dataset.action) {
const actionIndex = parseInt(e.currentTarget.dataset.action, 10);
const { action } = actions[actionIndex];
action();
}
},
[onClose, actions]
);
return (
<div className="module-confirmation-dialog__container">
{title ? (
<h1 className="module-confirmation-dialog__container__title">
{title}
</h1>
) : null}
<div className="module-confirmation-dialog__container__content">
{children}
</div>
@ -79,28 +78,24 @@ export const ConfirmationDialog = React.memo(
>
{i18n('confirmation-dialog--Cancel')}
</button>
{onNegative && negativeText ? (
{actions.map((action, i) => (
<button
onClick={handleNegative}
key={i}
onClick={handleAction}
data-action={i}
className={classNames(
'module-confirmation-dialog__container__buttons__button',
'module-confirmation-dialog__container__buttons__button--negative'
action.style === 'affirmative'
? 'module-confirmation-dialog__container__buttons__button--affirmative'
: null,
action.style === 'negative'
? 'module-confirmation-dialog__container__buttons__button--negative'
: null
)}
>
{negativeText}
{action.text}
</button>
) : null}
{onAffirmative && affirmativeText ? (
<button
onClick={handleAffirmative}
className={classNames(
'module-confirmation-dialog__container__buttons__button',
'module-confirmation-dialog__container__buttons__button--affirmative'
)}
>
{affirmativeText}
</button>
) : null}
))}
</div>
</div>
);

View file

@ -1,31 +1,21 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { ConfirmationDialog } from './ConfirmationDialog';
import {
ConfirmationDialog,
Props as ConfirmationDialogProps,
} from './ConfirmationDialog';
import { LocalizerType } from '../types/Util';
export type OwnProps = {
readonly i18n: LocalizerType;
readonly children: React.ReactNode;
readonly affirmativeText?: string;
readonly onAffirmative?: () => unknown;
readonly onClose: () => unknown;
readonly negativeText?: string;
readonly onNegative?: () => unknown;
};
export type Props = OwnProps;
export type Props = OwnProps & ConfirmationDialogProps;
export const ConfirmationModal = React.memo(
// tslint:disable-next-line max-func-body-length
({
i18n,
onClose,
children,
onAffirmative,
onNegative,
affirmativeText,
negativeText,
}: Props) => {
({ i18n, onClose, children, ...rest }: Props) => {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
React.useEffect(() => {
@ -72,14 +62,7 @@ export const ConfirmationModal = React.memo(
className="module-confirmation-dialog__overlay"
onClick={handleCancel}
>
<ConfirmationDialog
i18n={i18n}
onClose={onClose}
onAffirmative={onAffirmative}
onNegative={onNegative}
affirmativeText={affirmativeText}
negativeText={negativeText}
>
<ConfirmationDialog i18n={i18n} {...rest} onClose={onClose}>
{children}
</ConfirmationDialog>
</div>,

View file

@ -25,7 +25,7 @@ export interface PropsType {
phoneNumber: string;
isMe: boolean;
name?: string;
color: ColorType;
color?: ColorType;
verified: boolean;
profileName?: string;
avatarPath?: string;

View file

@ -2,7 +2,7 @@ import React from 'react';
import { Emojify } from './Emojify';
interface Props {
export interface Props {
phoneNumber?: string;
name?: string;
profileName?: string;

View file

@ -64,6 +64,7 @@ const stories: Array<ConversationHeaderStory> = [
phoneNumber: '(202) 555-0001',
id: '1',
profileName: '🔥Flames🔥',
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -76,6 +77,7 @@ const stories: Array<ConversationHeaderStory> = [
name: 'Someone 🔥 Somewhere',
phoneNumber: '(202) 555-0002',
id: '2',
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -88,6 +90,7 @@ const stories: Array<ConversationHeaderStory> = [
phoneNumber: '(202) 555-0003',
id: '3',
profileName: '🔥Flames🔥',
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -97,6 +100,7 @@ const stories: Array<ConversationHeaderStory> = [
props: {
phoneNumber: '(202) 555-0011',
id: '11',
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -108,6 +112,7 @@ const stories: Array<ConversationHeaderStory> = [
color: 'deep_orange',
phoneNumber: '(202) 555-0004',
id: '4',
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -129,6 +134,7 @@ const stories: Array<ConversationHeaderStory> = [
value: 10,
},
],
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -159,6 +165,7 @@ const stories: Array<ConversationHeaderStory> = [
value: 10,
},
],
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -183,6 +190,7 @@ const stories: Array<ConversationHeaderStory> = [
value: 10,
},
],
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -200,6 +208,25 @@ const stories: Array<ConversationHeaderStory> = [
phoneNumber: '(202) 555-0007',
id: '7',
isMe: true,
isAccepted: true,
...actionProps,
...housekeepingProps,
},
},
],
},
{
title: 'Unaccepted',
description: 'No safety number entry.',
items: [
{
title: '1:1 conversation',
props: {
color: 'blue',
phoneNumber: '(202) 555-0007',
id: '7',
isMe: false,
isAccepted: false,
...actionProps,
...housekeepingProps,
},

View file

@ -25,6 +25,7 @@ export interface PropsData {
color?: ColorType;
avatarPath?: string;
isAccepted?: boolean;
isVerified?: boolean;
isMe?: boolean;
isGroup?: boolean;
@ -222,6 +223,7 @@ export class ConversationHeader extends React.Component<Props> {
public renderMenu(triggerId: string) {
const {
i18n,
isAccepted,
isMe,
isGroup,
isArchived,
@ -241,7 +243,7 @@ export class ConversationHeader extends React.Component<Props> {
return (
<ContextMenu id={triggerId}>
{leftGroup ? null : (
{!leftGroup && isAccepted ? (
<SubMenu title={disappearingTitle}>
{(timerOptions || []).map(item => (
<MenuItem
@ -254,7 +256,7 @@ export class ConversationHeader extends React.Component<Props> {
</MenuItem>
))}
</SubMenu>
)}
) : null}
<MenuItem onClick={onShowAllMedia}>{i18n('viewRecentMedia')}</MenuItem>
{isGroup ? (
<MenuItem onClick={onShowGroupMembers}>
@ -266,7 +268,7 @@ export class ConversationHeader extends React.Component<Props> {
{i18n('showSafetyNumber')}
</MenuItem>
) : null}
{!isGroup ? (
{!isGroup && isAccepted ? (
<MenuItem onClick={onResetSession}>{i18n('resetSession')}</MenuItem>
) : null}
{isArchived ? (

View file

@ -0,0 +1,130 @@
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { number as numberKnob, text } from '@storybook/addon-knobs';
import { ConversationHero } from './ConversationHero';
// @ts-ignore
import { setup as setupI18n } from '../../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const getName = () => text('name', 'Cayce Bollard');
const getProfileName = () => text('profileName', 'Cayce Bollard');
const getAvatarPath = () =>
text('avatarPath', '/fixtures/kitten-4-112-112.jpg');
const getPhoneNumber = () => text('phoneNumber', '+1 (646) 327-2700');
storiesOf('Components/Conversation/ConversationHero', module)
.add('Direct (Three Other Groups)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
groups={['NYC Rock Climbers', 'Dinner Party', 'Friends 🌿']}
/>
</div>
);
})
.add('Direct (Two Other Groups)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
groups={['NYC Rock Climbers', 'Dinner Party']}
/>
</div>
);
})
.add('Direct (One Other Group)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
groups={['NYC Rock Climbers']}
/>
</div>
);
})
.add('Direct (No Other Groups)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
groups={[]}
/>
</div>
);
})
.add('Group (many members)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
name={text('groupName', 'NYC Rock Climbers')}
phoneNumber={text('phoneNumber', '+1 (646) 327-2700')}
conversationType="group"
membersCount={numberKnob('membersCount', 22)}
/>
</div>
);
})
.add('Group (one member)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
name={text('groupName', 'NYC Rock Climbers')}
phoneNumber={text('phoneNumber', '+1 (646) 327-2700')}
conversationType="group"
membersCount={1}
/>
</div>
);
})
.add('Group (zero members)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
name={text('groupName', 'NYC Rock Climbers')}
phoneNumber={text('phoneNumber', '+1 (646) 327-2700')}
conversationType="group"
membersCount={0}
/>
</div>
);
})
.add('Note to Self', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
isMe={true}
conversationType="direct"
phoneNumber={getPhoneNumber()}
/>
</div>
);
});

View file

@ -0,0 +1,125 @@
import * as React from 'react';
import { take } from 'lodash';
import { Avatar, Props as AvatarProps } from '../Avatar';
import { ContactName } from './ContactName';
import { Emojify } from './Emojify';
import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util';
export type Props = {
i18n: LocalizerType;
isMe?: boolean;
groups?: Array<string>;
membersCount?: number;
phoneNumber: string;
onHeightChange?: () => unknown;
} & Omit<AvatarProps, 'onClick' | 'size' | 'noteToSelf'>;
const renderMembershipRow = ({
i18n,
groups,
conversationType,
isMe,
}: Pick<Props, 'i18n' | 'groups' | 'conversationType' | 'isMe'>) => {
const className = 'module-conversation-hero__membership';
const nameClassName = `${className}__name`;
if (isMe) {
return <div className={className}>{i18n('noteToSelfHero')}</div>;
}
if (conversationType === 'direct' && groups && groups.length > 0) {
const firstThreeGroups = take(groups, 3).map((group, i) => (
<strong key={i} className={nameClassName}>
<Emojify text={group} />
</strong>
));
return (
<div className={className}>
<Intl
i18n={i18n}
id={`ConversationHero--membership-${firstThreeGroups.length}`}
components={firstThreeGroups}
/>
</div>
);
}
return null;
};
export const ConversationHero = ({
i18n,
avatarPath,
color,
conversationType,
isMe,
membersCount,
groups = [],
name,
phoneNumber,
profileName,
onHeightChange,
}: Props) => {
const firstRenderRef = React.useRef(true);
React.useEffect(() => {
// If any of the depenencies for this hook change then the height of this
// component may have changed. The cleanup function notifies listeners of
// any potential height changes.
return () => {
if (onHeightChange && !firstRenderRef.current) {
onHeightChange();
} else {
firstRenderRef.current = false;
}
};
}, [
firstRenderRef,
onHeightChange,
// Avoid collisions in these dependencies by prefixing them
// These dependencies may be dynamic, and therefore may cause height changes
`mc-${membersCount}`,
`n-${name}`,
`pn-${profileName}`,
...groups.map(g => `g-${g}`),
]);
return (
<div className="module-conversation-hero">
<Avatar
i18n={i18n}
color={color}
noteToSelf={isMe}
avatarPath={avatarPath}
conversationType={conversationType}
name={name}
profileName={profileName}
size={112}
className="module-conversation-hero__avatar"
/>
<h1 className="module-conversation-hero__profile-name">
{isMe ? (
i18n('noteToSelf')
) : (
<ContactName
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
/>
)}
</h1>
{!isMe ? (
<div className="module-conversation-hero__with">
{membersCount === 1
? i18n('ConversationHero--members-1')
: membersCount !== undefined
? i18n('ConversationHero--members', [`${membersCount}`])
: phoneNumber}
</div>
) : null}
{renderMembershipRow({ isMe, groups, conversationType, i18n })}
</div>
);
};

View file

@ -1,5 +1,6 @@
import React from 'react';
import classNames from 'classnames';
import { Blurhash } from 'react-blurhash';
import { Spinner } from '../Spinner';
import { LocalizerType } from '../../types/Util';
@ -30,6 +31,7 @@ interface Props {
darkOverlay?: boolean;
playIconOverlay?: boolean;
softCorners?: boolean;
blurHash?: string;
i18n: LocalizerType;
onClick?: (attachment: AttachmentType) => void;
@ -38,7 +40,21 @@ interface Props {
}
export class Image extends React.Component<Props> {
private canClick() {
const { onClick, attachment, url } = this.props;
const { pending } = attachment || { pending: true };
return Boolean(onClick && !pending && url);
}
public handleClick = (event: React.MouseEvent) => {
if (!this.canClick()) {
event.preventDefault();
event.stopPropagation();
return;
}
const { onClick, attachment } = this.props;
if (onClick) {
@ -50,6 +66,13 @@ export class Image extends React.Component<Props> {
};
public handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (!this.canClick()) {
event.preventDefault();
event.stopPropagation();
return;
}
const { onClick, attachment } = this.props;
if (onClick && (event.key === 'Enter' || event.key === 'Space')) {
@ -64,6 +87,7 @@ export class Image extends React.Component<Props> {
const {
alt,
attachment,
blurHash,
bottomOverlay,
closeButton,
curveBottomLeft,
@ -71,11 +95,10 @@ export class Image extends React.Component<Props> {
curveTopLeft,
curveTopRight,
darkOverlay,
height,
height = 0,
i18n,
noBackground,
noBorder,
onClick,
onClickClose,
onError,
overlayText,
@ -84,18 +107,16 @@ export class Image extends React.Component<Props> {
softCorners,
tabIndex,
url,
width,
width = 0,
} = this.props;
const { caption, pending } = attachment || { caption: null, pending: true };
const canClick = onClick && !pending;
const canClick = this.canClick();
const overlayClassName = classNames(
'module-image__border-overlay',
noBorder ? null : 'module-image__border-overlay--with-border',
canClick && onClick
? 'module-image__border-overlay--with-click-handler'
: null,
canClick ? 'module-image__border-overlay--with-click-handler' : null,
curveTopLeft ? 'module-image--curved-top-left' : null,
curveTopRight ? 'module-image--curved-top-right' : null,
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
@ -105,19 +126,14 @@ export class Image extends React.Component<Props> {
darkOverlay ? 'module-image__border-overlay--dark' : null
);
let overlay;
if (canClick && onClick) {
overlay = (
<button
className={overlayClassName}
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
tabIndex={tabIndex}
/>
);
} else {
overlay = <div className={overlayClassName} />;
}
const overlay = canClick ? (
<button
className={overlayClassName}
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
tabIndex={tabIndex}
/>
) : null;
return (
<div
@ -145,7 +161,7 @@ export class Image extends React.Component<Props> {
>
<Spinner svgSize="normal" />
</div>
) : (
) : url ? (
<img
onError={onError}
className="module-image__image"
@ -154,7 +170,14 @@ export class Image extends React.Component<Props> {
width={width}
src={url}
/>
)}
) : blurHash ? (
<Blurhash
hash={blurHash}
width={width}
height={height}
style={{ display: 'block' }}
/>
) : null}
{caption ? (
<img
className="module-image__caption-icon"

View file

@ -76,6 +76,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
blurHash={attachments[0].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
noBackground={isSticker}
@ -103,6 +104,7 @@ export class ImageGrid extends React.Component<Props> {
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
attachment={attachments[0]}
blurHash={attachments[0].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={false}
curveTopLeft={curveTopLeft}
@ -117,6 +119,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
blurHash={attachments[1].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={false}
curveTopRight={curveTopRight}
@ -139,6 +142,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
blurHash={attachments[0].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={false}
curveTopLeft={curveTopLeft}
@ -155,6 +159,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
blurHash={attachments[1].blurHash}
curveTopRight={curveTopRight}
height={99}
width={99}
@ -167,6 +172,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[2], i18n)}
i18n={i18n}
blurHash={attachments[2].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={false}
curveBottomRight={curveBottomRight}
@ -191,6 +197,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
blurHash={attachments[0].blurHash}
curveTopLeft={curveTopLeft}
noBorder={false}
attachment={attachments[0]}
@ -204,6 +211,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
blurHash={attachments[1].blurHash}
curveTopRight={curveTopRight}
playIconOverlay={isVideoAttachment(attachments[1])}
noBorder={false}
@ -219,6 +227,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[2], i18n)}
i18n={i18n}
blurHash={attachments[2].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={false}
curveBottomLeft={curveBottomLeft}
@ -233,6 +242,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[3], i18n)}
i18n={i18n}
blurHash={attachments[3].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={false}
curveBottomRight={curveBottomRight}
@ -262,6 +272,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
blurHash={attachments[0].blurHash}
curveTopLeft={curveTopLeft}
attachment={attachments[0]}
playIconOverlay={isVideoAttachment(attachments[0])}
@ -274,6 +285,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
blurHash={attachments[1].blurHash}
curveTopRight={curveTopRight}
playIconOverlay={isVideoAttachment(attachments[1])}
height={149}
@ -288,6 +300,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[2], i18n)}
i18n={i18n}
blurHash={attachments[2].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
curveBottomLeft={curveBottomLeft}
@ -302,6 +315,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[3], i18n)}
i18n={i18n}
blurHash={attachments[3].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
playIconOverlay={isVideoAttachment(attachments[3])}
@ -315,6 +329,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[4], i18n)}
i18n={i18n}
blurHash={attachments[4].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
curveBottomRight={curveBottomRight}

View file

@ -16,6 +16,7 @@ import {
PropsHousekeeping,
} from './Message';
import { EmojiPicker } from '../emoji/EmojiPicker';
import { MIMEType } from '../../types/MIME';
const book = storiesOf('Components/Conversation/Message', module);
@ -1272,6 +1273,28 @@ const stories: Array<MessageStory> = [
})),
],
],
[
'BlurHash',
[
{
title: 'Incoming BlurHash',
makeDataProps: () => ({
...baseDataProps,
direction: 'incoming',
attachments: [
{
blurHash: 'LEHV6nWB2yk8pyo0adR*.7kCMdnj',
width: 300,
height: 600,
fileName: 'foo.jpg',
contentType: 'image/jpeg' as MIMEType,
url: '',
},
],
}),
},
],
],
];
const renderEmojiPicker: AllProps['renderEmojiPicker'] = ({

View file

@ -573,6 +573,9 @@ export class Message extends React.PureComponent<Props, State> {
withContentBelow
? 'module-message__attachment-container--with-content-below'
: null,
collapseMetadata
? 'module-message__attachment-container--with-collapsed-metadata'
: null,
isSticker && !collapseMetadata
? 'module-message__sticker-container--with-content-below'
: null
@ -627,6 +630,9 @@ export class Message extends React.PureComponent<Props, State> {
: null,
withContentAbove
? 'module-message__generic-attachment--with-content-above'
: null,
!firstAttachment.url
? 'module-message__generic-attachment--not-active'
: null
)}
// There's only ever one of these, so we don't want users to tab into it
@ -635,6 +641,10 @@ export class Message extends React.PureComponent<Props, State> {
event.stopPropagation();
event.preventDefault();
if (!firstAttachment.url) {
return;
}
this.openGenericAttachment();
}}
>
@ -1117,7 +1127,7 @@ export class Message extends React.PureComponent<Props, State> {
)}
>
{canReply ? reactButton : null}
{downloadButton}
{canReply ? downloadButton : null}
{canReply ? replyButton : null}
{menuButton}
</div>
@ -1881,6 +1891,15 @@ export class Message extends React.PureComponent<Props, State> {
return;
}
// If there an incomplete attachment, do not execute the default action
const { attachments } = this.props;
if (attachments && attachments.length > 0) {
const [firstAttachment] = attachments;
if (!firstAttachment.url) {
return;
}
}
this.handleOpen(event);
};

View file

@ -0,0 +1,59 @@
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import {
MessageRequestActions,
Props as MessageRequestActionsProps,
} from './MessageRequestActions';
// @ts-ignore
import { setup as setupI18n } from '../../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const getBaseProps = (isGroup = false): MessageRequestActionsProps => ({
i18n,
conversationType: isGroup ? 'group' : 'direct',
profileName: isGroup ? undefined : text('profileName', 'Cayce Bollard'),
name: isGroup
? text('name', 'NYC Rock Climbers')
: text('name', 'Cayce Bollard'),
onBlock: action('block'),
onDelete: action('delete'),
onBlockAndDelete: action('blockAndDelete'),
onUnblock: action('unblock'),
onAccept: action('accept'),
});
storiesOf('Components/Conversation/MessageRequestActions', module)
.add('Direct', () => {
return (
<div style={{ width: '480px' }}>
<MessageRequestActions {...getBaseProps()} />
</div>
);
})
.add('Direct (Blocked)', () => {
return (
<div style={{ width: '480px' }}>
<MessageRequestActions {...getBaseProps()} isBlocked={true} />
</div>
);
})
.add('Group', () => {
return (
<div style={{ width: '480px' }}>
<MessageRequestActions {...getBaseProps(true)} />
</div>
);
})
.add('Group (Blocked)', () => {
return (
<div style={{ width: '480px' }}>
<MessageRequestActions {...getBaseProps(true)} isBlocked={true} />
</div>
);
});

View file

@ -0,0 +1,130 @@
import * as React from 'react';
import classNames from 'classnames';
import { ContactName, Props as ContactNameProps } from './ContactName';
import {
MessageRequestActionsConfirmation,
MessageRequestState,
Props as MessageRequestActionsConfirmationProps,
} from './MessageRequestActionsConfirmation';
import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util';
export type Props = {
i18n: LocalizerType;
onAccept(): unknown;
} & Omit<ContactNameProps, 'module'> &
Omit<
MessageRequestActionsConfirmationProps,
'i18n' | 'state' | 'onChangeState'
>;
export const MessageRequestActions = ({
i18n,
name,
profileName,
phoneNumber,
conversationType,
isBlocked,
onBlock,
onBlockAndDelete,
onUnblock,
onDelete,
onAccept,
}: Props) => {
const [mrState, setMrState] = React.useState(MessageRequestState.default);
return (
<>
{mrState !== MessageRequestState.default ? (
<MessageRequestActionsConfirmation
i18n={i18n}
onBlock={onBlock}
onBlockAndDelete={onBlockAndDelete}
onUnblock={onUnblock}
onDelete={onDelete}
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
conversationType={conversationType}
state={mrState}
onChangeState={setMrState}
/>
) : null}
<div className="module-message-request-actions">
<p className="module-message-request-actions__message">
<Intl
i18n={i18n}
id={`MessageRequests--message-${conversationType}${
isBlocked ? '-blocked' : ''
}`}
components={[
<strong
key="name"
className="module-message-request-actions__message__name"
>
<ContactName
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
/>
</strong>,
]}
/>
</p>
<div className="module-message-request-actions__buttons">
{isBlocked ? (
<button
onClick={() => {
setMrState(MessageRequestState.unblocking);
}}
tabIndex={0}
className={classNames(
'module-message-request-actions__buttons__button',
'module-message-request-actions__buttons__button--accept'
)}
>
{i18n('MessageRequests--unblock')}
</button>
) : (
<button
onClick={() => {
setMrState(MessageRequestState.blocking);
}}
tabIndex={0}
className={classNames(
'module-message-request-actions__buttons__button',
'module-message-request-actions__buttons__button--deny'
)}
>
{i18n('MessageRequests--block')}
</button>
)}
<button
onClick={() => {
setMrState(MessageRequestState.deleting);
}}
tabIndex={0}
className={classNames(
'module-message-request-actions__buttons__button',
'module-message-request-actions__buttons__button--deny'
)}
>
{i18n('MessageRequests--delete')}
</button>
{!isBlocked ? (
<button
onClick={onAccept}
tabIndex={0}
className={classNames(
'module-message-request-actions__buttons__button',
'module-message-request-actions__buttons__button--accept'
)}
>
{i18n('MessageRequests--accept')}
</button>
) : null}
</div>
</div>
</>
);
};

View file

@ -0,0 +1,156 @@
import * as React from 'react';
import { ContactName, Props as ContactNameProps } from './ContactName';
import { ConfirmationModal } from '../ConfirmationModal';
import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util';
export enum MessageRequestState {
blocking,
deleting,
unblocking,
default,
}
export type Props = {
i18n: LocalizerType;
conversationType: 'group' | 'direct';
isBlocked?: boolean;
onBlock(): unknown;
onBlockAndDelete(): unknown;
onUnblock(): unknown;
onDelete(): unknown;
state: MessageRequestState;
onChangeState(state: MessageRequestState): unknown;
} & Omit<ContactNameProps, 'module'>;
// tslint:disable-next-line: max-func-body-length
export const MessageRequestActionsConfirmation = ({
i18n,
name,
profileName,
phoneNumber,
conversationType,
onBlock,
onBlockAndDelete,
onUnblock,
onDelete,
state,
onChangeState,
}: Props) => {
if (state === MessageRequestState.blocking) {
return (
// tslint:disable-next-line: use-simple-attributes
<ConfirmationModal
i18n={i18n}
onClose={() => {
onChangeState(MessageRequestState.default);
}}
title={
<Intl
i18n={i18n}
id={`MessageRequests--block-${conversationType}-confirm-title`}
components={[
<ContactName
key="name"
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
/>,
]}
/>
}
actions={[
{
text: i18n('MessageRequests--block'),
action: onBlock,
style: 'negative',
},
{
text: i18n('MessageRequests--block-and-delete'),
action: onBlockAndDelete,
style: 'negative',
},
]}
>
{i18n(`MessageRequests--block-${conversationType}-confirm-body`)}
</ConfirmationModal>
);
}
if (state === MessageRequestState.unblocking) {
return (
// tslint:disable-next-line: use-simple-attributes
<ConfirmationModal
i18n={i18n}
onClose={() => {
onChangeState(MessageRequestState.default);
}}
title={
<Intl
i18n={i18n}
id={'MessageRequests--unblock-confirm-title'}
components={[
<ContactName
key="name"
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
/>,
]}
/>
}
actions={[
{
text: i18n('MessageRequests--unblock'),
action: onUnblock,
style: 'affirmative',
},
{
text: i18n('MessageRequests--delete'),
action: onDelete,
style: 'negative',
},
]}
>
{i18n(`MessageRequests--unblock-${conversationType}-confirm-body`)}
</ConfirmationModal>
);
}
if (state === MessageRequestState.deleting) {
return (
// tslint:disable-next-line: use-simple-attributes
<ConfirmationModal
i18n={i18n}
onClose={() => {
onChangeState(MessageRequestState.default);
}}
title={
<Intl
i18n={i18n}
id={`MessageRequests--delete-${conversationType}-confirm-title`}
components={[
<ContactName
key="name"
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
/>,
]}
/>
}
actions={[
{
text: i18n(`MessageRequests--delete-${conversationType}`),
action: onDelete,
style: 'negative',
},
]}
>
{i18n(`MessageRequests--delete-${conversationType}-confirm-body`)}
</ConfirmationModal>
);
}
return null;
};

View file

@ -50,6 +50,7 @@ type PropsHousekeepingType = {
actions: Object
) => JSX.Element;
renderLastSeenIndicator: (id: string) => JSX.Element;
renderHeroRow: (id: string, resizeHeroRow: () => unknown) => JSX.Element;
renderLoadingRow: (id: string) => JSX.Element;
renderTypingBubble: (id: string) => JSX.Element;
};
@ -250,6 +251,10 @@ export class Timeline extends React.PureComponent<Props, State> {
this.recomputeRowHeights(row || 0);
};
public resizeHeroRow = () => {
this.resize(0);
};
public onScroll = (data: OnScrollParamsType) => {
// Ignore scroll events generated as react-virtualized recursively scrolls and
// re-measures to get us where we want to go.
@ -501,6 +506,7 @@ export class Timeline extends React.PureComponent<Props, State> {
haveOldest,
items,
renderItem,
renderHeroRow,
renderLoadingRow,
renderLastSeenIndicator,
renderTypingBubble,
@ -515,7 +521,13 @@ export class Timeline extends React.PureComponent<Props, State> {
const typingBubbleRow = this.getTypingBubbleRow();
let rowContents;
if (!haveOldest && row === 0) {
if (haveOldest && row === 0) {
rowContents = (
<div data-row={row} style={styleWithWidth} role="row">
{renderHeroRow(id, this.resizeHeroRow)}
</div>
);
} else if (!haveOldest && row === 0) {
rowContents = (
<div data-row={row} style={styleWithWidth} role="row">
{renderLoadingRow(id)}
@ -574,13 +586,10 @@ export class Timeline extends React.PureComponent<Props, State> {
};
public fromItemIndexToRow(index: number) {
const { haveOldest, oldestUnreadIndex } = this.props;
const { oldestUnreadIndex } = this.props;
let addition = 0;
if (!haveOldest) {
addition += 1;
}
// We will always render either the hero row or the loading row
let addition = 1;
if (isNumber(oldestUnreadIndex) && index >= oldestUnreadIndex) {
addition += 1;
@ -590,15 +599,12 @@ export class Timeline extends React.PureComponent<Props, State> {
}
public getRowCount() {
const { haveOldest, oldestUnreadIndex, typingContact } = this.props;
const { oldestUnreadIndex, typingContact } = this.props;
const { items } = this.props;
const itemsCount = items && items.length ? items.length : 0;
let extraRows = 0;
if (!haveOldest) {
extraRows += 1;
}
// We will always render either the hero row or the loading row
let extraRows = 1;
if (isNumber(oldestUnreadIndex)) {
extraRows += 1;
@ -612,13 +618,10 @@ export class Timeline extends React.PureComponent<Props, State> {
}
public fromRowToItemIndex(row: number, props?: Props): number | undefined {
const { haveOldest, items } = props || this.props;
const { items } = props || this.props;
let subtraction = 0;
if (!haveOldest) {
subtraction += 1;
}
// We will always render either the hero row or the loading row
let subtraction = 1;
const oldestUnreadRow = this.getLastSeenIndicatorRow();
if (isNumber(oldestUnreadRow) && row > oldestUnreadRow) {

View file

@ -30,6 +30,7 @@ const renderEmojiPicker: TimelineItemProps['renderEmojiPicker'] = ({
const getDefaultProps = () => ({
conversationId: 'conversation-id',
conversationAccepted: true,
id: 'asdf',
isSelected: false,
selectMessage: action('selectMessage'),

View file

@ -77,6 +77,7 @@ export type TimelineItemType =
type PropsLocalType = {
conversationId: string;
conversationAccepted: boolean;
item?: TimelineItemType;
id: string;
isSelected: boolean;

View file

@ -92,8 +92,13 @@ export const StickerManagerPackRow = React.memo(
<ConfirmationModal
i18n={i18n}
onClose={clearUninstalling}
negativeText={i18n('stickers--StickerManager--Uninstall')}
onNegative={handleConfirmUninstall}
actions={[
{
style: 'negative',
text: i18n('stickers--StickerManager--Uninstall'),
action: handleConfirmUninstall,
},
]}
>
{i18n('stickers--StickerManager--UninstallWarning')}
</ConfirmationModal>

View file

@ -174,8 +174,13 @@ export const StickerPreviewModal = React.memo(
<ConfirmationDialog
i18n={i18n}
onClose={onClose}
negativeText={i18n('stickers--StickerManager--Uninstall')}
onNegative={handleUninstall}
actions={[
{
style: 'negative',
text: i18n('stickers--StickerManager--Uninstall'),
action: handleUninstall,
},
]}
>
{i18n('stickers--StickerManager--UninstallWarning')}
</ConfirmationDialog>

View file

@ -852,8 +852,8 @@ async function searchMessagesInConversation(
// Message
async function getMessageCount() {
return channels.getMessageCount();
async function getMessageCount(conversationId?: string) {
return channels.getMessageCount(conversationId);
}
async function saveMessage(

View file

@ -85,7 +85,7 @@ export interface DataInterface {
options?: { limit?: number }
) => Promise<Array<SearchResultMessageType>>;
getMessageCount: () => Promise<number>;
getMessageCount: (conversationId?: string) => Promise<number>;
saveMessages: (
arrayOfMessages: Array<MessageType>,
options: { forceSave?: boolean }

View file

@ -1542,6 +1542,40 @@ async function updateToSchemaVersion20(
}
}
async function updateToSchemaVersion21(
currentVersion: number,
instance: PromisifiedSQLDatabase
) {
if (currentVersion >= 21) {
return;
}
try {
await instance.run('BEGIN TRANSACTION;');
await instance.run(`
UPDATE conversations
SET json = json_set(
json,
'$.messageCount',
(SELECT count(*) FROM messages WHERE messages.conversationId = conversations.id)
);
`);
await instance.run(`
UPDATE conversations
SET json = json_set(
json,
'$.sentMessageCount',
(SELECT count(*) FROM messages WHERE messages.conversationId = conversations.id AND messages.type = 'outgoing')
);
`);
await instance.run('PRAGMA user_version = 21;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion21: success!');
} catch (error) {
await instance.run('ROLLBACK');
throw error;
}
}
const SCHEMA_VERSIONS = [
updateToSchemaVersion1,
updateToSchemaVersion2,
@ -1563,6 +1597,7 @@ const SCHEMA_VERSIONS = [
updateToSchemaVersion18,
updateToSchemaVersion19,
updateToSchemaVersion20,
updateToSchemaVersion21,
];
async function updateSchema(instance: PromisifiedSQLDatabase) {
@ -2326,9 +2361,14 @@ async function searchMessagesInConversation(
}));
}
async function getMessageCount() {
async function getMessageCount(conversationId?: string) {
const db = getInstance();
const row = await db.get('SELECT count(*) from messages;');
const row = conversationId
? await db.get(
'SELECT count(*) from messages WHERE conversationId = $conversationId;',
{ $conversationId: conversationId }
)
: await db.get('SELECT count(*) from messages;');
if (!row) {
throw new Error('getMessageCount: Unable to get count of messages');

View file

@ -12,6 +12,7 @@ import {
import { trigger } from '../../shims/events';
import { NoopActionType } from './noop';
import { AttachmentType } from '../../types/Attachment';
import { ColorType } from '../../types/Util';
// State
@ -24,7 +25,11 @@ export type DBConversationType = {
export type ConversationType = {
id: string;
name?: string;
isArchived: boolean;
profileName?: string;
avatarPath?: string;
color?: ColorType;
isArchived?: boolean;
isBlocked?: boolean;
activeAt?: number;
timestamp: number;
inboxPosition: number;
@ -33,6 +38,7 @@ export type ConversationType = {
text: string;
};
phoneNumber: string;
membersCount?: number;
type: 'direct' | 'group';
isMe: boolean;
lastUpdated: number;
@ -49,6 +55,9 @@ export type ConversationType = {
shouldShowDraft?: boolean;
draftText?: string;
draftPreview?: string;
messageRequestsEnabled?: boolean;
acceptedMessageRequest?: boolean;
};
export type ConversationLookupType = {
[key: string]: ConversationType;

View file

@ -71,6 +71,14 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
recentStickers,
showIntroduction,
showPickerHint,
// Message Requests
messageRequestsEnabled: conversation.messageRequestsEnabled,
acceptedMessageRequest: conversation.acceptedMessageRequest,
isBlocked: conversation.isBlocked,
conversationType: conversation.type,
name: conversation.name,
profileName: conversation.profileName,
phoneNumber: conversation.phoneNumber,
};
};

View file

@ -0,0 +1,37 @@
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { ConversationHero } from '../../components/conversation/ConversationHero';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
type ExternalProps = {
id: string;
};
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id } = props;
const conversation = state.conversations.conversationLookup[id];
if (!conversation) {
throw new Error(`Did not find conversation ${id} in state!`);
}
return {
i18n: getIntl(state),
avatarPath: conversation.avatarPath,
color: conversation.color,
conversationType: conversation.type,
isMe: conversation.isMe,
membersCount: conversation.membersCount,
name: conversation.name,
phoneNumber: conversation.phoneNumber,
profileName: conversation.profileName,
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartHeroRow = smart(ConversationHero);

View file

@ -16,6 +16,7 @@ import {
import { SmartTimelineItem } from './TimelineItem';
import { SmartTypingBubble } from './TypingBubble';
import { SmartLastSeenIndicator } from './LastSeenIndicator';
import { SmartHeroRow } from './HeroRow';
import { SmartTimelineLoadingRow } from './TimelineLoadingRow';
import { SmartEmojiPicker } from './EmojiPicker';
@ -24,6 +25,7 @@ import { SmartEmojiPicker } from './EmojiPicker';
const FilteredSmartTimelineItem = SmartTimelineItem as any;
const FilteredSmartTypingBubble = SmartTypingBubble as any;
const FilteredSmartLastSeenIndicator = SmartLastSeenIndicator as any;
const FilteredSmartHeroRow = SmartHeroRow as any;
const FilteredSmartTimelineLoadingRow = SmartTimelineLoadingRow as any;
type ExternalProps = {
@ -66,6 +68,9 @@ function renderEmojiPicker({
function renderLastSeenIndicator(id: string): JSX.Element {
return <FilteredSmartLastSeenIndicator id={id} />;
}
function renderHeroRow(id: string, onHeightChange: () => unknown): JSX.Element {
return <FilteredSmartHeroRow id={id} onHeightChange={onHeightChange} />;
}
function renderLoadingRow(id: string): JSX.Element {
return <FilteredSmartTimelineLoadingRow id={id} />;
}
@ -88,6 +93,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
i18n: getIntl(state),
renderItem,
renderLastSeenIndicator,
renderHeroRow,
renderLoadingRow,
renderTypingBubble,
...actions,

View file

@ -31,6 +31,8 @@ describe('state/selectors/conversations', () => {
color: 'blue',
phoneNumber: '+18005551111',
},
acceptedMessageRequest: true,
},
id2: {
id: 'id2',
@ -51,6 +53,8 @@ describe('state/selectors/conversations', () => {
color: 'blue',
phoneNumber: '+18005551111',
},
acceptedMessageRequest: true,
},
id3: {
id: 'id3',
@ -71,6 +75,8 @@ describe('state/selectors/conversations', () => {
color: 'blue',
phoneNumber: '+18005551111',
},
acceptedMessageRequest: true,
},
id4: {
id: 'id4',
@ -91,6 +97,8 @@ describe('state/selectors/conversations', () => {
color: 'blue',
phoneNumber: '+18005551111',
},
acceptedMessageRequest: true,
},
id5: {
id: 'id5',
@ -111,6 +119,8 @@ describe('state/selectors/conversations', () => {
color: 'blue',
phoneNumber: '+18005551111',
},
acceptedMessageRequest: true,
},
};
const comparator = _getConversationComparator(i18n, regionCode);

18
ts/textsecure.d.ts vendored
View file

@ -531,6 +531,7 @@ export declare class SyncMessageClass {
padding?: ProtoBinaryType;
stickerPackOperation?: Array<SyncMessageClass.StickerPackOperation>;
viewOnceOpen?: SyncMessageClass.ViewOnceOpen;
messageRequestResponse?: SyncMessageClass.MessageRequestResponse;
}
// Note: we need to use namespaces to express nested classes in Typescript
@ -582,6 +583,13 @@ export declare namespace SyncMessageClass {
senderUuid?: string;
timestamp?: ProtoBinaryType;
}
class MessageRequestResponse {
threadE164?: string;
threadUuid?: string;
groupId?: ProtoBinaryType;
type?: number;
}
}
// Note: we need to use namespaces to express nested classes in Typescript
@ -612,6 +620,16 @@ export declare namespace SyncMessageClass.StickerPackOperation {
}
}
export declare namespace SyncMessageClass.MessageRequestResponse {
class Type {
static UNKNOWN: number;
static ACCEPT: number;
static DELETE: number;
static BLOCK: number;
static BLOCK_AND_DELETE: number;
}
}
export declare class TypingMessageClass {
static decode: (
data: ArrayBuffer | ByteBufferClass,

View file

@ -42,6 +42,8 @@ declare global {
deliveryReceipt?: any;
error?: any;
groupDetails?: any;
groupId?: string;
messageRequestResponseType?: number;
proto?: any;
read?: any;
reason?: any;
@ -51,6 +53,8 @@ declare global {
source?: any;
sourceUuid?: any;
stickerPacks?: any;
threadE164?: string;
threadUuid?: string;
timestamp?: any;
typing?: any;
verified?: any;
@ -1119,6 +1123,22 @@ class MessageReceiverInner extends EventTarget {
) {
p = this.handleEndSession(destination);
}
if (
msg.flags &&
msg.flags &
window.textsecure.protobuf.DataMessage.Flags.PROFILE_KEY_UPDATE
) {
const ev = new Event('profileKeyUpdate');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.data = {
source: envelope.source,
sourceUuid: envelope.sourceUuid,
profileKey: msg.profileKey.toString('base64'),
};
return this.dispatchAndWait(ev);
}
return p.then(async () =>
this.processDecrypted(envelope, msg).then(message => {
const groupId = message.group && message.group.id;
@ -1373,6 +1393,11 @@ class MessageReceiverInner extends EventTarget {
);
} else if (syncMessage.viewOnceOpen) {
return this.handleViewOnceOpen(envelope, syncMessage.viewOnceOpen);
} else if (syncMessage.messageRequestResponse) {
return this.handleMessageRequestResponse(
envelope,
syncMessage.messageRequestResponse
);
}
this.removeFromCache(envelope);
@ -1408,6 +1433,27 @@ class MessageReceiverInner extends EventTarget {
return this.dispatchAndWait(ev);
}
async handleMessageRequestResponse(
envelope: EnvelopeClass,
sync: SyncMessageClass.MessageRequestResponse
) {
window.log.info('got message request response sync message');
const ev = new Event('messageRequestResponse');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.threadE164 = sync.threadE164;
ev.threadUuid = sync.threadUuid;
ev.groupId = sync.groupId ? sync.groupId.toString('binary') : null;
ev.messageRequestResponseType = sync.type;
window.normalizeUuids(
ev,
['threadUuid'],
'MessageReceiver::handleMessageRequestResponse'
);
return this.dispatchAndWait(ev);
}
async handleStickerPackOperation(
envelope: EnvelopeClass,
operations: Array<SyncMessageClass.StickerPackOperation>

View file

@ -74,7 +74,7 @@ type MessageOptionsType = {
};
needsSync?: boolean;
preview?: Array<PreviewType> | null;
profileKey?: string;
profileKey?: ArrayBuffer;
quote?: any;
recipients: Array<string>;
sticker?: any;
@ -93,7 +93,7 @@ class Message {
};
needsSync?: boolean;
preview: any;
profileKey?: string;
profileKey?: ArrayBuffer;
quote?: any;
recipients: Array<string>;
sticker?: any;
@ -274,6 +274,8 @@ export type AttachmentType = {
caption: string;
attachmentPointer?: AttachmentPointerClass;
blurHash?: string;
};
export default class MessageSender {
@ -348,6 +350,9 @@ export default class MessageSender {
if (attachment.caption) {
proto.caption = attachment.caption;
}
if (attachment.blurHash) {
proto.blurHash = attachment.blurHash;
}
return proto;
}
@ -862,6 +867,31 @@ export default class MessageSender {
);
}
async sendProfileKeyUpdate(
profileKey: ArrayBuffer,
recipients: Array<string>,
sendOptions: SendOptionsType,
groupId?: string
) {
return this.sendMessage(
{
recipients,
timestamp: Date.now(),
profileKey,
flags: window.textsecure.protobuf.DataMessage.Flags.PROFILE_KEY_UPDATE,
...(groupId
? {
group: {
id: groupId,
type: window.textsecure.protobuf.GroupContext.Type.DELIVER,
},
}
: {}),
},
sendOptions
);
}
async sendDeliveryReceipt(
recipientE164: string,
recipientUuid: string,
@ -985,6 +1015,48 @@ export default class MessageSender {
);
}
async syncMessageRequestResponse(
responseArgs: {
threadE164?: string;
threadUuid?: string;
groupId?: string;
type: number;
},
sendOptions?: SendOptionsType
) {
const myNumber = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getUuid();
const myDevice = window.textsecure.storage.user.getDeviceId();
if (myDevice === 1 || myDevice === '1') {
return null;
}
const syncMessage = this.createSyncMessage();
const response = new window.textsecure.protobuf.SyncMessage.MessageRequestResponse();
response.threadE164 = responseArgs.threadE164;
response.threadUuid = responseArgs.threadUuid;
response.groupId = responseArgs.groupId
? window.Signal.Crypto.fromEncodedBinaryToArrayBuffer(
responseArgs.groupId
)
: null;
response.type = responseArgs.type;
syncMessage.messageRequestResponse = response;
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
const silent = true;
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
silent,
sendOptions
);
}
async sendStickerPackSync(
operations: Array<{
packId: string;
@ -1152,7 +1224,7 @@ export default class MessageSender {
reaction: any,
timestamp: number,
expireTimer: number | undefined,
profileKey?: string,
profileKey?: ArrayBuffer,
flags?: number
) {
const attributes = {
@ -1195,7 +1267,7 @@ export default class MessageSender {
reaction: any,
timestamp: number,
expireTimer: number | undefined,
profileKey?: string,
profileKey?: ArrayBuffer,
options?: SendOptionsType
) {
return this.sendMessage(
@ -1308,7 +1380,7 @@ export default class MessageSender {
reaction: any,
timestamp: number,
expireTimer: number | undefined,
profileKey?: string,
profileKey?: ArrayBuffer,
options?: SendOptionsType
) {
const myE164 = window.textsecure.storage.user.getNumber();
@ -1480,7 +1552,7 @@ export default class MessageSender {
groupIdentifiers: Array<string>,
expireTimer: number | undefined,
timestamp: number,
profileKey?: string,
profileKey?: ArrayBuffer,
options?: SendOptionsType
) {
const myNumber = window.textsecure.storage.user.getNumber();
@ -1517,7 +1589,7 @@ export default class MessageSender {
identifier: string,
expireTimer: number | undefined,
timestamp: number,
profileKey?: string,
profileKey?: ArrayBuffer,
options?: SendOptionsType
) {
return this.sendMessage(

View file

@ -489,6 +489,7 @@ const URL_CALLS = {
signed: 'v2/keys/signed',
getStickerPackUpload: 'v1/sticker/pack/form',
whoami: 'v1/accounts/whoami',
config: 'v1/config',
};
type InitializeOptionsType = {
@ -604,6 +605,7 @@ export type WebAPIType = {
setSignedPreKey: (signedPreKey: SignedPreKeyType) => Promise<void>;
updateDeviceName: (deviceName: string) => Promise<void>;
whoami: () => Promise<any>;
getConfig: () => Promise<Array<{ name: string; enabled: boolean }>>;
};
export type SignedPreKeyType = {
@ -724,9 +726,10 @@ export function initialize({
setSignedPreKey,
updateDeviceName,
whoami,
getConfig,
};
async function _ajax(param: AjaxOptionsType) {
async function _ajax(param: AjaxOptionsType): Promise<any> {
if (!param.urlParameters) {
param.urlParameters = '';
}
@ -792,6 +795,21 @@ export function initialize({
});
}
async function getConfig() {
type ResType = {
config: Array<{ name: string; enabled: boolean }>;
};
const res: ResType = await _ajax({
call: 'config',
httpType: 'GET',
responseType: 'json',
});
return res.config.filter(({ name }: { name: string }) =>
name.startsWith('desktop.')
);
}
async function getSenderCertificate() {
return _ajax({
call: 'deliveryCert',

View file

@ -18,6 +18,7 @@ const MIN_HEIGHT = 50;
// Used for display
export interface AttachmentType {
blurHash?: string;
caption?: string;
contentType: MIME.MIMEType;
fileName: string;
@ -133,7 +134,7 @@ export function hasImage(attachments?: Array<AttachmentType>) {
return (
attachments &&
attachments[0] &&
(attachments[0].url || attachments[0].pending)
(attachments[0].url || attachments[0].pending || attachments[0].blurHash)
);
}

View file

@ -0,0 +1,50 @@
import loadImage from 'blueimp-load-image';
import { encode } from 'blurhash';
type Input = Parameters<typeof loadImage>[0];
const loadImageData = async (input: Input): Promise<ImageData> => {
return new Promise((resolve, reject) => {
loadImage(
input,
canvasOrError => {
if (canvasOrError instanceof Event && canvasOrError.type === 'error') {
const processError = new Error(
'imageToBlurHash: Failed to process image'
);
processError.cause = canvasOrError;
reject(processError);
return;
}
if (canvasOrError instanceof HTMLCanvasElement) {
const context = canvasOrError.getContext('2d');
resolve(
context?.getImageData(
0,
0,
canvasOrError.width,
canvasOrError.height
)
);
}
const error = new Error(
'imageToBlurHash: Failed to place image on canvas'
);
reject(error);
return;
},
// Calculating the blurhash on large images is a long-running and
// synchronous operation, so here we ensure the images are a reasonable
// size before calculating the blurhash. iOS uses a max size of 200x200
// and Android uses a max size of 1/16 the original size. 200x200 is
// easier for us.
{ canvas: true, orientation: true, maxWidth: 200, maxHeight: 200 }
);
});
};
export const imageToBlurHash = async (input: Input) => {
const { data, width, height } = await loadImageData(input);
// 4 horizontal components and 3 vertical components
return encode(data, width, height, 4, 3);
};

View file

@ -219,6 +219,14 @@
"reasonCategory": "usageTrusted",
"updated": "2020-03-25T15:45:04.024Z"
},
{
"rule": "jQuery-wrap(",
"path": "js/models/conversations.js",
"line": " await wrap(",
"lineNumber": 641,
"reasonCategory": "falseMatch",
"updated": "2020-05-27T21:15:43.044Z"
},
{
"rule": "jQuery-append(",
"path": "js/modules/debuglogs.js",
@ -570,7 +578,7 @@
"line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {",
"lineNumber": 198,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Known DOM elements"
},
{
@ -579,7 +587,7 @@
"line": " this.$('.conversation:first .recorder').trigger('close');",
"lineNumber": 201,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Hardcoded selector"
},
{
@ -11426,18 +11434,18 @@
"rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.js",
"line": " el.innerHTML = '';",
"lineNumber": 22,
"lineNumber": 23,
"reasonCategory": "usageTrusted",
"updated": "2019-08-01T14:10:37.481Z",
"updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Our code, no user input, only clearing out the dom"
},
{
"rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.tsx",
"line": " el.innerHTML = '';",
"lineNumber": 73,
"lineNumber": 80,
"reasonCategory": "usageTrusted",
"updated": "2019-12-16T14:36:25.614ZZ",
"updated": "2020-06-03T19:23:21.195Z",
"reasonDetail": "Our code, no user input, only clearing out the dom"
},
{
@ -11507,9 +11515,9 @@
"rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.tsx",
"line": " this.menuTriggerRef = React.createRef();",
"lineNumber": 67,
"lineNumber": 68,
"reasonCategory": "usageTrusted",
"updated": "2019-07-31T00:19:18.696Z",
"updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Used to reference popup menu"
},
{
@ -11553,7 +11561,7 @@
"line": " public audioRef: React.RefObject<HTMLAudioElement> = React.createRef();",
"lineNumber": 184,
"reasonCategory": "usageTrusted",
"updated": "2020-04-30T15:59:13.160Z"
"updated": "2020-05-21T16:56:07.875Z"
},
{
"rule": "React-createRef",
@ -11561,7 +11569,7 @@
"line": " > = React.createRef();",
"lineNumber": 188,
"reasonCategory": "usageTrusted",
"updated": "2020-04-30T15:59:13.160Z"
"updated": "2020-05-21T16:56:07.875Z"
},
{
"rule": "React-createRef",
@ -11784,4 +11792,4 @@
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
}
]
]

6
ts/window.d.ts vendored
View file

@ -31,7 +31,7 @@ declare global {
storage: {
put: (key: string, value: any) => void;
remove: (key: string) => void;
get: (key: string) => any;
get: <T = any>(key: string) => T | undefined;
};
textsecure: TextSecureType;
@ -48,6 +48,10 @@ declare global {
WebAPI: WebAPIConnectType;
Whisper: WhisperType;
}
interface Error {
cause?: Event;
}
}
export type ConversationType = {

View file

@ -2052,6 +2052,13 @@
"@types/jquery" "*"
"@types/underscore" "*"
"@types/blueimp-load-image@2.23.6":
version "2.23.6"
resolved "https://registry.yarnpkg.com/@types/blueimp-load-image/-/blueimp-load-image-2.23.6.tgz#2ccf3c69bd17c5bd1f4471470505a7f065a84a9f"
integrity sha512-RF5EQ2miGm/o5XmZk0PhpLnikXOwe1BxGJVikYcNhwr4s7i7LqVc4U51k/zFTD11GeqyK+yz6vWwvvtoKsPYLw==
dependencies:
"@types/node" "*"
"@types/body-parser@*":
version "1.17.1"
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.17.1.tgz#18fcf61768fb5c30ccc508c21d6fd2e8b3bf7897"
@ -3957,6 +3964,11 @@ blueimp-load-image@2.18.0:
resolved "https://registry.yarnpkg.com/blueimp-load-image/-/blueimp-load-image-2.18.0.tgz#03b93687eb382a7136cfbcbd4f0e936b6763fc0e"
integrity sha512-GUrxVE/7FpzAw/WU6GMiI3v+LpFmlAxp7sF36EQB8rGAg97ND8iTeYZ3FQbhsxS5s2dNarGKZEWhKPNKKSmMuA==
blurhash@1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.1.3.tgz#dc325af7da836d07a0861d830bdd63694382483e"
integrity sha512-yUhPJvXexbqbyijCIE/T2NCXcj9iNPhWmOKbPTuR/cm7Q5snXYIfnVnz6m7MWOXxODMz/Cr3UcVkRdHiuDVRDw==
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
version "4.11.8"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
@ -13579,6 +13591,11 @@ rc@^1.1.2:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
react-blurhash@0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/react-blurhash/-/react-blurhash-0.1.2.tgz#16bdce59be4f48dc7816a26e8f0435f73d3a2bb2"
integrity sha512-p7TAQ+Qw78rBO9LSxkURfNDJA8TGAuOW2GVJKzNHrOY61XtA02PYmpAY9lotbFpftczEXZ7h4Su2JRmEUI7/Hw==
react-clientside-effect@^1.2.0:
version "1.2.2"
resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.2.tgz#6212fb0e07b204e714581dd51992603d1accc837"