Introduce a new design for the left pane

This commit is contained in:
Evan Hahn 2021-10-12 18:59:08 -05:00 committed by GitHub
parent d60600d6fb
commit 35a54cdc02
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 1205 additions and 576 deletions

View File

@ -60,26 +60,19 @@
<script type="text/x-tmpl-mustache" id="two-column">
<div class='module-title-bar-drag-area'></div>
<div class='inbox-container'>
<div class='gutter'>
<div class='left-pane-placeholder'></div>
<div class='left-pane-placeholder'></div>
<div class='conversation-stack'>
<div class='no-conversation-open'>
<div class="module-splash-screen__logo module-img--128 module-logo-blue"></div>
<h3>{{ welcomeToSignal }}</h3>
<p class="whats-new-placeholder"></p>
<p>{{ selectAContact }}</p>
</div>
<div class='conversation-stack'>
<div class='conversation placeholder'>
<div class='conversation-header'></div>
<div class='container'>
<div class='content'>
<div class="module-splash-screen__logo module-img--128 module-logo-blue"></div>
<h3>{{ welcomeToSignal }}</h3>
<p class="whats-new-placeholder"></p>
<p>{{ selectAContact }}</p>
</div>
</div>
</div>
<div id="toast"></div>
</div>
<div class='lightbox-container'></div>
<div id="toast"></div>
</div>
<div class='lightbox-container'></div>
</script>
<script type="text/x-tmpl-mustache" id="conversation">

View File

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m10.75 8h2.5l-.5 6.5h-1.5zm2.75 9.5a1.5 1.5 0 1 0 -1.5 1.5 1.5 1.5 0 0 0 1.5-1.5zm-1.5-13.23-9.4 16.23h18.8zm0-2.27a.9.9 0 0 1 .75.53l10.5 18.17c.41.72.07 1.3-.75 1.3h-21c-.83 0-1.16-.58-.75-1.3l10.5-18.13a.9.9 0 0 1 .75-.57z"/></svg>

After

Width:  |  Height:  |  Size: 303 B

View File

