From c44437f62c7dd5805a55d6c788e74d579bc3f2f6 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Wed, 8 Jan 2020 16:59:53 +1100 Subject: [PATCH] add channel section and refactor closableOverlay --- _locales/en/messages.json | 23 +- .../_session_theme_dark_left_pane.scss | 5 + ts/components/LeftPane.tsx | 118 ++---- ts/components/session/ActionsPanel.tsx | 10 +- .../session/LeftPaneChannelSection.tsx | 400 ++++++++++++++++++ .../session/LeftPaneContactSection.tsx | 15 +- .../session/LeftPaneMessageSection.tsx | 30 +- ts/components/session/RegistrationTabs.tsx | 3 +- .../session/SessionClosableOverlay.tsx | 131 ++++++ 9 files changed, 619 insertions(+), 116 deletions(-) create mode 100644 ts/components/session/LeftPaneChannelSection.tsx create mode 100644 ts/components/session/SessionClosableOverlay.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index cf1bfd6a1..b489119d7 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -748,7 +748,7 @@ "message": "Loki Messenger" }, "search": { - "message": "Enter name or public key", + "message": "Search", "description": "Placeholder text in the search input" }, "noSearchResults": { @@ -2550,5 +2550,26 @@ "example": "26" } } + }, + "channels": { + "message": "Channels" + }, + "addChannel": { + "message": "Add channel" + }, + "enterChannelURL": { + "message": "Enter Channel URL" + }, + "channelUrlPlaceholder": { + "message": "https://yourchannel.lokinet.org" + }, + "addChannelDescription": { + "message": "Enter the URL of the public channel you'd like to join in the format above." + }, + "joinChannel": { + "message": "Join Channel" + }, + "next": { + "message": "Next" } } diff --git a/stylesheets/_session_theme_dark_left_pane.scss b/stylesheets/_session_theme_dark_left_pane.scss index ae62fac10..69c5be6b8 100644 --- a/stylesheets/_session_theme_dark_left_pane.scss +++ b/stylesheets/_session_theme_dark_left_pane.scss @@ -211,6 +211,9 @@ $session-compose-margin: 20px; } } } +.module-search-results { + flex-grow: 1; +} .module-conversations-list-content { overflow-x: hidden; @@ -395,6 +398,8 @@ $session-compose-margin: 20px; & > div { display: block; } + flex-shrink: 0; + @include session-dark-background; &.active { background-color: $session-shade-9; diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 8e9ce3281..d9ff7fc2a 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { MainViewController } from './MainViewController'; import { ActionsPanel, SectionType } from './session/ActionsPanel'; import { LeftPaneMessageSection } from './session/LeftPaneMessageSection'; @@ -9,21 +8,11 @@ import { PropsData as ConversationListItemPropsType } from './ConversationListIt import { PropsData as SearchResultsProps } from './SearchResults'; import { SearchOptions } from '../types/Search'; import { LeftPaneSectionHeader } from './session/LeftPaneSectionHeader'; -import { - SessionIconButton, - SessionIconSize, - SessionIconType, -} from './session/icon'; -import { SessionIdEditable } from './session/SessionIdEditable'; -import { UserSearchDropdown } from './session/UserSearchDropdown'; -import { - SessionButton, - SessionButtonColor, - SessionButtonType, -} from './session/SessionButton'; + import { ConversationType } from '../state/ducks/conversations'; import { LeftPaneContactSection } from './session/LeftPaneContactSection'; import { LeftPaneSettingSection } from './session/LeftPaneSettingSection'; +import { LeftPaneChannelSection } from './session/LeftPaneChannelSection'; // from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5 export type RowRendererParamsType = { @@ -86,81 +75,6 @@ export class LeftPane extends React.Component { ); } - public static RENDER_CLOSABLE_OVERLAY( - isAddContactView: boolean, - onChangeSessionID: any, - onCloseClick: any, - onButtonClick: any, - searchTerm: string, - searchResults?: any, - updateSearch?: any - ): JSX.Element { - const title = isAddContactView - ? window.i18n('addContact') - : window.i18n('enterRecipient'); - const buttonText = isAddContactView - ? window.i18n('addContact') - : window.i18n('message'); - const ourSessionID = window.textsecure.storage.user.getNumber(); - - return ( -
-
- -
-

{title}

-

{window.i18n('enterSessionID')}

-
-
-
-
- - -
- {window.i18n('usersCanShareTheir...')} -
- {isAddContactView ||

{window.i18n('or')}

} - - {isAddContactView || ( - - )} - - {isAddContactView && ( -
- {window.i18n('yourPublicKey')} -
- )} - - {isAddContactView && ( - - )} - -
- ); - } - public handleSectionSelected(section: SectionType) { this.props.clearSearch(); this.setState({ selectedSection: section }); @@ -187,6 +101,8 @@ export class LeftPane extends React.Component { return this.renderMessageSection(); case SectionType.Contact: return this.renderContactSection(); + case SectionType.Channel: + return this.renderChannelSection(); case SectionType.Settings: return this.renderSettingSection(); case SectionType.Moon: @@ -259,4 +175,30 @@ export class LeftPane extends React.Component { private renderSettingSection() { return ; } + + private renderChannelSection() { + const { + openConversationInternal, + conversations, + searchResults, + searchTerm, + isSecondaryDevice, + updateSearchTerm, + search, + clearSearch, + } = this.props; + + return ( + + ); + } } diff --git a/ts/components/session/ActionsPanel.tsx b/ts/components/session/ActionsPanel.tsx index 93d4be53a..d764f448a 100644 --- a/ts/components/session/ActionsPanel.tsx +++ b/ts/components/session/ActionsPanel.tsx @@ -7,7 +7,7 @@ export enum SectionType { Profile, Message, Contact, - Globe, + Channel, Settings, Moon, } @@ -81,7 +81,7 @@ const Section = ({ case SectionType.Contact: iconType = SessionIconType.Users; break; - case SectionType.Globe: + case SectionType.Channel: iconType = SessionIconType.Globe; break; case SectionType.Settings: @@ -147,7 +147,7 @@ export class ActionsPanel extends React.Component { const isProfilePageSelected = selectedSection === SectionType.Profile; const isMessagePageSelected = selectedSection === SectionType.Message; const isContactPageSelected = selectedSection === SectionType.Contact; - const isGlobePageSelected = selectedSection === SectionType.Globe; + const isChannelPageSelected = selectedSection === SectionType.Channel; const isSettingsPageSelected = selectedSection === SectionType.Settings; const isMoonPageSelected = selectedSection === SectionType.Moon; @@ -172,8 +172,8 @@ export class ActionsPanel extends React.Component { notificationCount={receivedFriendRequestCount} />
; + + searchResults?: SearchResultsProps; + + updateSearchTerm: (searchTerm: string) => void; + search: (query: string, options: SearchOptions) => void; + openConversationInternal: (id: string, messageId?: string) => void; + clearSearch: () => void; +} + + +interface State { + showAddChannelView: boolean; + channelUrlPasted: string; + loading: boolean; + connectSuccess: boolean; +} + +export class LeftPaneChannelSection extends React.Component { + private readonly updateSearchBound: (searchedString: string) => void; + private readonly debouncedSearch: (searchTerm: string) => void; + + public constructor(props: Props) { + super(props); + this.state = { + showAddChannelView: false, + channelUrlPasted: '', + loading: false, + connectSuccess: false, + }; + + this.handleOnPasteUrl = this.handleOnPasteUrl.bind(this); + this.handleJoinChannelButtonClick = this.handleJoinChannelButtonClick.bind(this); + this.handleToggleOverlay = this.handleToggleOverlay.bind(this); + this.updateSearchBound = this.updateSearch.bind(this); + this.debouncedSearch = debounce(this.search.bind(this), 20); + this.attemptConnection = this.attemptConnection.bind(this); + + } + + public componentWillUnmount() { + this.updateSearch(''); + } + + public getCurrentConversations(): + | Array + | undefined { + const { conversations } = this.props; + + let conversationList = conversations; + if (conversationList !== undefined) { + conversationList = conversationList.filter( + // a channel is either a public group or a rss group + conversation => + conversation.type === 'group' && (conversation.isPublic || (conversation.lastMessage && conversation.lastMessage.isRss)) + ); + } + + return conversationList; + } + + + public renderRow = ({ + index, + key, + style, + }: RowRendererParamsType): JSX.Element => { + const { openConversationInternal } = this.props; + + const conversations = this.getCurrentConversations(); + + if (!conversations) { + throw new Error('renderRow: Tried to render without conversations'); + } + + const conversation = conversations[index]; + + return ( + + ); + }; + + public renderList(): JSX.Element | Array { + const { openConversationInternal, searchResults } = this.props; + + if (searchResults) { + return ( + + ); + } + + const conversations = this.getCurrentConversations(); + + if (!conversations) { + throw new Error( + 'render: must provided conversations if no search results are provided' + ); + } + + const length = conversations.length; + + // 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 renderHeader(): JSX.Element { + const labels = [window.i18n('channels')]; + + return LeftPane.RENDER_HEADER( + labels, + null + ); + } + + public render(): JSX.Element { + return ( +
+ {this.renderHeader()} + {this.state.showAddChannelView + ? this.renderClosableOverlay() + : this.renderGroups()} +
+ ); + } + + public renderGroups() { + + return ( +
+ + {this.renderList()} + {this.renderBottomButtons()} +
+ ); + } + + public updateSearch(searchTerm: string) { + const { updateSearchTerm, clearSearch } = this.props; + + if (!searchTerm) { + clearSearch(); + + return; + } + + this.setState({ channelUrlPasted: '' }, () => { + window.Session.emptyContentEditableDivs(); + }); + + 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 handleToggleOverlay() { + this.setState(prevState => ({ showAddChannelView: !prevState.showAddChannelView })); + } + + private renderClosableOverlay() { + const { searchTerm } = this.props; + const { loading } = this.state; + + return ( + + ); + } + + private renderBottomButtons(): JSX.Element { + const edit = window.i18n('edit'); + const addChannel = window.i18n('addChannel'); + + return ( +
+ + +
+ ); + } + + + private handleOnPasteUrl(e: any) { + if (e.target.innerHTML) { + const cleanText = e.target.innerHTML.replace(/<\/?[^>]+(>|$)/g, ''); + this.setState({ channelUrlPasted: cleanText }); + } + } + + private handleJoinChannelButtonClick() { + + const { loading, channelUrlPasted } = this.state; + + if (loading) { + + return false; + } + + if (channelUrlPasted.length <= 0) { + window.pushToast({ + title: window.i18n('noServerURL'), + type: 'error', + id: 'connectToServerFail', + }); + + return false; + } + + // TODO: Make this not hard coded + const channelId = 1; + this.setState({ loading: true }); + const connectionResult = this.attemptConnection(channelUrlPasted, channelId); + + // Give 5s maximum for promise to revole. Else, throw error. + const maxConnectionDuration = 5000; + const connectionTimeout = setTimeout(() => { + if (!this.state.connectSuccess) { + this.setState({ loading: false }); + window.pushToast({ + title: window.i18n('connectToServerFail'), + type: 'error', + id: 'connectToServerFail', + }); + + return; + } + }, maxConnectionDuration); + + connectionResult + .then(() => { + clearTimeout(connectionTimeout); + + if (this.state.loading) { + this.setState({ + connectSuccess: true, + loading: false, + }); + window.pushToast({ + title: window.i18n('connectToServerSuccess'), + id: 'connectToServerSuccess', + type: 'success', + }); + this.handleToggleOverlay(); + } + }) + .catch((connectionError: string) => { + clearTimeout(connectionTimeout); + this.setState({ + connectSuccess: true, + loading: false, + }); + window.pushToast({ + title: connectionError, + id: 'connectToServerFail', + type: 'error', + }); + + return false; + }); + + return true; + } + + + private async attemptConnection(serverURL: string, channelId: number) { + const rawserverURL = serverURL + .replace(/^https?:\/\//i, '') + .replace(/[/\\]+$/i, ''); + const sslServerURL = `https://${rawserverURL}`; + const conversationId = `publicChat:${channelId}@${rawserverURL}`; + + const conversationExists = window.ConversationController.get( + conversationId + ); + if (conversationExists) { + // We are already a member of this public chat + return new Promise((_resolve, reject) => { + reject(window.i18n('publicChatExists')); + }); + } + + const serverAPI = await window.lokiPublicChatAPI.findOrCreateServer( + sslServerURL + ); + if (!serverAPI) { + // Url incorrect or server not compatible + return new Promise((_resolve, reject) => { + reject(window.i18n('connectToServerFail')); + }); + } + + const conversation = await window.ConversationController.getOrCreateAndWait( + conversationId, + 'group' + ); + + await serverAPI.findOrCreateChannel(channelId, conversationId); + await conversation.setPublicSource(sslServerURL, channelId); + await conversation.setFriendRequestStatus( + window.friends.friendRequestStatusEnum.friends + ); + + return conversation; + } +} diff --git a/ts/components/session/LeftPaneContactSection.tsx b/ts/components/session/LeftPaneContactSection.tsx index 61830000b..27320735e 100644 --- a/ts/components/session/LeftPaneContactSection.tsx +++ b/ts/components/session/LeftPaneContactSection.tsx @@ -17,6 +17,7 @@ import { import { AutoSizer, List } from 'react-virtualized'; import { validateNumber } from '../../types/PhoneNumber'; import { ConversationType } from '../../state/ducks/conversations'; +import { SessionClosableOverlay } from './SessionClosableOverlay'; export interface Props { searchTerm: string; @@ -96,13 +97,7 @@ export class LeftPaneContactSection extends React.Component {
{this.renderHeader()} {this.state.showAddContactView - ? LeftPane.RENDER_CLOSABLE_OVERLAY( - true, - this.handleRecipientSessionIDChanged, - this.handleToggleOverlay, - this.handleOnAddContact, - '' - ) + ? this.renderClosableOverlay() : this.renderContacts()}
); @@ -198,6 +193,12 @@ export class LeftPaneContactSection extends React.Component { } } + private renderClosableOverlay() { + return ( + + ); + } + private getCurrentFriends(): Array { const { friends } = this.props; diff --git a/ts/components/session/LeftPaneMessageSection.tsx b/ts/components/session/LeftPaneMessageSection.tsx index 9f8716146..408990571 100644 --- a/ts/components/session/LeftPaneMessageSection.tsx +++ b/ts/components/session/LeftPaneMessageSection.tsx @@ -17,6 +17,7 @@ import { cleanSearchTerm } from '../../util/cleanSearchTerm'; import { SearchOptions } from '../../types/Search'; import { validateNumber } from '../../types/PhoneNumber'; import { LeftPane, RowRendererParamsType } from '../LeftPane'; +import { SessionClosableOverlay } from './SessionClosableOverlay'; export interface Props { searchTerm: string; @@ -44,7 +45,7 @@ export class LeftPaneMessageSection extends React.Component { }; this.updateSearchBound = this.updateSearch.bind(this); - this.handleComposeClick = this.handleComposeClick.bind(this); + this.handleToggleOverlay = this.handleToggleOverlay.bind(this); this.handleOnPasteSessionID = this.handleOnPasteSessionID.bind(this); this.handleMessageButtonClick = this.handleMessageButtonClick.bind(this); this.debouncedSearch = debounce(this.search.bind(this), 20); @@ -152,7 +153,7 @@ export class LeftPaneMessageSection extends React.Component { labels, null, window.i18n('compose'), - this.handleComposeClick + this.handleToggleOverlay ); } @@ -163,15 +164,7 @@ export class LeftPaneMessageSection extends React.Component {
{this.renderHeader()} {this.state.showComposeView - ? LeftPane.RENDER_CLOSABLE_OVERLAY( - false, - this.handleOnPasteSessionID, - this.handleComposeClick, - this.handleMessageButtonClick, - this.props.searchTerm, - this.props.searchResults, - this.updateSearchBound - ) + ? this.renderClosableOverlay() : this.renderConversations()}
); @@ -221,7 +214,6 @@ export class LeftPaneMessageSection extends React.Component { public clearSearch() { this.props.clearSearch(); - //this.setFocus(); } public search() { @@ -238,7 +230,15 @@ export class LeftPaneMessageSection extends React.Component { } } - private handleComposeClick() { + private renderClosableOverlay() { + const { searchTerm, searchResults } = this.props; + + return ( + + ); + } + + private handleToggleOverlay() { this.setState((state: any) => { return { showComposeView: !state.showComposeView }; }); @@ -249,7 +249,9 @@ export class LeftPaneMessageSection extends React.Component { private handleOnPasteSessionID(e: any) { // reset our search, we can either have a pasted sessionID or a sessionID got from a search this.updateSearch(''); - this.setState({ pubKeyPasted: e.target.innerHTML }); + const cleanText = e.target.innerHTML.replace(/<\/?[^>]+(>|$)/g, ''); + + this.setState({ pubKeyPasted: cleanText }); } private handleMessageButtonClick() { diff --git a/ts/components/session/RegistrationTabs.tsx b/ts/components/session/RegistrationTabs.tsx index fed62801b..99b20bfdf 100644 --- a/ts/components/session/RegistrationTabs.tsx +++ b/ts/components/session/RegistrationTabs.tsx @@ -498,7 +498,8 @@ export class RegistrationTabs extends React.Component<{}, State> { private onSecondDeviceSessionIDChanged(e: any) { e.preventDefault(); - const hexEncodedPubKey = e.target.innerHTML; + const cleanText = e.target.innerHTML.replace(/<\/?[^>]+(>|$)/g, ''); + const hexEncodedPubKey = cleanText; this.setState({ primaryDevicePubKey: hexEncodedPubKey, }); diff --git a/ts/components/session/SessionClosableOverlay.tsx b/ts/components/session/SessionClosableOverlay.tsx new file mode 100644 index 000000000..92bb65566 --- /dev/null +++ b/ts/components/session/SessionClosableOverlay.tsx @@ -0,0 +1,131 @@ +import React from 'react'; + +import { + SessionIconButton, + SessionIconSize, + SessionIconType, + } from './icon'; +import { SessionIdEditable } from './SessionIdEditable'; +import { UserSearchDropdown } from './UserSearchDropdown'; +import { + SessionButton, + SessionButtonColor, + SessionButtonType, +} from './SessionButton'; +import { SessionSpinner } from './SessionSpinner'; + + +interface Props { + overlayMode: 'message' | 'contact' | 'channel'; + onChangeSessionID: any; + onCloseClick: any; + onButtonClick: any; + searchTerm?: string; + searchResults?: any; + updateSearch?: any; + showSpinner?: boolean; +} + + +export class SessionClosableOverlay extends React.Component { + + public constructor(props: Props) { + super(props); + } + + public render(): JSX.Element { + const { overlayMode, onCloseClick, onChangeSessionID, showSpinner, searchTerm, updateSearch, searchResults, onButtonClick } = this.props; + + const isAddContactView = overlayMode === 'contact'; + const isMessageView = overlayMode === 'message'; + // const isChannelView = overlayMode === 'channel'; + + let title; + let buttonText; + let descriptionLong; + let subtitle; + let placeholder; + switch (overlayMode) { + case 'message': + title = window.i18n('enterRecipient'); + buttonText = window.i18n('next'); + descriptionLong = window.i18n('usersCanShareTheir...'); + subtitle = window.i18n('enterSessionID'); + placeholder = window.i18n('pasteSessionIDRecipient'); + break; + case 'contact': + title = window.i18n('addContact'); + buttonText = window.i18n('addContact'); + descriptionLong = window.i18n('usersCanShareTheir...'); + subtitle = window.i18n('enterSessionID') + placeholder = window.i18n('pasteSessionIDRecipient'); + break; + case 'channel': + default: + title = window.i18n('addChannel'); + buttonText = window.i18n('joinChannel'); + descriptionLong = window.i18n('addChannelDescription'); + subtitle = window.i18n('enterChannelURL'); + placeholder = window.i18n('channelUrlPlaceholder'); + } + + const ourSessionID = window.textsecure.storage.user.getNumber(); + + return ( +
+
+ +
+

{title}

+

{subtitle}

+
+
+
+
+ + {showSpinner && } +
+ {descriptionLong} +
+ {isMessageView &&

{window.i18n('or')}

} + + {isMessageView && ( + + )} + + {isAddContactView && ( +
+ {window.i18n('yourPublicKey')} +
+ )} + + {isAddContactView && ( + + )} + +
+ ); + } +}