add channel section and refactor closableOverlay

This commit is contained in:
Audric Ackermann 2020-01-08 16:59:53 +11:00
parent 2335dbab93
commit c44437f62c
9 changed files with 619 additions and 116 deletions

View File

@ -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"
}
}

View File

@ -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;

View File

@ -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<Props, State> {
);
}
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 (
<div className="module-left-pane-overlay">
<div className="exit">
<SessionIconButton
iconSize={SessionIconSize.Small}
iconType={SessionIconType.Exit}
onClick={onCloseClick}
/>
</div>
<h2>{title}</h2>
<h3>{window.i18n('enterSessionID')}</h3>
<div className="module-left-pane-overlay-border-container">
<hr className="white" />
<hr className="green" />
</div>
<SessionIdEditable
editable={true}
placeholder={window.i18n('pasteSessionIDRecipient')}
onChange={onChangeSessionID}
/>
<div className="session-description-long">
{window.i18n('usersCanShareTheir...')}
</div>
{isAddContactView || <h4>{window.i18n('or')}</h4>}
{isAddContactView || (
<UserSearchDropdown
searchTerm={searchTerm}
updateSearch={updateSearch}
placeholder={window.i18n('searchByIDOrDisplayName')}
searchResults={searchResults}
/>
)}
{isAddContactView && (
<div className="panel-text-divider">
<span>{window.i18n('yourPublicKey')}</span>
</div>
)}
{isAddContactView && (
<SessionIdEditable
editable={false}
placeholder=""
text={ourSessionID}
/>
)}
<SessionButton
buttonColor={SessionButtonColor.Green}
buttonType={SessionButtonType.BrandOutline}
text={buttonText}
onClick={onButtonClick}
/>
</div>
);
}
public handleSectionSelected(section: SectionType) {
this.props.clearSearch();
this.setState({ selectedSection: section });
@ -187,6 +101,8 @@ export class LeftPane extends React.Component<Props, State> {
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<Props, State> {
private renderSettingSection() {
return <LeftPaneSettingSection />;
}
private renderChannelSection() {
const {
openConversationInternal,
conversations,
searchResults,
searchTerm,
isSecondaryDevice,
updateSearchTerm,
search,
clearSearch,
} = this.props;
return (
<LeftPaneChannelSection
openConversationInternal={openConversationInternal}
conversations={conversations}
searchResults={searchResults}
searchTerm={searchTerm}
isSecondaryDevice={isSecondaryDevice}
updateSearchTerm={updateSearchTerm}
search={search}
clearSearch={clearSearch}
/>
);
}
}

View File

@ -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<Props, State> {
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<Props, State> {
notificationCount={receivedFriendRequestCount}
/>
<Section
type={SectionType.Globe}
isSelected={isGlobePageSelected}
type={SectionType.Channel}
isSelected={isChannelPageSelected}
onSelect={this.handleSectionSelect}
/>
<Section

View File

@ -0,0 +1,400 @@
import React from 'react';
import { AutoSizer, List } from 'react-virtualized';
import {
ConversationListItem,
PropsData as ConversationListItemPropsType,
} from '../ConversationListItem';
import { LeftPane, RowRendererParamsType } from '../LeftPane';
import { SessionButton, SessionButtonColor, SessionButtonType } from './SessionButton';
import {
PropsData as SearchResultsProps,
SearchResults,
} from '../SearchResults';
import { SearchOptions } from '../../types/Search';
import { debounce } from 'lodash';
import { cleanSearchTerm } from '../../util/cleanSearchTerm';
import { SessionSearchInput } from './SessionSearchInput';
import { SessionClosableOverlay } from './SessionClosableOverlay';
export interface Props {
searchTerm: string;
isSecondaryDevice: boolean;
conversations?: Array<ConversationListItemPropsType>;
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<Props, State> {
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<ConversationListItemPropsType>
| 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 (
<ConversationListItem
key={key}
style={style}
{...conversation}
onClick={openConversationInternal}
i18n={window.i18n}
/>
);
};
public renderList(): JSX.Element | Array<JSX.Element | null> {
const { openConversationInternal, searchResults } = this.props;
if (searchResults) {
return (
<SearchResults
{...searchResults}
openConversation={openConversationInternal}
i18n={window.i18n}
/>
);
}
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 = (
<div className="module-left-pane__list" key={0}>
<AutoSizer>
{({ height, width }) => (
<List
className="module-left-pane__virtual-list"
conversations={conversations}
height={height}
rowCount={length}
rowHeight={64}
rowRenderer={this.renderRow}
width={width}
autoHeight={true}
/>
)}
</AutoSizer>
</div>
);
return [list];
}
public renderHeader(): JSX.Element {
const labels = [window.i18n('channels')];
return LeftPane.RENDER_HEADER(
labels,
null
);
}
public render(): JSX.Element {
return (
<div className="session-left-pane-section-content">
{this.renderHeader()}
{this.state.showAddChannelView
? this.renderClosableOverlay()
: this.renderGroups()}
</div>
);
}
public renderGroups() {
return (
<div className="module-conversations-list-content" >
<SessionSearchInput
searchString={this.props.searchTerm}
onChange={this.updateSearchBound}
placeholder={window.i18n('search')}
/>
{this.renderList()}
{this.renderBottomButtons()}
</div>
);
}
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 (
<SessionClosableOverlay overlayMode="channel" onChangeSessionID={this.handleOnPasteUrl} onCloseClick={this.handleToggleOverlay} onButtonClick={this.handleJoinChannelButtonClick} searchTerm={searchTerm} updateSearch={this.updateSearchBound} showSpinner={loading}/>
);
}
private renderBottomButtons(): JSX.Element {
const edit = window.i18n('edit');
const addChannel = window.i18n('addChannel');
return (
<div className="left-pane-contact-bottom-buttons">
<SessionButton
text={edit}
buttonType={SessionButtonType.SquareOutline}
buttonColor={SessionButtonColor.White}
/>
<SessionButton
text={addChannel}
buttonType={SessionButtonType.SquareOutline}
buttonColor={SessionButtonColor.Green}
onClick={this.handleToggleOverlay}
/>
</div>
);
}
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;
}
}

View File

@ -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<Props, State> {
<div className="left-pane-contact-section">
{this.renderHeader()}
{this.state.showAddContactView
? LeftPane.RENDER_CLOSABLE_OVERLAY(
true,
this.handleRecipientSessionIDChanged,
this.handleToggleOverlay,
this.handleOnAddContact,
''
)
? this.renderClosableOverlay()
: this.renderContacts()}
</div>
);
@ -198,6 +193,12 @@ export class LeftPaneContactSection extends React.Component<Props, State> {
}
}
private renderClosableOverlay() {
return (
<SessionClosableOverlay overlayMode="contact" onChangeSessionID={this.handleRecipientSessionIDChanged} onCloseClick={this.handleToggleOverlay} onButtonClick={this.handleOnAddContact} />
);
}
private getCurrentFriends(): Array<ConversationType> {
const { friends } = this.props;

View File

@ -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<Props, any> {
};
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<Props, any> {
labels,
null,
window.i18n('compose'),
this.handleComposeClick
this.handleToggleOverlay
);
}
@ -163,15 +164,7 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
<div className="session-left-pane-section-content">
{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()}
</div>
);
@ -221,7 +214,6 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
public clearSearch() {
this.props.clearSearch();
//this.setFocus();
}
public search() {
@ -238,7 +230,15 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
}
}
private handleComposeClick() {
private renderClosableOverlay() {
const { searchTerm, searchResults } = this.props;
return (
<SessionClosableOverlay overlayMode="message" onChangeSessionID={this.handleOnPasteSessionID} onCloseClick={this.handleToggleOverlay} onButtonClick={this.handleMessageButtonClick} searchTerm={searchTerm} searchResults={searchResults} updateSearch={this.updateSearchBound} />
);
}
private handleToggleOverlay() {
this.setState((state: any) => {
return { showComposeView: !state.showComposeView };
});
@ -249,7 +249,9 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
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() {

View File

@ -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,
});

View File

@ -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<Props> {
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 (
<div className="module-left-pane-overlay">
<div className="exit">
<SessionIconButton
iconSize={SessionIconSize.Small}
iconType={SessionIconType.Exit}
onClick={onCloseClick}
/>
</div>
<h2>{title}</h2>
<h3>{subtitle}</h3>
<div className="module-left-pane-overlay-border-container">
<hr className="white" />
<hr className="green" />
</div>
<SessionIdEditable
editable={true}
placeholder={placeholder}
onChange={onChangeSessionID}
/>
{showSpinner && <SessionSpinner />}
<div className="session-description-long">
{descriptionLong}
</div>
{isMessageView && <h4>{window.i18n('or')}</h4>}
{isMessageView && (
<UserSearchDropdown
searchTerm={searchTerm || ''}
updateSearch={updateSearch}
placeholder={window.i18n('searchByIDOrDisplayName')}
searchResults={searchResults}
/>
)}
{isAddContactView && (
<div className="panel-text-divider">
<span>{window.i18n('yourPublicKey')}</span>
</div>
)}
{isAddContactView && (
<SessionIdEditable
editable={false}
placeholder=""
text={ourSessionID}
/>
)}
<SessionButton
buttonColor={SessionButtonColor.Green}
buttonType={SessionButtonType.BrandOutline}
text={buttonText}
onClick={onButtonClick}
/>
</div>
);
}
}