Adds message forwarding
This commit is contained in:
		
					parent
					
						
							
								cd489a35fd
							
						
					
				
			
			
				commit
				
					
						d203f125c6
					
				
			
		
					 42 changed files with 1638 additions and 139 deletions
				
			
		| 
						 | 
					@ -1049,6 +1049,10 @@
 | 
				
			||||||
  "theirIdentityUnknown": {
 | 
					  "theirIdentityUnknown": {
 | 
				
			||||||
    "message": "You haven't exchanged any messages with this contact yet. Your safety number with them will be available after the first message."
 | 
					    "message": "You haven't exchanged any messages with this contact yet. Your safety number with them will be available after the first message."
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  "back": {
 | 
				
			||||||
 | 
					    "message": "Back",
 | 
				
			||||||
 | 
					    "description": "Generic label for back"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  "goBack": {
 | 
					  "goBack": {
 | 
				
			||||||
    "message": "Go back",
 | 
					    "message": "Go back",
 | 
				
			||||||
    "description": "Label for back button in a conversation"
 | 
					    "description": "Label for back button in a conversation"
 | 
				
			||||||
| 
						 | 
					@ -1061,6 +1065,10 @@
 | 
				
			||||||
    "message": "Retry Send",
 | 
					    "message": "Retry Send",
 | 
				
			||||||
    "description": "Shown on the drop-down menu for an individual message, but only if it is an outgoing message that failed to send"
 | 
					    "description": "Shown on the drop-down menu for an individual message, but only if it is an outgoing message that failed to send"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  "forwardMessage": {
 | 
				
			||||||
 | 
					    "message": "Forward message",
 | 
				
			||||||
 | 
					    "description": "Shown on the drop-down menu for an individual message, forwards a message"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  "deleteMessage": {
 | 
					  "deleteMessage": {
 | 
				
			||||||
    "message": "Delete message for me",
 | 
					    "message": "Delete message for me",
 | 
				
			||||||
    "description": "Shown on the drop-down menu for an individual message, deletes single message"
 | 
					    "description": "Shown on the drop-down menu for an individual message, deletes single message"
 | 
				
			||||||
| 
						 | 
					@ -5152,5 +5160,9 @@
 | 
				
			||||||
  "composeIcon": {
 | 
					  "composeIcon": {
 | 
				
			||||||
    "message": "compose button",
 | 
					    "message": "compose button",
 | 
				
			||||||
    "description": "Shown in the left-pane when the inbox is empty. Describes the button that composes a new message."
 | 
					    "description": "Shown in the left-pane when the inbox is empty. Describes the button that composes a new message."
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "ForwardMessageModal--continue": {
 | 
				
			||||||
 | 
					    "message": "Continue",
 | 
				
			||||||
 | 
					    "description": "aria-label for the 'next' button in the forward a message modal dialog"
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -73,6 +73,9 @@ const {
 | 
				
			||||||
  createConversationHeader,
 | 
					  createConversationHeader,
 | 
				
			||||||
} = require('../../ts/state/roots/createConversationHeader');
 | 
					} = require('../../ts/state/roots/createConversationHeader');
 | 
				
			||||||
const { createCallManager } = require('../../ts/state/roots/createCallManager');
 | 
					const { createCallManager } = require('../../ts/state/roots/createCallManager');
 | 
				
			||||||
 | 
					const {
 | 
				
			||||||
 | 
					  createForwardMessageModal,
 | 
				
			||||||
 | 
					} = require('../../ts/state/roots/createForwardMessageModal');
 | 
				
			||||||
const {
 | 
					const {
 | 
				
			||||||
  createGroupLinkManagement,
 | 
					  createGroupLinkManagement,
 | 
				
			||||||
} = require('../../ts/state/roots/createGroupLinkManagement');
 | 
					} = require('../../ts/state/roots/createGroupLinkManagement');
 | 
				
			||||||
| 
						 | 
					@ -111,6 +114,7 @@ const conversationsDuck = require('../../ts/state/ducks/conversations');
 | 
				
			||||||
const emojisDuck = require('../../ts/state/ducks/emojis');
 | 
					const emojisDuck = require('../../ts/state/ducks/emojis');
 | 
				
			||||||
const expirationDuck = require('../../ts/state/ducks/expiration');
 | 
					const expirationDuck = require('../../ts/state/ducks/expiration');
 | 
				
			||||||
const itemsDuck = require('../../ts/state/ducks/items');
 | 
					const itemsDuck = require('../../ts/state/ducks/items');
 | 
				
			||||||
 | 
					const linkPreviewsDuck = require('../../ts/state/ducks/linkPreviews');
 | 
				
			||||||
const networkDuck = require('../../ts/state/ducks/network');
 | 
					const networkDuck = require('../../ts/state/ducks/network');
 | 
				
			||||||
const searchDuck = require('../../ts/state/ducks/search');
 | 
					const searchDuck = require('../../ts/state/ducks/search');
 | 
				
			||||||
const stickersDuck = require('../../ts/state/ducks/stickers');
 | 
					const stickersDuck = require('../../ts/state/ducks/stickers');
 | 
				
			||||||
| 
						 | 
					@ -344,6 +348,7 @@ exports.setup = (options = {}) => {
 | 
				
			||||||
    createContactModal,
 | 
					    createContactModal,
 | 
				
			||||||
    createConversationDetails,
 | 
					    createConversationDetails,
 | 
				
			||||||
    createConversationHeader,
 | 
					    createConversationHeader,
 | 
				
			||||||
 | 
					    createForwardMessageModal,
 | 
				
			||||||
    createGroupLinkManagement,
 | 
					    createGroupLinkManagement,
 | 
				
			||||||
    createGroupV1MigrationModal,
 | 
					    createGroupV1MigrationModal,
 | 
				
			||||||
    createGroupV2JoinModal,
 | 
					    createGroupV2JoinModal,
 | 
				
			||||||
| 
						 | 
					@ -364,6 +369,7 @@ exports.setup = (options = {}) => {
 | 
				
			||||||
    emojis: emojisDuck,
 | 
					    emojis: emojisDuck,
 | 
				
			||||||
    expiration: expirationDuck,
 | 
					    expiration: expirationDuck,
 | 
				
			||||||
    items: itemsDuck,
 | 
					    items: itemsDuck,
 | 
				
			||||||
 | 
					    linkPreviews: linkPreviewsDuck,
 | 
				
			||||||
    network: networkDuck,
 | 
					    network: networkDuck,
 | 
				
			||||||
    updates: updatesDuck,
 | 
					    updates: updatesDuck,
 | 
				
			||||||
    user: userDuck,
 | 
					    user: userDuck,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11064,6 +11064,23 @@ $contact-modal-padding: 18px;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__forward-message::before {
 | 
				
			||||||
 | 
					    transform: scaleX(-1);
 | 
				
			||||||
 | 
					    @include light-theme {
 | 
				
			||||||
 | 
					      @include color-svg(
 | 
				
			||||||
 | 
					        '../images/icons/v2/reply-outline-24.svg',
 | 
				
			||||||
 | 
					        $color-black
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @include dark-theme {
 | 
				
			||||||
 | 
					      @include color-svg(
 | 
				
			||||||
 | 
					        '../images/icons/v2/reply-solid-24.svg',
 | 
				
			||||||
 | 
					        $color-gray-15
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  &__delete-message::before {
 | 
					  &__delete-message::before {
 | 
				
			||||||
    @include light-theme {
 | 
					    @include light-theme {
 | 
				
			||||||
      @include color-svg(
 | 
					      @include color-svg(
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										293
									
								
								stylesheets/components/ForwardMessageModal.scss
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										293
									
								
								stylesheets/components/ForwardMessageModal.scss
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,293 @@
 | 
				
			||||||
 | 
					// Copyright 2021 Signal Messenger, LLC
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.module-ForwardMessageModal {
 | 
				
			||||||
 | 
					  $padding: 16px;
 | 
				
			||||||
 | 
					  @include popper-shadow();
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  margin: 0 auto;
 | 
				
			||||||
 | 
					  max-height: 90vh;
 | 
				
			||||||
 | 
					  max-width: 360px;
 | 
				
			||||||
 | 
					  width: 95%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @include light-theme() {
 | 
				
			||||||
 | 
					    background: $color-white;
 | 
				
			||||||
 | 
					    color: $color-gray-90;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @include dark-theme() {
 | 
				
			||||||
 | 
					    background: $color-gray-75;
 | 
				
			||||||
 | 
					    color: $color-gray-05;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &--link-preview {
 | 
				
			||||||
 | 
					    border-bottom: 1px solid $color-gray-15;
 | 
				
			||||||
 | 
					    padding: 12px 16px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @include dark-theme() {
 | 
				
			||||||
 | 
					      border-color: $color-gray-60;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__input {
 | 
				
			||||||
 | 
					    &__input {
 | 
				
			||||||
 | 
					      background: inherit;
 | 
				
			||||||
 | 
					      border: none;
 | 
				
			||||||
 | 
					      border-radius: 0;
 | 
				
			||||||
 | 
					      height: 100%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &:focus-within {
 | 
				
			||||||
 | 
					        border: none;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      @include dark-theme() {
 | 
				
			||||||
 | 
					        border: none;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        &:focus-within {
 | 
				
			||||||
 | 
					          border: none;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      @include keyboard-mode {
 | 
				
			||||||
 | 
					        &:focus-within {
 | 
				
			||||||
 | 
					          border: solid 1px $ultramarine-ui-light;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &__scroller {
 | 
				
			||||||
 | 
					      max-height: 300px;
 | 
				
			||||||
 | 
					      min-height: 300px;
 | 
				
			||||||
 | 
					      padding-right: 36px;
 | 
				
			||||||
 | 
					      padding: 16px;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__header {
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    justify-content: center;
 | 
				
			||||||
 | 
					    position: relative;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &--edit {
 | 
				
			||||||
 | 
					      border-bottom: 1px solid $color-gray-15;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      @include dark-theme() {
 | 
				
			||||||
 | 
					        border-color: $color-gray-60;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &--cancel {
 | 
				
			||||||
 | 
					      @include button-reset;
 | 
				
			||||||
 | 
					      position: absolute;
 | 
				
			||||||
 | 
					      left: 16px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      @include keyboard-mode {
 | 
				
			||||||
 | 
					        &:focus {
 | 
				
			||||||
 | 
					          color: $ultramarine-ui-light;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &--back {
 | 
				
			||||||
 | 
					      @include button-reset;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      height: 24px;
 | 
				
			||||||
 | 
					      left: 16px;
 | 
				
			||||||
 | 
					      position: absolute;
 | 
				
			||||||
 | 
					      width: 24px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      @include light-theme {
 | 
				
			||||||
 | 
					        @include color-svg(
 | 
				
			||||||
 | 
					          '../images/icons/v2/chevron-left-24.svg',
 | 
				
			||||||
 | 
					          $color-gray-60
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      @include keyboard-mode {
 | 
				
			||||||
 | 
					        &:focus {
 | 
				
			||||||
 | 
					          @include color-svg(
 | 
				
			||||||
 | 
					            '../images/icons/v2/chevron-left-24.svg',
 | 
				
			||||||
 | 
					            $ultramarine-ui-light
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      @include dark-theme {
 | 
				
			||||||
 | 
					        @include color-svg(
 | 
				
			||||||
 | 
					          '../images/icons/v2/chevron-left-24.svg',
 | 
				
			||||||
 | 
					          $color-gray-25
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      @include dark-keyboard-mode {
 | 
				
			||||||
 | 
					        &:hover {
 | 
				
			||||||
 | 
					          @include color-svg(
 | 
				
			||||||
 | 
					            '../images/icons/v2/chevron-left-24.svg',
 | 
				
			||||||
 | 
					            $ultramarine-ui-dark
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    h1 {
 | 
				
			||||||
 | 
					      @include font-body-1-bold;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__search {
 | 
				
			||||||
 | 
					    border-radius: 8px;
 | 
				
			||||||
 | 
					    border: none;
 | 
				
			||||||
 | 
					    margin: 10px 16px;
 | 
				
			||||||
 | 
					    padding: 5px 12px;
 | 
				
			||||||
 | 
					    position: relative;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @include font-body-2;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @include light-theme {
 | 
				
			||||||
 | 
					      background-color: $color-gray-02;
 | 
				
			||||||
 | 
					      border: solid 1px $color-gray-02;
 | 
				
			||||||
 | 
					      color: $color-gray-90;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @include dark-theme {
 | 
				
			||||||
 | 
					      background: $color-gray-65;
 | 
				
			||||||
 | 
					      border: solid 1px $color-gray-65;
 | 
				
			||||||
 | 
					      color: $color-gray-05;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &--icon {
 | 
				
			||||||
 | 
					      cursor: text;
 | 
				
			||||||
 | 
					      height: 16px;
 | 
				
			||||||
 | 
					      left: 8px;
 | 
				
			||||||
 | 
					      position: absolute;
 | 
				
			||||||
 | 
					      top: 6px;
 | 
				
			||||||
 | 
					      width: 16px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      @include light-theme {
 | 
				
			||||||
 | 
					        @include color-svg('../images/icons/v2/search-16.svg', $color-gray-45);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @include keyboard-mode {
 | 
				
			||||||
 | 
					      &:focus-within {
 | 
				
			||||||
 | 
					        border: solid 1px $ultramarine-ui-light;
 | 
				
			||||||
 | 
					        outline: none;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &--input {
 | 
				
			||||||
 | 
					      background: inherit;
 | 
				
			||||||
 | 
					      border: none;
 | 
				
			||||||
 | 
					      padding-left: 16px;
 | 
				
			||||||
 | 
					      width: 100%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &:placeholder {
 | 
				
			||||||
 | 
					        color: $color-gray-45;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      @include dark-theme {
 | 
				
			||||||
 | 
					        color: $color-gray-05;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &:focus {
 | 
				
			||||||
 | 
					        outline: none;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__list-wrapper {
 | 
				
			||||||
 | 
					    flex-grow: 1;
 | 
				
			||||||
 | 
					    overflow: hidden;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__main-body {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: column;
 | 
				
			||||||
 | 
					    min-height: 300px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__text-edit-area {
 | 
				
			||||||
 | 
					    height: 100%;
 | 
				
			||||||
 | 
					    position: relative;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__no-candidate-contacts {
 | 
				
			||||||
 | 
					    flex-grow: 1;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    justify-content: center;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__send-button {
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    border: none;
 | 
				
			||||||
 | 
					    border-radius: 100%;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    height: 32px;
 | 
				
			||||||
 | 
					    justify-content: center;
 | 
				
			||||||
 | 
					    width: 32px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &::after {
 | 
				
			||||||
 | 
					      content: '';
 | 
				
			||||||
 | 
					      display: block;
 | 
				
			||||||
 | 
					      flex-shrink: 0;
 | 
				
			||||||
 | 
					      height: 24px;
 | 
				
			||||||
 | 
					      width: 24px;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &--continue {
 | 
				
			||||||
 | 
					      &::after {
 | 
				
			||||||
 | 
					        @include color-svg(
 | 
				
			||||||
 | 
					          '../images/icons/v2/arrow-down-24.svg',
 | 
				
			||||||
 | 
					          $color-white
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        transform: rotate(270deg);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &--forward {
 | 
				
			||||||
 | 
					      &::after {
 | 
				
			||||||
 | 
					        @include color-svg('../images/icons/v2/send-24.svg', $color-white);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__emoji {
 | 
				
			||||||
 | 
					    position: absolute;
 | 
				
			||||||
 | 
					    right: 8px;
 | 
				
			||||||
 | 
					    top: 8px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    button::after {
 | 
				
			||||||
 | 
					      background-color: $color-black;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__footer {
 | 
				
			||||||
 | 
					    @include font-body-2;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    border-bottom-left-radius: 8px;
 | 
				
			||||||
 | 
					    border-bottom-right-radius: 8px;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    justify-content: space-between;
 | 
				
			||||||
 | 
					    margin-top: 0;
 | 
				
			||||||
 | 
					    padding: $padding;
 | 
				
			||||||
 | 
					    position: relative;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @include light-theme {
 | 
				
			||||||
 | 
					      background-color: $color-gray-02;
 | 
				
			||||||
 | 
					      color: $color-gray-60;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @include dark-theme() {
 | 
				
			||||||
 | 
					      background: $color-gray-65;
 | 
				
			||||||
 | 
					      color: $color-gray-25;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Disable cursor since images are non-clickable
 | 
				
			||||||
 | 
					  .module-image__image {
 | 
				
			||||||
 | 
					    cursor: inherit;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -34,6 +34,7 @@
 | 
				
			||||||
@import './components/ContactPills.scss';
 | 
					@import './components/ContactPills.scss';
 | 
				
			||||||
@import './components/ConversationHeader.scss';
 | 
					@import './components/ConversationHeader.scss';
 | 
				
			||||||
@import './components/EditConversationAttributesModal.scss';
 | 
					@import './components/EditConversationAttributesModal.scss';
 | 
				
			||||||
 | 
					@import './components/ForwardMessageModal.scss';
 | 
				
			||||||
@import './components/GroupDialog.scss';
 | 
					@import './components/GroupDialog.scss';
 | 
				
			||||||
@import './components/GroupTitleInput.scss';
 | 
					@import './components/GroupTitleInput.scss';
 | 
				
			||||||
@import './components/MessageAudio.scss';
 | 
					@import './components/MessageAudio.scss';
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -831,6 +831,10 @@ export async function startApp(): Promise<void> {
 | 
				
			||||||
      window.Signal.State.Ducks.items.actions,
 | 
					      window.Signal.State.Ducks.items.actions,
 | 
				
			||||||
      store.dispatch
 | 
					      store.dispatch
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					    actions.linkPreviews = window.Signal.State.bindActionCreators(
 | 
				
			||||||
 | 
					      window.Signal.State.Ducks.linkPreviews.actions,
 | 
				
			||||||
 | 
					      store.dispatch
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
    actions.network = window.Signal.State.bindActionCreators(
 | 
					    actions.network = window.Signal.State.bindActionCreators(
 | 
				
			||||||
      window.Signal.State.Ducks.network.actions,
 | 
					      window.Signal.State.Ducks.network.actions,
 | 
				
			||||||
      store.dispatch
 | 
					      store.dispatch
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -33,3 +33,11 @@ story.add('Kitchen sink', () => (
 | 
				
			||||||
    ))}
 | 
					    ))}
 | 
				
			||||||
  </>
 | 
					  </>
 | 
				
			||||||
));
 | 
					));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					story.add('aria-label', () => (
 | 
				
			||||||
 | 
					  <Button
 | 
				
			||||||
 | 
					    aria-label="hello"
 | 
				
			||||||
 | 
					    className="module-ForwardMessageModal__header--back"
 | 
				
			||||||
 | 
					    onClick={action('onClick')}
 | 
				
			||||||
 | 
					  />
 | 
				
			||||||
 | 
					));
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -15,7 +15,6 @@ export enum ButtonVariant {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type PropsType = {
 | 
					type PropsType = {
 | 
				
			||||||
  children: ReactNode;
 | 
					 | 
				
			||||||
  className?: string;
 | 
					  className?: string;
 | 
				
			||||||
  disabled?: boolean;
 | 
					  disabled?: boolean;
 | 
				
			||||||
  variant?: ButtonVariant;
 | 
					  variant?: ButtonVariant;
 | 
				
			||||||
| 
						 | 
					@ -26,7 +25,21 @@ type PropsType = {
 | 
				
			||||||
  | {
 | 
					  | {
 | 
				
			||||||
      type: 'submit';
 | 
					      type: 'submit';
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
);
 | 
					) &
 | 
				
			||||||
 | 
					  (
 | 
				
			||||||
 | 
					    | {
 | 
				
			||||||
 | 
					        'aria-label': string;
 | 
				
			||||||
 | 
					        children: ReactNode;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    | {
 | 
				
			||||||
 | 
					        'aria-label'?: string;
 | 
				
			||||||
 | 
					        children: ReactNode;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    | {
 | 
				
			||||||
 | 
					        'aria-label': string;
 | 
				
			||||||
 | 
					        children?: ReactNode;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const VARIANT_CLASS_NAMES = new Map<ButtonVariant, string>([
 | 
					const VARIANT_CLASS_NAMES = new Map<ButtonVariant, string>([
 | 
				
			||||||
  [ButtonVariant.Primary, 'module-Button--primary'],
 | 
					  [ButtonVariant.Primary, 'module-Button--primary'],
 | 
				
			||||||
| 
						 | 
					@ -50,6 +63,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
 | 
				
			||||||
      disabled = false,
 | 
					      disabled = false,
 | 
				
			||||||
      variant = ButtonVariant.Primary,
 | 
					      variant = ButtonVariant.Primary,
 | 
				
			||||||
    } = props;
 | 
					    } = props;
 | 
				
			||||||
 | 
					    const ariaLabel = props['aria-label'];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let onClick: undefined | MouseEventHandler<HTMLButtonElement>;
 | 
					    let onClick: undefined | MouseEventHandler<HTMLButtonElement>;
 | 
				
			||||||
    let type: 'button' | 'submit';
 | 
					    let type: 'button' | 'submit';
 | 
				
			||||||
| 
						 | 
					@ -66,6 +80,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <button
 | 
					      <button
 | 
				
			||||||
 | 
					        aria-label={ariaLabel}
 | 
				
			||||||
        className={classNames('module-Button', variantClassName, className)}
 | 
					        className={classNames('module-Button', variantClassName, className)}
 | 
				
			||||||
        disabled={disabled}
 | 
					        disabled={disabled}
 | 
				
			||||||
        onClick={onClick}
 | 
					        onClick={onClick}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -63,6 +63,7 @@ export type Props = {
 | 
				
			||||||
  readonly skinTone?: EmojiPickDataType['skinTone'];
 | 
					  readonly skinTone?: EmojiPickDataType['skinTone'];
 | 
				
			||||||
  readonly draftText?: string;
 | 
					  readonly draftText?: string;
 | 
				
			||||||
  readonly draftBodyRanges?: Array<BodyRangeType>;
 | 
					  readonly draftBodyRanges?: Array<BodyRangeType>;
 | 
				
			||||||
 | 
					  readonly moduleClassName?: string;
 | 
				
			||||||
  sortedGroupMembers?: Array<ConversationType>;
 | 
					  sortedGroupMembers?: Array<ConversationType>;
 | 
				
			||||||
  onDirtyChange?(dirty: boolean): unknown;
 | 
					  onDirtyChange?(dirty: boolean): unknown;
 | 
				
			||||||
  onEditorStateChange?(
 | 
					  onEditorStateChange?(
 | 
				
			||||||
| 
						 | 
					@ -79,12 +80,24 @@ export type Props = {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const MAX_LENGTH = 64 * 1024;
 | 
					const MAX_LENGTH = 64 * 1024;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function getClassName(
 | 
				
			||||||
 | 
					  moduleClassName?: string,
 | 
				
			||||||
 | 
					  modifier?: string | null
 | 
				
			||||||
 | 
					): string | undefined {
 | 
				
			||||||
 | 
					  if (!moduleClassName || !modifier) {
 | 
				
			||||||
 | 
					    return undefined;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return `${moduleClassName}${modifier}`;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const CompositionInput: React.ComponentType<Props> = props => {
 | 
					export const CompositionInput: React.ComponentType<Props> = props => {
 | 
				
			||||||
  const {
 | 
					  const {
 | 
				
			||||||
    i18n,
 | 
					    i18n,
 | 
				
			||||||
    disabled,
 | 
					    disabled,
 | 
				
			||||||
    large,
 | 
					    large,
 | 
				
			||||||
    inputApi,
 | 
					    inputApi,
 | 
				
			||||||
 | 
					    moduleClassName,
 | 
				
			||||||
    onPickEmoji,
 | 
					    onPickEmoji,
 | 
				
			||||||
    onSubmit,
 | 
					    onSubmit,
 | 
				
			||||||
    skinTone,
 | 
					    skinTone,
 | 
				
			||||||
| 
						 | 
					@ -240,12 +253,12 @@ export const CompositionInput: React.ComponentType<Props> = props => {
 | 
				
			||||||
    propsRef.current = props;
 | 
					    propsRef.current = props;
 | 
				
			||||||
  }, [props]);
 | 
					  }, [props]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onShortKeyEnter = () => {
 | 
					  const onShortKeyEnter = (): boolean => {
 | 
				
			||||||
    submit();
 | 
					    submit();
 | 
				
			||||||
    return false;
 | 
					    return false;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onEnter = () => {
 | 
					  const onEnter = (): boolean => {
 | 
				
			||||||
    const quill = quillRef.current;
 | 
					    const quill = quillRef.current;
 | 
				
			||||||
    const emojiCompletion = emojiCompletionRef.current;
 | 
					    const emojiCompletion = emojiCompletionRef.current;
 | 
				
			||||||
    const mentionCompletion = mentionCompletionRef.current;
 | 
					    const mentionCompletion = mentionCompletionRef.current;
 | 
				
			||||||
| 
						 | 
					@ -277,7 +290,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
 | 
				
			||||||
    return false;
 | 
					    return false;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onTab = () => {
 | 
					  const onTab = (): boolean => {
 | 
				
			||||||
    const quill = quillRef.current;
 | 
					    const quill = quillRef.current;
 | 
				
			||||||
    const emojiCompletion = emojiCompletionRef.current;
 | 
					    const emojiCompletion = emojiCompletionRef.current;
 | 
				
			||||||
    const mentionCompletion = mentionCompletionRef.current;
 | 
					    const mentionCompletion = mentionCompletionRef.current;
 | 
				
			||||||
| 
						 | 
					@ -303,7 +316,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
 | 
				
			||||||
    return true;
 | 
					    return true;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onEscape = () => {
 | 
					  const onEscape = (): boolean => {
 | 
				
			||||||
    const quill = quillRef.current;
 | 
					    const quill = quillRef.current;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (quill === undefined) {
 | 
					    if (quill === undefined) {
 | 
				
			||||||
| 
						 | 
					@ -335,7 +348,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
 | 
				
			||||||
    return true;
 | 
					    return true;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onBackspace = () => {
 | 
					  const onBackspace = (): boolean => {
 | 
				
			||||||
    const quill = quillRef.current;
 | 
					    const quill = quillRef.current;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (quill === undefined) {
 | 
					    if (quill === undefined) {
 | 
				
			||||||
| 
						 | 
					@ -361,7 +374,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
 | 
				
			||||||
    return false;
 | 
					    return false;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onChange = () => {
 | 
					  const onChange = (): void => {
 | 
				
			||||||
    const quill = quillRef.current;
 | 
					    const quill = quillRef.current;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const [text, mentions] = getTextAndMentions();
 | 
					    const [text, mentions] = getTextAndMentions();
 | 
				
			||||||
| 
						 | 
					@ -471,6 +484,22 @@ export const CompositionInput: React.ComponentType<Props> = props => {
 | 
				
			||||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
					    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
				
			||||||
  }, [JSON.stringify(memberIds)]);
 | 
					  }, [JSON.stringify(memberIds)]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Placing all of these callbacks inside of a ref since Quill is not able
 | 
				
			||||||
 | 
					  // to re-render. We want to make sure that all these callbacks are fresh
 | 
				
			||||||
 | 
					  // so that the consumers of this component won't deal with stale props or
 | 
				
			||||||
 | 
					  // stale state as the result of calling them.
 | 
				
			||||||
 | 
					  const unstaleCallbacks = {
 | 
				
			||||||
 | 
					    onBackspace,
 | 
				
			||||||
 | 
					    onChange,
 | 
				
			||||||
 | 
					    onEnter,
 | 
				
			||||||
 | 
					    onEscape,
 | 
				
			||||||
 | 
					    onPickEmoji,
 | 
				
			||||||
 | 
					    onShortKeyEnter,
 | 
				
			||||||
 | 
					    onTab,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  const callbacksRef = React.useRef(unstaleCallbacks);
 | 
				
			||||||
 | 
					  callbacksRef.current = unstaleCallbacks;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const reactQuill = React.useMemo(
 | 
					  const reactQuill = React.useMemo(
 | 
				
			||||||
    () => {
 | 
					    () => {
 | 
				
			||||||
      const delta = generateDelta(draftText || '', draftBodyRanges || []);
 | 
					      const delta = generateDelta(draftText || '', draftBodyRanges || []);
 | 
				
			||||||
| 
						 | 
					@ -478,7 +507,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
 | 
				
			||||||
      return (
 | 
					      return (
 | 
				
			||||||
        <ReactQuill
 | 
					        <ReactQuill
 | 
				
			||||||
          className="module-composition-input__quill"
 | 
					          className="module-composition-input__quill"
 | 
				
			||||||
          onChange={onChange}
 | 
					          onChange={() => callbacksRef.current.onChange()}
 | 
				
			||||||
          defaultValue={delta}
 | 
					          defaultValue={delta}
 | 
				
			||||||
          modules={{
 | 
					          modules={{
 | 
				
			||||||
            toolbar: false,
 | 
					            toolbar: false,
 | 
				
			||||||
| 
						 | 
					@ -494,19 +523,29 @@ export const CompositionInput: React.ComponentType<Props> = props => {
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            keyboard: {
 | 
					            keyboard: {
 | 
				
			||||||
              bindings: {
 | 
					              bindings: {
 | 
				
			||||||
                onEnter: { key: 13, handler: onEnter }, // 13 = Enter
 | 
					                onEnter: {
 | 
				
			||||||
 | 
					                  key: 13,
 | 
				
			||||||
 | 
					                  handler: () => callbacksRef.current.onEnter(),
 | 
				
			||||||
 | 
					                }, // 13 = Enter
 | 
				
			||||||
                onShortKeyEnter: {
 | 
					                onShortKeyEnter: {
 | 
				
			||||||
                  key: 13, // 13 = Enter
 | 
					                  key: 13, // 13 = Enter
 | 
				
			||||||
                  shortKey: true,
 | 
					                  shortKey: true,
 | 
				
			||||||
                  handler: onShortKeyEnter,
 | 
					                  handler: () => callbacksRef.current.onShortKeyEnter(),
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
                onEscape: { key: 27, handler: onEscape }, // 27 = Escape
 | 
					                onEscape: {
 | 
				
			||||||
                onBackspace: { key: 8, handler: onBackspace }, // 8 = Backspace
 | 
					                  key: 27,
 | 
				
			||||||
 | 
					                  handler: () => callbacksRef.current.onEscape(),
 | 
				
			||||||
 | 
					                }, // 27 = Escape
 | 
				
			||||||
 | 
					                onBackspace: {
 | 
				
			||||||
 | 
					                  key: 8,
 | 
				
			||||||
 | 
					                  handler: () => callbacksRef.current.onBackspace(),
 | 
				
			||||||
 | 
					                }, // 8 = Backspace
 | 
				
			||||||
              },
 | 
					              },
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            emojiCompletion: {
 | 
					            emojiCompletion: {
 | 
				
			||||||
              setEmojiPickerElement: setEmojiCompletionElement,
 | 
					              setEmojiPickerElement: setEmojiCompletionElement,
 | 
				
			||||||
              onPickEmoji,
 | 
					              onPickEmoji: (emoji: EmojiPickDataType) =>
 | 
				
			||||||
 | 
					                callbacksRef.current.onPickEmoji(emoji),
 | 
				
			||||||
              skinTone,
 | 
					              skinTone,
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            mentionCompletion: {
 | 
					            mentionCompletion: {
 | 
				
			||||||
| 
						 | 
					@ -528,7 +567,10 @@ export const CompositionInput: React.ComponentType<Props> = props => {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              // force the tab handler to be prepended, otherwise it won't be
 | 
					              // force the tab handler to be prepended, otherwise it won't be
 | 
				
			||||||
              // executed: https://github.com/quilljs/quill/issues/1967
 | 
					              // executed: https://github.com/quilljs/quill/issues/1967
 | 
				
			||||||
              keyboard.bindings[9].unshift({ key: 9, handler: onTab }); // 9 = Tab
 | 
					              keyboard.bindings[9].unshift({
 | 
				
			||||||
 | 
					                key: 9,
 | 
				
			||||||
 | 
					                handler: () => callbacksRef.current.onTab(),
 | 
				
			||||||
 | 
					              }); // 9 = Tab
 | 
				
			||||||
              // also, remove the default \t insertion binding
 | 
					              // also, remove the default \t insertion binding
 | 
				
			||||||
              keyboard.bindings[9].pop();
 | 
					              keyboard.bindings[9].pop();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -583,7 +625,13 @@ export const CompositionInput: React.ComponentType<Props> = props => {
 | 
				
			||||||
    <Manager>
 | 
					    <Manager>
 | 
				
			||||||
      <Reference>
 | 
					      <Reference>
 | 
				
			||||||
        {({ ref }) => (
 | 
					        {({ ref }) => (
 | 
				
			||||||
          <div className="module-composition-input__input" ref={ref}>
 | 
					          <div
 | 
				
			||||||
 | 
					            className={classNames(
 | 
				
			||||||
 | 
					              'module-composition-input__input',
 | 
				
			||||||
 | 
					              getClassName(moduleClassName, '__input')
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					            ref={ref}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
            <div
 | 
					            <div
 | 
				
			||||||
              ref={scrollerRef}
 | 
					              ref={scrollerRef}
 | 
				
			||||||
              onClick={focus}
 | 
					              onClick={focus}
 | 
				
			||||||
| 
						 | 
					@ -591,6 +639,10 @@ export const CompositionInput: React.ComponentType<Props> = props => {
 | 
				
			||||||
                'module-composition-input__input__scroller',
 | 
					                'module-composition-input__input__scroller',
 | 
				
			||||||
                large
 | 
					                large
 | 
				
			||||||
                  ? 'module-composition-input__input__scroller--large'
 | 
					                  ? 'module-composition-input__input__scroller--large'
 | 
				
			||||||
 | 
					                  : null,
 | 
				
			||||||
 | 
					                getClassName(moduleClassName, '__scroller'),
 | 
				
			||||||
 | 
					                large
 | 
				
			||||||
 | 
					                  ? getClassName(moduleClassName, '__scroller--large')
 | 
				
			||||||
                  : null
 | 
					                  : null
 | 
				
			||||||
              )}
 | 
					              )}
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										119
									
								
								ts/components/ForwardMessageModal.stories.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								ts/components/ForwardMessageModal.stories.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,119 @@
 | 
				
			||||||
 | 
					// Copyright 2021 Signal Messenger, LLC
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import * as React from 'react';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { storiesOf } from '@storybook/react';
 | 
				
			||||||
 | 
					import { action } from '@storybook/addon-actions';
 | 
				
			||||||
 | 
					import { text } from '@storybook/addon-knobs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import enMessages from '../../_locales/en/messages.json';
 | 
				
			||||||
 | 
					import { AttachmentType } from '../types/Attachment';
 | 
				
			||||||
 | 
					import { ForwardMessageModal, PropsType } from './ForwardMessageModal';
 | 
				
			||||||
 | 
					import { IMAGE_JPEG, MIMEType, VIDEO_MP4 } from '../types/MIME';
 | 
				
			||||||
 | 
					import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
 | 
				
			||||||
 | 
					import { setup as setupI18n } from '../../js/modules/i18n';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const createAttachment = (
 | 
				
			||||||
 | 
					  props: Partial<AttachmentType> = {}
 | 
				
			||||||
 | 
					): AttachmentType => ({
 | 
				
			||||||
 | 
					  contentType: text(
 | 
				
			||||||
 | 
					    'attachment contentType',
 | 
				
			||||||
 | 
					    props.contentType || ''
 | 
				
			||||||
 | 
					  ) as MIMEType,
 | 
				
			||||||
 | 
					  fileName: text('attachment fileName', props.fileName || ''),
 | 
				
			||||||
 | 
					  screenshot: props.screenshot,
 | 
				
			||||||
 | 
					  url: text('attachment url', props.url || ''),
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const story = storiesOf('Components/ForwardMessageModal', module);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const i18n = setupI18n('en', enMessages);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const LONG_TITLE =
 | 
				
			||||||
 | 
					  "This is a super-sweet site. And it's got some really amazing content in store for you if you just click that link. Can you click that link for me?";
 | 
				
			||||||
 | 
					const LONG_DESCRIPTION =
 | 
				
			||||||
 | 
					  "You're gonna love this description. Not only does it have a lot of characters, but it will also be truncated in the UI. How cool is that??";
 | 
				
			||||||
 | 
					const candidateConversations = Array.from(Array(100), () =>
 | 
				
			||||||
 | 
					  getDefaultConversation()
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
 | 
				
			||||||
 | 
					  attachments: overrideProps.attachments,
 | 
				
			||||||
 | 
					  candidateConversations,
 | 
				
			||||||
 | 
					  doForwardMessage: action('doForwardMessage'),
 | 
				
			||||||
 | 
					  i18n,
 | 
				
			||||||
 | 
					  isSticker: Boolean(overrideProps.isSticker),
 | 
				
			||||||
 | 
					  linkPreview: overrideProps.linkPreview,
 | 
				
			||||||
 | 
					  messageBody: text('messageBody', overrideProps.messageBody || ''),
 | 
				
			||||||
 | 
					  onClose: action('onClose'),
 | 
				
			||||||
 | 
					  onEditorStateChange: action('onEditorStateChange'),
 | 
				
			||||||
 | 
					  onPickEmoji: action('onPickEmoji'),
 | 
				
			||||||
 | 
					  onTextTooLong: action('onTextTooLong'),
 | 
				
			||||||
 | 
					  onSetSkinTone: action('onSetSkinTone'),
 | 
				
			||||||
 | 
					  recentEmojis: [],
 | 
				
			||||||
 | 
					  removeLinkPreview: action('removeLinkPreview'),
 | 
				
			||||||
 | 
					  skinTone: 0,
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					story.add('Modal', () => {
 | 
				
			||||||
 | 
					  return <ForwardMessageModal {...createProps()} />;
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					story.add('with text', () => {
 | 
				
			||||||
 | 
					  return <ForwardMessageModal {...createProps({ messageBody: 'sup' })} />;
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					story.add('a sticker', () => {
 | 
				
			||||||
 | 
					  return <ForwardMessageModal {...createProps({ isSticker: true })} />;
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					story.add('link preview', () => {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <ForwardMessageModal
 | 
				
			||||||
 | 
					      {...createProps({
 | 
				
			||||||
 | 
					        linkPreview: {
 | 
				
			||||||
 | 
					          description: LONG_DESCRIPTION,
 | 
				
			||||||
 | 
					          date: Date.now(),
 | 
				
			||||||
 | 
					          domain: 'https://www.signal.org',
 | 
				
			||||||
 | 
					          url: 'signal.org',
 | 
				
			||||||
 | 
					          image: createAttachment({
 | 
				
			||||||
 | 
					            url: '/fixtures/kitten-4-112-112.jpg',
 | 
				
			||||||
 | 
					            contentType: IMAGE_JPEG,
 | 
				
			||||||
 | 
					          }),
 | 
				
			||||||
 | 
					          isStickerPack: false,
 | 
				
			||||||
 | 
					          title: LONG_TITLE,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        messageBody: 'signal.org',
 | 
				
			||||||
 | 
					      })}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					story.add('media attachments', () => {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <ForwardMessageModal
 | 
				
			||||||
 | 
					      {...createProps({
 | 
				
			||||||
 | 
					        attachments: [
 | 
				
			||||||
 | 
					          createAttachment({
 | 
				
			||||||
 | 
					            contentType: IMAGE_JPEG,
 | 
				
			||||||
 | 
					            fileName: 'tina-rolf-269345-unsplash.jpg',
 | 
				
			||||||
 | 
					            url: '/fixtures/tina-rolf-269345-unsplash.jpg',
 | 
				
			||||||
 | 
					          }),
 | 
				
			||||||
 | 
					          createAttachment({
 | 
				
			||||||
 | 
					            contentType: VIDEO_MP4,
 | 
				
			||||||
 | 
					            fileName: 'pixabay-Soap-Bubble-7141.mp4',
 | 
				
			||||||
 | 
					            url: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
 | 
				
			||||||
 | 
					            screenshot: {
 | 
				
			||||||
 | 
					              height: 112,
 | 
				
			||||||
 | 
					              width: 112,
 | 
				
			||||||
 | 
					              url: '/fixtures/kitten-4-112-112.jpg',
 | 
				
			||||||
 | 
					              contentType: IMAGE_JPEG,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          }),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        messageBody: 'cats',
 | 
				
			||||||
 | 
					      })}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										409
									
								
								ts/components/ForwardMessageModal.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										409
									
								
								ts/components/ForwardMessageModal.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,409 @@
 | 
				
			||||||
 | 
					// Copyright 2021 Signal Messenger, LLC
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import React, {
 | 
				
			||||||
 | 
					  FunctionComponent,
 | 
				
			||||||
 | 
					  useCallback,
 | 
				
			||||||
 | 
					  useEffect,
 | 
				
			||||||
 | 
					  useMemo,
 | 
				
			||||||
 | 
					  useRef,
 | 
				
			||||||
 | 
					  useState,
 | 
				
			||||||
 | 
					} from 'react';
 | 
				
			||||||
 | 
					import Measure, { MeasuredComponentProps } from 'react-measure';
 | 
				
			||||||
 | 
					import { noop } from 'lodash';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import classNames from 'classnames';
 | 
				
			||||||
 | 
					import { AttachmentList } from './conversation/AttachmentList';
 | 
				
			||||||
 | 
					import { AttachmentType } from '../types/Attachment';
 | 
				
			||||||
 | 
					import { Button } from './Button';
 | 
				
			||||||
 | 
					import { CompositionInput, InputApi } from './CompositionInput';
 | 
				
			||||||
 | 
					import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
 | 
				
			||||||
 | 
					import { ConversationList, Row, RowType } from './ConversationList';
 | 
				
			||||||
 | 
					import { ConversationType } from '../state/ducks/conversations';
 | 
				
			||||||
 | 
					import { EmojiButton, Props as EmojiButtonProps } from './emoji/EmojiButton';
 | 
				
			||||||
 | 
					import { EmojiPickDataType } from './emoji/EmojiPicker';
 | 
				
			||||||
 | 
					import { LinkPreviewType } from '../types/message/LinkPreviews';
 | 
				
			||||||
 | 
					import { BodyRangeType, LocalizerType } from '../types/Util';
 | 
				
			||||||
 | 
					import { ModalHost } from './ModalHost';
 | 
				
			||||||
 | 
					import { StagedLinkPreview } from './conversation/StagedLinkPreview';
 | 
				
			||||||
 | 
					import { assert } from '../util/assert';
 | 
				
			||||||
 | 
					import { filterAndSortConversations } from '../util/filterAndSortConversations';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type DataPropsType = {
 | 
				
			||||||
 | 
					  attachments?: Array<AttachmentType>;
 | 
				
			||||||
 | 
					  candidateConversations: ReadonlyArray<ConversationType>;
 | 
				
			||||||
 | 
					  doForwardMessage: (
 | 
				
			||||||
 | 
					    selectedContacts: Array<string>,
 | 
				
			||||||
 | 
					    messageBody?: string,
 | 
				
			||||||
 | 
					    attachments?: Array<AttachmentType>,
 | 
				
			||||||
 | 
					    linkPreview?: LinkPreviewType
 | 
				
			||||||
 | 
					  ) => void;
 | 
				
			||||||
 | 
					  i18n: LocalizerType;
 | 
				
			||||||
 | 
					  isSticker: boolean;
 | 
				
			||||||
 | 
					  linkPreview?: LinkPreviewType;
 | 
				
			||||||
 | 
					  messageBody?: string;
 | 
				
			||||||
 | 
					  onClose: () => void;
 | 
				
			||||||
 | 
					  onEditorStateChange: (
 | 
				
			||||||
 | 
					    messageText: string,
 | 
				
			||||||
 | 
					    bodyRanges: Array<BodyRangeType>,
 | 
				
			||||||
 | 
					    caretLocation?: number
 | 
				
			||||||
 | 
					  ) => unknown;
 | 
				
			||||||
 | 
					  onTextTooLong: () => void;
 | 
				
			||||||
 | 
					} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ActionPropsType = Pick<
 | 
				
			||||||
 | 
					  EmojiButtonProps,
 | 
				
			||||||
 | 
					  'onPickEmoji' | 'onSetSkinTone'
 | 
				
			||||||
 | 
					> & {
 | 
				
			||||||
 | 
					  removeLinkPreview: () => void;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type PropsType = DataPropsType & ActionPropsType;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const MAX_FORWARD = 5;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const ForwardMessageModal: FunctionComponent<PropsType> = ({
 | 
				
			||||||
 | 
					  attachments,
 | 
				
			||||||
 | 
					  candidateConversations,
 | 
				
			||||||
 | 
					  doForwardMessage,
 | 
				
			||||||
 | 
					  i18n,
 | 
				
			||||||
 | 
					  isSticker,
 | 
				
			||||||
 | 
					  linkPreview,
 | 
				
			||||||
 | 
					  messageBody,
 | 
				
			||||||
 | 
					  onClose,
 | 
				
			||||||
 | 
					  onEditorStateChange,
 | 
				
			||||||
 | 
					  onPickEmoji,
 | 
				
			||||||
 | 
					  onSetSkinTone,
 | 
				
			||||||
 | 
					  onTextTooLong,
 | 
				
			||||||
 | 
					  recentEmojis,
 | 
				
			||||||
 | 
					  removeLinkPreview,
 | 
				
			||||||
 | 
					  skinTone,
 | 
				
			||||||
 | 
					}) => {
 | 
				
			||||||
 | 
					  const inputRef = useRef<null | HTMLInputElement>(null);
 | 
				
			||||||
 | 
					  const inputApiRef = React.useRef<InputApi | undefined>();
 | 
				
			||||||
 | 
					  const [selectedContacts, setSelectedContacts] = useState<
 | 
				
			||||||
 | 
					    Array<ConversationType>
 | 
				
			||||||
 | 
					  >([]);
 | 
				
			||||||
 | 
					  const [searchTerm, setSearchTerm] = useState('');
 | 
				
			||||||
 | 
					  const [filteredContacts, setFilteredContacts] = useState(
 | 
				
			||||||
 | 
					    filterAndSortConversations(candidateConversations, '')
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					  const [attachmentsToForward, setAttachmentsToForward] = useState(attachments);
 | 
				
			||||||
 | 
					  const [isEditingMessage, setIsEditingMessage] = useState(false);
 | 
				
			||||||
 | 
					  const [messageBodyText, setMessageBodyText] = useState(messageBody || '');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const isMessageEditable = !isSticker;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const hasSelectedMaximumNumberOfContacts =
 | 
				
			||||||
 | 
					    selectedContacts.length >= MAX_FORWARD;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const selectedConversationIdsSet: Set<string> = useMemo(
 | 
				
			||||||
 | 
					    () => new Set(selectedContacts.map(contact => contact.id)),
 | 
				
			||||||
 | 
					    [selectedContacts]
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const focusTextEditInput = React.useCallback(() => {
 | 
				
			||||||
 | 
					    if (inputApiRef.current) {
 | 
				
			||||||
 | 
					      inputApiRef.current.focus();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [inputApiRef]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const insertEmoji = React.useCallback(
 | 
				
			||||||
 | 
					    (e: EmojiPickDataType) => {
 | 
				
			||||||
 | 
					      if (inputApiRef.current) {
 | 
				
			||||||
 | 
					        inputApiRef.current.insertEmoji(e);
 | 
				
			||||||
 | 
					        onPickEmoji(e);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    [inputApiRef, onPickEmoji]
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const forwardMessage = React.useCallback(() => {
 | 
				
			||||||
 | 
					    if (!messageBodyText) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    doForwardMessage(
 | 
				
			||||||
 | 
					      selectedContacts.map(contact => contact.id),
 | 
				
			||||||
 | 
					      messageBodyText,
 | 
				
			||||||
 | 
					      attachmentsToForward,
 | 
				
			||||||
 | 
					      linkPreview
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }, [
 | 
				
			||||||
 | 
					    attachmentsToForward,
 | 
				
			||||||
 | 
					    doForwardMessage,
 | 
				
			||||||
 | 
					    linkPreview,
 | 
				
			||||||
 | 
					    messageBodyText,
 | 
				
			||||||
 | 
					    selectedContacts,
 | 
				
			||||||
 | 
					  ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const hasContactsSelected = Boolean(selectedContacts.length);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const canForwardMessage =
 | 
				
			||||||
 | 
					    hasContactsSelected &&
 | 
				
			||||||
 | 
					    (Boolean(messageBodyText) ||
 | 
				
			||||||
 | 
					      isSticker ||
 | 
				
			||||||
 | 
					      (attachmentsToForward && attachmentsToForward.length));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const normalizedSearchTerm = searchTerm.trim();
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    const timeout = setTimeout(() => {
 | 
				
			||||||
 | 
					      setFilteredContacts(
 | 
				
			||||||
 | 
					        filterAndSortConversations(candidateConversations, normalizedSearchTerm)
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }, 200);
 | 
				
			||||||
 | 
					    return () => {
 | 
				
			||||||
 | 
					      clearTimeout(timeout);
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }, [candidateConversations, normalizedSearchTerm, setFilteredContacts]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const contactLookup = useMemo(() => {
 | 
				
			||||||
 | 
					    const map = new Map();
 | 
				
			||||||
 | 
					    candidateConversations.forEach(contact => {
 | 
				
			||||||
 | 
					      map.set(contact.id, contact);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    return map;
 | 
				
			||||||
 | 
					  }, [candidateConversations]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const toggleSelectedContact = useCallback(
 | 
				
			||||||
 | 
					    (conversationId: string) => {
 | 
				
			||||||
 | 
					      let removeContact = false;
 | 
				
			||||||
 | 
					      const nextSelectedContacts = selectedContacts.filter(contact => {
 | 
				
			||||||
 | 
					        if (contact.id === conversationId) {
 | 
				
			||||||
 | 
					          removeContact = true;
 | 
				
			||||||
 | 
					          return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return true;
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      if (removeContact) {
 | 
				
			||||||
 | 
					        setSelectedContacts(nextSelectedContacts);
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      const selectedContact = contactLookup.get(conversationId);
 | 
				
			||||||
 | 
					      if (selectedContact) {
 | 
				
			||||||
 | 
					        setSelectedContacts([...nextSelectedContacts, selectedContact]);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    [contactLookup, selectedContacts, setSelectedContacts]
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const rowCount = filteredContacts.length;
 | 
				
			||||||
 | 
					  const getRow = (index: number): undefined | Row => {
 | 
				
			||||||
 | 
					    const contact = filteredContacts[index];
 | 
				
			||||||
 | 
					    if (!contact) {
 | 
				
			||||||
 | 
					      return undefined;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const isSelected = selectedConversationIdsSet.has(contact.id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let disabledReason: undefined | ContactCheckboxDisabledReason;
 | 
				
			||||||
 | 
					    if (hasSelectedMaximumNumberOfContacts && !isSelected) {
 | 
				
			||||||
 | 
					      disabledReason = ContactCheckboxDisabledReason.MaximumContactsSelected;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      type: RowType.ContactCheckbox,
 | 
				
			||||||
 | 
					      contact,
 | 
				
			||||||
 | 
					      isChecked: isSelected,
 | 
				
			||||||
 | 
					      disabledReason,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <ModalHost onClose={onClose}>
 | 
				
			||||||
 | 
					      <div className="module-ForwardMessageModal">
 | 
				
			||||||
 | 
					        <div
 | 
				
			||||||
 | 
					          className={classNames('module-ForwardMessageModal__header', {
 | 
				
			||||||
 | 
					            'module-ForwardMessageModal__header--edit': isEditingMessage,
 | 
				
			||||||
 | 
					          })}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          {isEditingMessage ? (
 | 
				
			||||||
 | 
					            <button
 | 
				
			||||||
 | 
					              aria-label={i18n('back')}
 | 
				
			||||||
 | 
					              className="module-ForwardMessageModal__header--back"
 | 
				
			||||||
 | 
					              onClick={() => setIsEditingMessage(false)}
 | 
				
			||||||
 | 
					              type="button"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					               
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					          ) : (
 | 
				
			||||||
 | 
					            <button
 | 
				
			||||||
 | 
					              aria-label={i18n('cancel')}
 | 
				
			||||||
 | 
					              className="module-ForwardMessageModal__header--cancel"
 | 
				
			||||||
 | 
					              onClick={onClose}
 | 
				
			||||||
 | 
					              type="button"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              {i18n('cancel')}
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					          <h1>{i18n('forwardMessage')}</h1>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        {isEditingMessage ? (
 | 
				
			||||||
 | 
					          <div className="module-ForwardMessageModal__main-body">
 | 
				
			||||||
 | 
					            {linkPreview ? (
 | 
				
			||||||
 | 
					              <div className="module-ForwardMessageModal--link-preview">
 | 
				
			||||||
 | 
					                <StagedLinkPreview
 | 
				
			||||||
 | 
					                  date={linkPreview.date || null}
 | 
				
			||||||
 | 
					                  description={linkPreview.description || ''}
 | 
				
			||||||
 | 
					                  domain={linkPreview.url}
 | 
				
			||||||
 | 
					                  i18n={i18n}
 | 
				
			||||||
 | 
					                  image={linkPreview.image}
 | 
				
			||||||
 | 
					                  isLoaded
 | 
				
			||||||
 | 
					                  onClose={() => removeLinkPreview()}
 | 
				
			||||||
 | 
					                  title={linkPreview.title}
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            ) : null}
 | 
				
			||||||
 | 
					            {attachmentsToForward && attachmentsToForward.length ? (
 | 
				
			||||||
 | 
					              <AttachmentList
 | 
				
			||||||
 | 
					                attachments={attachmentsToForward}
 | 
				
			||||||
 | 
					                i18n={i18n}
 | 
				
			||||||
 | 
					                onCloseAttachment={(attachment: AttachmentType) => {
 | 
				
			||||||
 | 
					                  const newAttachments = attachmentsToForward.filter(
 | 
				
			||||||
 | 
					                    currentAttachment => currentAttachment !== attachment
 | 
				
			||||||
 | 
					                  );
 | 
				
			||||||
 | 
					                  setAttachmentsToForward(newAttachments);
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					            ) : null}
 | 
				
			||||||
 | 
					            <div className="module-ForwardMessageModal__text-edit-area">
 | 
				
			||||||
 | 
					              <CompositionInput
 | 
				
			||||||
 | 
					                clearQuotedMessage={shouldNeverBeCalled}
 | 
				
			||||||
 | 
					                draftText={messageBodyText}
 | 
				
			||||||
 | 
					                getQuotedMessage={noop}
 | 
				
			||||||
 | 
					                i18n={i18n}
 | 
				
			||||||
 | 
					                inputApi={inputApiRef}
 | 
				
			||||||
 | 
					                large
 | 
				
			||||||
 | 
					                moduleClassName="module-ForwardMessageModal__input"
 | 
				
			||||||
 | 
					                onEditorStateChange={(
 | 
				
			||||||
 | 
					                  messageText,
 | 
				
			||||||
 | 
					                  bodyRanges,
 | 
				
			||||||
 | 
					                  caretLocation
 | 
				
			||||||
 | 
					                ) => {
 | 
				
			||||||
 | 
					                  setMessageBodyText(messageText);
 | 
				
			||||||
 | 
					                  onEditorStateChange(messageText, bodyRanges, caretLocation);
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					                onPickEmoji={onPickEmoji}
 | 
				
			||||||
 | 
					                onSubmit={forwardMessage}
 | 
				
			||||||
 | 
					                onTextTooLong={onTextTooLong}
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					              <div className="module-ForwardMessageModal__emoji">
 | 
				
			||||||
 | 
					                <EmojiButton
 | 
				
			||||||
 | 
					                  doSend={noop}
 | 
				
			||||||
 | 
					                  i18n={i18n}
 | 
				
			||||||
 | 
					                  onClose={focusTextEditInput}
 | 
				
			||||||
 | 
					                  onPickEmoji={insertEmoji}
 | 
				
			||||||
 | 
					                  onSetSkinTone={onSetSkinTone}
 | 
				
			||||||
 | 
					                  recentEmojis={recentEmojis}
 | 
				
			||||||
 | 
					                  skinTone={skinTone}
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        ) : (
 | 
				
			||||||
 | 
					          <div className="module-ForwardMessageModal__main-body">
 | 
				
			||||||
 | 
					            <div className="module-ForwardMessageModal__search">
 | 
				
			||||||
 | 
					              <i className="module-ForwardMessageModal__search--icon" />
 | 
				
			||||||
 | 
					              <input
 | 
				
			||||||
 | 
					                type="text"
 | 
				
			||||||
 | 
					                className="module-ForwardMessageModal__search--input"
 | 
				
			||||||
 | 
					                disabled={candidateConversations.length === 0}
 | 
				
			||||||
 | 
					                placeholder={i18n('contactSearchPlaceholder')}
 | 
				
			||||||
 | 
					                onChange={event => {
 | 
				
			||||||
 | 
					                  setSearchTerm(event.target.value);
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					                ref={inputRef}
 | 
				
			||||||
 | 
					                value={searchTerm}
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            {candidateConversations.length ? (
 | 
				
			||||||
 | 
					              <Measure bounds>
 | 
				
			||||||
 | 
					                {({ contentRect, measureRef }: MeasuredComponentProps) => {
 | 
				
			||||||
 | 
					                  // We disable this ESLint rule because we're capturing a bubbled keydown
 | 
				
			||||||
 | 
					                  //   event. See [this note in the jsx-a11y docs][0].
 | 
				
			||||||
 | 
					                  //
 | 
				
			||||||
 | 
					                  // [0]: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/c275964f52c35775208bd00cb612c6f82e42e34f/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
 | 
				
			||||||
 | 
					                  /* eslint-disable jsx-a11y/no-static-element-interactions */
 | 
				
			||||||
 | 
					                  return (
 | 
				
			||||||
 | 
					                    <div
 | 
				
			||||||
 | 
					                      className="module-ForwardMessageModal__list-wrapper"
 | 
				
			||||||
 | 
					                      ref={measureRef}
 | 
				
			||||||
 | 
					                      onKeyDown={event => {
 | 
				
			||||||
 | 
					                        if (event.key === 'Enter') {
 | 
				
			||||||
 | 
					                          inputRef.current?.focus();
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                      }}
 | 
				
			||||||
 | 
					                    >
 | 
				
			||||||
 | 
					                      <ConversationList
 | 
				
			||||||
 | 
					                        dimensions={contentRect.bounds}
 | 
				
			||||||
 | 
					                        getRow={getRow}
 | 
				
			||||||
 | 
					                        i18n={i18n}
 | 
				
			||||||
 | 
					                        onClickArchiveButton={shouldNeverBeCalled}
 | 
				
			||||||
 | 
					                        onClickContactCheckbox={(
 | 
				
			||||||
 | 
					                          conversationId: string,
 | 
				
			||||||
 | 
					                          disabledReason:
 | 
				
			||||||
 | 
					                            | undefined
 | 
				
			||||||
 | 
					                            | ContactCheckboxDisabledReason
 | 
				
			||||||
 | 
					                        ) => {
 | 
				
			||||||
 | 
					                          if (
 | 
				
			||||||
 | 
					                            disabledReason !==
 | 
				
			||||||
 | 
					                            ContactCheckboxDisabledReason.MaximumContactsSelected
 | 
				
			||||||
 | 
					                          ) {
 | 
				
			||||||
 | 
					                            toggleSelectedContact(conversationId);
 | 
				
			||||||
 | 
					                          }
 | 
				
			||||||
 | 
					                        }}
 | 
				
			||||||
 | 
					                        onSelectConversation={shouldNeverBeCalled}
 | 
				
			||||||
 | 
					                        renderMessageSearchResult={() => {
 | 
				
			||||||
 | 
					                          shouldNeverBeCalled();
 | 
				
			||||||
 | 
					                          return <div />;
 | 
				
			||||||
 | 
					                        }}
 | 
				
			||||||
 | 
					                        rowCount={rowCount}
 | 
				
			||||||
 | 
					                        shouldRecomputeRowHeights={false}
 | 
				
			||||||
 | 
					                        showChooseGroupMembers={shouldNeverBeCalled}
 | 
				
			||||||
 | 
					                        startNewConversationFromPhoneNumber={
 | 
				
			||||||
 | 
					                          shouldNeverBeCalled
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                      />
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                  );
 | 
				
			||||||
 | 
					                  /* eslint-enable jsx-a11y/no-static-element-interactions */
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					              </Measure>
 | 
				
			||||||
 | 
					            ) : (
 | 
				
			||||||
 | 
					              <div className="module-ForwardMessageModal__no-candidate-contacts">
 | 
				
			||||||
 | 
					                {i18n('noContactsFound')}
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					        <div className="module-ForwardMessageModal__footer">
 | 
				
			||||||
 | 
					          <div>
 | 
				
			||||||
 | 
					            {Boolean(selectedContacts.length) &&
 | 
				
			||||||
 | 
					              selectedContacts.map(contact => contact.title).join(', ')}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div>
 | 
				
			||||||
 | 
					            {isEditingMessage || !isMessageEditable ? (
 | 
				
			||||||
 | 
					              <Button
 | 
				
			||||||
 | 
					                aria-label={i18n('ForwardMessageModal--continue')}
 | 
				
			||||||
 | 
					                className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward"
 | 
				
			||||||
 | 
					                disabled={!canForwardMessage}
 | 
				
			||||||
 | 
					                onClick={forwardMessage}
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					            ) : (
 | 
				
			||||||
 | 
					              <Button
 | 
				
			||||||
 | 
					                aria-label={i18n('forwardMessage')}
 | 
				
			||||||
 | 
					                className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--continue"
 | 
				
			||||||
 | 
					                disabled={!hasContactsSelected}
 | 
				
			||||||
 | 
					                onClick={() => setIsEditingMessage(true)}
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </ModalHost>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function shouldNeverBeCalled(..._args: ReadonlyArray<unknown>): void {
 | 
				
			||||||
 | 
					  assert(false, 'This should never be called. Doing nothing');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -18,10 +18,10 @@ import {
 | 
				
			||||||
export type Props = {
 | 
					export type Props = {
 | 
				
			||||||
  attachments: Array<AttachmentType>;
 | 
					  attachments: Array<AttachmentType>;
 | 
				
			||||||
  i18n: LocalizerType;
 | 
					  i18n: LocalizerType;
 | 
				
			||||||
  onClickAttachment: (attachment: AttachmentType) => void;
 | 
					  onAddAttachment?: () => void;
 | 
				
			||||||
 | 
					  onClickAttachment?: (attachment: AttachmentType) => void;
 | 
				
			||||||
 | 
					  onClose?: () => void;
 | 
				
			||||||
  onCloseAttachment: (attachment: AttachmentType) => void;
 | 
					  onCloseAttachment: (attachment: AttachmentType) => void;
 | 
				
			||||||
  onAddAttachment: () => void;
 | 
					 | 
				
			||||||
  onClose: () => void;
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const IMAGE_WIDTH = 120;
 | 
					const IMAGE_WIDTH = 120;
 | 
				
			||||||
| 
						 | 
					@ -47,7 +47,7 @@ export const AttachmentList = ({
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className="module-attachments">
 | 
					    <div className="module-attachments">
 | 
				
			||||||
      {attachments.length > 1 ? (
 | 
					      {onClose && attachments.length > 1 ? (
 | 
				
			||||||
        <div className="module-attachments__header">
 | 
					        <div className="module-attachments__header">
 | 
				
			||||||
          <button
 | 
					          <button
 | 
				
			||||||
            type="button"
 | 
					            type="button"
 | 
				
			||||||
| 
						 | 
					@ -105,7 +105,7 @@ export const AttachmentList = ({
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          );
 | 
					          );
 | 
				
			||||||
        })}
 | 
					        })}
 | 
				
			||||||
        {allVisualAttachments ? (
 | 
					        {allVisualAttachments && onAddAttachment ? (
 | 
				
			||||||
          <StagedPlaceholderAttachment onClick={onAddAttachment} i18n={i18n} />
 | 
					          <StagedPlaceholderAttachment onClick={onAddAttachment} i18n={i18n} />
 | 
				
			||||||
        ) : null}
 | 
					        ) : null}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -130,6 +130,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
 | 
				
			||||||
  showExpiredOutgoingTapToViewToast: action(
 | 
					  showExpiredOutgoingTapToViewToast: action(
 | 
				
			||||||
    'showExpiredOutgoingTapToViewToast'
 | 
					    'showExpiredOutgoingTapToViewToast'
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
 | 
					  showForwardMessageModal: action('showForwardMessageModal'),
 | 
				
			||||||
  showMessageDetail: action('showMessageDetail'),
 | 
					  showMessageDetail: action('showMessageDetail'),
 | 
				
			||||||
  showVisualAttachment: action('showVisualAttachment'),
 | 
					  showVisualAttachment: action('showVisualAttachment'),
 | 
				
			||||||
  status: overrideProps.status || 'sent',
 | 
					  status: overrideProps.status || 'sent',
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -170,6 +170,7 @@ export type PropsActions = {
 | 
				
			||||||
  ) => void;
 | 
					  ) => void;
 | 
				
			||||||
  replyToMessage: (id: string) => void;
 | 
					  replyToMessage: (id: string) => void;
 | 
				
			||||||
  retrySend: (id: string) => void;
 | 
					  retrySend: (id: string) => void;
 | 
				
			||||||
 | 
					  showForwardMessageModal: (id: string) => void;
 | 
				
			||||||
  deleteMessage: (id: string) => void;
 | 
					  deleteMessage: (id: string) => void;
 | 
				
			||||||
  deleteMessageForEveryone: (id: string) => void;
 | 
					  deleteMessageForEveryone: (id: string) => void;
 | 
				
			||||||
  showMessageDetail: (id: string) => void;
 | 
					  showMessageDetail: (id: string) => void;
 | 
				
			||||||
| 
						 | 
					@ -1401,6 +1402,7 @@ export class Message extends React.PureComponent<Props, State> {
 | 
				
			||||||
      canReply,
 | 
					      canReply,
 | 
				
			||||||
      deleteMessage,
 | 
					      deleteMessage,
 | 
				
			||||||
      deleteMessageForEveryone,
 | 
					      deleteMessageForEveryone,
 | 
				
			||||||
 | 
					      deletedForEveryone,
 | 
				
			||||||
      direction,
 | 
					      direction,
 | 
				
			||||||
      i18n,
 | 
					      i18n,
 | 
				
			||||||
      id,
 | 
					      id,
 | 
				
			||||||
| 
						 | 
					@ -1408,10 +1410,13 @@ export class Message extends React.PureComponent<Props, State> {
 | 
				
			||||||
      isTapToView,
 | 
					      isTapToView,
 | 
				
			||||||
      replyToMessage,
 | 
					      replyToMessage,
 | 
				
			||||||
      retrySend,
 | 
					      retrySend,
 | 
				
			||||||
 | 
					      showForwardMessageModal,
 | 
				
			||||||
      showMessageDetail,
 | 
					      showMessageDetail,
 | 
				
			||||||
      status,
 | 
					      status,
 | 
				
			||||||
    } = this.props;
 | 
					    } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const canForward = !isTapToView && !deletedForEveryone;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { canDeleteForEveryone } = this.state;
 | 
					    const { canDeleteForEveryone } = this.state;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const showRetry =
 | 
					    const showRetry =
 | 
				
			||||||
| 
						 | 
					@ -1499,6 +1504,22 @@ export class Message extends React.PureComponent<Props, State> {
 | 
				
			||||||
            {i18n('retrySend')}
 | 
					            {i18n('retrySend')}
 | 
				
			||||||
          </MenuItem>
 | 
					          </MenuItem>
 | 
				
			||||||
        ) : null}
 | 
					        ) : null}
 | 
				
			||||||
 | 
					        {canForward ? (
 | 
				
			||||||
 | 
					          <MenuItem
 | 
				
			||||||
 | 
					            attributes={{
 | 
				
			||||||
 | 
					              className:
 | 
				
			||||||
 | 
					                'module-message__context--icon module-message__context__forward-message',
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					            onClick={(event: React.MouseEvent) => {
 | 
				
			||||||
 | 
					              event.stopPropagation();
 | 
				
			||||||
 | 
					              event.preventDefault();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              showForwardMessageModal(id);
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            {i18n('forwardMessage')}
 | 
				
			||||||
 | 
					          </MenuItem>
 | 
				
			||||||
 | 
					        ) : null}
 | 
				
			||||||
        <MenuItem
 | 
					        <MenuItem
 | 
				
			||||||
          attributes={{
 | 
					          attributes={{
 | 
				
			||||||
            className:
 | 
					            className:
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -72,6 +72,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
 | 
				
			||||||
  showContactModal: () => null,
 | 
					  showContactModal: () => null,
 | 
				
			||||||
  showExpiredIncomingTapToViewToast: () => null,
 | 
					  showExpiredIncomingTapToViewToast: () => null,
 | 
				
			||||||
  showExpiredOutgoingTapToViewToast: () => null,
 | 
					  showExpiredOutgoingTapToViewToast: () => null,
 | 
				
			||||||
 | 
					  showForwardMessageModal: () => null,
 | 
				
			||||||
  showVisualAttachment: () => null,
 | 
					  showVisualAttachment: () => null,
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -65,6 +65,7 @@ export type Props = {
 | 
				
			||||||
  | 'showContactModal'
 | 
					  | 'showContactModal'
 | 
				
			||||||
  | 'showExpiredIncomingTapToViewToast'
 | 
					  | 'showExpiredIncomingTapToViewToast'
 | 
				
			||||||
  | 'showExpiredOutgoingTapToViewToast'
 | 
					  | 'showExpiredOutgoingTapToViewToast'
 | 
				
			||||||
 | 
					  | 'showForwardMessageModal'
 | 
				
			||||||
  | 'showVisualAttachment'
 | 
					  | 'showVisualAttachment'
 | 
				
			||||||
>;
 | 
					>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -235,6 +236,7 @@ export class MessageDetail extends React.Component<Props> {
 | 
				
			||||||
      showContactModal,
 | 
					      showContactModal,
 | 
				
			||||||
      showExpiredIncomingTapToViewToast,
 | 
					      showExpiredIncomingTapToViewToast,
 | 
				
			||||||
      showExpiredOutgoingTapToViewToast,
 | 
					      showExpiredOutgoingTapToViewToast,
 | 
				
			||||||
 | 
					      showForwardMessageModal,
 | 
				
			||||||
      showVisualAttachment,
 | 
					      showVisualAttachment,
 | 
				
			||||||
    } = this.props;
 | 
					    } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -263,6 +265,7 @@ export class MessageDetail extends React.Component<Props> {
 | 
				
			||||||
              renderEmojiPicker={renderEmojiPicker}
 | 
					              renderEmojiPicker={renderEmojiPicker}
 | 
				
			||||||
              replyToMessage={replyToMessage}
 | 
					              replyToMessage={replyToMessage}
 | 
				
			||||||
              retrySend={retrySend}
 | 
					              retrySend={retrySend}
 | 
				
			||||||
 | 
					              showForwardMessageModal={showForwardMessageModal}
 | 
				
			||||||
              scrollToQuotedMessage={() => {
 | 
					              scrollToQuotedMessage={() => {
 | 
				
			||||||
                assert(
 | 
					                assert(
 | 
				
			||||||
                  false,
 | 
					                  false,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -61,6 +61,7 @@ const defaultMessageProps: MessagesProps = {
 | 
				
			||||||
  showContactModal: () => null,
 | 
					  showContactModal: () => null,
 | 
				
			||||||
  showExpiredIncomingTapToViewToast: () => null,
 | 
					  showExpiredIncomingTapToViewToast: () => null,
 | 
				
			||||||
  showExpiredOutgoingTapToViewToast: () => null,
 | 
					  showExpiredOutgoingTapToViewToast: () => null,
 | 
				
			||||||
 | 
					  showForwardMessageModal: () => null,
 | 
				
			||||||
  showMessageDetail: () => null,
 | 
					  showMessageDetail: () => null,
 | 
				
			||||||
  showVisualAttachment: () => null,
 | 
					  showVisualAttachment: () => null,
 | 
				
			||||||
  status: 'sent',
 | 
					  status: 'sent',
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -249,6 +249,7 @@ const actions = () => ({
 | 
				
			||||||
  showExpiredOutgoingTapToViewToast: action(
 | 
					  showExpiredOutgoingTapToViewToast: action(
 | 
				
			||||||
    'showExpiredOutgoingTapToViewToast'
 | 
					    'showExpiredOutgoingTapToViewToast'
 | 
				
			||||||
  ),
 | 
					  ),
 | 
				
			||||||
 | 
					  showForwardMessageModal: action('showForwardMessageModal'),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  showIdentity: action('showIdentity'),
 | 
					  showIdentity: action('showIdentity'),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -53,6 +53,7 @@ const getDefaultProps = () => ({
 | 
				
			||||||
  openConversation: action('openConversation'),
 | 
					  openConversation: action('openConversation'),
 | 
				
			||||||
  showContactDetail: action('showContactDetail'),
 | 
					  showContactDetail: action('showContactDetail'),
 | 
				
			||||||
  showContactModal: action('showContactModal'),
 | 
					  showContactModal: action('showContactModal'),
 | 
				
			||||||
 | 
					  showForwardMessageModal: action('showForwardMessageModal'),
 | 
				
			||||||
  showVisualAttachment: action('showVisualAttachment'),
 | 
					  showVisualAttachment: action('showVisualAttachment'),
 | 
				
			||||||
  downloadAttachment: action('downloadAttachment'),
 | 
					  downloadAttachment: action('downloadAttachment'),
 | 
				
			||||||
  displayTapToViewMessage: action('displayTapToViewMessage'),
 | 
					  displayTapToViewMessage: action('displayTapToViewMessage'),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -22,6 +22,7 @@ export type PropsDataType = {
 | 
				
			||||||
  color?: ColorType;
 | 
					  color?: ColorType;
 | 
				
			||||||
  disabledReason?: ContactCheckboxDisabledReason;
 | 
					  disabledReason?: ContactCheckboxDisabledReason;
 | 
				
			||||||
  id: string;
 | 
					  id: string;
 | 
				
			||||||
 | 
					  isMe?: boolean;
 | 
				
			||||||
  isChecked: boolean;
 | 
					  isChecked: boolean;
 | 
				
			||||||
  name?: string;
 | 
					  name?: string;
 | 
				
			||||||
  phoneNumber?: string;
 | 
					  phoneNumber?: string;
 | 
				
			||||||
| 
						 | 
					@ -49,6 +50,7 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
 | 
				
			||||||
    i18n,
 | 
					    i18n,
 | 
				
			||||||
    id,
 | 
					    id,
 | 
				
			||||||
    isChecked,
 | 
					    isChecked,
 | 
				
			||||||
 | 
					    isMe,
 | 
				
			||||||
    name,
 | 
					    name,
 | 
				
			||||||
    onClick,
 | 
					    onClick,
 | 
				
			||||||
    phoneNumber,
 | 
					    phoneNumber,
 | 
				
			||||||
| 
						 | 
					@ -58,7 +60,9 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
 | 
				
			||||||
  }) => {
 | 
					  }) => {
 | 
				
			||||||
    const disabled = Boolean(disabledReason);
 | 
					    const disabled = Boolean(disabledReason);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const headerName = (
 | 
					    const headerName = isMe ? (
 | 
				
			||||||
 | 
					      i18n('noteToSelf')
 | 
				
			||||||
 | 
					    ) : (
 | 
				
			||||||
      <ContactName
 | 
					      <ContactName
 | 
				
			||||||
        phoneNumber={phoneNumber}
 | 
					        phoneNumber={phoneNumber}
 | 
				
			||||||
        name={name}
 | 
					        name={name}
 | 
				
			||||||
| 
						 | 
					@ -91,6 +95,7 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
 | 
				
			||||||
        headerName={headerName}
 | 
					        headerName={headerName}
 | 
				
			||||||
        i18n={i18n}
 | 
					        i18n={i18n}
 | 
				
			||||||
        id={id}
 | 
					        id={id}
 | 
				
			||||||
 | 
					        isMe={isMe}
 | 
				
			||||||
        isSelected={false}
 | 
					        isSelected={false}
 | 
				
			||||||
        messageText={messageText}
 | 
					        messageText={messageText}
 | 
				
			||||||
        name={name}
 | 
					        name={name}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1397,6 +1397,9 @@ export class ConversationModel extends window.Backbone.Model<
 | 
				
			||||||
      sortedGroupMembers,
 | 
					      sortedGroupMembers,
 | 
				
			||||||
      timestamp,
 | 
					      timestamp,
 | 
				
			||||||
      title: this.getTitle()!,
 | 
					      title: this.getTitle()!,
 | 
				
			||||||
 | 
					      searchableTitle: this.isMe()
 | 
				
			||||||
 | 
					        ? window.i18n('noteToSelf')
 | 
				
			||||||
 | 
					        : this.getTitle(),
 | 
				
			||||||
      type: (this.isPrivate() ? 'direct' : 'group') as ConversationTypeType,
 | 
					      type: (this.isPrivate() ? 'direct' : 'group') as ConversationTypeType,
 | 
				
			||||||
      unreadCount: this.get('unreadCount')! || 0,
 | 
					      unreadCount: this.get('unreadCount')! || 0,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
| 
						 | 
					@ -2235,7 +2238,7 @@ export class ConversationModel extends window.Backbone.Model<
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getUntrusted(): Backbone.Collection {
 | 
					  getUntrusted(): Backbone.Collection<ConversationModel> {
 | 
				
			||||||
    if (this.isPrivate()) {
 | 
					    if (this.isPrivate()) {
 | 
				
			||||||
      if (this.isUntrusted()) {
 | 
					      if (this.isUntrusted()) {
 | 
				
			||||||
        return new window.Backbone.Collection([this]);
 | 
					        return new window.Backbone.Collection([this]);
 | 
				
			||||||
| 
						 | 
					@ -2243,16 +2246,14 @@ export class ConversationModel extends window.Backbone.Model<
 | 
				
			||||||
      return new window.Backbone.Collection();
 | 
					      return new window.Backbone.Collection();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
 | 
					 | 
				
			||||||
    const results = this.contactCollection!.map(contact => {
 | 
					 | 
				
			||||||
      if (contact.isMe()) {
 | 
					 | 
				
			||||||
        return [false, contact];
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      return [contact.isUntrusted(), contact];
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return new window.Backbone.Collection(
 | 
					    return new window.Backbone.Collection(
 | 
				
			||||||
      results.filter(result => result[0]).map(result => result[1])
 | 
					      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
 | 
				
			||||||
 | 
					      this.contactCollection!.filter(contact => {
 | 
				
			||||||
 | 
					        if (contact.isMe()) {
 | 
				
			||||||
 | 
					          return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return contact.isUntrusted();
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3320,13 +3321,21 @@ export class ConversationModel extends window.Backbone.Model<
 | 
				
			||||||
    quote: WhatIsThis,
 | 
					    quote: WhatIsThis,
 | 
				
			||||||
    preview: WhatIsThis,
 | 
					    preview: WhatIsThis,
 | 
				
			||||||
    sticker?: WhatIsThis,
 | 
					    sticker?: WhatIsThis,
 | 
				
			||||||
    mentions?: BodyRangesType
 | 
					    mentions?: BodyRangesType,
 | 
				
			||||||
 | 
					    { dontClearDraft = false } = {}
 | 
				
			||||||
  ): void {
 | 
					  ): void {
 | 
				
			||||||
    this.clearTypingTimers();
 | 
					    this.clearTypingTimers();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { clearUnreadMetrics } = window.reduxActions.conversations;
 | 
					    const { clearUnreadMetrics } = window.reduxActions.conversations;
 | 
				
			||||||
    clearUnreadMetrics(this.id);
 | 
					    clearUnreadMetrics(this.id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled(
 | 
				
			||||||
 | 
					      'desktop.mandatoryProfileSharing'
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    if (mandatoryProfileSharingEnabled && !this.get('profileSharing')) {
 | 
				
			||||||
 | 
					      this.set({ profileSharing: true });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
 | 
					    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
 | 
				
			||||||
    const destination = this.getSendTarget()!;
 | 
					    const destination = this.getSendTarget()!;
 | 
				
			||||||
    const expireTimer = this.get('expireTimer');
 | 
					    const expireTimer = this.get('expireTimer');
 | 
				
			||||||
| 
						 | 
					@ -3382,15 +3391,22 @@ export class ConversationModel extends window.Backbone.Model<
 | 
				
			||||||
        Message: window.Whisper.Message,
 | 
					        Message: window.Whisper.Message,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const draftProperties = dontClearDraft
 | 
				
			||||||
 | 
					        ? {}
 | 
				
			||||||
 | 
					        : {
 | 
				
			||||||
 | 
					            draft: null,
 | 
				
			||||||
 | 
					            draftTimestamp: null,
 | 
				
			||||||
 | 
					            lastMessage: model.getNotificationText(),
 | 
				
			||||||
 | 
					            lastMessageStatus: 'sending' as const,
 | 
				
			||||||
 | 
					          };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      this.set({
 | 
					      this.set({
 | 
				
			||||||
        lastMessage: model.getNotificationText(),
 | 
					        ...draftProperties,
 | 
				
			||||||
        lastMessageStatus: 'sending',
 | 
					 | 
				
			||||||
        active_at: now,
 | 
					        active_at: now,
 | 
				
			||||||
        timestamp: now,
 | 
					        timestamp: now,
 | 
				
			||||||
        isArchived: false,
 | 
					        isArchived: false,
 | 
				
			||||||
        draft: null,
 | 
					 | 
				
			||||||
        draftTimestamp: null,
 | 
					 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      this.incrementSentMessageCount();
 | 
					      this.incrementSentMessageCount();
 | 
				
			||||||
      window.Signal.Data.updateConversation(this.attributes);
 | 
					      window.Signal.Data.updateConversation(this.attributes);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -46,6 +46,7 @@ import {
 | 
				
			||||||
import { PropsType as ProfileChangeNotificationPropsType } from '../components/conversation/ProfileChangeNotification';
 | 
					import { PropsType as ProfileChangeNotificationPropsType } from '../components/conversation/ProfileChangeNotification';
 | 
				
			||||||
import { AttachmentType, isImage, isVideo } from '../types/Attachment';
 | 
					import { AttachmentType, isImage, isVideo } from '../types/Attachment';
 | 
				
			||||||
import { MIMEType } from '../types/MIME';
 | 
					import { MIMEType } from '../types/MIME';
 | 
				
			||||||
 | 
					import { LinkPreviewType } from '../types/message/LinkPreviews';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* eslint-disable camelcase */
 | 
					/* eslint-disable camelcase */
 | 
				
			||||||
/* eslint-disable more/no-then */
 | 
					/* eslint-disable more/no-then */
 | 
				
			||||||
| 
						 | 
					@ -1139,7 +1140,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getPropsForPreview(): WhatIsThis {
 | 
					  getPropsForPreview(): Array<LinkPreviewType> {
 | 
				
			||||||
    const previews = this.get('preview') || [];
 | 
					    const previews = this.get('preview') || [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return previews.map(preview => ({
 | 
					    return previews.map(preview => ({
 | 
				
			||||||
| 
						 | 
					@ -1592,6 +1593,17 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
 | 
				
			||||||
    return { text: '' };
 | 
					    return { text: '' };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getRawText(): string {
 | 
				
			||||||
 | 
					    const body = (this.get('body') || '').trim();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const bodyRanges = this.processBodyRanges();
 | 
				
			||||||
 | 
					    if (bodyRanges) {
 | 
				
			||||||
 | 
					      return getTextWithMentions(bodyRanges, body);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return body;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getNotificationText(): string {
 | 
					  getNotificationText(): string {
 | 
				
			||||||
    const { text, emoji } = this.getNotificationData();
 | 
					    const { text, emoji } = this.getNotificationData();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -7,6 +7,7 @@ import { actions as conversations } from './ducks/conversations';
 | 
				
			||||||
import { actions as emojis } from './ducks/emojis';
 | 
					import { actions as emojis } from './ducks/emojis';
 | 
				
			||||||
import { actions as expiration } from './ducks/expiration';
 | 
					import { actions as expiration } from './ducks/expiration';
 | 
				
			||||||
import { actions as items } from './ducks/items';
 | 
					import { actions as items } from './ducks/items';
 | 
				
			||||||
 | 
					import { actions as linkPreviews } from './ducks/linkPreviews';
 | 
				
			||||||
import { actions as network } from './ducks/network';
 | 
					import { actions as network } from './ducks/network';
 | 
				
			||||||
import { actions as safetyNumber } from './ducks/safetyNumber';
 | 
					import { actions as safetyNumber } from './ducks/safetyNumber';
 | 
				
			||||||
import { actions as search } from './ducks/search';
 | 
					import { actions as search } from './ducks/search';
 | 
				
			||||||
| 
						 | 
					@ -21,6 +22,7 @@ export const mapDispatchToProps = {
 | 
				
			||||||
  ...emojis,
 | 
					  ...emojis,
 | 
				
			||||||
  ...expiration,
 | 
					  ...expiration,
 | 
				
			||||||
  ...items,
 | 
					  ...items,
 | 
				
			||||||
 | 
					  ...linkPreviews,
 | 
				
			||||||
  ...network,
 | 
					  ...network,
 | 
				
			||||||
  ...safetyNumber,
 | 
					  ...safetyNumber,
 | 
				
			||||||
  ...search,
 | 
					  ...search,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -108,6 +108,7 @@ export type ConversationType = {
 | 
				
			||||||
  // This is used by the CompositionInput for @mentions
 | 
					  // This is used by the CompositionInput for @mentions
 | 
				
			||||||
  sortedGroupMembers?: Array<ConversationType>;
 | 
					  sortedGroupMembers?: Array<ConversationType>;
 | 
				
			||||||
  title: string;
 | 
					  title: string;
 | 
				
			||||||
 | 
					  searchableTitle?: string;
 | 
				
			||||||
  unreadCount?: number;
 | 
					  unreadCount?: number;
 | 
				
			||||||
  isSelected?: boolean;
 | 
					  isSelected?: boolean;
 | 
				
			||||||
  typingContact?: {
 | 
					  typingContact?: {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,11 +2,7 @@
 | 
				
			||||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { omit } from 'lodash';
 | 
					import { omit } from 'lodash';
 | 
				
			||||||
import { createSelector } from 'reselect';
 | 
					 | 
				
			||||||
import { useSelector } from 'react-redux';
 | 
					 | 
				
			||||||
import { StateType } from '../reducer';
 | 
					 | 
				
			||||||
import * as storageShim from '../../shims/storage';
 | 
					import * as storageShim from '../../shims/storage';
 | 
				
			||||||
import { isShortName } from '../../components/emoji/lib';
 | 
					 | 
				
			||||||
import { useBoundActions } from '../../util/hooks';
 | 
					import { useBoundActions } from '../../util/hooks';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// State
 | 
					// State
 | 
				
			||||||
| 
						 | 
					@ -54,6 +50,7 @@ export type ItemsActionType =
 | 
				
			||||||
// Action Creators
 | 
					// Action Creators
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const actions = {
 | 
					export const actions = {
 | 
				
			||||||
 | 
					  onSetSkinTone,
 | 
				
			||||||
  putItem,
 | 
					  putItem,
 | 
				
			||||||
  putItemExternal,
 | 
					  putItemExternal,
 | 
				
			||||||
  removeItem,
 | 
					  removeItem,
 | 
				
			||||||
| 
						 | 
					@ -72,6 +69,10 @@ function putItem(key: string, value: unknown): ItemPutAction {
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function onSetSkinTone(tone: number): ItemPutAction {
 | 
				
			||||||
 | 
					  return putItem('skinTone', tone);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function putItemExternal(key: string, value: unknown): ItemPutExternalAction {
 | 
					function putItemExternal(key: string, value: unknown): ItemPutExternalAction {
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
    type: 'items/PUT_EXTERNAL',
 | 
					    type: 'items/PUT_EXTERNAL',
 | 
				
			||||||
| 
						 | 
					@ -133,13 +134,3 @@ export function reducer(
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return state;
 | 
					  return state;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
// Selectors
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const selectRecentEmojis = createSelector(
 | 
					 | 
				
			||||||
  ({ emojis }: StateType) => emojis.recents,
 | 
					 | 
				
			||||||
  recents => recents.filter(isShortName)
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const useRecentEmojis = (): Array<string> =>
 | 
					 | 
				
			||||||
  useSelector(selectRecentEmojis);
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										77
									
								
								ts/state/ducks/linkPreviews.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								ts/state/ducks/linkPreviews.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,77 @@
 | 
				
			||||||
 | 
					// Copyright 2021 Signal Messenger, LLC
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { LinkPreviewType } from '../../types/message/LinkPreviews';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// State
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type LinkPreviewsStateType = {
 | 
				
			||||||
 | 
					  readonly linkPreview?: LinkPreviewType;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Actions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const ADD_PREVIEW = 'linkPreviews/ADD_PREVIEW';
 | 
				
			||||||
 | 
					const REMOVE_PREVIEW = 'linkPreviews/REMOVE_PREVIEW';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type AddLinkPreviewActionType = {
 | 
				
			||||||
 | 
					  type: 'linkPreviews/ADD_PREVIEW';
 | 
				
			||||||
 | 
					  payload: LinkPreviewType;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type RemoveLinkPreviewActionType = {
 | 
				
			||||||
 | 
					  type: 'linkPreviews/REMOVE_PREVIEW';
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type LinkPreviewsActionType =
 | 
				
			||||||
 | 
					  | AddLinkPreviewActionType
 | 
				
			||||||
 | 
					  | RemoveLinkPreviewActionType;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Action Creators
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const actions = {
 | 
				
			||||||
 | 
					  addLinkPreview,
 | 
				
			||||||
 | 
					  removeLinkPreview,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function addLinkPreview(payload: LinkPreviewType): AddLinkPreviewActionType {
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    type: ADD_PREVIEW,
 | 
				
			||||||
 | 
					    payload,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function removeLinkPreview(): RemoveLinkPreviewActionType {
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    type: REMOVE_PREVIEW,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Reducer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function getEmptyState(): LinkPreviewsStateType {
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    linkPreview: undefined,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function reducer(
 | 
				
			||||||
 | 
					  state: Readonly<LinkPreviewsStateType> = getEmptyState(),
 | 
				
			||||||
 | 
					  action: Readonly<LinkPreviewsActionType>
 | 
				
			||||||
 | 
					): LinkPreviewsStateType {
 | 
				
			||||||
 | 
					  if (action.type === ADD_PREVIEW) {
 | 
				
			||||||
 | 
					    const { payload } = action;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      linkPreview: payload,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (action.type === REMOVE_PREVIEW) {
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      linkPreview: undefined,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return state;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -9,6 +9,7 @@ import { reducer as conversations } from './ducks/conversations';
 | 
				
			||||||
import { reducer as emojis } from './ducks/emojis';
 | 
					import { reducer as emojis } from './ducks/emojis';
 | 
				
			||||||
import { reducer as expiration } from './ducks/expiration';
 | 
					import { reducer as expiration } from './ducks/expiration';
 | 
				
			||||||
import { reducer as items } from './ducks/items';
 | 
					import { reducer as items } from './ducks/items';
 | 
				
			||||||
 | 
					import { reducer as linkPreviews } from './ducks/linkPreviews';
 | 
				
			||||||
import { reducer as network } from './ducks/network';
 | 
					import { reducer as network } from './ducks/network';
 | 
				
			||||||
import { reducer as safetyNumber } from './ducks/safetyNumber';
 | 
					import { reducer as safetyNumber } from './ducks/safetyNumber';
 | 
				
			||||||
import { reducer as search } from './ducks/search';
 | 
					import { reducer as search } from './ducks/search';
 | 
				
			||||||
| 
						 | 
					@ -23,6 +24,7 @@ export const reducer = combineReducers({
 | 
				
			||||||
  emojis,
 | 
					  emojis,
 | 
				
			||||||
  expiration,
 | 
					  expiration,
 | 
				
			||||||
  items,
 | 
					  items,
 | 
				
			||||||
 | 
					  linkPreviews,
 | 
				
			||||||
  network,
 | 
					  network,
 | 
				
			||||||
  safetyNumber,
 | 
					  safetyNumber,
 | 
				
			||||||
  search,
 | 
					  search,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										21
									
								
								ts/state/roots/createForwardMessageModal.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								ts/state/roots/createForwardMessageModal.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,21 @@
 | 
				
			||||||
 | 
					// Copyright 2021 Signal Messenger, LLC
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import React from 'react';
 | 
				
			||||||
 | 
					import { Provider } from 'react-redux';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { Store } from 'redux';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  SmartForwardMessageModal,
 | 
				
			||||||
 | 
					  SmartForwardMessageModalProps,
 | 
				
			||||||
 | 
					} from '../smart/ForwardMessageModal';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const createForwardMessageModal = (
 | 
				
			||||||
 | 
					  store: Store,
 | 
				
			||||||
 | 
					  props: SmartForwardMessageModalProps
 | 
				
			||||||
 | 
					): React.ReactElement => (
 | 
				
			||||||
 | 
					  <Provider store={store}>
 | 
				
			||||||
 | 
					    <SmartForwardMessageModal {...props} />
 | 
				
			||||||
 | 
					  </Provider>
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
| 
						 | 
					@ -18,7 +18,6 @@ import {
 | 
				
			||||||
  OneTimeModalState,
 | 
					  OneTimeModalState,
 | 
				
			||||||
  PreJoinConversationType,
 | 
					  PreJoinConversationType,
 | 
				
			||||||
} from '../ducks/conversations';
 | 
					} from '../ducks/conversations';
 | 
				
			||||||
import { LocalizerType } from '../../types/Util';
 | 
					 | 
				
			||||||
import { getOwn } from '../../util/getOwn';
 | 
					import { getOwn } from '../../util/getOwn';
 | 
				
			||||||
import { deconstructLookup } from '../../util/deconstructLookup';
 | 
					import { deconstructLookup } from '../../util/deconstructLookup';
 | 
				
			||||||
import type { CallsByConversationType } from '../ducks/calling';
 | 
					import type { CallsByConversationType } from '../ducks/calling';
 | 
				
			||||||
| 
						 | 
					@ -350,6 +349,29 @@ function canComposeConversation(conversation: ConversationType): boolean {
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const getAllComposableConversations = createSelector(
 | 
				
			||||||
 | 
					  getConversationLookup,
 | 
				
			||||||
 | 
					  (conversationLookup: ConversationLookupType): Array<ConversationType> =>
 | 
				
			||||||
 | 
					    Object.values(conversationLookup).filter(
 | 
				
			||||||
 | 
					      contact =>
 | 
				
			||||||
 | 
					        !contact.isBlocked &&
 | 
				
			||||||
 | 
					        !isConversationUnregistered(contact) &&
 | 
				
			||||||
 | 
					        (isString(contact.name) || contact.profileSharing)
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getContactsAndMe = createSelector(
 | 
				
			||||||
 | 
					  getConversationLookup,
 | 
				
			||||||
 | 
					  (conversationLookup: ConversationLookupType): Array<ConversationType> =>
 | 
				
			||||||
 | 
					    Object.values(conversationLookup).filter(
 | 
				
			||||||
 | 
					      contact =>
 | 
				
			||||||
 | 
					        contact.type === 'direct' &&
 | 
				
			||||||
 | 
					        !contact.isBlocked &&
 | 
				
			||||||
 | 
					        !isConversationUnregistered(contact) &&
 | 
				
			||||||
 | 
					        (isString(contact.name) || contact.profileSharing)
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * This returns contacts for the composer and group members, which isn't just your primary
 | 
					 * This returns contacts for the composer and group members, which isn't just your primary
 | 
				
			||||||
 * system contacts. It may include false positives, which is better than missing contacts.
 | 
					 * system contacts. It may include false positives, which is better than missing contacts.
 | 
				
			||||||
| 
						 | 
					@ -381,29 +403,14 @@ const getNormalizedComposerConversationSearchTerm = createSelector(
 | 
				
			||||||
  (searchTerm: string): string => searchTerm.trim()
 | 
					  (searchTerm: string): string => searchTerm.trim()
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getNoteToSelfTitle = createSelector(getIntl, (i18n: LocalizerType) =>
 | 
					 | 
				
			||||||
  i18n('noteToSelf').toLowerCase()
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const getComposeContacts = createSelector(
 | 
					export const getComposeContacts = createSelector(
 | 
				
			||||||
  getNormalizedComposerConversationSearchTerm,
 | 
					  getNormalizedComposerConversationSearchTerm,
 | 
				
			||||||
  getComposableContacts,
 | 
					  getContactsAndMe,
 | 
				
			||||||
  getMe,
 | 
					 | 
				
			||||||
  getNoteToSelfTitle,
 | 
					 | 
				
			||||||
  (
 | 
					  (
 | 
				
			||||||
    searchTerm: string,
 | 
					    searchTerm: string,
 | 
				
			||||||
    contacts: Array<ConversationType>,
 | 
					    contacts: Array<ConversationType>
 | 
				
			||||||
    noteToSelf: ConversationType,
 | 
					 | 
				
			||||||
    noteToSelfTitle: string
 | 
					 | 
				
			||||||
  ): Array<ConversationType> => {
 | 
					  ): Array<ConversationType> => {
 | 
				
			||||||
    const result: Array<ConversationType> = filterAndSortConversations(
 | 
					    return filterAndSortConversations(contacts, searchTerm);
 | 
				
			||||||
      contacts,
 | 
					 | 
				
			||||||
      searchTerm
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    if (!searchTerm || noteToSelfTitle.includes(searchTerm)) {
 | 
					 | 
				
			||||||
      result.push(noteToSelf);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return result;
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										16
									
								
								ts/state/selectors/emojis.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								ts/state/selectors/emojis.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,16 @@
 | 
				
			||||||
 | 
					// Copyright 2021 Signal Messenger, LLC
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { createSelector } from 'reselect';
 | 
				
			||||||
 | 
					import { useSelector } from 'react-redux';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { StateType } from '../reducer';
 | 
				
			||||||
 | 
					import { isShortName } from '../../components/emoji/lib';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const selectRecentEmojis = createSelector(
 | 
				
			||||||
 | 
					  ({ emojis }: StateType) => emojis.recents,
 | 
				
			||||||
 | 
					  recents => recents.filter(isShortName)
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const useRecentEmojis = (): Array<string> =>
 | 
				
			||||||
 | 
					  useSelector(selectRecentEmojis);
 | 
				
			||||||
							
								
								
									
										21
									
								
								ts/state/selectors/linkPreviews.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								ts/state/selectors/linkPreviews.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,21 @@
 | 
				
			||||||
 | 
					// Copyright 2021 Signal Messenger, LLC
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { createSelector } from 'reselect';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { StateType } from '../reducer';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const getLinkPreview = createSelector(
 | 
				
			||||||
 | 
					  ({ linkPreviews }: StateType) => linkPreviews.linkPreview,
 | 
				
			||||||
 | 
					  linkPreview => {
 | 
				
			||||||
 | 
					    if (linkPreview) {
 | 
				
			||||||
 | 
					      return {
 | 
				
			||||||
 | 
					        ...linkPreview,
 | 
				
			||||||
 | 
					        domain: window.Signal.LinkPreviews.getDomain(linkPreview.url),
 | 
				
			||||||
 | 
					        isLoaded: true,
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return undefined;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
| 
						 | 
					@ -2,13 +2,12 @@
 | 
				
			||||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { connect } from 'react-redux';
 | 
					import { connect } from 'react-redux';
 | 
				
			||||||
import { createSelector } from 'reselect';
 | 
					 | 
				
			||||||
import { get } from 'lodash';
 | 
					import { get } from 'lodash';
 | 
				
			||||||
import { mapDispatchToProps } from '../actions';
 | 
					import { mapDispatchToProps } from '../actions';
 | 
				
			||||||
import { CompositionArea } from '../../components/CompositionArea';
 | 
					import { CompositionArea } from '../../components/CompositionArea';
 | 
				
			||||||
import { StateType } from '../reducer';
 | 
					import { StateType } from '../reducer';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { isShortName } from '../../components/emoji/lib';
 | 
					import { selectRecentEmojis } from '../selectors/emojis';
 | 
				
			||||||
import { getIntl } from '../selectors/user';
 | 
					import { getIntl } from '../selectors/user';
 | 
				
			||||||
import { getConversationSelector } from '../selectors/conversations';
 | 
					import { getConversationSelector } from '../selectors/conversations';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
| 
						 | 
					@ -24,11 +23,6 @@ type ExternalProps = {
 | 
				
			||||||
  id: string;
 | 
					  id: string;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const selectRecentEmojis = createSelector(
 | 
					 | 
				
			||||||
  ({ emojis }: StateType) => emojis.recents,
 | 
					 | 
				
			||||||
  recents => recents.filter(isShortName)
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const mapStateToProps = (state: StateType, props: ExternalProps) => {
 | 
					const mapStateToProps = (state: StateType, props: ExternalProps) => {
 | 
				
			||||||
  const { id } = props;
 | 
					  const { id } = props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -5,7 +5,7 @@ import * as React from 'react';
 | 
				
			||||||
import { useSelector } from 'react-redux';
 | 
					import { useSelector } from 'react-redux';
 | 
				
			||||||
import { get } from 'lodash';
 | 
					import { get } from 'lodash';
 | 
				
			||||||
import { StateType } from '../reducer';
 | 
					import { StateType } from '../reducer';
 | 
				
			||||||
import { useActions as useItemActions, useRecentEmojis } from '../ducks/items';
 | 
					import { useRecentEmojis } from '../selectors/emojis';
 | 
				
			||||||
import { useActions as useEmojiActions } from '../ducks/emojis';
 | 
					import { useActions as useEmojiActions } from '../ducks/emojis';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
| 
						 | 
					@ -17,8 +17,8 @@ import { LocalizerType } from '../../types/Util';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const SmartEmojiPicker = React.forwardRef<
 | 
					export const SmartEmojiPicker = React.forwardRef<
 | 
				
			||||||
  HTMLDivElement,
 | 
					  HTMLDivElement,
 | 
				
			||||||
  Pick<EmojiPickerProps, 'onPickEmoji' | 'onClose' | 'style'>
 | 
					  Pick<EmojiPickerProps, 'onPickEmoji' | 'onSetSkinTone' | 'onClose' | 'style'>
 | 
				
			||||||
>(({ onPickEmoji, onClose, style }, ref) => {
 | 
					>(({ onPickEmoji, onSetSkinTone, onClose, style }, ref) => {
 | 
				
			||||||
  const i18n = useSelector<StateType, LocalizerType>(getIntl);
 | 
					  const i18n = useSelector<StateType, LocalizerType>(getIntl);
 | 
				
			||||||
  const skinTone = useSelector<StateType, number>(state =>
 | 
					  const skinTone = useSelector<StateType, number>(state =>
 | 
				
			||||||
    get(state, ['items', 'skinTone'], 0)
 | 
					    get(state, ['items', 'skinTone'], 0)
 | 
				
			||||||
| 
						 | 
					@ -26,15 +26,6 @@ export const SmartEmojiPicker = React.forwardRef<
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const recentEmojis = useRecentEmojis();
 | 
					  const recentEmojis = useRecentEmojis();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { putItem } = useItemActions();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const onSetSkinTone = React.useCallback(
 | 
					 | 
				
			||||||
    tone => {
 | 
					 | 
				
			||||||
      putItem('skinTone', tone);
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    [putItem]
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const { onUseEmoji } = useEmojiActions();
 | 
					  const { onUseEmoji } = useEmojiActions();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handlePickEmoji = React.useCallback(
 | 
					  const handlePickEmoji = React.useCallback(
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										79
									
								
								ts/state/smart/ForwardMessageModal.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								ts/state/smart/ForwardMessageModal.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,79 @@
 | 
				
			||||||
 | 
					// Copyright 2021 Signal Messenger, LLC
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { connect } from 'react-redux';
 | 
				
			||||||
 | 
					import { get } from 'lodash';
 | 
				
			||||||
 | 
					import { mapDispatchToProps } from '../actions';
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  ForwardMessageModal,
 | 
				
			||||||
 | 
					  DataPropsType,
 | 
				
			||||||
 | 
					} from '../../components/ForwardMessageModal';
 | 
				
			||||||
 | 
					import { StateType } from '../reducer';
 | 
				
			||||||
 | 
					import { BodyRangeType } from '../../types/Util';
 | 
				
			||||||
 | 
					import { LinkPreviewType } from '../../types/message/LinkPreviews';
 | 
				
			||||||
 | 
					import { getAllComposableConversations } from '../selectors/conversations';
 | 
				
			||||||
 | 
					import { getLinkPreview } from '../selectors/linkPreviews';
 | 
				
			||||||
 | 
					import { getIntl } from '../selectors/user';
 | 
				
			||||||
 | 
					import { selectRecentEmojis } from '../selectors/emojis';
 | 
				
			||||||
 | 
					import { AttachmentType } from '../../types/Attachment';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type SmartForwardMessageModalProps = {
 | 
				
			||||||
 | 
					  attachments?: Array<AttachmentType>;
 | 
				
			||||||
 | 
					  doForwardMessage: (
 | 
				
			||||||
 | 
					    selectedContacts: Array<string>,
 | 
				
			||||||
 | 
					    messageBody?: string,
 | 
				
			||||||
 | 
					    attachments?: Array<AttachmentType>,
 | 
				
			||||||
 | 
					    linkPreview?: LinkPreviewType
 | 
				
			||||||
 | 
					  ) => void;
 | 
				
			||||||
 | 
					  isSticker: boolean;
 | 
				
			||||||
 | 
					  messageBody?: string;
 | 
				
			||||||
 | 
					  onClose: () => void;
 | 
				
			||||||
 | 
					  onEditorStateChange: (
 | 
				
			||||||
 | 
					    messageText: string,
 | 
				
			||||||
 | 
					    bodyRanges: Array<BodyRangeType>,
 | 
				
			||||||
 | 
					    caretLocation?: number
 | 
				
			||||||
 | 
					  ) => unknown;
 | 
				
			||||||
 | 
					  onTextTooLong: () => void;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const mapStateToProps = (
 | 
				
			||||||
 | 
					  state: StateType,
 | 
				
			||||||
 | 
					  props: SmartForwardMessageModalProps
 | 
				
			||||||
 | 
					): DataPropsType => {
 | 
				
			||||||
 | 
					  const {
 | 
				
			||||||
 | 
					    attachments,
 | 
				
			||||||
 | 
					    doForwardMessage,
 | 
				
			||||||
 | 
					    isSticker,
 | 
				
			||||||
 | 
					    messageBody,
 | 
				
			||||||
 | 
					    onClose,
 | 
				
			||||||
 | 
					    onEditorStateChange,
 | 
				
			||||||
 | 
					    onTextTooLong,
 | 
				
			||||||
 | 
					  } = props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const candidateConversations = getAllComposableConversations(state);
 | 
				
			||||||
 | 
					  const recentEmojis = selectRecentEmojis(state);
 | 
				
			||||||
 | 
					  const skinTone = get(state, ['items', 'skinTone'], 0);
 | 
				
			||||||
 | 
					  const linkPreview = getLinkPreview(state);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    attachments,
 | 
				
			||||||
 | 
					    candidateConversations,
 | 
				
			||||||
 | 
					    doForwardMessage,
 | 
				
			||||||
 | 
					    i18n: getIntl(state),
 | 
				
			||||||
 | 
					    isSticker,
 | 
				
			||||||
 | 
					    linkPreview,
 | 
				
			||||||
 | 
					    messageBody,
 | 
				
			||||||
 | 
					    onClose,
 | 
				
			||||||
 | 
					    onEditorStateChange,
 | 
				
			||||||
 | 
					    recentEmojis,
 | 
				
			||||||
 | 
					    skinTone,
 | 
				
			||||||
 | 
					    onTextTooLong,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const smart = connect(mapStateToProps, {
 | 
				
			||||||
 | 
					  ...mapDispatchToProps,
 | 
				
			||||||
 | 
					  onPickEmoji: mapDispatchToProps.onUseEmoji,
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const SmartForwardMessageModal = smart(ForwardMessageModal);
 | 
				
			||||||
| 
						 | 
					@ -42,6 +42,7 @@ export type OwnProps = {
 | 
				
			||||||
  | 'showContactModal'
 | 
					  | 'showContactModal'
 | 
				
			||||||
  | 'showExpiredIncomingTapToViewToast'
 | 
					  | 'showExpiredIncomingTapToViewToast'
 | 
				
			||||||
  | 'showExpiredOutgoingTapToViewToast'
 | 
					  | 'showExpiredOutgoingTapToViewToast'
 | 
				
			||||||
 | 
					  | 'showForwardMessageModal'
 | 
				
			||||||
  | 'showVisualAttachment'
 | 
					  | 'showVisualAttachment'
 | 
				
			||||||
>;
 | 
					>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -72,6 +73,7 @@ const mapStateToProps = (
 | 
				
			||||||
    showContactModal,
 | 
					    showContactModal,
 | 
				
			||||||
    showExpiredIncomingTapToViewToast,
 | 
					    showExpiredIncomingTapToViewToast,
 | 
				
			||||||
    showExpiredOutgoingTapToViewToast,
 | 
					    showExpiredOutgoingTapToViewToast,
 | 
				
			||||||
 | 
					    showForwardMessageModal,
 | 
				
			||||||
    showVisualAttachment,
 | 
					    showVisualAttachment,
 | 
				
			||||||
  } = props;
 | 
					  } = props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -103,6 +105,7 @@ const mapStateToProps = (
 | 
				
			||||||
    showContactModal,
 | 
					    showContactModal,
 | 
				
			||||||
    showExpiredIncomingTapToViewToast,
 | 
					    showExpiredIncomingTapToViewToast,
 | 
				
			||||||
    showExpiredOutgoingTapToViewToast,
 | 
					    showExpiredOutgoingTapToViewToast,
 | 
				
			||||||
 | 
					    showForwardMessageModal,
 | 
				
			||||||
    showVisualAttachment,
 | 
					    showVisualAttachment,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -7,6 +7,7 @@ import { actions as conversations } from './ducks/conversations';
 | 
				
			||||||
import { actions as emojis } from './ducks/emojis';
 | 
					import { actions as emojis } from './ducks/emojis';
 | 
				
			||||||
import { actions as expiration } from './ducks/expiration';
 | 
					import { actions as expiration } from './ducks/expiration';
 | 
				
			||||||
import { actions as items } from './ducks/items';
 | 
					import { actions as items } from './ducks/items';
 | 
				
			||||||
 | 
					import { actions as linkPreviews } from './ducks/linkPreviews';
 | 
				
			||||||
import { actions as network } from './ducks/network';
 | 
					import { actions as network } from './ducks/network';
 | 
				
			||||||
import { actions as safetyNumber } from './ducks/safetyNumber';
 | 
					import { actions as safetyNumber } from './ducks/safetyNumber';
 | 
				
			||||||
import { actions as search } from './ducks/search';
 | 
					import { actions as search } from './ducks/search';
 | 
				
			||||||
| 
						 | 
					@ -21,6 +22,7 @@ export type ReduxActions = {
 | 
				
			||||||
  emojis: typeof emojis;
 | 
					  emojis: typeof emojis;
 | 
				
			||||||
  expiration: typeof expiration;
 | 
					  expiration: typeof expiration;
 | 
				
			||||||
  items: typeof items;
 | 
					  items: typeof items;
 | 
				
			||||||
 | 
					  linkPreviews: typeof linkPreviews;
 | 
				
			||||||
  network: typeof network;
 | 
					  network: typeof network;
 | 
				
			||||||
  safetyNumber: typeof safetyNumber;
 | 
					  safetyNumber: typeof safetyNumber;
 | 
				
			||||||
  search: typeof search;
 | 
					  search: typeof search;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										48
									
								
								ts/test-both/state/ducks/linkPreviews_test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								ts/test-both/state/ducks/linkPreviews_test.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,48 @@
 | 
				
			||||||
 | 
					// Copyright 2021 Signal Messenger, LLC
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { assert } from 'chai';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  actions,
 | 
				
			||||||
 | 
					  getEmptyState,
 | 
				
			||||||
 | 
					  reducer,
 | 
				
			||||||
 | 
					} from '../../../state/ducks/linkPreviews';
 | 
				
			||||||
 | 
					import { LinkPreviewType } from '../../../types/message/LinkPreviews';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe('both/state/ducks/linkPreviews', () => {
 | 
				
			||||||
 | 
					  function getMockLinkPreview(): LinkPreviewType {
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      title: 'Hello World',
 | 
				
			||||||
 | 
					      domain: 'signal.org',
 | 
				
			||||||
 | 
					      url: 'https://www.signal.org',
 | 
				
			||||||
 | 
					      isStickerPack: false,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('addLinkPreview', () => {
 | 
				
			||||||
 | 
					    const { addLinkPreview } = actions;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('updates linkPreview', () => {
 | 
				
			||||||
 | 
					      const state = getEmptyState();
 | 
				
			||||||
 | 
					      const linkPreview = getMockLinkPreview();
 | 
				
			||||||
 | 
					      const nextState = reducer(state, addLinkPreview(linkPreview));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      assert.strictEqual(nextState.linkPreview, linkPreview);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('removeLinkPreview', () => {
 | 
				
			||||||
 | 
					    const { removeLinkPreview } = actions;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('removes linkPreview', () => {
 | 
				
			||||||
 | 
					      const state = {
 | 
				
			||||||
 | 
					        ...getEmptyState(),
 | 
				
			||||||
 | 
					        linkPreview: getMockLinkPreview(),
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					      const nextState = reducer(state, removeLinkPreview());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      assert.isUndefined(nextState.linkPreview);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
| 
						 | 
					@ -46,6 +46,7 @@ describe('both/state/selectors/conversations', () => {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      id,
 | 
					      id,
 | 
				
			||||||
      type: 'direct',
 | 
					      type: 'direct',
 | 
				
			||||||
 | 
					      searchableTitle: `${id} title`,
 | 
				
			||||||
      title: `${id} title`,
 | 
					      title: `${id} title`,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					@ -478,6 +479,13 @@ describe('both/state/selectors/conversations', () => {
 | 
				
			||||||
    const getRootStateWithConversations = (searchTerm = ''): StateType => {
 | 
					    const getRootStateWithConversations = (searchTerm = ''): StateType => {
 | 
				
			||||||
      const result = getRootState(searchTerm);
 | 
					      const result = getRootState(searchTerm);
 | 
				
			||||||
      Object.assign(result.conversations.conversationLookup, {
 | 
					      Object.assign(result.conversations.conversationLookup, {
 | 
				
			||||||
 | 
					        'convo-0': {
 | 
				
			||||||
 | 
					          ...getDefaultConversation('convo-0'),
 | 
				
			||||||
 | 
					          name: 'Me, Myself, and I',
 | 
				
			||||||
 | 
					          title: 'Me, Myself, and I',
 | 
				
			||||||
 | 
					          searchableTitle: 'Note to Self',
 | 
				
			||||||
 | 
					          isMe: true,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
        'convo-1': {
 | 
					        'convo-1': {
 | 
				
			||||||
          ...getDefaultConversation('convo-1'),
 | 
					          ...getDefaultConversation('convo-1'),
 | 
				
			||||||
          name: 'In System Contacts',
 | 
					          name: 'In System Contacts',
 | 
				
			||||||
| 
						 | 
					@ -517,32 +525,20 @@ describe('both/state/selectors/conversations', () => {
 | 
				
			||||||
      return result;
 | 
					      return result;
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('only returns Note to Self when there are no other contacts', () => {
 | 
					    it('returns no results when there are no contacts', () => {
 | 
				
			||||||
      const state = getRootState();
 | 
					 | 
				
			||||||
      const result = getComposeContacts(state);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      assert.lengthOf(result, 1);
 | 
					 | 
				
			||||||
      assert.strictEqual(result[0]?.id, 'our-conversation-id');
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it("returns no results when search doesn't match Note to Self and there are no other contacts", () => {
 | 
					 | 
				
			||||||
      const state = getRootState('foo bar baz');
 | 
					      const state = getRootState('foo bar baz');
 | 
				
			||||||
      const result = getComposeContacts(state);
 | 
					      const result = getComposeContacts(state);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      assert.isEmpty(result);
 | 
					      assert.isEmpty(result);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('returns contacts with Note to Self at the end when there is no search term', () => {
 | 
					    it('includes Note to Self', () => {
 | 
				
			||||||
      const state = getRootStateWithConversations();
 | 
					      const state = getRootStateWithConversations();
 | 
				
			||||||
      const result = getComposeContacts(state);
 | 
					      const result = getComposeContacts(state);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const ids = result.map(contact => contact.id);
 | 
					      const ids = result.map(contact => contact.id);
 | 
				
			||||||
      assert.deepEqual(ids, [
 | 
					      // convo-6 is sorted last because it doesn't have a name
 | 
				
			||||||
        'convo-1',
 | 
					      assert.deepEqual(ids, ['convo-1', 'convo-5', 'convo-0', 'convo-6']);
 | 
				
			||||||
        'convo-5',
 | 
					 | 
				
			||||||
        'convo-6',
 | 
					 | 
				
			||||||
        'our-conversation-id',
 | 
					 | 
				
			||||||
      ]);
 | 
					 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('can search for contacts', () => {
 | 
					    it('can search for contacts', () => {
 | 
				
			||||||
| 
						 | 
					@ -553,6 +549,22 @@ describe('both/state/selectors/conversations', () => {
 | 
				
			||||||
      // NOTE: convo-6 matches because you can't write "Sharing" without "in"
 | 
					      // NOTE: convo-6 matches because you can't write "Sharing" without "in"
 | 
				
			||||||
      assert.deepEqual(ids, ['convo-1', 'convo-5', 'convo-6']);
 | 
					      assert.deepEqual(ids, ['convo-1', 'convo-5', 'convo-6']);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('can search for note to self', () => {
 | 
				
			||||||
 | 
					      const state = getRootStateWithConversations('note');
 | 
				
			||||||
 | 
					      const result = getComposeContacts(state);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const ids = result.map(contact => contact.id);
 | 
				
			||||||
 | 
					      assert.deepEqual(ids, ['convo-0']);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('returns not to self when searching for your own name', () => {
 | 
				
			||||||
 | 
					      const state = getRootStateWithConversations('Myself');
 | 
				
			||||||
 | 
					      const result = getComposeContacts(state);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const ids = result.map(contact => contact.id);
 | 
				
			||||||
 | 
					      assert.deepEqual(ids, ['convo-0']);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('#getComposeGroups', () => {
 | 
					  describe('#getComposeGroups', () => {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11,6 +11,10 @@ const FUSE_OPTIONS: FuseOptions<ConversationType> = {
 | 
				
			||||||
  threshold: 0.05,
 | 
					  threshold: 0.05,
 | 
				
			||||||
  tokenize: true,
 | 
					  tokenize: true,
 | 
				
			||||||
  keys: [
 | 
					  keys: [
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      name: 'searchableTitle',
 | 
				
			||||||
 | 
					      weight: 1,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
      name: 'title',
 | 
					      name: 'title',
 | 
				
			||||||
      weight: 1,
 | 
					      weight: 1,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -16238,7 +16238,7 @@
 | 
				
			||||||
    "rule": "React-useRef",
 | 
					    "rule": "React-useRef",
 | 
				
			||||||
    "path": "ts/components/CompositionInput.js",
 | 
					    "path": "ts/components/CompositionInput.js",
 | 
				
			||||||
    "line": "    const emojiCompletionRef = React.useRef();",
 | 
					    "line": "    const emojiCompletionRef = React.useRef();",
 | 
				
			||||||
    "lineNumber": 56,
 | 
					    "lineNumber": 62,
 | 
				
			||||||
    "reasonCategory": "falseMatch",
 | 
					    "reasonCategory": "falseMatch",
 | 
				
			||||||
    "updated": "2020-10-26T19:12:24.410Z",
 | 
					    "updated": "2020-10-26T19:12:24.410Z",
 | 
				
			||||||
    "reasonDetail": "Doesn't refer to a DOM element."
 | 
					    "reasonDetail": "Doesn't refer to a DOM element."
 | 
				
			||||||
| 
						 | 
					@ -16247,7 +16247,7 @@
 | 
				
			||||||
    "rule": "React-useRef",
 | 
					    "rule": "React-useRef",
 | 
				
			||||||
    "path": "ts/components/CompositionInput.js",
 | 
					    "path": "ts/components/CompositionInput.js",
 | 
				
			||||||
    "line": "    const mentionCompletionRef = React.useRef();",
 | 
					    "line": "    const mentionCompletionRef = React.useRef();",
 | 
				
			||||||
    "lineNumber": 57,
 | 
					    "lineNumber": 63,
 | 
				
			||||||
    "reasonCategory": "falseMatch",
 | 
					    "reasonCategory": "falseMatch",
 | 
				
			||||||
    "updated": "2020-10-26T23:54:34.273Z",
 | 
					    "updated": "2020-10-26T23:54:34.273Z",
 | 
				
			||||||
    "reasonDetail": "Doesn't refer to a DOM element."
 | 
					    "reasonDetail": "Doesn't refer to a DOM element."
 | 
				
			||||||
| 
						 | 
					@ -16256,7 +16256,7 @@
 | 
				
			||||||
    "rule": "React-useRef",
 | 
					    "rule": "React-useRef",
 | 
				
			||||||
    "path": "ts/components/CompositionInput.js",
 | 
					    "path": "ts/components/CompositionInput.js",
 | 
				
			||||||
    "line": "    const quillRef = React.useRef();",
 | 
					    "line": "    const quillRef = React.useRef();",
 | 
				
			||||||
    "lineNumber": 58,
 | 
					    "lineNumber": 64,
 | 
				
			||||||
    "reasonCategory": "falseMatch",
 | 
					    "reasonCategory": "falseMatch",
 | 
				
			||||||
    "updated": "2020-10-26T19:12:24.410Z",
 | 
					    "updated": "2020-10-26T19:12:24.410Z",
 | 
				
			||||||
    "reasonDetail": "Doesn't refer to a DOM element."
 | 
					    "reasonDetail": "Doesn't refer to a DOM element."
 | 
				
			||||||
| 
						 | 
					@ -16265,7 +16265,7 @@
 | 
				
			||||||
    "rule": "React-useRef",
 | 
					    "rule": "React-useRef",
 | 
				
			||||||
    "path": "ts/components/CompositionInput.js",
 | 
					    "path": "ts/components/CompositionInput.js",
 | 
				
			||||||
    "line": "    const scrollerRef = React.useRef(null);",
 | 
					    "line": "    const scrollerRef = React.useRef(null);",
 | 
				
			||||||
    "lineNumber": 59,
 | 
					    "lineNumber": 65,
 | 
				
			||||||
    "reasonCategory": "usageTrusted",
 | 
					    "reasonCategory": "usageTrusted",
 | 
				
			||||||
    "updated": "2020-10-26T19:12:24.410Z",
 | 
					    "updated": "2020-10-26T19:12:24.410Z",
 | 
				
			||||||
    "reasonDetail": "Used with Quill for scrolling."
 | 
					    "reasonDetail": "Used with Quill for scrolling."
 | 
				
			||||||
| 
						 | 
					@ -16274,7 +16274,7 @@
 | 
				
			||||||
    "rule": "React-useRef",
 | 
					    "rule": "React-useRef",
 | 
				
			||||||
    "path": "ts/components/CompositionInput.js",
 | 
					    "path": "ts/components/CompositionInput.js",
 | 
				
			||||||
    "line": "    const propsRef = React.useRef(props);",
 | 
					    "line": "    const propsRef = React.useRef(props);",
 | 
				
			||||||
    "lineNumber": 60,
 | 
					    "lineNumber": 66,
 | 
				
			||||||
    "reasonCategory": "falseMatch",
 | 
					    "reasonCategory": "falseMatch",
 | 
				
			||||||
    "updated": "2020-10-26T19:12:24.410Z",
 | 
					    "updated": "2020-10-26T19:12:24.410Z",
 | 
				
			||||||
    "reasonDetail": "Doesn't refer to a DOM element."
 | 
					    "reasonDetail": "Doesn't refer to a DOM element."
 | 
				
			||||||
| 
						 | 
					@ -16283,11 +16283,27 @@
 | 
				
			||||||
    "rule": "React-useRef",
 | 
					    "rule": "React-useRef",
 | 
				
			||||||
    "path": "ts/components/CompositionInput.js",
 | 
					    "path": "ts/components/CompositionInput.js",
 | 
				
			||||||
    "line": "    const memberRepositoryRef = React.useRef(new memberRepository_1.MemberRepository());",
 | 
					    "line": "    const memberRepositoryRef = React.useRef(new memberRepository_1.MemberRepository());",
 | 
				
			||||||
    "lineNumber": 61,
 | 
					    "lineNumber": 67,
 | 
				
			||||||
    "reasonCategory": "falseMatch",
 | 
					    "reasonCategory": "falseMatch",
 | 
				
			||||||
    "updated": "2020-10-26T23:56:13.482Z",
 | 
					    "updated": "2020-10-26T23:56:13.482Z",
 | 
				
			||||||
    "reasonDetail": "Doesn't refer to a DOM element."
 | 
					    "reasonDetail": "Doesn't refer to a DOM element."
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "rule": "React-useRef",
 | 
				
			||||||
 | 
					    "path": "ts/components/CompositionInput.js",
 | 
				
			||||||
 | 
					    "line": "    const callbacksRef = React.useRef(unstaleCallbacks);",
 | 
				
			||||||
 | 
					    "lineNumber": 338,
 | 
				
			||||||
 | 
					    "reasonCategory": "usageTrusted",
 | 
				
			||||||
 | 
					    "updated": "2021-04-21T21:35:38.757Z"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "rule": "React-useRef",
 | 
				
			||||||
 | 
					    "path": "ts/components/CompositionInput.tsx",
 | 
				
			||||||
 | 
					    "line": "  const callbacksRef = React.useRef(unstaleCallbacks);",
 | 
				
			||||||
 | 
					    "lineNumber": 500,
 | 
				
			||||||
 | 
					    "reasonCategory": "usageTrusted",
 | 
				
			||||||
 | 
					    "updated": "2021-04-21T21:35:38.757Z"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    "rule": "React-useRef",
 | 
					    "rule": "React-useRef",
 | 
				
			||||||
    "path": "ts/components/ContactPills.js",
 | 
					    "path": "ts/components/ContactPills.js",
 | 
				
			||||||
| 
						 | 
					@ -16315,6 +16331,22 @@
 | 
				
			||||||
    "updated": "2020-11-11T21:56:04.179Z",
 | 
					    "updated": "2020-11-11T21:56:04.179Z",
 | 
				
			||||||
    "reasonDetail": "Needed to render the remote video element."
 | 
					    "reasonDetail": "Needed to render the remote video element."
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "rule": "React-useRef",
 | 
				
			||||||
 | 
					    "path": "ts/components/ForwardMessageModal.js",
 | 
				
			||||||
 | 
					    "line": "    const inputRef = react_1.useRef(null);",
 | 
				
			||||||
 | 
					    "lineNumber": 44,
 | 
				
			||||||
 | 
					    "reasonCategory": "usageTrusted",
 | 
				
			||||||
 | 
					    "updated": "2021-04-19T18:13:21.664Z"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "rule": "React-useRef",
 | 
				
			||||||
 | 
					    "path": "ts/components/ForwardMessageModal.js",
 | 
				
			||||||
 | 
					    "line": "    const inputApiRef = react_1.default.useRef();",
 | 
				
			||||||
 | 
					    "lineNumber": 45,
 | 
				
			||||||
 | 
					    "reasonCategory": "usageTrusted",
 | 
				
			||||||
 | 
					    "updated": "2021-04-19T18:13:21.664Z"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    "rule": "React-useRef",
 | 
					    "rule": "React-useRef",
 | 
				
			||||||
    "path": "ts/components/GroupCallOverflowArea.js",
 | 
					    "path": "ts/components/GroupCallOverflowArea.js",
 | 
				
			||||||
| 
						 | 
					@ -16557,7 +16589,7 @@
 | 
				
			||||||
    "rule": "React-createRef",
 | 
					    "rule": "React-createRef",
 | 
				
			||||||
    "path": "ts/components/conversation/Message.tsx",
 | 
					    "path": "ts/components/conversation/Message.tsx",
 | 
				
			||||||
    "line": "  public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
 | 
					    "line": "  public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
 | 
				
			||||||
    "lineNumber": 241,
 | 
					    "lineNumber": 242,
 | 
				
			||||||
    "reasonCategory": "usageTrusted",
 | 
					    "reasonCategory": "usageTrusted",
 | 
				
			||||||
    "updated": "2021-03-05T19:57:01.431Z",
 | 
					    "updated": "2021-03-05T19:57:01.431Z",
 | 
				
			||||||
    "reasonDetail": "Used for managing focus only"
 | 
					    "reasonDetail": "Used for managing focus only"
 | 
				
			||||||
| 
						 | 
					@ -16566,7 +16598,7 @@
 | 
				
			||||||
    "rule": "React-createRef",
 | 
					    "rule": "React-createRef",
 | 
				
			||||||
    "path": "ts/components/conversation/Message.tsx",
 | 
					    "path": "ts/components/conversation/Message.tsx",
 | 
				
			||||||
    "line": "  public audioButtonRef: React.RefObject<HTMLButtonElement> = React.createRef();",
 | 
					    "line": "  public audioButtonRef: React.RefObject<HTMLButtonElement> = React.createRef();",
 | 
				
			||||||
    "lineNumber": 243,
 | 
					    "lineNumber": 244,
 | 
				
			||||||
    "reasonCategory": "usageTrusted",
 | 
					    "reasonCategory": "usageTrusted",
 | 
				
			||||||
    "updated": "2021-03-05T19:57:01.431Z",
 | 
					    "updated": "2021-03-05T19:57:01.431Z",
 | 
				
			||||||
    "reasonDetail": "Used for propagating click from the Message to MessageAudio's button"
 | 
					    "reasonDetail": "Used for propagating click from the Message to MessageAudio's button"
 | 
				
			||||||
| 
						 | 
					@ -16575,7 +16607,7 @@
 | 
				
			||||||
    "rule": "React-createRef",
 | 
					    "rule": "React-createRef",
 | 
				
			||||||
    "path": "ts/components/conversation/Message.tsx",
 | 
					    "path": "ts/components/conversation/Message.tsx",
 | 
				
			||||||
    "line": "  > = React.createRef();",
 | 
					    "line": "  > = React.createRef();",
 | 
				
			||||||
    "lineNumber": 247,
 | 
					    "lineNumber": 248,
 | 
				
			||||||
    "reasonCategory": "usageTrusted",
 | 
					    "reasonCategory": "usageTrusted",
 | 
				
			||||||
    "updated": "2021-03-05T19:57:01.431Z",
 | 
					    "updated": "2021-03-05T19:57:01.431Z",
 | 
				
			||||||
    "reasonDetail": "Used for detecting clicks outside reaction viewer"
 | 
					    "reasonDetail": "Used for detecting clicks outside reaction viewer"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,11 +4,12 @@
 | 
				
			||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
 | 
					/* eslint-disable @typescript-eslint/no-explicit-any */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { AttachmentType } from '../types/Attachment';
 | 
					import { AttachmentType } from '../types/Attachment';
 | 
				
			||||||
import { GroupV2PendingMemberType } from '../model-types.d';
 | 
					 | 
				
			||||||
import { MediaItemType } from '../components/LightboxGallery';
 | 
					 | 
				
			||||||
import { MessageType } from '../state/ducks/conversations';
 | 
					 | 
				
			||||||
import { ConversationModel } from '../models/conversations';
 | 
					import { ConversationModel } from '../models/conversations';
 | 
				
			||||||
 | 
					import { GroupV2PendingMemberType } from '../model-types.d';
 | 
				
			||||||
 | 
					import { LinkPreviewType } from '../types/message/LinkPreviews';
 | 
				
			||||||
 | 
					import { MediaItemType } from '../components/LightboxGallery';
 | 
				
			||||||
import { MessageModel } from '../models/messages';
 | 
					import { MessageModel } from '../models/messages';
 | 
				
			||||||
 | 
					import { MessageType } from '../state/ducks/conversations';
 | 
				
			||||||
import { assert } from '../util/assert';
 | 
					import { assert } from '../util/assert';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type GetLinkPreviewImageResult = {
 | 
					type GetLinkPreviewImageResult = {
 | 
				
			||||||
| 
						 | 
					@ -48,6 +49,8 @@ const {
 | 
				
			||||||
  getAbsoluteAttachmentPath,
 | 
					  getAbsoluteAttachmentPath,
 | 
				
			||||||
  getAbsoluteDraftPath,
 | 
					  getAbsoluteDraftPath,
 | 
				
			||||||
  getAbsoluteTempPath,
 | 
					  getAbsoluteTempPath,
 | 
				
			||||||
 | 
					  loadPreviewData,
 | 
				
			||||||
 | 
					  loadStickerData,
 | 
				
			||||||
  openFileInFolder,
 | 
					  openFileInFolder,
 | 
				
			||||||
  readAttachmentData,
 | 
					  readAttachmentData,
 | 
				
			||||||
  readDraftData,
 | 
					  readDraftData,
 | 
				
			||||||
| 
						 | 
					@ -608,7 +611,7 @@ Whisper.ConversationView = Whisper.View.extend({
 | 
				
			||||||
      onEditorStateChange: (
 | 
					      onEditorStateChange: (
 | 
				
			||||||
        msg: string,
 | 
					        msg: string,
 | 
				
			||||||
        bodyRanges: Array<typeof window.Whisper.BodyRangeType>,
 | 
					        bodyRanges: Array<typeof window.Whisper.BodyRangeType>,
 | 
				
			||||||
        caretLocation: number
 | 
					        caretLocation?: number
 | 
				
			||||||
      ) => this.onEditorStateChange(msg, bodyRanges, caretLocation),
 | 
					      ) => this.onEditorStateChange(msg, bodyRanges, caretLocation),
 | 
				
			||||||
      onTextTooLong: () => this.showToast(Whisper.MessageBodyTooLongToast),
 | 
					      onTextTooLong: () => this.showToast(Whisper.MessageBodyTooLongToast),
 | 
				
			||||||
      onChooseAttachment: this.onChooseAttachment.bind(this),
 | 
					      onChooseAttachment: this.onChooseAttachment.bind(this),
 | 
				
			||||||
| 
						 | 
					@ -774,6 +777,7 @@ Whisper.ConversationView = Whisper.View.extend({
 | 
				
			||||||
    const showExpiredOutgoingTapToViewToast = () => {
 | 
					    const showExpiredOutgoingTapToViewToast = () => {
 | 
				
			||||||
      this.showToast(Whisper.TapToViewExpiredOutgoingToast);
 | 
					      this.showToast(Whisper.TapToViewExpiredOutgoingToast);
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					    const showForwardMessageModal = this.showForwardMessageModal.bind(this);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      deleteMessage,
 | 
					      deleteMessage,
 | 
				
			||||||
| 
						 | 
					@ -792,6 +796,7 @@ Whisper.ConversationView = Whisper.View.extend({
 | 
				
			||||||
      showContactModal,
 | 
					      showContactModal,
 | 
				
			||||||
      showExpiredIncomingTapToViewToast,
 | 
					      showExpiredIncomingTapToViewToast,
 | 
				
			||||||
      showExpiredOutgoingTapToViewToast,
 | 
					      showExpiredOutgoingTapToViewToast,
 | 
				
			||||||
 | 
					      showForwardMessageModal,
 | 
				
			||||||
      showIdentity,
 | 
					      showIdentity,
 | 
				
			||||||
      showMessageDetail,
 | 
					      showMessageDetail,
 | 
				
			||||||
      showVisualAttachment,
 | 
					      showVisualAttachment,
 | 
				
			||||||
| 
						 | 
					@ -980,14 +985,18 @@ Whisper.ConversationView = Whisper.View.extend({
 | 
				
			||||||
    this.$('.timeline-placeholder').append(this.timelineView.el);
 | 
					    this.$('.timeline-placeholder').append(this.timelineView.el);
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  showToast(ToastView: any, options: any) {
 | 
					  showToast(ToastView: any, options: any, element: Element) {
 | 
				
			||||||
    const toast = new ToastView(options);
 | 
					    const toast = new ToastView(options);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const lightboxEl = $('.module-lightbox');
 | 
					    if (element) {
 | 
				
			||||||
    if (lightboxEl.length > 0) {
 | 
					      toast.$el.appendTo(element);
 | 
				
			||||||
      toast.$el.appendTo(lightboxEl);
 | 
					 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      toast.$el.appendTo(this.$el);
 | 
					      const lightboxEl = $('.module-lightbox');
 | 
				
			||||||
 | 
					      if (lightboxEl.length > 0) {
 | 
				
			||||||
 | 
					        toast.$el.appendTo(lightboxEl);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        toast.$el.appendTo(this.$el);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    toast.render();
 | 
					    toast.render();
 | 
				
			||||||
| 
						 | 
					@ -2139,6 +2148,196 @@ Whisper.ConversationView = Whisper.View.extend({
 | 
				
			||||||
    await message.retrySend();
 | 
					    await message.retrySend();
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  showForwardMessageModal(messageId: string) {
 | 
				
			||||||
 | 
					    const message = this.model.messageCollection.get(messageId);
 | 
				
			||||||
 | 
					    if (!message) {
 | 
				
			||||||
 | 
					      throw new Error(
 | 
				
			||||||
 | 
					        `showForwardMessageModal: Did not find message for id ${messageId}`
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const attachments = message.getAttachmentsForMessage();
 | 
				
			||||||
 | 
					    this.forwardMessageModal = new Whisper.ReactWrapperView({
 | 
				
			||||||
 | 
					      JSX: window.Signal.State.Roots.createForwardMessageModal(
 | 
				
			||||||
 | 
					        window.reduxStore,
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          attachments,
 | 
				
			||||||
 | 
					          doForwardMessage: async (
 | 
				
			||||||
 | 
					            conversationIds: Array<string>,
 | 
				
			||||||
 | 
					            messageBody?: string,
 | 
				
			||||||
 | 
					            includedAttachments?: Array<AttachmentType>,
 | 
				
			||||||
 | 
					            linkPreview?: LinkPreviewType
 | 
				
			||||||
 | 
					          ) => {
 | 
				
			||||||
 | 
					            const didForwardSuccessfully = await this.maybeForwardMessage(
 | 
				
			||||||
 | 
					              message,
 | 
				
			||||||
 | 
					              conversationIds,
 | 
				
			||||||
 | 
					              messageBody,
 | 
				
			||||||
 | 
					              includedAttachments,
 | 
				
			||||||
 | 
					              linkPreview
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (didForwardSuccessfully) {
 | 
				
			||||||
 | 
					              this.forwardMessageModal.remove();
 | 
				
			||||||
 | 
					              this.forwardMessageModal = null;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          isSticker: Boolean(message.get('sticker')),
 | 
				
			||||||
 | 
					          messageBody: message.getRawText(),
 | 
				
			||||||
 | 
					          onClose: () => {
 | 
				
			||||||
 | 
					            this.forwardMessageModal.remove();
 | 
				
			||||||
 | 
					            this.forwardMessageModal = null;
 | 
				
			||||||
 | 
					            this.resetLinkPreview();
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          onEditorStateChange: (
 | 
				
			||||||
 | 
					            messageText: string,
 | 
				
			||||||
 | 
					            _: Array<typeof window.Whisper.BodyRangeType>,
 | 
				
			||||||
 | 
					            caretLocation?: number
 | 
				
			||||||
 | 
					          ) => {
 | 
				
			||||||
 | 
					            if (!attachments.length) {
 | 
				
			||||||
 | 
					              this.debouncedMaybeGrabLinkPreview(messageText, caretLocation);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          onTextTooLong: () =>
 | 
				
			||||||
 | 
					            this.showToast(
 | 
				
			||||||
 | 
					              Whisper.MessageBodyTooLongToast,
 | 
				
			||||||
 | 
					              {},
 | 
				
			||||||
 | 
					              document.querySelector('.module-ForwardMessageModal')
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    this.forwardMessageModal.render();
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async maybeForwardMessage(
 | 
				
			||||||
 | 
					    message: MessageModel,
 | 
				
			||||||
 | 
					    conversationIds: Array<string>,
 | 
				
			||||||
 | 
					    messageBody?: string,
 | 
				
			||||||
 | 
					    attachments?: Array<AttachmentType>,
 | 
				
			||||||
 | 
					    linkPreview?: LinkPreviewType
 | 
				
			||||||
 | 
					  ): Promise<boolean> {
 | 
				
			||||||
 | 
					    const attachmentLookup = new Set();
 | 
				
			||||||
 | 
					    if (attachments) {
 | 
				
			||||||
 | 
					      attachments.forEach(attachment => {
 | 
				
			||||||
 | 
					        attachmentLookup.add(
 | 
				
			||||||
 | 
					          `${attachment.fileName}/${attachment.contentType}`
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const conversations = conversationIds.map(id =>
 | 
				
			||||||
 | 
					      window.ConversationController.get(id)
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Verify that all contacts that we're forwarding
 | 
				
			||||||
 | 
					    // to are verified and trusted
 | 
				
			||||||
 | 
					    const unverifiedContacts: Array<ConversationModel> = [];
 | 
				
			||||||
 | 
					    const untrustedContacts: Array<ConversationModel> = [];
 | 
				
			||||||
 | 
					    await Promise.all(
 | 
				
			||||||
 | 
					      conversations.map(async conversation => {
 | 
				
			||||||
 | 
					        if (conversation) {
 | 
				
			||||||
 | 
					          await conversation.updateVerified();
 | 
				
			||||||
 | 
					          const unverifieds = conversation.getUnverified();
 | 
				
			||||||
 | 
					          if (unverifieds.length) {
 | 
				
			||||||
 | 
					            unverifieds.forEach(unverifiedConversation =>
 | 
				
			||||||
 | 
					              unverifiedContacts.push(unverifiedConversation)
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          const untrusted = conversation.getUntrusted();
 | 
				
			||||||
 | 
					          if (untrusted.length) {
 | 
				
			||||||
 | 
					            untrusted.forEach(untrustedConversation =>
 | 
				
			||||||
 | 
					              untrustedContacts.push(untrustedConversation)
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // If there are any unverified or untrusted contacts, show the
 | 
				
			||||||
 | 
					    // SendAnywayDialog and if we're fine with sending then mark all as
 | 
				
			||||||
 | 
					    // verified and trusted and continue the send.
 | 
				
			||||||
 | 
					    const iffyConversations = [...unverifiedContacts, ...untrustedContacts];
 | 
				
			||||||
 | 
					    if (iffyConversations.length) {
 | 
				
			||||||
 | 
					      const forwardMessageModal = document.querySelector<HTMLElement>(
 | 
				
			||||||
 | 
					        '.module-ForwardMessageModal'
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      if (forwardMessageModal) {
 | 
				
			||||||
 | 
					        forwardMessageModal.style.display = 'none';
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      const sendAnyway = await this.showSendAnywayDialog(iffyConversations);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!sendAnyway) {
 | 
				
			||||||
 | 
					        if (forwardMessageModal) {
 | 
				
			||||||
 | 
					          forwardMessageModal.style.display = 'block';
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      let verifyPromise: Promise<void> | undefined;
 | 
				
			||||||
 | 
					      let approvePromise: Promise<void> | undefined;
 | 
				
			||||||
 | 
					      if (unverifiedContacts.length) {
 | 
				
			||||||
 | 
					        verifyPromise = this.markAllAsVerifiedDefault(unverifiedContacts);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (untrustedContacts.length) {
 | 
				
			||||||
 | 
					        approvePromise = this.markAllAsApproved(untrustedContacts);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      await Promise.all([verifyPromise, approvePromise]);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const sendMessageOptions = { dontClearDraft: true };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Actually send the message
 | 
				
			||||||
 | 
					    // load any sticker data, attachments, or link previews that we need to
 | 
				
			||||||
 | 
					    // send along with the message and do the send to each conversation.
 | 
				
			||||||
 | 
					    await Promise.all(
 | 
				
			||||||
 | 
					      conversations.map(async conversation => {
 | 
				
			||||||
 | 
					        if (conversation) {
 | 
				
			||||||
 | 
					          const sticker = message.get('sticker');
 | 
				
			||||||
 | 
					          if (sticker) {
 | 
				
			||||||
 | 
					            const stickerWithData = await loadStickerData(sticker);
 | 
				
			||||||
 | 
					            conversation.sendMessage(
 | 
				
			||||||
 | 
					              null,
 | 
				
			||||||
 | 
					              [],
 | 
				
			||||||
 | 
					              null,
 | 
				
			||||||
 | 
					              [],
 | 
				
			||||||
 | 
					              stickerWithData,
 | 
				
			||||||
 | 
					              undefined,
 | 
				
			||||||
 | 
					              sendMessageOptions
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					          } else {
 | 
				
			||||||
 | 
					            const preview = linkPreview
 | 
				
			||||||
 | 
					              ? await loadPreviewData([linkPreview])
 | 
				
			||||||
 | 
					              : [];
 | 
				
			||||||
 | 
					            const allAttachments = message.getAttachmentsForMessage();
 | 
				
			||||||
 | 
					            const attachmentsToSend = allAttachments.filter(
 | 
				
			||||||
 | 
					              (attachment: Partial<AttachmentType>) =>
 | 
				
			||||||
 | 
					                attachmentLookup.has(
 | 
				
			||||||
 | 
					                  `${attachment.fileName}/${attachment.contentType}`
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            conversation.sendMessage(
 | 
				
			||||||
 | 
					              messageBody || null,
 | 
				
			||||||
 | 
					              attachmentsToSend,
 | 
				
			||||||
 | 
					              null, // quote
 | 
				
			||||||
 | 
					              preview,
 | 
				
			||||||
 | 
					              null, // sticker
 | 
				
			||||||
 | 
					              undefined, // BodyRanges
 | 
				
			||||||
 | 
					              sendMessageOptions
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (linkPreview) {
 | 
				
			||||||
 | 
					      this.resetLinkPreview();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return true;
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async showAllMedia() {
 | 
					  async showAllMedia() {
 | 
				
			||||||
    // We fetch more documents than media as they don’t require to be loaded
 | 
					    // We fetch more documents than media as they don’t require to be loaded
 | 
				
			||||||
    // into memory right away. Revisit this once we have infinite scrolling:
 | 
					    // into memory right away. Revisit this once we have infinite scrolling:
 | 
				
			||||||
| 
						 | 
					@ -3203,7 +3402,10 @@ Whisper.ConversationView = Whisper.View.extend({
 | 
				
			||||||
    return true;
 | 
					    return true;
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  showSendAnywayDialog(contacts: any, confirmText: any) {
 | 
					  showSendAnywayDialog(
 | 
				
			||||||
 | 
					    contacts: Array<ConversationModel>,
 | 
				
			||||||
 | 
					    confirmText?: string
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
    return new Promise(resolve => {
 | 
					    return new Promise(resolve => {
 | 
				
			||||||
      const dialog = new Whisper.SafetyNumberChangeDialogView({
 | 
					      const dialog = new Whisper.SafetyNumberChangeDialogView({
 | 
				
			||||||
        confirmText,
 | 
					        confirmText,
 | 
				
			||||||
| 
						 | 
					@ -3255,13 +3457,6 @@ Whisper.ConversationView = Whisper.View.extend({
 | 
				
			||||||
        return;
 | 
					        return;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled(
 | 
					 | 
				
			||||||
        'desktop.mandatoryProfileSharing'
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      if (mandatoryProfileSharingEnabled && !this.model.get('profileSharing')) {
 | 
					 | 
				
			||||||
        this.model.set({ profileSharing: true });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (this.showInvalidMessageToast()) {
 | 
					      if (this.showInvalidMessageToast()) {
 | 
				
			||||||
        return;
 | 
					        return;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
| 
						 | 
					@ -3474,13 +3669,6 @@ Whisper.ConversationView = Whisper.View.extend({
 | 
				
			||||||
        return;
 | 
					        return;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled(
 | 
					 | 
				
			||||||
        'desktop.mandatoryProfileSharing'
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      if (mandatoryProfileSharingEnabled && !this.model.get('profileSharing')) {
 | 
					 | 
				
			||||||
        this.model.set({ profileSharing: true });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const attachments = await this.getFiles();
 | 
					      const attachments = await this.getFiles();
 | 
				
			||||||
      const sendDelta = Date.now() - this.sendStart;
 | 
					      const sendDelta = Date.now() - this.sendStart;
 | 
				
			||||||
      window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
 | 
					      window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
 | 
				
			||||||
| 
						 | 
					@ -3607,6 +3795,7 @@ Whisper.ConversationView = Whisper.View.extend({
 | 
				
			||||||
        URL.revokeObjectURL(item.url);
 | 
					        URL.revokeObjectURL(item.url);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					    window.reduxActions.linkPreviews.removeLinkPreview();
 | 
				
			||||||
    this.preview = null;
 | 
					    this.preview = null;
 | 
				
			||||||
    this.currentlyMatchedLink = null;
 | 
					    this.currentlyMatchedLink = null;
 | 
				
			||||||
    this.linkPreviewAbortController?.abort();
 | 
					    this.linkPreviewAbortController?.abort();
 | 
				
			||||||
| 
						 | 
					@ -3881,6 +4070,7 @@ Whisper.ConversationView = Whisper.View.extend({
 | 
				
			||||||
        URL.revokeObjectURL(item.url);
 | 
					        URL.revokeObjectURL(item.url);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					    window.reduxActions.linkPreviews.removeLinkPreview();
 | 
				
			||||||
    this.preview = null;
 | 
					    this.preview = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Cancel other in-flight link preview requests.
 | 
					    // Cancel other in-flight link preview requests.
 | 
				
			||||||
| 
						 | 
					@ -3937,6 +4127,7 @@ Whisper.ConversationView = Whisper.View.extend({
 | 
				
			||||||
        return;
 | 
					        return;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      window.reduxActions.linkPreviews.addLinkPreview(result);
 | 
				
			||||||
      this.preview = [result];
 | 
					      this.preview = [result];
 | 
				
			||||||
      this.renderLinkPreview();
 | 
					      this.renderLinkPreview();
 | 
				
			||||||
    } catch (error) {
 | 
					    } catch (error) {
 | 
				
			||||||
| 
						 | 
					@ -3952,6 +4143,9 @@ Whisper.ConversationView = Whisper.View.extend({
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  renderLinkPreview() {
 | 
					  renderLinkPreview() {
 | 
				
			||||||
 | 
					    if (this.forwardMessageModal) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    if (this.previewView) {
 | 
					    if (this.previewView) {
 | 
				
			||||||
      this.previewView.remove();
 | 
					      this.previewView.remove();
 | 
				
			||||||
      this.previewView = null;
 | 
					      this.previewView = null;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										4
									
								
								ts/window.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								ts/window.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
					@ -46,6 +46,7 @@ import { createCompositionArea } from './state/roots/createCompositionArea';
 | 
				
			||||||
import { createContactModal } from './state/roots/createContactModal';
 | 
					import { createContactModal } from './state/roots/createContactModal';
 | 
				
			||||||
import { createConversationDetails } from './state/roots/createConversationDetails';
 | 
					import { createConversationDetails } from './state/roots/createConversationDetails';
 | 
				
			||||||
import { createConversationHeader } from './state/roots/createConversationHeader';
 | 
					import { createConversationHeader } from './state/roots/createConversationHeader';
 | 
				
			||||||
 | 
					import { createForwardMessageModal } from './state/roots/createForwardMessageModal';
 | 
				
			||||||
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
 | 
					import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
 | 
				
			||||||
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
 | 
					import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
 | 
				
			||||||
import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
 | 
					import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
 | 
				
			||||||
| 
						 | 
					@ -63,6 +64,7 @@ import * as conversationsDuck from './state/ducks/conversations';
 | 
				
			||||||
import * as emojisDuck from './state/ducks/emojis';
 | 
					import * as emojisDuck from './state/ducks/emojis';
 | 
				
			||||||
import * as expirationDuck from './state/ducks/expiration';
 | 
					import * as expirationDuck from './state/ducks/expiration';
 | 
				
			||||||
import * as itemsDuck from './state/ducks/items';
 | 
					import * as itemsDuck from './state/ducks/items';
 | 
				
			||||||
 | 
					import * as linkPreviewsDuck from './state/ducks/linkPreviews';
 | 
				
			||||||
import * as networkDuck from './state/ducks/network';
 | 
					import * as networkDuck from './state/ducks/network';
 | 
				
			||||||
import * as updatesDuck from './state/ducks/updates';
 | 
					import * as updatesDuck from './state/ducks/updates';
 | 
				
			||||||
import * as userDuck from './state/ducks/user';
 | 
					import * as userDuck from './state/ducks/user';
 | 
				
			||||||
| 
						 | 
					@ -491,6 +493,7 @@ declare global {
 | 
				
			||||||
          createContactModal: typeof createContactModal;
 | 
					          createContactModal: typeof createContactModal;
 | 
				
			||||||
          createConversationDetails: typeof createConversationDetails;
 | 
					          createConversationDetails: typeof createConversationDetails;
 | 
				
			||||||
          createConversationHeader: typeof createConversationHeader;
 | 
					          createConversationHeader: typeof createConversationHeader;
 | 
				
			||||||
 | 
					          createForwardMessageModal: typeof createForwardMessageModal;
 | 
				
			||||||
          createGroupLinkManagement: typeof createGroupLinkManagement;
 | 
					          createGroupLinkManagement: typeof createGroupLinkManagement;
 | 
				
			||||||
          createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
 | 
					          createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
 | 
				
			||||||
          createGroupV2JoinModal: typeof createGroupV2JoinModal;
 | 
					          createGroupV2JoinModal: typeof createGroupV2JoinModal;
 | 
				
			||||||
| 
						 | 
					@ -510,6 +513,7 @@ declare global {
 | 
				
			||||||
          emojis: typeof emojisDuck;
 | 
					          emojis: typeof emojisDuck;
 | 
				
			||||||
          expiration: typeof expirationDuck;
 | 
					          expiration: typeof expirationDuck;
 | 
				
			||||||
          items: typeof itemsDuck;
 | 
					          items: typeof itemsDuck;
 | 
				
			||||||
 | 
					          linkPreviews: typeof linkPreviewsDuck;
 | 
				
			||||||
          network: typeof networkDuck;
 | 
					          network: typeof networkDuck;
 | 
				
			||||||
          updates: typeof updatesDuck;
 | 
					          updates: typeof updatesDuck;
 | 
				
			||||||
          user: typeof userDuck;
 | 
					          user: typeof userDuck;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue