import React from 'react'; import { AutoSizer, List } from 'react-virtualized'; import { MainViewController } from '../MainViewController'; import { ConversationListItemWithDetails, PropsData as ConversationListItemPropsType, } from '../ConversationListItem'; import { ConversationType } from '../../state/ducks/conversations'; import { PropsData as SearchResultsProps, SearchResults, } from '../SearchResults'; import { SessionSearchInput } from './SessionSearchInput'; import { debounce } from 'lodash'; import { cleanSearchTerm } from '../../util/cleanSearchTerm'; import { SearchOptions } from '../../types/Search'; import { validateNumber } from '../../types/PhoneNumber'; import { LeftPane, RowRendererParamsType } from '../LeftPane'; import { SessionClosableOverlay, SessionClosableOverlayType, } from './SessionClosableOverlay'; import { SessionIconType } from './icon'; import { ContactType } from './SessionMemberListItem'; import { SessionButton, SessionButtonColor, SessionButtonType, } from './SessionButton'; import { OpenGroup } from '../../session/types'; import { ToastUtils } from '../../session/utils'; import { toast } from 'react-toastify'; import { SessionToast, SessionToastType } from './SessionToast'; export interface Props { searchTerm: string; isSecondaryDevice: boolean; contacts: Array; conversations?: Array; searchResults?: SearchResultsProps; updateSearchTerm: (searchTerm: string) => void; search: (query: string, options: SearchOptions) => void; openConversationInternal: (id: string, messageId?: string) => void; clearSearch: () => void; } export enum SessionComposeToType { Message = 'message', OpenGroup = 'open-group', ClosedGroup = 'closed-group', } export const SessionGroupType = { OpenGroup: SessionComposeToType.OpenGroup, ClosedGroup: SessionComposeToType.ClosedGroup, }; export type SessionGroupType = SessionComposeToType; interface State { loading: boolean; overlay: false | SessionComposeToType; valuePasted: string; } export class LeftPaneMessageSection extends React.Component { private readonly updateSearchBound: (searchedString: string) => void; private readonly debouncedSearch: (searchTerm: string) => void; public constructor(props: Props) { super(props); this.state = { loading: false, overlay: false, valuePasted: '', }; this.updateSearchBound = this.updateSearch.bind(this); this.handleOnPaste = this.handleOnPaste.bind(this); this.handleToggleOverlay = this.handleToggleOverlay.bind(this); this.handleMessageButtonClick = this.handleMessageButtonClick.bind(this); this.handleNewSessionButtonClick = this.handleNewSessionButtonClick.bind( this ); this.handleJoinChannelButtonClick = this.handleJoinChannelButtonClick.bind( this ); this.onCreateClosedGroup = this.onCreateClosedGroup.bind(this); this.renderClosableOverlay = this.renderClosableOverlay.bind(this); this.debouncedSearch = debounce(this.search.bind(this), 20); this.closeOverlay = this.closeOverlay.bind(this); } public componentDidMount() { MainViewController.renderMessageView(); window.Whisper.events.on('calculatingPoW', this.closeOverlay); } public componentDidUpdate() { MainViewController.renderMessageView(); } public componentWillUnmount() { this.updateSearch(''); window.Whisper.events.off('calculatingPoW', this.closeOverlay); } public renderRow = ({ index, key, style, }: RowRendererParamsType): JSX.Element => { const { conversations, openConversationInternal } = this.props; if (!conversations) { throw new Error('renderRow: Tried to render without conversations'); } const conversation = conversations[index]; return ( ); }; public renderList(): JSX.Element | Array { const { conversations, openConversationInternal, searchResults, } = this.props; const contacts = searchResults?.contacts || []; if (searchResults) { return ( ); } if (!conversations) { throw new Error( 'render: must provided conversations if no search results are provided' ); } const length = conversations.length; const listKey = 0; // 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 // on startup and scroll. const list = (
{({ height, width }) => ( )}
); return [list]; } public closeOverlay({ pubKey }: { pubKey: string }) { if (this.state.valuePasted === pubKey) { this.setState({ overlay: false, valuePasted: '' }); } } public renderHeader(): JSX.Element { const labels = [window.i18n('messagesHeader')]; return LeftPane.RENDER_HEADER( labels, null, undefined, SessionIconType.Plus, this.handleNewSessionButtonClick ); } public render(): JSX.Element { const { overlay } = this.state; return (
{this.renderHeader()} {overlay ? this.renderClosableOverlay(overlay) : this.renderConversations()}
); } public renderConversations() { return (
{this.renderList()} {this.renderBottomButtons()}
); } public updateSearch(searchTerm: string) { const { updateSearchTerm, clearSearch } = this.props; if (!searchTerm) { clearSearch(); return; } // reset our pubKeyPasted, we can either have a pasted sessionID or a sessionID got from a search this.setState({ valuePasted: '' }); if (updateSearchTerm) { updateSearchTerm(searchTerm); } if (searchTerm.length < 2) { return; } const cleanedTerm = cleanSearchTerm(searchTerm); if (!cleanedTerm) { return; } this.debouncedSearch(cleanedTerm); } public clearSearch() { this.props.clearSearch(); } public search() { const { search } = this.props; const { searchTerm, isSecondaryDevice } = this.props; if (search) { search(searchTerm, { noteToSelf: window.i18n('noteToSelf').toLowerCase(), ourNumber: window.textsecure.storage.user.getNumber(), regionCode: '', isSecondaryDevice, }); } } private renderClosableOverlay(overlay: SessionComposeToType) { const { searchTerm, searchResults } = this.props; const { loading } = this.state; const openGroupElement = ( { this.handleToggleOverlay(undefined); }} onButtonClick={this.handleJoinChannelButtonClick} searchTerm={searchTerm} updateSearch={this.updateSearchBound} showSpinner={loading} /> ); const closedGroupElement = ( { this.handleToggleOverlay(undefined); }} onButtonClick={async ( groupName: string, groupMembers: Array ) => this.onCreateClosedGroup(groupName, groupMembers)} searchTerm={searchTerm} updateSearch={this.updateSearchBound} showSpinner={loading} /> ); const messageElement = ( { this.handleToggleOverlay(undefined); }} onButtonClick={this.handleMessageButtonClick} searchTerm={searchTerm} searchResults={searchResults} updateSearch={this.updateSearchBound} /> ); let overlayElement; switch (overlay) { case SessionComposeToType.OpenGroup: overlayElement = openGroupElement; break; case SessionComposeToType.ClosedGroup: overlayElement = closedGroupElement; break; default: overlayElement = messageElement; } return overlayElement; } private renderBottomButtons(): JSX.Element { const joinOpenGroup = window.i18n('joinOpenGroup'); const newClosedGroup = window.i18n('newClosedGroup'); return (
{ this.handleToggleOverlay(SessionComposeToType.OpenGroup); }} /> { this.handleToggleOverlay(SessionComposeToType.ClosedGroup); }} />
); } private handleToggleOverlay(conversationType?: SessionComposeToType) { const overlayState = conversationType || false; this.setState({ overlay: overlayState }); // empty our generalized searchedString (one for the whole app) this.updateSearch(''); } private handleOnPaste(value: string) { this.setState({ valuePasted: value }); } private handleMessageButtonClick() { const { openConversationInternal } = this.props; if (!this.state.valuePasted && !this.props.searchTerm) { ToastUtils.pushToastError( 'invalidPubKey', window.i18n('invalidNumberError') ); return; } let pubkey: string; pubkey = this.state.valuePasted || this.props.searchTerm; pubkey = pubkey.trim(); const error = validateNumber(pubkey); if (!error) { openConversationInternal(pubkey); } else { ToastUtils.pushToastError('invalidPubKey', error); } } private async handleJoinChannelButtonClick(serverUrl: string) { const { loading } = this.state; if (loading) { return; } // Server URL valid? if (serverUrl.length === 0 || !OpenGroup.validate(serverUrl)) { ToastUtils.pushToastError( 'connectToServer', window.i18n('invalidOpenGroupUrl') ); return; } // Already connected? if (Boolean(await OpenGroup.getConversation(serverUrl))) { ToastUtils.pushToastError( 'publicChatExists', window.i18n('publicChatExists') ); return; } // Connect to server try { ToastUtils.pushToastInfo( 'connectingToServer', window.i18n('connectingToServer') ); this.setState({ loading: true }); await OpenGroup.join(serverUrl); if (await OpenGroup.serverExists(serverUrl)) { ToastUtils.pushToastSuccess( 'connectToServerSuccess', window.i18n('connectToServerSuccess') ); } else { throw new Error( 'Open group joined but the corresponding server does not exist' ); } this.setState({ loading: false }); const openGroupConversation = await OpenGroup.getConversation(serverUrl); if (openGroupConversation) { // if no errors happened, trigger a sync with just this open group // so our other devices joins it await window.textsecure.messaging.sendOpenGroupsSyncMessage( openGroupConversation ); } else { window.log.error( 'Joined an opengroup but did not find ther corresponding conversation' ); } this.handleToggleOverlay(undefined); } catch (e) { window.log.error('Failed to connect to server:', e); ToastUtils.pushToastError( 'connectToServerFail', window.i18n('connectToServerFail') ); this.setState({ loading: false }); } } private async onCreateClosedGroup( groupName: string, groupMembers: Array ) { await MainViewController.createClosedGroup(groupName, groupMembers, () => { this.handleToggleOverlay(undefined); }); } private handleNewSessionButtonClick() { this.handleToggleOverlay(SessionComposeToType.Message); } }