Archive Conversation

pull/272/head
Scott Nonnenberg 6 years ago
parent d72f89d776
commit 6ffbc0ac06

@ -187,6 +187,27 @@
} }
} }
}, },
"archivedConversations": {
"message": "Archived Conversations",
"description":
"Shown in place of the search box when showing archived conversation list"
},
"archiveHelperText": {
"message":
"These conversations are archived and will only appear in the Inbox if new messages are received.",
"description":
"Shown at the top of the archived converations list in the left pane"
},
"archiveConversation": {
"message": "Archive Conversation",
"description":
"Shown in menu for conversation, and moves conversation out of main conversation list"
},
"moveConversationToInbox": {
"message": "Move Converstion to Inbox",
"description":
"Undoes Archive Conversation action, and moves archived conversation back to the main conversation list"
},
"chooseDirectory": { "chooseDirectory": {
"message": "Choose folder", "message": "Choose folder",
"description": "Button to allow the user to find a folder on disk" "description": "Button to allow the user to find a folder on disk"

@ -312,6 +312,7 @@
const result = { const result = {
id: this.id, id: this.id,
isArchived: this.get('isArchived'),
activeAt: this.get('active_at'), activeAt: this.get('active_at'),
avatarPath: this.getAvatarPath(), avatarPath: this.getAvatarPath(),
color, color,
@ -889,6 +890,7 @@
lastMessageStatus: 'sending', lastMessageStatus: 'sending',
active_at: now, active_at: now,
timestamp: now, timestamp: now,
isArchived: false,
}); });
await window.Signal.Data.updateConversation(this.id, this.attributes, { await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation, Conversation: Whisper.Conversation,
@ -1170,6 +1172,13 @@
} }
}, },
async setArchived(isArchived) {
this.set({ isArchived });
await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation,
});
},
async updateExpirationTimer( async updateExpirationTimer(
providedExpireTimer, providedExpireTimer,
providedSource, providedSource,

@ -1645,10 +1645,10 @@
c.onReadMessage(message); c.onReadMessage(message);
} }
} else { } else {
conversation.set( conversation.set({
'unreadCount', unreadCount: conversation.get('unreadCount') + 1,
conversation.get('unreadCount') + 1 isArchived: false,
); });
} }
} }

@ -1,7 +1,6 @@
/* global Signal:false */ /* global Signal:false */
/* global Backbone: false */ /* global Backbone: false */
/* global ConversationController: false */
/* global drawAttention: false */ /* global drawAttention: false */
/* global i18n: false */ /* global i18n: false */
/* global isFocused: false */ /* global isFocused: false */

