Keyboard shortcuts and accessibility
This commit is contained in:
parent
8590a047c7
commit
20a892247f
87 changed files with 3652 additions and 711 deletions
|
@ -688,9 +688,15 @@
|
|||
"message": "Debug Log",
|
||||
"description": "View menu item to open the debug log (title case)"
|
||||
},
|
||||
"helpMenuShowKeyboardShortcuts": {
|
||||
"message": "Show Keyboard Shortcuts",
|
||||
"description":
|
||||
"Item under the help menu, pops up a screen showing the application's keyboard shortcuts"
|
||||
},
|
||||
"goToReleaseNotes": {
|
||||
"message": "Go to Release Notes",
|
||||
"description": ""
|
||||
"description":
|
||||
"Item under the help menu, takes you to GitHub page for release notes"
|
||||
},
|
||||
"goToForums": {
|
||||
"message": "Go to Forums",
|
||||
|
@ -1949,5 +1955,188 @@
|
|||
"message": "(draft)",
|
||||
"description":
|
||||
"Text shown in left pane as preview for conversation with saved a saved draft message"
|
||||
},
|
||||
"Keyboard--navigate-by-section": {
|
||||
"message": "Navigate by section",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--previous-conversation": {
|
||||
"message": "Previous conversation",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--next-conversation": {
|
||||
"message": "Next conversation",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--previous-unread-conversation": {
|
||||
"message": "Previous unread conversation",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--next-unread-conversation": {
|
||||
"message": "Next unread conversation",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--preferences": {
|
||||
"message": "Preferences",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--open-conversation-menu": {
|
||||
"message": "Open conversation menu",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--archive-conversation": {
|
||||
"message": "Archive conversation",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--unarchive-conversation": {
|
||||
"message": "Unarchive conversation",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--search": {
|
||||
"message": "Search",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--search-in-conversation": {
|
||||
"message": "Search in conversation",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--focus-composer": {
|
||||
"message": "Focus composer",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--open-all-media-view": {
|
||||
"message": "Open All Media view",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--open-emoji-chooser": {
|
||||
"message": "Open emoji chooser",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--open-sticker-chooser": {
|
||||
"message": "Open sticker chooser",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--begin-recording-voice-note": {
|
||||
"message": "Begin recording voice note",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--default-message-action": {
|
||||
"message": "Default action for selected message",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--view-details-for-selected-message": {
|
||||
"message": "View selected message details",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--toggle-reply": {
|
||||
"message": "Toggle reply to selected message",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--save-attachment": {
|
||||
"message": "Save attachment from selected message",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--delete-message": {
|
||||
"message": "Delete selected message",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--add-newline": {
|
||||
"message": "Add newline to message",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--expand-composer": {
|
||||
"message": "Expand composer",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--send-in-expanded-composer": {
|
||||
"message": "Send (in expanded composer)",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--attach-file": {
|
||||
"message": "Attach file",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--remove-draft-link-preview": {
|
||||
"message": "Remove draft link preview",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--remove-draft-attachments": {
|
||||
"message": "Remove all draft attachments",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--Key--ctrl": {
|
||||
"message": "Ctrl",
|
||||
"description": "Key shown in shortcut combination in shortcuts guide"
|
||||
},
|
||||
"Keyboard--Key--option": {
|
||||
"message": "Option",
|
||||
"description": "Key shown in shortcut combination in shortcuts guide"
|
||||
},
|
||||
"Keyboard--Key--alt": {
|
||||
"message": "Alt",
|
||||
"description": "Key shown in shortcut combination in shortcuts guide"
|
||||
},
|
||||
"Keyboard--Key--shift": {
|
||||
"message": "Shift",
|
||||
"description": "Key shown in shortcut combination in shortcuts guide"
|
||||
},
|
||||
"Keyboard--Key--enter": {
|
||||
"message": "Enter",
|
||||
"description": "Key shown in shortcut combination in shortcuts guide"
|
||||
},
|
||||
"Keyboard--header": {
|
||||
"message": "Keyboard Shortcuts",
|
||||
"description": "Title header of the keyboard shortcuts guide"
|
||||
},
|
||||
"Keyboard--navigation-header": {
|
||||
"message": "Navigation",
|
||||
"description": "Header of the keyboard shortcuts guide - navigation section"
|
||||
},
|
||||
"Keyboard--messages-header": {
|
||||
"message": "Messages",
|
||||
"description": "Header of the keyboard shortcuts guide - messages section"
|
||||
},
|
||||
"Keyboard--composer-header": {
|
||||
"message": "Composer",
|
||||
"description": "Header of the keyboard shortcuts guide - composer section"
|
||||
},
|
||||
"Keyboard--scroll-to-top": {
|
||||
"message": "Scroll to top of list",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--scroll-to-bottom": {
|
||||
"message": "Scroll to bottom of list",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"Keyboard--close-curent-conversation": {
|
||||
"message": "Close current conversation",
|
||||
"description": "Shown in the shortcuts guide"
|
||||
},
|
||||
"close-popup": {
|
||||
"message": "Close Popup",
|
||||
"description": "Used as alt text for any button closing a popup"
|
||||
},
|
||||
"add-image-attachment": {
|
||||
"message": "Add image attachment",
|
||||
"description":
|
||||
"Used in draft attachment list for the big 'add new attachment' button"
|
||||
},
|
||||
"remove-attachment": {
|
||||
"message": "Remove attachment",
|
||||
"description":
|
||||
"Used in draft attachment list to remove an individual attachment"
|
||||
},
|
||||
"backToInbox": {
|
||||
"message": "Back to inbox",
|
||||
"description": "Used as alt-text of button on archived conversations screen"
|
||||
},
|
||||
"conversationArchived": {
|
||||
"message": "Conversation archived",
|
||||
"description": "A toast that shows up when user archives a conversation"
|
||||
},
|
||||
"conversationReturnedToInbox": {
|
||||
"message": "Conversation returned to inbox",
|
||||
"description":
|
||||
"A toast that shows up when the user unarchives a conversation"
|
||||
}
|
||||
}
|
||||
|
|
10
app/menu.js
10
app/menu.js
|
@ -17,6 +17,7 @@ exports.createTemplate = (options, messages) => {
|
|||
setupWithImport,
|
||||
showAbout,
|
||||
showDebugLog,
|
||||
showKeyboardShortcuts,
|
||||
showSettings,
|
||||
} = options;
|
||||
|
||||
|
@ -131,12 +132,17 @@ exports.createTemplate = (options, messages) => {
|
|||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: messages.goToReleaseNotes.message,
|
||||
click: openReleaseNotes,
|
||||
label: messages.helpMenuShowKeyboardShortcuts.message,
|
||||
accelerator: 'CmdOrCtrl+/',
|
||||
click: showKeyboardShortcuts,
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: messages.goToReleaseNotes.message,
|
||||
click: openReleaseNotes,
|
||||
},
|
||||
{
|
||||
label: messages.goToForums.message,
|
||||
click: openForums,
|
||||
|
|
|
@ -111,19 +111,19 @@
|
|||
</script>
|
||||
|
||||
<script type='text/x-tmpl-mustache' id='recorder'>
|
||||
<button class='finish'><span class='icon'></span></button>
|
||||
<button class='close' tabIndex='2'><span class='icon'></span></button>
|
||||
<span class='time'>0:00</span>
|
||||
<button class='close'><span class='icon'></span></button>
|
||||
<button class='finish' tabIndex='1'><span class='icon'></span></button>
|
||||
</script>
|
||||
|
||||
<script type='text/x-tmpl-mustache' id='confirmation-dialog'>
|
||||
<div class="content">
|
||||
<div class='message'>{{ message }}</div>
|
||||
<div class='buttons'>
|
||||
<button class='ok' tabindex='2'>{{ ok }}</button>
|
||||
{{ #showCancel }}
|
||||
<button class='cancel' tabindex='2'>{{ cancel }}</button>
|
||||
<button class='cancel' tabindex='1'>{{ cancel }}</button>
|
||||
{{ /showCancel }}
|
||||
<button class='ok' tabindex='1'>{{ ok }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
|
@ -155,13 +155,13 @@
|
|||
</script>
|
||||
|
||||
<script type='text/x-tmpl-mustache' id='group-member-list'>
|
||||
<div class='container'>
|
||||
<div class='container' tabindex='0'>
|
||||
{{ #summary }} <div class='summary'>{{ summary }}</div>{{ /summary }}
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type='text/x-tmpl-mustache' id='key-verification'>
|
||||
<div class='container'>
|
||||
<div class='container' tabindex='0'>
|
||||
{{ ^hasTheirKey }}
|
||||
<div class='placeholder'>{{ theirKeyUnknown }}</div>
|
||||
{{ /hasTheirKey }}
|
||||
|
|
|
@ -16,7 +16,7 @@ if (window.getAppInstance()) {
|
|||
$('.environment').text(states.join(' - '));
|
||||
|
||||
// Install the 'dismiss with escape key' handler
|
||||
$(document).on('keyup', e => {
|
||||
$(document).on('keydown', e => {
|
||||
'use strict';
|
||||
|
||||
if (e.keyCode === 27) {
|
||||
|
|
511
js/background.js
511
js/background.js
|
@ -282,6 +282,8 @@
|
|||
$('.dark-overlay').on('click', () => $('.dark-overlay').remove());
|
||||
},
|
||||
removeDarkOverlay: () => $('.dark-overlay').remove(),
|
||||
showKeyboardShortcuts: () => window.showKeyboardShortcuts(),
|
||||
|
||||
deleteAllData: () => {
|
||||
const clearDataView = new window.Whisper.ClearDataView().render();
|
||||
$('body').append(clearDataView.el);
|
||||
|
@ -518,6 +520,7 @@
|
|||
tempPath: window.baseTempPath,
|
||||
regionCode: window.storage.get('regionCode'),
|
||||
ourNumber: textsecure.storage.user.getNumber(),
|
||||
platform: window.platform,
|
||||
i18n: window.i18n,
|
||||
},
|
||||
};
|
||||
|
@ -581,26 +584,498 @@
|
|||
Whisper.events.on('messageExpired', messageExpired);
|
||||
Whisper.events.on('userChanged', userChanged);
|
||||
|
||||
// In the future this listener will be added by the conversation view itself. But
|
||||
// because we currently have multiple converations open at once, we install just
|
||||
// one global handler.
|
||||
// $(document).on('keydown', event => {
|
||||
// const { ctrlKey, key } = event;
|
||||
let shortcutGuideView = null;
|
||||
|
||||
// We can add Command-E as the Mac shortcut when we add it to our Electron menus:
|
||||
// https://stackoverflow.com/questions/27380018/when-cmd-key-is-kept-pressed-keyup-is-not-triggered-for-any-other-key
|
||||
// For now, it will stay as CTRL-E only
|
||||
// if (key === 'e' && ctrlKey) {
|
||||
// const state = store.getState();
|
||||
// const selectedId = state.conversations.selectedConversation;
|
||||
// const conversation = ConversationController.get(selectedId);
|
||||
window.showKeyboardShortcuts = () => {
|
||||
if (!shortcutGuideView) {
|
||||
shortcutGuideView = new Whisper.ReactWrapperView({
|
||||
className: 'shortcut-guide-wrapper',
|
||||
JSX: Signal.State.Roots.createShortcutGuideModal(window.reduxStore, {
|
||||
close: () => {
|
||||
if (shortcutGuideView) {
|
||||
shortcutGuideView.remove();
|
||||
shortcutGuideView = null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
onClose: () => {
|
||||
shortcutGuideView = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// if (conversation && !conversation.get('isArchived')) {
|
||||
// conversation.setArchived(true);
|
||||
// conversation.trigger('unload');
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
function findConversation(conversationId, direction, unreadOnly) {
|
||||
const state = store.getState();
|
||||
const lists = Signal.State.Selectors.conversations.getLeftPaneLists(
|
||||
state
|
||||
);
|
||||
const toSearch = state.conversations.showArchived
|
||||
? lists.archivedConversations
|
||||
: lists.conversations;
|
||||
|
||||
const increment = direction === 'up' ? -1 : 1;
|
||||
let startIndex;
|
||||
|
||||
if (conversationId) {
|
||||
const index = toSearch.findIndex(item => item.id === conversationId);
|
||||
if (index >= 0) {
|
||||
startIndex = index + increment;
|
||||
}
|
||||
} else {
|
||||
startIndex = direction === 'up' ? toSearch.length - 1 : 0;
|
||||
}
|
||||
|
||||
for (
|
||||
let i = startIndex, max = toSearch.length;
|
||||
i >= 0 && i < max;
|
||||
i += increment
|
||||
) {
|
||||
const target = toSearch[i];
|
||||
if (!unreadOnly) {
|
||||
return target.id;
|
||||
} else if (target.unreadCount > 0) {
|
||||
return target.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
const { altKey, ctrlKey, key, metaKey, shiftKey } = event;
|
||||
|
||||
const optionOrAlt = altKey;
|
||||
const ctrlOrCommand = metaKey || ctrlKey;
|
||||
|
||||
const state = store.getState();
|
||||
const selectedId = state.conversations.selectedConversation;
|
||||
const conversation = ConversationController.get(selectedId);
|
||||
|
||||
// NAVIGATION
|
||||
|
||||
// Show keyboard shortcuts - handled by Electron-managed keyboard shortcuts
|
||||
|
||||
// Navigate by section
|
||||
if (ctrlOrCommand && !shiftKey && key === 't') {
|
||||
const focusedElement = document.activeElement;
|
||||
|
||||
const targets = [
|
||||
document.querySelector('.module-main-header .module-avatar-button'),
|
||||
document.querySelector('.module-left-pane__to-inbox-button'),
|
||||
document.querySelector('.module-main-header__search__input'),
|
||||
document.querySelector('.module-left-pane__list'),
|
||||
document.querySelector('.module-search-results'),
|
||||
document.querySelector(
|
||||
'.module-composition-area .public-DraftEditor-content'
|
||||
),
|
||||
];
|
||||
const focusedIndex = targets.findIndex(target => {
|
||||
if (!target || !focusedElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target === focusedElement) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (target.contains(focusedElement)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
const lastIndex = targets.length - 1;
|
||||
|
||||
let index;
|
||||
if (focusedIndex < 0 || focusedIndex >= lastIndex) {
|
||||
index = 0;
|
||||
} else {
|
||||
index = focusedIndex + 1;
|
||||
}
|
||||
|
||||
while (!targets[index]) {
|
||||
index += 1;
|
||||
if (index > lastIndex) {
|
||||
index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
targets[index].focus();
|
||||
}
|
||||
|
||||
// Cancel out of keyboard shortcut screen - has first precedence
|
||||
if (shortcutGuideView && key === 'Escape') {
|
||||
shortcutGuideView.remove();
|
||||
shortcutGuideView = null;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape is heavily overloaded - here we avoid clashes with other Escape handlers
|
||||
if (key === 'Escape') {
|
||||
// Check origin - if within a react component which handles escape, don't handle.
|
||||
// Why? Because React's synthetic events can cause events to be handled twice.
|
||||
const target = document.activeElement;
|
||||
|
||||
if (
|
||||
target &&
|
||||
target.attributes &&
|
||||
target.attributes.class &&
|
||||
target.attributes.class.value
|
||||
) {
|
||||
const className = target.attributes.class.value;
|
||||
|
||||
// These want to handle events internally
|
||||
|
||||
// CaptionEditor text box
|
||||
if (className.includes('module-caption-editor__caption-input')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// MainHeader search box
|
||||
if (className.includes('module-main-header__search__input')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// These add listeners to document, but we'll run first
|
||||
const confirmationModal = document.querySelector(
|
||||
'.module-confirmation-dialog__overlay'
|
||||
);
|
||||
if (confirmationModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojiPicker = document.querySelector('.module-emoji-picker');
|
||||
if (emojiPicker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lightBox = document.querySelector('.module-lightbox');
|
||||
if (lightBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stickerPicker = document.querySelector('.module-sticker-picker');
|
||||
if (stickerPicker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stickerPreview = document.querySelector(
|
||||
'.module-sticker-manager__preview-modal__overlay'
|
||||
);
|
||||
if (stickerPreview) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Close Backbone-based confirmation dialog
|
||||
if (Whisper.activeConfirmationView && key === 'Escape') {
|
||||
Whisper.activeConfirmationView.remove();
|
||||
Whisper.activeConfirmationView = null;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send Escape to active conversation so it can close panels
|
||||
if (conversation && key === 'Escape') {
|
||||
conversation.trigger('escape-pressed');
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Change currently selected conversation - up/down, to next/previous unread
|
||||
if (optionOrAlt && !shiftKey && key === 'ArrowUp') {
|
||||
const unreadOnly = false;
|
||||
const targetId = findConversation(
|
||||
conversation ? conversation.id : null,
|
||||
'up',
|
||||
unreadOnly
|
||||
);
|
||||
|
||||
if (targetId) {
|
||||
window.Whisper.events.trigger('showConversation', targetId);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (optionOrAlt && !shiftKey && key === 'ArrowDown') {
|
||||
const unreadOnly = false;
|
||||
const targetId = findConversation(
|
||||
conversation ? conversation.id : null,
|
||||
'down',
|
||||
unreadOnly
|
||||
);
|
||||
|
||||
if (targetId) {
|
||||
window.Whisper.events.trigger('showConversation', targetId);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (optionOrAlt && shiftKey && key === 'ArrowUp') {
|
||||
const unreadOnly = true;
|
||||
const targetId = findConversation(
|
||||
conversation ? conversation.id : null,
|
||||
'up',
|
||||
unreadOnly
|
||||
);
|
||||
|
||||
if (targetId) {
|
||||
window.Whisper.events.trigger('showConversation', targetId);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (optionOrAlt && shiftKey && key === 'ArrowDown') {
|
||||
const unreadOnly = true;
|
||||
const targetId = findConversation(
|
||||
conversation ? conversation.id : null,
|
||||
'down',
|
||||
unreadOnly
|
||||
);
|
||||
|
||||
if (targetId) {
|
||||
window.Whisper.events.trigger('showConversation', targetId);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Preferences - handled by Electron-managed keyboard shortcuts
|
||||
|
||||
// Open the top-right menu for current conversation
|
||||
if (conversation && ctrlOrCommand && shiftKey && key === 'i') {
|
||||
const button = document.querySelector(
|
||||
'.module-conversation-header__more-button'
|
||||
);
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Because the menu is shown at a location based on the initiating click, we need
|
||||
// to fake up a mouse event to get the menu to show somewhere other than (0,0).
|
||||
const { x, y, width, height } = button.getBoundingClientRect();
|
||||
const mouseEvent = document.createEvent('MouseEvents');
|
||||
mouseEvent.initMouseEvent(
|
||||
'click',
|
||||
true, // bubbles
|
||||
false, // cancelable
|
||||
null, // view
|
||||
null, // detail
|
||||
0, // screenX,
|
||||
0, // screenY,
|
||||
x + width / 2,
|
||||
y + height / 2,
|
||||
false, // ctrlKey,
|
||||
false, // altKey,
|
||||
false, // shiftKey,
|
||||
false, // metaKey,
|
||||
false, // button,
|
||||
document.body
|
||||
);
|
||||
|
||||
button.dispatchEvent(mouseEvent);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Search
|
||||
if (ctrlOrCommand && !shiftKey && key === 'f') {
|
||||
const { startSearch } = actions.search;
|
||||
startSearch();
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Search in conversation
|
||||
if (conversation && ctrlOrCommand && shiftKey && key === 'f') {
|
||||
const { searchInConversation } = actions.search;
|
||||
const name = conversation.isMe()
|
||||
? window.i18n('noteToSelf')
|
||||
: conversation.getTitle();
|
||||
searchInConversation(conversation.id, name);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus composer field
|
||||
if (conversation && ctrlOrCommand && shiftKey && key === 't') {
|
||||
conversation.trigger('focus-composer');
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Open all media
|
||||
if (conversation && ctrlOrCommand && shiftKey && key === 'm') {
|
||||
conversation.trigger('open-all-media');
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Open emoji picker - handled by component
|
||||
|
||||
// Open sticker picker - handled by component
|
||||
|
||||
// Begin recording voice note
|
||||
if (conversation && ctrlOrCommand && shiftKey && key === 'v') {
|
||||
conversation.trigger('begin-recording');
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Archive or unarchive conversation
|
||||
if (
|
||||
conversation &&
|
||||
!conversation.get('isArchived') &&
|
||||
ctrlOrCommand &&
|
||||
shiftKey &&
|
||||
key === 'a'
|
||||
) {
|
||||
conversation.setArchived(true);
|
||||
conversation.trigger('unload', 'keyboard shortcut archive');
|
||||
Whisper.ToastView.show(
|
||||
Whisper.ConversationArchivedToast,
|
||||
document.body
|
||||
);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
conversation &&
|
||||
conversation.get('isArchived') &&
|
||||
ctrlOrCommand &&
|
||||
shiftKey &&
|
||||
key === 'u'
|
||||
) {
|
||||
conversation.setArchived(false);
|
||||
Whisper.ToastView.show(
|
||||
Whisper.ConversationUnarchivedToast,
|
||||
document.body
|
||||
);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll to bottom of list - handled by component
|
||||
|
||||
// Scroll to top of list - handled by component
|
||||
|
||||
// Close conversation
|
||||
if (conversation && ctrlOrCommand && shiftKey && key === 'c') {
|
||||
conversation.trigger('unload', 'keyboard shortcut close');
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// MESSAGES
|
||||
|
||||
// Show message details
|
||||
if (conversation && ctrlOrCommand && !shiftKey && key === 'd') {
|
||||
const { selectedMessage } = state.conversations;
|
||||
if (!selectedMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
conversation.trigger('show-message-details', selectedMessage);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle reply to message
|
||||
if (conversation && ctrlOrCommand && shiftKey && key === 'r') {
|
||||
const { selectedMessage } = state.conversations;
|
||||
|
||||
conversation.trigger('toggle-reply', selectedMessage);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Save attachment
|
||||
if (conversation && ctrlOrCommand && !shiftKey && key === 's') {
|
||||
const { selectedMessage } = state.conversations;
|
||||
|
||||
if (selectedMessage) {
|
||||
conversation.trigger('save-attachment', selectedMessage);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (conversation && ctrlOrCommand && shiftKey && key === 'd') {
|
||||
const { selectedMessage } = state.conversations;
|
||||
|
||||
if (selectedMessage) {
|
||||
conversation.trigger('delete-message', selectedMessage);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// COMPOSER
|
||||
|
||||
// Create a newline in your message - handled by component
|
||||
|
||||
// Expand composer - handled by component
|
||||
|
||||
// Send in expanded composer - handled by component
|
||||
|
||||
// Attach file
|
||||
if (conversation && ctrlOrCommand && !shiftKey && key === 'u') {
|
||||
conversation.trigger('attach-file');
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove draft link preview
|
||||
if (conversation && ctrlOrCommand && !shiftKey && key === 'p') {
|
||||
conversation.trigger('remove-link-review');
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach file
|
||||
if (conversation && ctrlOrCommand && shiftKey && key === 'p') {
|
||||
conversation.trigger('remove-all-draft-attachments');
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// Commented out because this is the last item
|
||||
// return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Whisper.events.on('setupWithImport', () => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/* global $: false */
|
||||
/* global Whisper: false */
|
||||
|
||||
$(document).on('keyup', e => {
|
||||
$(document).on('keydown', e => {
|
||||
'use strict';
|
||||
|
||||
if (e.keyCode === 27) {
|
||||
|
|
|
@ -125,6 +125,17 @@
|
|||
};
|
||||
},
|
||||
|
||||
isNormalBubble() {
|
||||
return (
|
||||
!this.isUnsupportedMessage() &&
|
||||
!this.isExpirationTimerUpdate() &&
|
||||
!this.isKeyChange() &&
|
||||
!this.isVerifiedChange() &&
|
||||
!this.isGroupUpdate() &&
|
||||
!this.isEndSession()
|
||||
);
|
||||
},
|
||||
|
||||
// Top-level prop generation for the message bubble
|
||||
getPropsForBubble() {
|
||||
if (this.isUnsupportedMessage()) {
|
||||
|
@ -489,6 +500,7 @@
|
|||
text: this.createNonBreakingLastSeparator(this.get('body')),
|
||||
textPending: this.get('bodyPending'),
|
||||
id: this.id,
|
||||
conversationId: this.get('conversationId'),
|
||||
isSticker: Boolean(sticker),
|
||||
direction: this.isIncoming() ? 'incoming' : 'outgoing',
|
||||
timestamp: this.get('sent_at'),
|
||||
|
|
|
@ -57,6 +57,9 @@ const {
|
|||
const {
|
||||
createStickerPreviewModal,
|
||||
} = require('../../ts/state/roots/createStickerPreviewModal');
|
||||
const {
|
||||
createShortcutGuideModal,
|
||||
} = require('../../ts/state/roots/createShortcutGuideModal');
|
||||
|
||||
const { createStore } = require('../../ts/state/createStore');
|
||||
const conversationsDuck = require('../../ts/state/ducks/conversations');
|
||||
|
@ -66,6 +69,8 @@ const searchDuck = require('../../ts/state/ducks/search');
|
|||
const stickersDuck = require('../../ts/state/ducks/stickers');
|
||||
const userDuck = require('../../ts/state/ducks/user');
|
||||
|
||||
const conversationsSelectors = require('../../ts/state/selectors/conversations');
|
||||
|
||||
// Migrations
|
||||
const {
|
||||
getPlaceholderMigrations,
|
||||
|
@ -266,9 +271,10 @@ exports.setup = (options = {}) => {
|
|||
const Roots = {
|
||||
createCompositionArea,
|
||||
createLeftPane,
|
||||
createTimeline,
|
||||
createShortcutGuideModal,
|
||||
createStickerManager,
|
||||
createStickerPreviewModal,
|
||||
createTimeline,
|
||||
};
|
||||
const Ducks = {
|
||||
conversations: conversationsDuck,
|
||||
|
@ -278,11 +284,16 @@ exports.setup = (options = {}) => {
|
|||
search: searchDuck,
|
||||
stickers: stickersDuck,
|
||||
};
|
||||
const Selectors = {
|
||||
conversations: conversationsSelectors,
|
||||
};
|
||||
|
||||
const State = {
|
||||
bindActionCreators,
|
||||
createStore,
|
||||
Roots,
|
||||
Ducks,
|
||||
Selectors,
|
||||
};
|
||||
|
||||
const Types = {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* global $, Whisper, i18n */
|
||||
|
||||
$(document).on('keyup', e => {
|
||||
$(document).on('keydown', e => {
|
||||
'use strict';
|
||||
|
||||
if (e.keyCode === 27) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* global $, Whisper */
|
||||
|
||||
$(document).on('keyup', e => {
|
||||
$(document).on('keydown', e => {
|
||||
'use strict';
|
||||
|
||||
if (e.keyCode === 27) {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/* global Whisper, i18n */
|
||||
/* global Backbone, Whisper, i18n */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function() {
|
||||
|
@ -10,6 +10,8 @@
|
|||
className: 'confirmation-dialog modal',
|
||||
templateName: 'confirmation-dialog',
|
||||
initialize(options) {
|
||||
this.previousFocus = document.activeElement;
|
||||
|
||||
this.message = options.message;
|
||||
this.hideCancel = options.hideCancel;
|
||||
|
||||
|
@ -19,13 +21,26 @@
|
|||
this.reject = options.reject;
|
||||
this.cancelText = options.cancelText || i18n('cancel');
|
||||
|
||||
if (Whisper.activeConfirmationView) {
|
||||
Whisper.activeConfirmationView.remove();
|
||||
Whisper.activeConfirmationView = null;
|
||||
}
|
||||
|
||||
Whisper.activeConfirmationView = this;
|
||||
|
||||
this.render();
|
||||
},
|
||||
events: {
|
||||
keyup: 'onKeyup',
|
||||
keydown: 'onKeydown',
|
||||
'click .ok': 'ok',
|
||||
'click .cancel': 'cancel',
|
||||
},
|
||||
remove() {
|
||||
if (this.previousFocus && this.previousFocus.focus) {
|
||||
this.previousFocus.focus();
|
||||
}
|
||||
Backbone.View.prototype.remove.call(this);
|
||||
},
|
||||
render_attributes() {
|
||||
return {
|
||||
message: this.message,
|
||||
|
@ -46,9 +61,12 @@
|
|||
this.reject();
|
||||
}
|
||||
},
|
||||
onKeyup(event) {
|
||||
onKeydown(event) {
|
||||
if (event.key === 'Escape' || event.key === 'Esc') {
|
||||
this.cancel();
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
focusCancel() {
|
||||
|
|
|
@ -77,6 +77,16 @@
|
|||
return { toastMessage: i18n('voiceNoteMustBeOnlyAttachment') };
|
||||
},
|
||||
});
|
||||
Whisper.ConversationArchivedToast = Whisper.ToastView.extend({
|
||||
render_attributes() {
|
||||
return { toastMessage: i18n('conversationArchived') };
|
||||
},
|
||||
});
|
||||
Whisper.ConversationUnarchivedToast = Whisper.ToastView.extend({
|
||||
render_attributes() {
|
||||
return { toastMessage: i18n('conversationReturnedToInbox') };
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
|
||||
Whisper.MessageBodyTooLongToast = Whisper.ToastView.extend({
|
||||
|
@ -143,6 +153,28 @@
|
|||
this.listenTo(this.model, 'unload', reason =>
|
||||
this.unload(`model trigger - ${reason}`)
|
||||
);
|
||||
this.listenTo(this.model, 'focus-composer', this.focusMessageField);
|
||||
this.listenTo(this.model, 'open-all-media', this.showAllMedia);
|
||||
this.listenTo(this.model, 'begin-recording', this.captureAudio);
|
||||
this.listenTo(this.model, 'attach-file', this.onChooseAttachment);
|
||||
this.listenTo(this.model, 'escape-pressed', this.resetPanel);
|
||||
this.listenTo(this.model, 'show-message-details', this.showMessageDetail);
|
||||
this.listenTo(this.model, 'toggle-reply', messageId => {
|
||||
const target = this.quote || !messageId ? null : messageId;
|
||||
this.setQuoteMessage(target);
|
||||
});
|
||||
this.listenTo(
|
||||
this.model,
|
||||
'save-attachment',
|
||||
this.downloadAttachmentWrapper
|
||||
);
|
||||
this.listenTo(this.model, 'delete-message', this.deleteMessage);
|
||||
this.listenTo(this.model, 'remove-link-review', this.removeLinkPreview);
|
||||
this.listenTo(
|
||||
this.model,
|
||||
'remove-all-draft-attachments',
|
||||
this.clearAttachments
|
||||
);
|
||||
|
||||
// Events on Message models - we still listen to these here because they
|
||||
// can be emitted by the non-reduxified MessageDetail pane
|
||||
|
@ -272,9 +304,8 @@
|
|||
onShowSafetyNumber: () => {
|
||||
this.showSafetyNumber();
|
||||
},
|
||||
onShowAllMedia: async () => {
|
||||
await this.showAllMedia();
|
||||
this.updateHeader();
|
||||
onShowAllMedia: () => {
|
||||
this.showAllMedia();
|
||||
},
|
||||
onShowGroupMembers: async () => {
|
||||
await this.showMembers();
|
||||
|
@ -282,15 +313,24 @@
|
|||
},
|
||||
onGoBack: () => {
|
||||
this.resetPanel();
|
||||
this.updateHeader();
|
||||
},
|
||||
|
||||
onArchive: () => {
|
||||
this.model.trigger('unload', 'archive');
|
||||
this.model.setArchived(true);
|
||||
this.model.trigger('unload', 'archive');
|
||||
|
||||
Whisper.ToastView.show(
|
||||
Whisper.ConversationArchivedToast,
|
||||
document.body
|
||||
);
|
||||
},
|
||||
onMoveToInbox: () => {
|
||||
this.model.setArchived(false);
|
||||
|
||||
Whisper.ToastView.show(
|
||||
Whisper.ConversationUnarchivedToast,
|
||||
document.body
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -655,12 +695,13 @@
|
|||
|
||||
const cleaned = await this.cleanModels(all);
|
||||
this.model.messageCollection.reset(cleaned);
|
||||
const scrollToMessageId = disableScroll ? undefined : messageId;
|
||||
|
||||
messagesReset(
|
||||
conversationId,
|
||||
cleaned.map(model => model.getReduxData()),
|
||||
metrics,
|
||||
disableScroll ? undefined : messageId
|
||||
scrollToMessageId
|
||||
);
|
||||
} catch (error) {
|
||||
setMessagesLoading(conversationId, false);
|
||||
|
@ -670,7 +711,7 @@
|
|||
}
|
||||
},
|
||||
|
||||
async loadNewestMessages(newestMessageId) {
|
||||
async loadNewestMessages(newestMessageId, setFocus) {
|
||||
const {
|
||||
messagesReset,
|
||||
setMessagesLoading,
|
||||
|
@ -701,7 +742,9 @@
|
|||
const metrics = await getMessageMetricsForConversation(conversationId);
|
||||
|
||||
if (scrollToLatestUnread && metrics.oldestUnread) {
|
||||
this.loadAndScroll(metrics.oldestUnread.id, { disableScroll: true });
|
||||
this.loadAndScroll(metrics.oldestUnread.id, {
|
||||
disableScroll: !setFocus,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -712,11 +755,14 @@
|
|||
|
||||
const cleaned = await this.cleanModels(messages);
|
||||
this.model.messageCollection.reset(cleaned);
|
||||
const scrollToMessageId =
|
||||
setFocus && metrics.newest ? metrics.newest.id : undefined;
|
||||
|
||||
messagesReset(
|
||||
conversationId,
|
||||
cleaned.map(model => model.getReduxData()),
|
||||
metrics
|
||||
metrics,
|
||||
scrollToMessageId
|
||||
);
|
||||
} catch (error) {
|
||||
setMessagesLoading(conversationId, false);
|
||||
|
@ -1485,7 +1531,13 @@
|
|||
},
|
||||
|
||||
captureAudio(e) {
|
||||
e.preventDefault();
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (this.compositionApi.current.isDirty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasFiles()) {
|
||||
this.showToast(Whisper.VoiceNoteMustBeOnlyAttachmentToast);
|
||||
|
@ -1507,6 +1559,7 @@
|
|||
view.on('send', this.handleAudioCapture.bind(this));
|
||||
view.on('closed', this.endCaptureAudio.bind(this));
|
||||
view.$el.appendTo(this.$('.capture-audio'));
|
||||
view.$('.finish').focus();
|
||||
this.compositionApi.current.setMicActive(true);
|
||||
|
||||
this.disableMessageField();
|
||||
|
@ -1527,12 +1580,18 @@
|
|||
flags: textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE,
|
||||
};
|
||||
|
||||
// Note: The RecorderView removes itself on send
|
||||
this.captureAudioView = null;
|
||||
|
||||
this.sendMessage();
|
||||
},
|
||||
endCaptureAudio() {
|
||||
this.enableMessageField();
|
||||
this.$('.microphone').show();
|
||||
|
||||
// Note: The RecorderView removes itself on close
|
||||
this.captureAudioView = null;
|
||||
|
||||
this.compositionApi.current.setMicActive(false);
|
||||
},
|
||||
|
||||
|
@ -1727,6 +1786,7 @@
|
|||
this.listenTo(this.model.messageCollection, 'remove', update);
|
||||
|
||||
this.listenBack(view);
|
||||
this.updateHeader();
|
||||
},
|
||||
|
||||
focusMessageField() {
|
||||
|
@ -1835,6 +1895,27 @@
|
|||
}
|
||||
},
|
||||
|
||||
downloadAttachmentWrapper(messageId) {
|
||||
const message = this.model.messageCollection.get(messageId);
|
||||
if (!message) {
|
||||
throw new Error(
|
||||
`downloadAttachmentWrapper: Did not find message for id ${messageId}`
|
||||
);
|
||||
}
|
||||
|
||||
const { attachments, sent_at: timestamp } = message.attributes;
|
||||
if (!attachments || attachments.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attachment = attachments[0];
|
||||
const { fileName } = attachment;
|
||||
|
||||
const isDangerous = window.Signal.Util.isFileDangerous(fileName || '');
|
||||
|
||||
this.downloadAttachment({ attachment, timestamp, isDangerous });
|
||||
},
|
||||
|
||||
downloadAttachment({ attachment, timestamp, isDangerous }) {
|
||||
if (isDangerous) {
|
||||
this.showToast(Whisper.DangerousFileTypeToast);
|
||||
|
@ -1944,7 +2025,6 @@
|
|||
message.trigger('unload');
|
||||
this.model.messageCollection.remove(message.id);
|
||||
this.resetPanel();
|
||||
this.updateHeader();
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -2081,10 +2161,13 @@
|
|||
);
|
||||
}
|
||||
|
||||
if (!message.isNormalBubble()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
this.stopListening(message, 'change', update);
|
||||
this.resetPanel();
|
||||
this.updateHeader();
|
||||
};
|
||||
|
||||
const props = message.getPropsForMessageDetail();
|
||||
|
@ -2111,7 +2194,6 @@
|
|||
JSX: Signal.State.Roots.createStickerManager(window.reduxStore),
|
||||
onClose: () => {
|
||||
this.resetPanel();
|
||||
this.updateHeader();
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -2135,7 +2217,6 @@
|
|||
},
|
||||
onClose: () => {
|
||||
this.resetPanel();
|
||||
this.updateHeader();
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -2149,6 +2230,11 @@
|
|||
|
||||
listenBack(view) {
|
||||
this.panels = this.panels || [];
|
||||
|
||||
if (this.panels.length === 0) {
|
||||
this.previousFocus = document.activeElement;
|
||||
}
|
||||
|
||||
this.panels.unshift(view);
|
||||
view.$el.insertAfter(this.$('.panel').last());
|
||||
view.$el.one('animationend', () => {
|
||||
|
@ -2162,11 +2248,23 @@
|
|||
|
||||
const view = this.panels.shift();
|
||||
|
||||
if (
|
||||
this.panels.length === 0 &&
|
||||
this.previousFocus &&
|
||||
this.previousFocus.focus
|
||||
) {
|
||||
this.previousFocus.focus();
|
||||
this.previousFocus = null;
|
||||
}
|
||||
|
||||
if (this.panels.length > 0) {
|
||||
this.panels[0].$el.fadeIn(250);
|
||||
}
|
||||
this.updateHeader();
|
||||
|
||||
view.$el.addClass('panel--remove').one('transitionend', () => {
|
||||
view.remove();
|
||||
|
||||
if (this.panels.length === 0) {
|
||||
// Make sure poppers are positioned properly
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
|
@ -2290,8 +2388,19 @@
|
|||
},
|
||||
|
||||
async setQuoteMessage(messageId) {
|
||||
const model = messageId
|
||||
? await getMessageById(messageId, {
|
||||
Message: Whisper.Message,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (model && !model.isNormalBubble()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.quote = null;
|
||||
this.quotedMessage = null;
|
||||
this.quoteHolder = null;
|
||||
|
||||
const existing = this.model.get('quotedMessageId');
|
||||
if (existing !== messageId) {
|
||||
|
@ -2303,25 +2412,20 @@
|
|||
await this.saveModel();
|
||||
}
|
||||
|
||||
if (this.quoteHolder) {
|
||||
this.quoteHolder.unload();
|
||||
this.quoteHolder = null;
|
||||
if (this.quoteView) {
|
||||
this.quoteView.remove();
|
||||
this.quoteView = null;
|
||||
}
|
||||
|
||||
if (messageId) {
|
||||
const model = await getMessageById(messageId, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
if (model) {
|
||||
const message = MessageController.register(model.id, model);
|
||||
this.quotedMessage = message;
|
||||
if (model) {
|
||||
const message = MessageController.register(model.id, model);
|
||||
this.quotedMessage = message;
|
||||
|
||||
if (message) {
|
||||
const quote = await this.model.makeQuote(this.quotedMessage);
|
||||
this.quote = quote;
|
||||
if (message) {
|
||||
const quote = await this.model.makeQuote(this.quotedMessage);
|
||||
this.quote = quote;
|
||||
|
||||
this.focusMessageFieldAndClearDisabled();
|
||||
}
|
||||
this.focusMessageFieldAndClearDisabled();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2363,6 +2467,8 @@
|
|||
props: Object.assign({}, props, {
|
||||
withContentAbove: true,
|
||||
onClose: () => {
|
||||
// This can't be the normal 'onClose' because that is always run when this
|
||||
// view is removed from the DOM, and would clear the draft quote.
|
||||
this.setQuoteMessage(null);
|
||||
},
|
||||
}),
|
||||
|
|
|
@ -34,7 +34,7 @@
|
|||
);
|
||||
view.$el.appendTo(this.el);
|
||||
|
||||
if (this.lastConversation) {
|
||||
if (this.lastConversation && this.lastConversation !== conversation) {
|
||||
this.lastConversation.trigger(
|
||||
'unload',
|
||||
'opened another conversation'
|
||||
|
@ -171,6 +171,13 @@
|
|||
if (view) {
|
||||
this.appLoadingScreen = null;
|
||||
view.remove();
|
||||
|
||||
const searchInput = document.querySelector(
|
||||
'.module-main-header__search__input'
|
||||
);
|
||||
if (searchInput && searchInput.focus) {
|
||||
searchInput.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
onProgress(count) {
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
},
|
||||
events: {
|
||||
change: 'validateNumber',
|
||||
keyup: 'validateNumber',
|
||||
keydown: 'validateNumber',
|
||||
},
|
||||
validateNumber() {
|
||||
const input = this.$('input.number');
|
||||
|
|
|
@ -18,6 +18,9 @@
|
|||
this.onSwitchAwayBound = this.onSwitchAway.bind(this);
|
||||
$(window).on('blur', this.onSwitchAwayBound);
|
||||
|
||||
this.handleKeyDownBound = this.handleKeyDown.bind(this);
|
||||
this.$el.on('keydown', this.handleKeyDownBound);
|
||||
|
||||
this.start();
|
||||
},
|
||||
events: {
|
||||
|
@ -28,6 +31,14 @@
|
|||
onSwitchAway() {
|
||||
this.close();
|
||||
},
|
||||
handleKeyDown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
this.close();
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
updateTime() {
|
||||
const duration = moment.duration(Date.now() - this.startTime, 'ms');
|
||||
const minutes = `${Math.trunc(duration.asMinutes())}`;
|
||||
|
@ -67,6 +78,8 @@
|
|||
this.trigger('closed');
|
||||
|
||||
$(window).off('blur', this.onSwitchAwayBound);
|
||||
|
||||
this.$el.off('keydown', this.handleKeyDownBound);
|
||||
},
|
||||
finish() {
|
||||
this.clickedFinish = true;
|
||||
|
|
|
@ -28,4 +28,10 @@
|
|||
setTimeout(this.close.bind(this), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
Whisper.ToastView.show = (View, el) => {
|
||||
const toast = new View();
|
||||
toast.$el.appendTo(el);
|
||||
toast.render();
|
||||
};
|
||||
})();
|
||||
|
|
7
main.js
7
main.js
|
@ -455,6 +455,12 @@ function openForums() {
|
|||
shell.openExternal('https://community.signalusers.org/');
|
||||
}
|
||||
|
||||
function showKeyboardShortcuts() {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('show-keyboard-shortcuts');
|
||||
}
|
||||
}
|
||||
|
||||
function setupWithImport() {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('set-up-with-import');
|
||||
|
@ -787,6 +793,7 @@ function setupMenu(options) {
|
|||
const menuOptions = Object.assign({}, options, {
|
||||
development,
|
||||
showDebugLog: showDebugLogWindow,
|
||||
showKeyboardShortcuts,
|
||||
showWindow,
|
||||
showAbout,
|
||||
showSettings: showSettingsWindow,
|
||||
|
|
34
patches/react-contextmenu+2.11.0.patch
Normal file
34
patches/react-contextmenu+2.11.0.patch
Normal file
|
@ -0,0 +1,34 @@
|
|||
diff --git a/node_modules/react-contextmenu/modules/ContextMenu.js b/node_modules/react-contextmenu/modules/ContextMenu.js
|
||||
index 622a1f9..61c8e70 100644
|
||||
--- a/node_modules/react-contextmenu/modules/ContextMenu.js
|
||||
+++ b/node_modules/react-contextmenu/modules/ContextMenu.js
|
||||
@@ -226,6 +226,9 @@ var ContextMenu = function (_AbstractMenu) {
|
||||
|
||||
if (this.state.isVisible) {
|
||||
var wrapper = window.requestAnimationFrame || setTimeout;
|
||||
+ if (!this.previousFocus) {
|
||||
+ this.previousFocus = document.activeElement;
|
||||
+ }
|
||||
|
||||
wrapper(function () {
|
||||
var _state = _this2.state,
|
||||
@@ -242,12 +245,19 @@ var ContextMenu = function (_AbstractMenu) {
|
||||
_this2.menu.style.left = left + 'px';
|
||||
_this2.menu.style.opacity = 1;
|
||||
_this2.menu.style.pointerEvents = 'auto';
|
||||
+
|
||||
+ _this2.menu.focus();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
if (!this.menu) return;
|
||||
this.menu.style.opacity = 0;
|
||||
this.menu.style.pointerEvents = 'none';
|
||||
+
|
||||
+ if (this.previousFocus && this.previousFocus.focus) {
|
||||
+ this.previousFocus.focus();
|
||||
+ this.previousFocus = null;
|
||||
+ }
|
||||
}
|
||||
}
|
||||
}, {
|
|
@ -70,7 +70,7 @@ index 262776b..156cf0f 100644
|
|||
this._cellHeightCache[key] = height;
|
||||
this._cellWidthCache[key] = width;
|
||||
diff --git a/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js b/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js
|
||||
index e1b959a..8c5fb6b 100644
|
||||
index e1b959a..7410c0a 100644
|
||||
--- a/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js
|
||||
+++ b/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js
|
||||
@@ -132,6 +132,9 @@ var Grid = function (_React$PureComponent) {
|
||||
|
@ -155,7 +155,7 @@ index e1b959a..8c5fb6b 100644
|
|||
});
|
||||
|
||||
this._maybeCallOnScrollbarPresenceChange();
|
||||
@@ -584,6 +616,67 @@ var Grid = function (_React$PureComponent) {
|
||||
@@ -584,6 +616,65 @@ var Grid = function (_React$PureComponent) {
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -178,7 +178,7 @@ index e1b959a..8c5fb6b 100644
|
|||
+ this._hasScrolledToRowTarget = true;
|
||||
+ }
|
||||
+
|
||||
+ if (scrollToColumn >= 0 && !this._hasScrolledToColumnTarget) {
|
||||
+ if (scrollToColumn >= 0 && !this._hasScrolledToColumnTarget && scrollLeft + width <= totalColumnsWidth) {
|
||||
+ const scrollRight = scrollLeft + width;
|
||||
+ const targetColumn = instanceProps.columnSizeAndPositionManager.getSizeAndPositionOfCell(scrollToColumn);
|
||||
+
|
||||
|
@ -192,10 +192,10 @@ index e1b959a..8c5fb6b 100644
|
|||
+
|
||||
+ if (isVisible) {
|
||||
+ const maxScroll = totalColumnsWidth - width;
|
||||
+ this._hasScrolledToColumnTarget = targetColumn.offset === scrollLeft || (targetColumn.offset < totalColumnsColumsHeight && scrollLeft >= maxScroll);
|
||||
+ this._hasScrolledToColumnTarget = (scrollLeft >= maxScroll || targetColumn.offset === scrollLeft);
|
||||
+ }
|
||||
+ }
|
||||
+ if (scrollToRow >= 0 && !this._hasScrolledToRowTarget) {
|
||||
+ if (scrollToRow >= 0 && !this._hasScrolledToRowTarget && scrollTop + height <= totalRowsHeight) {
|
||||
+ const scrollBottom = scrollTop + height;
|
||||
+ const targetRow = instanceProps.rowSizeAndPositionManager.getSizeAndPositionOfCell(scrollToRow);
|
||||
+ const maxScroll = totalRowsHeight - height;
|
||||
|
@ -213,9 +213,7 @@ index e1b959a..8c5fb6b 100644
|
|||
+ }
|
||||
+
|
||||
+ if (isVisible) {
|
||||
+ // We check the target row's offset against the totalRowsHeight because sometimes the math can be a bit off,
|
||||
+ // and the target row has an offset greater than our calculated total height.
|
||||
+ this._hasScrolledToRowTarget = targetRow.offset === scrollTop || (targetRow.offset < totalRowsHeight && scrollTop >= maxScroll);
|
||||
+ this._hasScrolledToRowTarget = (scrollTop >= maxScroll || targetRow.offset === scrollTop);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
|
@ -223,7 +221,7 @@ index e1b959a..8c5fb6b 100644
|
|||
// Special case where the previous size was 0:
|
||||
// In this case we don't show any windowed cells at all.
|
||||
// So we should always recalculate offset afterwards.
|
||||
@@ -594,6 +687,8 @@ var Grid = function (_React$PureComponent) {
|
||||
@@ -594,6 +685,8 @@ var Grid = function (_React$PureComponent) {
|
||||
if (this._recomputeScrollLeftFlag) {
|
||||
this._recomputeScrollLeftFlag = false;
|
||||
this._updateScrollLeftForScrollToColumn(this.props);
|
||||
|
@ -232,7 +230,7 @@ index e1b959a..8c5fb6b 100644
|
|||
} else {
|
||||
(0, _updateScrollIndexHelper2.default)({
|
||||
cellSizeAndPositionManager: instanceProps.columnSizeAndPositionManager,
|
||||
@@ -616,6 +711,8 @@ var Grid = function (_React$PureComponent) {
|
||||
@@ -616,6 +709,8 @@ var Grid = function (_React$PureComponent) {
|
||||
if (this._recomputeScrollTopFlag) {
|
||||
this._recomputeScrollTopFlag = false;
|
||||
this._updateScrollTopForScrollToRow(this.props);
|
||||
|
@ -241,7 +239,7 @@ index e1b959a..8c5fb6b 100644
|
|||
} else {
|
||||
(0, _updateScrollIndexHelper2.default)({
|
||||
cellSizeAndPositionManager: instanceProps.rowSizeAndPositionManager,
|
||||
@@ -635,19 +732,50 @@ var Grid = function (_React$PureComponent) {
|
||||
@@ -635,19 +730,50 @@ var Grid = function (_React$PureComponent) {
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -296,7 +294,7 @@ index e1b959a..8c5fb6b 100644
|
|||
});
|
||||
}
|
||||
|
||||
@@ -909,6 +1037,11 @@ var Grid = function (_React$PureComponent) {
|
||||
@@ -909,6 +1035,11 @@ var Grid = function (_React$PureComponent) {
|
||||
visibleRowIndices: visibleRowIndices
|
||||
});
|
||||
|
||||
|
@ -308,7 +306,7 @@ index e1b959a..8c5fb6b 100644
|
|||
// update the indices
|
||||
this._columnStartIndex = columnStartIndex;
|
||||
this._columnStopIndex = columnStopIndex;
|
||||
@@ -962,7 +1095,11 @@ var Grid = function (_React$PureComponent) {
|
||||
@@ -962,7 +1093,11 @@ var Grid = function (_React$PureComponent) {
|
||||
var scrollLeft = _ref6.scrollLeft,
|
||||
scrollTop = _ref6.scrollTop,
|
||||
totalColumnsWidth = _ref6.totalColumnsWidth,
|
||||
|
@ -321,7 +319,7 @@ index e1b959a..8c5fb6b 100644
|
|||
|
||||
this._onScrollMemoizer({
|
||||
callback: function callback(_ref7) {
|
||||
@@ -973,19 +1110,26 @@ var Grid = function (_React$PureComponent) {
|
||||
@@ -973,19 +1108,26 @@ var Grid = function (_React$PureComponent) {
|
||||
onScroll = _props7.onScroll,
|
||||
width = _props7.width;
|
||||
|
||||
|
@ -351,7 +349,7 @@ index e1b959a..8c5fb6b 100644
|
|||
}
|
||||
});
|
||||
}
|
||||
@@ -1325,6 +1469,15 @@ var Grid = function (_React$PureComponent) {
|
||||
@@ -1325,6 +1467,15 @@ var Grid = function (_React$PureComponent) {
|
||||
var totalColumnsWidth = instanceProps.columnSizeAndPositionManager.getTotalSize();
|
||||
var scrollBarSize = instanceProps.scrollbarSizeMeasured && totalColumnsWidth > width ? instanceProps.scrollbarSize : 0;
|
||||
|
||||
|
|
13
preload.js
13
preload.js
|
@ -128,17 +128,14 @@ ipc.on('set-up-as-standalone', () => {
|
|||
window.showSettings = () => ipc.send('show-settings');
|
||||
window.showPermissionsPopup = () => ipc.send('show-permissions-popup');
|
||||
|
||||
ipc.on('show-keyboard-shortcuts', () => {
|
||||
window.Events.showKeyboardShortcuts();
|
||||
});
|
||||
ipc.on('add-dark-overlay', () => {
|
||||
const { addDarkOverlay } = window.Events;
|
||||
if (addDarkOverlay) {
|
||||
addDarkOverlay();
|
||||
}
|
||||
window.Events.addDarkOverlay();
|
||||
});
|
||||
ipc.on('remove-dark-overlay', () => {
|
||||
const { removeDarkOverlay } = window.Events;
|
||||
if (removeDarkOverlay) {
|
||||
removeDarkOverlay();
|
||||
}
|
||||
window.Events.removeDarkOverlay();
|
||||
});
|
||||
|
||||
installGetter('device-name', 'getDeviceName');
|
||||
|
|
|
@ -119,10 +119,13 @@
|
|||
}
|
||||
|
||||
.key-verification {
|
||||
.container {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 10px 0;
|
||||
@include font-body-2;
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
|
|
@ -27,7 +27,10 @@ body.dark-theme {
|
|||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
// For vertical scrollbars
|
||||
width: 9px;
|
||||
// For horizontal scrollbars
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
|
@ -192,17 +195,23 @@ a {
|
|||
}
|
||||
}
|
||||
|
||||
.group-member-list .members .contact {
|
||||
@include light-theme {
|
||||
border-bottom: 1px solid $color-gray-05;
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
.group-member-list {
|
||||
.container {
|
||||
outline: none;
|
||||
}
|
||||
@include dark-theme {
|
||||
border-bottom: 1px solid $color-gray-75;
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
|
||||
.members .contact {
|
||||
@include light-theme {
|
||||
border-bottom: 1px solid $color-gray-05;
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
@include dark-theme {
|
||||
border-bottom: 1px solid $color-gray-75;
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
}
|
||||
|
||||
.iconButton {
|
||||
@include button-reset;
|
||||
|
||||
// NOTE: Cannot move these to inline styles as hover breaks due to precedence.
|
||||
// We use vanilla CSS-in-JS which outputs inline styles. The `:hover`
|
||||
// pseudo-class cannot be expressed using vanilla CSS-in-JS, so we define it
|
||||
|
@ -21,7 +23,6 @@
|
|||
height: 50px;
|
||||
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
padding: 3px;
|
||||
|
||||
|
@ -32,7 +33,8 @@
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: $color-gray-60;
|
||||
}
|
||||
|
||||
|
|
|
@ -139,7 +139,9 @@
|
|||
color: inherit;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
outline: inherit;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -182,12 +182,17 @@
|
|||
"role": "help",
|
||||
"submenu": [
|
||||
{
|
||||
"label": "Go to Release Notes",
|
||||
"label": "Show Keyboard Shortcuts",
|
||||
"accelerator": "CmdOrCtrl+/",
|
||||
"click": null
|
||||
},
|
||||
{
|
||||
"type": "separator"
|
||||
},
|
||||
{
|
||||
"label": "Go to Release Notes",
|
||||
"click": null
|
||||
},
|
||||
{
|
||||
"label": "Go to Forums",
|
||||
"click": null
|
||||
|
|
|
@ -169,12 +169,17 @@
|
|||
"role": "help",
|
||||
"submenu": [
|
||||
{
|
||||
"label": "Go to Release Notes",
|
||||
"label": "Show Keyboard Shortcuts",
|
||||
"accelerator": "CmdOrCtrl+/",
|
||||
"click": null
|
||||
},
|
||||
{
|
||||
"type": "separator"
|
||||
},
|
||||
{
|
||||
"label": "Go to Release Notes",
|
||||
"click": null
|
||||
},
|
||||
{
|
||||
"label": "Go to Forums",
|
||||
"click": null
|
||||
|
|
|
@ -120,12 +120,17 @@
|
|||
"role": "help",
|
||||
"submenu": [
|
||||
{
|
||||
"label": "Go to Release Notes",
|
||||
"label": "Show Keyboard Shortcuts",
|
||||
"accelerator": "CmdOrCtrl+/",
|
||||
"click": null
|
||||
},
|
||||
{
|
||||
"type": "separator"
|
||||
},
|
||||
{
|
||||
"label": "Go to Release Notes",
|
||||
"click": null
|
||||
},
|
||||
{
|
||||
"label": "Go to Forums",
|
||||
"click": null
|
||||
|
|
|
@ -109,12 +109,17 @@
|
|||
"role": "help",
|
||||
"submenu": [
|
||||
{
|
||||
"label": "Go to Release Notes",
|
||||
"label": "Show Keyboard Shortcuts",
|
||||
"accelerator": "CmdOrCtrl+/",
|
||||
"click": null
|
||||
},
|
||||
{
|
||||
"type": "separator"
|
||||
},
|
||||
{
|
||||
"label": "Go to Release Notes",
|
||||
"click": null
|
||||
},
|
||||
{
|
||||
"label": "Go to Forums",
|
||||
"click": null
|
||||
|
|
|
@ -57,6 +57,7 @@ describe('SignalMenu', () => {
|
|||
setupWithImport: null,
|
||||
showAbout: null,
|
||||
showDebugLog: null,
|
||||
showKeyboardShortcuts: null,
|
||||
showSettings: null,
|
||||
showWindow: null,
|
||||
};
|
||||
|
|
|
@ -133,7 +133,17 @@ export class Avatar extends React.Component<Props, State> {
|
|||
throw new Error(`Size ${size} is not supported!`);
|
||||
}
|
||||
|
||||
const role = onClick ? 'button' : undefined;
|
||||
let contents;
|
||||
|
||||
if (onClick) {
|
||||
contents = (
|
||||
<button className="module-avatar-button" onClick={onClick}>
|
||||
{hasImage ? this.renderImage() : this.renderNoImage()}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
contents = hasImage ? this.renderImage() : this.renderNoImage();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -141,14 +151,11 @@ 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,
|
||||
onClick ? 'module-avatar--with-click' : null
|
||||
!hasImage ? `module-avatar--${color}` : null
|
||||
)}
|
||||
ref={innerRef}
|
||||
role={role}
|
||||
onClick={onClick}
|
||||
>
|
||||
{hasImage ? this.renderImage() : this.renderNoImage()}
|
||||
{contents}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ export type Props = {
|
|||
} & AvatarProps;
|
||||
|
||||
export const AvatarPopup = (props: Props) => {
|
||||
const focusRef = React.useRef<HTMLButtonElement>(null);
|
||||
const {
|
||||
i18n,
|
||||
profileName,
|
||||
|
@ -28,6 +29,22 @@ export const AvatarPopup = (props: Props) => {
|
|||
|
||||
const hasProfileName = !isEmpty(profileName);
|
||||
|
||||
// Note: mechanisms to dismiss this view are all in its host, MainHeader
|
||||
|
||||
// Focus first button after initial render, restore focus on teardown
|
||||
React.useEffect(() => {
|
||||
const lastFocused = document.activeElement as any;
|
||||
if (focusRef.current) {
|
||||
focusRef.current.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (lastFocused && lastFocused.focus) {
|
||||
lastFocused.focus();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={style} className="module-avatar-popup">
|
||||
<div className="module-avatar-popup__profile">
|
||||
|
@ -44,7 +61,11 @@ export const AvatarPopup = (props: Props) => {
|
|||
</div>
|
||||
</div>
|
||||
<hr className="module-avatar-popup__divider" />
|
||||
<button className="module-avatar-popup__item" onClick={onViewPreferences}>
|
||||
<button
|
||||
ref={focusRef}
|
||||
className="module-avatar-popup__item"
|
||||
onClick={onViewPreferences}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-avatar-popup__item__icon',
|
||||
|
|
|
@ -21,7 +21,7 @@ interface State {
|
|||
}
|
||||
|
||||
export class CaptionEditor extends React.Component<Props, State> {
|
||||
private readonly handleKeyUpBound: (
|
||||
private readonly handleKeyDownBound: (
|
||||
event: React.KeyboardEvent<HTMLInputElement>
|
||||
) => void;
|
||||
private readonly setFocusBound: () => void;
|
||||
|
@ -39,7 +39,7 @@ export class CaptionEditor extends React.Component<Props, State> {
|
|||
caption: caption || '',
|
||||
};
|
||||
|
||||
this.handleKeyUpBound = this.handleKeyUp.bind(this);
|
||||
this.handleKeyDownBound = this.handleKeyDown.bind(this);
|
||||
this.setFocusBound = this.setFocus.bind(this);
|
||||
this.onChangeBound = this.onChange.bind(this);
|
||||
this.onSaveBound = this.onSave.bind(this);
|
||||
|
@ -53,16 +53,22 @@ export class CaptionEditor extends React.Component<Props, State> {
|
|||
}, 200);
|
||||
}
|
||||
|
||||
public handleKeyUp(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
public handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
const { close, onSave } = this.props;
|
||||
|
||||
if (close && event.key === 'Escape') {
|
||||
close();
|
||||
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (onSave && event.key === 'Enter') {
|
||||
const { caption } = this.state;
|
||||
onSave(caption);
|
||||
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -120,7 +126,7 @@ export class CaptionEditor extends React.Component<Props, State> {
|
|||
public render() {
|
||||
const { i18n, close } = this.props;
|
||||
const { caption } = this.state;
|
||||
const onKeyUp = close ? this.handleKeyUpBound : undefined;
|
||||
const onKeyDown = close ? this.handleKeyDownBound : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -129,6 +135,7 @@ export class CaptionEditor extends React.Component<Props, State> {
|
|||
className="module-caption-editor"
|
||||
>
|
||||
<div
|
||||
// Okay that this isn't a button; the escape key can be used to close this view
|
||||
role="button"
|
||||
onClick={close}
|
||||
className="module-caption-editor__close-button"
|
||||
|
@ -145,17 +152,16 @@ export class CaptionEditor extends React.Component<Props, State> {
|
|||
maxLength={200}
|
||||
placeholder={i18n('addACaption')}
|
||||
className="module-caption-editor__caption-input"
|
||||
onKeyUp={onKeyUp}
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={this.onChangeBound}
|
||||
/>
|
||||
{caption ? (
|
||||
<div
|
||||
role="button"
|
||||
<button
|
||||
onClick={this.onSaveBound}
|
||||
className="module-caption-editor__save-button"
|
||||
>
|
||||
{i18n('save')}
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -23,6 +23,7 @@ export type OwnProps = {
|
|||
readonly i18n: LocalizerType;
|
||||
readonly compositionApi?: React.MutableRefObject<{
|
||||
focusInput: () => void;
|
||||
isDirty: () => boolean;
|
||||
setDisabled: (disabled: boolean) => void;
|
||||
setShowMic: (showMic: boolean) => void;
|
||||
setMicActive: (micActive: boolean) => void;
|
||||
|
@ -148,6 +149,7 @@ export const CompositionArea = ({
|
|||
|
||||
if (compositionApi) {
|
||||
compositionApi.current = {
|
||||
isDirty: () => dirty,
|
||||
focusInput,
|
||||
setDisabled,
|
||||
setShowMic,
|
||||
|
@ -220,7 +222,6 @@ export const CompositionArea = ({
|
|||
recentEmojis={recentEmojis}
|
||||
skinTone={skinTone}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
onClose={focusInput}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -230,7 +231,10 @@ export const CompositionArea = ({
|
|||
className={classNames(
|
||||
'module-composition-area__button-cell',
|
||||
micActive ? 'module-composition-area__button-cell--mic-active' : null,
|
||||
large ? 'module-composition-area__button-cell--large-right' : null
|
||||
large ? 'module-composition-area__button-cell--large-right' : null,
|
||||
micActive && large
|
||||
? 'module-composition-area__button-cell--large-right-mic-active'
|
||||
: null
|
||||
)}
|
||||
ref={micCellRef}
|
||||
/>
|
||||
|
@ -313,6 +317,8 @@ export const CompositionArea = ({
|
|||
? 'module-composition-area__toggle-large__button--large-active'
|
||||
: null
|
||||
)}
|
||||
// This prevents the user from tabbing here
|
||||
tabIndex={-1}
|
||||
onClick={handleToggleLarge}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -37,10 +37,10 @@ export const ConfirmationDialog = React.memo(
|
|||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keyup', handler);
|
||||
document.addEventListener('keydown', handler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keyup', handler);
|
||||
document.removeEventListener('keydown', handler);
|
||||
};
|
||||
},
|
||||
[onClose]
|
||||
|
|
|
@ -41,15 +41,18 @@ export const ConfirmationModal = React.memo(
|
|||
|
||||
React.useEffect(
|
||||
() => {
|
||||
const handler = ({ key }: KeyboardEvent) => {
|
||||
if (key === 'Escape') {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keyup', handler);
|
||||
document.addEventListener('keydown', handler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keyup', handler);
|
||||
document.removeEventListener('keydown', handler);
|
||||
};
|
||||
},
|
||||
[onClose]
|
||||
|
@ -67,6 +70,7 @@ export const ConfirmationModal = React.memo(
|
|||
return root
|
||||
? createPortal(
|
||||
<div
|
||||
// Not really a button. Just a background which can be clicked to close modal
|
||||
role="button"
|
||||
className="module-confirmation-dialog__overlay"
|
||||
onClick={handleCancel}
|
||||
|
|
|
@ -68,8 +68,7 @@ export class ContactListItem extends React.Component<Props> {
|
|||
const showVerified = !isMe && verified;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
'module-contact-list-item',
|
||||
|
@ -90,7 +89,7 @@ export class ContactListItem extends React.Component<Props> {
|
|||
{showNumber ? phoneNumber : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,10 @@ export type PropsData = {
|
|||
};
|
||||
};
|
||||
|
||||
export function cleanId(id: string): string {
|
||||
return id.replace(/[^\u0020-\u007e\u00a0-\u00ff]/g, '_');
|
||||
}
|
||||
|
||||
type PropsHousekeeping = {
|
||||
i18n: LocalizerType;
|
||||
style?: Object;
|
||||
|
@ -207,8 +211,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
|
|||
const { unreadCount, onClick, id, isSelected, style } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onClick) {
|
||||
onClick(id);
|
||||
|
@ -220,13 +223,14 @@ export class ConversationListItem extends React.PureComponent<Props> {
|
|||
unreadCount > 0 ? 'module-conversation-list-item--has-unread' : null,
|
||||
isSelected ? 'module-conversation-list-item--is-selected' : null
|
||||
)}
|
||||
data-id={cleanId(id)}
|
||||
>
|
||||
{this.renderAvatar()}
|
||||
<div className="module-conversation-list-item__content">
|
||||
{this.renderHeader()}
|
||||
{this.renderMessage()}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import React from 'react';
|
||||
import { AutoSizer, List } from 'react-virtualized';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import {
|
||||
cleanId,
|
||||
ConversationListItem,
|
||||
PropsData as ConversationListItemPropsType,
|
||||
} from './ConversationListItem';
|
||||
|
@ -14,6 +16,7 @@ import { LocalizerType } from '../types/Util';
|
|||
export interface PropsType {
|
||||
conversations?: Array<ConversationListItemPropsType>;
|
||||
archivedConversations?: Array<ConversationListItemPropsType>;
|
||||
selectedConversationId?: string;
|
||||
searchResults?: SearchResultsProps;
|
||||
showArchived?: boolean;
|
||||
|
||||
|
@ -44,6 +47,11 @@ type RowRendererParamsType = {
|
|||
};
|
||||
|
||||
export class LeftPane extends React.Component<PropsType> {
|
||||
public listRef = React.createRef<any>();
|
||||
public containerRef = React.createRef<HTMLDivElement>();
|
||||
public setFocusToFirstNeeded = false;
|
||||
public setFocusToLastNeeded = false;
|
||||
|
||||
public renderRow = ({
|
||||
index,
|
||||
key,
|
||||
|
@ -85,13 +93,13 @@ export class LeftPane extends React.Component<PropsType> {
|
|||
);
|
||||
};
|
||||
|
||||
public renderArchivedButton({
|
||||
public renderArchivedButton = ({
|
||||
key,
|
||||
style,
|
||||
}: {
|
||||
key: string;
|
||||
style: Object;
|
||||
}): JSX.Element {
|
||||
}): JSX.Element => {
|
||||
const {
|
||||
archivedConversations,
|
||||
i18n,
|
||||
|
@ -105,22 +113,172 @@ export class LeftPane extends React.Component<PropsType> {
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={key}
|
||||
className="module-left-pane__archived-button"
|
||||
style={style}
|
||||
role="button"
|
||||
onClick={showArchivedConversations}
|
||||
>
|
||||
{i18n('archivedConversations')}{' '}
|
||||
<span className="module-left-pane__archived-button__archived-count">
|
||||
{archivedConversations.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
public renderList(): JSX.Element | Array<JSX.Element | null> {
|
||||
public handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const commandOrCtrl = event.metaKey || event.ctrlKey;
|
||||
|
||||
if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowUp') {
|
||||
this.scrollToRow(0);
|
||||
this.setFocusToFirstNeeded = true;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowDown') {
|
||||
const length = this.getLength();
|
||||
this.scrollToRow(length - 1);
|
||||
this.setFocusToLastNeeded = true;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
public handleFocus = () => {
|
||||
const { selectedConversationId } = this.props;
|
||||
const { current: container } = this.containerRef;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.activeElement === container) {
|
||||
const scrollingContainer = this.getScrollContainer();
|
||||
if (selectedConversationId && scrollingContainer) {
|
||||
const escapedId = cleanId(selectedConversationId).replace(
|
||||
/["\\]/g,
|
||||
'\\$&'
|
||||
);
|
||||
// tslint:disable-next-line no-unnecessary-type-assertion
|
||||
const target = scrollingContainer.querySelector(
|
||||
`.module-conversation-list-item[data-id="${escapedId}"]`
|
||||
) as any;
|
||||
|
||||
if (target && target.focus) {
|
||||
target.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setFocusToFirst();
|
||||
}
|
||||
};
|
||||
|
||||
public scrollToRow = (row: number) => {
|
||||
if (!this.listRef || !this.listRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.listRef.current.scrollToRow(row);
|
||||
};
|
||||
|
||||
public getScrollContainer = () => {
|
||||
if (!this.listRef || !this.listRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = this.listRef.current;
|
||||
|
||||
if (!list.Grid || !list.Grid._scrollingContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
return list.Grid._scrollingContainer as HTMLDivElement;
|
||||
};
|
||||
|
||||
public setFocusToFirst = () => {
|
||||
const scrollContainer = this.getScrollContainer();
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// tslint:disable-next-line no-unnecessary-type-assertion
|
||||
const item = scrollContainer.querySelector(
|
||||
'.module-conversation-list-item'
|
||||
) as any;
|
||||
if (item && item.focus) {
|
||||
item.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// tslint:disable-next-line member-ordering
|
||||
public onScroll = debounce(
|
||||
() => {
|
||||
if (this.setFocusToFirstNeeded) {
|
||||
this.setFocusToFirstNeeded = false;
|
||||
this.setFocusToFirst();
|
||||
}
|
||||
if (this.setFocusToLastNeeded) {
|
||||
this.setFocusToLastNeeded = false;
|
||||
|
||||
const scrollContainer = this.getScrollContainer();
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// tslint:disable-next-line no-unnecessary-type-assertion
|
||||
const button = scrollContainer.querySelector(
|
||||
'.module-left-pane__archived-button'
|
||||
) as any;
|
||||
if (button && button.focus) {
|
||||
button.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
// tslint:disable-next-line no-unnecessary-type-assertion
|
||||
const items = scrollContainer.querySelectorAll(
|
||||
'.module-conversation-list-item'
|
||||
) as any;
|
||||
if (items && items.length > 0) {
|
||||
const last = items[items.length - 1];
|
||||
|
||||
if (last && last.focus) {
|
||||
last.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
100,
|
||||
{ maxWait: 100 }
|
||||
);
|
||||
|
||||
public getLength = () => {
|
||||
const { archivedConversations, conversations, showArchived } = this.props;
|
||||
|
||||
if (!conversations || !archivedConversations) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// That extra 1 element added to the list is the 'archived conversations' button
|
||||
return showArchived
|
||||
? archivedConversations.length
|
||||
: conversations.length + (archivedConversations.length ? 1 : 0);
|
||||
};
|
||||
|
||||
public renderList = (): JSX.Element | Array<JSX.Element | null> => {
|
||||
const {
|
||||
archivedConversations,
|
||||
i18n,
|
||||
|
@ -150,10 +308,7 @@ export class LeftPane extends React.Component<PropsType> {
|
|||
);
|
||||
}
|
||||
|
||||
// That extra 1 element added to the list is the 'archived converastions' button
|
||||
const length = showArchived
|
||||
? archivedConversations.length
|
||||
: conversations.length + (archivedConversations.length ? 1 : 0);
|
||||
const length = this.getLength();
|
||||
|
||||
const archived = showArchived ? (
|
||||
<div className="module-left-pane__archive-helper-text" key={0}>
|
||||
|
@ -171,15 +326,27 @@ export class LeftPane extends React.Component<PropsType> {
|
|||
// it re-renders when our conversation data changes. Otherwise it would just render
|
||||
// on startup and scroll.
|
||||
const list = (
|
||||
<div className="module-left-pane__list" key={listKey} aria-live="polite">
|
||||
<div
|
||||
className="module-left-pane__list"
|
||||
key={listKey}
|
||||
aria-live="polite"
|
||||
role="group"
|
||||
tabIndex={-1}
|
||||
ref={this.containerRef}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onFocus={this.handleFocus}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List
|
||||
ref={this.listRef}
|
||||
onScroll={this.onScroll}
|
||||
className="module-left-pane__virtual-list"
|
||||
conversations={conversations}
|
||||
height={height}
|
||||
rowCount={length}
|
||||
rowHeight={68}
|
||||
tabIndex={-1}
|
||||
rowRenderer={this.renderRow}
|
||||
width={width}
|
||||
/>
|
||||
|
@ -189,24 +356,24 @@ export class LeftPane extends React.Component<PropsType> {
|
|||
);
|
||||
|
||||
return [archived, list];
|
||||
}
|
||||
};
|
||||
|
||||
public renderArchivedHeader(): JSX.Element {
|
||||
public renderArchivedHeader = (): JSX.Element => {
|
||||
const { i18n, showInbox } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-left-pane__archive-header">
|
||||
<div
|
||||
role="button"
|
||||
<button
|
||||
onClick={showInbox}
|
||||
className="module-left-pane__to-inbox-button"
|
||||
title={i18n('backToInbox')}
|
||||
/>
|
||||
<div className="module-left-pane__archive-header-text">
|
||||
{i18n('archivedConversations')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
const { renderMainHeader, showArchived } = this.props;
|
||||
|
|
|
@ -62,6 +62,7 @@ const styles = {
|
|||
paddingBottom: 0,
|
||||
// To ensure that a large image doesn't overflow the flex layout
|
||||
minHeight: '50px',
|
||||
outline: 'none',
|
||||
} as React.CSSProperties,
|
||||
objectContainer: {
|
||||
position: 'relative',
|
||||
|
@ -142,7 +143,7 @@ interface IconButtonProps {
|
|||
}
|
||||
|
||||
const IconButton = ({ onClick, style, type }: IconButtonProps) => {
|
||||
const clickHandler = (event: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||
const clickHandler = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
event.preventDefault();
|
||||
if (!onClick) {
|
||||
return;
|
||||
|
@ -152,11 +153,9 @@ const IconButton = ({ onClick, style, type }: IconButtonProps) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
<button
|
||||
onClick={clickHandler}
|
||||
className={classNames('iconButton', type)}
|
||||
role="button"
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
|
@ -170,56 +169,62 @@ const Icon = ({
|
|||
onClick,
|
||||
url,
|
||||
}: {
|
||||
onClick?: (
|
||||
event: React.MouseEvent<HTMLImageElement | HTMLDivElement>
|
||||
) => void;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
url: string;
|
||||
}) => (
|
||||
<div
|
||||
<button
|
||||
style={{
|
||||
...styles.object,
|
||||
...colorSVG(url, Colors.ICON_SECONDARY),
|
||||
maxWidth: 200,
|
||||
}}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
/>
|
||||
);
|
||||
|
||||
export class Lightbox extends React.Component<Props, State> {
|
||||
private readonly containerRef: React.RefObject<HTMLDivElement>;
|
||||
private readonly videoRef: React.RefObject<HTMLVideoElement>;
|
||||
public readonly containerRef = React.createRef<HTMLDivElement>();
|
||||
public readonly videoRef = React.createRef<HTMLVideoElement>();
|
||||
public readonly focusRef = React.createRef<HTMLDivElement>();
|
||||
public previousFocus: any;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.videoRef = React.createRef();
|
||||
this.containerRef = React.createRef();
|
||||
|
||||
this.state = {
|
||||
videoTime: undefined,
|
||||
};
|
||||
}
|
||||
public state = {
|
||||
videoTime: undefined,
|
||||
};
|
||||
|
||||
public componentDidMount() {
|
||||
this.previousFocus = document.activeElement;
|
||||
|
||||
const { isViewOnce } = this.props;
|
||||
|
||||
const useCapture = true;
|
||||
document.addEventListener('keyup', this.onKeyUp, useCapture);
|
||||
document.addEventListener('keydown', this.onKeyDown, useCapture);
|
||||
|
||||
const video = this.getVideo();
|
||||
if (video && isViewOnce) {
|
||||
video.addEventListener('timeupdate', this.onTimeUpdate);
|
||||
}
|
||||
|
||||
this.playVideo();
|
||||
// Wait until we're added to the DOM. ConversationView first creates this view, then
|
||||
// appends its elements into the DOM.
|
||||
setTimeout(() => {
|
||||
this.playVideo();
|
||||
|
||||
if (this.focusRef && this.focusRef.current) {
|
||||
this.focusRef.current.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.previousFocus && this.previousFocus.focus) {
|
||||
this.previousFocus.focus();
|
||||
}
|
||||
|
||||
const { isViewOnce } = this.props;
|
||||
|
||||
const useCapture = true;
|
||||
document.removeEventListener('keyup', this.onKeyUp, useCapture);
|
||||
document.removeEventListener('keydown', this.onKeyDown, useCapture);
|
||||
|
||||
const video = this.getVideo();
|
||||
if (video && isViewOnce) {
|
||||
|
@ -269,12 +274,13 @@ export class Lightbox extends React.Component<Props, State> {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="module-lightbox"
|
||||
style={styles.container}
|
||||
onClick={this.onContainerClick}
|
||||
ref={this.containerRef}
|
||||
role="dialog"
|
||||
>
|
||||
<div style={styles.mainContainer}>
|
||||
<div style={styles.mainContainer} tabIndex={-1} ref={this.focusRef}>
|
||||
<div style={styles.controlsOffsetPlaceholder} />
|
||||
<div style={styles.objectContainer}>
|
||||
{!is.undefined(contentType)
|
||||
|
@ -342,7 +348,6 @@ export class Lightbox extends React.Component<Props, State> {
|
|||
if (isVideoTypeSupported) {
|
||||
return (
|
||||
<video
|
||||
role="button"
|
||||
ref={this.videoRef}
|
||||
loop={isViewOnce}
|
||||
controls={!isViewOnce}
|
||||
|
@ -391,22 +396,32 @@ export class Lightbox extends React.Component<Props, State> {
|
|||
});
|
||||
};
|
||||
|
||||
private readonly onKeyUp = (event: KeyboardEvent) => {
|
||||
private readonly onKeyDown = (event: KeyboardEvent) => {
|
||||
const { onNext, onPrevious } = this.props;
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
this.onClose();
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
break;
|
||||
|
||||
case 'ArrowLeft':
|
||||
if (onPrevious) {
|
||||
onPrevious();
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowRight':
|
||||
if (onNext) {
|
||||
onNext();
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
break;
|
||||
|
||||
|
@ -424,7 +439,7 @@ export class Lightbox extends React.Component<Props, State> {
|
|||
};
|
||||
|
||||
private readonly onObjectClick = (
|
||||
event: React.MouseEvent<HTMLImageElement | HTMLDivElement>
|
||||
event: React.MouseEvent<HTMLButtonElement | HTMLImageElement>
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
this.onClose();
|
||||
|
|
|
@ -13,6 +13,7 @@ export interface PropsType {
|
|||
searchTerm: string;
|
||||
searchConversationName?: string;
|
||||
searchConversationId?: string;
|
||||
startSearchCounter: number;
|
||||
|
||||
// To be used as an ID
|
||||
ourNumber: string;
|
||||
|
@ -79,7 +80,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
}
|
||||
|
||||
public componentDidUpdate(prevProps: PropsType) {
|
||||
const { searchConversationId } = this.props;
|
||||
const { searchConversationId, startSearchCounter } = this.props;
|
||||
|
||||
// When user chooses to search in a given conversation we focus the field for them
|
||||
if (
|
||||
|
@ -88,6 +89,10 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
) {
|
||||
this.setFocus();
|
||||
}
|
||||
// When user chooses to start a new search, we focus the field
|
||||
if (startSearchCounter !== prevProps.startSearchCounter) {
|
||||
this.setSelected();
|
||||
}
|
||||
}
|
||||
|
||||
public handleOutsideClick = ({ target }: MouseEvent) => {
|
||||
|
@ -102,7 +107,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
}
|
||||
};
|
||||
|
||||
public handleOutsideKeyUp = (event: KeyboardEvent) => {
|
||||
public handleOutsideKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
this.hideAvatarPopup();
|
||||
}
|
||||
|
@ -113,12 +118,12 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
showingAvatarPopup: true,
|
||||
});
|
||||
document.addEventListener('click', this.handleOutsideClick);
|
||||
document.addEventListener('keydown', this.handleOutsideKeyUp);
|
||||
document.addEventListener('keydown', this.handleOutsideKeyDown);
|
||||
};
|
||||
|
||||
public hideAvatarPopup = () => {
|
||||
document.removeEventListener('click', this.handleOutsideClick);
|
||||
document.removeEventListener('keydown', this.handleOutsideKeyUp);
|
||||
document.removeEventListener('keydown', this.handleOutsideKeyDown);
|
||||
this.setState({
|
||||
showingAvatarPopup: false,
|
||||
});
|
||||
|
@ -130,7 +135,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
if (popperRoot) {
|
||||
document.body.removeChild(popperRoot);
|
||||
document.removeEventListener('click', this.handleOutsideClick);
|
||||
document.removeEventListener('keydown', this.handleOutsideKeyUp);
|
||||
document.removeEventListener('keydown', this.handleOutsideKeyDown);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -204,7 +209,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
this.setFocus();
|
||||
};
|
||||
|
||||
public handleKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
public handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const {
|
||||
clearConversationSearch,
|
||||
clearSearch,
|
||||
|
@ -221,6 +226,9 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
} else {
|
||||
clearSearch();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
public handleXButton = () => {
|
||||
|
@ -246,6 +254,13 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
}
|
||||
};
|
||||
|
||||
public setSelected = () => {
|
||||
if (this.inputRef.current) {
|
||||
// @ts-ignore
|
||||
this.inputRef.current.select();
|
||||
}
|
||||
};
|
||||
|
||||
// tslint:disable-next-line:max-func-body-length
|
||||
public render() {
|
||||
const {
|
||||
|
@ -320,6 +335,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
<button
|
||||
className="module-main-header__search__in-conversation-pill"
|
||||
onClick={this.clearSearch}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="module-main-header__search__in-conversation-pill__avatar-container">
|
||||
<div className="module-main-header__search__in-conversation-pill__avatar" />
|
||||
|
@ -330,6 +346,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
<button
|
||||
className="module-main-header__search__icon"
|
||||
onClick={this.setFocus}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
|
@ -346,13 +363,13 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
)}
|
||||
placeholder={placeholder}
|
||||
dir="auto"
|
||||
onKeyUp={this.handleKeyUp}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
value={searchTerm}
|
||||
onChange={this.updateSearch}
|
||||
/>
|
||||
{searchTerm ? (
|
||||
<div
|
||||
role="button"
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="module-main-header__search__cancel-icon"
|
||||
onClick={this.handleXButton}
|
||||
/>
|
||||
|
|
|
@ -138,8 +138,7 @@ export class MessageSearchResult extends React.PureComponent<PropsType> {
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
<button
|
||||
onClick={() => {
|
||||
if (openConversationInternal) {
|
||||
openConversationInternal(conversationId, id);
|
||||
|
@ -149,6 +148,7 @@ export class MessageSearchResult extends React.PureComponent<PropsType> {
|
|||
'module-message-search-result',
|
||||
isSelected ? 'module-message-search-result--is-selected' : null
|
||||
)}
|
||||
data-id={id}
|
||||
>
|
||||
{this.renderAvatar()}
|
||||
<div className="module-message-search-result__text">
|
||||
|
@ -162,7 +162,7 @@ export class MessageSearchResult extends React.PureComponent<PropsType> {
|
|||
<MessageBodyHighlight text={snippet} i18n={i18n} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,11 +5,13 @@ import {
|
|||
CellMeasurerCache,
|
||||
List,
|
||||
} from 'react-virtualized';
|
||||
import { debounce, isNumber } from 'lodash';
|
||||
|
||||
import { Intl } from './Intl';
|
||||
import { Emojify } from './conversation/Emojify';
|
||||
import { Spinner } from './Spinner';
|
||||
import {
|
||||
cleanId,
|
||||
ConversationListItem,
|
||||
PropsData as ConversationListItemPropsType,
|
||||
} from './ConversationListItem';
|
||||
|
@ -25,6 +27,8 @@ export type PropsDataType = {
|
|||
regionCode: string;
|
||||
searchConversationName?: string;
|
||||
searchTerm: string;
|
||||
selectedConversationId?: string;
|
||||
selectedMessageId?: string;
|
||||
};
|
||||
|
||||
type StartNewConversationType = {
|
||||
|
@ -82,6 +86,9 @@ type PropsHousekeepingType = {
|
|||
};
|
||||
|
||||
type PropsType = PropsDataType & PropsHousekeepingType;
|
||||
type StateType = {
|
||||
scrollToIndex?: number;
|
||||
};
|
||||
|
||||
// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5
|
||||
type RowRendererParamsType = {
|
||||
|
@ -92,8 +99,23 @@ type RowRendererParamsType = {
|
|||
parent: Object;
|
||||
style: Object;
|
||||
};
|
||||
type OnScrollParamsType = {
|
||||
scrollTop: number;
|
||||
clientHeight: number;
|
||||
scrollHeight: number;
|
||||
|
||||
export class SearchResults extends React.Component<PropsType> {
|
||||
clientWidth: number;
|
||||
scrollWidth?: number;
|
||||
scrollLeft?: number;
|
||||
scrollToColumn?: number;
|
||||
_hasScrolledToColumnTarget?: boolean;
|
||||
scrollToRow?: number;
|
||||
_hasScrolledToRowTarget?: boolean;
|
||||
};
|
||||
|
||||
export class SearchResults extends React.Component<PropsType, StateType> {
|
||||
public setFocusToFirstNeeded = false;
|
||||
public setFocusToLastNeeded = false;
|
||||
public mostRecentWidth = 0;
|
||||
public mostRecentHeight = 0;
|
||||
public cellSizeCache = new CellMeasurerCache({
|
||||
|
@ -101,6 +123,10 @@ export class SearchResults extends React.Component<PropsType> {
|
|||
fixedWidth: true,
|
||||
});
|
||||
public listRef = React.createRef<any>();
|
||||
public containerRef = React.createRef<HTMLDivElement>();
|
||||
public state = {
|
||||
scrollToIndex: undefined,
|
||||
};
|
||||
|
||||
public handleStartNewConversation = () => {
|
||||
const { regionCode, searchTerm, startNewConversation } = this.props;
|
||||
|
@ -108,6 +134,208 @@ export class SearchResults extends React.Component<PropsType> {
|
|||
startNewConversation(searchTerm, { regionCode });
|
||||
};
|
||||
|
||||
public handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const { items } = this.props;
|
||||
const commandOrCtrl = event.metaKey || event.ctrlKey;
|
||||
|
||||
if (!items || items.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowUp') {
|
||||
this.setState({ scrollToIndex: 0 });
|
||||
this.setFocusToFirstNeeded = true;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowDown') {
|
||||
const lastIndex = items.length - 1;
|
||||
this.setState({ scrollToIndex: lastIndex });
|
||||
this.setFocusToLastNeeded = true;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
public handleFocus = () => {
|
||||
const { selectedConversationId, selectedMessageId } = this.props;
|
||||
const { current: container } = this.containerRef;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.activeElement === container) {
|
||||
const scrollingContainer = this.getScrollContainer();
|
||||
|
||||
// First we try to scroll to the selected message
|
||||
if (selectedMessageId && scrollingContainer) {
|
||||
// tslint:disable-next-line no-unnecessary-type-assertion
|
||||
const target = scrollingContainer.querySelector(
|
||||
`.module-message-search-result[data-id="${selectedMessageId}"]`
|
||||
) as any;
|
||||
|
||||
if (target && target.focus) {
|
||||
target.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Then we try for the selected conversation
|
||||
if (selectedConversationId && scrollingContainer) {
|
||||
const escapedId = cleanId(selectedConversationId).replace(
|
||||
/["\\]/g,
|
||||
'\\$&'
|
||||
);
|
||||
// tslint:disable-next-line no-unnecessary-type-assertion
|
||||
const target = scrollingContainer.querySelector(
|
||||
`.module-conversation-list-item[data-id="${escapedId}"]`
|
||||
) as any;
|
||||
|
||||
if (target && target.focus) {
|
||||
target.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise we set focus to the first non-header item
|
||||
this.setFocusToFirst();
|
||||
}
|
||||
};
|
||||
|
||||
public setFocusToFirst = () => {
|
||||
const scrollContainer = this.getScrollContainer();
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// tslint:disable-next-line no-unnecessary-type-assertion
|
||||
const startItem = scrollContainer.querySelector(
|
||||
'.module-start-new-conversation'
|
||||
) as any;
|
||||
if (startItem && startItem.focus) {
|
||||
startItem.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// tslint:disable-next-line no-unnecessary-type-assertion
|
||||
const conversationItem = scrollContainer.querySelector(
|
||||
'.module-conversation-list-item'
|
||||
) as any;
|
||||
if (conversationItem && conversationItem.focus) {
|
||||
conversationItem.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// tslint:disable-next-line no-unnecessary-type-assertion
|
||||
const messageItem = scrollContainer.querySelector(
|
||||
'.module-message-search-result'
|
||||
) as any;
|
||||
if (messageItem && messageItem.focus) {
|
||||
messageItem.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
public getScrollContainer = () => {
|
||||
if (!this.listRef || !this.listRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = this.listRef.current;
|
||||
|
||||
if (!list.Grid || !list.Grid._scrollingContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
return list.Grid._scrollingContainer as HTMLDivElement;
|
||||
};
|
||||
|
||||
// tslint:disable-next-line member-ordering
|
||||
public onScroll = debounce(
|
||||
// tslint:disable-next-line cyclomatic-complexity
|
||||
(data: OnScrollParamsType) => {
|
||||
// Ignore scroll events generated as react-virtualized recursively scrolls and
|
||||
// re-measures to get us where we want to go.
|
||||
if (
|
||||
isNumber(data.scrollToRow) &&
|
||||
data.scrollToRow >= 0 &&
|
||||
!data._hasScrolledToRowTarget
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ scrollToIndex: undefined });
|
||||
|
||||
if (this.setFocusToFirstNeeded) {
|
||||
this.setFocusToFirstNeeded = false;
|
||||
this.setFocusToFirst();
|
||||
}
|
||||
|
||||
if (this.setFocusToLastNeeded) {
|
||||
this.setFocusToLastNeeded = false;
|
||||
|
||||
const scrollContainer = this.getScrollContainer();
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageItems = scrollContainer.querySelectorAll(
|
||||
'.module-message-search-result'
|
||||
) as any;
|
||||
if (messageItems && messageItems.length > 0) {
|
||||
const last = messageItems[messageItems.length - 1];
|
||||
|
||||
if (last && last.focus) {
|
||||
last.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const contactItems = scrollContainer.querySelectorAll(
|
||||
'.module-conversation-list-item'
|
||||
) as any;
|
||||
if (contactItems && contactItems.length > 0) {
|
||||
const last = contactItems[contactItems.length - 1];
|
||||
|
||||
if (last && last.focus) {
|
||||
last.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const startItem = scrollContainer.querySelectorAll(
|
||||
'.module-start-new-conversation'
|
||||
) as any;
|
||||
if (startItem && startItem.length > 0) {
|
||||
const last = startItem[startItem.length - 1];
|
||||
|
||||
if (last && last.focus) {
|
||||
last.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
100,
|
||||
{ maxWait: 100 }
|
||||
);
|
||||
|
||||
public renderRowContents(row: SearchResultRowType) {
|
||||
const {
|
||||
searchTerm,
|
||||
|
@ -281,6 +509,7 @@ export class SearchResults extends React.Component<PropsType> {
|
|||
searchConversationName,
|
||||
searchTerm,
|
||||
} = this.props;
|
||||
const { scrollToIndex } = this.state;
|
||||
|
||||
if (noResults) {
|
||||
return (
|
||||
|
@ -306,7 +535,15 @@ export class SearchResults extends React.Component<PropsType> {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="module-search-results" aria-live="polite">
|
||||
<div
|
||||
className="module-search-results"
|
||||
aria-live="polite"
|
||||
role="group"
|
||||
tabIndex={-1}
|
||||
ref={this.containerRef}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onFocus={this.handleFocus}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => {
|
||||
this.mostRecentWidth = width;
|
||||
|
@ -323,6 +560,9 @@ export class SearchResults extends React.Component<PropsType> {
|
|||
rowCount={this.getRowCount()}
|
||||
rowHeight={this.cellSizeCache.rowHeight}
|
||||
rowRenderer={this.renderRow}
|
||||
scrollToIndex={scrollToIndex}
|
||||
tabIndex={-1}
|
||||
onScroll={this.onScroll as any}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
|
|
7
ts/components/ShortcutGuide.md
Normal file
7
ts/components/ShortcutGuide.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<div style={{ position: 'relative', height: '500px', width: '600px; }}>
|
||||
<ShortcutGuide i18n={util.i18n} />
|
||||
</div>
|
||||
</util.ConversationContext>
|
||||
```
|
304
ts/components/ShortcutGuide.tsx
Normal file
304
ts/components/ShortcutGuide.tsx
Normal file
|
@ -0,0 +1,304 @@
|
|||
import * as React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
export type Props = {
|
||||
hasInstalledStickers: boolean;
|
||||
platform: string;
|
||||
readonly close: () => unknown;
|
||||
readonly i18n: LocalizerType;
|
||||
};
|
||||
|
||||
type KeyType =
|
||||
| 'commandOrCtrl'
|
||||
| 'optionOrAlt'
|
||||
| 'shift'
|
||||
| 'enter'
|
||||
| '↑'
|
||||
| '↓'
|
||||
| ','
|
||||
| '.'
|
||||
| 'A'
|
||||
| 'C'
|
||||
| 'D'
|
||||
| 'E'
|
||||
| 'F'
|
||||
| 'I'
|
||||
| 'M'
|
||||
| 'P'
|
||||
| 'R'
|
||||
| 'S'
|
||||
| 'T'
|
||||
| 'U'
|
||||
| 'V'
|
||||
| 'X';
|
||||
type ShortcutType = {
|
||||
description: string;
|
||||
keys: Array<KeyType>;
|
||||
};
|
||||
|
||||
const NAVIGATION_SHORTCUTS: Array<ShortcutType> = [
|
||||
{
|
||||
description: 'Keyboard--navigate-by-section',
|
||||
keys: ['commandOrCtrl', 'T'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--previous-conversation',
|
||||
keys: ['optionOrAlt', '↑'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--next-conversation',
|
||||
keys: ['optionOrAlt', '↓'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--previous-unread-conversation',
|
||||
keys: ['optionOrAlt', 'shift', '↑'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--next-unread-conversation',
|
||||
keys: ['optionOrAlt', 'shift', '↓'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--preferences',
|
||||
keys: ['commandOrCtrl', ','],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--open-conversation-menu',
|
||||
keys: ['commandOrCtrl', 'shift', 'I'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--search',
|
||||
keys: ['commandOrCtrl', 'F'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--search-in-conversation',
|
||||
keys: ['commandOrCtrl', 'shift', 'F'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--focus-composer',
|
||||
keys: ['commandOrCtrl', 'shift', 'T'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--open-all-media-view',
|
||||
keys: ['commandOrCtrl', 'shift', 'M'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--open-emoji-chooser',
|
||||
keys: ['commandOrCtrl', 'shift', 'E'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--open-sticker-chooser',
|
||||
keys: ['commandOrCtrl', 'shift', 'S'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--begin-recording-voice-note',
|
||||
keys: ['commandOrCtrl', 'shift', 'V'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--archive-conversation',
|
||||
keys: ['commandOrCtrl', 'shift', 'A'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--unarchive-conversation',
|
||||
keys: ['commandOrCtrl', 'shift', 'U'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--scroll-to-top',
|
||||
keys: ['commandOrCtrl', '↑'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--scroll-to-bottom',
|
||||
keys: ['commandOrCtrl', '↓'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--close-curent-conversation',
|
||||
keys: ['commandOrCtrl', 'shift', 'C'],
|
||||
},
|
||||
];
|
||||
|
||||
const MESSAGE_SHORTCUTS: Array<ShortcutType> = [
|
||||
{
|
||||
description: 'Keyboard--default-message-action',
|
||||
keys: ['enter'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--view-details-for-selected-message',
|
||||
keys: ['commandOrCtrl', 'D'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--toggle-reply',
|
||||
keys: ['commandOrCtrl', 'shift', 'R'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--save-attachment',
|
||||
keys: ['commandOrCtrl', 'S'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--delete-message',
|
||||
keys: ['commandOrCtrl', 'shift', 'D'],
|
||||
},
|
||||
];
|
||||
|
||||
const COMPOSER_SHORTCUTS: Array<ShortcutType> = [
|
||||
{
|
||||
description: 'Keyboard--add-newline',
|
||||
keys: ['shift', 'enter'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--expand-composer',
|
||||
keys: ['commandOrCtrl', 'shift', 'X'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--send-in-expanded-composer',
|
||||
keys: ['commandOrCtrl', 'enter'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--attach-file',
|
||||
keys: ['commandOrCtrl', 'U'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--remove-draft-link-preview',
|
||||
keys: ['commandOrCtrl', 'P'],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--remove-draft-attachments',
|
||||
keys: ['commandOrCtrl', 'shift', 'P'],
|
||||
},
|
||||
];
|
||||
|
||||
export const ShortcutGuide = (props: Props) => {
|
||||
const focusRef = React.useRef<HTMLDivElement>(null);
|
||||
const { i18n, close, hasInstalledStickers, platform } = props;
|
||||
const isMacOS = platform === 'darwin';
|
||||
|
||||
// Restore focus on teardown
|
||||
React.useEffect(() => {
|
||||
const lastFocused = document.activeElement as any;
|
||||
if (focusRef.current) {
|
||||
focusRef.current.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (lastFocused && lastFocused.focus) {
|
||||
lastFocused.focus();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="module-shortcut-guide">
|
||||
<div className="module-shortcut-guide__header">
|
||||
<div className="module-shortcut-guide__header-text">
|
||||
{i18n('Keyboard--header')}
|
||||
</div>
|
||||
<button
|
||||
className="module-shortcut-guide__header-close"
|
||||
onClick={close}
|
||||
title={i18n('close-popup')}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="module-shortcut-guide__scroll-container"
|
||||
ref={focusRef}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="module-shortcut-guide__section-container">
|
||||
<div className="module-shortcut-guide__section">
|
||||
<div className="module-shortcut-guide__section-header">
|
||||
{i18n('Keyboard--navigation-header')}
|
||||
</div>
|
||||
<div className="module-shortcut-guide__section-list">
|
||||
{NAVIGATION_SHORTCUTS.map((shortcut, index) => {
|
||||
if (
|
||||
!hasInstalledStickers &&
|
||||
shortcut.description === 'Keyboard--open-sticker-chooser'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return renderShortcut(shortcut, index, isMacOS, i18n);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="module-shortcut-guide__section">
|
||||
<div className="module-shortcut-guide__section-header">
|
||||
{i18n('Keyboard--messages-header')}
|
||||
</div>
|
||||
<div className="module-shortcut-guide__section-list">
|
||||
{MESSAGE_SHORTCUTS.map((shortcut, index) =>
|
||||
renderShortcut(shortcut, index, isMacOS, i18n)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="module-shortcut-guide__section">
|
||||
<div className="module-shortcut-guide__section-header">
|
||||
{i18n('Keyboard--composer-header')}
|
||||
</div>
|
||||
<div className="module-shortcut-guide__section-list">
|
||||
{COMPOSER_SHORTCUTS.map((shortcut, index) =>
|
||||
renderShortcut(shortcut, index, isMacOS, i18n)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function renderShortcut(
|
||||
shortcut: ShortcutType,
|
||||
index: number,
|
||||
isMacOS: boolean,
|
||||
i18n: LocalizerType
|
||||
) {
|
||||
return (
|
||||
<div key={index} className="module-shortcut-guide__shortcut" tabIndex={0}>
|
||||
<div className="module-shortcut-guide__shortcut__description">
|
||||
{i18n(shortcut.description)}
|
||||
</div>
|
||||
<div className="module-shortcut-guide__shortcut__key-container">
|
||||
{shortcut.keys.map((key, mapIndex) => {
|
||||
let label: string = key;
|
||||
let isSquare = true;
|
||||
|
||||
if (key === 'commandOrCtrl' && isMacOS) {
|
||||
label = '⌘';
|
||||
}
|
||||
if (key === 'commandOrCtrl' && !isMacOS) {
|
||||
label = i18n('Keyboard--Key--ctrl');
|
||||
isSquare = false;
|
||||
}
|
||||
if (key === 'optionOrAlt' && isMacOS) {
|
||||
label = i18n('Keyboard--Key--option');
|
||||
isSquare = false;
|
||||
}
|
||||
if (key === 'optionOrAlt' && !isMacOS) {
|
||||
label = i18n('Keyboard--Key--alt');
|
||||
isSquare = false;
|
||||
}
|
||||
if (key === 'shift') {
|
||||
label = i18n('Keyboard--Key--shift');
|
||||
isSquare = false;
|
||||
}
|
||||
if (key === 'enter') {
|
||||
label = i18n('Keyboard--Key--enter');
|
||||
isSquare = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={mapIndex}
|
||||
className={classNames(
|
||||
'module-shortcut-guide__shortcut__key',
|
||||
isSquare ? 'module-shortcut-guide__shortcut__key--square' : null
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
45
ts/components/ShortcutGuideModal.tsx
Normal file
45
ts/components/ShortcutGuideModal.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { ShortcutGuide } from './ShortcutGuide';
|
||||
|
||||
export type PropsType = {
|
||||
hasInstalledStickers: boolean;
|
||||
platform: string;
|
||||
readonly close: () => unknown;
|
||||
readonly i18n: LocalizerType;
|
||||
};
|
||||
|
||||
export const ShortcutGuideModal = React.memo(
|
||||
// tslint:disable-next-line max-func-body-length
|
||||
(props: PropsType) => {
|
||||
const { i18n, close, hasInstalledStickers, platform } = props;
|
||||
const [root, setRoot] = React.useState<HTMLElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
setRoot(div);
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(div);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return root
|
||||
? createPortal(
|
||||
<div className="module-shortcut-guide-modal">
|
||||
<div className="module-shortcut-guide-container">
|
||||
<ShortcutGuide
|
||||
hasInstalledStickers={hasInstalledStickers}
|
||||
platform={platform}
|
||||
close={close}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
root
|
||||
)
|
||||
: null;
|
||||
}
|
||||
);
|
|
@ -15,11 +15,7 @@ export class StartNewConversation extends React.PureComponent<Props> {
|
|||
const { phoneNumber, i18n, onClick } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
className="module-start-new-conversation"
|
||||
onClick={onClick}
|
||||
>
|
||||
<button className="module-start-new-conversation" onClick={onClick}>
|
||||
<Avatar
|
||||
color="grey"
|
||||
conversationType="direct"
|
||||
|
@ -35,7 +31,7 @@ export class StartNewConversation extends React.PureComponent<Props> {
|
|||
{i18n('startConversation')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,8 +50,7 @@ export class AttachmentList extends React.Component<Props> {
|
|||
<div className="module-attachments">
|
||||
{attachments.length > 1 ? (
|
||||
<div className="module-attachments__header">
|
||||
<div
|
||||
role="button"
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="module-attachments__close-button"
|
||||
/>
|
||||
|
@ -105,7 +104,10 @@ export class AttachmentList extends React.Component<Props> {
|
|||
);
|
||||
})}
|
||||
{allVisualAttachments ? (
|
||||
<StagedPlaceholderAttachment onClick={onAddAttachment} />
|
||||
<StagedPlaceholderAttachment
|
||||
onClick={onAddAttachment}
|
||||
i18n={i18n}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -93,17 +93,12 @@ export class ContactDetail extends React.Component<Props> {
|
|||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="module-contact-detail__send-message"
|
||||
role="button"
|
||||
// tslint:disable-next-line react-this-binding-issue
|
||||
onClick={onClick}
|
||||
>
|
||||
<button className="module-contact-detail__send-message__inner">
|
||||
<button className="module-contact-detail__send-message" onClick={onClick}>
|
||||
<div className="module-contact-detail__send-message__inner">
|
||||
<div className="module-contact-detail__send-message__bubble-icon" />
|
||||
{i18n('sendMessageToContact')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@ export class EmbeddedContact extends React.Component<Props> {
|
|||
const direction = isIncoming ? 'incoming' : 'outgoing';
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
className={classNames(
|
||||
'module-embedded-contact',
|
||||
withContentAbove
|
||||
|
@ -43,15 +43,21 @@ export class EmbeddedContact extends React.Component<Props> {
|
|||
? 'module-embedded-contact--with-content-below'
|
||||
: null
|
||||
)}
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
if (onClick) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderAvatar({ contact, i18n, size: 52, direction })}
|
||||
<div className="module-embedded-contact__text-container">
|
||||
{renderName({ contact, isIncoming, module })}
|
||||
{renderContactShorthand({ contact, isIncoming, module })}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,10 +12,10 @@ interface Props {
|
|||
|
||||
height?: number;
|
||||
width?: number;
|
||||
tabIndex?: number;
|
||||
|
||||
overlayText?: string;
|
||||
|
||||
isSelected?: boolean;
|
||||
noBorder?: boolean;
|
||||
noBackground?: boolean;
|
||||
bottomOverlay?: boolean;
|
||||
|
@ -38,6 +38,27 @@ interface Props {
|
|||
}
|
||||
|
||||
export class Image extends React.Component<Props> {
|
||||
public handleClick = (event: React.MouseEvent) => {
|
||||
const { onClick, attachment } = this.props;
|
||||
|
||||
if (onClick) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
onClick(attachment);
|
||||
}
|
||||
};
|
||||
|
||||
public handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
const { onClick, attachment } = this.props;
|
||||
|
||||
if (onClick && (event.key === 'Enter' || event.key === 'Space')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick(attachment);
|
||||
}
|
||||
};
|
||||
|
||||
// tslint:disable-next-line max-func-body-length cyclomatic-complexity
|
||||
public render() {
|
||||
const {
|
||||
|
@ -52,7 +73,6 @@ export class Image extends React.Component<Props> {
|
|||
darkOverlay,
|
||||
height,
|
||||
i18n,
|
||||
isSelected,
|
||||
noBackground,
|
||||
noBorder,
|
||||
onClick,
|
||||
|
@ -62,26 +82,48 @@ export class Image extends React.Component<Props> {
|
|||
playIconOverlay,
|
||||
smallCurveTopLeft,
|
||||
softCorners,
|
||||
tabIndex,
|
||||
url,
|
||||
width,
|
||||
} = this.props;
|
||||
|
||||
const { caption, pending } = attachment || { caption: null, pending: true };
|
||||
const canClick = onClick && !pending;
|
||||
const role = canClick ? 'button' : undefined;
|
||||
|
||||
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,
|
||||
curveTopLeft ? 'module-image--curved-top-left' : null,
|
||||
curveTopRight ? 'module-image--curved-top-right' : null,
|
||||
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
|
||||
curveBottomRight ? 'module-image--curved-bottom-right' : null,
|
||||
smallCurveTopLeft ? 'module-image--small-curved-top-left' : null,
|
||||
softCorners ? 'module-image--soft-corners' : null,
|
||||
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} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role={role}
|
||||
onClick={() => {
|
||||
if (canClick && onClick) {
|
||||
onClick(attachment);
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
'module-image',
|
||||
!noBackground ? 'module-image--with-background' : null,
|
||||
canClick ? 'module-image__with-click-handler' : null,
|
||||
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
|
||||
curveBottomRight ? 'module-image--curved-bottom-right' : null,
|
||||
curveTopLeft ? 'module-image--curved-top-left' : null,
|
||||
|
@ -90,9 +132,6 @@ export class Image extends React.Component<Props> {
|
|||
softCorners ? 'module-image--soft-corners' : null
|
||||
)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<div className="module-image--selection--selected" />
|
||||
) : null}
|
||||
{pending ? (
|
||||
<div
|
||||
className="module-image__loading-placeholder"
|
||||
|
@ -102,7 +141,7 @@ export class Image extends React.Component<Props> {
|
|||
lineHeight: `${height}px`,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
// alt={i18n('loading')}
|
||||
title={i18n('loading')}
|
||||
>
|
||||
<Spinner svgSize="normal" />
|
||||
</div>
|
||||
|
@ -123,30 +162,19 @@ export class Image extends React.Component<Props> {
|
|||
alt={i18n('imageCaptionIconAlt')}
|
||||
/>
|
||||
) : null}
|
||||
{!noBorder ? (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-image__border-overlay',
|
||||
curveTopLeft ? 'module-image--curved-top-left' : null,
|
||||
curveTopRight ? 'module-image--curved-top-right' : null,
|
||||
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
|
||||
curveBottomRight ? 'module-image--curved-bottom-right' : null,
|
||||
smallCurveTopLeft ? 'module-image--small-curved-top-left' : null,
|
||||
softCorners ? 'module-image--soft-corners' : null,
|
||||
darkOverlay ? 'module-image__border-overlay--dark' : null
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{overlay}
|
||||
{closeButton ? (
|
||||
<div
|
||||
role="button"
|
||||
<button
|
||||
onClick={(e: React.MouseEvent<{}>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (onClickClose) {
|
||||
onClickClose(attachment);
|
||||
}
|
||||
}}
|
||||
className="module-image__close-button"
|
||||
title={i18n('remove-attachment')}
|
||||
/>
|
||||
) : null}
|
||||
{bottomOverlay ? (
|
||||
|
|
|
@ -21,8 +21,8 @@ interface Props {
|
|||
withContentBelow?: boolean;
|
||||
bottomOverlay?: boolean;
|
||||
isSticker?: boolean;
|
||||
isSelected?: boolean;
|
||||
stickerSize?: number;
|
||||
tabIndex?: number;
|
||||
|
||||
i18n: LocalizerType;
|
||||
|
||||
|
@ -38,10 +38,10 @@ export class ImageGrid extends React.Component<Props> {
|
|||
bottomOverlay,
|
||||
i18n,
|
||||
isSticker,
|
||||
isSelected,
|
||||
stickerSize,
|
||||
onError,
|
||||
onClick,
|
||||
tabIndex,
|
||||
withContentAbove,
|
||||
withContentBelow,
|
||||
} = this.props;
|
||||
|
@ -85,10 +85,10 @@ export class ImageGrid extends React.Component<Props> {
|
|||
curveBottomRight={curveBottomRight}
|
||||
attachment={attachments[0]}
|
||||
playIconOverlay={isVideoAttachment(attachments[0])}
|
||||
isSelected={isSelected}
|
||||
height={finalHeight}
|
||||
width={finalWidth}
|
||||
url={getUrl(attachments[0])}
|
||||
tabIndex={tabIndex}
|
||||
onClick={onClick}
|
||||
onError={onError}
|
||||
/>
|
||||
|
@ -104,7 +104,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
i18n={i18n}
|
||||
attachment={attachments[0]}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
noBorder={isSticker}
|
||||
noBorder={false}
|
||||
curveTopLeft={curveTopLeft}
|
||||
curveBottomLeft={curveBottomLeft}
|
||||
playIconOverlay={isVideoAttachment(attachments[0])}
|
||||
|
@ -118,7 +118,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
alt={getAlt(attachments[1], i18n)}
|
||||
i18n={i18n}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
noBorder={isSticker}
|
||||
noBorder={false}
|
||||
curveTopRight={curveTopRight}
|
||||
curveBottomRight={curveBottomRight}
|
||||
playIconOverlay={isVideoAttachment(attachments[1])}
|
||||
|
@ -140,7 +140,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
alt={getAlt(attachments[0], i18n)}
|
||||
i18n={i18n}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
noBorder={isSticker}
|
||||
noBorder={false}
|
||||
curveTopLeft={curveTopLeft}
|
||||
curveBottomLeft={curveBottomLeft}
|
||||
attachment={attachments[0]}
|
||||
|
@ -168,7 +168,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
alt={getAlt(attachments[2], i18n)}
|
||||
i18n={i18n}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
noBorder={isSticker}
|
||||
noBorder={false}
|
||||
curveBottomRight={curveBottomRight}
|
||||
height={99}
|
||||
width={99}
|
||||
|
@ -192,6 +192,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
alt={getAlt(attachments[0], i18n)}
|
||||
i18n={i18n}
|
||||
curveTopLeft={curveTopLeft}
|
||||
noBorder={false}
|
||||
attachment={attachments[0]}
|
||||
playIconOverlay={isVideoAttachment(attachments[0])}
|
||||
height={149}
|
||||
|
@ -205,6 +206,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
i18n={i18n}
|
||||
curveTopRight={curveTopRight}
|
||||
playIconOverlay={isVideoAttachment(attachments[1])}
|
||||
noBorder={false}
|
||||
height={149}
|
||||
width={149}
|
||||
attachment={attachments[1]}
|
||||
|
@ -218,7 +220,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
alt={getAlt(attachments[2], i18n)}
|
||||
i18n={i18n}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
noBorder={isSticker}
|
||||
noBorder={false}
|
||||
curveBottomLeft={curveBottomLeft}
|
||||
playIconOverlay={isVideoAttachment(attachments[2])}
|
||||
height={149}
|
||||
|
@ -232,7 +234,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
alt={getAlt(attachments[3], i18n)}
|
||||
i18n={i18n}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
noBorder={isSticker}
|
||||
noBorder={false}
|
||||
curveBottomRight={curveBottomRight}
|
||||
playIconOverlay={isVideoAttachment(attachments[3])}
|
||||
height={149}
|
||||
|
|
52
ts/components/conversation/InlineNotificationWrapper.tsx
Normal file
52
ts/components/conversation/InlineNotificationWrapper.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import React from 'react';
|
||||
|
||||
export type PropsType = {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
isSelected: boolean;
|
||||
selectMessage: (messageId: string, conversationId: string) => unknown;
|
||||
};
|
||||
|
||||
export class InlineNotificationWrapper extends React.Component<PropsType> {
|
||||
public focusRef: React.RefObject<HTMLDivElement> = React.createRef();
|
||||
|
||||
public setFocus = () => {
|
||||
if (this.focusRef.current) {
|
||||
this.focusRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
public setSelected = () => {
|
||||
const { id, conversationId, selectMessage } = this.props;
|
||||
|
||||
selectMessage(id, conversationId);
|
||||
};
|
||||
|
||||
public componentDidMount() {
|
||||
const { isSelected } = this.props;
|
||||
if (isSelected) {
|
||||
this.setFocus();
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: PropsType) {
|
||||
if (!prevProps.isSelected && this.props.isSelected) {
|
||||
this.setFocus();
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { children } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="module-inline-notification-wrapper"
|
||||
tabIndex={0}
|
||||
ref={this.focusRef}
|
||||
onFocus={this.setSelected}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -40,7 +40,6 @@ interface Trigger {
|
|||
// Same as MIN_WIDTH in ImageGrid.tsx
|
||||
const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200;
|
||||
const STICKER_SIZE = 128;
|
||||
const SELECTED_TIMEOUT = 1000;
|
||||
|
||||
interface LinkPreviewType {
|
||||
title: string;
|
||||
|
@ -52,11 +51,11 @@ interface LinkPreviewType {
|
|||
|
||||
export type PropsData = {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
text?: string;
|
||||
textPending?: boolean;
|
||||
isSticker: boolean;
|
||||
isSelected: boolean;
|
||||
isSelectedCounter: number;
|
||||
direction: 'incoming' | 'outgoing';
|
||||
timestamp: number;
|
||||
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
|
||||
|
@ -131,6 +130,7 @@ export type PropsActions = {
|
|||
sentAt: number;
|
||||
}
|
||||
) => void;
|
||||
selectMessage: (messageId: string, conversationId: string) => unknown;
|
||||
};
|
||||
|
||||
export type Props = PropsData & PropsHousekeeping & PropsActions;
|
||||
|
@ -139,58 +139,64 @@ interface State {
|
|||
expiring: boolean;
|
||||
expired: boolean;
|
||||
imageBroken: boolean;
|
||||
|
||||
isSelected: boolean;
|
||||
prevSelectedCounter: number;
|
||||
}
|
||||
|
||||
const EXPIRATION_CHECK_MINIMUM = 2000;
|
||||
const EXPIRED_DELAY = 600;
|
||||
|
||||
export class Message extends React.PureComponent<Props, State> {
|
||||
public captureMenuTriggerBound: (trigger: any) => void;
|
||||
public showMenuBound: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
public handleImageErrorBound: () => void;
|
||||
|
||||
public menuTriggerRef: Trigger | undefined;
|
||||
public focusRef: React.RefObject<HTMLDivElement> = React.createRef();
|
||||
public audioRef: React.RefObject<HTMLAudioElement> = React.createRef();
|
||||
|
||||
public state = {
|
||||
expiring: false,
|
||||
expired: false,
|
||||
imageBroken: false,
|
||||
};
|
||||
|
||||
public expirationCheckInterval: any;
|
||||
public expiredTimeout: any;
|
||||
public selectedTimeout: any;
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
public captureMenuTrigger = (triggerRef: Trigger) => {
|
||||
this.menuTriggerRef = triggerRef;
|
||||
};
|
||||
|
||||
this.captureMenuTriggerBound = this.captureMenuTrigger.bind(this);
|
||||
this.showMenuBound = this.showMenu.bind(this);
|
||||
this.handleImageErrorBound = this.handleImageError.bind(this);
|
||||
|
||||
this.state = {
|
||||
expiring: false,
|
||||
expired: false,
|
||||
imageBroken: false,
|
||||
|
||||
isSelected: props.isSelected,
|
||||
prevSelectedCounter: props.isSelectedCounter,
|
||||
};
|
||||
}
|
||||
|
||||
public static getDerivedStateFromProps(props: Props, state: State): State {
|
||||
if (
|
||||
props.isSelected &&
|
||||
props.isSelectedCounter !== state.prevSelectedCounter
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
isSelected: props.isSelected,
|
||||
prevSelectedCounter: props.isSelectedCounter,
|
||||
};
|
||||
public showMenu = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (this.menuTriggerRef) {
|
||||
this.menuTriggerRef.handleContextClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
return state;
|
||||
}
|
||||
public handleImageError = () => {
|
||||
const { id } = this.props;
|
||||
// tslint:disable-next-line no-console
|
||||
console.log(
|
||||
`Message ${id}: Image failed to load; failing over to placeholder`
|
||||
);
|
||||
this.setState({
|
||||
imageBroken: true,
|
||||
});
|
||||
};
|
||||
|
||||
public setSelected = () => {
|
||||
const { id, conversationId, selectMessage } = this.props;
|
||||
|
||||
selectMessage(id, conversationId);
|
||||
};
|
||||
|
||||
public setFocus = () => {
|
||||
if (this.focusRef.current) {
|
||||
this.focusRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
public componentDidMount() {
|
||||
this.startSelectedTimer();
|
||||
const { isSelected } = this.props;
|
||||
if (isSelected) {
|
||||
this.setFocus();
|
||||
}
|
||||
|
||||
const { expirationLength } = this.props;
|
||||
if (!expirationLength) {
|
||||
|
@ -219,27 +225,14 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
}
|
||||
}
|
||||
|
||||
public componentDidUpdate() {
|
||||
this.startSelectedTimer();
|
||||
public componentDidUpdate(prevProps: Props) {
|
||||
if (!prevProps.isSelected && this.props.isSelected) {
|
||||
this.setFocus();
|
||||
}
|
||||
|
||||
this.checkExpired();
|
||||
}
|
||||
|
||||
public startSelectedTimer() {
|
||||
const { isSelected } = this.state;
|
||||
if (!isSelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.selectedTimeout) {
|
||||
this.selectedTimeout = setTimeout(() => {
|
||||
this.selectedTimeout = undefined;
|
||||
this.setState({ isSelected: false });
|
||||
this.props.clearSelectedMessage();
|
||||
}, SELECTED_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
public checkExpired() {
|
||||
const now = Date.now();
|
||||
const { isExpired, expirationTimestamp, expirationLength } = this.props;
|
||||
|
@ -265,14 +258,6 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
}
|
||||
}
|
||||
|
||||
public handleImageError() {
|
||||
// tslint:disable-next-line no-console
|
||||
console.log('Message: Image failed to load; failing over to placeholder');
|
||||
this.setState({
|
||||
imageBroken: true,
|
||||
});
|
||||
}
|
||||
|
||||
public renderMetadata() {
|
||||
const {
|
||||
collapseMetadata,
|
||||
|
@ -427,7 +412,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
isSticker,
|
||||
text,
|
||||
} = this.props;
|
||||
const { imageBroken, isSelected } = this.state;
|
||||
const { imageBroken } = this.state;
|
||||
|
||||
if (!attachments || !attachments[0]) {
|
||||
return null;
|
||||
|
@ -449,6 +434,8 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
) {
|
||||
const prefix = isSticker ? 'sticker' : 'attachment';
|
||||
const bottomOverlay = !isSticker && !collapseMetadata;
|
||||
// We only want users to tab into this if there's more than one
|
||||
const tabIndex = attachments.length > 1 ? 0 : -1;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -470,11 +457,11 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
withContentAbove={isSticker || withContentAbove}
|
||||
withContentBelow={isSticker || withContentBelow}
|
||||
isSticker={isSticker}
|
||||
isSelected={isSticker && isSelected}
|
||||
stickerSize={STICKER_SIZE}
|
||||
bottomOverlay={bottomOverlay}
|
||||
i18n={i18n}
|
||||
onError={this.handleImageErrorBound}
|
||||
onError={this.handleImageError}
|
||||
tabIndex={tabIndex}
|
||||
onClick={attachment => {
|
||||
showVisualAttachment({ attachment, messageId: id });
|
||||
}}
|
||||
|
@ -484,6 +471,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
} else if (!firstAttachment.pending && isAudio(attachments)) {
|
||||
return (
|
||||
<audio
|
||||
ref={this.audioRef}
|
||||
controls={true}
|
||||
className={classNames(
|
||||
'module-message__audio-attachment',
|
||||
|
@ -505,7 +493,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
const isDangerous = isFileDangerous(fileName || '');
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
className={classNames(
|
||||
'module-message__generic-attachment',
|
||||
withContentBelow
|
||||
|
@ -515,6 +503,14 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
? 'module-message__generic-attachment--with-content-above'
|
||||
: null
|
||||
)}
|
||||
// There's only ever one of these, so we don't want users to tab into it
|
||||
tabIndex={-1}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
this.openGenericAttachment();
|
||||
}}
|
||||
>
|
||||
{pending ? (
|
||||
<div className="module-message__generic-attachment__spinner-container">
|
||||
|
@ -554,7 +550,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
{fileSize}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -597,15 +593,25 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
<button
|
||||
className={classNames(
|
||||
'module-message__link-preview',
|
||||
withContentAbove
|
||||
? 'module-message__link-preview--with-content-above'
|
||||
: null
|
||||
)}
|
||||
onClick={() => {
|
||||
onKeyDown={(event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === 'Space') {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
openLink(first.url);
|
||||
}
|
||||
}}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
openLink(first.url);
|
||||
}}
|
||||
>
|
||||
|
@ -614,7 +620,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
attachments={[first.image]}
|
||||
withContentAbove={withContentAbove}
|
||||
withContentBelow={true}
|
||||
onError={this.handleImageErrorBound}
|
||||
onError={this.handleImageError}
|
||||
i18n={i18n}
|
||||
/>
|
||||
) : null}
|
||||
|
@ -638,7 +644,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
width={72}
|
||||
url={first.image.url}
|
||||
attachment={first.image}
|
||||
onError={this.handleImageErrorBound}
|
||||
onError={this.handleImageError}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</div>
|
||||
|
@ -659,7 +665,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -751,8 +757,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
<button
|
||||
onClick={() => {
|
||||
if (contact.signalAccount) {
|
||||
openConversation(contact.signalAccount);
|
||||
|
@ -761,7 +766,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
className="module-message__send-message-button"
|
||||
>
|
||||
{i18n('sendMessageToContact')}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -853,35 +858,21 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
public captureMenuTrigger(triggerRef: Trigger) {
|
||||
this.menuTriggerRef = triggerRef;
|
||||
}
|
||||
public showMenu(event: React.MouseEvent<HTMLDivElement>) {
|
||||
if (this.menuTriggerRef) {
|
||||
this.menuTriggerRef.handleContextClick(event);
|
||||
}
|
||||
}
|
||||
|
||||
public renderMenu(isCorrectSide: boolean, triggerId: string) {
|
||||
const {
|
||||
attachments,
|
||||
direction,
|
||||
disableMenu,
|
||||
downloadAttachment,
|
||||
id,
|
||||
isSticker,
|
||||
isTapToView,
|
||||
replyToMessage,
|
||||
timestamp,
|
||||
} = this.props;
|
||||
|
||||
if (!isCorrectSide || disableMenu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileName =
|
||||
attachments && attachments[0] ? attachments[0].fileName : null;
|
||||
const isDangerous = isFileDangerous(fileName || '');
|
||||
const multipleAttachments = attachments && attachments.length > 1;
|
||||
const firstAttachment = attachments && attachments[0];
|
||||
|
||||
|
@ -892,13 +883,8 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
firstAttachment &&
|
||||
!firstAttachment.pending ? (
|
||||
<div
|
||||
onClick={() => {
|
||||
downloadAttachment({
|
||||
isDangerous,
|
||||
attachment: firstAttachment,
|
||||
timestamp,
|
||||
});
|
||||
}}
|
||||
onClick={this.openGenericAttachment}
|
||||
// This a menu meant for mouse use only
|
||||
role="button"
|
||||
className={classNames(
|
||||
'module-message__buttons__download',
|
||||
|
@ -909,9 +895,13 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
|
||||
const replyButton = (
|
||||
<div
|
||||
onClick={() => {
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
replyToMessage(id);
|
||||
}}
|
||||
// This a menu meant for mouse use only
|
||||
role="button"
|
||||
className={classNames(
|
||||
'module-message__buttons__reply',
|
||||
|
@ -921,10 +911,11 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
);
|
||||
|
||||
const menuButton = (
|
||||
<ContextMenuTrigger id={triggerId} ref={this.captureMenuTriggerBound}>
|
||||
<ContextMenuTrigger id={triggerId} ref={this.captureMenuTrigger as any}>
|
||||
<div
|
||||
// This a menu meant for mouse use only
|
||||
role="button"
|
||||
onClick={this.showMenuBound}
|
||||
onClick={this.showMenu}
|
||||
className={classNames(
|
||||
'module-message__buttons__menu',
|
||||
`module-message__buttons__download--${direction}`
|
||||
|
@ -955,7 +946,6 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
attachments,
|
||||
deleteMessage,
|
||||
direction,
|
||||
downloadAttachment,
|
||||
i18n,
|
||||
id,
|
||||
isSticker,
|
||||
|
@ -964,13 +954,9 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
retrySend,
|
||||
showMessageDetail,
|
||||
status,
|
||||
timestamp,
|
||||
} = this.props;
|
||||
|
||||
const showRetry = status === 'error' && direction === 'outgoing';
|
||||
const fileName =
|
||||
attachments && attachments[0] ? attachments[0].fileName : null;
|
||||
const isDangerous = isFileDangerous(fileName || '');
|
||||
const multipleAttachments = attachments && attachments.length > 1;
|
||||
|
||||
const menu = (
|
||||
|
@ -984,13 +970,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
attributes={{
|
||||
className: 'module-message__context__download',
|
||||
}}
|
||||
onClick={() => {
|
||||
downloadAttachment({
|
||||
attachment: attachments[0],
|
||||
timestamp,
|
||||
isDangerous,
|
||||
});
|
||||
}}
|
||||
onClick={this.openGenericAttachment}
|
||||
>
|
||||
{i18n('downloadAttachment')}
|
||||
</MenuItem>
|
||||
|
@ -999,7 +979,10 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
attributes={{
|
||||
className: 'module-message__context__reply',
|
||||
}}
|
||||
onClick={() => {
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
replyToMessage(id);
|
||||
}}
|
||||
>
|
||||
|
@ -1009,7 +992,10 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
attributes={{
|
||||
className: 'module-message__context__more-info',
|
||||
}}
|
||||
onClick={() => {
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
showMessageDetail(id);
|
||||
}}
|
||||
>
|
||||
|
@ -1020,7 +1006,10 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
attributes={{
|
||||
className: 'module-message__context__retry-send',
|
||||
}}
|
||||
onClick={() => {
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
retrySend(id);
|
||||
}}
|
||||
>
|
||||
|
@ -1031,7 +1020,10 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
attributes={{
|
||||
className: 'module-message__context__delete-message',
|
||||
}}
|
||||
onClick={() => {
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
deleteMessage(id);
|
||||
}}
|
||||
>
|
||||
|
@ -1048,13 +1040,14 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
|
||||
if (attachments && attachments.length) {
|
||||
if (isSticker) {
|
||||
// Padding is 8px, on both sides
|
||||
return STICKER_SIZE + 8 * 2;
|
||||
// Padding is 8px, on both sides, plus two for 1px border
|
||||
return STICKER_SIZE + 8 * 2 + 2;
|
||||
}
|
||||
|
||||
const dimensions = getGridDimensions(attachments);
|
||||
if (dimensions) {
|
||||
return dimensions.width;
|
||||
// Add two for 1px border
|
||||
return dimensions.width + 2;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1074,7 +1067,8 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
) {
|
||||
const dimensions = getImageDimensions(first.image);
|
||||
if (dimensions) {
|
||||
return dimensions.width;
|
||||
// Add two for 1px border
|
||||
return dimensions.width + 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1249,25 +1243,188 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
}
|
||||
|
||||
// tslint:disable-next-line cyclomatic-complexity
|
||||
public render() {
|
||||
public handleOpen = (
|
||||
event: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent
|
||||
) => {
|
||||
const {
|
||||
authorPhoneNumber,
|
||||
authorColor,
|
||||
attachments,
|
||||
conversationType,
|
||||
direction,
|
||||
displayTapToViewMessage,
|
||||
id,
|
||||
isTapToView,
|
||||
isTapToViewExpired,
|
||||
showVisualAttachment,
|
||||
} = this.props;
|
||||
const { imageBroken } = this.state;
|
||||
|
||||
const isAttachmentPending = this.isAttachmentPending();
|
||||
|
||||
if (isTapToView) {
|
||||
if (!isTapToViewExpired && !isAttachmentPending) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
displayTapToViewMessage(id);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!imageBroken &&
|
||||
attachments &&
|
||||
attachments.length > 0 &&
|
||||
!isAttachmentPending &&
|
||||
canDisplayImage(attachments) &&
|
||||
((isImage(attachments) && hasImage(attachments)) ||
|
||||
(isVideo(attachments) && hasVideoScreenshot(attachments)))
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const attachment = attachments[0];
|
||||
|
||||
showVisualAttachment({ attachment, messageId: id });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
attachments &&
|
||||
attachments.length === 1 &&
|
||||
!isAttachmentPending &&
|
||||
!isAudio(attachments)
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.openGenericAttachment();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isAttachmentPending &&
|
||||
isAudio(attachments) &&
|
||||
this.audioRef &&
|
||||
this.audioRef.current
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.audioRef.current.paused) {
|
||||
// tslint:disable-next-line no-floating-promises
|
||||
this.audioRef.current.play();
|
||||
} else {
|
||||
// tslint:disable-next-line no-floating-promises
|
||||
this.audioRef.current.pause();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public openGenericAttachment = (event?: React.MouseEvent) => {
|
||||
const { attachments, downloadAttachment, timestamp } = this.props;
|
||||
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (!attachments || attachments.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attachment = attachments[0];
|
||||
const { fileName } = attachment;
|
||||
const isDangerous = isFileDangerous(fileName || '');
|
||||
|
||||
downloadAttachment({
|
||||
isDangerous,
|
||||
attachment,
|
||||
timestamp,
|
||||
});
|
||||
};
|
||||
|
||||
public handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Enter' && event.key !== 'Space') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleOpen(event);
|
||||
};
|
||||
|
||||
public handleClick = (event: React.MouseEvent) => {
|
||||
// We don't want clicks on body text to result in the 'default action' for the message
|
||||
const { text } = this.props;
|
||||
if (text && text.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleOpen(event);
|
||||
};
|
||||
|
||||
public renderContainer() {
|
||||
const {
|
||||
authorColor,
|
||||
direction,
|
||||
isSelected,
|
||||
isSticker,
|
||||
isTapToView,
|
||||
isTapToViewExpired,
|
||||
isTapToViewError,
|
||||
timestamp,
|
||||
} = this.props;
|
||||
const { expired, expiring, imageBroken, isSelected } = this.state;
|
||||
|
||||
const isAttachmentPending = this.isAttachmentPending();
|
||||
const isButton = isTapToView && !isTapToViewExpired && !isAttachmentPending;
|
||||
|
||||
const width = this.getWidth();
|
||||
const isShowingImage = this.isShowingImage();
|
||||
|
||||
const containerClassnames = classNames(
|
||||
'module-message__container',
|
||||
isSelected && !isSticker ? 'module-message__container--selected' : null,
|
||||
isSticker ? 'module-message__container--with-sticker' : null,
|
||||
!isSticker ? `module-message__container--${direction}` : null,
|
||||
isTapToView ? 'module-message__container--with-tap-to-view' : null,
|
||||
isTapToView && isTapToViewExpired
|
||||
? 'module-message__container--with-tap-to-view-expired'
|
||||
: null,
|
||||
!isSticker && direction === 'incoming'
|
||||
? `module-message__container--incoming-${authorColor}`
|
||||
: null,
|
||||
isTapToView && isAttachmentPending && !isTapToViewExpired
|
||||
? 'module-message__container--with-tap-to-view-pending'
|
||||
: null,
|
||||
isTapToView && isAttachmentPending && !isTapToViewExpired
|
||||
? `module-message__container--${direction}-${authorColor}-tap-to-view-pending`
|
||||
: null,
|
||||
isTapToViewError
|
||||
? 'module-message__container--with-tap-to-view-error'
|
||||
: null
|
||||
);
|
||||
const containerStyles = {
|
||||
width: isShowingImage ? width : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={containerClassnames} style={containerStyles}>
|
||||
{this.renderAuthor()}
|
||||
{this.renderContents()}
|
||||
{this.renderAvatar()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// tslint:disable-next-line cyclomatic-complexity
|
||||
public render() {
|
||||
const {
|
||||
authorPhoneNumber,
|
||||
attachments,
|
||||
conversationType,
|
||||
direction,
|
||||
id,
|
||||
isSticker,
|
||||
timestamp,
|
||||
} = this.props;
|
||||
const { expired, expiring, imageBroken } = this.state;
|
||||
|
||||
// This id is what connects our triple-dot click with our associated pop-up menu.
|
||||
// It needs to be unique.
|
||||
|
@ -1281,11 +1438,6 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
return null;
|
||||
}
|
||||
|
||||
const width = this.getWidth();
|
||||
const isShowingImage = this.isShowingImage();
|
||||
const role = isButton ? 'button' : undefined;
|
||||
const onClick = isButton ? () => displayTapToViewMessage(id) : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
|
@ -1294,44 +1446,18 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
expiring ? 'module-message--expired' : null,
|
||||
conversationType === 'group' ? 'module-message--group' : null
|
||||
)}
|
||||
tabIndex={0}
|
||||
// We pretend to be a button because we sometimes contain buttons and a button
|
||||
// cannot be within another button
|
||||
role="button"
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onClick={this.handleClick}
|
||||
onFocus={this.setSelected}
|
||||
ref={this.focusRef}
|
||||
>
|
||||
{this.renderError(direction === 'incoming')}
|
||||
{this.renderMenu(direction === 'outgoing', triggerId)}
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__container',
|
||||
isSelected && !isSticker
|
||||
? 'module-message__container--selected'
|
||||
: null,
|
||||
isSticker ? 'module-message__container--with-sticker' : null,
|
||||
!isSticker ? `module-message__container--${direction}` : null,
|
||||
isTapToView ? 'module-message__container--with-tap-to-view' : null,
|
||||
isTapToView && isTapToViewExpired
|
||||
? 'module-message__container--with-tap-to-view-expired'
|
||||
: null,
|
||||
!isSticker && direction === 'incoming'
|
||||
? `module-message__container--incoming-${authorColor}`
|
||||
: null,
|
||||
isTapToView && isAttachmentPending && !isTapToViewExpired
|
||||
? 'module-message__container--with-tap-to-view-pending'
|
||||
: null,
|
||||
isTapToView && isAttachmentPending && !isTapToViewExpired
|
||||
? `module-message__container--${direction}-${authorColor}-tap-to-view-pending`
|
||||
: null,
|
||||
isTapToViewError
|
||||
? 'module-message__container--with-tap-to-view-error'
|
||||
: null
|
||||
)}
|
||||
style={{
|
||||
width: isShowingImage ? width : undefined,
|
||||
}}
|
||||
role={role}
|
||||
onClick={onClick}
|
||||
>
|
||||
{this.renderAuthor()}
|
||||
{this.renderContents()}
|
||||
{this.renderAvatar()}
|
||||
</div>
|
||||
{this.renderContainer()}
|
||||
{this.renderError(direction === 'outgoing')}
|
||||
{this.renderMenu(direction === 'incoming', triggerId)}
|
||||
{this.renderContextMenu(triggerId)}
|
||||
|
|
|
@ -35,6 +35,23 @@ interface Props {
|
|||
}
|
||||
|
||||
export class MessageDetail extends React.Component<Props> {
|
||||
private readonly focusRef: React.RefObject<HTMLDivElement>;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.focusRef = React.createRef();
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
// When this component is created, it's initially not part of the DOM, and then it's
|
||||
// added off-screen and animated in. This ensures that the focus takes.
|
||||
setTimeout(() => {
|
||||
if (this.focusRef.current) {
|
||||
this.focusRef.current.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public renderAvatar(contact: Contact) {
|
||||
const { i18n } = this.props;
|
||||
const { avatarPath, color, phoneNumber, name, profileName } = contact;
|
||||
|
@ -144,7 +161,7 @@ export class MessageDetail extends React.Component<Props> {
|
|||
const { errors, message, receivedAt, sentAt, i18n } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-message-detail">
|
||||
<div className="module-message-detail" tabIndex={0} ref={this.focusRef}>
|
||||
<div className="module-message-detail__message-container">
|
||||
<Message i18n={i18n} {...message} />
|
||||
</div>
|
||||
|
|
|
@ -90,25 +90,36 @@ function getTypeLabel({
|
|||
}
|
||||
|
||||
export class Quote extends React.Component<Props, State> {
|
||||
public handleImageErrorBound: () => void;
|
||||
public state = {
|
||||
imageBroken: false,
|
||||
};
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
public handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
const { onClick } = this.props;
|
||||
|
||||
this.handleImageErrorBound = this.handleImageError.bind(this);
|
||||
// This is important to ensure that using this quote to navigate to the referenced
|
||||
// message doesn't also trigger its parent message's keydown.
|
||||
if (onClick && (event.key === 'Enter' || event.key === 'Space')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
this.state = {
|
||||
imageBroken: false,
|
||||
};
|
||||
}
|
||||
// We prevent this from bubbling to prevent the focus flash around a message when
|
||||
// you click a quote.
|
||||
public handleMouseDown = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
public handleImageError() {
|
||||
public handleImageError = () => {
|
||||
// tslint:disable-next-line no-console
|
||||
console.log('Message: Image failed to load; failing over to placeholder');
|
||||
this.setState({
|
||||
imageBroken: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
public renderImage(url: string, i18n: LocalizerType, icon?: string) {
|
||||
const iconElement = icon ? (
|
||||
|
@ -129,7 +140,7 @@ export class Quote extends React.Component<Props, State> {
|
|||
<img
|
||||
src={url}
|
||||
alt={i18n('quoteThumbnailAlt')}
|
||||
onError={this.handleImageErrorBound}
|
||||
onError={this.handleImageError}
|
||||
/>
|
||||
{iconElement}
|
||||
</div>
|
||||
|
@ -264,6 +275,8 @@ export class Quote extends React.Component<Props, State> {
|
|||
// propagation before handing control to the caller's callback.
|
||||
const onClick = (e: React.MouseEvent<{}>): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
@ -271,8 +284,10 @@ export class Quote extends React.Component<Props, State> {
|
|||
return (
|
||||
<div className="module-quote__close-container">
|
||||
<div
|
||||
className="module-quote__close-button"
|
||||
tabIndex={0}
|
||||
// We can't be a button because the overall quote is a button; can't nest them
|
||||
role="button"
|
||||
className="module-quote__close-button"
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
|
@ -365,9 +380,10 @@ export class Quote extends React.Component<Props, State> {
|
|||
withContentAbove ? 'module-quote-container--with-content-above' : null
|
||||
)}
|
||||
>
|
||||
<div
|
||||
<button
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
className={classNames(
|
||||
'module-quote',
|
||||
isIncoming ? 'module-quote--incoming' : 'module-quote--outgoing',
|
||||
|
@ -388,7 +404,7 @@ export class Quote extends React.Component<Props, State> {
|
|||
</div>
|
||||
{this.renderIconContainer()}
|
||||
{this.renderClose()}
|
||||
</div>
|
||||
</button>
|
||||
{this.renderReferenceWarning()}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -55,15 +55,14 @@ export class SafetyNumberNotification extends React.Component<Props> {
|
|||
i18n={i18n}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
<button
|
||||
onClick={() => {
|
||||
showIdentity(contact.id);
|
||||
}}
|
||||
className="module-safety-number-notification__button"
|
||||
>
|
||||
{i18n('verifyNewNumber')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -17,9 +17,8 @@ export class StagedGenericAttachment extends React.Component<Props> {
|
|||
|
||||
return (
|
||||
<div className="module-staged-generic-attachment">
|
||||
<div
|
||||
<button
|
||||
className="module-staged-generic-attachment__close-button"
|
||||
role="button"
|
||||
onClick={() => {
|
||||
if (onClose) {
|
||||
onClose(attachment);
|
||||
|
|
|
@ -53,8 +53,7 @@ export class StagedLinkPreview extends React.Component<Props> {
|
|||
<div className="module-staged-link-preview__location">{domain}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
role="button"
|
||||
<button
|
||||
className="module-staged-link-preview__close-button"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
|
|
@ -5,6 +5,9 @@ const attachment = {
|
|||
};
|
||||
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<StagedPlaceholderAttachment onClick={attachment => console.log('onClick')} />
|
||||
<StagedPlaceholderAttachment
|
||||
onClick={attachment => console.log('onClick')}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
|
|
@ -1,21 +1,23 @@
|
|||
import React from 'react';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
onClick: () => void;
|
||||
i18n: LocalizerType;
|
||||
}
|
||||
|
||||
export class StagedPlaceholderAttachment extends React.Component<Props> {
|
||||
public render() {
|
||||
const { onClick } = this.props;
|
||||
const { i18n, onClick } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
className="module-staged-placeholder-attachment"
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
title={i18n('add-image-attachment')}
|
||||
>
|
||||
<div className="module-staged-placeholder-attachment__plus-icon" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,10 +40,15 @@ type PropsHousekeepingType = {
|
|||
id: string;
|
||||
unreadCount?: number;
|
||||
typingContact?: Object;
|
||||
selectedMessageId?: string;
|
||||
|
||||
i18n: LocalizerType;
|
||||
|
||||
renderItem: (id: string, actions: Object) => JSX.Element;
|
||||
renderItem: (
|
||||
id: string,
|
||||
conversationId: string,
|
||||
actions: Object
|
||||
) => JSX.Element;
|
||||
renderLastSeenIndicator: (id: string) => JSX.Element;
|
||||
renderLoadingRow: (id: string) => JSX.Element;
|
||||
renderTypingBubble: (id: string) => JSX.Element;
|
||||
|
@ -60,8 +65,10 @@ type PropsActionsType = {
|
|||
loadAndScroll: (messageId: string) => unknown;
|
||||
loadOlderMessages: (messageId: string) => unknown;
|
||||
loadNewerMessages: (messageId: string) => unknown;
|
||||
loadNewestMessages: (messageId: string) => unknown;
|
||||
loadNewestMessages: (messageId: string, setFocus?: boolean) => unknown;
|
||||
markMessageRead: (messageId: string) => unknown;
|
||||
selectMessage: (messageId: string, conversationId: string) => unknown;
|
||||
clearSelectedMessage: () => unknown;
|
||||
} & MessageActionsType &
|
||||
SafetyNumberActionsType;
|
||||
|
||||
|
@ -547,7 +554,7 @@ export class Timeline extends React.PureComponent<Props, State> {
|
|||
style={styleWithWidth}
|
||||
role="row"
|
||||
>
|
||||
{renderItem(messageId, this.props)}
|
||||
{renderItem(messageId, id, this.props)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -662,7 +669,15 @@ export class Timeline extends React.PureComponent<Props, State> {
|
|||
}
|
||||
};
|
||||
|
||||
public scrollToBottom = () => {
|
||||
public scrollToBottom = (setFocus?: boolean) => {
|
||||
const { selectMessage, id, items } = this.props;
|
||||
|
||||
if (setFocus && items && items.length > 0) {
|
||||
const lastIndex = items.length - 1;
|
||||
const lastMessageId = items[lastIndex];
|
||||
selectMessage(lastMessageId, id);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
propScrollToIndex: undefined,
|
||||
oneTimeScrollRow: undefined,
|
||||
|
@ -671,20 +686,31 @@ export class Timeline extends React.PureComponent<Props, State> {
|
|||
};
|
||||
|
||||
public onClickScrollDownButton = () => {
|
||||
this.scrollDown(false);
|
||||
};
|
||||
|
||||
public scrollDown = (setFocus?: boolean) => {
|
||||
const {
|
||||
haveNewest,
|
||||
id,
|
||||
isLoadingMessages,
|
||||
items,
|
||||
loadNewestMessages,
|
||||
oldestUnreadIndex,
|
||||
selectMessage,
|
||||
} = this.props;
|
||||
if (!items || items.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastId = items[items.length - 1];
|
||||
const lastSeenIndicatorRow = this.getLastSeenIndicatorRow();
|
||||
|
||||
if (!this.visibleRows) {
|
||||
if (haveNewest) {
|
||||
this.scrollToBottom();
|
||||
this.scrollToBottom(setFocus);
|
||||
} else if (!isLoadingMessages) {
|
||||
loadNewestMessages(lastId);
|
||||
loadNewestMessages(lastId, setFocus);
|
||||
}
|
||||
|
||||
return;
|
||||
|
@ -697,13 +723,17 @@ export class Timeline extends React.PureComponent<Props, State> {
|
|||
isNumber(lastSeenIndicatorRow) &&
|
||||
newest.row < lastSeenIndicatorRow
|
||||
) {
|
||||
if (setFocus && isNumber(oldestUnreadIndex)) {
|
||||
const messageId = items[oldestUnreadIndex];
|
||||
selectMessage(messageId, id);
|
||||
}
|
||||
this.setState({
|
||||
oneTimeScrollRow: lastSeenIndicatorRow,
|
||||
});
|
||||
} else if (haveNewest) {
|
||||
this.scrollToBottom();
|
||||
this.scrollToBottom(setFocus);
|
||||
} else if (!isLoadingMessages) {
|
||||
loadNewestMessages(lastId);
|
||||
loadNewestMessages(lastId, setFocus);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -743,6 +773,10 @@ export class Timeline extends React.PureComponent<Props, State> {
|
|||
prevProps.items.length === 0 ||
|
||||
resetCounter !== prevProps.resetCounter
|
||||
) {
|
||||
if (prevProps.items && prevProps.items.length > 0) {
|
||||
this.resize();
|
||||
}
|
||||
|
||||
const oneTimeScrollRow = this.getLastSeenIndicatorRow();
|
||||
this.setState({
|
||||
oneTimeScrollRow,
|
||||
|
@ -751,10 +785,6 @@ export class Timeline extends React.PureComponent<Props, State> {
|
|||
prevPropScrollToIndex: scrollToIndex,
|
||||
});
|
||||
|
||||
if (prevProps.items && prevProps.items.length > 0) {
|
||||
this.resize();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -881,6 +911,93 @@ export class Timeline extends React.PureComponent<Props, State> {
|
|||
return scrollToBottom;
|
||||
};
|
||||
|
||||
public handleBlur = (event: React.FocusEvent) => {
|
||||
const { clearSelectedMessage } = this.props;
|
||||
|
||||
const { currentTarget } = event;
|
||||
|
||||
// Thanks to https://gist.github.com/pstoica/4323d3e6e37e8a23dd59
|
||||
setTimeout(() => {
|
||||
if (!currentTarget.contains(document.activeElement)) {
|
||||
clearSelectedMessage();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
public handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const { selectMessage, selectedMessageId, items, id } = this.props;
|
||||
const commandOrCtrl = event.metaKey || event.ctrlKey;
|
||||
|
||||
if (!items || items.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedMessageId && !commandOrCtrl && event.key === 'ArrowUp') {
|
||||
const selectedMessageIndex = items.findIndex(
|
||||
item => item === selectedMessageId
|
||||
);
|
||||
if (selectedMessageIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIndex = selectedMessageIndex - 1;
|
||||
if (targetIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = items[targetIndex];
|
||||
selectMessage(messageId, id);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedMessageId && !commandOrCtrl && event.key === 'ArrowDown') {
|
||||
const selectedMessageIndex = items.findIndex(
|
||||
item => item === selectedMessageId
|
||||
);
|
||||
if (selectedMessageIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIndex = selectedMessageIndex + 1;
|
||||
if (targetIndex >= items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = items[targetIndex];
|
||||
selectMessage(messageId, id);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (commandOrCtrl && event.key === 'ArrowUp') {
|
||||
this.setState({ oneTimeScrollRow: 0 });
|
||||
|
||||
const firstMessageId = items[0];
|
||||
selectMessage(firstMessageId, id);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (commandOrCtrl && event.key === 'ArrowDown') {
|
||||
this.scrollDown(true);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { i18n, id, items } = this.props;
|
||||
const {
|
||||
|
@ -896,7 +1013,13 @@ export class Timeline extends React.PureComponent<Props, State> {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="module-timeline">
|
||||
<div
|
||||
className="module-timeline"
|
||||
role="group"
|
||||
tabIndex={-1}
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => {
|
||||
if (this.mostRecentWidth && this.mostRecentWidth !== width) {
|
||||
|
@ -925,6 +1048,7 @@ export class Timeline extends React.PureComponent<Props, State> {
|
|||
rowRenderer={this.rowRenderer}
|
||||
scrollToAlignment="start"
|
||||
scrollToIndex={scrollToIndex}
|
||||
tabIndex={-1}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -6,6 +6,8 @@ import {
|
|||
PropsActions as MessageActionsType,
|
||||
PropsData as MessageProps,
|
||||
} from './Message';
|
||||
|
||||
import { InlineNotificationWrapper } from './InlineNotificationWrapper';
|
||||
import {
|
||||
PropsActions as UnsupportedMessageActionsType,
|
||||
PropsData as UnsupportedMessageProps,
|
||||
|
@ -67,27 +69,35 @@ export type TimelineItemType =
|
|||
| ResetSessionNotificationType
|
||||
| GroupNotificationType;
|
||||
|
||||
type PropsData = {
|
||||
type PropsLocalType = {
|
||||
conversationId: string;
|
||||
item?: TimelineItemType;
|
||||
};
|
||||
|
||||
type PropsHousekeeping = {
|
||||
id: string;
|
||||
isSelected: boolean;
|
||||
selectMessage: (messageId: string, conversationId: string) => unknown;
|
||||
i18n: LocalizerType;
|
||||
};
|
||||
|
||||
type PropsActions = MessageActionsType &
|
||||
type PropsActionsType = MessageActionsType &
|
||||
UnsupportedMessageActionsType &
|
||||
SafetyNumberActionsType;
|
||||
|
||||
type Props = PropsData & PropsHousekeeping & PropsActions;
|
||||
type PropsType = PropsLocalType & PropsActionsType;
|
||||
|
||||
export class TimelineItem extends React.PureComponent<Props> {
|
||||
export class TimelineItem extends React.PureComponent<PropsType> {
|
||||
public render() {
|
||||
const { item, i18n } = this.props;
|
||||
const {
|
||||
conversationId,
|
||||
id,
|
||||
isSelected,
|
||||
item,
|
||||
i18n,
|
||||
selectMessage,
|
||||
} = this.props;
|
||||
|
||||
if (!item) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn('TimelineItem: item provided was falsey');
|
||||
console.warn(`TimelineItem: item ${id} provided was falsey`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
@ -95,31 +105,46 @@ export class TimelineItem extends React.PureComponent<Props> {
|
|||
if (item.type === 'message') {
|
||||
return <Message {...this.props} {...item.data} i18n={i18n} />;
|
||||
}
|
||||
|
||||
let notification;
|
||||
|
||||
if (item.type === 'unsupportedMessage') {
|
||||
return <UnsupportedMessage {...this.props} {...item.data} i18n={i18n} />;
|
||||
}
|
||||
if (item.type === 'timerNotification') {
|
||||
return <TimerNotification {...this.props} {...item.data} i18n={i18n} />;
|
||||
}
|
||||
if (item.type === 'safetyNumberNotification') {
|
||||
return (
|
||||
notification = (
|
||||
<UnsupportedMessage {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'timerNotification') {
|
||||
notification = (
|
||||
<TimerNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'safetyNumberNotification') {
|
||||
notification = (
|
||||
<SafetyNumberNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
}
|
||||
if (item.type === 'verificationNotification') {
|
||||
return (
|
||||
} else if (item.type === 'verificationNotification') {
|
||||
notification = (
|
||||
<VerificationNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
}
|
||||
if (item.type === 'groupNotification') {
|
||||
return <GroupNotification {...this.props} {...item.data} i18n={i18n} />;
|
||||
}
|
||||
if (item.type === 'resetSessionNotification') {
|
||||
return (
|
||||
} else if (item.type === 'groupNotification') {
|
||||
notification = (
|
||||
<GroupNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'resetSessionNotification') {
|
||||
notification = (
|
||||
<ResetSessionNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else {
|
||||
throw new Error('TimelineItem: Unknown type!');
|
||||
}
|
||||
|
||||
throw new Error('TimelineItem: Unknown type!');
|
||||
return (
|
||||
<InlineNotificationWrapper
|
||||
id={id}
|
||||
conversationId={conversationId}
|
||||
isSelected={isSelected}
|
||||
selectMessage={selectMessage}
|
||||
>
|
||||
{notification}
|
||||
</InlineNotificationWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -71,15 +71,14 @@ export class UnsupportedMessage extends React.Component<Props> {
|
|||
/>
|
||||
</div>
|
||||
{canProcessNow ? null : (
|
||||
<div
|
||||
role="button"
|
||||
<button
|
||||
onClick={() => {
|
||||
downloadNewVersion();
|
||||
}}
|
||||
className="module-unsupported-message__button"
|
||||
>
|
||||
{i18n('Message--update-signal')}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -42,9 +42,8 @@ export class DocumentListItem extends React.Component<Props> {
|
|||
const { fileName, fileSize, timestamp } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
className="module-document-list-item__content"
|
||||
role="button"
|
||||
onClick={this.props.onClick}
|
||||
>
|
||||
<div className="module-document-list-item__icon" />
|
||||
|
@ -59,7 +58,7 @@ export class DocumentListItem extends React.Component<Props> {
|
|||
<div className="module-document-list-item__date">
|
||||
{moment(timestamp).format('ddd, MMM D, Y')}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ interface Props {
|
|||
documents: Array<MediaItemType>;
|
||||
i18n: LocalizerType;
|
||||
media: Array<MediaItemType>;
|
||||
|
||||
onItemClick?: (event: ItemClickEvent) => void;
|
||||
}
|
||||
|
||||
|
@ -61,15 +62,26 @@ const Tab = ({
|
|||
};
|
||||
|
||||
export class MediaGallery extends React.Component<Props, State> {
|
||||
public readonly focusRef: React.RefObject<HTMLDivElement> = React.createRef();
|
||||
public state: State = {
|
||||
selectedTab: 'media',
|
||||
};
|
||||
|
||||
public componentDidMount() {
|
||||
// When this component is created, it's initially not part of the DOM, and then it's
|
||||
// added off-screen and animated in. This ensures that the focus takes.
|
||||
setTimeout(() => {
|
||||
if (this.focusRef.current) {
|
||||
this.focusRef.current.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { selectedTab } = this.state;
|
||||
|
||||
return (
|
||||
<div className="module-media-gallery">
|
||||
<div className="module-media-gallery" tabIndex={0} ref={this.focusRef}>
|
||||
<div className="module-media-gallery__tab-container">
|
||||
<Tab
|
||||
label="Media"
|
||||
|
|
|
@ -109,13 +109,9 @@ export class MediaGridItem extends React.Component<Props, State> {
|
|||
|
||||
public render() {
|
||||
return (
|
||||
<div
|
||||
className="module-media-grid-item"
|
||||
role="button"
|
||||
onClick={this.props.onClick}
|
||||
>
|
||||
<button className="module-media-grid-item" onClick={this.props.onClick}>
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,15 +19,11 @@ export type OwnProps = {
|
|||
export type Props = OwnProps &
|
||||
Pick<
|
||||
EmojiPickerProps,
|
||||
| 'onClose'
|
||||
| 'doSend'
|
||||
| 'onPickEmoji'
|
||||
| 'onSetSkinTone'
|
||||
| 'recentEmojis'
|
||||
| 'skinTone'
|
||||
'doSend' | 'onPickEmoji' | 'onSetSkinTone' | 'recentEmojis' | 'skinTone'
|
||||
>;
|
||||
|
||||
export const EmojiButton = React.memo(
|
||||
// tslint:disable-next-line:max-func-body-length
|
||||
({
|
||||
i18n,
|
||||
doSend,
|
||||
|
@ -35,7 +31,6 @@ export const EmojiButton = React.memo(
|
|||
skinTone,
|
||||
onSetSkinTone,
|
||||
recentEmojis,
|
||||
onClose,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [popperRoot, setPopperRoot] = React.useState<HTMLElement | null>(
|
||||
|
@ -56,9 +51,8 @@ export const EmojiButton = React.memo(
|
|||
const handleClose = React.useCallback(
|
||||
() => {
|
||||
setOpen(false);
|
||||
onClose();
|
||||
},
|
||||
[setOpen, onClose]
|
||||
[setOpen]
|
||||
);
|
||||
|
||||
// Create popper root and handle outside clicks
|
||||
|
@ -71,7 +65,6 @@ export const EmojiButton = React.memo(
|
|||
const handleOutsideClick = ({ target }: MouseEvent) => {
|
||||
if (!root.contains(target as Node)) {
|
||||
setOpen(false);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleOutsideClick);
|
||||
|
@ -88,6 +81,29 @@ export const EmojiButton = React.memo(
|
|||
[open, setOpen, setPopperRoot]
|
||||
);
|
||||
|
||||
// Install keyboard shortcut to open emoji picker
|
||||
React.useEffect(
|
||||
() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
const { ctrlKey, key, metaKey, shiftKey } = event;
|
||||
const ctrlOrCommand = metaKey || ctrlKey;
|
||||
|
||||
if (ctrlOrCommand && shiftKey && key === 'e') {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
setOpen(!open);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
},
|
||||
[open, setOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<Manager>
|
||||
<Reference>
|
||||
|
|
|
@ -33,7 +33,7 @@ export type OwnProps = {
|
|||
|
||||
export type Props = OwnProps & Pick<React.HTMLProps<HTMLDivElement>, 'style'>;
|
||||
|
||||
function focusRef(el: HTMLElement | null) {
|
||||
function focusOnRender(el: HTMLElement | null) {
|
||||
if (el) {
|
||||
el.focus();
|
||||
}
|
||||
|
@ -69,6 +69,7 @@ export const EmojiPicker = React.memo(
|
|||
}: Props,
|
||||
ref
|
||||
) => {
|
||||
const focusRef = React.useRef<HTMLButtonElement>(null);
|
||||
// Per design: memoize the initial recent emojis so the grid only updates after re-opening the picker.
|
||||
const firstRecent = React.useMemo(() => {
|
||||
return recentEmojis;
|
||||
|
@ -140,11 +141,14 @@ export const EmojiPicker = React.memo(
|
|||
// Handle escape key
|
||||
React.useEffect(
|
||||
() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (searchMode && e.key === 'Escape') {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (searchMode && event.key === 'Escape') {
|
||||
setSearchText('');
|
||||
setSearchMode(false);
|
||||
setScrollToRow(0);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
} else if (
|
||||
!searchMode &&
|
||||
![
|
||||
|
@ -155,21 +159,38 @@ export const EmojiPicker = React.memo(
|
|||
'Shift',
|
||||
'Tab',
|
||||
' ', // Space
|
||||
].includes(e.key)
|
||||
].includes(event.key)
|
||||
) {
|
||||
onClose();
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keyup', handler);
|
||||
document.addEventListener('keydown', handler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keyup', handler);
|
||||
document.removeEventListener('keydown', handler);
|
||||
};
|
||||
},
|
||||
[onClose, searchMode]
|
||||
);
|
||||
|
||||
// Restore focus on teardown
|
||||
React.useEffect(() => {
|
||||
const lastFocused = document.activeElement as any;
|
||||
if (focusRef.current) {
|
||||
focusRef.current.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (lastFocused && lastFocused.focus) {
|
||||
lastFocused.focus();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const emojiGrid = React.useMemo(
|
||||
() => {
|
||||
if (searchText) {
|
||||
|
@ -287,6 +308,7 @@ export const EmojiPicker = React.memo(
|
|||
<div className="module-emoji-picker" ref={ref} style={style}>
|
||||
<header className="module-emoji-picker__header">
|
||||
<button
|
||||
ref={focusRef}
|
||||
onClick={handleToggleSearch}
|
||||
title={i18n('EmojiPicker--search-placeholder')}
|
||||
className={classNames(
|
||||
|
@ -300,7 +322,7 @@ export const EmojiPicker = React.memo(
|
|||
{searchMode ? (
|
||||
<div className="module-emoji-picker__header__search-field">
|
||||
<input
|
||||
ref={focusRef}
|
||||
ref={focusOnRender}
|
||||
className="module-emoji-picker__header__search-field__input"
|
||||
placeholder={i18n('EmojiPicker--search-placeholder')}
|
||||
onChange={handleSearchChange}
|
||||
|
|
|
@ -147,6 +147,29 @@ export const StickerButton = React.memo(
|
|||
[open, setOpen, setPopperRoot]
|
||||
);
|
||||
|
||||
// Install keyboard shortcut to open sticker picker
|
||||
React.useEffect(
|
||||
() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
const { ctrlKey, key, metaKey, shiftKey } = event;
|
||||
const ctrlOrCommand = metaKey || ctrlKey;
|
||||
|
||||
if (ctrlOrCommand && shiftKey && key === 's') {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
setOpen(!open);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
},
|
||||
[open, setOpen]
|
||||
);
|
||||
|
||||
// Clear the installed pack after one minute
|
||||
React.useEffect(
|
||||
() => {
|
||||
|
@ -192,11 +215,10 @@ export const StickerButton = React.memo(
|
|||
{!open && !showIntroduction && installedPack ? (
|
||||
<Popper placement={position} key={installedPack.id}>
|
||||
{({ ref, style, placement, arrowProps }) => (
|
||||
<div
|
||||
<button
|
||||
ref={ref}
|
||||
style={style}
|
||||
className="module-sticker-button__tooltip"
|
||||
role="button"
|
||||
onClick={clearInstalledStickerPack}
|
||||
>
|
||||
{installedPack.cover ? (
|
||||
|
@ -222,21 +244,20 @@ export const StickerButton = React.memo(
|
|||
`module-sticker-button__tooltip__triangle--${placement}`
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</Popper>
|
||||
) : null}
|
||||
{!open && showIntroduction ? (
|
||||
<Popper placement={position}>
|
||||
{({ ref, style, placement, arrowProps }) => (
|
||||
<div
|
||||
<button
|
||||
ref={ref}
|
||||
style={style}
|
||||
className={classNames(
|
||||
'module-sticker-button__tooltip',
|
||||
'module-sticker-button__tooltip--introduction'
|
||||
)}
|
||||
role="button"
|
||||
onClick={handleClearIntroduction}
|
||||
>
|
||||
{/* <div className="module-sticker-button__tooltip--introduction__image" /> */}
|
||||
|
@ -263,7 +284,7 @@ export const StickerButton = React.memo(
|
|||
`module-sticker-button__tooltip__triangle--${placement}`
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</Popper>
|
||||
) : null}
|
||||
|
|
|
@ -18,7 +18,14 @@ export type OwnProps = {
|
|||
|
||||
export type Props = OwnProps;
|
||||
|
||||
function focusOnRender(el: HTMLElement | null) {
|
||||
if (el) {
|
||||
el.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export const StickerManager = React.memo(
|
||||
// tslint:disable-next-line max-func-body-length
|
||||
({
|
||||
installedPacks,
|
||||
receivedPacks,
|
||||
|
@ -69,7 +76,11 @@ export const StickerManager = React.memo(
|
|||
uninstallStickerPack={uninstallStickerPack}
|
||||
/>
|
||||
) : null}
|
||||
<div className="module-sticker-manager">
|
||||
<div
|
||||
className="module-sticker-manager"
|
||||
tabIndex={-1}
|
||||
ref={focusOnRender}
|
||||
>
|
||||
{[
|
||||
{
|
||||
i18nKey: 'stickers--StickerManager--InstalledPacks',
|
||||
|
|
|
@ -65,9 +65,27 @@ export const StickerManagerPackRow = React.memo(
|
|||
[id, key, clearUninstalling]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (
|
||||
onClickPreview &&
|
||||
(event.key === 'Enter' || event.key === 'Space')
|
||||
) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
onClickPreview(pack);
|
||||
}
|
||||
},
|
||||
[onClickPreview, pack]
|
||||
);
|
||||
|
||||
const handleClickPreview = React.useCallback(
|
||||
() => {
|
||||
(event: React.MouseEvent) => {
|
||||
if (onClickPreview) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
onClickPreview(pack);
|
||||
}
|
||||
},
|
||||
|
@ -87,7 +105,10 @@ export const StickerManagerPackRow = React.memo(
|
|||
</ConfirmationModal>
|
||||
) : null}
|
||||
<div
|
||||
tabIndex={0}
|
||||
// This can't be a button because we have buttons as descendants
|
||||
role="button"
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClickPreview}
|
||||
className="module-sticker-manager__pack-row"
|
||||
>
|
||||
|
|
|
@ -68,6 +68,7 @@ export const StickerPicker = React.memo(
|
|||
}: Props,
|
||||
ref
|
||||
) => {
|
||||
const focusRef = React.useRef<HTMLButtonElement>(null);
|
||||
const tabIds = React.useMemo(
|
||||
() => ['recents', ...packs.map(({ id }) => id)],
|
||||
packs
|
||||
|
@ -84,6 +85,7 @@ export const StickerPicker = React.memo(
|
|||
} =
|
||||
selectedPack || {};
|
||||
|
||||
const [isUsingKeyboard, setIsUsingKeyboard] = React.useState(false);
|
||||
const [packsPage, setPacksPage] = React.useState(0);
|
||||
const onClickPrevPackPage = React.useCallback(
|
||||
() => {
|
||||
|
@ -101,22 +103,50 @@ export const StickerPicker = React.memo(
|
|||
// Handle escape key
|
||||
React.useEffect(
|
||||
() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Tab') {
|
||||
// We do NOT prevent default here to allow Tab to be used normally
|
||||
|
||||
setIsUsingKeyboard(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
onClose();
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keyup', handler);
|
||||
document.addEventListener('keydown', handler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keyup', handler);
|
||||
document.removeEventListener('keydown', handler);
|
||||
};
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
// Focus popup on after initial render, restore focus on teardown
|
||||
React.useEffect(() => {
|
||||
const lastFocused = document.activeElement as any;
|
||||
if (focusRef.current) {
|
||||
focusRef.current.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (lastFocused && lastFocused.focus) {
|
||||
lastFocused.focus();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isEmpty = stickers.length === 0;
|
||||
const addPackRef = isEmpty ? focusRef : undefined;
|
||||
const downloadError =
|
||||
selectedPack &&
|
||||
selectedPack.status === 'error' &&
|
||||
|
@ -186,7 +216,7 @@ export const StickerPicker = React.memo(
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
{packsPage > 0 ? (
|
||||
{!isUsingKeyboard && packsPage > 0 ? (
|
||||
<button
|
||||
className={classNames(
|
||||
'module-sticker-picker__header__button',
|
||||
|
@ -195,7 +225,7 @@ export const StickerPicker = React.memo(
|
|||
onClick={onClickPrevPackPage}
|
||||
/>
|
||||
) : null}
|
||||
{!isLastPacksPage(packsPage, packs.length) ? (
|
||||
{!isUsingKeyboard && !isLastPacksPage(packsPage, packs.length) ? (
|
||||
<button
|
||||
className={classNames(
|
||||
'module-sticker-picker__header__button',
|
||||
|
@ -206,6 +236,7 @@ export const StickerPicker = React.memo(
|
|||
) : null}
|
||||
</div>
|
||||
<button
|
||||
ref={addPackRef}
|
||||
className={classNames(
|
||||
'module-sticker-picker__header__button',
|
||||
'module-sticker-picker__header__button--add-pack',
|
||||
|
@ -274,19 +305,24 @@ export const StickerPicker = React.memo(
|
|||
'module-sticker-picker__body__content--under-long-text': showLongText,
|
||||
})}
|
||||
>
|
||||
{stickers.map(({ packId, id, url }) => (
|
||||
<button
|
||||
key={`${packId}-${id}`}
|
||||
className="module-sticker-picker__body__cell"
|
||||
onClick={() => onPickSticker(packId, id)}
|
||||
>
|
||||
<img
|
||||
className="module-sticker-picker__body__cell__image"
|
||||
src={url}
|
||||
alt={packTitle}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{stickers.map(({ packId, id, url }, index: number) => {
|
||||
const maybeFocusRef = index === 0 ? focusRef : undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={maybeFocusRef}
|
||||
key={`${packId}-${id}`}
|
||||
className="module-sticker-picker__body__cell"
|
||||
onClick={() => onPickSticker(packId, id)}
|
||||
>
|
||||
<img
|
||||
className="module-sticker-picker__body__cell__image"
|
||||
src={url}
|
||||
alt={packTitle}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{Array(pendingCount)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
|
|
|
@ -23,12 +23,6 @@ export type OwnProps = {
|
|||
|
||||
export type Props = OwnProps;
|
||||
|
||||
function focusRef(el: HTMLElement | null) {
|
||||
if (el) {
|
||||
el.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function renderBody({ pack, i18n }: Props) {
|
||||
if (pack && pack.status === 'error') {
|
||||
return (
|
||||
|
@ -80,9 +74,27 @@ export const StickerPreviewModal = React.memo(
|
|||
installStickerPack,
|
||||
uninstallStickerPack,
|
||||
} = props;
|
||||
const focusRef = React.useRef<HTMLButtonElement>(null);
|
||||
const [root, setRoot] = React.useState<HTMLElement | null>(null);
|
||||
const [confirmingUninstall, setConfirmingUninstall] = React.useState(false);
|
||||
|
||||
// Restore focus on teardown
|
||||
React.useEffect(
|
||||
() => {
|
||||
const lastFocused = document.activeElement as any;
|
||||
if (focusRef.current) {
|
||||
focusRef.current.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (lastFocused && lastFocused.focus) {
|
||||
lastFocused.focus();
|
||||
}
|
||||
};
|
||||
},
|
||||
[root]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
|
@ -148,10 +160,10 @@ export const StickerPreviewModal = React.memo(
|
|||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keyup', handler);
|
||||
document.addEventListener('keydown', handler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keyup', handler);
|
||||
document.removeEventListener('keydown', handler);
|
||||
};
|
||||
},
|
||||
[onClose]
|
||||
|
@ -169,6 +181,7 @@ export const StickerPreviewModal = React.memo(
|
|||
return root
|
||||
? createPortal(
|
||||
<div
|
||||
// Not really a button. Just a background which can be clicked to close modal
|
||||
role="button"
|
||||
className="module-sticker-manager__preview-modal__overlay"
|
||||
onClick={handleClickToClose}
|
||||
|
|
|
@ -130,7 +130,7 @@ type ConversationRemovedActionType = {
|
|||
id: string;
|
||||
};
|
||||
};
|
||||
type ConversationUnloadedActionType = {
|
||||
export type ConversationUnloadedActionType = {
|
||||
type: 'CONVERSATION_UNLOADED';
|
||||
payload: {
|
||||
id: string;
|
||||
|
@ -140,6 +140,13 @@ export type RemoveAllConversationsActionType = {
|
|||
type: 'CONVERSATIONS_REMOVE_ALL';
|
||||
payload: null;
|
||||
};
|
||||
export type MessageSelectedActionType = {
|
||||
type: 'MESSAGE_SELECTED';
|
||||
payload: {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
};
|
||||
};
|
||||
export type MessageChangedActionType = {
|
||||
type: 'MESSAGE_CHANGED';
|
||||
payload: {
|
||||
|
@ -228,7 +235,7 @@ type ShowInboxActionType = {
|
|||
type: 'SHOW_INBOX';
|
||||
payload: null;
|
||||
};
|
||||
type ShowArchivedConversationsActionType = {
|
||||
export type ShowArchivedConversationsActionType = {
|
||||
type: 'SHOW_ARCHIVED_CONVERSATIONS';
|
||||
payload: null;
|
||||
};
|
||||
|
@ -239,6 +246,7 @@ export type ConversationActionType =
|
|||
| ConversationRemovedActionType
|
||||
| ConversationUnloadedActionType
|
||||
| RemoveAllConversationsActionType
|
||||
| MessageSelectedActionType
|
||||
| MessageChangedActionType
|
||||
| MessageDeletedActionType
|
||||
| MessagesAddedActionType
|
||||
|
@ -264,6 +272,7 @@ export const actions = {
|
|||
conversationRemoved,
|
||||
conversationUnloaded,
|
||||
removeAllConversations,
|
||||
selectMessage,
|
||||
messageDeleted,
|
||||
messageChanged,
|
||||
messagesAdded,
|
||||
|
@ -328,6 +337,16 @@ function removeAllConversations(): RemoveAllConversationsActionType {
|
|||
};
|
||||
}
|
||||
|
||||
function selectMessage(messageId: string, conversationId: string) {
|
||||
return {
|
||||
type: 'MESSAGE_SELECTED',
|
||||
payload: {
|
||||
messageId,
|
||||
conversationId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function messageChanged(
|
||||
id: string,
|
||||
conversationId: string,
|
||||
|
@ -632,9 +651,14 @@ export function reducer(
|
|||
}
|
||||
|
||||
const { messageIds } = existingConversation;
|
||||
const selectedConversation =
|
||||
state.selectedConversation !== id
|
||||
? state.selectedConversation
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...state,
|
||||
selectedConversation,
|
||||
messagesLookup: omit(state.messagesLookup, messageIds),
|
||||
messagesByConversation: omit(state.messagesByConversation, [id]),
|
||||
};
|
||||
|
@ -642,6 +666,19 @@ export function reducer(
|
|||
if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
|
||||
return getEmptyState();
|
||||
}
|
||||
if (action.type === 'MESSAGE_SELECTED') {
|
||||
const { messageId, conversationId } = action.payload;
|
||||
|
||||
if (state.selectedConversation !== conversationId) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
selectedMessage: messageId,
|
||||
selectedMessageCounter: state.selectedMessageCounter + 1,
|
||||
};
|
||||
}
|
||||
if (action.type === 'MESSAGE_CHANGED') {
|
||||
const { id, conversationId, data } = action.payload;
|
||||
const existingConversation = state.messagesByConversation[conversationId];
|
||||
|
@ -712,7 +749,9 @@ export function reducer(
|
|||
[conversationId]: {
|
||||
isLoadingMessages: false,
|
||||
scrollToMessageId,
|
||||
scrollToMessageCounter: 0,
|
||||
scrollToMessageCounter: existingConversation
|
||||
? existingConversation.scrollToMessageCounter + 1
|
||||
: 0,
|
||||
messageIds,
|
||||
metrics,
|
||||
resetCounter,
|
||||
|
|
|
@ -12,10 +12,12 @@ import { makeLookup } from '../../util/makeLookup';
|
|||
|
||||
import {
|
||||
ConversationType,
|
||||
ConversationUnloadedActionType,
|
||||
MessageDeletedActionType,
|
||||
MessageType,
|
||||
RemoveAllConversationsActionType,
|
||||
SelectedConversationChangedActionType,
|
||||
ShowArchivedConversationsActionType,
|
||||
} from './conversations';
|
||||
|
||||
// State
|
||||
|
@ -29,6 +31,7 @@ export type MessageSearchResultLookupType = {
|
|||
};
|
||||
|
||||
export type SearchStateType = {
|
||||
startSearchCounter: number;
|
||||
searchConversationId?: string;
|
||||
searchConversationName?: string;
|
||||
// We store just ids of conversations, since that data is always cached in memory
|
||||
|
@ -81,6 +84,10 @@ type UpdateSearchTermActionType = {
|
|||
query: string;
|
||||
};
|
||||
};
|
||||
type StartSearchActionType = {
|
||||
type: 'SEARCH_START';
|
||||
payload: null;
|
||||
};
|
||||
type ClearSearchActionType = {
|
||||
type: 'SEARCH_CLEAR';
|
||||
payload: null;
|
||||
|
@ -103,18 +110,22 @@ export type SEARCH_TYPES =
|
|||
| SearchMessagesResultsFulfilledActionType
|
||||
| SearchDiscussionsResultsFulfilledActionType
|
||||
| UpdateSearchTermActionType
|
||||
| StartSearchActionType
|
||||
| ClearSearchActionType
|
||||
| ClearConversationSearchActionType
|
||||
| SearchInConversationActionType
|
||||
| MessageDeletedActionType
|
||||
| RemoveAllConversationsActionType
|
||||
| SelectedConversationChangedActionType;
|
||||
| SelectedConversationChangedActionType
|
||||
| ShowArchivedConversationsActionType
|
||||
| ConversationUnloadedActionType;
|
||||
|
||||
// Action Creators
|
||||
|
||||
export const actions = {
|
||||
searchMessages,
|
||||
searchDiscussions,
|
||||
startSearch,
|
||||
clearSearch,
|
||||
clearConversationSearch,
|
||||
searchInConversation,
|
||||
|
@ -188,7 +199,12 @@ async function doSearchDiscussions(
|
|||
query,
|
||||
};
|
||||
}
|
||||
|
||||
function startSearch(): StartSearchActionType {
|
||||
return {
|
||||
type: 'SEARCH_START',
|
||||
payload: null,
|
||||
};
|
||||
}
|
||||
function clearSearch(): ClearSearchActionType {
|
||||
return {
|
||||
type: 'SEARCH_CLEAR',
|
||||
|
@ -294,6 +310,7 @@ async function queryConversationsAndContacts(
|
|||
|
||||
function getEmptyState(): SearchStateType {
|
||||
return {
|
||||
startSearchCounter: 0,
|
||||
query: '',
|
||||
messageIds: [],
|
||||
messageLookup: {},
|
||||
|
@ -304,11 +321,24 @@ function getEmptyState(): SearchStateType {
|
|||
};
|
||||
}
|
||||
|
||||
// tslint:disable-next-line max-func-body-length
|
||||
// tslint:disable-next-line cyclomatic-complexity max-func-body-length
|
||||
export function reducer(
|
||||
state: SearchStateType = getEmptyState(),
|
||||
action: SEARCH_TYPES
|
||||
): SearchStateType {
|
||||
if (action.type === 'SHOW_ARCHIVED_CONVERSATIONS') {
|
||||
return getEmptyState();
|
||||
}
|
||||
|
||||
if (action.type === 'SEARCH_START') {
|
||||
return {
|
||||
...state,
|
||||
searchConversationId: undefined,
|
||||
searchConversationName: undefined,
|
||||
startSearchCounter: state.startSearchCounter + 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'SEARCH_CLEAR') {
|
||||
return getEmptyState();
|
||||
}
|
||||
|
@ -341,13 +371,17 @@ export function reducer(
|
|||
const { searchConversationId, searchConversationName } = payload;
|
||||
|
||||
if (searchConversationId === state.searchConversationId) {
|
||||
return state;
|
||||
return {
|
||||
...state,
|
||||
startSearchCounter: state.startSearchCounter + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...getEmptyState(),
|
||||
searchConversationId,
|
||||
searchConversationName,
|
||||
startSearchCounter: state.startSearchCounter + 1,
|
||||
};
|
||||
}
|
||||
if (action.type === 'CLEAR_CONVERSATION_SEARCH') {
|
||||
|
@ -412,6 +446,18 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
|
||||
if (action.type === 'CONVERSATION_UNLOADED') {
|
||||
const { payload } = action;
|
||||
const { id } = payload;
|
||||
const { searchConversationId } = state;
|
||||
|
||||
if (searchConversationId && searchConversationId === id) {
|
||||
return getEmptyState();
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
if (action.type === 'MESSAGE_DELETED') {
|
||||
const { messageIds, messageLookup } = state;
|
||||
if (!messageIds || messageIds.length < 1) {
|
||||
|
|
|
@ -8,6 +8,7 @@ export type UserStateType = {
|
|||
stickersPath: string;
|
||||
tempPath: string;
|
||||
ourNumber: string;
|
||||
platform: string;
|
||||
regionCode: string;
|
||||
i18n: LocalizerType;
|
||||
};
|
||||
|
@ -49,6 +50,7 @@ function getEmptyState(): UserStateType {
|
|||
tempPath: 'missing',
|
||||
ourNumber: 'missing',
|
||||
regionCode: 'missing',
|
||||
platform: 'missing',
|
||||
i18n: () => 'missing',
|
||||
};
|
||||
}
|
||||
|
|
16
ts/state/roots/createShortcutGuideModal.tsx
Normal file
16
ts/state/roots/createShortcutGuideModal.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { Store } from 'redux';
|
||||
|
||||
import { SmartShortcutGuideModal } from '../smart/ShortcutGuideModal';
|
||||
|
||||
// Workaround: A react component's required properties are filtering up through connect()
|
||||
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
|
||||
const FilteredShortcutGuideModal = SmartShortcutGuideModal as any;
|
||||
|
||||
export const createShortcutGuideModal = (store: Store, props: Object) => (
|
||||
<Provider store={store}>
|
||||
<FilteredShortcutGuideModal {...props} />
|
||||
</Provider>
|
||||
);
|
|
@ -50,6 +50,11 @@ export const getSearchConversationName = createSelector(
|
|||
(state: SearchStateType): string | undefined => state.searchConversationName
|
||||
);
|
||||
|
||||
export const getStartSearchCounter = createSelector(
|
||||
getSearch,
|
||||
(state: SearchStateType): number => state.startSearchCounter
|
||||
);
|
||||
|
||||
export const isSearching = createSelector(
|
||||
getSearch,
|
||||
(state: SearchStateType) => {
|
||||
|
@ -64,12 +69,19 @@ export const getMessageSearchResultLookup = createSelector(
|
|||
(state: SearchStateType) => state.messageLookup
|
||||
);
|
||||
export const getSearchResults = createSelector(
|
||||
[getSearch, getRegionCode, getConversationLookup, getSelectedConversation],
|
||||
[
|
||||
getSearch,
|
||||
getRegionCode,
|
||||
getConversationLookup,
|
||||
getSelectedConversation,
|
||||
getSelectedMessage,
|
||||
],
|
||||
(
|
||||
state: SearchStateType,
|
||||
regionCode: string,
|
||||
lookup: ConversationLookupType,
|
||||
selectedConversation?: string
|
||||
selectedConversationId?: string,
|
||||
selectedMessageId?: string
|
||||
// tslint:disable-next-line max-func-body-length
|
||||
): SearchResultsPropsType | undefined => {
|
||||
const {
|
||||
|
@ -115,7 +127,7 @@ export const getSearchResults = createSelector(
|
|||
type: 'conversation',
|
||||
data: {
|
||||
...data,
|
||||
isSelected: Boolean(data && id === selectedConversation),
|
||||
isSelected: Boolean(data && id === selectedConversationId),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -141,7 +153,7 @@ export const getSearchResults = createSelector(
|
|||
type: 'contact',
|
||||
data: {
|
||||
...data,
|
||||
isSelected: Boolean(data && id === selectedConversation),
|
||||
isSelected: Boolean(data && id === selectedConversationId),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -177,6 +189,8 @@ export const getSearchResults = createSelector(
|
|||
regionCode: regionCode,
|
||||
searchConversationName,
|
||||
searchTerm: state.query,
|
||||
selectedConversationId,
|
||||
selectedMessageId,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
@ -32,6 +32,11 @@ export const getStickersPath = createSelector(
|
|||
(state: UserStateType): string => state.stickersPath
|
||||
);
|
||||
|
||||
export const getPlatform = createSelector(
|
||||
getUser,
|
||||
(state: UserStateType): string => state.platform
|
||||
);
|
||||
|
||||
export const getTempPath = createSelector(
|
||||
getUser,
|
||||
(state: UserStateType): string => state.tempPath
|
||||
|
|
|
@ -6,7 +6,11 @@ import { StateType } from '../reducer';
|
|||
|
||||
import { getSearchResults, isSearching } from '../selectors/search';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getLeftPaneLists, getShowArchived } from '../selectors/conversations';
|
||||
import {
|
||||
getLeftPaneLists,
|
||||
getSelectedConversation,
|
||||
getShowArchived,
|
||||
} from '../selectors/conversations';
|
||||
|
||||
import { SmartMainHeader } from './MainHeader';
|
||||
import { SmartMessageSearchResult } from './MessageSearchResult';
|
||||
|
@ -28,10 +32,12 @@ const mapStateToProps = (state: StateType) => {
|
|||
|
||||
const lists = showSearch ? undefined : getLeftPaneLists(state);
|
||||
const searchResults = showSearch ? getSearchResults(state) : undefined;
|
||||
const selectedConversationId = getSelectedConversation(state);
|
||||
|
||||
return {
|
||||
...lists,
|
||||
searchResults,
|
||||
selectedConversationId,
|
||||
showArchived: getShowArchived(state),
|
||||
i18n: getIntl(state),
|
||||
renderMainHeader,
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
getQuery,
|
||||
getSearchConversationId,
|
||||
getSearchConversationName,
|
||||
getStartSearchCounter,
|
||||
} from '../selectors/search';
|
||||
import { getIntl, getRegionCode, getUserNumber } from '../selectors/user';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
|
@ -17,6 +18,7 @@ const mapStateToProps = (state: StateType) => {
|
|||
searchTerm: getQuery(state),
|
||||
searchConversationId: getSearchConversationId(state),
|
||||
searchConversationName: getSearchConversationName(state),
|
||||
startSearchCounter: getStartSearchCounter(state),
|
||||
regionCode: getRegionCode(state),
|
||||
ourNumber: getUserNumber(state),
|
||||
...getMe(state),
|
||||
|
|
47
ts/state/smart/ShortcutGuideModal.tsx
Normal file
47
ts/state/smart/ShortcutGuideModal.tsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { mapDispatchToProps } from '../actions';
|
||||
import { ShortcutGuideModal } from '../../components/ShortcutGuideModal';
|
||||
import { StateType } from '../reducer';
|
||||
|
||||
import { countStickers } from '../../components/stickers/lib';
|
||||
import { getIntl, getPlatform } from '../selectors/user';
|
||||
import {
|
||||
getBlessedStickerPacks,
|
||||
getInstalledStickerPacks,
|
||||
getKnownStickerPacks,
|
||||
getReceivedStickerPacks,
|
||||
} from '../selectors/stickers';
|
||||
|
||||
type ExternalProps = {
|
||||
close: () => unknown;
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||
const { close } = props;
|
||||
|
||||
const blessedPacks = getBlessedStickerPacks(state);
|
||||
const installedPacks = getInstalledStickerPacks(state);
|
||||
const knownPacks = getKnownStickerPacks(state);
|
||||
const receivedPacks = getReceivedStickerPacks(state);
|
||||
|
||||
const hasInstalledStickers =
|
||||
countStickers({
|
||||
knownPacks,
|
||||
blessedPacks,
|
||||
installedPacks,
|
||||
receivedPacks,
|
||||
}) === 0;
|
||||
|
||||
const platform = getPlatform(state);
|
||||
|
||||
return {
|
||||
close,
|
||||
hasInstalledStickers,
|
||||
platform,
|
||||
i18n: getIntl(state),
|
||||
};
|
||||
};
|
||||
|
||||
const smart = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
export const SmartShortcutGuideModal = smart(ShortcutGuideModal);
|
|
@ -9,6 +9,7 @@ import { getIntl } from '../selectors/user';
|
|||
import {
|
||||
getConversationMessagesSelector,
|
||||
getConversationSelector,
|
||||
getSelectedMessage,
|
||||
} from '../selectors/conversations';
|
||||
|
||||
import { SmartTimelineItem } from './TimelineItem';
|
||||
|
@ -30,8 +31,18 @@ type ExternalProps = {
|
|||
// are provided by ConversationView in setupTimeline().
|
||||
};
|
||||
|
||||
function renderItem(messageId: string, actionProps: Object): JSX.Element {
|
||||
return <FilteredSmartTimelineItem {...actionProps} id={messageId} />;
|
||||
function renderItem(
|
||||
messageId: string,
|
||||
conversationId: string,
|
||||
actionProps: Object
|
||||
): JSX.Element {
|
||||
return (
|
||||
<FilteredSmartTimelineItem
|
||||
{...actionProps}
|
||||
conversationId={conversationId}
|
||||
id={messageId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function renderLastSeenIndicator(id: string): JSX.Element {
|
||||
return <FilteredSmartLastSeenIndicator id={id} />;
|
||||
|
@ -48,11 +59,13 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
|||
|
||||
const conversation = getConversationSelector(state)(id);
|
||||
const conversationMessages = getConversationMessagesSelector(state)(id);
|
||||
const selectedMessage = getSelectedMessage(state);
|
||||
|
||||
return {
|
||||
id,
|
||||
...pick(conversation, ['unreadCount', 'typingContact']),
|
||||
...conversationMessages,
|
||||
selectedMessageId: selectedMessage ? selectedMessage.id : undefined,
|
||||
i18n: getIntl(state),
|
||||
renderItem,
|
||||
renderLastSeenIndicator,
|
||||
|
|
|
@ -5,20 +5,30 @@ import { StateType } from '../reducer';
|
|||
|
||||
import { TimelineItem } from '../../components/conversation/TimelineItem';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getMessageSelector } from '../selectors/conversations';
|
||||
import {
|
||||
getMessageSelector,
|
||||
getSelectedMessage,
|
||||
} from '../selectors/conversations';
|
||||
|
||||
type ExternalProps = {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||
const { id } = props;
|
||||
const { id, conversationId } = props;
|
||||
|
||||
const messageSelector = getMessageSelector(state);
|
||||
const item = messageSelector(id);
|
||||
|
||||
const selectedMessage = getSelectedMessage(state);
|
||||
const isSelected = Boolean(selectedMessage && id === selectedMessage.id);
|
||||
|
||||
return {
|
||||
item,
|
||||
id,
|
||||
conversationId,
|
||||
isSelected,
|
||||
i18n: getIntl(state),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -145,7 +145,7 @@
|
|||
{
|
||||
"rule": "jQuery-$(",
|
||||
"path": "js/about_start.js",
|
||||
"line": "$(document).on('keyup', e => {",
|
||||
"line": "$(document).on('keydown', e => {",
|
||||
"lineNumber": 19,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2018-09-19T21:59:32.770Z",
|
||||
|
@ -179,7 +179,7 @@
|
|||
{
|
||||
"rule": "jQuery-$(",
|
||||
"path": "js/debug_log_start.js",
|
||||
"line": "$(document).on('keyup', e => {",
|
||||
"line": "$(document).on('keydown', e => {",
|
||||
"lineNumber": 4,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2018-09-19T21:59:32.770Z",
|
||||
|
@ -246,7 +246,7 @@
|
|||
{
|
||||
"rule": "jQuery-$(",
|
||||
"path": "js/permissions_popup_start.js",
|
||||
"line": "$(document).on('keyup', e => {",
|
||||
"line": "$(document).on('keydown', e => {",
|
||||
"lineNumber": 3,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2018-09-19T21:59:32.770Z",
|
||||
|
@ -273,7 +273,7 @@
|
|||
{
|
||||
"rule": "jQuery-$(",
|
||||
"path": "js/settings_start.js",
|
||||
"line": "$(document).on('keyup', e => {",
|
||||
"line": "$(document).on('keydown', e => {",
|
||||
"lineNumber": 3,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2018-09-19T21:59:32.770Z",
|
||||
|
@ -336,7 +336,7 @@
|
|||
"rule": "jQuery-$(",
|
||||
"path": "js/views/confirmation_dialog_view.js",
|
||||
"line": " this.$('.cancel').focus();",
|
||||
"lineNumber": 55,
|
||||
"lineNumber": 73,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2018-09-19T21:59:32.770Z",
|
||||
"reasonDetail": "Protected from arbitrary input"
|
||||
|
@ -542,7 +542,7 @@
|
|||
"rule": "jQuery-$(",
|
||||
"path": "js/views/inbox_view.js",
|
||||
"line": " if (e && this.$(e.target).closest('.placeholder').length) {",
|
||||
"lineNumber": 183,
|
||||
"lineNumber": 190,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-10-21T22:30:15.622Z",
|
||||
"reasonDetail": "Known DOM elements"
|
||||
|
@ -551,7 +551,7 @@
|
|||
"rule": "jQuery-$(",
|
||||
"path": "js/views/inbox_view.js",
|
||||
"line": " this.$('#header, .gutter').addClass('inactive');",
|
||||
"lineNumber": 187,
|
||||
"lineNumber": 194,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-10-21T22:30:15.622Z",
|
||||
"reasonDetail": "Hardcoded selector"
|
||||
|
@ -560,7 +560,7 @@
|
|||
"rule": "jQuery-$(",
|
||||
"path": "js/views/inbox_view.js",
|
||||
"line": " this.$('.conversation-stack').addClass('inactive');",
|
||||
"lineNumber": 191,
|
||||
"lineNumber": 198,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-10-21T22:30:15.622Z",
|
||||
"reasonDetail": "Hardcoded selector"
|
||||
|
@ -569,7 +569,7 @@
|
|||
"rule": "jQuery-$(",
|
||||
"path": "js/views/inbox_view.js",
|
||||
"line": " this.$('.conversation:first .menu').trigger('close');",
|
||||
"lineNumber": 193,
|
||||
"lineNumber": 200,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-10-21T22:30:15.622Z",
|
||||
"reasonDetail": "Hardcoded selector"
|
||||
|
@ -578,7 +578,7 @@
|
|||
"rule": "jQuery-$(",
|
||||
"path": "js/views/inbox_view.js",
|
||||
"line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {",
|
||||
"lineNumber": 213,
|
||||
"lineNumber": 220,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-10-21T22:30:15.622Z",
|
||||
"reasonDetail": "Known DOM elements"
|
||||
|
@ -587,7 +587,7 @@
|
|||
"rule": "jQuery-$(",
|
||||
"path": "js/views/inbox_view.js",
|
||||
"line": " this.$('.conversation:first .recorder').trigger('close');",
|
||||
"lineNumber": 216,
|
||||
"lineNumber": 223,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-10-21T22:30:15.622Z",
|
||||
"reasonDetail": "Hardcoded selector"
|
||||
|
@ -811,7 +811,7 @@
|
|||
"rule": "jQuery-$(",
|
||||
"path": "js/views/recorder_view.js",
|
||||
"line": " this.$('.time').text(`${minutes}:${seconds}`);",
|
||||
"lineNumber": 38,
|
||||
"lineNumber": 49,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2018-09-19T21:59:32.770Z",
|
||||
"reasonDetail": "Protected from arbitrary input"
|
||||
|
@ -820,7 +820,7 @@
|
|||
"rule": "jQuery-$(",
|
||||
"path": "js/views/recorder_view.js",
|
||||
"line": " $(window).off('blur', this.onSwitchAwayBound);",
|
||||
"lineNumber": 69,
|
||||
"lineNumber": 80,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2018-10-11T19:22:47.331Z",
|
||||
"reasonDetail": "Operating on already-existing DOM elements"
|
||||
|
@ -1139,6 +1139,15 @@
|
|||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2018-09-15T00:38:04.183Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-appendTo(",
|
||||
"path": "js/views/toast_view.js",
|
||||
"line": " toast.$el.appendTo(el);",
|
||||
"lineNumber": 34,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-11-06T19:56:38.557Z",
|
||||
"reasonDetail": "Protected from arbitrary input"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-html(",
|
||||
"path": "js/views/whisper_view.js",
|
||||
|
@ -7515,34 +7524,43 @@
|
|||
"rule": "DOM-innerHTML",
|
||||
"path": "ts/components/CompositionArea.tsx",
|
||||
"line": " el.innerHTML = '';",
|
||||
"lineNumber": 69,
|
||||
"lineNumber": 70,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-08-01T14:10:37.481Z",
|
||||
"reasonDetail": "Our code, no user input, only clearing out the dom"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/Lightbox.js",
|
||||
"line": " this.videoRef = react_1.default.createRef();",
|
||||
"lineNumber": 207,
|
||||
"path": "ts/components/LeftPane.js",
|
||||
"line": " this.listRef = react_1.default.createRef();",
|
||||
"lineNumber": 14,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-03-09T00:08:44.242Z",
|
||||
"reasonDetail": "Used to auto-start playback on videos"
|
||||
"updated": "2019-11-05T01:14:21.081Z",
|
||||
"reasonDetail": "Used for focus management"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/Lightbox.tsx",
|
||||
"line": " this.videoRef = React.createRef();",
|
||||
"lineNumber": 196,
|
||||
"path": "ts/components/Lightbox.js",
|
||||
"line": " this.containerRef = react_1.default.createRef();",
|
||||
"lineNumber": 141,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-03-09T00:08:44.242Z",
|
||||
"reasonDetail": "Used to auto-start playback on videos"
|
||||
"updated": "2019-11-06T19:56:38.557Z",
|
||||
"reasonDetail": "Used to double-check outside clicks"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/Lightbox.js",
|
||||
"line": " this.focusRef = react_1.default.createRef();",
|
||||
"lineNumber": 143,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-11-06T19:56:38.557Z",
|
||||
"reasonDetail": "Used to manage focus"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/MainHeader.js",
|
||||
"line": " this.inputRef = react_1.default.createRef();",
|
||||
"lineNumber": 118,
|
||||
"lineNumber": 126,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-08-09T21:17:57.798Z",
|
||||
"reasonDetail": "Used only to set focus"
|
||||
|
@ -7551,7 +7569,7 @@
|
|||
"rule": "React-createRef",
|
||||
"path": "ts/components/MainHeader.tsx",
|
||||
"line": " this.inputRef = React.createRef();",
|
||||
"lineNumber": 64,
|
||||
"lineNumber": 65,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-08-09T21:17:57.798Z",
|
||||
"reasonDetail": "Used only to set focus"
|
||||
|
@ -7560,7 +7578,7 @@
|
|||
"rule": "React-createRef",
|
||||
"path": "ts/components/SearchResults.js",
|
||||
"line": " this.listRef = react_1.default.createRef();",
|
||||
"lineNumber": 22,
|
||||
"lineNumber": 25,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-08-09T00:44:31.008Z",
|
||||
"reasonDetail": "SearchResults needs to interact with its child List directly"
|
||||
|
@ -7583,6 +7601,60 @@
|
|||
"updated": "2019-07-31T00:19:18.696Z",
|
||||
"reasonDetail": "Used to reference popup menu"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/conversation/InlineNotificationWrapper.js",
|
||||
"line": " this.focusRef = react_1.default.createRef();",
|
||||
"lineNumber": 10,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-11-06T19:56:38.557Z",
|
||||
"reasonDetail": "Used to manage focus"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/conversation/InlineNotificationWrapper.tsx",
|
||||
"line": " public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
|
||||
"lineNumber": 11,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-11-06T19:56:38.557Z",
|
||||
"reasonDetail": "Used to manage focus"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/conversation/Message.js",
|
||||
"line": " this.focusRef = react_1.default.createRef();",
|
||||
"lineNumber": 31,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-11-01T22:46:33.013Z",
|
||||
"reasonDetail": "Used for setting focus only"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/conversation/Message.tsx",
|
||||
"line": " public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
|
||||
"lineNumber": 149,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-11-01T22:46:33.013Z",
|
||||
"reasonDetail": "Used for setting focus only"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/conversation/MessageDetail.js",
|
||||
"line": " this.focusRef = react_1.default.createRef();",
|
||||
"lineNumber": 15,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-11-01T22:46:33.013Z",
|
||||
"reasonDetail": "Used for setting focus only"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/conversation/MessageDetail.tsx",
|
||||
"line": " this.focusRef = React.createRef();",
|
||||
"lineNumber": 42,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-11-01T22:46:33.013Z",
|
||||
"reasonDetail": "Used for setting focus only"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/conversation/Timeline.js",
|
||||
|
@ -7592,6 +7664,24 @@
|
|||
"updated": "2019-07-31T00:19:18.696Z",
|
||||
"reasonDetail": "Timeline needs to interact with its child List directly"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/conversation/media-gallery/MediaGallery.js",
|
||||
"line": " this.focusRef = react_1.default.createRef();",
|
||||
"lineNumber": 25,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-11-01T22:46:33.013Z",
|
||||
"reasonDetail": "Used for setting focus only"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/conversation/media-gallery/MediaGallery.tsx",
|
||||
"line": " public readonly focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
|
||||
"lineNumber": 65,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-11-01T22:46:33.013Z",
|
||||
"reasonDetail": "Used for setting focus only"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-wrap(",
|
||||
"path": "ts/shims/textsecure.js",
|
||||
|
|
|
@ -3113,6 +3113,7 @@ es6-weak-map@^2.0.2:
|
|||
escape-html@~1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
||||
integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
|
||||
|
||||
escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
|
||||
version "1.0.5"
|
||||
|
|
Loading…
Reference in a new issue