Message Requests
This commit is contained in:
parent
4d4b7a26a5
commit
83574eb067
60 changed files with 2566 additions and 216 deletions
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
BIN
fixtures/kitten-4-112-112.jpg
Normal file
BIN
fixtures/kitten-4-112-112.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
|
@ -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
91
js/message_requests.js
Normal 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
|
||||
);
|
||||
}
|
||||
},
|
||||
}))();
|
||||
})();
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
|
@ -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
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
45
patches/react-blurhash+0.1.2.patch
Normal file
45
patches/react-blurhash+0.1.2.patch
Normal 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
|
|
@ -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();
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
94
ts/RemoteConfig.ts
Normal 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);
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
```
|
40
ts/components/ConfirmationDialog.stories.tsx
Normal file
40
ts/components/ConfirmationDialog.stories.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -25,7 +25,7 @@ export interface PropsType {
|
|||
phoneNumber: string;
|
||||
isMe: boolean;
|
||||
name?: string;
|
||||
color: ColorType;
|
||||
color?: ColorType;
|
||||
verified: boolean;
|
||||
profileName?: string;
|
||||
avatarPath?: string;
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
|
||||
import { Emojify } from './Emojify';
|
||||
|
||||
interface Props {
|
||||
export interface Props {
|
||||
phoneNumber?: string;
|
||||
name?: string;
|
||||
profileName?: string;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
130
ts/components/conversation/ConversationHero.stories.tsx
Normal file
130
ts/components/conversation/ConversationHero.stories.tsx
Normal 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>
|
||||
);
|
||||
});
|
125
ts/components/conversation/ConversationHero.tsx
Normal file
125
ts/components/conversation/ConversationHero.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'] = ({
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
59
ts/components/conversation/MessageRequestActions.stories.tsx
Normal file
59
ts/components/conversation/MessageRequestActions.stories.tsx
Normal 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>
|
||||
);
|
||||
});
|
130
ts/components/conversation/MessageRequestActions.tsx
Normal file
130
ts/components/conversation/MessageRequestActions.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
156
ts/components/conversation/MessageRequestActionsConfirmation.tsx
Normal file
156
ts/components/conversation/MessageRequestActionsConfirmation.tsx
Normal 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;
|
||||
};
|
|
@ -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) {
|
||||
|
|
|
@ -30,6 +30,7 @@ const renderEmojiPicker: TimelineItemProps['renderEmojiPicker'] = ({
|
|||
|
||||
const getDefaultProps = () => ({
|
||||
conversationId: 'conversation-id',
|
||||
conversationAccepted: true,
|
||||
id: 'asdf',
|
||||
isSelected: false,
|
||||
selectMessage: action('selectMessage'),
|
||||
|
|
|
@ -77,6 +77,7 @@ export type TimelineItemType =
|
|||
|
||||
type PropsLocalType = {
|
||||
conversationId: string;
|
||||
conversationAccepted: boolean;
|
||||
item?: TimelineItemType;
|
||||
id: string;
|
||||
isSelected: boolean;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
37
ts/state/smart/HeroRow.tsx
Normal file
37
ts/state/smart/HeroRow.tsx
Normal 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);
|
|
@ -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,
|
||||
|
|
|
@ -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
18
ts/textsecure.d.ts
vendored
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
50
ts/util/imageToBlurHash.ts
Normal file
50
ts/util/imageToBlurHash.ts
Normal 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);
|
||||
};
|
|
@ -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
6
ts/window.d.ts
vendored
|
@ -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 = {
|
||||
|
|
17
yarn.lock
17
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue