From f91ed7729b5fd458821fb142dc8c1c0a7378e3ca Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Mon, 8 Nov 2021 11:03:08 +1100 Subject: [PATCH] cleanup SessionCompositionBox --- .../conversation/ConversationHeader.tsx | 2 +- ts/components/conversation/Quote.tsx | 4 +- .../conversation/StagedLinkPreview.tsx | 2 +- ts/components/conversation/TypingBubble.tsx | 2 +- .../dialog/UpdateGroupMembersDialog.tsx | 2 +- ts/components/session/ActionsPanel.tsx | 2 +- ts/components/session/SessionInboxView.tsx | 2 +- .../session/SessionJoinableDefaultRooms.tsx | 2 +- .../conversation/SessionConversation.tsx | 36 +- .../conversation/SessionConversationDrafts.ts | 19 + .../session/conversation/SessionRecording.tsx | 33 +- .../conversation/SessionStagedLinkPreview.tsx | 4 +- .../CompositionBox.tsx} | 398 ++++++------------ .../composition/CompositionButtons.tsx | 60 +++ .../session/registration/SignInTab.tsx | 4 +- ts/interactions/conversationInteractions.ts | 43 ++ ts/models/conversation.ts | 8 +- ts/session/utils/Toast.tsx | 7 +- ts/state/ducks/conversations.ts | 2 +- ts/state/ducks/stagedAttachments.ts | 2 +- ts/state/selectors/conversations.ts | 18 +- ts/state/selectors/stagedAttachments.ts | 2 +- ts/util/attachmentsUtil.ts | 2 +- 23 files changed, 316 insertions(+), 340 deletions(-) create mode 100644 ts/components/session/conversation/SessionConversationDrafts.ts rename ts/components/session/conversation/{SessionCompositionBox.tsx => composition/CompositionBox.tsx} (72%) create mode 100644 ts/components/session/conversation/composition/CompositionButtons.tsx diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 2b0a64f56..8b610ac29 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -128,7 +128,7 @@ const SelectionOverlay = () => { const TripleDotsMenu = (props: { triggerId: string; showBackButton: boolean }) => { const { showBackButton } = props; if (showBackButton) { - return <>; + return null; } return (
; + return null; } const { fileName, contentType } = attachment; @@ -167,7 +167,7 @@ export const QuoteGenericFile = ( !MIME.isAudio(contentType); if (!isGenericFile) { - return <>; + return null; } return ( diff --git a/ts/components/conversation/StagedLinkPreview.tsx b/ts/components/conversation/StagedLinkPreview.tsx index b9b377534..57c4803a0 100644 --- a/ts/components/conversation/StagedLinkPreview.tsx +++ b/ts/components/conversation/StagedLinkPreview.tsx @@ -22,7 +22,7 @@ export const StagedLinkPreview = (props: Props) => { const isImage = image && isImageAttachment(image); if (isLoaded && !(title && domain)) { - return <>; + return null; } const isLoading = !isLoaded; diff --git a/ts/components/conversation/TypingBubble.tsx b/ts/components/conversation/TypingBubble.tsx index d36f0d8e3..ba1ccf85c 100644 --- a/ts/components/conversation/TypingBubble.tsx +++ b/ts/components/conversation/TypingBubble.tsx @@ -26,7 +26,7 @@ const TypingBubbleContainer = styled.div` export const TypingBubble = (props: TypingBubbleProps) => { if (props.conversationType === ConversationTypeEnum.GROUP) { - return <>; + return null; } if (!props.isTyping) { diff --git a/ts/components/dialog/UpdateGroupMembersDialog.tsx b/ts/components/dialog/UpdateGroupMembersDialog.tsx index 4c8bc5cfe..da94fc094 100644 --- a/ts/components/dialog/UpdateGroupMembersDialog.tsx +++ b/ts/components/dialog/UpdateGroupMembersDialog.tsx @@ -193,7 +193,7 @@ export class UpdateGroupMembersDialog extends React.Component { const { zombies } = this.state; if (!zombies.length) { - return <>; + return null; } const zombieElements = zombies.map((member: ContactType, index: number) => { diff --git a/ts/components/session/ActionsPanel.tsx b/ts/components/session/ActionsPanel.tsx index e7e8d51ce..84f268cc3 100644 --- a/ts/components/session/ActionsPanel.tsx +++ b/ts/components/session/ActionsPanel.tsx @@ -274,7 +274,7 @@ export const ActionsPanel = () => { if (!ourPrimaryConversation) { window?.log?.warn('ActionsPanel: ourPrimaryConversation is not set'); - return <>; + return null; } useInterval(() => { diff --git a/ts/components/session/SessionInboxView.tsx b/ts/components/session/SessionInboxView.tsx index 5a65908ad..fdb34b04d 100644 --- a/ts/components/session/SessionInboxView.tsx +++ b/ts/components/session/SessionInboxView.tsx @@ -50,7 +50,7 @@ export class SessionInboxView extends React.Component { public render() { if (!this.state.isInitialLoadComplete) { - return <>; + return null; } const persistor = persistStore(this.store); diff --git a/ts/components/session/SessionJoinableDefaultRooms.tsx b/ts/components/session/SessionJoinableDefaultRooms.tsx index b047fac3f..e9abccea7 100644 --- a/ts/components/session/SessionJoinableDefaultRooms.tsx +++ b/ts/components/session/SessionJoinableDefaultRooms.tsx @@ -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 ? ( diff --git a/ts/components/session/conversation/SessionConversation.tsx b/ts/components/session/conversation/SessionConversation.tsx index 01902344a..0dc49ea88 100644 --- a/ts/components/session/conversation/SessionConversation.tsx +++ b/ts/components/session/conversation/SessionConversation.tsx @@ -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 { super(props); this.state = { - showRecordingView: false, isDraggingFile: false, }; this.messageContainerRef = React.createRef(); @@ -135,7 +133,6 @@ export class SessionConversation extends React.Component { } if (newConversationKey !== oldConversationKey) { this.setState({ - showRecordingView: false, isDraggingFile: false, }); } @@ -247,11 +244,9 @@ export class SessionConversation extends React.Component { {isDraggingFile && }
- @@ -264,35 +259,12 @@ export class SessionConversation extends React.Component { ); } - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // ~~~~~~~~~~~~ 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': diff --git a/ts/components/session/conversation/SessionConversationDrafts.ts b/ts/components/session/conversation/SessionConversationDrafts.ts new file mode 100644 index 000000000..e1a967eaa --- /dev/null +++ b/ts/components/session/conversation/SessionConversationDrafts.ts @@ -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 = {}; + +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; +} diff --git a/ts/components/session/conversation/SessionRecording.tsx b/ts/components/session/conversation/SessionRecording.tsx index 2f48c933a..d8dbdf393 100644 --- a/ts/components/session/conversation/SessionRecording.tsx +++ b/ts/components/session/conversation/SessionRecording.tsx @@ -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; } 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` } `; -class SessionRecordingInner extends React.Component { - private recorder: any; +export class SessionRecording extends React.Component { + 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 { actionHover: false, startTimestamp: now, nowTimestamp: now, - updateTimerInterval, }; } @@ -86,10 +78,13 @@ class SessionRecordingInner extends React.Component { 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 { return; } - this.props.sendVoiceMessage(this.audioBlobMp3); + void this.props.sendVoiceMessage(this.audioBlobMp3); } private async initiateRecordingStream() { @@ -348,5 +343,3 @@ class SessionRecordingInner extends React.Component { } } } - -export const SessionRecording = SessionRecordingInner; diff --git a/ts/components/session/conversation/SessionStagedLinkPreview.tsx b/ts/components/session/conversation/SessionStagedLinkPreview.tsx index 9d3001333..14978a5ad 100644 --- a/ts/components/session/conversation/SessionStagedLinkPreview.tsx +++ b/ts/components/session/conversation/SessionStagedLinkPreview.tsx @@ -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 ( diff --git a/ts/components/session/conversation/SessionCompositionBox.tsx b/ts/components/session/conversation/composition/CompositionBox.tsx similarity index 72% rename from ts/components/session/conversation/SessionCompositionBox.tsx rename to ts/components/session/conversation/composition/CompositionBox.tsx index b71117a1d..0ea38bee7 100644 --- a/ts/components/session/conversation/SessionCompositionBox.tsx +++ b/ts/components/session/conversation/composition/CompositionBox.tsx @@ -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 ( - - ); -}; - -const StartRecordingButton = (props: { onClick: () => void }) => { - return ( - - ); -}; - -const ToggleEmojiButton = React.forwardRef void }>( - (props, ref) => { - return ( - - ); - } -); - -const SendMessageButton = (props: { onClick: () => void }) => { - return ( -
- -
- ); -}; - -// 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; onChoseAttachments: (newAttachments: Array) => 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 { +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 { private readonly textarea: React.RefObject; private readonly fileInput: React.RefObject; - private readonly emojiPanel: any; + private readonly emojiPanel: React.RefObject; 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 { 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 { 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 { imgBlob = item.getAsFile(); break; case 'text': - void this.showLinkSharingConfirmationModalDialog(e); + void showLinkSharingConfirmationModalDialog(e); break; default: } @@ -315,47 +314,6 @@ class SessionCompositionBoxInner extends React.Component { } } - /** - * 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 { ); } - 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 { : 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 { 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 > { 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 { // 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 { 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 { // 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 { /> ); } - return <>; + return null; } private renderAttachmentsStaged() { @@ -785,7 +734,7 @@ class SessionCompositionBoxInner extends React.Component { ); } - return <>; + return null; } private onChooseAttachment() { @@ -838,25 +787,13 @@ class SessionCompositionBoxInner extends React.Component { } } - 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 { 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 { } 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 { 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 { 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); diff --git a/ts/components/session/conversation/composition/CompositionButtons.tsx b/ts/components/session/conversation/composition/CompositionButtons.tsx new file mode 100644 index 000000000..b6b047c8c --- /dev/null +++ b/ts/components/session/conversation/composition/CompositionButtons.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { SessionIconButton } from '../../icon'; + +export const AddStagedAttachmentButton = (props: { onClick: () => void }) => { + return ( + + ); +}; + +export const StartRecordingButton = (props: { onClick: () => void }) => { + return ( + + ); +}; + +export const ToggleEmojiButton = React.forwardRef void }>( + (props, ref) => { + return ( + + ); + } +); + +export const SendMessageButton = (props: { onClick: () => void }) => { + return ( +
+ +
+ ); +}; diff --git a/ts/components/session/registration/SignInTab.tsx b/ts/components/session/registration/SignInTab.tsx index ee33bce29..ed77d61ed 100644 --- a/ts/components/session/registration/SignInTab.tsx +++ b/ts/components/session/registration/SignInTab.tsx @@ -64,7 +64,7 @@ const SignInContinueButton = (props: { handleContinueYourSessionClick: () => any; }) => { if (props.signInMode === SignInMode.Default) { - return <>; + return null; } return ( any; }) => { if (props.signInMode !== SignInMode.Default) { - return <>; + return null; } return (
diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index 1ce06cd3e..ff6583857 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -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); +} diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index b48ba361d..d92a3e86c 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -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 { public updateLastMessage: () => any; - public throttledBumpTyping: any; - public throttledNotify: any; + public throttledBumpTyping: () => void; + public throttledNotify: (message: MessageModel) => void; public markRead: (newestUnreadDate: number, providedOptions?: any) => Promise; public initialPromise: any; @@ -192,7 +192,7 @@ export class ConversationModel extends Backbone.Model { private typingTimer?: NodeJS.Timeout | null; private lastReadTimestamp: number; - private pending: any; + private pending?: Promise; constructor(attributes: ConversationAttributesOptionals) { super(fillConvoAttributesWithDefaults(attributes)); diff --git a/ts/session/utils/Toast.tsx b/ts/session/utils/Toast.tsx index ce2fa7191..ecd644168 100644 --- a/ts/session/utils/Toast.tsx +++ b/ts/session/utils/Toast.tsx @@ -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)); + } ); } diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 90bccae5e..973e3e1c7 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -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'; diff --git a/ts/state/ducks/stagedAttachments.ts b/ts/state/ducks/stagedAttachments.ts index d4474dbc2..643925a04 100644 --- a/ts/state/ducks/stagedAttachments.ts +++ b/ts/state/ducks/stagedAttachments.ts @@ -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 }; diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 40034835d..87ca4b523 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -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 diff --git a/ts/state/selectors/stagedAttachments.ts b/ts/state/selectors/stagedAttachments.ts index 477e70c60..c86be245f 100644 --- a/ts/state/selectors/stagedAttachments.ts +++ b/ts/state/selectors/stagedAttachments.ts @@ -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'; diff --git a/ts/util/attachmentsUtil.ts b/ts/util/attachmentsUtil.ts index a3c5ea9dd..5cdb01f4e 100644 --- a/ts/util/attachmentsUtil.ts +++ b/ts/util/attachmentsUtil.ts @@ -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';