Collapse message bubbles when applicable
This commit is contained in:
parent
16cd115530
commit
c527de0a8d
19 changed files with 707 additions and 383 deletions
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2019-2021 Signal Messenger, LLC
|
// Copyright 2019-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
@import '../../stylesheets/variables';
|
@import '../../stylesheets/variables';
|
||||||
|
@ -6,7 +6,7 @@
|
||||||
.base {
|
.base {
|
||||||
background-color: $color-ultramarine;
|
background-color: $color-ultramarine;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 16px;
|
border-radius: 18px;
|
||||||
color: $color-white-alpha-90;
|
color: $color-white-alpha-90;
|
||||||
font: {
|
font: {
|
||||||
size: 12px;
|
size: 12px;
|
||||||
|
|
|
@ -51,26 +51,6 @@
|
||||||
|
|
||||||
// Module: Message
|
// Module: Message
|
||||||
|
|
||||||
// Note: this does the same thing as module-timeline__message-container but
|
|
||||||
// can be used outside the Timeline contact more easily.
|
|
||||||
.module-message-container {
|
|
||||||
@include button-reset;
|
|
||||||
|
|
||||||
cursor: inherit;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 4px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
visibility: hidden;
|
|
||||||
display: block;
|
|
||||||
font-size: 0;
|
|
||||||
content: ' ';
|
|
||||||
clear: both;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-message {
|
.module-message {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -79,6 +59,7 @@
|
||||||
outline: none;
|
outline: none;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
|
transition: background 0.1s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message--expired {
|
.module-message--expired {
|
||||||
|
@ -141,114 +122,68 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message__buttons__download {
|
@mixin module-message__buttons__button($light-icon, $dark-icon: $light-icon) {
|
||||||
|
cursor: pointer;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
cursor: pointer;
|
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
@include color-svg(
|
@include color-svg($light-icon, $color-gray-45);
|
||||||
'../images/icons/v2/save-outline-24.svg',
|
|
||||||
$color-gray-45
|
|
||||||
);
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@include color-svg(
|
@include color-svg($light-icon, $color-gray-90);
|
||||||
'../images/icons/v2/save-outline-24.svg',
|
|
||||||
$color-gray-90
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include dark-theme {
|
@include dark-theme {
|
||||||
@include color-svg('../images/icons/v2/save-solid-24.svg', $color-gray-45);
|
@include color-svg($dark-icon, $color-gray-45);
|
||||||
&:hover {
|
&:hover {
|
||||||
@include color-svg(
|
@include color-svg($light-icon, $color-gray-02);
|
||||||
'../images/icons/v2/save-solid-24.svg',
|
|
||||||
$color-gray-02
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-message--selected & {
|
||||||
|
@include mouse-mode {
|
||||||
|
background-color: $color-ultramarine;
|
||||||
|
}
|
||||||
|
@include dark-mouse-mode {
|
||||||
|
background-color: $color-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message:focus & {
|
||||||
|
@include keyboard-mode {
|
||||||
|
background-color: $color-ultramarine;
|
||||||
|
}
|
||||||
|
@include dark-keyboard-mode {
|
||||||
|
background-color: $color-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__buttons__download {
|
||||||
|
@include module-message__buttons__button(
|
||||||
|
'../images/icons/v2/save-outline-24.svg',
|
||||||
|
'../images/icons/v2/save-solid-24.svg'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message__buttons__react {
|
.module-message__buttons__react {
|
||||||
height: 24px;
|
@include module-message__buttons__button(
|
||||||
width: 24px;
|
'../images/icons/v2/add-emoji-outline-24.svg'
|
||||||
cursor: pointer;
|
);
|
||||||
@include light-theme {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v2/add-emoji-outline-24.svg',
|
|
||||||
$color-gray-45
|
|
||||||
);
|
|
||||||
&:hover {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v2/add-emoji-outline-24.svg',
|
|
||||||
$color-gray-90
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v2/add-emoji-outline-24.svg',
|
|
||||||
$color-gray-45
|
|
||||||
);
|
|
||||||
&:hover {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v2/add-emoji-outline-24.svg',
|
|
||||||
$color-gray-02
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message__buttons__reply {
|
.module-message__buttons__reply {
|
||||||
height: 24px;
|
@include module-message__buttons__button(
|
||||||
width: 24px;
|
'../images/icons/v2/reply-outline-24.svg',
|
||||||
cursor: pointer;
|
'../images/icons/v2/reply-solid-24.svg'
|
||||||
|
);
|
||||||
@include light-theme {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v2/reply-outline-24.svg',
|
|
||||||
$color-gray-45
|
|
||||||
);
|
|
||||||
&:hover {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v2/reply-outline-24.svg',
|
|
||||||
$color-gray-90
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
@include color-svg('../images/icons/v2/reply-solid-24.svg', $color-gray-45);
|
|
||||||
&:hover {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v2/reply-solid-24.svg',
|
|
||||||
$color-gray-02
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message__buttons__menu {
|
.module-message__buttons__menu {
|
||||||
height: 24px;
|
@include module-message__buttons__button(
|
||||||
width: 24px;
|
'../images/icons/v2/more-horiz-24.svg'
|
||||||
display: inline-block;
|
);
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
@include color-svg('../images/icons/v2/more-horiz-24.svg', $color-gray-45);
|
|
||||||
@include light-theme {
|
|
||||||
&:hover {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v2/more-horiz-24.svg',
|
|
||||||
$color-gray-90
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
&:hover {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v2/more-horiz-24.svg',
|
|
||||||
$color-gray-02
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--container {
|
&--container {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
@ -256,13 +191,6 @@
|
||||||
// the z-index here is so that this container is above the message and when
|
// the z-index here is so that this container is above the message and when
|
||||||
// clicked on, doesn't propagate the click event to the message.
|
// clicked on, doesn't propagate the click event to the message.
|
||||||
z-index: $z-index-above-base;
|
z-index: $z-index-above-base;
|
||||||
|
|
||||||
@include light-theme {
|
|
||||||
background-color: $color-white;
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
background-color: $color-gray-95;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -331,9 +259,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.module-message__container {
|
.module-message__container {
|
||||||
|
$collapsed-border-radius: 4px;
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border-radius: 16px;
|
border-radius: 18px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
min-width: 0px;
|
min-width: 0px;
|
||||||
|
@ -343,71 +273,47 @@
|
||||||
padding: {
|
padding: {
|
||||||
left: 12px;
|
left: 12px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
top: 10px;
|
top: 8px;
|
||||||
bottom: 7px;
|
bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include light-theme {
|
.module-message--collapsed-above & {
|
||||||
border: 1px solid $color-white;
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.module-message--collapsed-below & {
|
||||||
|
margin-bottom: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include dark-theme {
|
.module-message--incoming.module-message--collapsed-above & {
|
||||||
border: 1px solid $color-gray-95;
|
border-top-left-radius: $collapsed-border-radius;
|
||||||
|
}
|
||||||
|
.module-message--incoming.module-message--collapsed-below & {
|
||||||
|
border-bottom-left-radius: $collapsed-border-radius;
|
||||||
|
}
|
||||||
|
.module-message--outgoing.module-message--collapsed-above & {
|
||||||
|
border-top-right-radius: $collapsed-border-radius;
|
||||||
|
}
|
||||||
|
.module-message--outgoing.module-message--collapsed-below & {
|
||||||
|
border-bottom-right-radius: $collapsed-border-radius;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is the component we put the outline around when the whole message is selected
|
.module-message--selected {
|
||||||
.module-message--selected .module-message__container {
|
|
||||||
@include mouse-mode {
|
@include mouse-mode {
|
||||||
animation: message--mouse-selected 1s $ease-out-expo;
|
background: $color-selected-message-background-light;
|
||||||
|
}
|
||||||
|
@include dark-mouse-mode {
|
||||||
|
background: $color-selected-message-background-dark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.module-message:focus .module-message__container {
|
|
||||||
|
.module-message:focus {
|
||||||
@include keyboard-mode {
|
@include keyboard-mode {
|
||||||
box-shadow: 0 0 0 3px $color-ultramarine;
|
background: $color-selected-message-background-light;
|
||||||
}
|
}
|
||||||
}
|
@include dark-keyboard-mode {
|
||||||
|
background: $color-selected-message-background-dark;
|
||||||
@keyframes message--mouse-selected {
|
|
||||||
0% {
|
|
||||||
box-shadow: 0 0 0 5px transparent;
|
|
||||||
}
|
}
|
||||||
10% {
|
|
||||||
box-shadow: 0 0 0 5px $color-ultramarine;
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
box-shadow: 0 0 0 5px $color-ultramarine;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 0 5px transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We disable this highlight for messages with stickers, instead highlighting the sticker
|
|
||||||
.module-message--selected .module-message__container--with-sticker {
|
|
||||||
@include mouse-mode {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.module-message:focus .module-message__container--with-sticker {
|
|
||||||
@include keyboard-mode {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-message__container--with-sticker {
|
|
||||||
@include light-theme {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include dark-theme {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Leave some padding to eat the negative margin-bottom from
|
|
||||||
* .module-message__metadata
|
|
||||||
*/
|
|
||||||
padding-bottom: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message__container--emoji {
|
.module-message__container--emoji {
|
||||||
|
@ -600,8 +506,8 @@
|
||||||
margin: {
|
margin: {
|
||||||
left: -12px;
|
left: -12px;
|
||||||
right: -12px;
|
right: -12px;
|
||||||
top: -10px;
|
top: -8px;
|
||||||
bottom: -7px;
|
bottom: -8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
|
@ -1055,13 +961,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message__metadata {
|
.module-message__metadata {
|
||||||
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
justify-content: flex-end;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
|
|
||||||
&--outgoing {
|
&--inline {
|
||||||
justify-content: flex-end;
|
float: right;
|
||||||
|
margin-top: -14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1242,9 +1150,10 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
|
||||||
&--with-reactions {
|
&--with-reactions {
|
||||||
padding-bottom: 12px;
|
padding-bottom: 15px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1533,6 +1442,14 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-quote--curve-top-left {
|
||||||
|
border-top-left-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-quote--curve-top-right {
|
||||||
|
border-top-right-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.module-quote__primary {
|
.module-quote__primary {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
|
@ -2037,10 +1954,10 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
@include keyboard-mode {
|
@include keyboard-mode {
|
||||||
background-color: $color-gray-02;
|
background: $color-selected-message-background-light;
|
||||||
}
|
}
|
||||||
@include dark-keyboard-mode {
|
@include dark-keyboard-mode {
|
||||||
background-color: $color-gray-80;
|
background: $color-selected-message-background-dark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2974,16 +2891,16 @@ button.ConversationDetails__action-button {
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-image--curved-top-left {
|
.module-image--curved-top-left {
|
||||||
border-top-left-radius: 16px;
|
border-top-left-radius: 18px;
|
||||||
}
|
}
|
||||||
.module-image--curved-top-right {
|
.module-image--curved-top-right {
|
||||||
border-top-right-radius: 16px;
|
border-top-right-radius: 18px;
|
||||||
}
|
}
|
||||||
.module-image--curved-bottom-left {
|
.module-image--curved-bottom-left {
|
||||||
border-bottom-left-radius: 16px;
|
border-bottom-left-radius: 18px;
|
||||||
}
|
}
|
||||||
.module-image--curved-bottom-right {
|
.module-image--curved-bottom-right {
|
||||||
border-bottom-right-radius: 16px;
|
border-bottom-right-radius: 18px;
|
||||||
}
|
}
|
||||||
.module-image--small-curved-top-left {
|
.module-image--small-curved-top-left {
|
||||||
border-top-left-radius: 10px;
|
border-top-left-radius: 10px;
|
||||||
|
@ -3039,38 +2956,6 @@ button.ConversationDetails__action-button {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only if it's a sticker do we put the outline inside it
|
|
||||||
.module-message--selected
|
|
||||||
.module-message__container--with-sticker
|
|
||||||
.module-image__border-overlay {
|
|
||||||
@include mouse-mode {
|
|
||||||
top: 1px;
|
|
||||||
bottom: 1px;
|
|
||||||
left: 1px;
|
|
||||||
right: 1px;
|
|
||||||
border-radius: 10px;
|
|
||||||
|
|
||||||
animation: message--mouse-selected 1s $ease-out-expo;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-message:focus .module-message__container--with-sticker {
|
|
||||||
$border-radius: 10px;
|
|
||||||
|
|
||||||
.module-image__image {
|
|
||||||
@include keyboard-mode {
|
|
||||||
border-radius: $border-radius;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-image__border-overlay {
|
|
||||||
@include keyboard-mode {
|
|
||||||
border-radius: $border-radius;
|
|
||||||
box-shadow: 0 0 0 3px $color-ultramarine;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button.module-image__border-overlay:focus {
|
button.module-image__border-overlay:focus {
|
||||||
@include keyboard-mode {
|
@include keyboard-mode {
|
||||||
box-shadow: inset 0px 0px 0px 2px $color-ultramarine;
|
box-shadow: inset 0px 0px 0px 2px $color-ultramarine;
|
||||||
|
@ -8303,7 +8188,7 @@ button.module-image__border-overlay:focus {
|
||||||
}
|
}
|
||||||
|
|
||||||
&--with-reactions {
|
&--with-reactions {
|
||||||
margin-bottom: -9px;
|
margin-bottom: -6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--deleted-for-everyone {
|
&--deleted-for-everyone {
|
||||||
|
|
|
@ -230,6 +230,9 @@ $color-white-alpha-40: rgba($color-white, 0.4);
|
||||||
// Used in tap-to-view error states
|
// Used in tap-to-view error states
|
||||||
$color-deep-red: #ff261f;
|
$color-deep-red: #ff261f;
|
||||||
|
|
||||||
|
$color-selected-message-background-light: rgba(44, 107, 237, 0.24);
|
||||||
|
$color-selected-message-background-dark: $color-gray-65;
|
||||||
|
|
||||||
// -- A few layout variables used cross-file
|
// -- A few layout variables used cross-file
|
||||||
|
|
||||||
$header-height: 52px;
|
$header-height: 52px;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
@mixin system-message-icon($light, $dark) {
|
@mixin system-message-icon($light, $dark) {
|
||||||
|
@ -19,8 +19,8 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
margin-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
margin-top: 16px;
|
padding-top: 16px;
|
||||||
|
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
color: $color-gray-60;
|
color: $color-gray-60;
|
||||||
|
|
12
ts/components/AvatarSpacer.tsx
Normal file
12
ts/components/AvatarSpacer.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import React from 'react';
|
||||||
|
import type { AvatarSize } from './Avatar';
|
||||||
|
|
||||||
|
export const AvatarSpacer = ({
|
||||||
|
size,
|
||||||
|
}: Readonly<{ size: AvatarSize }>): ReactElement => (
|
||||||
|
<div style={{ minWidth: size, height: size, width: size }} />
|
||||||
|
);
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2020-2021 Signal Messenger, LLC
|
// Copyright 2020-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
@ -383,17 +383,11 @@ book.add('GroupNotification', () =>
|
||||||
stories.map(([title, propsArray]) => (
|
stories.map(([title, propsArray]) => (
|
||||||
<>
|
<>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
{propsArray.map((props, i) => {
|
{propsArray.map((props, i) => (
|
||||||
return (
|
<div key={i} className="module-inline-notification-wrapper">
|
||||||
<>
|
<GroupNotification {...props} />
|
||||||
<div key={i} className="module-message-container">
|
</div>
|
||||||
<div className="module-inline-notification-wrapper">
|
))}
|
||||||
<GroupNotification {...props} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
|
|
@ -29,6 +29,7 @@ import enMessages from '../../../_locales/en/messages.json';
|
||||||
import { pngUrl } from '../../storybook/Fixtures';
|
import { pngUrl } from '../../storybook/Fixtures';
|
||||||
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
||||||
import { WidthBreakpoint } from '../_util';
|
import { WidthBreakpoint } from '../_util';
|
||||||
|
import { MINUTE } from '../../util/durations';
|
||||||
|
|
||||||
import { fakeAttachment } from '../../test-both/helpers/fakeAttachment';
|
import { fakeAttachment } from '../../test-both/helpers/fakeAttachment';
|
||||||
import { getFakeBadge } from '../../test-both/helpers/getFakeBadge';
|
import { getFakeBadge } from '../../test-both/helpers/getFakeBadge';
|
||||||
|
@ -108,7 +109,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
canRetryDeleteForEveryone: overrideProps.canRetryDeleteForEveryone || false,
|
canRetryDeleteForEveryone: overrideProps.canRetryDeleteForEveryone || false,
|
||||||
checkForAccount: action('checkForAccount'),
|
checkForAccount: action('checkForAccount'),
|
||||||
clearSelectedMessage: action('clearSelectedMessage'),
|
clearSelectedMessage: action('clearSelectedMessage'),
|
||||||
collapseMetadata: overrideProps.collapseMetadata,
|
|
||||||
containerElementRef: React.createRef<HTMLElement>(),
|
containerElementRef: React.createRef<HTMLElement>(),
|
||||||
containerWidthBreakpoint: WidthBreakpoint.Wide,
|
containerWidthBreakpoint: WidthBreakpoint.Wide,
|
||||||
conversationColor:
|
conversationColor:
|
||||||
|
@ -188,13 +188,33 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
timestamp: number('timestamp', overrideProps.timestamp || Date.now()),
|
timestamp: number('timestamp', overrideProps.timestamp || Date.now()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderBothDirections = (props: Props) => (
|
const createTimelineItem = (data: undefined | Props) =>
|
||||||
<>
|
data && {
|
||||||
<Message {...props} />
|
type: 'message' as const,
|
||||||
<br />
|
data,
|
||||||
<Message {...props} direction="outgoing" />
|
timestamp: data.timestamp,
|
||||||
</>
|
};
|
||||||
);
|
|
||||||
|
const renderMany = (propsArray: ReadonlyArray<Props>) =>
|
||||||
|
propsArray.map((message, index) => (
|
||||||
|
<Message
|
||||||
|
key={message.text}
|
||||||
|
{...message}
|
||||||
|
previousItem={createTimelineItem(propsArray[index - 1])}
|
||||||
|
item={createTimelineItem(message)}
|
||||||
|
nextItem={createTimelineItem(propsArray[index + 1])}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
const renderBothDirections = (props: Props) =>
|
||||||
|
renderMany([
|
||||||
|
props,
|
||||||
|
{
|
||||||
|
...props,
|
||||||
|
author: { ...props.author, id: getDefaultConversation().id },
|
||||||
|
direction: 'outgoing',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
story.add('Plain Message', () => {
|
story.add('Plain Message', () => {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
|
@ -350,17 +370,6 @@ story.add('Pending', () => {
|
||||||
return renderBothDirections(props);
|
return renderBothDirections(props);
|
||||||
});
|
});
|
||||||
|
|
||||||
story.add('Collapsed Metadata', () => {
|
|
||||||
const props = createProps({
|
|
||||||
author: getDefaultConversation({ title: 'Fred Willard' }),
|
|
||||||
collapseMetadata: true,
|
|
||||||
conversationType: 'group',
|
|
||||||
text: 'Hello there from a pal!',
|
|
||||||
});
|
|
||||||
|
|
||||||
return renderBothDirections(props);
|
|
||||||
});
|
|
||||||
|
|
||||||
story.add('Recent', () => {
|
story.add('Recent', () => {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
text: 'Hello there from a pal!',
|
text: 'Hello there from a pal!',
|
||||||
|
@ -1392,3 +1401,67 @@ story.add('Custom Color', () => (
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
story.add('Collapsing text-only DMs', () => {
|
||||||
|
const them = getDefaultConversation();
|
||||||
|
const me = getDefaultConversation({ isMe: true });
|
||||||
|
|
||||||
|
return renderMany([
|
||||||
|
createProps({
|
||||||
|
author: them,
|
||||||
|
text: 'One',
|
||||||
|
timestamp: Date.now() - 5 * MINUTE,
|
||||||
|
}),
|
||||||
|
createProps({
|
||||||
|
author: them,
|
||||||
|
text: 'Two',
|
||||||
|
timestamp: Date.now() - 4 * MINUTE,
|
||||||
|
}),
|
||||||
|
createProps({
|
||||||
|
author: them,
|
||||||
|
text: 'Three',
|
||||||
|
timestamp: Date.now() - 3 * MINUTE,
|
||||||
|
}),
|
||||||
|
createProps({
|
||||||
|
author: me,
|
||||||
|
direction: 'outgoing',
|
||||||
|
text: 'Four',
|
||||||
|
timestamp: Date.now() - 2 * MINUTE,
|
||||||
|
}),
|
||||||
|
createProps({
|
||||||
|
text: 'Five',
|
||||||
|
author: me,
|
||||||
|
timestamp: Date.now() - MINUTE,
|
||||||
|
direction: 'outgoing',
|
||||||
|
}),
|
||||||
|
createProps({
|
||||||
|
author: me,
|
||||||
|
direction: 'outgoing',
|
||||||
|
text: 'Six',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
story.add('Collapsing text-only group messages', () => {
|
||||||
|
const author = getDefaultConversation();
|
||||||
|
|
||||||
|
return renderMany([
|
||||||
|
createProps({
|
||||||
|
author,
|
||||||
|
conversationType: 'group',
|
||||||
|
text: 'One',
|
||||||
|
timestamp: Date.now() - 2 * MINUTE,
|
||||||
|
}),
|
||||||
|
createProps({
|
||||||
|
author,
|
||||||
|
conversationType: 'group',
|
||||||
|
text: 'Two',
|
||||||
|
timestamp: Date.now() - MINUTE,
|
||||||
|
}),
|
||||||
|
createProps({
|
||||||
|
author,
|
||||||
|
conversationType: 'group',
|
||||||
|
text: 'Three',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
|
@ -15,14 +15,17 @@ import type {
|
||||||
ConversationTypeType,
|
ConversationTypeType,
|
||||||
InteractionModeType,
|
InteractionModeType,
|
||||||
} from '../../state/ducks/conversations';
|
} from '../../state/ducks/conversations';
|
||||||
|
import type { TimelineItemType } from './TimelineItem';
|
||||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
import { Avatar } from '../Avatar';
|
import { Avatar, AvatarSize } from '../Avatar';
|
||||||
|
import { AvatarSpacer } from '../AvatarSpacer';
|
||||||
import { Spinner } from '../Spinner';
|
import { Spinner } from '../Spinner';
|
||||||
import {
|
import {
|
||||||
doesMessageBodyOverflow,
|
doesMessageBodyOverflow,
|
||||||
MessageBodyReadMore,
|
MessageBodyReadMore,
|
||||||
} from './MessageBodyReadMore';
|
} from './MessageBodyReadMore';
|
||||||
import { MessageMetadata } from './MessageMetadata';
|
import { MessageMetadata } from './MessageMetadata';
|
||||||
|
import { MessageTextMetadataSpacer } from './MessageTextMetadataSpacer';
|
||||||
import { ImageGrid } from './ImageGrid';
|
import { ImageGrid } from './ImageGrid';
|
||||||
import { GIF } from './GIF';
|
import { GIF } from './GIF';
|
||||||
import { Image } from './Image';
|
import { Image } from './Image';
|
||||||
|
@ -80,15 +83,36 @@ import { getCustomColorStyle } from '../../util/getCustomColorStyle';
|
||||||
import { offsetDistanceModifier } from '../../util/popperUtil';
|
import { offsetDistanceModifier } from '../../util/popperUtil';
|
||||||
import * as KeyboardLayout from '../../services/keyboardLayout';
|
import * as KeyboardLayout from '../../services/keyboardLayout';
|
||||||
import { StopPropagation } from '../StopPropagation';
|
import { StopPropagation } from '../StopPropagation';
|
||||||
|
import {
|
||||||
|
areMessagesInSameGroup,
|
||||||
|
UnreadIndicatorPlacement,
|
||||||
|
} from '../../util/timelineUtil';
|
||||||
|
|
||||||
type Trigger = {
|
type Trigger = {
|
||||||
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_METADATA_WIDTH = 20;
|
||||||
|
const EXPIRATION_CHECK_MINIMUM = 2000;
|
||||||
|
const EXPIRED_DELAY = 600;
|
||||||
|
const GROUP_AVATAR_SIZE = AvatarSize.TWENTY_EIGHT;
|
||||||
const STICKER_SIZE = 200;
|
const STICKER_SIZE = 200;
|
||||||
const GIF_SIZE = 300;
|
const GIF_SIZE = 300;
|
||||||
const SELECTED_TIMEOUT = 1000;
|
const SELECTED_TIMEOUT = 1000;
|
||||||
const THREE_HOURS = 3 * 60 * 60 * 1000;
|
const THREE_HOURS = 3 * 60 * 60 * 1000;
|
||||||
|
const SENT_STATUSES = new Set<MessageStatusType>([
|
||||||
|
'delivered',
|
||||||
|
'read',
|
||||||
|
'sent',
|
||||||
|
'viewed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
enum MetadataPlacement {
|
||||||
|
NotRendered,
|
||||||
|
RenderedByMessageAudioComponent,
|
||||||
|
InlineWithText,
|
||||||
|
Bottom,
|
||||||
|
}
|
||||||
|
|
||||||
export const MessageStatuses = [
|
export const MessageStatuses = [
|
||||||
'delivered',
|
'delivered',
|
||||||
|
@ -111,6 +135,7 @@ export type AudioAttachmentProps = {
|
||||||
buttonRef: React.RefObject<HTMLButtonElement>;
|
buttonRef: React.RefObject<HTMLButtonElement>;
|
||||||
theme: ThemeType | undefined;
|
theme: ThemeType | undefined;
|
||||||
attachment: AttachmentType;
|
attachment: AttachmentType;
|
||||||
|
collapseMetadata: boolean;
|
||||||
withContentAbove: boolean;
|
withContentAbove: boolean;
|
||||||
withContentBelow: boolean;
|
withContentBelow: boolean;
|
||||||
|
|
||||||
|
@ -211,18 +236,21 @@ export type PropsData = {
|
||||||
export type PropsHousekeeping = {
|
export type PropsHousekeeping = {
|
||||||
containerElementRef: RefObject<HTMLElement>;
|
containerElementRef: RefObject<HTMLElement>;
|
||||||
containerWidthBreakpoint: WidthBreakpoint;
|
containerWidthBreakpoint: WidthBreakpoint;
|
||||||
|
disableMenu?: boolean;
|
||||||
|
disableScroll?: boolean;
|
||||||
getPreferredBadge: PreferredBadgeSelectorType;
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
now: number;
|
now: number;
|
||||||
interactionMode: InteractionModeType;
|
interactionMode: InteractionModeType;
|
||||||
theme: ThemeType;
|
item?: TimelineItemType;
|
||||||
disableMenu?: boolean;
|
nextItem?: TimelineItemType;
|
||||||
disableScroll?: boolean;
|
previousItem?: TimelineItemType;
|
||||||
collapseMetadata?: boolean;
|
|
||||||
renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element;
|
renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element;
|
||||||
renderReactionPicker: (
|
renderReactionPicker: (
|
||||||
props: React.ComponentProps<typeof SmartReactionPicker>
|
props: React.ComponentProps<typeof SmartReactionPicker>
|
||||||
) => JSX.Element;
|
) => JSX.Element;
|
||||||
|
theme: ThemeType;
|
||||||
|
unreadIndicatorPlacement?: undefined | UnreadIndicatorPlacement;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropsActions = {
|
export type PropsActions = {
|
||||||
|
@ -287,6 +315,8 @@ export type Props = PropsData &
|
||||||
Pick<ReactionPickerProps, 'renderEmojiPicker'>;
|
Pick<ReactionPickerProps, 'renderEmojiPicker'>;
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
|
metadataWidth: number;
|
||||||
|
|
||||||
expiring: boolean;
|
expiring: boolean;
|
||||||
expired: boolean;
|
expired: boolean;
|
||||||
imageBroken: boolean;
|
imageBroken: boolean;
|
||||||
|
@ -300,9 +330,6 @@ type State = {
|
||||||
hasDeleteForEveryoneTimerExpired: boolean;
|
hasDeleteForEveryoneTimerExpired: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EXPIRATION_CHECK_MINIMUM = 2000;
|
|
||||||
const EXPIRED_DELAY = 600;
|
|
||||||
|
|
||||||
export class Message extends React.PureComponent<Props, State> {
|
export class Message extends React.PureComponent<Props, State> {
|
||||||
public menuTriggerRef: Trigger | undefined;
|
public menuTriggerRef: Trigger | undefined;
|
||||||
|
|
||||||
|
@ -327,6 +354,8 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
|
metadataWidth: DEFAULT_METADATA_WIDTH,
|
||||||
|
|
||||||
expiring: false,
|
expiring: false,
|
||||||
expired: false,
|
expired: false,
|
||||||
imageBroken: false,
|
imageBroken: false,
|
||||||
|
@ -464,7 +493,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
this.toggleReactionPicker(true);
|
this.toggleReactionPicker(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override componentDidUpdate(prevProps: Props): void {
|
public override componentDidUpdate(prevProps: Readonly<Props>): void {
|
||||||
const { isSelected, status, timestamp } = this.props;
|
const { isSelected, status, timestamp } = this.props;
|
||||||
|
|
||||||
this.startSelectedTimer();
|
this.startSelectedTimer();
|
||||||
|
@ -494,6 +523,37 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getMetadataPlacement(
|
||||||
|
{
|
||||||
|
attachments,
|
||||||
|
expirationLength,
|
||||||
|
expirationTimestamp,
|
||||||
|
status,
|
||||||
|
text,
|
||||||
|
}: Readonly<Props> = this.props
|
||||||
|
): MetadataPlacement {
|
||||||
|
if (
|
||||||
|
!expirationLength &&
|
||||||
|
!expirationTimestamp &&
|
||||||
|
(!status || SENT_STATUSES.has(status)) &&
|
||||||
|
this.isCollapsedBelow()
|
||||||
|
) {
|
||||||
|
return MetadataPlacement.NotRendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return isAudio(attachments)
|
||||||
|
? MetadataPlacement.RenderedByMessageAudioComponent
|
||||||
|
: MetadataPlacement.Bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.canRenderStickerLikeEmoji()) {
|
||||||
|
return MetadataPlacement.Bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MetadataPlacement.InlineWithText;
|
||||||
|
}
|
||||||
|
|
||||||
public startSelectedTimer(): void {
|
public startSelectedTimer(): void {
|
||||||
const { clearSelectedMessage, interactionMode } = this.props;
|
const { clearSelectedMessage, interactionMode } = this.props;
|
||||||
const { isSelected } = this.state;
|
const { isSelected } = this.state;
|
||||||
|
@ -569,6 +629,37 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
return isMessageRequestAccepted && !isBlocked;
|
return isMessageRequestAccepted && !isBlocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isCollapsedAbove(
|
||||||
|
{ item, previousItem, unreadIndicatorPlacement }: Readonly<Props> = this
|
||||||
|
.props
|
||||||
|
): boolean {
|
||||||
|
return areMessagesInSameGroup(
|
||||||
|
previousItem,
|
||||||
|
unreadIndicatorPlacement === UnreadIndicatorPlacement.JustAbove,
|
||||||
|
item
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCollapsedBelow(
|
||||||
|
{ item, nextItem, unreadIndicatorPlacement }: Readonly<Props> = this.props
|
||||||
|
): boolean {
|
||||||
|
return areMessagesInSameGroup(
|
||||||
|
item,
|
||||||
|
unreadIndicatorPlacement === UnreadIndicatorPlacement.JustBelow,
|
||||||
|
nextItem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldRenderAuthor(): boolean {
|
||||||
|
const { author, conversationType, direction } = this.props;
|
||||||
|
return Boolean(
|
||||||
|
direction === 'incoming' &&
|
||||||
|
conversationType === 'group' &&
|
||||||
|
author.title &&
|
||||||
|
!this.isCollapsedAbove()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private canRenderStickerLikeEmoji(): boolean {
|
private canRenderStickerLikeEmoji(): boolean {
|
||||||
const { text, quote, attachments, previews } = this.props;
|
const { text, quote, attachments, previews } = this.props;
|
||||||
|
|
||||||
|
@ -582,10 +673,34 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public renderMetadata(): JSX.Element | null {
|
private updateMetadataWidth = (newMetadataWidth: number): void => {
|
||||||
|
this.setState(({ metadataWidth }) => ({
|
||||||
|
// We don't want text to jump around if the metadata shrinks, but we want to make
|
||||||
|
// sure we have enough room.
|
||||||
|
metadataWidth: Math.max(metadataWidth, newMetadataWidth),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
private renderMetadata(): ReactNode {
|
||||||
|
let isInline: boolean;
|
||||||
|
const metadataPlacement = this.getMetadataPlacement();
|
||||||
|
switch (metadataPlacement) {
|
||||||
|
case MetadataPlacement.NotRendered:
|
||||||
|
case MetadataPlacement.RenderedByMessageAudioComponent:
|
||||||
|
return null;
|
||||||
|
case MetadataPlacement.InlineWithText:
|
||||||
|
isInline = true;
|
||||||
|
break;
|
||||||
|
case MetadataPlacement.Bottom:
|
||||||
|
isInline = false;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
log.error(missingCaseError(metadataPlacement));
|
||||||
|
isInline = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attachments,
|
|
||||||
collapseMetadata,
|
|
||||||
deletedForEveryone,
|
deletedForEveryone,
|
||||||
direction,
|
direction,
|
||||||
expirationLength,
|
expirationLength,
|
||||||
|
@ -602,16 +717,6 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
showMessageDetail,
|
showMessageDetail,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (collapseMetadata) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The message audio component renders its own metadata because it positions the
|
|
||||||
// metadata in line with some of its own.
|
|
||||||
if (isAudio(attachments) && !text) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isStickerLike = isSticker || this.canRenderStickerLikeEmoji();
|
const isStickerLike = isSticker || this.canRenderStickerLikeEmoji();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -623,10 +728,12 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
hasText={Boolean(text)}
|
hasText={Boolean(text)}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
id={id}
|
id={id}
|
||||||
|
isInline={isInline}
|
||||||
isShowingImage={this.isShowingImage()}
|
isShowingImage={this.isShowingImage()}
|
||||||
isSticker={isStickerLike}
|
isSticker={isStickerLike}
|
||||||
isTapToViewExpired={isTapToViewExpired}
|
isTapToViewExpired={isTapToViewExpired}
|
||||||
now={now}
|
now={now}
|
||||||
|
onWidthMeasured={isInline ? this.updateMetadataWidth : undefined}
|
||||||
showMessageDetail={showMessageDetail}
|
showMessageDetail={showMessageDetail}
|
||||||
status={status}
|
status={status}
|
||||||
textPending={textPending}
|
textPending={textPending}
|
||||||
|
@ -635,27 +742,16 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public renderAuthor(): JSX.Element | null {
|
private renderAuthor(): ReactNode {
|
||||||
const {
|
const {
|
||||||
author,
|
author,
|
||||||
collapseMetadata,
|
|
||||||
contactNameColor,
|
contactNameColor,
|
||||||
conversationType,
|
|
||||||
direction,
|
|
||||||
isSticker,
|
isSticker,
|
||||||
isTapToView,
|
isTapToView,
|
||||||
isTapToViewExpired,
|
isTapToViewExpired,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (collapseMetadata) {
|
if (!this.shouldRenderAuthor()) {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
direction !== 'incoming' ||
|
|
||||||
conversationType !== 'group' ||
|
|
||||||
!author.title
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -681,8 +777,6 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
public renderAttachment(): JSX.Element | null {
|
public renderAttachment(): JSX.Element | null {
|
||||||
const {
|
const {
|
||||||
attachments,
|
attachments,
|
||||||
collapseMetadata,
|
|
||||||
conversationType,
|
|
||||||
direction,
|
direction,
|
||||||
expirationLength,
|
expirationLength,
|
||||||
expirationTimestamp,
|
expirationTimestamp,
|
||||||
|
@ -709,6 +803,9 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
const { imageBroken } = this.state;
|
const { imageBroken } = this.state;
|
||||||
|
|
||||||
|
const collapseMetadata =
|
||||||
|
this.getMetadataPlacement() === MetadataPlacement.NotRendered;
|
||||||
|
|
||||||
if (!attachments || !attachments[0]) {
|
if (!attachments || !attachments[0]) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -716,9 +813,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
// For attachments which aren't full-frame
|
// For attachments which aren't full-frame
|
||||||
const withContentBelow = Boolean(text);
|
const withContentBelow = Boolean(text);
|
||||||
const withContentAbove =
|
const withContentAbove = Boolean(quote) || this.shouldRenderAuthor();
|
||||||
Boolean(quote) ||
|
|
||||||
(conversationType === 'group' && direction === 'incoming');
|
|
||||||
const displayImage = canDisplayImage(attachments);
|
const displayImage = canDisplayImage(attachments);
|
||||||
|
|
||||||
if (displayImage && !imageBroken) {
|
if (displayImage && !imageBroken) {
|
||||||
|
@ -773,8 +868,12 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
<div className={containerClassName}>
|
<div className={containerClassName}>
|
||||||
<ImageGrid
|
<ImageGrid
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
withContentAbove={isSticker || withContentAbove}
|
withContentAbove={
|
||||||
withContentBelow={isSticker || withContentBelow}
|
isSticker || withContentAbove || this.isCollapsedAbove()
|
||||||
|
}
|
||||||
|
withContentBelow={
|
||||||
|
isSticker || withContentBelow || this.isCollapsedBelow()
|
||||||
|
}
|
||||||
isSticker={isSticker}
|
isSticker={isSticker}
|
||||||
stickerSize={STICKER_SIZE}
|
stickerSize={STICKER_SIZE}
|
||||||
bottomOverlay={bottomOverlay}
|
bottomOverlay={bottomOverlay}
|
||||||
|
@ -815,6 +914,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
renderingContext,
|
renderingContext,
|
||||||
theme,
|
theme,
|
||||||
attachment: firstAttachment,
|
attachment: firstAttachment,
|
||||||
|
collapseMetadata,
|
||||||
withContentAbove,
|
withContentAbove,
|
||||||
withContentBelow,
|
withContentBelow,
|
||||||
|
|
||||||
|
@ -1085,17 +1185,34 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isIncoming = direction === 'incoming';
|
||||||
|
|
||||||
|
let curveTopLeft: boolean;
|
||||||
|
let curveTopRight: boolean;
|
||||||
|
if (this.shouldRenderAuthor()) {
|
||||||
|
curveTopLeft = false;
|
||||||
|
curveTopRight = false;
|
||||||
|
} else if (isIncoming) {
|
||||||
|
curveTopLeft = !this.isCollapsedAbove();
|
||||||
|
curveTopRight = true;
|
||||||
|
} else {
|
||||||
|
curveTopLeft = true;
|
||||||
|
curveTopRight = !this.isCollapsedAbove();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Quote
|
<Quote
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onClick={clickHandler}
|
onClick={clickHandler}
|
||||||
text={quote.text}
|
text={quote.text}
|
||||||
rawAttachment={quote.rawAttachment}
|
rawAttachment={quote.rawAttachment}
|
||||||
isIncoming={direction === 'incoming'}
|
isIncoming={isIncoming}
|
||||||
authorTitle={quote.authorTitle}
|
authorTitle={quote.authorTitle}
|
||||||
bodyRanges={quote.bodyRanges}
|
bodyRanges={quote.bodyRanges}
|
||||||
conversationColor={conversationColor}
|
conversationColor={conversationColor}
|
||||||
customColor={customColor}
|
customColor={customColor}
|
||||||
|
curveTopLeft={curveTopLeft}
|
||||||
|
curveTopRight={curveTopRight}
|
||||||
isViewOnce={isViewOnce}
|
isViewOnce={isViewOnce}
|
||||||
referencedMessageNotFound={referencedMessageNotFound}
|
referencedMessageNotFound={referencedMessageNotFound}
|
||||||
isFromMe={quote.isFromMe}
|
isFromMe={quote.isFromMe}
|
||||||
|
@ -1108,7 +1225,6 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
public renderEmbeddedContact(): JSX.Element | null {
|
public renderEmbeddedContact(): JSX.Element | null {
|
||||||
const {
|
const {
|
||||||
collapseMetadata,
|
|
||||||
contact,
|
contact,
|
||||||
conversationType,
|
conversationType,
|
||||||
direction,
|
direction,
|
||||||
|
@ -1123,7 +1239,9 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
const withCaption = Boolean(text);
|
const withCaption = Boolean(text);
|
||||||
const withContentAbove =
|
const withContentAbove =
|
||||||
conversationType === 'group' && direction === 'incoming';
|
conversationType === 'group' && direction === 'incoming';
|
||||||
const withContentBelow = withCaption || !collapseMetadata;
|
const withContentBelow =
|
||||||
|
withCaption ||
|
||||||
|
this.getMetadataPlacement() !== MetadataPlacement.NotRendered;
|
||||||
|
|
||||||
const otherContent =
|
const otherContent =
|
||||||
(contact && contact.firstNumber && contact.isNumberOnSignal) ||
|
(contact && contact.firstNumber && contact.isNumberOnSignal) ||
|
||||||
|
@ -1166,22 +1284,19 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasAvatar(): boolean {
|
private renderAvatar(): ReactNode {
|
||||||
const { collapseMetadata, conversationType, direction } = this.props;
|
const {
|
||||||
|
author,
|
||||||
|
getPreferredBadge,
|
||||||
|
i18n,
|
||||||
|
showContactModal,
|
||||||
|
theme,
|
||||||
|
conversationType,
|
||||||
|
direction,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
return Boolean(
|
if (conversationType !== 'group' || direction !== 'incoming') {
|
||||||
!collapseMetadata &&
|
return null;
|
||||||
conversationType === 'group' &&
|
|
||||||
direction !== 'outgoing'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public renderAvatar(): JSX.Element | undefined {
|
|
||||||
const { author, getPreferredBadge, i18n, showContactModal, theme } =
|
|
||||||
this.props;
|
|
||||||
|
|
||||||
if (!this.hasAvatar()) {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1191,29 +1306,33 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
this.hasReactions(),
|
this.hasReactions(),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Avatar
|
{this.isCollapsedBelow() ? (
|
||||||
acceptedMessageRequest={author.acceptedMessageRequest}
|
<AvatarSpacer size={GROUP_AVATAR_SIZE} />
|
||||||
avatarPath={author.avatarPath}
|
) : (
|
||||||
badge={getPreferredBadge(author.badges)}
|
<Avatar
|
||||||
color={author.color}
|
acceptedMessageRequest={author.acceptedMessageRequest}
|
||||||
conversationType="direct"
|
avatarPath={author.avatarPath}
|
||||||
i18n={i18n}
|
badge={getPreferredBadge(author.badges)}
|
||||||
isMe={author.isMe}
|
color={author.color}
|
||||||
name={author.name}
|
conversationType="direct"
|
||||||
onClick={event => {
|
i18n={i18n}
|
||||||
event.stopPropagation();
|
isMe={author.isMe}
|
||||||
event.preventDefault();
|
name={author.name}
|
||||||
|
onClick={event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
showContactModal(author.id);
|
showContactModal(author.id);
|
||||||
}}
|
}}
|
||||||
phoneNumber={author.phoneNumber}
|
phoneNumber={author.phoneNumber}
|
||||||
profileName={author.profileName}
|
profileName={author.profileName}
|
||||||
sharedGroupNames={author.sharedGroupNames}
|
sharedGroupNames={author.sharedGroupNames}
|
||||||
size={28}
|
size={GROUP_AVATAR_SIZE}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
title={author.title}
|
title={author.title}
|
||||||
unblurredAvatarPath={author.unblurredAvatarPath}
|
unblurredAvatarPath={author.unblurredAvatarPath}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1232,6 +1351,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
text,
|
text,
|
||||||
textPending,
|
textPending,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
const { metadataWidth } = this.state;
|
||||||
|
|
||||||
// eslint-disable-next-line no-nested-ternary
|
// eslint-disable-next-line no-nested-ternary
|
||||||
const contents = deletedForEveryone
|
const contents = deletedForEveryone
|
||||||
|
@ -1267,6 +1387,9 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
text={contents || ''}
|
text={contents || ''}
|
||||||
textPending={textPending}
|
textPending={textPending}
|
||||||
/>
|
/>
|
||||||
|
{this.getMetadataPlacement() === MetadataPlacement.InlineWithText && (
|
||||||
|
<MessageTextMetadataSpacer metadataWidth={metadataWidth} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1680,14 +1803,13 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSticker) {
|
if (isSticker) {
|
||||||
// Padding is 8px, on both sides, plus two for 1px border
|
// Padding is 8px, on both sides
|
||||||
return STICKER_SIZE + 8 * 2 + 2;
|
return STICKER_SIZE + 8 * 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dimensions = getGridDimensions(attachments);
|
const dimensions = getGridDimensions(attachments);
|
||||||
if (dimensions) {
|
if (dimensions) {
|
||||||
// Add two for 1px border
|
return dimensions.width;
|
||||||
return dimensions.width + 2;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1699,8 +1821,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
) {
|
) {
|
||||||
const dimensions = getImageDimensions(firstLinkPreview.image);
|
const dimensions = getImageDimensions(firstLinkPreview.image);
|
||||||
if (dimensions) {
|
if (dimensions) {
|
||||||
// Add two for 1px border
|
return dimensions.width;
|
||||||
return dimensions.width + 2;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1797,13 +1918,14 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
public renderTapToView(): JSX.Element {
|
public renderTapToView(): JSX.Element {
|
||||||
const {
|
const {
|
||||||
collapseMetadata,
|
|
||||||
conversationType,
|
conversationType,
|
||||||
direction,
|
direction,
|
||||||
isTapToViewExpired,
|
isTapToViewExpired,
|
||||||
isTapToViewError,
|
isTapToViewError,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
const collapseMetadata =
|
||||||
|
this.getMetadataPlacement() === MetadataPlacement.NotRendered;
|
||||||
const withContentBelow = !collapseMetadata;
|
const withContentBelow = !collapseMetadata;
|
||||||
const withContentAbove =
|
const withContentAbove =
|
||||||
!collapseMetadata &&
|
!collapseMetadata &&
|
||||||
|
@ -2372,7 +2494,6 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
isSelected && !isStickerLike
|
isSelected && !isStickerLike
|
||||||
? 'module-message__container--selected'
|
? 'module-message__container--selected'
|
||||||
: null,
|
: null,
|
||||||
isStickerLike ? 'module-message__container--with-sticker' : null,
|
|
||||||
!isStickerLike ? `module-message__container--${direction}` : null,
|
!isStickerLike ? `module-message__container--${direction}` : null,
|
||||||
isEmojiOnly ? 'module-message__container--emoji' : null,
|
isEmojiOnly ? 'module-message__container--emoji' : null,
|
||||||
isTapToView ? 'module-message__container--with-tap-to-view' : null,
|
isTapToView ? 'module-message__container--with-tap-to-view' : null,
|
||||||
|
@ -2440,9 +2561,10 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-message',
|
'module-message',
|
||||||
`module-message--${direction}`,
|
`module-message--${direction}`,
|
||||||
|
this.isCollapsedAbove() && 'module-message--collapsed-above',
|
||||||
|
this.isCollapsedBelow() && 'module-message--collapsed-below',
|
||||||
isSelected ? 'module-message--selected' : null,
|
isSelected ? 'module-message--selected' : null,
|
||||||
expiring ? 'module-message--expired' : null,
|
expiring ? 'module-message--expired' : null
|
||||||
this.hasAvatar() ? 'module-message--with-avatar' : null
|
|
||||||
)}
|
)}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
// We pretend to be a button because we sometimes contain buttons and a button
|
// We pretend to be a button because we sometimes contain buttons and a button
|
||||||
|
|
|
@ -19,6 +19,7 @@ export type Props = {
|
||||||
renderingContext: string;
|
renderingContext: string;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
attachment: AttachmentType;
|
attachment: AttachmentType;
|
||||||
|
collapseMetadata: boolean;
|
||||||
withContentAbove: boolean;
|
withContentAbove: boolean;
|
||||||
withContentBelow: boolean;
|
withContentBelow: boolean;
|
||||||
|
|
||||||
|
@ -151,6 +152,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
i18n,
|
i18n,
|
||||||
renderingContext,
|
renderingContext,
|
||||||
attachment,
|
attachment,
|
||||||
|
collapseMetadata,
|
||||||
withContentAbove,
|
withContentAbove,
|
||||||
withContentBelow,
|
withContentBelow,
|
||||||
|
|
||||||
|
@ -530,7 +532,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
|
|
||||||
const metadata = (
|
const metadata = (
|
||||||
<div className={`${CSS_BASE}__metadata`}>
|
<div className={`${CSS_BASE}__metadata`}>
|
||||||
{!withContentBelow && (
|
{!withContentBelow && !collapseMetadata && (
|
||||||
<MessageMetadata
|
<MessageMetadata
|
||||||
direction={direction}
|
direction={direction}
|
||||||
expirationLength={expirationLength}
|
expirationLength={expirationLength}
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
// Copyright 2018-2022 Signal Messenger, LLC
|
// Copyright 2018-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { FunctionComponent, ReactChild } from 'react';
|
import type { ReactChild, ReactElement } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import Measure from 'react-measure';
|
||||||
|
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
import type { DirectionType, MessageStatusType } from './Message';
|
import type { DirectionType, MessageStatusType } from './Message';
|
||||||
|
@ -19,35 +20,37 @@ type PropsType = {
|
||||||
hasText: boolean;
|
hasText: boolean;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
id: string;
|
id: string;
|
||||||
|
isInline?: boolean;
|
||||||
isShowingImage: boolean;
|
isShowingImage: boolean;
|
||||||
isSticker?: boolean;
|
isSticker?: boolean;
|
||||||
isTapToViewExpired?: boolean;
|
isTapToViewExpired?: boolean;
|
||||||
now: number;
|
now: number;
|
||||||
|
onWidthMeasured?: (width: number) => unknown;
|
||||||
showMessageDetail: (id: string) => void;
|
showMessageDetail: (id: string) => void;
|
||||||
status?: MessageStatusType;
|
status?: MessageStatusType;
|
||||||
textPending?: boolean;
|
textPending?: boolean;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MessageMetadata: FunctionComponent<PropsType> = props => {
|
export const MessageMetadata = ({
|
||||||
const {
|
deletedForEveryone,
|
||||||
deletedForEveryone,
|
direction,
|
||||||
direction,
|
expirationLength,
|
||||||
expirationLength,
|
expirationTimestamp,
|
||||||
expirationTimestamp,
|
hasText,
|
||||||
hasText,
|
i18n,
|
||||||
i18n,
|
id,
|
||||||
id,
|
isInline,
|
||||||
isShowingImage,
|
isShowingImage,
|
||||||
isSticker,
|
isSticker,
|
||||||
isTapToViewExpired,
|
isTapToViewExpired,
|
||||||
now,
|
now,
|
||||||
showMessageDetail,
|
onWidthMeasured,
|
||||||
status,
|
showMessageDetail,
|
||||||
textPending,
|
status,
|
||||||
timestamp,
|
textPending,
|
||||||
} = props;
|
timestamp,
|
||||||
|
}: Readonly<PropsType>): ReactElement => {
|
||||||
const withImageNoCaption = Boolean(!isSticker && !hasText && isShowingImage);
|
const withImageNoCaption = Boolean(!isSticker && !hasText && isShowingImage);
|
||||||
const metadataDirection = isSticker ? undefined : direction;
|
const metadataDirection = isSticker ? undefined : direction;
|
||||||
|
|
||||||
|
@ -114,16 +117,13 @@ export const MessageMetadata: FunctionComponent<PropsType> = props => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const className = classNames(
|
||||||
<div
|
'module-message__metadata',
|
||||||
className={classNames(
|
isInline && 'module-message__metadata--inline',
|
||||||
'module-message__metadata',
|
withImageNoCaption && 'module-message__metadata--with-image-no-caption'
|
||||||
`module-message__metadata--${direction}`,
|
);
|
||||||
withImageNoCaption
|
const children = (
|
||||||
? 'module-message__metadata--with-image-no-caption'
|
<>
|
||||||
: null
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{timestampNode}
|
{timestampNode}
|
||||||
{expirationLength && expirationTimestamp ? (
|
{expirationLength && expirationTimestamp ? (
|
||||||
<ExpireTimer
|
<ExpireTimer
|
||||||
|
@ -161,6 +161,25 @@ export const MessageMetadata: FunctionComponent<PropsType> = props => {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onWidthMeasured) {
|
||||||
|
return (
|
||||||
|
<Measure
|
||||||
|
bounds
|
||||||
|
onResize={({ bounds }) => {
|
||||||
|
onWidthMeasured(bounds?.width || 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ measureRef }) => (
|
||||||
|
<div className={className} ref={measureRef}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Measure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
};
|
};
|
||||||
|
|
13
ts/components/conversation/MessageTextMetadataSpacer.tsx
Normal file
13
ts/components/conversation/MessageTextMetadataSpacer.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const SPACING = 10;
|
||||||
|
|
||||||
|
export const MessageTextMetadataSpacer = ({
|
||||||
|
metadataWidth,
|
||||||
|
}: Readonly<{ metadataWidth: number }>): ReactElement => (
|
||||||
|
<span style={{ display: 'inline-block', width: metadataWidth + SPACING }} />
|
||||||
|
);
|
|
@ -23,6 +23,8 @@ import { getCustomColorStyle } from '../../util/getCustomColorStyle';
|
||||||
export type Props = {
|
export type Props = {
|
||||||
authorTitle: string;
|
authorTitle: string;
|
||||||
conversationColor: ConversationColorType;
|
conversationColor: ConversationColorType;
|
||||||
|
curveTopLeft?: boolean;
|
||||||
|
curveTopRight?: boolean;
|
||||||
customColor?: CustomColorType;
|
customColor?: CustomColorType;
|
||||||
bodyRanges?: BodyRangesType;
|
bodyRanges?: BodyRangesType;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
@ -422,6 +424,8 @@ export class Quote extends React.Component<Props, State> {
|
||||||
public override render(): JSX.Element | null {
|
public override render(): JSX.Element | null {
|
||||||
const {
|
const {
|
||||||
conversationColor,
|
conversationColor,
|
||||||
|
curveTopLeft,
|
||||||
|
curveTopRight,
|
||||||
customColor,
|
customColor,
|
||||||
isIncoming,
|
isIncoming,
|
||||||
onClick,
|
onClick,
|
||||||
|
@ -444,10 +448,10 @@ export class Quote extends React.Component<Props, State> {
|
||||||
isIncoming
|
isIncoming
|
||||||
? `module-quote--incoming-${conversationColor}`
|
? `module-quote--incoming-${conversationColor}`
|
||||||
: `module-quote--outgoing-${conversationColor}`,
|
: `module-quote--outgoing-${conversationColor}`,
|
||||||
!onClick ? 'module-quote--no-click' : null,
|
!onClick && 'module-quote--no-click',
|
||||||
referencedMessageNotFound
|
referencedMessageNotFound && 'module-quote--with-reference-warning',
|
||||||
? 'module-quote--with-reference-warning'
|
curveTopLeft && 'module-quote--curve-top-left',
|
||||||
: null
|
curveTopRight && 'module-quote--curve-top-right'
|
||||||
)}
|
)}
|
||||||
style={{ ...getCustomColorStyle(customColor, true) }}
|
style={{ ...getCustomColorStyle(customColor, true) }}
|
||||||
>
|
>
|
||||||
|
|
|
@ -32,7 +32,10 @@ import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
|
||||||
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
||||||
import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions';
|
import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions';
|
||||||
import { TimelineFloatingHeader } from './TimelineFloatingHeader';
|
import { TimelineFloatingHeader } from './TimelineFloatingHeader';
|
||||||
import { getWidthBreakpoint } from '../../util/timelineUtil';
|
import {
|
||||||
|
getWidthBreakpoint,
|
||||||
|
UnreadIndicatorPlacement,
|
||||||
|
} from '../../util/timelineUtil';
|
||||||
import {
|
import {
|
||||||
getScrollBottom,
|
getScrollBottom,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
|
@ -117,6 +120,7 @@ type PropsHousekeepingType = {
|
||||||
nextMessageId: undefined | string;
|
nextMessageId: undefined | string;
|
||||||
now: number;
|
now: number;
|
||||||
previousMessageId: undefined | string;
|
previousMessageId: undefined | string;
|
||||||
|
unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
|
||||||
}) => JSX.Element;
|
}) => JSX.Element;
|
||||||
renderLastSeenIndicator: (id: string) => JSX.Element;
|
renderLastSeenIndicator: (id: string) => JSX.Element;
|
||||||
renderHeroRow: (
|
renderHeroRow: (
|
||||||
|
@ -839,8 +843,11 @@ export class Timeline extends React.Component<
|
||||||
|
|
||||||
const messageNodes: Array<ReactChild> = [];
|
const messageNodes: Array<ReactChild> = [];
|
||||||
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
|
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
|
||||||
const previousMessageId: undefined | string = items[itemIndex - 1];
|
const previousItemIndex = itemIndex - 1;
|
||||||
const nextMessageId: undefined | string = items[itemIndex + 1];
|
const nextItemIndex = itemIndex + 1;
|
||||||
|
|
||||||
|
const previousMessageId: undefined | string = items[previousItemIndex];
|
||||||
|
const nextMessageId: undefined | string = items[nextItemIndex];
|
||||||
const messageId = items[itemIndex];
|
const messageId = items[itemIndex];
|
||||||
|
|
||||||
if (!messageId) {
|
if (!messageId) {
|
||||||
|
@ -851,10 +858,14 @@ export class Timeline extends React.Component<
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
|
||||||
if (oldestUnreadIndex === itemIndex) {
|
if (oldestUnreadIndex === itemIndex) {
|
||||||
|
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustAbove;
|
||||||
messageNodes.push(
|
messageNodes.push(
|
||||||
<Fragment key="unread">{renderLastSeenIndicator(id)}</Fragment>
|
<Fragment key="unread">{renderLastSeenIndicator(id)}</Fragment>
|
||||||
);
|
);
|
||||||
|
} else if (oldestUnreadIndex === nextItemIndex) {
|
||||||
|
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustBelow;
|
||||||
}
|
}
|
||||||
|
|
||||||
messageNodes.push(
|
messageNodes.push(
|
||||||
|
@ -874,6 +885,7 @@ export class Timeline extends React.Component<
|
||||||
nextMessageId,
|
nextMessageId,
|
||||||
now: nowThatUpdatesEveryMinute,
|
now: nowThatUpdatesEveryMinute,
|
||||||
previousMessageId,
|
previousMessageId,
|
||||||
|
unreadIndicatorPlacement,
|
||||||
})}
|
})}
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
import type { ReactChild, RefObject } from 'react';
|
import type { ReactChild, RefObject } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { omit } from 'lodash';
|
|
||||||
|
|
||||||
import type { LocalizerType, ThemeType } from '../../types/Util';
|
import type { LocalizerType, ThemeType } from '../../types/Util';
|
||||||
import { isSameDay } from '../../util/timestamp';
|
import { isSameDay } from '../../util/timestamp';
|
||||||
|
@ -54,6 +53,7 @@ import type { SmartContactRendererType } from '../../groupChange';
|
||||||
import { ResetSessionNotification } from './ResetSessionNotification';
|
import { ResetSessionNotification } from './ResetSessionNotification';
|
||||||
import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileChangeNotification';
|
import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileChangeNotification';
|
||||||
import { ProfileChangeNotification } from './ProfileChangeNotification';
|
import { ProfileChangeNotification } from './ProfileChangeNotification';
|
||||||
|
import type { UnreadIndicatorPlacement } from '../../util/timelineUtil';
|
||||||
import type { FullJSXType } from '../Intl';
|
import type { FullJSXType } from '../Intl';
|
||||||
|
|
||||||
type CallHistoryType = {
|
type CallHistoryType = {
|
||||||
|
@ -156,6 +156,7 @@ type PropsLocalType = {
|
||||||
previousItem: undefined | TimelineItemType;
|
previousItem: undefined | TimelineItemType;
|
||||||
nextItem: undefined | TimelineItemType;
|
nextItem: undefined | TimelineItemType;
|
||||||
now: number;
|
now: number;
|
||||||
|
unreadIndicatorPlacement?: undefined | UnreadIndicatorPlacement;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PropsActionsType = MessageActionsType &
|
type PropsActionsType = MessageActionsType &
|
||||||
|
@ -196,6 +197,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
||||||
returnToActiveCall,
|
returnToActiveCall,
|
||||||
selectMessage,
|
selectMessage,
|
||||||
startCallingLobby,
|
startCallingLobby,
|
||||||
|
unreadIndicatorPlacement,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
|
@ -212,13 +214,14 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
||||||
if (item.type === 'message') {
|
if (item.type === 'message') {
|
||||||
itemContents = (
|
itemContents = (
|
||||||
<Message
|
<Message
|
||||||
{...omit(this.props, ['item'])}
|
{...this.props}
|
||||||
{...item.data}
|
{...item.data}
|
||||||
containerElementRef={containerElementRef}
|
containerElementRef={containerElementRef}
|
||||||
getPreferredBadge={getPreferredBadge}
|
getPreferredBadge={getPreferredBadge}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
renderingContext="conversation/TimelineItem"
|
renderingContext="conversation/TimelineItem"
|
||||||
|
unreadIndicatorPlacement={unreadIndicatorPlacement}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -21,6 +21,7 @@ export type Props = {
|
||||||
renderingContext: string;
|
renderingContext: string;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
attachment: AttachmentType;
|
attachment: AttachmentType;
|
||||||
|
collapseMetadata: boolean;
|
||||||
withContentAbove: boolean;
|
withContentAbove: boolean;
|
||||||
withContentBelow: boolean;
|
withContentBelow: boolean;
|
||||||
|
|
||||||
|
|
|
@ -46,6 +46,7 @@ import {
|
||||||
invertIdsByTitle,
|
invertIdsByTitle,
|
||||||
} from '../../util/groupMemberNameCollisions';
|
} from '../../util/groupMemberNameCollisions';
|
||||||
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
||||||
|
import type { UnreadIndicatorPlacement } from '../../util/timelineUtil';
|
||||||
import type { WidthBreakpoint } from '../../components/_util';
|
import type { WidthBreakpoint } from '../../components/_util';
|
||||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||||
|
|
||||||
|
@ -109,6 +110,7 @@ function renderItem({
|
||||||
nextMessageId,
|
nextMessageId,
|
||||||
now,
|
now,
|
||||||
previousMessageId,
|
previousMessageId,
|
||||||
|
unreadIndicatorPlacement,
|
||||||
}: {
|
}: {
|
||||||
actionProps: TimelineActionsType;
|
actionProps: TimelineActionsType;
|
||||||
containerElementRef: RefObject<HTMLElement>;
|
containerElementRef: RefObject<HTMLElement>;
|
||||||
|
@ -119,6 +121,7 @@ function renderItem({
|
||||||
nextMessageId: undefined | string;
|
nextMessageId: undefined | string;
|
||||||
now: number;
|
now: number;
|
||||||
previousMessageId: undefined | string;
|
previousMessageId: undefined | string;
|
||||||
|
unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<SmartTimelineItem
|
<SmartTimelineItem
|
||||||
|
@ -134,6 +137,7 @@ function renderItem({
|
||||||
renderEmojiPicker={renderEmojiPicker}
|
renderEmojiPicker={renderEmojiPicker}
|
||||||
renderReactionPicker={renderReactionPicker}
|
renderReactionPicker={renderReactionPicker}
|
||||||
renderAudioAttachment={renderAudioAttachment}
|
renderAudioAttachment={renderAudioAttachment}
|
||||||
|
unreadIndicatorPlacement={unreadIndicatorPlacement}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {
|
||||||
getMessageSelector,
|
getMessageSelector,
|
||||||
getSelectedMessage,
|
getSelectedMessage,
|
||||||
} from '../selectors/conversations';
|
} from '../selectors/conversations';
|
||||||
|
import type { UnreadIndicatorPlacement } from '../../util/timelineUtil';
|
||||||
|
|
||||||
import { SmartContactName } from './ContactName';
|
import { SmartContactName } from './ContactName';
|
||||||
import { SmartUniversalTimerNotification } from './UniversalTimerNotification';
|
import { SmartUniversalTimerNotification } from './UniversalTimerNotification';
|
||||||
|
@ -28,6 +29,7 @@ type ExternalProps = {
|
||||||
nextMessageId: undefined | string;
|
nextMessageId: undefined | string;
|
||||||
previousMessageId: undefined | string;
|
previousMessageId: undefined | string;
|
||||||
now: number;
|
now: number;
|
||||||
|
unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderContact(conversationId: string): JSX.Element {
|
function renderContact(conversationId: string): JSX.Element {
|
||||||
|
@ -47,6 +49,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
nextMessageId,
|
nextMessageId,
|
||||||
previousMessageId,
|
previousMessageId,
|
||||||
now,
|
now,
|
||||||
|
unreadIndicatorPlacement,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const messageSelector = getMessageSelector(state);
|
const messageSelector = getMessageSelector(state);
|
||||||
|
@ -82,6 +85,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
i18n: getIntl(state),
|
i18n: getIntl(state),
|
||||||
interactionMode: getInteractionMode(state),
|
interactionMode: getInteractionMode(state),
|
||||||
theme: getTheme(state),
|
theme: getTheme(state),
|
||||||
|
unreadIndicatorPlacement,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
116
ts/test-both/util/timelineUtil_test.ts
Normal file
116
ts/test-both/util/timelineUtil_test.ts
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { MINUTE, SECOND } from '../../util/durations';
|
||||||
|
import { areMessagesInSameGroup } from '../../util/timelineUtil';
|
||||||
|
|
||||||
|
describe('<Timeline> utilities', () => {
|
||||||
|
describe('areMessagesInSameGroup', () => {
|
||||||
|
const defaultNewer = {
|
||||||
|
type: 'message' as const,
|
||||||
|
data: {
|
||||||
|
author: { id: uuid() },
|
||||||
|
timestamp: new Date(1998, 10, 21, 12, 34, 56, 123).valueOf(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const defaultOlder = {
|
||||||
|
...defaultNewer,
|
||||||
|
data: {
|
||||||
|
...defaultNewer.data,
|
||||||
|
timestamp: defaultNewer.data.timestamp - MINUTE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns false if either item is missing', () => {
|
||||||
|
assert.isFalse(areMessagesInSameGroup(undefined, false, undefined));
|
||||||
|
assert.isFalse(areMessagesInSameGroup(defaultNewer, false, undefined));
|
||||||
|
assert.isFalse(areMessagesInSameGroup(undefined, false, defaultNewer));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false if either item is not a message', () => {
|
||||||
|
const linkNotification = {
|
||||||
|
type: 'linkNotification' as const,
|
||||||
|
data: null,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.isFalse(
|
||||||
|
areMessagesInSameGroup(defaultNewer, false, linkNotification)
|
||||||
|
);
|
||||||
|
assert.isFalse(
|
||||||
|
areMessagesInSameGroup(linkNotification, false, defaultNewer)
|
||||||
|
);
|
||||||
|
assert.isFalse(
|
||||||
|
areMessagesInSameGroup(linkNotification, false, linkNotification)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false if authors don't match", () => {
|
||||||
|
const older = {
|
||||||
|
...defaultOlder,
|
||||||
|
data: { ...defaultOlder.data, author: { id: uuid() } },
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.isFalse(areMessagesInSameGroup(older, false, defaultNewer));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false if the older item was sent more than 3 minutes before', () => {
|
||||||
|
const older = {
|
||||||
|
...defaultNewer,
|
||||||
|
data: {
|
||||||
|
...defaultNewer.data,
|
||||||
|
timestamp: defaultNewer.data.timestamp - 3 * MINUTE - SECOND,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.isFalse(areMessagesInSameGroup(older, false, defaultNewer));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false if the older item was somehow sent in the future', () => {
|
||||||
|
assert.isFalse(areMessagesInSameGroup(defaultNewer, false, defaultOlder));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false if the older item was sent across a day boundary, even if they're otherwise <3m apart", () => {
|
||||||
|
const older = {
|
||||||
|
...defaultOlder,
|
||||||
|
data: {
|
||||||
|
...defaultOlder.data,
|
||||||
|
timestamp: new Date(2000, 2, 2, 23, 59, 0, 0).valueOf(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const newer = {
|
||||||
|
...defaultNewer,
|
||||||
|
data: {
|
||||||
|
...defaultNewer.data,
|
||||||
|
timestamp: new Date(2000, 2, 3, 0, 1, 0, 0).valueOf(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.isBelow(
|
||||||
|
newer.data.timestamp - older.data.timestamp,
|
||||||
|
3 * MINUTE,
|
||||||
|
'Test was set up incorrectly'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.isFalse(areMessagesInSameGroup(older, false, newer));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false if the older item has reactions', () => {
|
||||||
|
const older = {
|
||||||
|
...defaultOlder,
|
||||||
|
data: { ...defaultOlder.data, reactions: [{}] },
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.isFalse(areMessagesInSameGroup(older, false, defaultNewer));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false if there's an unread indicator in the middle", () => {
|
||||||
|
assert.isFalse(areMessagesInSameGroup(defaultOlder, true, defaultNewer));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true if the everything above works out', () => {
|
||||||
|
assert.isTrue(areMessagesInSameGroup(defaultOlder, false, defaultNewer));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,7 +1,64 @@
|
||||||
// Copyright 2022 Signal Messenger, LLC
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { TimelineItemType } from '../components/conversation/TimelineItem';
|
||||||
import { WidthBreakpoint } from '../components/_util';
|
import { WidthBreakpoint } from '../components/_util';
|
||||||
|
import { MINUTE } from './durations';
|
||||||
|
import { isSameDay } from './timestamp';
|
||||||
|
|
||||||
|
const COLLAPSE_WITHIN = 3 * MINUTE;
|
||||||
|
|
||||||
|
export enum UnreadIndicatorPlacement {
|
||||||
|
JustAbove,
|
||||||
|
JustBelow,
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageTimelineItemDataType = Readonly<{
|
||||||
|
author: { id: string };
|
||||||
|
reactions?: ReadonlyArray<unknown>;
|
||||||
|
timestamp: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// This lets us avoid passing a full `MessageType`. That's useful for tests and for
|
||||||
|
// documentation.
|
||||||
|
type MaybeMessageTimelineItemType = Readonly<
|
||||||
|
| undefined
|
||||||
|
| TimelineItemType
|
||||||
|
| { type: 'message'; data: MessageTimelineItemDataType }
|
||||||
|
>;
|
||||||
|
|
||||||
|
const getMessageTimelineItemData = (
|
||||||
|
timelineItem: MaybeMessageTimelineItemType
|
||||||
|
): undefined | MessageTimelineItemDataType =>
|
||||||
|
timelineItem?.type === 'message' ? timelineItem.data : undefined;
|
||||||
|
|
||||||
|
export function areMessagesInSameGroup(
|
||||||
|
olderTimelineItem: MaybeMessageTimelineItemType,
|
||||||
|
unreadIndicator: boolean,
|
||||||
|
newerTimelineItem: MaybeMessageTimelineItemType
|
||||||
|
): boolean {
|
||||||
|
if (unreadIndicator) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const olderMessage = getMessageTimelineItemData(olderTimelineItem);
|
||||||
|
if (!olderMessage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newerMessage = getMessageTimelineItemData(newerTimelineItem);
|
||||||
|
if (!newerMessage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(
|
||||||
|
!olderMessage.reactions?.length &&
|
||||||
|
olderMessage.author.id === newerMessage.author.id &&
|
||||||
|
newerMessage.timestamp >= olderMessage.timestamp &&
|
||||||
|
newerMessage.timestamp - olderMessage.timestamp < COLLAPSE_WITHIN &&
|
||||||
|
isSameDay(olderMessage.timestamp, newerMessage.timestamp)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getWidthBreakpoint(width: number): WidthBreakpoint {
|
export function getWidthBreakpoint(width: number): WidthBreakpoint {
|
||||||
if (width > 606) {
|
if (width > 606) {
|
||||||
|
|
Loading…
Reference in a new issue