cleanup SessionCompositionBox

This commit is contained in:
Audric Ackermann 2021-11-08 11:03:08 +11:00
parent 3741e96c61
commit f91ed7729b
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4
23 changed files with 316 additions and 340 deletions

View File

@ -128,7 +128,7 @@ const SelectionOverlay = () => {
const TripleDotsMenu = (props: { triggerId: string; showBackButton: boolean }) => {
const { showBackButton } = props;
if (showBackButton) {
return <></>;
return null;
}
return (
<div

View File

@ -157,7 +157,7 @@ export const QuoteGenericFile = (
const { attachment, isIncoming } = props;
if (!attachment) {
return <></>;
return null;
}
const { fileName, contentType } = attachment;
@ -167,7 +167,7 @@ export const QuoteGenericFile = (
!MIME.isAudio(contentType);
if (!isGenericFile) {
return <></>;
return null;
}
return (

View File

@ -22,7 +22,7 @@ export const StagedLinkPreview = (props: Props) => {
const isImage = image && isImageAttachment(image);
if (isLoaded && !(title && domain)) {
return <></>;
return null;
}
const isLoading = !isLoaded;

View File

@ -26,7 +26,7 @@ const TypingBubbleContainer = styled.div<TypingBubbleProps>`
export const TypingBubble = (props: TypingBubbleProps) => {
if (props.conversationType === ConversationTypeEnum.GROUP) {
return <></>;
return null;
}
if (!props.isTyping) {

View File

@ -193,7 +193,7 @@ export class UpdateGroupMembersDialog extends React.Component<Props, State> {
const { zombies } = this.state;
if (!zombies.length) {
return <></>;
return null;
}
const zombieElements = zombies.map((member: ContactType, index: number) => {

View File

@ -274,7 +274,7 @@ export const ActionsPanel = () => {
if (!ourPrimaryConversation) {
window?.log?.warn('ActionsPanel: ourPrimaryConversation is not set');
return <></>;
return null;
}
useInterval(() => {

View File

@ -50,7 +50,7 @@ export class SessionInboxView extends React.Component<any, State> {
public render() {
if (!this.state.isInitialLoadComplete) {
return <></>;
return null;
}
const persistor = persistStore(this.store);

View File

@ -162,7 +162,7 @@ export const SessionJoinableRooms = (props: { onRoomClicked: () => void }) => {
if (!joinableRooms.inProgress && !joinableRooms.rooms?.length) {
window?.log?.info('no default joinable rooms yet and not in progress');
return <></>;
return null;
}
const componentToRender = joinableRooms.inProgress ? (

View File

@ -4,9 +4,9 @@ import classNames from 'classnames';
import {
SendMessageType,
SessionCompositionBox,
CompositionBox,
StagedAttachmentType,
} from './SessionCompositionBox';
} from './composition/CompositionBox';
import { Constants } from '../../../session';
import _ from 'lodash';
@ -41,7 +41,6 @@ import { SplitViewContainer } from '../SplitViewContainer';
// tslint:disable: jsx-curly-spacing
interface State {
showRecordingView: boolean;
isDraggingFile: boolean;
}
export interface LightBoxOptions {
@ -75,7 +74,6 @@ export class SessionConversation extends React.Component<Props, State> {
super(props);
this.state = {
showRecordingView: false,
isDraggingFile: false,
};
this.messageContainerRef = React.createRef();
@ -135,7 +133,6 @@ export class SessionConversation extends React.Component<Props, State> {
}
if (newConversationKey !== oldConversationKey) {
this.setState({
showRecordingView: false,
isDraggingFile: false,
});
}
@ -247,11 +244,9 @@ export class SessionConversation extends React.Component<Props, State> {
{isDraggingFile && <SessionFileDropzone />}
</div>
<SessionCompositionBox
<CompositionBox
sendMessage={this.sendMessageFn}
stagedAttachments={this.props.stagedAttachments}
onLoadVoiceNoteView={this.onLoadVoiceNoteView}
onExitVoiceNoteView={this.onExitVoiceNoteView}
onChoseAttachments={this.onChoseAttachments}
/>
</div>
@ -264,35 +259,12 @@ export class SessionConversation extends React.Component<Props, State> {
);
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~~~~~~~~~ MICROPHONE METHODS ~~~~~~~~~~~~
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
private onLoadVoiceNoteView() {
this.setState({
showRecordingView: true,
});
window.inboxStore?.dispatch(resetSelectedMessageIds());
}
private onExitVoiceNoteView() {
this.setState({
showRecordingView: false,
});
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~~~~~~~~ KEYBOARD NAVIGATION ~~~~~~~~~~~~
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
private onKeyDown(event: any) {
const selectionMode = !!this.props.selectedMessages.length;
const recordingMode = this.state.showRecordingView;
if (event.key === 'Escape') {
// EXIT MEDIA VIEW
if (recordingMode) {
// EXIT RECORDING VIEW
}
// EXIT WHAT ELSE?
}
if (event.target.classList.contains('conversation-content')) {
switch (event.key) {
case 'Escape':

View File

@ -0,0 +1,19 @@
// keep this draft state local to not have to do a redux state update (a bit slow with our large state for some computers)
const draftsForConversations: Record<string, string> = {};
export function getDraftForConversation(conversationKey?: string) {
if (!conversationKey || !draftsForConversations[conversationKey]) {
return '';
}
return draftsForConversations[conversationKey] || '';
}
export function updateDraftForConversation({
conversationKey,
draft,
}: {
conversationKey: string;
draft: string;
}) {
draftsForConversations[conversationKey] = draft;
}

View File

@ -10,9 +10,9 @@ import MicRecorder from 'mic-recorder-to-mp3';
import styled from 'styled-components';
interface Props {
onExitVoiceNoteView: any;
onLoadVoiceNoteView: any;
sendVoiceMessage: any;
onExitVoiceNoteView: () => void;
onLoadVoiceNoteView: () => void;
sendVoiceMessage: (audioBlob: Blob) => Promise<void>;
}
interface State {
@ -24,13 +24,10 @@ interface State {
actionHover: boolean;
startTimestamp: number;
nowTimestamp: number;
updateTimerInterval: NodeJS.Timeout;
}
function getTimestamp(asInt = false) {
const timestamp = Date.now() / 1000;
return asInt ? Math.floor(timestamp) : timestamp;
function getTimestamp() {
return Date.now() / 1000;
}
interface StyledFlexWrapperProps {
@ -50,20 +47,16 @@ const StyledFlexWrapper = styled.div<StyledFlexWrapperProps>`
}
`;
class SessionRecordingInner extends React.Component<Props, State> {
private recorder: any;
export class SessionRecording extends React.Component<Props, State> {
private recorder?: any;
private audioBlobMp3?: Blob;
private audioElement?: HTMLAudioElement | null;
private updateTimerInterval?: NodeJS.Timeout;
constructor(props: Props) {
super(props);
autoBind(this);
// Refs
const now = getTimestamp();
const updateTimerInterval = global.setInterval(this.timerUpdate, 500);
this.state = {
recordDuration: 0,
@ -73,7 +66,6 @@ class SessionRecordingInner extends React.Component<Props, State> {
actionHover: false,
startTimestamp: now,
nowTimestamp: now,
updateTimerInterval,
};
}
@ -86,10 +78,13 @@ class SessionRecordingInner extends React.Component<Props, State> {
if (this.props.onLoadVoiceNoteView) {
this.props.onLoadVoiceNoteView();
}
this.updateTimerInterval = global.setInterval(this.timerUpdate, 500);
}
public componentWillUnmount() {
clearInterval(this.state.updateTimerInterval);
if (this.updateTimerInterval) {
clearInterval(this.updateTimerInterval);
}
}
// tslint:disable-next-line: cyclomatic-complexity
@ -276,7 +271,7 @@ class SessionRecordingInner extends React.Component<Props, State> {
return;
}
this.props.sendVoiceMessage(this.audioBlobMp3);
void this.props.sendVoiceMessage(this.audioBlobMp3);
}
private async initiateRecordingStream() {
@ -348,5 +343,3 @@ class SessionRecordingInner extends React.Component<Props, State> {
}
}
}
export const SessionRecording = SessionRecordingInner;

View File

@ -1,7 +1,7 @@
import React from 'react';
import { arrayBufferFromFile } from '../../../types/Attachment';
import { AttachmentUtil, LinkPreviewUtil } from '../../../util';
import { StagedLinkPreviewData } from './SessionCompositionBox';
import { StagedLinkPreviewData } from './composition/CompositionBox';
import { default as insecureNodeFetch } from 'node-fetch';
import { fetchLinkPreviewImage } from '../../../util/linkPreviewFetch';
import { AbortSignal } from 'abort-controller';
@ -107,7 +107,7 @@ export const getPreview = async (
export const SessionStagedLinkPreview = (props: StagedLinkPreviewProps) => {
if (!props.url) {
return <></>;
return null;
}
return (

View File

@ -1,56 +1,52 @@
import React from 'react';
import _, { debounce } from 'lodash';
import { AttachmentType } from '../../../types/Attachment';
import * as MIME from '../../../types/MIME';
import { AttachmentType } from '../../../../types/Attachment';
import * as MIME from '../../../../types/MIME';
import { SessionIconButton } from '../icon';
import { SessionEmojiPanel } from './SessionEmojiPanel';
import { SessionRecording } from './SessionRecording';
import { SessionEmojiPanel } from '../SessionEmojiPanel';
import { SessionRecording } from '../SessionRecording';
import { Constants } from '../../../session';
import { Constants } from '../../../../session';
import { toArray } from 'react-emoji-render';
import { Flex } from '../../basic/Flex';
import { StagedAttachmentList } from '../../conversation/StagedAttachmentList';
import { ToastUtils } from '../../../session/utils';
import { AttachmentUtil } from '../../../util';
import { Flex } from '../../../basic/Flex';
import { StagedAttachmentList } from '../../../conversation/StagedAttachmentList';
import { ToastUtils } from '../../../../session/utils';
import { AttachmentUtil } from '../../../../util';
import {
getPreview,
LINK_PREVIEW_TIMEOUT,
SessionStagedLinkPreview,
} from './SessionStagedLinkPreview';
} from '../SessionStagedLinkPreview';
import { AbortController } from 'abort-controller';
import { SessionQuotedMessageComposition } from './SessionQuotedMessageComposition';
import { SessionQuotedMessageComposition } from '../SessionQuotedMessageComposition';
import { Mention, MentionsInput } from 'react-mentions';
import { CaptionEditor } from '../../CaptionEditor';
import { getConversationController } from '../../../session/conversations';
import { ReduxConversationType } from '../../../state/ducks/conversations';
import { SessionMemberListItem } from '../SessionMemberListItem';
import { CaptionEditor } from '../../../CaptionEditor';
import { getConversationController } from '../../../../session/conversations';
import { ReduxConversationType } from '../../../../state/ducks/conversations';
import { SessionMemberListItem } from '../../SessionMemberListItem';
import autoBind from 'auto-bind';
import { getMediaPermissionsSettings, SessionSettingCategory } from '../settings/SessionSettings';
import { updateConfirmModal } from '../../../state/ducks/modalDialog';
import {
SectionType,
showLeftPaneSection,
showSettingsSection,
} from '../../../state/ducks/section';
import { SessionButtonColor } from '../SessionButton';
import {
createOrUpdateItem,
getItemById,
hasLinkPreviewPopupBeenDisplayed,
} from '../../../data/data';
import { getMediaPermissionsSettings } from '../../settings/SessionSettings';
import {
getIsTypingEnabled,
getMentionsInput,
getQuotedMessage,
getSelectedConversation,
getSelectedConversationKey,
} from '../../../state/selectors/conversations';
} from '../../../../state/selectors/conversations';
import { connect } from 'react-redux';
import { StateType } from '../../../state/reducer';
import { getTheme } from '../../../state/selectors/theme';
import { removeAllStagedAttachmentsInConversation } from '../../../state/ducks/stagedAttachments';
import { StateType } from '../../../../state/reducer';
import { getTheme } from '../../../../state/selectors/theme';
import { removeAllStagedAttachmentsInConversation } from '../../../../state/ducks/stagedAttachments';
import { getDraftForConversation, updateDraftForConversation } from '../SessionConversationDrafts';
import { showLinkSharingConfirmationModalDialog } from '../../../../interactions/conversationInteractions';
import {
AddStagedAttachmentButton,
StartRecordingButton,
ToggleEmojiButton,
SendMessageButton,
} from './CompositionButtons';
export interface ReplyingToMessageProps {
convoId: string;
@ -83,79 +79,11 @@ export type SendMessageType = {
groupInvitation: { url: string | undefined; name: string } | undefined;
};
const AddStagedAttachmentButton = (props: { onClick: () => void }) => {
return (
<SessionIconButton
iconType="plusThin"
backgroundColor={'var(--color-compose-view-button-background)'}
iconSize={'huge2'}
borderRadius="300px"
iconPadding="8px"
onClick={props.onClick}
/>
);
};
const StartRecordingButton = (props: { onClick: () => void }) => {
return (
<SessionIconButton
iconType="microphone"
iconSize={'huge2'}
backgroundColor={'var(--color-compose-view-button-background)'}
borderRadius="300px"
iconPadding="6px"
onClick={props.onClick}
/>
);
};
const ToggleEmojiButton = React.forwardRef<HTMLDivElement, { onClick: () => void }>(
(props, ref) => {
return (
<SessionIconButton
iconType="emoji"
ref={ref}
backgroundColor="var(--color-compose-view-button-background)"
iconSize={'huge2'}
borderRadius="300px"
iconPadding="6px"
onClick={props.onClick}
/>
);
}
);
const SendMessageButton = (props: { onClick: () => void }) => {
return (
<div className="send-message-button">
<SessionIconButton
iconType="send"
backgroundColor={'var(--color-compose-view-button-background)'}
iconSize={'huge2'}
iconRotation={90}
borderRadius="300px"
iconPadding="6px"
onClick={props.onClick}
/>
</div>
);
};
// keep this draft state local to not have to do a redux state update (a bit slow with our large state for soem computers)
const draftsForConversations: Array<{ conversationKey: string; draft: string }> = new Array();
function updateDraftForConversation(action: { conversationKey: string; draft: string }) {
const { conversationKey, draft } = action;
const foundAtIndex = draftsForConversations.findIndex(c => c.conversationKey === conversationKey);
foundAtIndex === -1
? draftsForConversations.push({ conversationKey, draft })
: (draftsForConversations[foundAtIndex] = action);
}
interface Props {
sendMessage: (msg: SendMessageType) => void;
onLoadVoiceNoteView: any;
onExitVoiceNoteView: any;
selectedConversationKey: string;
selectedConversation: ReduxConversationType | undefined;
typingEnabled: boolean;
quotedMessageProps?: ReplyingToMessageProps;
stagedAttachments: Array<StagedAttachmentType>;
onChoseAttachments: (newAttachments: Array<File>) => void;
@ -165,8 +93,7 @@ interface State {
showRecordingView: boolean;
draft: string;
showEmojiPanel: boolean;
voiceRecording?: Blob;
ignoredLink?: string; // set the the ignored url when users closed the link preview
ignoredLink?: string; // set the ignored url when users closed the link preview
stagedLinkPreview?: StagedLinkPreviewData;
showCaptionEditor?: AttachmentType;
}
@ -191,12 +118,10 @@ const sendMessageStyle = {
minHeight: '24px',
width: '100%',
};
const getDefaultState = (newConvoId?: string) => {
return {
draft:
(newConvoId && draftsForConversations.find(c => c.conversationKey === newConvoId)?.draft) ||
'',
voiceRecording: undefined,
draft: getDraftForConversation(newConvoId),
showRecordingView: false,
showEmojiPanel: false,
ignoredLink: undefined,
@ -205,14 +130,84 @@ const getDefaultState = (newConvoId?: string) => {
};
};
class SessionCompositionBoxInner extends React.Component<Props, State> {
function parseEmojis(value: string) {
const emojisArray = toArray(value);
// toArray outputs React elements for emojis and strings for other
return emojisArray.reduce((previous: string, current: any) => {
if (typeof current === 'string') {
return previous + current;
}
return previous + (current.props.children as string);
}, '');
}
const mentionsRegex = /@\uFFD205[0-9a-f]{64}\uFFD7[^\uFFD2]+\uFFD2/gu;
const getSelectionBasedOnMentions = (draft: string, index: number) => {
// we have to get the real selectionStart/end of an index in the mentions box.
// this is kind of a pain as the mentions box has two inputs, one with the real text, and one with the extracted mentions
// the index shown to the user is actually just the visible part of the mentions (so the part between ᅲ...ᅭ
const matches = draft.match(mentionsRegex);
let lastMatchStartIndex = 0;
let lastMatchEndIndex = 0;
let lastRealMatchEndIndex = 0;
if (!matches) {
return index;
}
const mapStartToLengthOfMatches = matches.map(match => {
const displayNameStart = match.indexOf('\uFFD7') + 1;
const displayNameEnd = match.lastIndexOf('\uFFD2');
const displayName = match.substring(displayNameStart, displayNameEnd);
const currentMatchStartIndex = draft.indexOf(match) + lastMatchStartIndex;
lastMatchStartIndex = currentMatchStartIndex;
lastMatchEndIndex = currentMatchStartIndex + match.length;
const realLength = displayName.length + 1;
lastRealMatchEndIndex = lastRealMatchEndIndex + realLength;
// the +1 is for the @
return {
length: displayName.length + 1,
lastRealMatchEndIndex,
start: lastMatchStartIndex,
end: lastMatchEndIndex,
};
});
const beforeFirstMatch = index < mapStartToLengthOfMatches[0].start;
if (beforeFirstMatch) {
// those first char are always just char, so the mentions logic does not come into account
return index;
}
const lastMatchMap = _.last(mapStartToLengthOfMatches);
if (!lastMatchMap) {
return Number.MAX_SAFE_INTEGER;
}
const indexIsAfterEndOfLastMatch = lastMatchMap.lastRealMatchEndIndex <= index;
if (indexIsAfterEndOfLastMatch) {
const lastEnd = lastMatchMap.end;
const diffBetweenEndAndLastRealEnd = index - lastMatchMap.lastRealMatchEndIndex;
return lastEnd + diffBetweenEndAndLastRealEnd - 1;
}
// now this is the hard part, the cursor is currently between the end of the first match and the start of the last match
// for now, just append it to the end
return Number.MAX_SAFE_INTEGER;
};
class CompositionBoxInner extends React.Component<Props, State> {
private readonly textarea: React.RefObject<any>;
private readonly fileInput: React.RefObject<HTMLInputElement>;
private readonly emojiPanel: any;
private readonly emojiPanel: React.RefObject<HTMLDivElement>;
private readonly emojiPanelButton: any;
private linkPreviewAbortController?: AbortController;
private container: any;
private readonly mentionsRegex = /@\uFFD205[0-9a-f]{64}\uFFD7[^\uFFD2]+\uFFD2/gu;
private container: HTMLDivElement | null;
private lastBumpTypingMessageLength: number = 0;
constructor(props: any) {
@ -222,6 +217,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
this.textarea = React.createRef();
this.fileInput = React.createRef();
this.container = null;
// Emojis
this.emojiPanel = React.createRef();
this.emojiPanelButton = React.createRef();
@ -286,10 +282,13 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
this.hideEmojiPanel();
}
private handlePaste(e: any) {
private handlePaste(e: ClipboardEvent) {
if (!e.clipboardData) {
return;
}
const { items } = e.clipboardData;
let imgBlob = null;
for (const item of items) {
for (const item of items as any) {
const pasteType = item.type.split('/')[0];
if (pasteType === 'image') {
imgBlob = item.getAsFile();
@ -300,7 +299,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
imgBlob = item.getAsFile();
break;
case 'text':
void this.showLinkSharingConfirmationModalDialog(e);
void showLinkSharingConfirmationModalDialog(e);
break;
default:
}
@ -315,47 +314,6 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
}
}
/**
* Check if what is pasted is a URL and prompt confirmation for a setting change
* @param e paste event
*/
private async showLinkSharingConfirmationModalDialog(e: any) {
const pastedText = e.clipboardData.getData('text');
if (this.isURL(pastedText) && !window.getSettingValue('link-preview-setting', false)) {
const alreadyDisplayedPopup =
(await getItemById(hasLinkPreviewPopupBeenDisplayed))?.value || false;
if (!alreadyDisplayedPopup) {
window.inboxStore?.dispatch(
updateConfirmModal({
shouldShowConfirm:
!window.getSettingValue('link-preview-setting') && !alreadyDisplayedPopup,
title: window.i18n('linkPreviewsTitle'),
message: window.i18n('linkPreviewsConfirmMessage'),
okTheme: SessionButtonColor.Danger,
onClickOk: () => {
window.setSettingValue('link-preview-setting', true);
},
onClickClose: async () => {
await createOrUpdateItem({ id: hasLinkPreviewPopupBeenDisplayed, value: true });
},
})
);
}
}
}
/**
*
* @param str String to evaluate
* @returns boolean if the string is true or false
*/
private isURL(str: string) {
const urlRegex =
'^(?!mailto:)(?:(?:http|https|ftp)://)(?:\\S+(?::\\S*)?@)?(?:(?:(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}(?:\\.(?:[0-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))|(?:(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)(?:\\.(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))|localhost)(?::\\d{2,5})?(?:(/|\\?|#)[^\\s]*)?$';
const url = new RegExp(urlRegex, 'i');
return str.length < 2083 && url.test(str);
}
private showEmojiPanel() {
document.addEventListener('mousedown', this.handleClick, false);
@ -390,18 +348,9 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
);
}
private isTypingEnabled(): boolean {
if (!this.props.selectedConversation) {
return false;
}
const { isBlocked, isKickedFromGroup, left } = this.props.selectedConversation;
return !(isBlocked || isKickedFromGroup || left);
}
private renderCompositionView() {
const { showEmojiPanel } = this.state;
const typingEnabled = this.isTypingEnabled();
const { typingEnabled } = this.props;
return (
<>
@ -463,7 +412,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
: isBlocked && !isPrivate
? i18n('unblockGroupToSend')
: i18n('sendMessage');
const typingEnabled = this.isTypingEnabled();
const { typingEnabled } = this.props;
let index = 0;
return (
@ -478,7 +427,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
disabled={!typingEnabled}
rows={1}
style={sendMessageStyle}
suggestionsPortalHost={this.container}
suggestionsPortalHost={this.container as any}
forceSuggestionsAboveCursor={true} // force mentions to be rendered on top of the cursor, this is working with a fork of react-mentions for now
>
<Mention
@ -585,10 +534,10 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
callback(mentionsData);
}
private renderStagedLinkPreview(): JSX.Element {
private renderStagedLinkPreview(): JSX.Element | null {
// Don't generate link previews if user has turned them off
if (!(window.getSettingValue('link-preview-setting') || false)) {
return <></>;
return null;
}
const { stagedAttachments, quotedMessageProps } = this.props;
@ -596,7 +545,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
// Don't render link previews if quoted message or attachments are already added
if (stagedAttachments.length !== 0 || quotedMessageProps?.id) {
return <></>;
return null;
}
// we try to match the first link found in the current message
const links = window.Signal.LinkPreviews.findLinks(this.state.draft, undefined);
@ -606,7 +555,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
stagedLinkPreview: undefined,
});
}
return <></>;
return null;
}
const firstLink = links[0];
// if the first link changed, reset the ignored link so that the preview is generated
@ -620,7 +569,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
// if the fetch did not start yet, just don't show anything
if (!this.state.stagedLinkPreview) {
return <></>;
return null;
}
const { isLoaded, title, description, domain, image } = this.state.stagedLinkPreview;
@ -767,7 +716,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
/>
);
}
return <></>;
return null;
}
private renderAttachmentsStaged() {
@ -785,7 +734,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
</>
);
}
return <></>;
return null;
}
private onChooseAttachment() {
@ -838,25 +787,13 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
}
}
private parseEmojis(value: string) {
const emojisArray = toArray(value);
// toArray outputs React elements for emojis and strings for other
return emojisArray.reduce((previous: string, current: any) => {
if (typeof current === 'string') {
return previous + current;
}
return previous + (current.props.children as string);
}, '');
}
// tslint:disable-next-line: cyclomatic-complexity
private async onSendMessage() {
this.abortLinkPreviewFetch();
// this is dirty but we have to replace all @(xxx) by @xxx manually here
const cleanMentions = (text: string): string => {
const matches = text.match(this.mentionsRegex);
const matches = text.match(mentionsRegex);
let replacedMentions = text;
(matches || []).forEach(match => {
const replacedMention = match.substring(2, match.indexOf('\uFFD7'));
@ -866,7 +803,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
return replacedMentions;
};
const messagePlaintext = cleanMentions(this.parseEmojis(this.state.draft));
const messagePlaintext = cleanMentions(parseEmojis(this.state.draft));
const { selectedConversation } = this.props;
@ -1008,29 +945,18 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
}
private async onLoadVoiceNoteView() {
// Do stuff for component, then run callback to SessionConversation
const mediaSetting = getMediaPermissionsSettings();
if (mediaSetting) {
this.setState({
showRecordingView: true,
showEmojiPanel: false,
});
this.props.onLoadVoiceNoteView();
if (!getMediaPermissionsSettings()) {
ToastUtils.pushAudioPermissionNeeded();
return;
}
ToastUtils.pushAudioPermissionNeeded(() => {
window.inboxStore?.dispatch(showLeftPaneSection(SectionType.Settings));
window.inboxStore?.dispatch(showSettingsSection(SessionSettingCategory.Privacy));
this.setState({
showRecordingView: true,
showEmojiPanel: false,
});
}
private onExitVoiceNoteView() {
// Do stuff for component, then run callback to SessionConversation
this.setState({ showRecordingView: false });
this.props.onExitVoiceNoteView();
}
private onChange(event: any) {
@ -1039,63 +965,6 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
updateDraftForConversation({ conversationKey: this.props.selectedConversationKey, draft });
}
private getSelectionBasedOnMentions(index: number) {
// we have to get the real selectionStart/end of an index in the mentions box.
// this is kind of a pain as the mentions box has two inputs, one with the real text, and one with the extracted mentions
// the index shown to the user is actually just the visible part of the mentions (so the part between ᅲ...ᅭ
const matches = this.state.draft.match(this.mentionsRegex);
let lastMatchStartIndex = 0;
let lastMatchEndIndex = 0;
let lastRealMatchEndIndex = 0;
if (!matches) {
return index;
}
const mapStartToLengthOfMatches = matches.map(match => {
const displayNameStart = match.indexOf('\uFFD7') + 1;
const displayNameEnd = match.lastIndexOf('\uFFD2');
const displayName = match.substring(displayNameStart, displayNameEnd);
const currentMatchStartIndex = this.state.draft.indexOf(match) + lastMatchStartIndex;
lastMatchStartIndex = currentMatchStartIndex;
lastMatchEndIndex = currentMatchStartIndex + match.length;
const realLength = displayName.length + 1;
lastRealMatchEndIndex = lastRealMatchEndIndex + realLength;
// the +1 is for the @
return {
length: displayName.length + 1,
lastRealMatchEndIndex,
start: lastMatchStartIndex,
end: lastMatchEndIndex,
};
});
const beforeFirstMatch = index < mapStartToLengthOfMatches[0].start;
if (beforeFirstMatch) {
// those first char are always just char, so the mentions logic does not come into account
return index;
}
const lastMatchMap = _.last(mapStartToLengthOfMatches);
if (!lastMatchMap) {
return Number.MAX_SAFE_INTEGER;
}
const indexIsAfterEndOfLastMatch = lastMatchMap.lastRealMatchEndIndex <= index;
if (indexIsAfterEndOfLastMatch) {
const lastEnd = lastMatchMap.end;
const diffBetweenEndAndLastRealEnd = index - lastMatchMap.lastRealMatchEndIndex;
return lastEnd + diffBetweenEndAndLastRealEnd - 1;
}
// now this is the hard part, the cursor is currently between the end of the first match and the start of the last match
// for now, just append it to the end
return Number.MAX_SAFE_INTEGER;
}
private onEmojiClick({ colons }: { colons: string }) {
const messageBox = this.textarea.current;
if (!messageBox) {
@ -1106,7 +975,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
const currentSelectionStart = Number(messageBox.selectionStart);
const realSelectionStart = this.getSelectionBasedOnMentions(currentSelectionStart);
const realSelectionStart = getSelectionBasedOnMentions(draft, currentSelectionStart);
const before = draft.slice(0, realSelectionStart);
const end = draft.slice(realSelectionStart);
@ -1146,10 +1015,11 @@ const mapStateToProps = (state: StateType) => {
quotedMessageProps: getQuotedMessage(state),
selectedConversation: getSelectedConversation(state),
selectedConversationKey: getSelectedConversationKey(state),
typingEnabled: getIsTypingEnabled(state),
theme: getTheme(state),
};
};
const smart = connect(mapStateToProps);
export const SessionCompositionBox = smart(SessionCompositionBoxInner);
export const CompositionBox = smart(CompositionBoxInner);

View File

@ -0,0 +1,60 @@
import React from 'react';
import { SessionIconButton } from '../../icon';
export const AddStagedAttachmentButton = (props: { onClick: () => void }) => {
return (
<SessionIconButton
iconType="plusThin"
backgroundColor={'var(--color-compose-view-button-background)'}
iconSize={'huge2'}
borderRadius="300px"
iconPadding="8px"
onClick={props.onClick}
/>
);
};
export const StartRecordingButton = (props: { onClick: () => void }) => {
return (
<SessionIconButton
iconType="microphone"
iconSize={'huge2'}
backgroundColor={'var(--color-compose-view-button-background)'}
borderRadius="300px"
iconPadding="6px"
onClick={props.onClick}
/>
);
};
export const ToggleEmojiButton = React.forwardRef<HTMLDivElement, { onClick: () => void }>(
(props, ref) => {
return (
<SessionIconButton
iconType="emoji"
ref={ref}
backgroundColor="var(--color-compose-view-button-background)"
iconSize={'huge2'}
borderRadius="300px"
iconPadding="6px"
onClick={props.onClick}
/>
);
}
);
export const SendMessageButton = (props: { onClick: () => void }) => {
return (
<div className="send-message-button">
<SessionIconButton
iconType="send"
backgroundColor={'var(--color-compose-view-button-background)'}
iconSize={'huge2'}
iconRotation={90}
borderRadius="300px"
iconPadding="6px"
onClick={props.onClick}
/>
</div>
);
};

View File

@ -64,7 +64,7 @@ const SignInContinueButton = (props: {
handleContinueYourSessionClick: () => any;
}) => {
if (props.signInMode === SignInMode.Default) {
return <></>;
return null;
}
return (
<ContinueYourSessionButton
@ -80,7 +80,7 @@ const SignInButtons = (props: {
onLinkDeviceButtonClicked: () => any;
}) => {
if (props.signInMode !== SignInMode.Default) {
return <></>;
return null;
}
return (
<div>

View File

@ -22,7 +22,9 @@ import {
} from '../state/ducks/modalDialog';
import {
createOrUpdateItem,
getItemById,
getMessageById,
hasLinkPreviewPopupBeenDisplayed,
lastAvatarUploadTimestamp,
removeAllMessagesInConversation,
} from '../data/data';
@ -388,3 +390,44 @@ export async function replyToMessage(messageId: string) {
window.inboxStore?.dispatch(quoteMessage(undefined));
}
}
/**
* Check if what is pasted is a URL and prompt confirmation for a setting change
* @param e paste event
*/
export async function showLinkSharingConfirmationModalDialog(e: any) {
const pastedText = e.clipboardData.getData('text');
if (isURL(pastedText) && !window.getSettingValue('link-preview-setting', false)) {
const alreadyDisplayedPopup =
(await getItemById(hasLinkPreviewPopupBeenDisplayed))?.value || false;
if (!alreadyDisplayedPopup) {
window.inboxStore?.dispatch(
updateConfirmModal({
shouldShowConfirm:
!window.getSettingValue('link-preview-setting') && !alreadyDisplayedPopup,
title: window.i18n('linkPreviewsTitle'),
message: window.i18n('linkPreviewsConfirmMessage'),
okTheme: SessionButtonColor.Danger,
onClickOk: () => {
window.setSettingValue('link-preview-setting', true);
},
onClickClose: async () => {
await createOrUpdateItem({ id: hasLinkPreviewPopupBeenDisplayed, value: true });
},
})
);
}
}
}
/**
*
* @param str String to evaluate
* @returns boolean if the string is true or false
*/
function isURL(str: string) {
const urlRegex =
'^(?!mailto:)(?:(?:http|https|ftp)://)(?:\\S+(?::\\S*)?@)?(?:(?:(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}(?:\\.(?:[0-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))|(?:(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)(?:\\.(?:[a-z\\u00a1-\\uffff0-9]+-?)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))|localhost)(?::\\d{2,5})?(?:(/|\\?|#)[^\\s]*)?$';
const url = new RegExp(urlRegex, 'i');
return str.length < 2083 && url.test(str);
}

View File

@ -44,7 +44,7 @@ import { perfEnd, perfStart } from '../session/utils/Performance';
import {
ReplyingToMessageProps,
SendMessageType,
} from '../components/session/conversation/SessionCompositionBox';
} from '../components/session/conversation/composition/CompositionBox';
import { ed25519Str } from '../session/onions/onionPath';
import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager';
import { IMAGE_JPEG } from '../types/MIME';
@ -180,8 +180,8 @@ export type CallState = 'offering' | 'incoming' | 'connecting' | 'ongoing' | 'no
export class ConversationModel extends Backbone.Model<ConversationAttributes> {
public updateLastMessage: () => any;
public throttledBumpTyping: any;
public throttledNotify: any;
public throttledBumpTyping: () => void;
public throttledNotify: (message: MessageModel) => void;
public markRead: (newestUnreadDate: number, providedOptions?: any) => Promise<void>;
public initialPromise: any;
@ -192,7 +192,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
private typingTimer?: NodeJS.Timeout | null;
private lastReadTimestamp: number;
private pending: any;
private pending?: Promise<any>;
constructor(attributes: ConversationAttributesOptionals) {
super(fillConvoAttributesWithDefaults(attributes));

View File

@ -175,12 +175,15 @@ export function pushVideoCallPermissionNeeded() {
);
}
export function pushAudioPermissionNeeded(onClicked: () => void) {
export function pushAudioPermissionNeeded() {
pushToastInfo(
'audioPermissionNeeded',
window.i18n('audioPermissionNeededTitle'),
window.i18n('audioPermissionNeeded'),
onClicked
() => {
window.inboxStore?.dispatch(showLeftPaneSection(SectionType.Settings));
window.inboxStore?.dispatch(showSettingsSection(SessionSettingCategory.Privacy));
}
);
}

View File

@ -13,7 +13,7 @@ import {
PropsForDataExtractionNotification,
} from '../../models/messageType';
import { LightBoxOptions } from '../../components/session/conversation/SessionConversation';
import { ReplyingToMessageProps } from '../../components/session/conversation/SessionCompositionBox';
import { ReplyingToMessageProps } from '../../components/session/conversation/composition/CompositionBox';
import { QuotedAttachmentType } from '../../components/conversation/Quote';
import { perfEnd, perfStart } from '../../session/utils/Performance';
import { omit } from 'lodash';

View File

@ -1,6 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import _ from 'lodash';
import { StagedAttachmentType } from '../../components/session/conversation/SessionCompositionBox';
import { StagedAttachmentType } from '../../components/session/conversation/composition/CompositionBox';
export type StagedAttachmentsStateType = {
stagedAttachments: { [conversationKey: string]: Array<StagedAttachmentType> };

View File

@ -21,7 +21,7 @@ import {
ConversationHeaderTitleProps,
} from '../../components/conversation/ConversationHeader';
import { LightBoxOptions } from '../../components/session/conversation/SessionConversation';
import { ReplyingToMessageProps } from '../../components/session/conversation/SessionCompositionBox';
import { ReplyingToMessageProps } from '../../components/session/conversation/composition/CompositionBox';
import { getConversationController } from '../../session/conversations';
import { UserUtils } from '../../session/utils';
import { MessageAvatarSelectorProps } from '../../components/conversation/message/MessageAvatar';
@ -188,6 +188,22 @@ export const getCallIsInFullScreen = createSelector(
(state: ConversationsStateType): boolean => state.callIsInFullScreen
);
export const getIsTypingEnabled = createSelector(
getConversations,
getSelectedConversationKey,
(state: ConversationsStateType, selectedConvoPubkey?: string): boolean => {
if (!selectedConvoPubkey) {
return false;
}
const selectedConvo = state.conversationLookup[selectedConvoPubkey];
if (!selectedConvo) {
return false;
}
const { isBlocked, isKickedFromGroup, left } = selectedConvo;
return !(isBlocked || isKickedFromGroup || left);
}
);
/**
* Returns true if the current conversation selected is a group conversation.
* Returns false if the current conversation selected is not a group conversation, or none are selected

View File

@ -1,5 +1,5 @@
import { createSelector } from 'reselect';
import { StagedAttachmentType } from '../../components/session/conversation/SessionCompositionBox';
import { StagedAttachmentType } from '../../components/session/conversation/composition/CompositionBox';
import { StagedAttachmentsStateType } from '../ducks/stagedAttachments';
import { StateType } from '../reducer';
import { getSelectedConversationKey } from './conversations';

View File

@ -1,4 +1,4 @@
import { StagedAttachmentType } from '../components/session/conversation/SessionCompositionBox';
import { StagedAttachmentType } from '../components/session/conversation/composition/CompositionBox';
import { SignalService } from '../protobuf';
import { Constants } from '../session';
import loadImage from 'blueimp-load-image';