@ -185,9 +185,12 @@
profileName: this.model.getProfileName(), profileName: this.model.getProfileName(),
color: this.model.getColor(), color: this.model.getColor(),
avatarPath: this.model.getAvatarPath(), avatarPath: this.model.getAvatarPath(),
isVerified: this.model.isVerified(), isVerified: this.model.isVerified(),
isMe: this.model.isMe(), isMe: this.model.isMe(),
isGroup: !this.model.isPrivate(), isGroup: !this.model.isPrivate(),
isArchived: this.model.get('isArchived'),
expirationSettingName, expirationSettingName,
showBackButton: Boolean(this.panels && this.panels.length), showBackButton: Boolean(this.panels && this.panels.length),
timerOptions: Whisper.ExpirationTimerOptions.map(item => ({ timerOptions: Whisper.ExpirationTimerOptions.map(item => ({
@ -217,6 +220,14 @@
this.resetPanel(); this.resetPanel();
this.updateHeader(); this.updateHeader();
}, },
onArchive: () => {
this.unload();
this.model.setArchived(true);
},
onMoveToInbox: () => {
this.model.setArchived(false);
},
}; };
}; };
this.titleView = new Whisper.ReactWrapperView({ this.titleView = new Whisper.ReactWrapperView({

@ -220,7 +220,7 @@
window.location.reload(); window.location.reload();
}, },
async openConversation(id, messageId) { async openConversation(id, messageId) {
const conversation = await window.ConversationController.getOrCreateAndWait( const conversation = await ConversationController.getOrCreateAndWait(
id, id,
'private' 'private'
); );

@ -2986,15 +2986,74 @@
flex-grow: 0; flex-grow: 0;
} }
.module-left-pane__archive-header {
height: 48px;
width: 100%;
display: inline-flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid $color-gray-15;
}
.module-left-pane__to-inbox-button {
margin-left: 2px;
width: 35px;
height: 35px;
cursor: pointer;
@include color-svg('../images/back.svg', $color-gray-60);
}
.module-left-pane__archive-header-text {
color: $color-gray-90;
font-size: 16px;
font-weight: 300px;
}
.module-left-pane__list { .module-left-pane__list {
flex-grow: 1; flex-grow: 1;
flex-shrink: 1; flex-shrink: 1;
} }
.module-left-pane__archive-helper-text {
padding: 1em;
font-size: 12px;
color: $color-gray-60;
background-color: $color-gray-05;
}
.module-left-pane__virtual-list { .module-left-pane__virtual-list {
outline: none; outline: none;
} }
.module-left-pane__archived-button {
font-size: 14px;
height: 64px;
line-height: 64px;
text-align: center;
font-weight: 300;
color: $color-gray-60;
cursor: pointer;
&:hover {
background-color: $color-gray-05;
}
}
.module-left-pane__archived-button__archived-count {
font-size: 12px;
font-weight: 300;
color: $color-gray-60;
background-color: $color-gray-05;
padding: 6px;
padding-top: 1px;
padding-bottom: 1px;
border-radius: 10px;
}
// Module: Start New Conversation // Module: Start New Conversation
.module-start-new-conversation { .module-start-new-conversation {

@ -1346,7 +1346,7 @@ body.dark-theme {
} }
.module-main-header__search__cancel-icon { .module-main-header__search__cancel-icon {
@include color-svg('../images/x.svg', $color-gray-25); @include color-svg('../images/x-16.svg', $color-gray-25);
} }
// Module: Image // Module: Image
@ -1382,7 +1382,7 @@ body.dark-theme {
} }
.module-attachments__close-button { .module-attachments__close-button {
@include color-svg('../images/x.svg', $color-gray-45); @include color-svg('../images/x-16.svg', $color-gray-45);
} }
// Module: Staged Generic Attachment // Module: Staged Generic Attachment
@ -1482,7 +1482,7 @@ body.dark-theme {
} }
.module-caption-editor__close-button { .module-caption-editor__close-button {
@include color-svg('../images/x.svg', $color-white); @include color-svg('../images/x-16.svg', $color-white);
} }
.module-caption-editor__media-container { .module-caption-editor__media-container {
@ -1553,6 +1553,35 @@ body.dark-theme {
border-right: 1px solid $color-gray-75; border-right: 1px solid $color-gray-75;
} }
.module-left-pane__archive-header {
border-bottom: 1px solid $color-gray-75;
}
.module-left-pane__to-inbox-button {
background-color: $color-gray-25;
}
.module-left-pane__archive-header-text {
color: $color-gray-05;
}
.module-left-pane__archive-helper-text {
color: $color-gray-25;
background-color: $color-gray-75;
}
.module-left-pane__archived-button {
color: $color-gray-25;
&:hover {
background-color: $color-gray-75;
}
}
.module-left-pane__archived-button__archived-count {
color: $color-gray-25;
background-color: $color-gray-75;
}
// Module: Start New Conversation // Module: Start New Conversation
.module-start-new-conversation { .module-start-new-conversation {

@ -129,8 +129,14 @@ window.searchResults.messages = [
<util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}> <util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
<LeftPane <LeftPane
searchResults={window.searchResults} searchResults={window.searchResults}
openConversation={result => console.log('openConversation', result)} startNewConversation={(query, options) =>
openMessage={result => console.log('onClickMessage', result)} console.log('startNewConversation', query, options)
}
openConversationInternal={(id, messageId) =>
console.log('openConversation', id, messageId)
}
showArchivedConversations={() => console.log('showArchivedConversations')}
showInbox={() => console.log('showInbox')}
renderMainHeader={() => ( renderMainHeader={() => (
<MainHeader <MainHeader
searchTerm="Hi there!" searchTerm="Hi there!"
@ -151,8 +157,74 @@ window.searchResults.messages = [
<util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}> <util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
<LeftPane <LeftPane
conversations={window.searchResults.conversations} conversations={window.searchResults.conversations}
openConversation={result => console.log('openConversation', result)} archivedConversations={[]}
openMessage={result => console.log('onClickMessage', result)} startNewConversation={(query, options) =>
console.log('startNewConversation', query, options)
}
openConversationInternal={(id, messageId) =>
console.log('openConversation', id, messageId)
}
showArchivedConversations={() => console.log('showArchivedConversations')}
showInbox={() => console.log('showInbox')}
renderMainHeader={() => (
<MainHeader
searchTerm="Hi there!"
search={result => console.log('search', result)}
updateSearch={result => console.log('updateSearch', result)}
clearSearch={result => console.log('clearSearch', result)}
i18n={util.i18n}
/>
)}
i18n={util.i18n}
/>
</util.LeftPaneContext>
```
#### Showing inbox, with some archived
```jsx
<util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
<LeftPane
conversations={window.searchResults.conversations.slice(0, 2)}
archivedConversations={window.searchResults.conversations.slice(2)}
startNewConversation={(query, options) =>
console.log('startNewConversation', query, options)
}
openConversationInternal={(id, messageId) =>
console.log('openConversation', id, messageId)
}
showArchivedConversations={() => console.log('showArchivedConversations')}
showInbox={() => console.log('showInbox')}
renderMainHeader={() => (
<MainHeader
searchTerm="Hi there!"
search={result => console.log('search', result)}
updateSearch={result => console.log('updateSearch', result)}
clearSearch={result => console.log('clearSearch', result)}
i18n={util.i18n}
/>
)}
i18n={util.i18n}
/>
</util.LeftPaneContext>
```
#### Showing archived conversations
```jsx
<util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
<LeftPane
conversations={window.searchResults.conversations.slice(0, 2)}
archivedConversations={window.searchResults.conversations.slice(2)}
showArchived={true}
startNewConversation={(query, options) =>
console.log('startNewConversation', query, options)
}
openConversationInternal={(id, messageId) =>
console.log('openConversation', id, messageId)
}
showArchivedConversations={() => console.log('showArchivedConversations')}
showInbox={() => console.log('showInbox')}
renderMainHeader={() => ( renderMainHeader={() => (
<MainHeader <MainHeader
searchTerm="Hi there!" searchTerm="Hi there!"

@ -13,19 +13,27 @@ import { LocalizerType } from '../types/Util';
export interface Props { export interface Props {
conversations?: Array<ConversationListItemPropsType>; conversations?: Array<ConversationListItemPropsType>;
archivedConversations?: Array<ConversationListItemPropsType>;
searchResults?: SearchResultsProps; searchResults?: SearchResultsProps;
showArchived?: boolean;
i18n: LocalizerType; i18n: LocalizerType;
// Action Creators // Action Creators
startNewConversation: () => void; startNewConversation: (
query: string,
options: { regionCode: string }
) => void;
openConversationInternal: (id: string, messageId?: string) => void; openConversationInternal: (id: string, messageId?: string) => void;
showArchivedConversations: () => void;
showInbox: () => void;
// Render Props // Render Props
renderMainHeader: () => JSX.Element; renderMainHeader: () => JSX.Element;
} }
// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5 // from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5
type RowRendererParams = { type RowRendererParamsType = {
index: number; index: number;
isScrolling: boolean; isScrolling: boolean;
isVisible: boolean; isVisible: boolean;
@ -35,12 +43,51 @@ type RowRendererParams = {
}; };
export class LeftPane extends React.Component<Props> { export class LeftPane extends React.Component<Props> {
public renderRow = ({ index, key, style }: RowRendererParams) => { public listRef: React.RefObject<any> = React.createRef();
const { conversations, i18n, openConversationInternal } = this.props;
if (!conversations) { public scrollToTop() {
return null; if (this.listRef && this.listRef.current) {
const { current } = this.listRef;
current.scrollToRow(0);
}
}
public componentDidUpdate(prevProps: Props) {
const { showArchived, searchResults } = this.props;
const isNotShowingSearchResults = !searchResults;
const hasArchiveViewChanged = showArchived !== prevProps.showArchived;
if (isNotShowingSearchResults && hasArchiveViewChanged) {
this.scrollToTop();
}
}
public renderRow = ({
index,
key,
style,
}: RowRendererParamsType): JSX.Element => {
const {
archivedConversations,
conversations,
i18n,
openConversationInternal,
showArchived,
} = this.props;
if (!conversations || !archivedConversations) {
throw new Error(
'renderRow: Tried to render without conversations or archivedConversations'
);
}
if (!showArchived && index === conversations.length) {
return this.renderArchivedButton({ key, style });
} }
const conversation = conversations[index];
const conversation = showArchived
? archivedConversations[index]
: conversations[index];
return ( return (
<ConversationListItem <ConversationListItem
@ -53,13 +100,50 @@ export class LeftPane extends React.Component<Props> {
); );
}; };
public renderList() { public renderArchivedButton({
key,
style,
}: {
key: string;
style: Object;
}): JSX.Element {
const { const {
archivedConversations,
i18n,
showArchivedConversations,
} = this.props;
if (!archivedConversations || !archivedConversations.length) {
throw new Error(
'renderArchivedButton: Tried to render without archivedConversations'
);
}
return (
<div
key={key}
className="module-left-pane__archived-button"
style={style}
role="button"
onClick={showArchivedConversations}
>
{i18n('archivedConversations')}{' '}
<span className="module-left-pane__archived-button__archived-count">
{archivedConversations.length}
</span>
</div>
);
}
public renderList(): JSX.Element {
const {
archivedConversations,
i18n, i18n,
conversations, conversations,
openConversationInternal, openConversationInternal,
startNewConversation, startNewConversation,
searchResults, searchResults,
showArchived,
} = this.props; } = this.props;
if (searchResults) { if (searchResults) {
@ -73,22 +157,35 @@ export class LeftPane extends React.Component<Props> {
); );
} }
if (!conversations || !conversations.length) { if (!conversations || !archivedConversations) {
return null; throw new Error(
'render: must provided conversations and archivedConverstions if no search results are provided'
);
} }
// That extra 1 element added to the list is the 'archived converastions' button
const length = showArchived
? archivedConversations.length
: conversations.length + (archivedConversations.length ? 1 : 0);
// Note: conversations is not a known prop for List, but it is required to ensure that // Note: conversations is not a known prop for List, but it is required to ensure that
// it re-renders when our conversation data changes. Otherwise it would just render // it re-renders when our conversation data changes. Otherwise it would just render
// on startup and scroll. // on startup and scroll.
return ( return (
<div className="module-left-pane__list"> <div className="module-left-pane__list">
{showArchived ? (
<div className="module-left-pane__archive-helper-text">
{i18n('archiveHelperText')}
</div>
) : null}
<AutoSizer> <AutoSizer>
{({ height, width }) => ( {({ height, width }) => (
<List <List
className="module-left-pane__virtual-list" className="module-left-pane__virtual-list"
ref={this.listRef}
conversations={conversations} conversations={conversations}
height={height} height={height}
rowCount={conversations.length} rowCount={length}
rowHeight={64} rowHeight={64}
rowRenderer={this.renderRow} rowRenderer={this.renderRow}
width={width} width={width}
@ -99,12 +196,31 @@ export class LeftPane extends React.Component<Props> {
); );
} }
public render() { public renderArchivedHeader(): JSX.Element {
const { renderMainHeader } = this.props; const { i18n, showInbox } = this.props;
return (
<div className="module-left-pane__archive-header">
<div
role="button"
onClick={showInbox}
className="module-left-pane__to-inbox-button"
/>
<div className="module-left-pane__archive-header-text">
{i18n('archivedConversations')}
</div>
</div>
);
}
public render(): JSX.Element {
const { renderMainHeader, showArchived } = this.props;
return ( return (
<div className="module-left-pane"> <div className="module-left-pane">
<div className="module-left-pane__header">{renderMainHeader()}</div> <div className="module-left-pane__header">
{showArchived ? this.renderArchivedHeader() : renderMainHeader()}
</div>
{this.renderList()} {this.renderList()}
</div> </div>
); );

@ -113,7 +113,9 @@ window.searchResults.messages = [
i18n={util.i18n} i18n={util.i18n}
onClickMessage={id => console.log('onClickMessage', id)} onClickMessage={id => console.log('onClickMessage', id)}
onClickConversation={id => console.log('onClickConversation', id)} onClickConversation={id => console.log('onClickConversation', id)}
onStartNewConversation={() => console.log('onStartNewConversation')} onStartNewConversation={(query, options) =>
console.log('onStartNewConversation', query, options)
}
/> />
</util.LeftPaneContext>; </util.LeftPaneContext>;
``` ```
@ -131,7 +133,9 @@ window.searchResults.messages = [
i18n={util.i18n} i18n={util.i18n}
onClickMessage={id => console.log('onClickMessage', id)} onClickMessage={id => console.log('onClickMessage', id)}
onClickConversation={id => console.log('onClickConversation', id)} onClickConversation={id => console.log('onClickConversation', id)}
onStartNewConversation={() => console.log('onStartNewConversation')} onStartNewConversation={(query, options) =>
console.log('onStartNewConversation', query, options)
}
/> />
</util.LeftPaneContext> </util.LeftPaneContext>
``` ```
@ -147,7 +151,9 @@ window.searchResults.messages = [
i18n={util.i18n} i18n={util.i18n}
onClickMessage={id => console.log('onClickMessage', id)} onClickMessage={id => console.log('onClickMessage', id)}
onClickConversation={id => console.log('onClickConversation', id)} onClickConversation={id => console.log('onClickConversation', id)}
onStartNewConversation={() => console.log('onStartNewConversation')} onStartNewConversation={(query, options) =>
console.log('onStartNewConversation', query, options)
}
/> />
</util.LeftPaneContext> </util.LeftPaneContext>
``` ```
@ -163,7 +169,9 @@ window.searchResults.messages = [
i18n={util.i18n} i18n={util.i18n}
onClickMessage={id => console.log('onClickMessage', id)} onClickMessage={id => console.log('onClickMessage', id)}
onClickConversation={id => console.log('onClickConversation', id)} onClickConversation={id => console.log('onClickConversation', id)}
onStartNewConversation={() => console.log('onStartNewConversation')} onStartNewConversation={(query, options) =>
console.log('onStartNewConversation', query, options)
}
/> />
</util.LeftPaneContext> </util.LeftPaneContext>
``` ```

@ -16,6 +16,7 @@ export type PropsData = {
conversations: Array<ConversationListItemPropsType>; conversations: Array<ConversationListItemPropsType>;
hideMessagesHeader: boolean; hideMessagesHeader: boolean;
messages: Array<MessageSearchResultPropsType>; messages: Array<MessageSearchResultPropsType>;
regionCode: string;
searchTerm: string; searchTerm: string;
showStartNewConversation: boolean; showStartNewConversation: boolean;
}; };
@ -23,12 +24,21 @@ export type PropsData = {
type PropsHousekeeping = { type PropsHousekeeping = {
i18n: LocalizerType; i18n: LocalizerType;
openConversation: (id: string, messageId?: string) => void; openConversation: (id: string, messageId?: string) => void;
startNewConversation: (id: string) => void; startNewConversation: (
query: string,
options: { regionCode: string }
) => void;
}; };
type Props = PropsData & PropsHousekeeping; type Props = PropsData & PropsHousekeeping;
export class SearchResults extends React.Component<Props> { export class SearchResults extends React.Component<Props> {
public handleStartNewConversation = () => {
const { regionCode, searchTerm, startNewConversation } = this.props;
startNewConversation(searchTerm, { regionCode });
};
public render() { public render() {
const { const {
conversations, conversations,
@ -37,7 +47,6 @@ export class SearchResults extends React.Component<Props> {
i18n, i18n,
messages, messages,
openConversation, openConversation,
startNewConversation,
searchTerm, searchTerm,
showStartNewConversation, showStartNewConversation,
} = this.props; } = this.props;
@ -62,7 +71,7 @@ export class SearchResults extends React.Component<Props> {
<StartNewConversation <StartNewConversation
phoneNumber={searchTerm} phoneNumber={searchTerm}
i18n={i18n} i18n={i18n}
onClick={startNewConversation} onClick={this.handleStartNewConversation}
/> />
) : null} ) : null}
{haveConversations ? ( {haveConversations ? (

@ -7,7 +7,7 @@ import { LocalizerType } from '../types/Util';
export interface Props { export interface Props {
phoneNumber: string; phoneNumber: string;
i18n: LocalizerType; i18n: LocalizerType;
onClick: (id: string) => void; onClick: () => void;
} }
export class StartNewConversation extends React.PureComponent<Props> { export class StartNewConversation extends React.PureComponent<Props> {
@ -18,9 +18,7 @@ export class StartNewConversation extends React.PureComponent<Props> {
<div <div
role="button" role="button"
className="module-start-new-conversation" className="module-start-new-conversation"
onClick={() => { onClick={onClick}
onClick(phoneNumber);
}}
> >
<Avatar <Avatar
color="grey" color="grey"

@ -16,17 +16,19 @@ interface TimerOption {
} }
interface Props { interface Props {
i18n: LocalizerType;
isVerified: boolean;
name?: string;
id: string; id: string;
name?: string;
phoneNumber: string; phoneNumber: string;
profileName?: string; profileName?: string;
color: string; color: string;
avatarPath?: string; avatarPath?: string;
isVerified: boolean;
isMe: boolean; isMe: boolean;
isGroup: boolean; isGroup: boolean;
isArchived: boolean;
expirationSettingName?: string; expirationSettingName?: string;
showBackButton: boolean; showBackButton: boolean;
timerOptions: Array<TimerOption>; timerOptions: Array<TimerOption>;
@ -39,6 +41,11 @@ interface Props {
onShowAllMedia: () => void; onShowAllMedia: () => void;
onShowGroupMembers: () => void; onShowGroupMembers: () => void;
onGoBack: () => void; onGoBack: () => void;
onArchive: () => void;
onMoveToInbox: () => void;
i18n: LocalizerType;
} }
export class ConversationHeader extends React.Component<Props> { export class ConversationHeader extends React.Component<Props> {
@ -184,12 +191,15 @@ export class ConversationHeader extends React.Component<Props> {
i18n, i18n,
isMe, isMe,
isGroup, isGroup,
isArchived,
onDeleteMessages, onDeleteMessages,
onResetSession, onResetSession,
onSetDisappearingMessages, onSetDisappearingMessages,
onShowAllMedia, onShowAllMedia,
onShowGroupMembers, onShowGroupMembers,
onShowSafetyNumber, onShowSafetyNumber,
onArchive,
onMoveToInbox,
timerOptions, timerOptions,
} = this.props; } = this.props;
@ -223,6 +233,13 @@ export class ConversationHeader extends React.Component<Props> {
{!isGroup ? ( {!isGroup ? (
<MenuItem onClick={onResetSession}>{i18n('resetSession')}</MenuItem> <MenuItem onClick={onResetSession}>{i18n('resetSession')}</MenuItem>
) : null} ) : null}
{isArchived ? (
<MenuItem onClick={onMoveToInbox}>
{i18n('moveConversationToInbox')}
</MenuItem>
) : (
<MenuItem onClick={onArchive}>{i18n('archiveConversation')}</MenuItem>
)}
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem> <MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem>
</ContextMenu> </ContextMenu>
); );

@ -34,6 +34,7 @@ export type MessageType = {
export type ConversationType = { export type ConversationType = {
id: string; id: string;
name?: string; name?: string;
isArchived: boolean;
activeAt?: number; activeAt?: number;
timestamp: number; timestamp: number;
lastMessage?: { lastMessage?: {
@ -55,6 +56,7 @@ export type ConversationLookupType = {
export type ConversationsStateType = { export type ConversationsStateType = {
conversationLookup: ConversationLookupType; conversationLookup: ConversationLookupType;
selectedConversation?: string; selectedConversation?: string;
showArchived: boolean;
}; };
// Actions // Actions
@ -97,6 +99,14 @@ export type SelectedConversationChangedActionType = {
messageId?: string; messageId?: string;
}; };
}; };
type ShowInboxActionType = {
type: 'SHOW_INBOX';
payload: null;
};
type ShowArchivedConversationsActionType = {
type: 'SHOW_ARCHIVED_CONVERSATIONS';
payload: null;
};
export type ConversationActionType = export type ConversationActionType =
| ConversationAddedActionType | ConversationAddedActionType
@ -104,7 +114,11 @@ export type ConversationActionType =
| ConversationRemovedActionType | ConversationRemovedActionType
| RemoveAllConversationsActionType | RemoveAllConversationsActionType
| MessageExpiredActionType | MessageExpiredActionType
| SelectedConversationChangedActionType; | SelectedConversationChangedActionType
| MessageExpiredActionType
| SelectedConversationChangedActionType
| ShowInboxActionType
| ShowArchivedConversationsActionType;
// Action Creators // Action Creators
@ -116,6 +130,8 @@ export const actions = {
messageExpired, messageExpired,
openConversationInternal, openConversationInternal,
openConversationExternal, openConversationExternal,
showInbox,
showArchivedConversations,
}; };
function conversationAdded( function conversationAdded(
@ -156,6 +172,7 @@ function removeAllConversations(): RemoveAllConversationsActionType {
payload: null, payload: null,
}; };
} }
function messageExpired( function messageExpired(
id: string, id: string,
conversationId: string conversationId: string
@ -196,11 +213,25 @@ function openConversationExternal(
}; };
} }
function showInbox() {
return {
type: 'SHOW_INBOX',
payload: null,
};
}
function showArchivedConversations() {
return {
type: 'SHOW_ARCHIVED_CONVERSATIONS',
payload: null,
};
}
// Reducer // Reducer
function getEmptyState(): ConversationsStateType { function getEmptyState(): ConversationsStateType {
return { return {
conversationLookup: {}, conversationLookup: {},
showArchived: false,
}; };
} }
@ -225,27 +256,38 @@ export function reducer(
}, },
}; };
} }
if (action.type === 'SELECTED_CONVERSATION_CHANGED') {
const { payload } = action;
const { id } = payload;
return {
...state,
selectedConversation: id,
};
}
if (action.type === 'CONVERSATION_CHANGED') { if (action.type === 'CONVERSATION_CHANGED') {
const { payload } = action; const { payload } = action;
const { id, data } = payload; const { id, data } = payload;
const { conversationLookup } = state; const { conversationLookup } = state;
let showArchived = state.showArchived;
let selectedConversation = state.selectedConversation;
const existing = conversationLookup[id];
// In the change case we only modify the lookup if we already had that conversation // In the change case we only modify the lookup if we already had that conversation
if (!conversationLookup[id]) { if (!existing) {
return state; return state;
} }
if (selectedConversation === id) {
// Archived -> Inbox: we go back to the normal inbox view
if (existing.isArchived && !data.isArchived) {
showArchived = false;
}
// Inbox -> Archived: no conversation is selected
// Note: With today's stacked converastions architecture, this can result in weird
// behavior - no selected conversation in the left pane, but a conversation show
// in the right pane.
if (!existing.isArchived && data.isArchived) {
selectedConversation = undefined;
}
}
return { return {
...state, ...state,
selectedConversation,
showArchived,
conversationLookup: { conversationLookup: {
...conversationLookup, ...conversationLookup,
[id]: data, [id]: data,
@ -268,6 +310,27 @@ export function reducer(
if (action.type === 'MESSAGE_EXPIRED') { if (action.type === 'MESSAGE_EXPIRED') {
// noop - for now this is only important for search // noop - for now this is only important for search
} }
if (action.type === 'SELECTED_CONVERSATION_CHANGED') {
const { payload } = action;
const { id } = payload;
return {
...state,
selectedConversation: id,
};
}
if (action.type === 'SHOW_INBOX') {
return {
...state,
showArchived: false,
};
}
if (action.type === 'SHOW_ARCHIVED_CONVERSATIONS') {
return {
...state,
showArchived: true,
};
}
return state; return state;
} }

@ -1,4 +1,3 @@
import { compact } from 'lodash';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { format } from '../../types/PhoneNumber'; import { format } from '../../types/PhoneNumber';
@ -29,6 +28,13 @@ export const getSelectedConversation = createSelector(
} }
); );
export const getShowArchived = createSelector(
getConversations,
(state: ConversationsStateType): boolean => {
return Boolean(state.showArchived);
}
);
function getConversationTitle( function getConversationTitle(
conversation: ConversationType, conversation: ConversationType,
options: { i18n: LocalizerType; ourRegionCode: string } options: { i18n: LocalizerType; ourRegionCode: string }
@ -83,37 +89,49 @@ export const getConversationComparator = createSelector(
_getConversationComparator _getConversationComparator
); );
export const _getLeftPaneList = ( export const _getLeftPaneLists = (
lookup: ConversationLookupType, lookup: ConversationLookupType,
comparator: (left: ConversationType, right: ConversationType) => number, comparator: (left: ConversationType, right: ConversationType) => number,
selectedConversation?: string selectedConversation?: string
): Array<ConversationType> => { ): {
conversations: Array<ConversationType>;
archivedConversations: Array<ConversationType>;
} => {
const values = Object.values(lookup); const values = Object.values(lookup);
const filtered = compact( const sorted = values.sort(comparator);
values.map(conversation => {
const conversations: Array<ConversationType> = [];
const archivedConversations: Array<ConversationType> = [];
const max = sorted.length;
for (let i = 0; i < max; i += 1) {
let conversation = sorted[i];
if (!conversation.activeAt) { if (!conversation.activeAt) {
return null; continue;
} }
if (selectedConversation === conversation.id) { if (selectedConversation === conversation.id) {
return { conversation = {
...conversation, ...conversation,
isSelected: true, isSelected: true,
}; };
} }
return conversation; if (conversation.isArchived) {
}) archivedConversations.push(conversation);
); } else {
conversations.push(conversation);
}
}
return filtered.sort(comparator); return { conversations, archivedConversations };
}; };
export const getLeftPaneList = createSelector( export const getLeftPaneLists = createSelector(
getConversationLookup, getConversationLookup,
getConversationComparator, getConversationComparator,
getSelectedConversation, getSelectedConversation,
_getLeftPaneList _getLeftPaneLists
); );
export const getMe = createSelector( export const getMe = createSelector(

@ -2,14 +2,16 @@ import { compact } from 'lodash';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { SearchStateType } from '../ducks/search';
import { SearchStateType } from '../ducks/search';
import { import {
getConversationLookup, getConversationLookup,
getSelectedConversation, getSelectedConversation,
} from './conversations'; } from './conversations';
import { ConversationLookupType } from '../ducks/conversations'; import { ConversationLookupType } from '../ducks/conversations';
import { getRegionCode } from './user';
export const getSearch = (state: StateType): SearchStateType => state.search; export const getSearch = (state: StateType): SearchStateType => state.search;
export const getQuery = createSelector( export const getQuery = createSelector(
@ -34,12 +36,14 @@ export const isSearching = createSelector(
export const getSearchResults = createSelector( export const getSearchResults = createSelector(
[ [
getSearch, getSearch,
getRegionCode,
getConversationLookup, getConversationLookup,
getSelectedConversation, getSelectedConversation,
getSelectedMessage, getSelectedMessage,
], ],
( (
state: SearchStateType, state: SearchStateType,
regionCode: string,
lookup: ConversationLookupType, lookup: ConversationLookupType,
selectedConversation?: string, selectedConversation?: string,
selectedMessage?: string selectedMessage?: string
@ -84,6 +88,7 @@ export const getSearchResults = createSelector(
return message; return message;
}), }),
regionCode: regionCode,
searchTerm: state.query, searchTerm: state.query,
showStartNewConversation: Boolean( showStartNewConversation: Boolean(
state.normalizedPhoneNumber && !lookup[state.normalizedPhoneNumber] state.normalizedPhoneNumber && !lookup[state.normalizedPhoneNumber]

@ -4,9 +4,9 @@ import { mapDispatchToProps } from '../actions';
import { LeftPane } from '../../components/LeftPane'; import { LeftPane } from '../../components/LeftPane';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { getQuery, getSearchResults, isSearching } from '../selectors/search'; import { getSearchResults, isSearching } from '../selectors/search';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { getLeftPaneList, getMe } from '../selectors/conversations'; import { getLeftPaneLists, getShowArchived } from '../selectors/conversations';
import { SmartMainHeader } from './MainHeader'; import { SmartMainHeader } from './MainHeader';
@ -17,12 +17,14 @@ const FilteredSmartMainHeader = SmartMainHeader as any;
const mapStateToProps = (state: StateType) => { const mapStateToProps = (state: StateType) => {
const showSearch = isSearching(state); const showSearch = isSearching(state);
const lists = showSearch ? undefined : getLeftPaneLists(state);
const searchResults = showSearch ? getSearchResults(state) : undefined;
return { return {
...lists,
searchResults,
showArchived: getShowArchived(state),
i18n: getIntl(state), i18n: getIntl(state),
me: getMe(state),
query: getQuery(state),
conversations: showSearch ? undefined : getLeftPaneList(state),
searchResults: showSearch ? getSearchResults(state) : undefined,
renderMainHeader: () => <FilteredSmartMainHeader />, renderMainHeader: () => <FilteredSmartMainHeader />,
}; };
}; };

@ -3,7 +3,7 @@ import { assert } from 'chai';
import { ConversationLookupType } from '../../../state/ducks/conversations'; import { ConversationLookupType } from '../../../state/ducks/conversations';
import { import {
_getConversationComparator, _getConversationComparator,
_getLeftPaneList, _getLeftPaneLists,
} from '../../../state/selectors/conversations'; } from '../../../state/selectors/conversations';
describe('state/selectors/conversations', () => { describe('state/selectors/conversations', () => {
@ -11,13 +11,14 @@ describe('state/selectors/conversations', () => {
it('sorts conversations based on timestamp then by intl-friendly title', () => { it('sorts conversations based on timestamp then by intl-friendly title', () => {
const i18n = (key: string) => key; const i18n = (key: string) => key;
const regionCode = 'US'; const regionCode = 'US';
const conversations: ConversationLookupType = { const data: ConversationLookupType = {
id1: { id1: {
id: 'id1', id: 'id1',
activeAt: Date.now(), activeAt: Date.now(),
name: 'No timestamp', name: 'No timestamp',
timestamp: 0, timestamp: 0,
phoneNumber: 'notused', phoneNumber: 'notused',
isArchived: false,
type: 'direct', type: 'direct',
isMe: false, isMe: false,
@ -32,6 +33,7 @@ describe('state/selectors/conversations', () => {
name: 'B', name: 'B',
timestamp: 20, timestamp: 20,
phoneNumber: 'notused', phoneNumber: 'notused',
isArchived: false,
type: 'direct', type: 'direct',
isMe: false, isMe: false,
@ -46,6 +48,7 @@ describe('state/selectors/conversations', () => {
name: 'C', name: 'C',
timestamp: 20, timestamp: 20,
phoneNumber: 'notused', phoneNumber: 'notused',
isArchived: false,
type: 'direct', type: 'direct',
isMe: false, isMe: false,
@ -60,6 +63,7 @@ describe('state/selectors/conversations', () => {
name: 'Á', name: 'Á',
timestamp: 20, timestamp: 20,
phoneNumber: 'notused', phoneNumber: 'notused',
isArchived: false,
type: 'direct', type: 'direct',
isMe: false, isMe: false,
@ -74,6 +78,7 @@ describe('state/selectors/conversations', () => {
name: 'First!', name: 'First!',
timestamp: 30, timestamp: 30,
phoneNumber: 'notused', phoneNumber: 'notused',
isArchived: false,
type: 'direct', type: 'direct',
isMe: false, isMe: false,
@ -84,13 +89,13 @@ describe('state/selectors/conversations', () => {
}, },
}; };
const comparator = _getConversationComparator(i18n, regionCode); const comparator = _getConversationComparator(i18n, regionCode);
const list = _getLeftPaneList(conversations, comparator); const { conversations } = _getLeftPaneLists(data, comparator);
assert.strictEqual(list[0].name, 'First!'); assert.strictEqual(conversations[0].name, 'First!');
assert.strictEqual(list[1].name, 'Á'); assert.strictEqual(conversations[1].name, 'Á');
assert.strictEqual(list[2].name, 'B'); assert.strictEqual(conversations[2].name, 'B');
assert.strictEqual(list[3].name, 'C'); assert.strictEqual(conversations[3].name, 'C');
assert.strictEqual(list[4].name, 'No timestamp'); assert.strictEqual(conversations[4].name, 'No timestamp');
}); });
}); });
}); });

@ -164,7 +164,7 @@
"rule": "jQuery-load(", "rule": "jQuery-load(",
"path": "js/conversation_controller.js", "path": "js/conversation_controller.js",
"line": " async load() {", "line": " async load() {",
"lineNumber": 179, "lineNumber": 177,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-02T21:00:44.007Z" "updated": "2018-10-02T21:00:44.007Z"
}, },
@ -172,7 +172,7 @@
"rule": "jQuery-load(", "rule": "jQuery-load(",
"path": "js/conversation_controller.js", "path": "js/conversation_controller.js",
"line": " this._initialPromise = load();", "line": " this._initialPromise = load();",
"lineNumber": 214, "lineNumber": 212,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-02T21:00:44.007Z" "updated": "2018-10-02T21:00:44.007Z"
}, },
@ -562,7 +562,7 @@
"rule": "jQuery-append(", "rule": "jQuery-append(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " .append(this.networkStatusView.render().el);", "line": " .append(this.networkStatusView.render().el);",
"lineNumber": 89, "lineNumber": 88,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Interacting with already-existing DOM nodes"
@ -571,7 +571,7 @@
"rule": "jQuery-prependTo(", "rule": "jQuery-prependTo(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " banner.$el.prependTo(this.$el);", "line": " banner.$el.prependTo(this.$el);",
"lineNumber": 93, "lineNumber": 92,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Interacting with already-existing DOM nodes"
@ -580,7 +580,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);", "line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);",
"lineNumber": 164, "lineNumber": 166,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -589,7 +589,7 @@
"rule": "jQuery-append(", "rule": "jQuery-append(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);", "line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);",
"lineNumber": 164, "lineNumber": 166,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -598,7 +598,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " if (e && this.$(e.target).closest('.placeholder').length) {", "line": " if (e && this.$(e.target).closest('.placeholder').length) {",
"lineNumber": 205, "lineNumber": 207,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -607,7 +607,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " this.$('#header, .gutter').addClass('inactive');", "line": " this.$('#header, .gutter').addClass('inactive');",
"lineNumber": 209, "lineNumber": 211,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -616,7 +616,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " this.$('.conversation-stack').addClass('inactive');", "line": " this.$('.conversation-stack').addClass('inactive');",
"lineNumber": 213, "lineNumber": 215,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -625,7 +625,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " this.$('.conversation:first .menu').trigger('close');", "line": " this.$('.conversation:first .menu').trigger('close');",
"lineNumber": 215, "lineNumber": 217,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -634,7 +634,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {", "line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {",
"lineNumber": 230, "lineNumber": 236,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -643,7 +643,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " this.$('.conversation:first .recorder').trigger('close');", "line": " this.$('.conversation:first .recorder').trigger('close');",
"lineNumber": 233, "lineNumber": 239,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -5464,6 +5464,24 @@
"updated": "2019-03-09T00:08:44.242Z", "updated": "2019-03-09T00:08:44.242Z",
"reasonDetail": "Used only to set focus" "reasonDetail": "Used only to set focus"
}, },
{
"rule": "React-createRef",
"path": "ts/components/LeftPane.js",
"line": " this.listRef = react_1.default.createRef();",
"lineNumber": 13,
"reasonCategory": "usageTrusted",
"updated": "2019-03-12T23:33:50.889Z",
"reasonDetail": "Used only to scroll to top on archive/inbox switch"
},
{
"rule": "React-createRef",
"path": "ts/components/LeftPane.tsx",
"line": " public listRef: React.RefObject<any> = React.createRef();",
"lineNumber": 46,
"reasonCategory": "usageTrusted",
"updated": "2019-03-12T23:33:50.889Z",
"reasonDetail": "Used only to scroll to top on archive/inbox switch"
},
{ {
"rule": "React-createRef", "rule": "React-createRef",
"path": "ts/components/Lightbox.js", "path": "ts/components/Lightbox.js",
@ -5513,7 +5531,7 @@
"rule": "React-createRef", "rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.tsx", "path": "ts/components/conversation/ConversationHeader.tsx",
"line": " this.menuTriggerRef = React.createRef();", "line": " this.menuTriggerRef = React.createRef();",
"lineNumber": 51, "lineNumber": 58,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-09T00:08:44.242Z", "updated": "2019-03-09T00:08:44.242Z",
"reasonDetail": "Used only to trigger menu display" "reasonDetail": "Used only to trigger menu display"

Loading…
Cancel
Save