You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-desktop/ts/components/session/LeftPaneMessageSection.tsx

478 lines
14 KiB
TypeScript

import React from 'react';
import { AutoSizer, List } from 'react-virtualized';
import { MainViewController } from '../MainViewController';
import {
ConversationListItemProps,
MemoConversationListItemWithDetails,
} from '../ConversationListItem';
import {
openConversationWithMessages,
ReduxConversationType,
} from '../../state/ducks/conversations';
import { SearchResults, SearchResultsProps } from '../SearchResults';
import { SessionSearchInput } from './SessionSearchInput';
import { cleanSearchTerm } from '../../util/cleanSearchTerm';
import { RowRendererParamsType } from '../LeftPane';
import { SessionClosableOverlay, SessionClosableOverlayType } from './SessionClosableOverlay';
5 years ago
import { ContactType } from './SessionMemberListItem';
import { SessionButton, SessionButtonColor, SessionButtonType } from './SessionButton';
import { PubKey } from '../../session/types';
import { ToastUtils, UserUtils } from '../../session/utils';
import { LeftPaneSectionHeader } from './LeftPaneSectionHeader';
import { getConversationController } from '../../session/conversations';
import { ConversationTypeEnum } from '../../models/conversation';
import { openGroupV2CompleteURLRegex } from '../../opengroup/utils/OpenGroupUtils';
import { joinOpenGroupV2WithUIEvents } from '../../opengroup/opengroupV2/JoinOpenGroupV2';
import autoBind from 'auto-bind';
import { onsNameRegex } from '../../session/snode_api/SNodeAPI';
import { SNodeAPI } from '../../session/snode_api';
import { clearSearch, search, updateSearchTerm } from '../../state/ducks/search';
import _ from 'lodash';
import { MessageRequestsBanner } from './MessageRequestsBanner';
import { BlockedNumberController } from '../../util';
import { forceSyncConfigurationNowIfNeeded } from '../../session/utils/syncUtils';
export interface Props {
searchTerm: string;
contacts: Array<ReduxConversationType>;
conversations?: Array<ConversationListItemProps>;
searchResults?: SearchResultsProps;
4 years ago
messageRequestsEnabled?: boolean;
}
5 years ago
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 | SessionClosableOverlayType;
5 years ago
valuePasted: string;
}
export class LeftPaneMessageSection extends React.Component<Props, State> {
private readonly debouncedSearch: (searchTerm: string) => void;
public constructor(props: Props) {
super(props);
5 years ago
this.state = {
loading: false,
overlay: false,
valuePasted: '',
};
autoBind(this);
this.debouncedSearch = _.debounce(this.search.bind(this), 20);
}
public renderRow = ({ index, key, style }: RowRendererParamsType): JSX.Element | null => {
const { conversations } = this.props;
//assume conversations that have been marked unapproved should be filtered out by selector.
if (!conversations) {
throw new Error('renderRow: Tried to render without conversations');
}
4 years ago
const conversation = conversations[index];
if (!conversation) {
4 years ago
throw new Error('renderRow: conversations selector returned element containing falsy value.');
return null;
}
return <MemoConversationListItemWithDetails key={key} style={style} {...conversation} />;
};
public renderList(): JSX.Element | Array<JSX.Element | null> {
const { conversations, searchResults } = this.props;
const contacts = searchResults?.contacts || [];
if (searchResults) {
return <SearchResults {...searchResults} contacts={contacts} />;
}
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 = (
<div className="module-left-pane__list" key={listKey}>
<AutoSizer>
{({ height, width }) => (
<List
className="module-left-pane__virtual-list"
conversations={conversations}
height={height}
rowCount={length}
rowHeight={64}
rowRenderer={this.renderRow}
width={width}
autoHeight={false}
/>
)}
</AutoSizer>
</div>
);
return [list];
}
public closeOverlay({ pubKey }: { pubKey: string }) {
if (this.state.valuePasted === pubKey) {
this.setState({ overlay: false, valuePasted: '' });
}
}
5 years ago
public render(): JSX.Element {
5 years ago
const { overlay } = this.state;
return (
<div className="session-left-pane-section-content">
<LeftPaneSectionHeader buttonClicked={this.handleNewSessionButtonClick} />
{overlay ? this.renderClosableOverlay() : this.renderConversations()}
</div>
);
}
public renderConversations() {
return (
<div className="module-conversations-list-content">
5 years ago
<SessionSearchInput
searchString={this.props.searchTerm}
onChange={this.updateSearch}
placeholder={window.i18n('searchFor...')}
5 years ago
/>
{window.lokiFeatureFlags.useMessageRequests ? (
<MessageRequestsBanner handleOnClick={this.handleMessageRequestsClick} />
) : null}
5 years ago
{this.renderList()}
{this.renderBottomButtons()}
</div>
5 years ago
);
}
5 years ago
public updateSearch(searchTerm: string) {
if (!searchTerm) {
window.inboxStore?.dispatch(clearSearch());
return;
}
5 years ago
// reset our pubKeyPasted, we can either have a pasted sessionID or a sessionID got from a search
this.setState({ valuePasted: '' });
window.inboxStore?.dispatch(updateSearchTerm(searchTerm));
if (searchTerm.length < 2) {
return;
}
const cleanedTerm = cleanSearchTerm(searchTerm);
if (!cleanedTerm) {
return;
}
this.debouncedSearch(cleanedTerm);
}
public clearSearch() {
window.inboxStore?.dispatch(clearSearch());
}
public search() {
4 years ago
const { searchTerm } = this.props;
window.inboxStore?.dispatch(
search(searchTerm, {
noteToSelf: window.i18n('noteToSelf').toLowerCase(),
ourNumber: UserUtils.getOurPubKeyStrFromCache(),
})
);
}
private handleMessageRequestsClick() {
this.handleToggleOverlay(SessionClosableOverlayType.MessageRequests);
}
/**
* Blocks all message request conversations and synchronizes across linked devices
* @returns void
*/
private async handleBlockAllRequestsClick() {
4 years ago
const messageRequestsEnabled =
this.props.messageRequestsEnabled && window?.lokiFeatureFlags?.useMessageRequests;
if (!messageRequestsEnabled) {
return;
}
// block all convo requests. Force sync if there were changes.
window?.log?.info('Blocking all conversations');
const conversations = getConversationController().getConversations();
if (!conversations) {
window?.log?.info('No message requests to block.');
return;
}
const conversationRequests = conversations.filter(
c => c.isPrivate() && c.get('active_at') && c.get('isApproved')
);
let syncRequired = false;
if (!conversationRequests) {
window?.log?.info('No conversation requests to block.');
return;
}
await Promise.all(
conversationRequests.map(async convo => {
await BlockedNumberController.block(convo.id);
syncRequired = true;
})
);
if (syncRequired) {
await forceSyncConfigurationNowIfNeeded();
}
}
private renderClosableOverlay() {
const { searchTerm, searchResults } = this.props;
const { loading, overlay } = this.state;
5 years ago
const openGroupElement = (
<SessionClosableOverlay
overlayMode={SessionClosableOverlayType.OpenGroup}
5 years ago
onChangeSessionID={this.handleOnPaste}
onCloseClick={() => {
this.handleToggleOverlay(undefined);
}}
onButtonClick={this.handleJoinChannelButtonClick}
searchTerm={searchTerm}
updateSearch={this.updateSearch}
5 years ago
showSpinner={loading}
/>
);
const closedGroupElement = (
5 years ago
<SessionClosableOverlay
contacts={this.props.contacts}
overlayMode={SessionClosableOverlayType.ClosedGroup}
5 years ago
onChangeSessionID={this.handleOnPaste}
onCloseClick={() => {
this.handleToggleOverlay(undefined);
}}
onButtonClick={async (groupName: string, groupMembers: Array<ContactType>) =>
this.onCreateClosedGroup(groupName, groupMembers)
}
5 years ago
searchTerm={searchTerm}
updateSearch={this.updateSearch}
5 years ago
showSpinner={loading}
/>
);
const messageElement = (
5 years ago
<SessionClosableOverlay
overlayMode={SessionClosableOverlayType.Message}
5 years ago
onChangeSessionID={this.handleOnPaste}
onCloseClick={() => {
this.handleToggleOverlay(undefined);
}}
5 years ago
onButtonClick={this.handleMessageButtonClick}
searchTerm={searchTerm}
searchResults={searchResults}
showSpinner={loading}
updateSearch={this.updateSearch}
5 years ago
/>
);
5 years ago
const messageRequestsElement = (
<SessionClosableOverlay
overlayMode={SessionClosableOverlayType.MessageRequests}
onCloseClick={() => {
this.handleToggleOverlay(undefined);
}}
onButtonClick={this.handleBlockAllRequestsClick}
/>
);
5 years ago
let overlayElement;
switch (overlay) {
case SessionClosableOverlayType.OpenGroup:
5 years ago
overlayElement = openGroupElement;
break;
case SessionClosableOverlayType.ClosedGroup:
5 years ago
overlayElement = closedGroupElement;
break;
case SessionClosableOverlayType.Message:
5 years ago
overlayElement = messageElement;
break;
case SessionClosableOverlayType.MessageRequests:
overlayElement = messageRequestsElement;
break;
default:
overlayElement = false;
5 years ago
}
return overlayElement;
}
5 years ago
private renderBottomButtons(): JSX.Element {
const joinOpenGroup = window.i18n('joinOpenGroup');
const newClosedGroup = window.i18n('newClosedGroup');
5 years ago
return (
<div className="left-pane-contact-bottom-buttons">
<SessionButton
text={joinOpenGroup}
buttonType={SessionButtonType.SquareOutline}
buttonColor={SessionButtonColor.Green}
onClick={() => {
this.handleToggleOverlay(SessionClosableOverlayType.OpenGroup);
5 years ago
}}
/>
<SessionButton
text={newClosedGroup}
5 years ago
buttonType={SessionButtonType.SquareOutline}
buttonColor={SessionButtonColor.White}
onClick={() => {
this.handleToggleOverlay(SessionClosableOverlayType.ClosedGroup);
5 years ago
}}
/>
</div>
);
}
private handleToggleOverlay(overlayType?: SessionClosableOverlayType) {
const overlayState = overlayType || false;
5 years ago
this.setState({ overlay: overlayState });
5 years ago
// empty our generalized searchedString (one for the whole app)
this.updateSearch('');
5 years ago
}
5 years ago
private handleOnPaste(value: string) {
this.setState({ valuePasted: value });
}
private async handleMessageButtonClick() {
5 years ago
if (!this.state.valuePasted && !this.props.searchTerm) {
ToastUtils.pushToastError('invalidPubKey', window.i18n('invalidNumberError')); // or ons name
return;
}
let pubkeyorOns: string;
pubkeyorOns = this.state.valuePasted || this.props.searchTerm;
pubkeyorOns = pubkeyorOns.trim();
const errorOnPubkey = PubKey.validateWithError(pubkeyorOns);
if (!errorOnPubkey) {
// this is a pubkey
await getConversationController().getOrCreateAndWait(
pubkeyorOns,
ConversationTypeEnum.PRIVATE
);
await openConversationWithMessages({ conversationKey: pubkeyorOns });
this.handleToggleOverlay(undefined);
} else {
// this might be an ONS, validate the regex first
const mightBeOnsName = new RegExp(onsNameRegex, 'g').test(pubkeyorOns);
if (!mightBeOnsName) {
ToastUtils.pushToastError('invalidPubKey', window.i18n('invalidNumberError'));
return;
}
this.setState({ loading: true });
try {
const resolvedSessionID = await SNodeAPI.getSessionIDForOnsName(pubkeyorOns);
if (PubKey.validateWithError(resolvedSessionID)) {
throw new Error('Got a resolved ONS but the returned entry is not a vlaid SessionID');
}
// this is a pubkey
await getConversationController().getOrCreateAndWait(
resolvedSessionID,
ConversationTypeEnum.PRIVATE
);
await openConversationWithMessages({ conversationKey: resolvedSessionID });
this.handleToggleOverlay(undefined);
} catch (e) {
window?.log?.warn('failed to resolve ons name', pubkeyorOns, e);
ToastUtils.pushToastError('invalidPubKey', window.i18n('failedResolveOns'));
} finally {
this.setState({ loading: false });
}
}
}
private async handleOpenGroupJoinV2(serverUrlV2: string) {
const loadingCallback = (loading: boolean) => {
this.setState({ loading });
};
const joinSuccess = await joinOpenGroupV2WithUIEvents(
serverUrlV2,
true,
false,
loadingCallback
);
return joinSuccess;
}
private async handleJoinChannelButtonClick(serverUrl: string) {
const { loading } = this.state;
if (loading) {
return;
}
// guess if this is an open
if (serverUrl.match(openGroupV2CompleteURLRegex)) {
const groupCreated = await this.handleOpenGroupJoinV2(serverUrl);
if (groupCreated) {
this.handleToggleOverlay(undefined);
}
} else {
Open group regex fixes (#2058) * Open group URL regex fixes - Capital letters in room tokens were not being accepted (it eventually gets lower-cased internally, which works fine, but that happens *after* the URL is tested for acceptability). - `-` in room was not being allowed (it is and always has been on SOGS, session-android, and session-ios). - single-letter room ids are valid, but only 2+ letter ids were being accepted. - complete URL regex wasn't anchored so something like `garbagehttps://example.com/room?public_key=<64hex>moregarbage` was being accepted in the GUI input (it fails later when other code tries to parse it as a URL). - removed `m` modifier from open group regex: without anchors it wasn't doing anything anyway, but *with* anchors it would still allow leading/trailing garbage if delineated by newlines. - public key regex was accepting g-z letters, and not accepting A-F. - various regex cleanups: - use non-capture groups (?:...) rather than capturing groups (...) - avoid repetition in host segment matching - tightened up host pattern matching a bit: - DNS host segments have a max length of 63 - Limit port max length to 5, and disallow starting with 0 * Show an error when the open group URL is invalid It's quite disconcerting when you have a bad open group URL and try to add it and the join button just "doesn't work" without any feedback at all. Fix it to show an error message. (There is already an i18n entry for this because this same message is thrown if the URL can't be parsed later on).
4 years ago
ToastUtils.pushToastError('invalidOpenGroupUrl', window.i18n('invalidOpenGroupUrl'));
window.log.warn('Invalid opengroupv2 url');
}
}
private async onCreateClosedGroup(groupName: string, groupMembers: Array<ContactType>) {
if (this.state.loading) {
window?.log?.warn('Closed group creation already in progress');
return;
}
this.setState({ loading: true }, async () => {
const groupCreated = await MainViewController.createClosedGroup(groupName, groupMembers);
if (groupCreated) {
this.handleToggleOverlay(undefined);
}
this.setState({ loading: false });
});
5 years ago
}
private handleNewSessionButtonClick() {
this.handleToggleOverlay(SessionClosableOverlayType.Message);
}
}