@ -14,12 +14,6 @@
}
.conversation {
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
@include light-theme {
background-color: $color-white;
}

View File

@ -35,6 +35,10 @@ body {
background-color: $color-gray-95;
color: $color-gray-05;
}
&.is-resizing-left-pane {
cursor: col-resize;
}
}
::-webkit-scrollbar {

View File

@ -7,54 +7,27 @@
.conversation-stack,
.inbox,
.inbox-container,
.gutter {
.no-conversation-open {
height: 100%;
overflow: hidden;
position: relative;
}
.inbox {
display: flex;
flex-direction: column;
}
.scrollable {
height: 100%;
overflow: auto;
}
.gutter {
@include light-theme {
background-color: $color-gray-02;
::-webkit-scrollbar-thumb {
border: 2px solid $color-gray-02;
}
}
@include dark-theme {
background-color: $color-gray-80;
::-webkit-scrollbar-thumb {
border: 2px solid $color-gray-80;
}
}
float: left;
width: $left-pane-width;
user-select: none;
.content {
overflow-y: scroll;
max-height: calc(100% - 88px);
}
.no-conversation-open {
display: flex;
flex-direction: column;
text-align: center;
justify-content: center;
align-items: center;
}
.left-pane-placeholder {
height: 100%;
}
.left-pane-wrapper {
height: 100%;
.conversation-stack {
flex-grow: 1;
}
.conversation.placeholder {
@ -62,7 +35,6 @@
user-select: none;
.container {
position: absolute;
height: 100%;
width: 100%;
display: flex;

View File

@ -78,6 +78,17 @@
align-items: stretch;
outline: none;
max-width: 406px;
.module-timeline--width-wide &,
.module-message-detail & {
max-width: 75%;
}
.module-timeline--width-medium & {
max-width: 480px;
}
}
.module-message--expired {
@ -102,21 +113,17 @@
}
}
// Spec: container < 438px
.module-message--incoming {
margin-left: 16px;
margin-right: 32px;
margin-right: auto;
}
.module-message--outgoing {
float: right;
margin-right: 16px;
margin-left: 32px;
margin-left: auto;
}
.module-message__buttons {
position: absolute;
top: 0;
bottom: 0;
display: inline-flex;
flex-direction: row;
align-items: center;
@ -128,19 +135,31 @@
}
.module-message__buttons--incoming {
left: 100%;
padding-left: 8px;
& > * {
margin-left: 12px;
&:first-child {
margin-left: 0;
}
}
}
.module-message__buttons--outgoing {
right: 100%;
padding-right: 8px;
flex-direction: row-reverse;
& > * {
margin-right: 12px;
&:first-child {
margin-right: 0;
}
}
}
.module-message__buttons__download {
height: 24px;
width: 24px;
display: inline-block;
cursor: pointer;
@include light-theme {
@include color-svg(
@ -168,7 +187,6 @@
.module-message__buttons__react {
height: 24px;
width: 24px;
display: inline-block;
cursor: pointer;
@include light-theme {
@include color-svg(
@ -196,17 +214,9 @@
}
}
.module-message__buttons__download--incoming {
margin-left: 12px;
}
.module-message__buttons__download--outgoing {
margin-right: 12px;
}
.module-message__buttons__reply {
height: 24px;
width: 24px;
display: inline-block;
cursor: pointer;
@include light-theme {
@ -232,13 +242,6 @@
}
}
.module-message__buttons__reply--incoming {
margin-left: 12px;
}
.module-message__buttons__reply--outgoing {
margin-right: 12px;
}
.module-message__buttons__menu {
height: 24px;
width: 24px;
@ -279,14 +282,6 @@
}
}
.module-message__buttons__menu--incoming {
margin-left: 12px;
}
.module_message__buttons__menu--outgoing {
margin-right: 12px;
}
.module-message__error-container {
min-width: 28px;
position: relative;
@ -3278,8 +3273,8 @@ button.module-conversation-details__action-button {
height: calc(#{$header-height} + var(--title-bar-drag-area-height));
width: 100%;
padding-left: 16px;
padding-right: 16px;
padding-left: 24px;
padding-right: 24px;
padding-top: var(--title-bar-drag-area-height);
display: flex;
@ -3311,6 +3306,10 @@ button.module-conversation-details__action-button {
top: 0;
right: 0;
}
.module-left-pane--width-narrow & {
display: none;
}
}
&__search {
@ -3459,6 +3458,10 @@ button.module-conversation-details__action-button {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-25);
}
}
.module-left-pane--width-narrow & {
display: none;
}
}
&__compose-icon {
@ -5640,7 +5643,11 @@ button.module-image__border-overlay:focus {
// Module: conversation list
.module-conversation-list {
$normal-row-height: 72px;
@include scrollbar;
padding-left: 10px;
padding-right: 10px;
&--scroll-behavior {
&-default {
@ -5654,11 +5661,15 @@ button.module-image__border-overlay:focus {
align-items: center;
display: flex;
height: 64px;
justify-content: center;
line-height: 64px;
border-radius: 10px;
height: $normal-row-height;
line-height: $normal-row-height;
text-align: center;
width: 100%;
padding-left: 14px;
padding-right: 14px;
display: flex;
@include light-theme {
color: $color-gray-60;
@ -5721,20 +5732,42 @@ button.module-image__border-overlay:focus {
background-color: $color-gray-75;
}
}
.module-conversation-list--width-narrow & {
justify-content: flex-start;
&__icon {
display: block;
width: 48px;
height: 48px;
padding: 12px;
}
&__text,
&__archived-count {
display: none;
}
}
}
&--contact-or-conversation {
@include button-reset;
align-items: center;
border-radius: 10px;
cursor: inherit;
display: flex;
flex-direction: row;
padding-left: 16px;
padding-right: 16px;
height: $normal-row-height;
margin: 2px 0;
padding: 8px 14px;
user-select: none;
width: 100%;
.module-conversation-list--width-narrow & {
padding-right: 8px;
}
&--is-button {
cursor: pointer;
@ -5745,10 +5778,10 @@ button.module-image__border-overlay:focus {
&:hover:not(:disabled),
&:focus:not(:disabled) {
@include light-theme {
background-color: $color-gray-05;
background-color: $color-black-alpha-06;
}
@include dark-theme {
background-color: $color-gray-75;
background-color: $color-white-alpha-06;
}
}
}
@ -5772,59 +5805,40 @@ button.module-image__border-overlay:focus {
}
}
&--has-unread {
padding-left: 12px;
@include light-theme {
border-left: 4px solid $color-ultramarine;
}
@include dark-theme {
border-left: 4px solid $color-ultramarine-light;
}
}
&--is-selected {
@include light-theme {
background-color: $color-gray-15;
background-color: $color-black-alpha-12;
}
@include dark-theme {
background-color: $color-gray-65;
background-color: $color-white-alpha-12;
}
}
&__avatar-container {
position: relative;
margin-top: 8px;
margin-bottom: 8px;
}
&__unread-count {
text-align: center;
padding-left: 3px;
padding-right: 3px;
position: absolute;
right: -6px;
top: 0px;
$size: 18px;
@include font-caption-bold;
height: 20px;
min-width: 20px;
line-height: 20px;
border-radius: 10px;
color: $color-white;
height: $size;
line-height: $size;
margin-left: 10px;
margin-top: 2px;
min-width: $size;
padding-left: 3px;
padding-right: 3px;
text-align: center;
word-break: normal;
.module-conversation-list--width-narrow & {
margin-left: 0;
}
@include light-theme {
background-color: $color-ultramarine;
box-shadow: 0px 0px 0px 1px $color-gray-02;
}
@include dark-theme {
background-color: $color-ultramarine-light;
box-shadow: 0px 0px 0px 1px $color-gray-90;
}
}
@ -5836,6 +5850,10 @@ button.module-image__border-overlay:focus {
align-items: stretch;
overflow: hidden;
.module-conversation-list--width-narrow & {
margin-left: 0;
}
&--disabled {
opacity: 0.5;
}
@ -5845,6 +5863,10 @@ button.module-image__border-overlay:focus {
flex-direction: row;
align-items: center;
.module-conversation-list--width-narrow & {
display: none;
}
&__name {
flex-grow: 1;
flex-shrink: 1;
@ -5867,17 +5889,6 @@ button.module-image__border-overlay:focus {
display: inline-block;
flex-shrink: 0;
&--has-unread {
@include font-caption-bold;
@include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-05;
}
}
&__timestamp {
flex-shrink: 0;
margin-left: 6px;
@ -5894,10 +5905,6 @@ button.module-image__border-overlay:focus {
@include dark-theme {
color: $color-gray-25;
}
&--with-unread {
@include font-caption-bold;
}
}
}
}
@ -5905,7 +5912,11 @@ button.module-image__border-overlay:focus {
&__message {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
.module-conversation-list--width-narrow & {
align-items: center;
}
&__text {
flex-grow: 1;
@ -5913,8 +5924,10 @@ button.module-image__border-overlay:focus {
@include font-body-2;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
display: -webkit-box;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-align: left;
@ -5925,15 +5938,12 @@ button.module-image__border-overlay:focus {
color: $color-gray-25;
}
&--has-unread {
@include font-body-2-bold;
.module-conversation-list--width-narrow & {
display: none;
}
@include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-05;
}
&--always-full-size {
height: 36px; // two lines
}
&__muted {
@ -5977,12 +5987,16 @@ button.module-image__border-overlay:focus {
&__status-icon {
flex-shrink: 0;
margin-top: 2px;
margin-top: 4px;
width: 12px;
height: 12px;
display: inline-block;
margin-left: 6px;
.module-conversation-list--width-narrow & {
display: none;
}
@mixin normal-status-icon($icon) {
@include light-theme {
@include color-svg($icon, $color-gray-25);
@ -6146,14 +6160,38 @@ button.module-image__border-overlay:focus {
&--header {
@include font-body-1-bold;
display: inline-flex;
align-items: center;
padding-left: 16px;
height: 100%;
overflow-x: hidden;
text-overflow: ellipsis;
user-select: none;
white-space: nowrap;
@include dark-theme {
color: $color-gray-05;
}
.module-conversation-list--width-narrow & {
@include rounded-corners;
display: block;
height: 2px;
margin: 19px 0 19px 10px;
width: 56px;
// Hide the text, but keep it for screen readers.
color: transparent;
overflow: hidden;
text-indent: -99999px;
@include light-theme {
background: $color-black-alpha-12;
}
@include dark-theme {
background: $color-white-alpha-12;
}
}
}
&--spinner {
@ -6168,12 +6206,36 @@ button.module-image__border-overlay:focus {
// Module: Left Pane
.module-left-pane {
border-right-style: solid;
border-right-width: 1px;
display: inline-flex;
flex-direction: column;
width: $left-pane-width;
height: 100%;
position: relative;
@include light-theme {
border-color: $color-gray-20;
background-color: $color-gray-02;
::-webkit-scrollbar-thumb {
border: 2px solid $color-gray-02;
}
}
@include dark-theme {
border-color: $color-gray-65;
background-color: $color-gray-80;
::-webkit-scrollbar-thumb {
border: 2px solid $color-gray-80;
}
}
@media (prefers-reduced-motion: no-preference) {
&:not(.module-left-pane--is-resizing) {
transition: width 200ms cubic-bezier(0.17, 0.17, 0, 1);
}
}
}
.module-left-pane__empty {
@ -6184,6 +6246,10 @@ button.module-image__border-overlay:focus {
padding: 0 32px;
text-align: center;
.module-left-pane--width-narrow & {
display: none;
}
&--composer_icon {
align-items: center;
background-color: $color-gray-05;
@ -6375,6 +6441,16 @@ button.module-image__border-overlay:focus {
}
}
.module-left-pane__resize-grab-area {
position: absolute;
width: 8px;
height: 100%;
right: -8px;
top: 0;
z-index: 2;
cursor: col-resize;
}
// Module: Timeline Loading Row
.module-timeline-loading-row {
@ -8103,7 +8179,7 @@ button.module-image__border-overlay:focus {
.module-avatar-popup {
min-width: 240px;
max-width: $left-pane-width;
max-width: 320px;
border-radius: 4px;
padding-bottom: 4px;
@ -9124,20 +9200,6 @@ button.module-image__border-overlay:focus {
}
}
// All media query widths have 300px added to account for the left pane
// And have been tweaked for smoother transitions
// To hide in small breakpoints
.module-message__buttons__download {
display: none;
}
.module-message__buttons__reply {
display: none;
}
.module-message__buttons__react {
display: none;
}
// To limit messages with things forcing them wider, like long attachment names
.module-message__container {
&--incoming {
@ -9290,87 +9352,3 @@ button.module-image__border-overlay:focus {
}
}
}
/* Spec: container < 515px */
@media (min-width: 0px) and (max-width: 834px) {
.module-message {
// Add 2px for 1px border
max-width: 302px;
}
}
/* Spec: container > 514px and container < 606px */
@media (min-width: 835px) and (max-width: 925px) {
.module-message {
// Add 2px for 1px border
max-width: 360px;
&--with-avatar {
// Remove 36px from avatar
max-width: 324px;
}
}
// Spec: container < 438px
.module-message--incoming {
margin-right: auto;
}
.module-message--outgoing {
margin-left: auto;
}
// To hide in small breakpoints
.module-message__buttons__download {
display: inline-block;
}
.module-message__buttons__reply {
display: inline-block;
}
.module-message__buttons__react {
display: inline-block;
}
// To hide in larger breakpoints
.module-message__context__download {
display: none;
}
.module-message__context__reply {
display: none;
}
}
// Spec: container > 593px
@media (min-width: 926px) {
.module-message {
max-width: 66%;
}
.module-message--incoming {
margin-right: auto;
}
.module-message--outgoing {
margin-left: auto;
}
// To hide in small breakpoints
.module-message__buttons__download {
display: inline-block;
}
.module-message__buttons__reply {
display: inline-block;
}
.module-message__buttons__react {
display: inline-block;
}
// To hide in larger breakpoints
.module-message__context__download {
display: none;
}
.module-message__context__reply {
display: none;
}
.module-message__context__react {
display: none;
}
}

View File

@ -27,6 +27,8 @@ $color-gray-90: #1b1b1b;
$color-gray-95: #121212;
$color-black: #000000;
$color-white-alpha-06: rgba($color-white, 0.06);
$color-white-alpha-12: rgba($color-white, 0.12);
$color-white-alpha-20: rgba($color-white, 0.2);
$color-white-alpha-40: rgba($color-white, 0.4);
$color-white-alpha-60: rgba($color-white, 0.6);
@ -35,6 +37,8 @@ $color-white-alpha-80: rgba($color-white, 0.8);
$color-white-alpha-90: rgba($color-white, 0.9);
$color-black-alpha-05: rgba($color-black, 0.05);
$color-black-alpha-06: rgba($color-black, 0.06);
$color-black-alpha-12: rgba($color-black, 0.12);
$color-black-alpha-20: rgba($color-black, 0.2);
$color-black-alpha-30: rgba($color-black, 0.3);
$color-black-alpha-40: rgba($color-black, 0.4);
@ -226,7 +230,6 @@ $color-deep-red: #ff261f;
// -- A few layout variables used cross-file
$left-pane-width: 320px;
$header-height: 52px;
$ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);

View File

@ -0,0 +1,8 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.Inbox {
display: flex;
flex-direction: row;
height: 100%;
}

View File

@ -11,9 +11,16 @@
}
.LeftPaneDialog {
$default-background-color: $color-ultramarine;
$default-text-color: $color-white;
$error-background-color: $color-accent-red;
$error-text-color: $default-text-color;
$warning-background-color: $color-accent-yellow;
$warning-text-color: $color-black;
align-items: center;
background: $color-ultramarine;
color: $color-white;
background: $default-background-color;
color: $default-text-color;
display: flex;
width: 100%;
min-height: 64px;
@ -21,6 +28,10 @@
user-select: none;
cursor: inherit;
.module-left-pane--width-narrow & {
padding-left: 36px;
}
&--clickable {
cursor: pointer;
}
@ -34,6 +45,10 @@
&__container-close {
display: flex;
justify-content: flex-end;
.module-left-pane--width-narrow & {
display: none;
}
}
&__spinner-container {
@ -74,6 +89,11 @@
&--update {
-webkit-mask: url('../images/icons/v2/refresh-24.svg') no-repeat center;
}
&--warning {
-webkit-mask: url('../images/icons/v2/warning-outline-24.svg') no-repeat
center;
}
}
&__action-text {
@ -110,6 +130,13 @@
&__message {
width: 100%;
.module-left-pane--width-narrow & {
display: none;
}
}
&__message,
&__tooltip {
h3 {
@include font-body-1-bold;
padding: 0px;
@ -127,12 +154,13 @@
}
&--error {
background-color: $color-accent-red;
background-color: $error-background-color;
color: $error-text-color;
}
&--warning {
background-color: $color-accent-yellow;
color: $color-black;
background-color: $warning-background-color;
color: $warning-text-color;
a {
color: $color-black;
@ -174,4 +202,21 @@
transition: width 500ms ease-out;
}
}
&__tooltip {
--tooltip-background-color: #{$default-background-color};
--tooltip-text-color: #{$default-text-color};
min-width: 280px;
text-align: inherit;
&--error {
--tooltip-text-color: #{$error-text-color};
--tooltip-background-color: #{$error-background-color};
}
&--warning {
--tooltip-text-color: #{$warning-text-color};
--tooltip-background-color: #{$warning-background-color};
}
}
}

View File

@ -6,7 +6,7 @@
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 16px;
padding: 14px;
&::before {
content: '';

View File

@ -6,12 +6,12 @@
display: flex;
align-items: center;
justify-content: center;
padding-left: 16px;
padding-right: 16px;
padding: 10px 14px;
margin: 2px 0;
&__avatar {
width: 52px;
height: 52px;
width: 48px;
height: 48px;
border-radius: 100%;
@include search-results-loading-pulsating-background;
}
@ -23,13 +23,20 @@
justify-content: center;
margin-left: 12px;
&__header {
@include search-results-loading-box(50%);
margin-bottom: 8px;
}
&__line {
&:nth-child(1) {
@include search-results-loading-box(30%);
margin-bottom: 8px;
}
&__message {
@include search-results-loading-box(90%);
&:nth-child(2) {
@include search-results-loading-box(90%);
margin-bottom: 8px;
}
&:nth-child(3) {
@include search-results-loading-box(60%);
}
}
}
}

View File

@ -61,6 +61,7 @@
@import './components/GroupDescription.scss';
@import './components/GroupDialog.scss';
@import './components/GroupInput.scss';
@import './components/Inbox.scss';
@import './components/IncomingCallBar.scss';
@import './components/Input.scss';
@import './components/LeftPaneDialog.scss';

View File

@ -29,25 +29,19 @@
<script type="text/x-tmpl-mustache" id="two-column">
<div class='module-title-bar-drag-area'></div>
<div class='inbox-container'>
<div class='gutter'>
<div class='left-pane-placeholder'></div>
<div class='left-pane-placeholder'></div>
<div class='conversation-stack'>
<div class='no-conversation-open'>
<div class="module-splash-screen__logo module-img--128 module-logo-blue"></div>
<h3>{{ welcomeToSignal }}</h3>
<p class="whats-new-placeholder"></p>
<p>{{ selectAContact }}</p>
</div>
<div class='conversation-stack'>
<div class='conversation placeholder'>
<div class='conversation-header'></div>
<div class='container'>
<div class='content'>
<div class="module-splash-screen__logo module-img--128 module-logo-blue"></div>
<h3>{{ welcomeToSignal }}</h3>
<p class="whats-new-placeholder"></p>
<p>{{ selectAContact }}</p>
</div>
</div>
</div>
</div>
<div class='lightbox-container'></div>
<div id="toast"></div>
</div>
<div class='lightbox-container'></div>
</script>
<script type="text/x-tmpl-mustache" id="conversation">

View File

@ -31,6 +31,7 @@ export enum AvatarSize {
TWENTY_EIGHT = 28,
THIRTY_TWO = 32,
THIRTY_SIX = 36,
FORTY_EIGHT = 48,
FIFTY_TWO = 52,
EIGHTY = 80,
NINETY_SIX = 96,

View File

@ -33,6 +33,11 @@ const defaultConversations: Array<ConversationListItemPropsType> = [
isSelected: true,
unreadCount: 12,
title: 'Marc Barraca',
lastMessage: {
deletedForEveryone: false,
text:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris. Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit. Donec et mollis dolor. Praesent et diam eget libero egestas mattis sit amet vitae augue. Nam tincidunt congue enim, ut porta lorem lacinia consectetur. Donec ut libero sed arcu vehicula ultricies a non tortor. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ut gravida lorem. Ut turpis felis, pulvinar a semper sed, adipiscing id dolor. Pellentesque auctor nisi id magna consequat sagittis. Curabitur dapibus enim sit amet elit pharetra tincidunt feugiat nisl imperdiet. Ut convallis libero in urna ultrices accumsan. Donec sed odio eros. Donec viverra mi quis quam pulvinar at malesuada arcu rhoncus. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In rutrum accumsan ultricies. Mauris vitae nisi at sem facilisis semper ac in est.',
},
}),
getDefaultConversation({
id: 'long-name-convo',

View File

@ -9,6 +9,7 @@ import { get, pick } from 'lodash';
import { missingCaseError } from '../util/missingCaseError';
import { assert } from '../util/assert';
import { LocalizerType, ScrollBehavior } from '../types/Util';
import { getConversationListWidthBreakpoint } from './_util';
import {
ConversationListItem,
@ -134,7 +135,7 @@ export type PropsType = {
startNewConversationFromPhoneNumber: (e164: string) => void;
};
const NORMAL_ROW_HEIGHT = 68;
const NORMAL_ROW_HEIGHT = 76;
const HEADER_ROW_HEIGHT = 40;
export const ConversationList: React.FC<PropsType> = ({
@ -193,6 +194,7 @@ export const ConversationList: React.FC<PropsType> = ({
case RowType.ArchiveButton:
result = (
<button
aria-label={i18n('archivedConversations')}
className="module-conversation-list__item--archive-button"
onClick={onClickArchiveButton}
type="button"
@ -345,11 +347,14 @@ export const ConversationList: React.FC<PropsType> = ({
return null;
}
const widthBreakpoint = getConversationListWidthBreakpoint(width);
return (
<List
className={classNames(
'module-conversation-list',
`module-conversation-list--scroll-behavior-${scrollBehavior}`
`module-conversation-list--scroll-behavior-${scrollBehavior}`,
`module-conversation-list--width-${widthBreakpoint}`
)}
height={height}
ref={listRef}

View File

@ -1,21 +1,38 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { boolean } from '@storybook/addon-knobs';
import { boolean, select } from '@storybook/addon-knobs';
import { DialogExpiredBuild } from './DialogExpiredBuild';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import { WidthBreakpoint } from './_util';
import { FakeLeftPaneContainer } from '../test-both/helpers/FakeLeftPaneContainer';
const i18n = setupI18n('en', enMessages);
storiesOf('Components/DialogExpiredBuild', module).add(
'DialogExpiredBuild',
() => {
const containerWidthBreakpoint = select(
'containerWidthBreakpoint',
WidthBreakpoint,
WidthBreakpoint.Wide
);
const hasExpired = boolean('hasExpired', true);
return <DialogExpiredBuild hasExpired={hasExpired} i18n={i18n} />;
return (
<FakeLeftPaneContainer
containerWidthBreakpoint={containerWidthBreakpoint}
>
<DialogExpiredBuild
containerWidthBreakpoint={containerWidthBreakpoint}
hasExpired={hasExpired}
i18n={i18n}
/>
</FakeLeftPaneContainer>
);
}
);

View File

@ -4,15 +4,19 @@
import React from 'react';
import { LocalizerType } from '../types/Util';
import type { WidthBreakpoint } from './_util';
import { LeftPaneDialog } from './LeftPaneDialog';
import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser';
type PropsType = {
containerWidthBreakpoint: WidthBreakpoint;
hasExpired: boolean;
i18n: LocalizerType;
};
export const DialogExpiredBuild = ({
containerWidthBreakpoint,
hasExpired,
i18n,
}: PropsType): JSX.Element | null => {
@ -21,17 +25,16 @@ export const DialogExpiredBuild = ({
}
return (
<LeftPaneDialog type="error">
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
type="error"
onClick={() => {
openLinkInWebBrowser('https://signal.org/download/');
}}
clickLabel={i18n('upgrade')}
hasAction
>
{i18n('expiredWarning')}{' '}
<a
className="LeftPaneDialog__action-text"
href="https://signal.org/download/"
rel="noreferrer"
tabIndex={-1}
target="_blank"
>
{i18n('upgrade')}
</a>
</LeftPaneDialog>
);
};

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -10,10 +10,13 @@ import { DialogNetworkStatus } from './DialogNetworkStatus';
import { SocketStatus } from '../types/SocketStatus';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import { WidthBreakpoint } from './_util';
import { FakeLeftPaneContainer } from '../test-both/helpers/FakeLeftPaneContainer';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
containerWidthBreakpoint: WidthBreakpoint.Wide,
hasNetworkDialog: true,
i18n,
isOnline: true,
@ -26,6 +29,11 @@ const defaultProps = {
const story = storiesOf('Components/DialogNetworkStatus', module);
story.add('Knobs Playground', () => {
const containerWidthBreakpoint = select(
'containerWidthBreakpoint',
WidthBreakpoint,
WidthBreakpoint.Wide
);
const hasNetworkDialog = boolean('hasNetworkDialog', true);
const isOnline = boolean('isOnline', true);
const socketStatus = select(
@ -40,30 +48,57 @@ story.add('Knobs Playground', () => {
);
return (
<DialogNetworkStatus
{...defaultProps}
hasNetworkDialog={hasNetworkDialog}
isOnline={isOnline}
socketStatus={socketStatus}
/>
<FakeLeftPaneContainer containerWidthBreakpoint={containerWidthBreakpoint}>
<DialogNetworkStatus
{...defaultProps}
containerWidthBreakpoint={containerWidthBreakpoint}
hasNetworkDialog={hasNetworkDialog}
isOnline={isOnline}
socketStatus={socketStatus}
/>
</FakeLeftPaneContainer>
);
});
story.add('Connecting', () => (
<DialogNetworkStatus
{...defaultProps}
socketStatus={SocketStatus.CONNECTING}
/>
));
([
['wide', WidthBreakpoint.Wide],
['narrow', WidthBreakpoint.Narrow],
] as const).forEach(([name, containerWidthBreakpoint]) => {
const defaultPropsForBreakpoint = {
...defaultProps,
containerWidthBreakpoint,
};
story.add('Closing', () => (
<DialogNetworkStatus {...defaultProps} socketStatus={SocketStatus.CLOSING} />
));
story.add(`Connecting (${name} container)`, () => (
<FakeLeftPaneContainer containerWidthBreakpoint={containerWidthBreakpoint}>
<DialogNetworkStatus
{...defaultPropsForBreakpoint}
socketStatus={SocketStatus.CONNECTING}
/>
</FakeLeftPaneContainer>
));
story.add('Closed', () => (
<DialogNetworkStatus {...defaultProps} socketStatus={SocketStatus.CLOSED} />
));
story.add(`Closing (${name} container)`, () => (
<FakeLeftPaneContainer containerWidthBreakpoint={containerWidthBreakpoint}>
<DialogNetworkStatus
{...defaultPropsForBreakpoint}
socketStatus={SocketStatus.CLOSING}
/>
</FakeLeftPaneContainer>
));
story.add('Offline', () => (
<DialogNetworkStatus {...defaultProps} isOnline={false} />
));
story.add(`Closed (${name} container)`, () => (
<FakeLeftPaneContainer containerWidthBreakpoint={containerWidthBreakpoint}>
<DialogNetworkStatus
{...defaultPropsForBreakpoint}
socketStatus={SocketStatus.CLOSED}
/>
</FakeLeftPaneContainer>
));
story.add(`Offline (${name} container)`, () => (
<FakeLeftPaneContainer containerWidthBreakpoint={containerWidthBreakpoint}>
<DialogNetworkStatus {...defaultPropsForBreakpoint} isOnline={false} />
</FakeLeftPaneContainer>
));
});

View File

@ -8,16 +8,19 @@ import { Spinner } from './Spinner';
import { LocalizerType } from '../types/Util';
import { SocketStatus } from '../types/SocketStatus';
import { NetworkStateType } from '../state/ducks/network';
import type { WidthBreakpoint } from './_util';
const FIVE_SECONDS = 5 * 1000;
export type PropsType = NetworkStateType & {
containerWidthBreakpoint: WidthBreakpoint;
hasNetworkDialog: boolean;
i18n: LocalizerType;
manualReconnect: () => void;
};
export const DialogNetworkStatus = ({
containerWidthBreakpoint,
hasNetworkDialog,
i18n,
isOnline,
@ -70,6 +73,7 @@ export const DialogNetworkStatus = ({
return (
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
type="warning"
icon={spinner}
title={i18n('connecting')}
@ -80,6 +84,7 @@ export const DialogNetworkStatus = ({
return (
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
type="warning"
icon="network"
title={isOnline ? i18n('disconnected') : i18n('offline')}

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -9,10 +9,13 @@ import { action } from '@storybook/addon-actions';
import { DialogRelink } from './DialogRelink';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import { WidthBreakpoint } from './_util';
import { FakeLeftPaneContainer } from '../test-both/helpers/FakeLeftPaneContainer';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
containerWidthBreakpoint: WidthBreakpoint.Wide,
i18n,
isRegistrationDone: true,
relinkDevice: action('relink-device'),
@ -20,8 +23,16 @@ const defaultProps = {
const permutations = [
{
title: 'Unlinked',
title: 'Unlinked (wide container)',
props: {
containerWidthBreakpoint: WidthBreakpoint.Wide,
isRegistrationDone: false,
},
},
{
title: 'Unlinked (narrow container)',
props: {
containerWidthBreakpoint: WidthBreakpoint.Narrow,
isRegistrationDone: false,
},
},
@ -39,7 +50,11 @@ storiesOf('Components/DialogRelink', module)
return permutations.map(({ props, title }) => (
<>
<h3>{title}</h3>
<DialogRelink {...defaultProps} {...props} />
<FakeLeftPaneContainer
containerWidthBreakpoint={props.containerWidthBreakpoint}
>
<DialogRelink {...defaultProps} {...props} />
</FakeLeftPaneContainer>
</>
));
});

View File

@ -4,16 +4,19 @@
import React from 'react';
import { LocalizerType } from '../types/Util';
import type { WidthBreakpoint } from './_util';
import { LeftPaneDialog } from './LeftPaneDialog';
export type PropsType = {
containerWidthBreakpoint: WidthBreakpoint;
i18n: LocalizerType;
isRegistrationDone: boolean;
relinkDevice: () => void;
};
export const DialogRelink = ({
containerWidthBreakpoint,
i18n,
isRegistrationDone,
relinkDevice,
@ -24,6 +27,7 @@ export const DialogRelink = ({
return (
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
type="warning"
icon="relink"
clickLabel={i18n('unlinkedWarning')}

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -7,6 +7,8 @@ import { boolean, select } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { DialogUpdate } from './DialogUpdate';
import { DialogType } from '../types/Dialogs';
import { WidthBreakpoint } from './_util';
import { FakeLeftPaneContainer } from '../test-both/helpers/FakeLeftPaneContainer';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
@ -14,6 +16,7 @@ import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
containerWidthBreakpoint: WidthBreakpoint.Wide,
dismissDialog: action('dismiss-dialog'),
downloadSize: 116504357,
downloadedSize: 61003110,
@ -29,36 +32,79 @@ const defaultProps = {
const story = storiesOf('Components/DialogUpdate', module);
story.add('Knobs Playground', () => {
const containerWidthBreakpoint = select(
'containerWidthBreakpoint',
WidthBreakpoint,
WidthBreakpoint.Wide
);
const dialogType = select('dialogType', DialogType, DialogType.Update);
const hasNetworkDialog = boolean('hasNetworkDialog', false);
const didSnooze = boolean('didSnooze', false);
return (
<DialogUpdate
{...defaultProps}
dialogType={dialogType}
didSnooze={didSnooze}
hasNetworkDialog={hasNetworkDialog}
/>
<FakeLeftPaneContainer containerWidthBreakpoint={containerWidthBreakpoint}>
<DialogUpdate
{...defaultProps}
containerWidthBreakpoint={containerWidthBreakpoint}
dialogType={dialogType}
didSnooze={didSnooze}
hasNetworkDialog={hasNetworkDialog}
/>
</FakeLeftPaneContainer>
);
});
story.add('Update', () => (
<DialogUpdate {...defaultProps} dialogType={DialogType.Update} />
));
([
['wide', WidthBreakpoint.Wide],
['narrow', WidthBreakpoint.Narrow],
] as const).forEach(([name, containerWidthBreakpoint]) => {
const defaultPropsForBreakpoint = {
...defaultProps,
containerWidthBreakpoint,
};
story.add('Download Ready', () => (
<DialogUpdate {...defaultProps} dialogType={DialogType.DownloadReady} />
));
story.add(`Update (${name} container)`, () => (
<FakeLeftPaneContainer containerWidthBreakpoint={containerWidthBreakpoint}>
<DialogUpdate
{...defaultPropsForBreakpoint}
dialogType={DialogType.Update}
/>
</FakeLeftPaneContainer>
));
story.add('Downloading', () => (
<DialogUpdate {...defaultProps} dialogType={DialogType.Downloading} />
));
story.add(`Download Ready (${name} container)`, () => (
<FakeLeftPaneContainer containerWidthBreakpoint={containerWidthBreakpoint}>
<DialogUpdate
{...defaultPropsForBreakpoint}
dialogType={DialogType.DownloadReady}
/>
</FakeLeftPaneContainer>
));
story.add('Cannot Update', () => (
<DialogUpdate {...defaultProps} dialogType={DialogType.Cannot_Update} />
));
story.add(`Downloading (${name} container)`, () => (
<FakeLeftPaneContainer containerWidthBreakpoint={containerWidthBreakpoint}>
<DialogUpdate
{...defaultPropsForBreakpoint}
dialogType={DialogType.Downloading}
/>
</FakeLeftPaneContainer>
));
story.add('macOS RO Error', () => (
<DialogUpdate {...defaultProps} dialogType={DialogType.MacOS_Read_Only} />
));
story.add(`Cannot Update (${name} container)`, () => (
<FakeLeftPaneContainer containerWidthBreakpoint={containerWidthBreakpoint}>
<DialogUpdate
{...defaultPropsForBreakpoint}
dialogType={DialogType.Cannot_Update}
/>
</FakeLeftPaneContainer>
));
story.add(`macOS RO Error (${name} container)`, () => (
<FakeLeftPaneContainer containerWidthBreakpoint={containerWidthBreakpoint}>
<DialogUpdate
{...defaultPropsForBreakpoint}
dialogType={DialogType.MacOS_Read_Only}
/>
</FakeLeftPaneContainer>
));
});

View File

@ -8,8 +8,10 @@ import { DialogType } from '../types/Dialogs';
import { LocalizerType } from '../types/Util';
import { Intl } from './Intl';
import { LeftPaneDialog } from './LeftPaneDialog';
import type { WidthBreakpoint } from './_util';
export type PropsType = {
containerWidthBreakpoint: WidthBreakpoint;
dialogType: DialogType;
didSnooze: boolean;
dismissDialog: () => void;
@ -24,6 +26,7 @@ export type PropsType = {
};
export const DialogUpdate = ({
containerWidthBreakpoint,
dialogType,
didSnooze,
dismissDialog,
@ -49,7 +52,11 @@ export const DialogUpdate = ({
if (dialogType === DialogType.Cannot_Update) {
return (
<LeftPaneDialog type="warning" title={i18n('cannotUpdate')}>
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
type="warning"
title={i18n('cannotUpdate')}
>
<span>
<Intl
components={[
@ -73,6 +80,7 @@ export const DialogUpdate = ({
if (dialogType === DialogType.MacOS_Read_Only) {
return (
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
type="warning"
title={i18n('cannotUpdate')}
hasXButton
@ -113,7 +121,12 @@ export const DialogUpdate = ({
);
return (
<LeftPaneDialog icon="update" title={title} hoverText={versionTitle}>
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
icon="update"
title={title}
hoverText={versionTitle}
>
<div className="LeftPaneDialog__progress--container">
<div
className="LeftPaneDialog__progress--bar"
@ -133,6 +146,7 @@ export const DialogUpdate = ({
return (
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
icon="update"
title={title}
hoverText={versionTitle}

View File

@ -96,7 +96,7 @@ export const Inbox = ({
return (
<>
<div className="inbox index" ref={hostRef} />
<div className="Inbox" ref={hostRef} />
{activeModal}
</>
);

View File

@ -72,12 +72,15 @@ const defaultModeSpecificProps = {
pinnedConversations,
conversations: defaultConversations,
archivedConversations: defaultArchivedConversations,
isAboutToSearchInAConversation: false,
startSearchCounter: 0,
};
const emptySearchResultsGroup = { isLoading: false, results: [] };
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
cantAddContactToGroup: action('cantAddContactToGroup'),
canResizeLeftPane: true,
clearGroupCreationError: action('clearGroupCreationError'),
closeCantAddContactToGroupModal: action('closeCantAddContactToGroupModal'),
closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'),
@ -88,6 +91,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
createGroup: action('createGroup'),
i18n,
modeSpecificProps: defaultModeSpecificProps,
preferredWidthFromStorage: 320,
openConversationInternal: action('openConversationInternal'),
regionCode: 'US',
challengeStatus: select(
@ -125,6 +129,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
),
selectedConversationId: undefined,
selectedMessageId: undefined,
savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'),
setComposeSearchTerm: action('setComposeSearchTerm'),
setComposeGroupAvatar: action('setComposeGroupAvatar'),
setComposeGroupName: action('setComposeGroupName'),
@ -155,6 +160,8 @@ story.add('Inbox: no conversations', () => (
pinnedConversations: [],
conversations: [],
archivedConversations: [],
isAboutToSearchInAConversation: false,
startSearchCounter: 0,
},
})}
/>
@ -168,6 +175,8 @@ story.add('Inbox: only pinned conversations', () => (
pinnedConversations,
conversations: [],
archivedConversations: [],
isAboutToSearchInAConversation: false,
startSearchCounter: 0,
},
})}
/>
@ -181,6 +190,8 @@ story.add('Inbox: only non-pinned conversations', () => (
pinnedConversations: [],
conversations: defaultConversations,
archivedConversations: [],
isAboutToSearchInAConversation: false,
startSearchCounter: 0,
},
})}
/>
@ -194,6 +205,8 @@ story.add('Inbox: only archived conversations', () => (
pinnedConversations: [],
conversations: [],
archivedConversations: defaultArchivedConversations,
isAboutToSearchInAConversation: false,
startSearchCounter: 0,
},
})}
/>
@ -207,6 +220,8 @@ story.add('Inbox: pinned and archived conversations', () => (
pinnedConversations,
conversations: [],
archivedConversations: defaultArchivedConversations,
isAboutToSearchInAConversation: false,
startSearchCounter: 0,
},
})}
/>
@ -220,6 +235,8 @@ story.add('Inbox: non-pinned and archived conversations', () => (
pinnedConversations: [],
conversations: defaultConversations,
archivedConversations: defaultArchivedConversations,
isAboutToSearchInAConversation: false,
startSearchCounter: 0,
},
})}
/>
@ -233,6 +250,8 @@ story.add('Inbox: pinned and non-pinned conversations', () => (
pinnedConversations,
conversations: defaultConversations,
archivedConversations: [],
isAboutToSearchInAConversation: false,
startSearchCounter: 0,
},
})}
/>
@ -499,6 +518,8 @@ story.add('Captcha dialog: required', () => (
pinnedConversations,
conversations: defaultConversations,
archivedConversations: [],
isAboutToSearchInAConversation: false,
startSearchCounter: 0,
},
challengeStatus: 'required',
})}
@ -513,6 +534,8 @@ story.add('Captcha dialog: pending', () => (
pinnedConversations,
conversations: defaultConversations,
archivedConversations: [],
isAboutToSearchInAConversation: false,
startSearchCounter: 0,
},
challengeStatus: 'pending',
})}

View File

@ -1,9 +1,10 @@
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useCallback, useMemo } from 'react';
import React, { useEffect, useCallback, useMemo, useState } from 'react';
import Measure, { MeasuredComponentProps } from 'react-measure';
import { isNumber } from 'lodash';
import classNames from 'classnames';
import { clamp, isNumber, noop } from 'lodash';
import {
LeftPaneHelper,
@ -39,6 +40,7 @@ import * as OS from '../OS';
import { LocalizerType, ScrollBehavior } from '../types/Util';
import { usePrevious } from '../hooks/usePrevious';
import { missingCaseError } from '../util/missingCaseError';
import { getConversationListWidthBreakpoint, WidthBreakpoint } from './_util';
import { ConversationList } from './ConversationList';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
@ -49,6 +51,11 @@ import {
SaveAvatarToDiskActionType,
} from '../types/Avatar';
const MIN_WIDTH = 119;
const MIN_SNAP_WIDTH = 280;
const MIN_FULL_WIDTH = 320;
const MAX_WIDTH = 380;
export enum LeftPaneMode {
Inbox,
Search,
@ -82,9 +89,11 @@ export type PropsType = {
mode: LeftPaneMode.SetGroupMetadata;
} & LeftPaneSetGroupMetadataPropsType);
i18n: LocalizerType;
preferredWidthFromStorage: number;
selectedConversationId: undefined | string;
selectedMessageId: undefined | string;
regionCode: string;
canResizeLeftPane: boolean;
challengeStatus: 'idle' | 'required' | 'pending';
setChallengeStatus: (status: 'idle') => void;
@ -101,6 +110,7 @@ export type PropsType = {
messageId?: string;
switchToAssociatedView?: boolean;
}) => void;
savePreferredLeftPaneWidth: (_: number) => void;
setComposeSearchTerm: (composeSearchTerm: string) => void;
setComposeGroupAvatar: (_: undefined | Uint8Array) => void;
setComposeGroupName: (_: string) => void;
@ -117,17 +127,26 @@ export type PropsType = {
toggleComposeEditingAvatar: () => unknown;
// Render Props
renderExpiredBuildDialog: () => JSX.Element;
renderExpiredBuildDialog: (
_: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
) => JSX.Element;
renderMainHeader: () => JSX.Element;
renderMessageSearchResult: (id: string) => JSX.Element;
renderNetworkStatus: () => JSX.Element;
renderRelinkDialog: () => JSX.Element;
renderUpdateDialog: () => JSX.Element;
renderNetworkStatus: (
_: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
) => JSX.Element;
renderRelinkDialog: (
_: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
) => JSX.Element;
renderUpdateDialog: (
_: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
) => JSX.Element;
renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element;
};
export const LeftPane: React.FC<PropsType> = ({
cantAddContactToGroup,
canResizeLeftPane,
challengeStatus,
clearGroupCreationError,
closeCantAddContactToGroupModal,
@ -140,6 +159,7 @@ export const LeftPane: React.FC<PropsType> = ({
i18n,
modeSpecificProps,
openConversationInternal,
preferredWidthFromStorage,
renderCaptchaDialog,
renderExpiredBuildDialog,
renderMainHeader,
@ -147,6 +167,7 @@ export const LeftPane: React.FC<PropsType> = ({
renderNetworkStatus,
renderRelinkDialog,
renderUpdateDialog,
savePreferredLeftPaneWidth,
selectedConversationId,
selectedMessageId,
setChallengeStatus,
@ -163,6 +184,12 @@ export const LeftPane: React.FC<PropsType> = ({
toggleComposeEditingAvatar,
toggleConversationInChooseMembers,
}) => {
const [preferredWidth, setPreferredWidth] = useState(
// This clamp is present just in case we get a bogus value from storage.
clamp(preferredWidthFromStorage, MIN_WIDTH, MAX_WIDTH)
);
const [isResizing, setIsResizing] = useState(false);
const previousModeSpecificProps = usePrevious(
modeSpecificProps,
modeSpecificProps
@ -349,6 +376,70 @@ export const LeftPane: React.FC<PropsType> = ({
startComposing,
]);
const requiresFullWidth = helper.requiresFullWidth();
useEffect(() => {
if (!isResizing) {
return noop;
}
const onMouseMove = (event: MouseEvent) => {
let width: number;
if (requiresFullWidth) {
width = Math.max(event.clientX, MIN_FULL_WIDTH);
} else if (event.clientX < MIN_SNAP_WIDTH) {
width = MIN_WIDTH;
} else {
width = Math.max(event.clientX, MIN_WIDTH);
}
setPreferredWidth(Math.min(width, MAX_WIDTH));
event.preventDefault();
};
const onMouseUp = () => {
setIsResizing(false);
};
document.body.addEventListener('mousemove', onMouseMove);
document.body.addEventListener('mouseup', onMouseUp);
return () => {
document.body.removeEventListener('mousemove', onMouseMove);
document.body.removeEventListener('mouseup', onMouseUp);
};
}, [isResizing, requiresFullWidth]);
useEffect(() => {
if (!isResizing) {
return noop;
}
document.body.classList.add('is-resizing-left-pane');
return () => {
document.body.classList.remove('is-resizing-left-pane');
};
}, [isResizing]);
useEffect(() => {
if (isResizing || preferredWidth === preferredWidthFromStorage) {
return;
}
const timeout = setTimeout(() => {
savePreferredLeftPaneWidth(preferredWidth);
}, 1000);
return () => {
clearTimeout(timeout);
};
}, [
isResizing,
preferredWidth,
preferredWidthFromStorage,
savePreferredLeftPaneWidth,
]);
const preRowsNode = helper.getPreRowsNode({
clearGroupCreationError,
closeCantAddContactToGroupModal,
@ -392,6 +483,15 @@ export const LeftPane: React.FC<PropsType> = ({
selectedConversationId
);
let width: number;
if (requiresFullWidth) {
width = Math.max(preferredWidth, MIN_FULL_WIDTH);
} else if (preferredWidth < MIN_SNAP_WIDTH) {
width = MIN_WIDTH;
} else {
width = preferredWidth;
}
const isScrollable = helper.isScrollable();
let rowIndexToScrollTo: undefined | number;
@ -413,13 +513,22 @@ export const LeftPane: React.FC<PropsType> = ({
// It also ensures that we scroll to the top when switching views.
const listKey = preRowsNode ? 1 : 0;
const widthBreakpoint = getConversationListWidthBreakpoint(width);
// We disable this lint rule because we're trying to capture bubbled events. See [the
// lint rule's docs][0].
//
// [0]: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/645900a0e296ca7053dbf6cd9e12cc85849de2d5/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-left-pane">
<div
className={classNames(
'module-left-pane',
isResizing && 'module-left-pane--is-resizing',
`module-left-pane--width-${widthBreakpoint}`
)}
style={{ width }}
>
{/* eslint-enable jsx-a11y/no-static-element-interactions */}
<div className="module-left-pane__header">
{helper.getHeaderContents({
@ -429,10 +538,10 @@ export const LeftPane: React.FC<PropsType> = ({
showChooseGroupMembers,
}) || renderMainHeader()}
</div>
{renderExpiredBuildDialog()}
{renderRelinkDialog()}
{renderNetworkStatus()}
{renderUpdateDialog()}
{renderExpiredBuildDialog({ containerWidthBreakpoint: widthBreakpoint })}
{renderRelinkDialog({ containerWidthBreakpoint: widthBreakpoint })}
{renderNetworkStatus({ containerWidthBreakpoint: widthBreakpoint })}
{renderUpdateDialog({ containerWidthBreakpoint: widthBreakpoint })}
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>}
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => (
@ -489,6 +598,17 @@ export const LeftPane: React.FC<PropsType> = ({
{footerContents && (
<div className="module-left-pane__footer">{footerContents}</div>
)}
{canResizeLeftPane && (
<>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="module-left-pane__resize-grab-area"
onMouseDown={() => {
setIsResizing(true);
}}
/>
</>
)}
{challengeStatus !== 'idle' &&
renderCaptchaDialog({
onSkip() {

View File

@ -1,17 +1,22 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactNode } from 'react';
import React, { ReactChild, ReactNode } from 'react';
import classNames from 'classnames';
import { Tooltip, TooltipPlacement } from './Tooltip';
import { WidthBreakpoint } from './_util';
const BASE_CLASS_NAME = 'LeftPaneDialog';
const TOOLTIP_CLASS_NAME = `${BASE_CLASS_NAME}__tooltip`;
export type PropsType = {
type?: 'warning' | 'error';
icon?: 'update' | 'relink' | 'network' | ReactNode;
icon?: 'update' | 'relink' | 'network' | 'warning' | ReactChild;
title?: string;
subtitle?: string;
children?: ReactNode;
hoverText?: string;
containerWidthBreakpoint: WidthBreakpoint;
} & (
| {
onClick?: undefined;
@ -38,7 +43,7 @@ export type PropsType = {
);
export const LeftPaneDialog: React.FC<PropsType> = ({
icon,
icon = 'warning',
type,
onClick,
clickLabel,
@ -48,6 +53,7 @@ export const LeftPaneDialog: React.FC<PropsType> = ({
hoverText,
hasAction,
containerWidthBreakpoint,
hasXButton,
onClose,
closeLabel,
@ -123,23 +129,28 @@ export const LeftPaneDialog: React.FC<PropsType> = ({
onClick === undefined ? undefined : `${BASE_CLASS_NAME}--clickable`,
]);
const message = (
<>
{title === undefined ? undefined : <h3>{title}</h3>}
{subtitle === undefined ? undefined : <div>{subtitle}</div>}
{children}
{action}
</>
);
const content = (
<>
<div className={`${BASE_CLASS_NAME}__container`}>
{typeof icon === 'string' ? <div className={iconClassName} /> : icon}
<div className={`${BASE_CLASS_NAME}__message`}>
{title === undefined ? undefined : <h3>{title}</h3>}
{subtitle === undefined ? undefined : <div>{subtitle}</div>}
{children}
{action}
</div>
<div className={`${BASE_CLASS_NAME}__message`}>{message}</div>
</div>
{xButton}
</>
);
let dialogNode: ReactChild;
if (onClick) {
return (
dialogNode = (
<div
className={className}
role="button"
@ -152,11 +163,28 @@ export const LeftPaneDialog: React.FC<PropsType> = ({
{content}
</div>
);
} else {
dialogNode = (
<div className={className} title={hoverText}>
{content}
</div>
);
}
return (
<div className={className} title={hoverText}>
{content}
</div>
);
if (containerWidthBreakpoint === WidthBreakpoint.Narrow) {
return (
<Tooltip
content={message}
direction={TooltipPlacement.Right}
className={classNames(
TOOLTIP_CLASS_NAME,
type && `${TOOLTIP_CLASS_NAME}--${type}`
)}
>
{dialogNode}
</Tooltip>
);
}
return dialogNode;
};

View File

@ -230,6 +230,13 @@ export class MainHeader extends React.Component<PropsType, StateType> {
this.setFocus();
};
private handleInputBlur = (): void => {
const { clearSearch, searchConversationId, searchTerm } = this.props;
if (!searchConversationId && !searchTerm) {
clearSearch();
}
};
public handleInputKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>
): void => {
@ -478,6 +485,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
)}
placeholder={placeholder}
dir="auto"
onBlur={this.handleInputBlur}
onKeyDown={this.handleInputKeyDown}
value={searchTerm}
onChange={this.updateSearch}

View File

@ -68,6 +68,7 @@ export enum TooltipPlacement {
export type PropsType = {
content: string | JSX.Element;
className?: string;
direction?: TooltipPlacement;
sticky?: boolean;
theme?: Theme;
@ -75,6 +76,7 @@ export type PropsType = {
export const Tooltip: React.FC<PropsType> = ({
children,
className,
content,
direction,
sticky,
@ -101,7 +103,11 @@ export const Tooltip: React.FC<PropsType> = ({
{({ arrowProps, placement, ref, style }) =>
showTooltip && (
<div
className={classNames('module-tooltip', tooltipThemeClassName)}
className={classNames(
'module-tooltip',
tooltipThemeClassName,
className
)}
ref={ref}
style={style}
data-placement={placement}

View File

@ -4,3 +4,14 @@
export function cleanId(id: string): string {
return id.replace(/[^\u0020-\u007e\u00a0-\u00ff]/g, '_');
}
export enum WidthBreakpoint {
Wide = 'wide',
Medium = 'medium',
Narrow = 'narrow',
}
export const getConversationListWidthBreakpoint = (
width: number
): WidthBreakpoint =>
width >= 150 ? WidthBreakpoint.Wide : WidthBreakpoint.Narrow;

View File

@ -27,6 +27,7 @@ import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import { pngUrl } from '../../storybook/Fixtures';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { WidthBreakpoint } from '../_util';
import { fakeAttachment } from '../../test-both/helpers/fakeAttachment';
@ -102,6 +103,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
clearSelectedMessage: action('clearSelectedMessage'),
collapseMetadata: overrideProps.collapseMetadata,
containerElementRef: React.createRef<HTMLElement>(),
containerWidthBreakpoint: WidthBreakpoint.Wide,
conversationColor:
overrideProps.conversationColor ||
select('conversationColor', ConversationColors, ConversationColors[0]),

View File

@ -34,6 +34,7 @@ import { Emoji } from '../emoji/Emoji';
import { LinkPreviewDate } from './LinkPreviewDate';
import { LinkPreviewType } from '../../types/message/LinkPreviews';
import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseFullSizeLinkPreviewImage';
import { WidthBreakpoint } from '../_util';
import * as log from '../../logging/log';
import {
@ -194,6 +195,7 @@ export type PropsData = {
export type PropsHousekeeping = {
containerElementRef: RefObject<HTMLElement>;
containerWidthBreakpoint: WidthBreakpoint;
i18n: LocalizerType;
interactionMode: InteractionModeType;
theme?: ThemeType;
@ -1450,9 +1452,13 @@ export class Message extends React.PureComponent<Props, State> {
`module-message__buttons--${direction}`
)}
>
{canReply ? reactButton : null}
{canDownload ? downloadButton : null}
{canReply ? replyButton : null}
{this.shouldShowAdditionalMenuButtons() && (
<>
{canReply ? reactButton : null}
{canDownload ? downloadButton : null}
{canReply ? replyButton : null}
</>
)}
{menuButton}
</div>
{reactionPickerRoot &&
@ -1523,6 +1529,7 @@ export class Message extends React.PureComponent<Props, State> {
const menu = (
<ContextMenu id={triggerId}>
{canDownload &&
!this.shouldShowAdditionalMenuButtons() &&
!isSticker &&
!multipleAttachments &&
!isTapToView &&
@ -1538,7 +1545,7 @@ export class Message extends React.PureComponent<Props, State> {
{i18n('downloadAttachment')}
</MenuItem>
) : null}
{canReply ? (
{canReply && !this.shouldShowAdditionalMenuButtons() ? (
<>
<MenuItem
attributes={{
@ -1652,6 +1659,11 @@ export class Message extends React.PureComponent<Props, State> {
return ReactDOM.createPortal(menu, document.body);
}
private shouldShowAdditionalMenuButtons(): boolean {
const { containerWidthBreakpoint } = this.props;
return containerWidthBreakpoint !== WidthBreakpoint.Narrow;
}
public getWidth(): number | undefined {
const { attachments, isSticker, previews } = this.props;

View File

@ -18,6 +18,7 @@ import { ConversationType } from '../../state/ducks/conversations';
import { groupBy } from '../../util/mapUtil';
import { ContactNameColorType } from '../../types/Colors';
import { SendStatus } from '../../messages/MessageSendState';
import { WidthBreakpoint } from '../_util';
import * as log from '../../logging/log';
import { Timestamp } from './Timestamp';
@ -301,6 +302,7 @@ export class MessageDetail extends React.Component<Props> {
clearSelectedMessage={clearSelectedMessage}
contactNameColor={contactNameColor}
containerElementRef={this.messageContainerRef}
containerWidthBreakpoint={WidthBreakpoint.Wide}
deleteMessage={() =>
log.warn('MessageDetail: deleteMessage called!')
}

View File

@ -23,6 +23,7 @@ import { ReadStatus } from '../../messages/MessageReadStatus';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { WidthBreakpoint } from '../_util';
const i18n = setupI18n('en', enMessages);
@ -39,6 +40,7 @@ const defaultMessageProps: MessagesProps = {
checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('default--clearSelectedMessage'),
containerElementRef: React.createRef<HTMLElement>(),
containerWidthBreakpoint: WidthBreakpoint.Wide,
conversationColor: 'crimson',
conversationId: 'conversationId',
conversationType: 'direct', // override

View File

@ -21,6 +21,7 @@ import { TimelineLoadingRow } from './TimelineLoadingRow';
import { TypingBubble } from './TypingBubble';
import { ContactSpoofingType } from '../../util/contactSpoofing';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { WidthBreakpoint } from '../_util';
const i18n = setupI18n('en', enMessages);
@ -369,9 +370,11 @@ const actions = () => ({
const renderItem = ({
messageId,
containerElementRef,
containerWidthBreakpoint,
}: {
messageId: string;
containerElementRef: React.RefObject<HTMLElement>;
containerWidthBreakpoint: WidthBreakpoint;
}) => (
<TimelineItem
id=""
@ -384,6 +387,7 @@ const renderItem = ({
i18n={i18n}
interactionMode="keyboard"
containerElementRef={containerElementRef}
containerWidthBreakpoint={containerWidthBreakpoint}
conversationId=""
renderContact={() => '*ContactName*'}
renderUniversalTimerNotification={() => (

View File

@ -20,6 +20,8 @@ import { AssertProps, LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations';
import { assert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError';
import { createRefMerger } from '../../util/refMerger';
import { WidthBreakpoint } from '../_util';
import { PropsActions as MessageActionsType } from './Message';
import { PropsActions as UnsupportedMessageActionsType } from './UnsupportedMessage';
@ -106,6 +108,7 @@ type PropsHousekeepingType = {
renderItem: (props: {
actionProps: PropsActionsType;
containerElementRef: RefObject<HTMLElement>;
containerWidthBreakpoint: WidthBreakpoint;
conversationId: string;
messageId: string;
nextMessageId: undefined | string;
@ -207,6 +210,8 @@ type StateType = {
atTop: boolean;
oneTimeScrollRow?: number;
widthBreakpoint: WidthBreakpoint;
prevPropScrollToIndex?: number;
prevPropScrollToIndexCounter?: number;
propScrollToIndex?: number;
@ -307,6 +312,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
public loadCountdownTimeout: NodeJS.Timeout | null = null;
private containerRefMerger = createRefMerger();
constructor(props: PropsType) {
super(props);
@ -328,6 +335,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
areUnreadBelowCurrentPosition: false,
hasDismissedDirectContactSpoofingWarning: false,
lastMeasuredWarningHeight: 0,
// This may be swiftly overridden.
widthBreakpoint: WidthBreakpoint.Wide,
};
}
@ -740,7 +749,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
unblurAvatar,
updateSharedGroups,
} = this.props;
const { lastMeasuredWarningHeight } = this.state;
const { lastMeasuredWarningHeight, widthBreakpoint } = this.state;
const styleWithWidth = {
...style,
@ -813,6 +822,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
{renderItem({
actionProps,
containerElementRef: this.containerRef,
containerWidthBreakpoint: widthBreakpoint,
conversationId: id,
messageId,
nextMessageId,
@ -1324,6 +1334,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
const {
shouldShowScrollDownButton,
areUnreadBelowCurrentPosition,
widthBreakpoint,
} = this.state;
const rowCount = this.getRowCount();
@ -1515,30 +1526,42 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
return (
<>
<div
className={classNames(
'module-timeline',
isGroupV1AndDisabled ? 'module-timeline--disabled' : null
)}
role="presentation"
tabIndex={-1}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
ref={this.containerRef}
<Measure
bounds
onResize={({ bounds }) => {
this.setState({
widthBreakpoint: getWidthBreakpoint(bounds?.width || 0),
});
}}
>
{timelineWarning}
{({ measureRef }) => (
<div
className={classNames(
'module-timeline',
isGroupV1AndDisabled ? 'module-timeline--disabled' : null,
`module-timeline--width-${widthBreakpoint}`
)}
role="presentation"
tabIndex={-1}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
ref={this.containerRefMerger(measureRef)}
>
{timelineWarning}
{autoSizer}
{autoSizer}
{shouldShowScrollDownButton ? (
<ScrollDownButton
conversationId={id}
withNewMessages={areUnreadBelowCurrentPosition}
scrollDown={this.onClickScrollDownButton}
i18n={i18n}
/>
) : null}
</div>
{shouldShowScrollDownButton ? (
<ScrollDownButton
conversationId={id}
withNewMessages={areUnreadBelowCurrentPosition}
scrollDown={this.onClickScrollDownButton}
i18n={i18n}
/>
) : null}
</div>
)}
</Measure>
{Boolean(invitedContactsForNewlyCreatedGroup.length) && (
<NewlyCreatedGroupInvitedContactsDialog
@ -1578,3 +1601,13 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
}
}
}
function getWidthBreakpoint(width: number): WidthBreakpoint {
if (width > 606) {
return WidthBreakpoint.Wide;
}
if (width > 514) {
return WidthBreakpoint.Medium;
}
return WidthBreakpoint.Narrow;
}

View File

@ -14,6 +14,7 @@ import { UniversalTimerNotification } from './UniversalTimerNotification';
import { CallMode } from '../../types/Calling';
import { AvatarColors } from '../../types/Colors';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { WidthBreakpoint } from '../_util';
const i18n = setupI18n('en', enMessages);
@ -46,6 +47,7 @@ const renderUniversalTimerNotification = () => (
const getDefaultProps = () => ({
containerElementRef: React.createRef<HTMLElement>(),
containerWidthBreakpoint: WidthBreakpoint.Wide,
conversationId: 'conversation-id',
id: 'asdf',
isSelected: false,

View File

@ -181,7 +181,10 @@ export type PropsType = PropsLocalType &
PropsActionsType &
Pick<
AllMessageProps,
'renderEmojiPicker' | 'renderAudioAttachment' | 'renderReactionPicker'
| 'containerWidthBreakpoint'
| 'renderEmojiPicker'
| 'renderAudioAttachment'
| 'renderReactionPicker'
>;
export class TimelineItem extends React.PureComponent<PropsType> {

View File

@ -1,4 +1,4 @@
// Copyright 2018-2020 Signal Messenger, LLC
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
@ -16,7 +16,6 @@ export type Props = {
withImageNoCaption?: boolean;
withSticker?: boolean;
withTapToViewExpired?: boolean;
withUnread?: boolean;
direction?: 'incoming' | 'outgoing';
i18n: LocalizerType;
};
@ -58,7 +57,6 @@ export class Timestamp extends React.Component<Props> {
withImageNoCaption,
withSticker,
withTapToViewExpired,
withUnread,
extended,
} = this.props;
const moduleName = module || 'module-timestamp';
@ -76,8 +74,7 @@ export class Timestamp extends React.Component<Props> {
? `${moduleName}--${direction}-with-tap-to-view-expired`
: null,
withImageNoCaption ? `${moduleName}--with-image-no-caption` : null,
withSticker ? `${moduleName}--with-sticker` : null,
withUnread ? `${moduleName}--with-unread` : null
withSticker ? `${moduleName}--with-sticker` : null
)}
title={moment(timestamp).format('llll')}
>

View File

@ -36,6 +36,7 @@ type PropsType = {
messageId?: string;
messageStatusIcon?: ReactNode;
messageText?: ReactNode;
messageTextIsAlwaysFullSize?: boolean;
onClick?: () => void;
unreadCount?: number;
} & Pick<
@ -71,6 +72,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
markedUnread,
messageStatusIcon,
messageText,
messageTextIsAlwaysFullSize,
name,
onClick,
phoneNumber,
@ -119,29 +121,22 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
const contents = (
<>
<div className={`${BASE_CLASS_NAME}__avatar-container`}>
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color}
conversationType={conversationType}
noteToSelf={isAvatarNoteToSelf}
i18n={i18n}
isMe={isMe}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.FIFTY_TWO}
unblurredAvatarPath={unblurredAvatarPath}
/>
{isUnread && (
<div className={`${BASE_CLASS_NAME}__unread-count`}>
{formatUnreadCount(unreadCount)}
</div>
)}
</div>
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color}
conversationType={conversationType}
noteToSelf={isAvatarNoteToSelf}
i18n={i18n}
isMe={isMe}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.FORTY_EIGHT}
unblurredAvatarPath={unblurredAvatarPath}
/>
<div
className={classNames(
CONTENT_CLASS_NAME,
@ -151,32 +146,36 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
<div className={HEADER_CLASS_NAME}>
<div className={`${HEADER_CLASS_NAME}__name`}>{headerName}</div>
{isNumber(headerDate) && (
<div
className={classNames(DATE_CLASS_NAME, {
[`${DATE_CLASS_NAME}--has-unread`]: isUnread,
})}
>
<div className={DATE_CLASS_NAME}>
<Timestamp
timestamp={headerDate}
extended={false}
module={TIMESTAMP_CLASS_NAME}
withUnread={isUnread}
i18n={i18n}
/>
</div>
)}
</div>
{messageText ? (
{messageText || isUnread ? (
<div className={MESSAGE_CLASS_NAME}>
<div
dir="auto"
className={classNames(MESSAGE_TEXT_CLASS_NAME, {
[`${MESSAGE_TEXT_CLASS_NAME}--has-unread`]: isUnread,
})}
>
{messageText}
</div>
{Boolean(messageText) && (
<div
dir="auto"
className={classNames(
MESSAGE_TEXT_CLASS_NAME,
messageTextIsAlwaysFullSize &&
`${MESSAGE_TEXT_CLASS_NAME}--always-full-size`
)}
>
{messageText}
</div>
)}
{messageStatusIcon}
{isUnread && (
<div className={`${BASE_CLASS_NAME}__unread-count`}>
{formatUnreadCount(unreadCount)}
</div>
)}
</div>
) : null}
</div>
@ -185,7 +184,6 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
);
const commonClassNames = classNames(BASE_CLASS_NAME, {
[`${BASE_CLASS_NAME}--has-unread`]: isUnread,
[`${BASE_CLASS_NAME}--is-selected`]: isSelected,
});

View File

@ -173,6 +173,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
markedUnread={markedUnread}
messageStatusIcon={messageStatusIcon}
messageText={messageText}
messageTextIsAlwaysFullSize
name={name}
onClick={onClickItem}
phoneNumber={phoneNumber}
@ -193,5 +194,5 @@ function truncateMessageText(text: unknown): string {
if (typeof text !== 'string') {
return '';
}
return text.split('\n', 1)[0];
return text.replace(/(?:\r?\n)+/g, ' ');
}

View File

@ -9,8 +9,9 @@ export const SearchResultsLoadingFakeRow: FunctionComponent<PropsType> = () => (
<div className="module-SearchResultsLoadingFakeRow">
<div className="module-SearchResultsLoadingFakeRow__avatar" />
<div className="module-SearchResultsLoadingFakeRow__content">
<div className="module-SearchResultsLoadingFakeRow__content__header" />
<div className="module-SearchResultsLoadingFakeRow__content__message" />
<div className="module-SearchResultsLoadingFakeRow__content__line" />
<div className="module-SearchResultsLoadingFakeRow__content__line" />
<div className="module-SearchResultsLoadingFakeRow__content__line" />
</div>
</div>
);

View File

@ -93,6 +93,10 @@ export abstract class LeftPaneHelper<T> {
return true;
}
requiresFullWidth(): boolean {
return true;
}
abstract getConversationAndMessageAtIndex(
conversationIndex: number
): undefined | { conversationId: string; messageId?: string };

View File

@ -15,6 +15,8 @@ export type LeftPaneInboxPropsType = {
conversations: ReadonlyArray<ConversationListItemPropsType>;
archivedConversations: ReadonlyArray<ConversationListItemPropsType>;
pinnedConversations: ReadonlyArray<ConversationListItemPropsType>;
isAboutToSearchInAConversation: boolean;
startSearchCounter: number;
};
/* eslint-disable class-methods-use-this */
@ -26,16 +28,24 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
private readonly pinnedConversations: ReadonlyArray<ConversationListItemPropsType>;
private readonly isAboutToSearchInAConversation: boolean;
private readonly startSearchCounter: number;
constructor({
conversations,
archivedConversations,
pinnedConversations,
isAboutToSearchInAConversation,
startSearchCounter,
}: Readonly<LeftPaneInboxPropsType>) {
super();
this.conversations = conversations;
this.archivedConversations = archivedConversations;
this.pinnedConversations = pinnedConversations;
this.isAboutToSearchInAConversation = isAboutToSearchInAConversation;
this.startSearchCounter = startSearchCounter;
}
getRowCount(): number {
@ -176,6 +186,18 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
return undefined;
}
requiresFullWidth(): boolean {
const hasNoConversations =
!this.conversations.length &&
!this.pinnedConversations.length &&
!this.archivedConversations.length;
return (
hasNoConversations ||
this.isAboutToSearchInAConversation ||
Boolean(this.startSearchCounter)
);
}
shouldRecomputeRowHeights(old: Readonly<LeftPaneInboxPropsType>): boolean {
return old.pinnedConversations.length !== this.pinnedConversations.length;
}

View File

@ -14,7 +14,7 @@ import { assert } from '../../util/assert';
// The "correct" thing to do is to measure the size of the left pane and render enough
// search results for the container height. But (1) that's slow (2) the list is
// virtualized (3) 99 rows is over 6000px tall, taller than most monitors (4) it's fine
// virtualized (3) 99 rows is over 7500px tall, taller than most monitors (4) it's fine
// if, in some extremely tall window, we have some empty space. So we just hard-code a
// fairly big number.
const SEARCH_RESULTS_FAKE_ROW_COUNT = 99;

View File

@ -30,6 +30,8 @@ export type ItemsStateType = {
readonly customColors?: CustomColorsItemType;
readonly preferredLeftPaneWidth?: number;
readonly preferredReactionEmoji?: Array<string>;
};
@ -76,6 +78,7 @@ export const actions = {
editCustomColor,
removeCustomColor,
resetDefaultChatColor,
savePreferredLeftPaneWidth,
setGlobalDefaultConversationColor,
onSetSkinTone,
putItem,
@ -256,6 +259,14 @@ function setGlobalDefaultConversationColor(
};
}
function savePreferredLeftPaneWidth(
preferredWidth: number
): ThunkAction<void, RootStateType, unknown, ItemPutAction> {
return dispatch => {
dispatch(putItem('preferredLeftPaneWidth', preferredWidth));
};
}
// Reducer
function getEmptyState(): ItemsStateType {

View File

@ -15,6 +15,8 @@ import {
} from '../../types/Colors';
import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji';
const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320;
export const getItems = (state: StateType): ItemsStateType => state.items;
export const getUserAgent = createSelector(
@ -63,6 +65,15 @@ export const getEmojiSkinTone = createSelector(
: 0
);
export const getPreferredLeftPaneWidth = createSelector(
getItems,
({ preferredLeftPaneWidth }: Readonly<ItemsStateType>): number =>
typeof preferredLeftPaneWidth === 'number' &&
Number.isInteger(preferredLeftPaneWidth)
? preferredLeftPaneWidth
: DEFAULT_PREFERRED_LEFT_PANE_WIDTH
);
export const getPreferredReactionEmoji = createSelector(
getItems,
getEmojiSkinTone,

View File

@ -48,6 +48,11 @@ export const getSearchConversationId = createSelector(
(state: SearchStateType): string | undefined => state.searchConversationId
);
export const getIsSearchingInAConversation = createSelector(
getSearchConversationId,
Boolean
);
export const getSearchConversationName = createSelector(
getSearch,
(state: SearchStateType): string | undefined => state.searchConversationName

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
@ -6,11 +6,15 @@ import { mapDispatchToProps } from '../actions';
import { DialogExpiredBuild } from '../../components/DialogExpiredBuild';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import type { WidthBreakpoint } from '../../components/_util';
const mapStateToProps = (state: StateType) => {
type PropsType = Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>;
const mapStateToProps = (state: StateType, ownProps: PropsType) => {
return {
hasExpired: state.expiration.hasExpired,
i18n: getIntl(state),
...ownProps,
};
};

View File

@ -14,8 +14,14 @@ import { StateType } from '../reducer';
import { missingCaseError } from '../../util/missingCaseError';
import { ComposerStep, OneTimeModalState } from '../ducks/conversations';
import { getSearchResults, isSearching } from '../selectors/search';
import {
getIsSearchingInAConversation,
getSearchResults,
getStartSearchCounter,
isSearching,
} from '../selectors/search';
import { getIntl, getRegionCode } from '../selectors/user';
import { getPreferredLeftPaneWidth } from '../selectors/items';
import {
getCantAddContactForModal,
getComposeAvatarData,
@ -38,6 +44,7 @@ import {
isCreatingGroup,
isEditingAvatar,
} from '../selectors/conversations';
import type { WidthBreakpoint } from '../../components/_util';
import { SmartExpiredBuildDialog } from './ExpiredBuildDialog';
import { SmartMainHeader } from './MainHeader';
@ -49,8 +56,10 @@ import { SmartCaptchaDialog } from './CaptchaDialog';
const FilteredSmartMessageSearchResult = SmartMessageSearchResult;
function renderExpiredBuildDialog(): JSX.Element {
return <SmartExpiredBuildDialog />;
function renderExpiredBuildDialog(
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
): JSX.Element {
return <SmartExpiredBuildDialog {...props} />;
}
function renderMainHeader(): JSX.Element {
return <SmartMainHeader />;
@ -58,14 +67,20 @@ function renderMainHeader(): JSX.Element {
function renderMessageSearchResult(id: string): JSX.Element {
return <FilteredSmartMessageSearchResult id={id} />;
}
function renderNetworkStatus(): JSX.Element {
return <SmartNetworkStatus />;
function renderNetworkStatus(
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
): JSX.Element {
return <SmartNetworkStatus {...props} />;
}
function renderRelinkDialog(): JSX.Element {
return <SmartRelinkDialog />;
function renderRelinkDialog(
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
): JSX.Element {
return <SmartRelinkDialog {...props} />;
}
function renderUpdateDialog(): JSX.Element {
return <SmartUpdateDialog />;
function renderUpdateDialog(
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
): JSX.Element {
return <SmartUpdateDialog {...props} />;
}
function renderCaptchaDialog({ onSkip }: { onSkip(): void }): JSX.Element {
return <SmartCaptchaDialog onSkip={onSkip} />;
@ -97,6 +112,8 @@ const getModeSpecificProps = (
}
return {
mode: LeftPaneMode.Inbox,
isAboutToSearchInAConversation: getIsSearchingInAConversation(state),
startSearchCounter: getStartSearchCounter(state),
...getLeftPaneLists(state),
};
case ComposerStep.StartDirectConversation:
@ -140,6 +157,10 @@ const getModeSpecificProps = (
const mapStateToProps = (state: StateType) => {
return {
modeSpecificProps: getModeSpecificProps(state),
canResizeLeftPane: window.Signal.RemoteConfig.isEnabled(
'desktop.internalUser'
),
preferredWidthFromStorage: getPreferredLeftPaneWidth(state),
selectedConversationId: getSelectedConversationId(state),
selectedMessageId: getSelectedMessage(state)?.id,
showArchived: getShowArchived(state),

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
@ -7,12 +7,16 @@ import { DialogNetworkStatus } from '../../components/DialogNetworkStatus';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { hasNetworkDialog } from '../selectors/network';
import type { WidthBreakpoint } from '../../components/_util';
const mapStateToProps = (state: StateType) => {
type PropsType = Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>;
const mapStateToProps = (state: StateType, ownProps: PropsType) => {
return {
...state.network,
hasNetworkDialog: hasNetworkDialog(state),
i18n: getIntl(state),
...ownProps,
};
};

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
@ -7,11 +7,15 @@ import { DialogRelink } from '../../components/DialogRelink';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { isDone } from '../../util/registration';
import type { WidthBreakpoint } from '../../components/_util';
const mapStateToProps = (state: StateType) => {
type PropsType = Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>;
const mapStateToProps = (state: StateType, ownProps: PropsType) => {
return {
i18n: getIntl(state),
isRegistrationDone: isDone(),
...ownProps,
};
};

View File

@ -46,6 +46,7 @@ import {
invertIdsByTitle,
} from '../../util/groupMemberNameCollisions';
import { ContactSpoofingType } from '../../util/contactSpoofing';
import type { WidthBreakpoint } from '../../components/_util';
type ExternalProps = {
id: string;
@ -109,6 +110,7 @@ const createBoundOnHeightChange = memoizee(
function renderItem({
actionProps,
containerElementRef,
containerWidthBreakpoint,
conversationId,
messageId,
nextMessageId,
@ -117,6 +119,7 @@ function renderItem({
}: {
actionProps: TimelineActionsType;
containerElementRef: RefObject<HTMLElement>;
containerWidthBreakpoint: WidthBreakpoint;
conversationId: string;
messageId: string;
nextMessageId: undefined | string;
@ -127,6 +130,7 @@ function renderItem({
<SmartTimelineItem
{...actionProps}
containerElementRef={containerElementRef}
containerWidthBreakpoint={containerWidthBreakpoint}
conversationId={conversationId}
messageId={messageId}
previousMessageId={previousMessageId}

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
@ -7,12 +7,16 @@ import { DialogUpdate } from '../../components/DialogUpdate';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { hasNetworkDialog } from '../selectors/network';
import type { WidthBreakpoint } from '../../components/_util';
const mapStateToProps = (state: StateType) => {
type PropsType = Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>;
const mapStateToProps = (state: StateType, ownProps: PropsType) => {
return {
...state.updates,
hasNetworkDialog: hasNetworkDialog(state),
i18n: getIntl(state),
...ownProps,
};
};

View File

@ -0,0 +1,29 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FunctionComponent } from 'react';
import { WidthBreakpoint } from '../../components/_util';
type PropsType = {
containerWidthBreakpoint: WidthBreakpoint;
};
const WIDTHS = {
[WidthBreakpoint.Wide]: 350,
[WidthBreakpoint.Medium]: 280,
[WidthBreakpoint.Narrow]: 130,
};
export const FakeLeftPaneContainer: FunctionComponent<PropsType> = ({
children,
containerWidthBreakpoint,
}) => {
return (
<div
className={`module-left-pane--width-${containerWidthBreakpoint}`}
style={{ width: WIDTHS[containerWidthBreakpoint] }}
>
{children}
</div>
);
};

View File

@ -5,6 +5,7 @@ import { assert } from 'chai';
import {
getEmojiSkinTone,
getPinnedConversationIds,
getPreferredLeftPaneWidth,
getPreferredReactionEmoji,
} from '../../../state/selectors/items';
import type { StateType } from '../../../state/reducer';
@ -50,6 +51,32 @@ describe('both/state/selectors/items', () => {
});
});
describe('#getPreferredLeftPaneWidth', () => {
it('returns a default if no value is present', () => {
const state = getRootState({});
assert.strictEqual(getPreferredLeftPaneWidth(state), 320);
});
it('returns a default value if passed something invalid', () => {
[undefined, null, '250', [250], 250.123].forEach(
preferredLeftPaneWidth => {
const state = getRootState({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
preferredLeftPaneWidth: preferredLeftPaneWidth as any,
});
assert.strictEqual(getPreferredLeftPaneWidth(state), 320);
}
);
});
it('returns the value in storage if it is valid', () => {
const state = getRootState({
preferredLeftPaneWidth: 345,
});
assert.strictEqual(getPreferredLeftPaneWidth(state), 345);
});
});
describe('#getPinnedConversationIds', () => {
it('returns pinnedConversationIds key from items', () => {
const expected = ['one', 'two'];

View File

@ -16,6 +16,7 @@ import {
} from '../../../state/ducks/search';
import { getEmptyState as getEmptyUserState } from '../../../state/ducks/user';
import {
getIsSearchingInAConversation,
getMessageSearchResultSelector,
getSearchResults,
} from '../../../state/selectors/search';
@ -68,6 +69,27 @@ describe('both/state/selectors/search', () => {
};
}
describe('#getIsSearchingInAConversation', () => {
it('returns false if not searching in a conversation', () => {
const state = getEmptyRootState();
assert.isFalse(getIsSearchingInAConversation(state));
});
it('returns true if searching in a conversation', () => {
const state = {
...getEmptyRootState(),
search: {
...getEmptySearchState(),
searchConversationId: 'abc123',
searchConversationName: 'Test Conversation',
},
};
assert.isTrue(getIsSearchingInAConversation(state));
});
});
describe('#getMessageSearchResultSelector', () => {
it('returns undefined if message not found in lookup', () => {
const state = getEmptyRootState();

View File

@ -7,16 +7,23 @@ import { RowType } from '../../../components/ConversationList';
import { FindDirection } from '../../../components/leftPane/LeftPaneHelper';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import { LeftPaneInboxHelper } from '../../../components/leftPane/LeftPaneInboxHelper';
import {
LeftPaneInboxHelper,
LeftPaneInboxPropsType,
} from '../../../components/leftPane/LeftPaneInboxHelper';
describe('LeftPaneInboxHelper', () => {
const defaultProps: LeftPaneInboxPropsType = {
conversations: [],
pinnedConversations: [],
archivedConversations: [],
isAboutToSearchInAConversation: false,
startSearchCounter: 0,
};
describe('getBackAction', () => {
it("returns undefined; you can't go back from the main inbox", () => {
const helper = new LeftPaneInboxHelper({
conversations: [],
pinnedConversations: [],
archivedConversations: [],
});
const helper = new LeftPaneInboxHelper(defaultProps);
assert.isUndefined(
helper.getBackAction({
@ -30,19 +37,14 @@ describe('LeftPaneInboxHelper', () => {
describe('getRowCount', () => {
it('returns 0 if there are no conversations', () => {
const helper = new LeftPaneInboxHelper({
conversations: [],
pinnedConversations: [],
archivedConversations: [],
});
const helper = new LeftPaneInboxHelper(defaultProps);
assert.strictEqual(helper.getRowCount(), 0);
});
it('returns 1 if there are only archived conversations', () => {
const helper = new LeftPaneInboxHelper({
conversations: [],
pinnedConversations: [],
...defaultProps,
archivedConversations: [getDefaultConversation()],
});
@ -51,13 +53,12 @@ describe('LeftPaneInboxHelper', () => {
it("returns the number of non-pinned conversations if that's all there is", () => {
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations: [
getDefaultConversation(),
getDefaultConversation(),
getDefaultConversation(),
],
pinnedConversations: [],
archivedConversations: [],
});
assert.strictEqual(helper.getRowCount(), 3);
@ -65,13 +66,12 @@ describe('LeftPaneInboxHelper', () => {
it("returns the number of pinned conversations if that's all there is", () => {
const helper = new LeftPaneInboxHelper({
conversations: [],
...defaultProps,
pinnedConversations: [
getDefaultConversation(),
getDefaultConversation(),
getDefaultConversation(),
],
archivedConversations: [],
});
assert.strictEqual(helper.getRowCount(), 3);
@ -79,13 +79,13 @@ describe('LeftPaneInboxHelper', () => {
it('adds 2 rows for each header if there are pinned and non-pinned conversations,', () => {
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations: [
getDefaultConversation(),
getDefaultConversation(),
getDefaultConversation(),
],
pinnedConversations: [getDefaultConversation()],
archivedConversations: [],
});
assert.strictEqual(helper.getRowCount(), 6);
@ -93,12 +93,12 @@ describe('LeftPaneInboxHelper', () => {
it('adds 1 row for the archive button if there are any archived conversations', () => {
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations: [
getDefaultConversation(),
getDefaultConversation(),
getDefaultConversation(),
],
pinnedConversations: [],
archivedConversations: [getDefaultConversation()],
});
@ -109,9 +109,9 @@ describe('LeftPaneInboxHelper', () => {
describe('getRowIndexToScrollTo', () => {
it('returns undefined if no conversation is selected', () => {
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations: [getDefaultConversation(), getDefaultConversation()],
pinnedConversations: [getDefaultConversation()],
archivedConversations: [],
});
assert.isUndefined(helper.getRowIndexToScrollTo(undefined));
@ -120,6 +120,7 @@ describe('LeftPaneInboxHelper', () => {
it('returns undefined if the selected conversation is not pinned or non-pinned', () => {
const archivedConversations = [getDefaultConversation()];
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations: [getDefaultConversation(), getDefaultConversation()],
pinnedConversations: [getDefaultConversation()],
archivedConversations,
@ -136,9 +137,8 @@ describe('LeftPaneInboxHelper', () => {
getDefaultConversation(),
];
const helper = new LeftPaneInboxHelper({
conversations: [],
...defaultProps,
pinnedConversations,
archivedConversations: [],
});
assert.strictEqual(
@ -157,9 +157,8 @@ describe('LeftPaneInboxHelper', () => {
getDefaultConversation(),
];
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations,
pinnedConversations: [],
archivedConversations: [],
});
assert.strictEqual(helper.getRowIndexToScrollTo(conversations[0].id), 0);
@ -172,9 +171,9 @@ describe('LeftPaneInboxHelper', () => {
getDefaultConversation(),
];
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations: [getDefaultConversation()],
pinnedConversations,
archivedConversations: [],
});
assert.strictEqual(
@ -193,13 +192,13 @@ describe('LeftPaneInboxHelper', () => {
getDefaultConversation(),
];
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations,
pinnedConversations: [
getDefaultConversation(),
getDefaultConversation(),
getDefaultConversation(),
],
archivedConversations: [],
});
assert.strictEqual(helper.getRowIndexToScrollTo(conversations[0].id), 5);
@ -210,8 +209,7 @@ describe('LeftPaneInboxHelper', () => {
describe('getRow', () => {
it('returns the archive button if there are only archived conversations', () => {
const helper = new LeftPaneInboxHelper({
conversations: [],
pinnedConversations: [],
...defaultProps,
archivedConversations: [
getDefaultConversation(),
getDefaultConversation(),
@ -232,9 +230,8 @@ describe('LeftPaneInboxHelper', () => {
];
const helper = new LeftPaneInboxHelper({
conversations: [],
...defaultProps,
pinnedConversations,
archivedConversations: [],
});
assert.deepEqual(helper.getRow(0), {
@ -255,7 +252,7 @@ describe('LeftPaneInboxHelper', () => {
];
const helper = new LeftPaneInboxHelper({
conversations: [],
...defaultProps,
pinnedConversations,
archivedConversations: [getDefaultConversation()],
});
@ -282,9 +279,8 @@ describe('LeftPaneInboxHelper', () => {
];
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations,
pinnedConversations: [],
archivedConversations: [],
});
assert.deepEqual(helper.getRow(0), {
@ -305,8 +301,8 @@ describe('LeftPaneInboxHelper', () => {
];
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations,
pinnedConversations: [],
archivedConversations: [getDefaultConversation()],
});
@ -337,9 +333,9 @@ describe('LeftPaneInboxHelper', () => {
];
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations,
pinnedConversations,
archivedConversations: [],
});
assert.deepEqual(helper.getRow(0), {
@ -385,6 +381,7 @@ describe('LeftPaneInboxHelper', () => {
];
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations,
pinnedConversations,
archivedConversations: [getDefaultConversation()],
@ -439,9 +436,9 @@ describe('LeftPaneInboxHelper', () => {
];
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations,
pinnedConversations,
archivedConversations: [],
});
assert.strictEqual(
@ -473,9 +470,8 @@ describe('LeftPaneInboxHelper', () => {
];
const helper = new LeftPaneInboxHelper({
conversations: [],
...defaultProps,
pinnedConversations,
archivedConversations: [],
});
assert.strictEqual(
@ -501,9 +497,8 @@ describe('LeftPaneInboxHelper', () => {
];
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations,
pinnedConversations: [],
archivedConversations: [],
});
assert.strictEqual(
@ -533,9 +528,9 @@ describe('LeftPaneInboxHelper', () => {
];
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations,
pinnedConversations,
archivedConversations: [],
});
assert.strictEqual(
@ -556,8 +551,7 @@ describe('LeftPaneInboxHelper', () => {
it('returns undefined if there are no conversations', () => {
const helper = new LeftPaneInboxHelper({
conversations: [],
pinnedConversations: [],
...defaultProps,
archivedConversations: [getDefaultConversation()],
});
@ -575,9 +569,9 @@ describe('LeftPaneInboxHelper', () => {
];
const conversations = [getDefaultConversation()];
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations,
pinnedConversations,
archivedConversations: [],
});
assert.deepEqual(
@ -593,9 +587,45 @@ describe('LeftPaneInboxHelper', () => {
// Additional tests are found with `getConversationInDirection`.
});
describe('requiresFullWidth', () => {
it("returns false if we're not about to search in a conversation and there's at least one", () => {
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations: [getDefaultConversation()],
});
assert.isFalse(helper.requiresFullWidth());
});
it('returns true if there are no conversations', () => {
const helper = new LeftPaneInboxHelper(defaultProps);
assert.isTrue(helper.requiresFullWidth());
});
it("returns true if we're about to search", () => {
const helper = new LeftPaneInboxHelper({
...defaultProps,
startSearchCounter: 1,
});
assert.isTrue(helper.requiresFullWidth());
});
it("returns true if we're about to search in a conversation", () => {
const helper = new LeftPaneInboxHelper({
...defaultProps,
isAboutToSearchInAConversation: true,
});
assert.isTrue(helper.requiresFullWidth());
});
});
describe('shouldRecomputeRowHeights', () => {
it("returns false if the number of conversations in each section doesn't change", () => {
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations: [
getDefaultConversation(),
getDefaultConversation(),
@ -610,6 +640,7 @@ describe('LeftPaneInboxHelper', () => {
assert.isFalse(
helper.shouldRecomputeRowHeights({
...defaultProps,
conversations: [
getDefaultConversation(),
getDefaultConversation(),
@ -629,6 +660,7 @@ describe('LeftPaneInboxHelper', () => {
it('returns false if the only thing changed is whether conversations are archived', () => {
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations: [
getDefaultConversation(),
getDefaultConversation(),
@ -643,6 +675,7 @@ describe('LeftPaneInboxHelper', () => {
assert.isFalse(
helper.shouldRecomputeRowHeights({
...defaultProps,
conversations: [
getDefaultConversation(),
getDefaultConversation(),
@ -652,13 +685,13 @@ describe('LeftPaneInboxHelper', () => {
getDefaultConversation(),
getDefaultConversation(),
],
archivedConversations: [],
})
);
});
it('returns false if the only thing changed is the number of non-pinned conversations', () => {
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations: [
getDefaultConversation(),
getDefaultConversation(),
@ -673,6 +706,7 @@ describe('LeftPaneInboxHelper', () => {
assert.isFalse(
helper.shouldRecomputeRowHeights({
...defaultProps,
conversations: [getDefaultConversation()],
pinnedConversations: [
getDefaultConversation(),
@ -688,6 +722,7 @@ describe('LeftPaneInboxHelper', () => {
it('returns true if the number of pinned conversations changes', () => {
const helper = new LeftPaneInboxHelper({
...defaultProps,
conversations: [getDefaultConversation()],
pinnedConversations: [
getDefaultConversation(),
@ -698,6 +733,7 @@ describe('LeftPaneInboxHelper', () => {
assert.isTrue(
helper.shouldRecomputeRowHeights({
...defaultProps,
conversations: [getDefaultConversation()],
pinnedConversations: [
getDefaultConversation(),
@ -709,6 +745,7 @@ describe('LeftPaneInboxHelper', () => {
);
assert.isTrue(
helper.shouldRecomputeRowHeights({
...defaultProps,
conversations: [getDefaultConversation()],
pinnedConversations: [getDefaultConversation()],
archivedConversations: [getDefaultConversation()],
@ -716,6 +753,7 @@ describe('LeftPaneInboxHelper', () => {
);
assert.isTrue(
helper.shouldRecomputeRowHeights({
...defaultProps,
conversations: [getDefaultConversation()],
pinnedConversations: [],
archivedConversations: [getDefaultConversation()],

View File

@ -132,6 +132,7 @@ export type StorageAccessType = {
senderCertificateNoE164: SerializedCertificateType;
paymentAddress: string;
zoomFactor: ZoomFactorType;
preferredLeftPaneWidth: number;
// Deprecated
senderCertificateWithUuid: never;

View File

@ -25,6 +25,7 @@ export const STORAGE_UI_KEYS: ReadonlyArray<keyof StorageAccessType> = [
'preferred-video-input-device',
'preferred-audio-input-device',
'preferred-audio-output-device',
'preferredLeftPaneWidth',
'preferredReactionEmoji',
'previousAudioDeviceModule',
'skinTone',

View File

@ -13323,9 +13323,16 @@
{
"rule": "jQuery-$(",
"path": "ts/views/inbox_view.js",
"line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);",
"line": " this.$('.left-pane-placeholder').replaceWith(this.leftPaneView.el);",
"reasonCategory": "usageTrusted",
"updated": "2021-09-15T21:07:50.995Z"
"updated": "2021-10-08T17:39:41.541Z"
},
{
"rule": "jQuery-$(",
"path": "ts/views/inbox_view.js",
"line": " this.$('.no-conversation-open').toggle(!isAnyConversationOpen);",
"reasonCategory": "usageTrusted",
"updated": "2021-10-08T17:40:22.770Z"
},
{
"rule": "jQuery-append(",
@ -13334,13 +13341,6 @@
"reasonCategory": "usageTrusted",
"updated": "2021-09-15T21:07:50.995Z"
},
{
"rule": "jQuery-append(",
"path": "ts/views/inbox_view.js",
"line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);",
"reasonCategory": "usageTrusted",
"updated": "2021-09-15T21:07:50.995Z"
},
{
"rule": "jQuery-appendTo(",
"path": "ts/views/inbox_view.js",
@ -13407,9 +13407,16 @@
{
"rule": "jQuery-$(",
"path": "ts/views/inbox_view.ts",
"line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);",
"line": " this.$('.no-conversation-open').toggle(!isAnyConversationOpen);",
"reasonCategory": "usageTrusted",
"updated": "2021-09-15T21:07:50.995Z"
"updated": "2021-10-08T17:40:22.770Z"
},
{
"rule": "jQuery-$(",
"path": "ts/views/inbox_view.ts",
"line": " this.$('.left-pane-placeholder').replaceWith(this.leftPaneView.el);",
"reasonCategory": "usageTrusted",
"updated": "2021-10-08T17:40:22.770Z"
},
{
"rule": "jQuery-append(",
@ -13418,13 +13425,6 @@
"reasonCategory": "usageTrusted",
"updated": "2021-09-15T21:07:50.995Z"
},
{
"rule": "jQuery-append(",
"path": "ts/views/inbox_view.ts",
"line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);",
"reasonCategory": "usageTrusted",
"updated": "2021-09-15T21:07:50.995Z"
},
{
"rule": "jQuery-appendTo(",
"path": "ts/views/inbox_view.ts",
@ -14083,4 +14083,4 @@
"reasonCategory": "usageTrusted",
"updated": "2021-09-17T21:02:59.414Z"
}
]
]

View File

@ -1,6 +1,7 @@
// Copyright 2014-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as Backbone from 'backbone';
import * as log from '../logging/log';
import { ConversationModel } from '../models/conversations';
import { showToast } from '../util/showToast';
@ -9,12 +10,19 @@ import { ToastStickerPackInstallFailed } from '../components/ToastStickerPackIns
window.Whisper = window.Whisper || {};
const { Whisper } = window;
const ConversationStack = Whisper.View.extend({
className: 'conversation-stack',
lastConversation: null,
open(conversation: ConversationModel, messageId: string) {
const id = `conversation-${conversation.cid}`;
if (id !== this.el.lastChild.id) {
class ConversationStack extends Backbone.View {
public className = 'conversation-stack';
private conversationStack: Array<ConversationModel> = [];
private getTopConversation(): undefined | ConversationModel {
return this.conversationStack[this.conversationStack.length - 1];
}
public open(conversation: ConversationModel, messageId: string): void {
const topConversation = this.getTopConversation();
if (!topConversation || topConversation.id !== conversation.id) {
const view = new Whisper.ConversationView({
model: conversation,
});
@ -24,36 +32,43 @@ const ConversationStack = Whisper.View.extend({
);
view.$el.appendTo(this.el);
if (this.lastConversation && this.lastConversation !== conversation) {
this.lastConversation.trigger('unload', 'opened another conversation');
this.stopListening(this.lastConversation);
this.lastConversation = null;
if (topConversation) {
topConversation.trigger('unload', 'opened another conversation');
}
this.lastConversation = conversation;
this.conversationStack.push(conversation);
conversation.trigger('opened', messageId);
} else if (messageId) {
conversation.trigger('scroll-to-message', messageId);
}
this.render();
}
public unload(): void {
this.getTopConversation()?.trigger('unload', 'force unload requested');
}
private onUnload(conversation: ConversationModel) {
this.stopListening(conversation);
this.conversationStack = this.conversationStack.filter(
(c: ConversationModel) => c !== conversation
);
this.render();
}
public render(): ConversationStack {
const isAnyConversationOpen = Boolean(this.conversationStack.length);
this.$('.no-conversation-open').toggle(!isAnyConversationOpen);
// Make sure poppers are positioned properly
window.dispatchEvent(new Event('resize'));
},
unload() {
const { lastConversation } = this;
if (!lastConversation) {
return;
}
lastConversation.trigger('unload', 'force unload requested');
},
onUnload(conversation: ConversationModel) {
if (this.lastConversation === conversation) {
this.stopListening(this.lastConversation);
this.lastConversation = null;
}
},
});
return this;
}
}
const AppLoadingScreen = Whisper.View.extend({
template: () => $('#app-loading-screen').html(),
@ -71,7 +86,7 @@ const AppLoadingScreen = Whisper.View.extend({
Whisper.InboxView = Whisper.View.extend({
template: () => $('#two-column').html(),
className: 'inbox index',
className: 'Inbox',
initialize(
options: {
initialLoadComplete?: boolean;
@ -83,7 +98,6 @@ Whisper.InboxView = Whisper.View.extend({
this.conversation_stack = new ConversationStack({
el: this.$('.conversation-stack'),
model: { window: options.window },
});
this.renderWhatsNew();
@ -166,7 +180,7 @@ Whisper.InboxView = Whisper.View.extend({
JSX: window.Signal.State.Roots.createLeftPane(window.reduxStore),
});
this.$('.left-pane-placeholder').append(this.leftPaneView.el);
this.$('.left-pane-placeholder').replaceWith(this.leftPaneView.el);
},
startConnectionListener() {
this.interval = setInterval(() => {