cleanup SessionCompositionBox

pull/2015/head
Audric Ackermann 3 years ago
parent 3741e96c61
commit f91ed7729b
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4

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

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

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

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

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

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

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

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

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

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

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

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

@ -1,56 +1,52 @@
import React from 'react'; import React from 'react';
import _, { debounce } from 'lodash'; import _, { debounce } from 'lodash';
import { AttachmentType } from '../../../types/Attachment'; import { AttachmentType } from '../../../../types/Attachment';
import * as MIME from '../../../types/MIME'; import * as MIME from '../../../../types/MIME';
import { SessionIconButton } from '../icon'; import { SessionEmojiPanel } from '../SessionEmojiPanel';
import { SessionEmojiPanel } from './SessionEmojiPanel'; import { SessionRecording } from '../SessionRecording';
import { SessionRecording } from './SessionRecording';
import { Constants } from '../../../session'; import { Constants } from '../../../../session';
import { toArray } from 'react-emoji-render'; import { toArray } from 'react-emoji-render';
import { Flex } from '../../basic/Flex'; import { Flex } from '../../../basic/Flex';
import { StagedAttachmentList } from '../../conversation/StagedAttachmentList'; import { StagedAttachmentList } from '../../../conversation/StagedAttachmentList';
import { ToastUtils } from '../../../session/utils'; import { ToastUtils } from '../../../../session/utils';
import { AttachmentUtil } from '../../../util'; import { AttachmentUtil } from '../../../../util';
import { import {
getPreview, getPreview,
LINK_PREVIEW_TIMEOUT, LINK_PREVIEW_TIMEOUT,
SessionStagedLinkPreview, SessionStagedLinkPreview,
} from './SessionStagedLinkPreview'; } from '../SessionStagedLinkPreview';
import { AbortController } from 'abort-controller'; import { AbortController } from 'abort-controller';
import { SessionQuotedMessageComposition } from './SessionQuotedMessageComposition'; import { SessionQuotedMessageComposition } from '../SessionQuotedMessageComposition';
import { Mention, MentionsInput } from 'react-mentions'; import { Mention, MentionsInput } from 'react-mentions';
import { CaptionEditor } from '../../CaptionEditor'; import { CaptionEditor } from '../../../CaptionEditor';
import { getConversationController } from '../../../session/conversations'; import { getConversationController } from '../../../../session/conversations';
import { ReduxConversationType } from '../../../state/ducks/conversations'; import { ReduxConversationType } from '../../../../state/ducks/conversations';
import { SessionMemberListItem } from '../SessionMemberListItem'; import { SessionMemberListItem } from '../../SessionMemberListItem';
import autoBind from 'auto-bind'; import autoBind from 'auto-bind';
import { getMediaPermissionsSettings, SessionSettingCategory } from '../settings/SessionSettings'; import { getMediaPermissionsSettings } 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 { import {
getIsTypingEnabled,
getMentionsInput, getMentionsInput,
getQuotedMessage, getQuotedMessage,
getSelectedConversation, getSelectedConversation,
getSelectedConversationKey, getSelectedConversationKey,
} from '../../../state/selectors/conversations'; } from '../../../../state/selectors/conversations';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { StateType } from '../../../state/reducer'; import { StateType } from '../../../../state/reducer';
import { getTheme } from '../../../state/selectors/theme'; import { getTheme } from '../../../../state/selectors/theme';
import { removeAllStagedAttachmentsInConversation } from '../../../state/ducks/stagedAttachments'; 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 { export interface ReplyingToMessageProps {
convoId: string; convoId: string;
@ -83,79 +79,11 @@ export type SendMessageType = {
groupInvitation: { url: string | undefined; name: string } | undefined; 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 { interface Props {
sendMessage: (msg: SendMessageType) => void; sendMessage: (msg: SendMessageType) => void;
onLoadVoiceNoteView: any;
onExitVoiceNoteView: any;
selectedConversationKey: string; selectedConversationKey: string;
selectedConversation: ReduxConversationType | undefined; selectedConversation: ReduxConversationType | undefined;
typingEnabled: boolean;
quotedMessageProps?: ReplyingToMessageProps; quotedMessageProps?: ReplyingToMessageProps;
stagedAttachments: Array<StagedAttachmentType>; stagedAttachments: Array<StagedAttachmentType>;
onChoseAttachments: (newAttachments: Array<File>) => void; onChoseAttachments: (newAttachments: Array<File>) => void;
@ -165,8 +93,7 @@ interface State {
showRecordingView: boolean; showRecordingView: boolean;
draft: string; draft: string;
showEmojiPanel: boolean; showEmojiPanel: boolean;
voiceRecording?: Blob; ignoredLink?: string; // set the ignored url when users closed the link preview
ignoredLink?: string; // set the the ignored url when users closed the link preview
stagedLinkPreview?: StagedLinkPreviewData; stagedLinkPreview?: StagedLinkPreviewData;
showCaptionEditor?: AttachmentType; showCaptionEditor?: AttachmentType;
} }
@ -191,12 +118,10 @@ const sendMessageStyle = {
minHeight: '24px', minHeight: '24px',
width: '100%', width: '100%',
}; };
const getDefaultState = (newConvoId?: string) => { const getDefaultState = (newConvoId?: string) => {
return { return {
draft: draft: getDraftForConversation(newConvoId),
(newConvoId && draftsForConversations.find(c => c.conversationKey === newConvoId)?.draft) ||
'',
voiceRecording: undefined,
showRecordingView: false, showRecordingView: false,
showEmojiPanel: false, showEmojiPanel: false,
ignoredLink: undefined, 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 textarea: React.RefObject<any>;
private readonly fileInput: React.RefObject<HTMLInputElement>; private readonly fileInput: React.RefObject<HTMLInputElement>;
private readonly emojiPanel: any; private readonly emojiPanel: React.RefObject<HTMLDivElement>;
private readonly emojiPanelButton: any; private readonly emojiPanelButton: any;
private linkPreviewAbortController?: AbortController; private linkPreviewAbortController?: AbortController;
private container: any; private container: HTMLDivElement | null;
private readonly mentionsRegex = /@\uFFD205[0-9a-f]{64}\uFFD7[^\uFFD2]+\uFFD2/gu;
private lastBumpTypingMessageLength: number = 0; private lastBumpTypingMessageLength: number = 0;
constructor(props: any) { constructor(props: any) {
@ -222,6 +217,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
this.textarea = React.createRef(); this.textarea = React.createRef();
this.fileInput = React.createRef(); this.fileInput = React.createRef();
this.container = null;
// Emojis // Emojis
this.emojiPanel = React.createRef(); this.emojiPanel = React.createRef();
this.emojiPanelButton = React.createRef(); this.emojiPanelButton = React.createRef();
@ -286,10 +282,13 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
this.hideEmojiPanel(); this.hideEmojiPanel();
} }
private handlePaste(e: any) { private handlePaste(e: ClipboardEvent) {
if (!e.clipboardData) {
return;
}
const { items } = e.clipboardData; const { items } = e.clipboardData;
let imgBlob = null; let imgBlob = null;
for (const item of items) { for (const item of items as any) {
const pasteType = item.type.split('/')[0]; const pasteType = item.type.split('/')[0];
if (pasteType === 'image') { if (pasteType === 'image') {
imgBlob = item.getAsFile(); imgBlob = item.getAsFile();
@ -300,7 +299,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
imgBlob = item.getAsFile(); imgBlob = item.getAsFile();
break; break;
case 'text': case 'text':
void this.showLinkSharingConfirmationModalDialog(e); void showLinkSharingConfirmationModalDialog(e);
break; break;
default: 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() { private showEmojiPanel() {
document.addEventListener('mousedown', this.handleClick, false); 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() { private renderCompositionView() {
const { showEmojiPanel } = this.state; const { showEmojiPanel } = this.state;
const typingEnabled = this.isTypingEnabled(); const { typingEnabled } = this.props;
return ( return (
<> <>
@ -463,7 +412,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
: isBlocked && !isPrivate : isBlocked && !isPrivate
? i18n('unblockGroupToSend') ? i18n('unblockGroupToSend')
: i18n('sendMessage'); : i18n('sendMessage');
const typingEnabled = this.isTypingEnabled(); const { typingEnabled } = this.props;
let index = 0; let index = 0;
return ( return (
@ -478,7 +427,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
disabled={!typingEnabled} disabled={!typingEnabled}
rows={1} rows={1}
style={sendMessageStyle} 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 forceSuggestionsAboveCursor={true} // force mentions to be rendered on top of the cursor, this is working with a fork of react-mentions for now
> >
<Mention <Mention
@ -585,10 +534,10 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
callback(mentionsData); callback(mentionsData);
} }
private renderStagedLinkPreview(): JSX.Element { private renderStagedLinkPreview(): JSX.Element | null {
// Don't generate link previews if user has turned them off // Don't generate link previews if user has turned them off
if (!(window.getSettingValue('link-preview-setting') || false)) { if (!(window.getSettingValue('link-preview-setting') || false)) {
return <></>; return null;
} }
const { stagedAttachments, quotedMessageProps } = this.props; 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 // Don't render link previews if quoted message or attachments are already added
if (stagedAttachments.length !== 0 || quotedMessageProps?.id) { if (stagedAttachments.length !== 0 || quotedMessageProps?.id) {
return <></>; return null;
} }
// we try to match the first link found in the current message // we try to match the first link found in the current message
const links = window.Signal.LinkPreviews.findLinks(this.state.draft, undefined); const links = window.Signal.LinkPreviews.findLinks(this.state.draft, undefined);
@ -606,7 +555,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
stagedLinkPreview: undefined, stagedLinkPreview: undefined,
}); });
} }
return <></>; return null;
} }
const firstLink = links[0]; const firstLink = links[0];
// if the first link changed, reset the ignored link so that the preview is generated // 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 the fetch did not start yet, just don't show anything
if (!this.state.stagedLinkPreview) { if (!this.state.stagedLinkPreview) {
return <></>; return null;
} }
const { isLoaded, title, description, domain, image } = this.state.stagedLinkPreview; 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() { private renderAttachmentsStaged() {
@ -785,7 +734,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
</> </>
); );
} }
return <></>; return null;
} }
private onChooseAttachment() { 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 // tslint:disable-next-line: cyclomatic-complexity
private async onSendMessage() { private async onSendMessage() {
this.abortLinkPreviewFetch(); this.abortLinkPreviewFetch();
// this is dirty but we have to replace all @(xxx) by @xxx manually here // this is dirty but we have to replace all @(xxx) by @xxx manually here
const cleanMentions = (text: string): string => { const cleanMentions = (text: string): string => {
const matches = text.match(this.mentionsRegex); const matches = text.match(mentionsRegex);
let replacedMentions = text; let replacedMentions = text;
(matches || []).forEach(match => { (matches || []).forEach(match => {
const replacedMention = match.substring(2, match.indexOf('\uFFD7')); const replacedMention = match.substring(2, match.indexOf('\uFFD7'));
@ -866,7 +803,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
return replacedMentions; return replacedMentions;
}; };
const messagePlaintext = cleanMentions(this.parseEmojis(this.state.draft)); const messagePlaintext = cleanMentions(parseEmojis(this.state.draft));
const { selectedConversation } = this.props; const { selectedConversation } = this.props;
@ -1008,29 +945,18 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
} }
private async onLoadVoiceNoteView() { private async onLoadVoiceNoteView() {
// Do stuff for component, then run callback to SessionConversation if (!getMediaPermissionsSettings()) {
const mediaSetting = getMediaPermissionsSettings(); ToastUtils.pushAudioPermissionNeeded();
if (mediaSetting) {
this.setState({
showRecordingView: true,
showEmojiPanel: false,
});
this.props.onLoadVoiceNoteView();
return; return;
} }
this.setState({
ToastUtils.pushAudioPermissionNeeded(() => { showRecordingView: true,
window.inboxStore?.dispatch(showLeftPaneSection(SectionType.Settings)); showEmojiPanel: false,
window.inboxStore?.dispatch(showSettingsSection(SessionSettingCategory.Privacy));
}); });
} }
private onExitVoiceNoteView() { private onExitVoiceNoteView() {
// Do stuff for component, then run callback to SessionConversation
this.setState({ showRecordingView: false }); this.setState({ showRecordingView: false });
this.props.onExitVoiceNoteView();
} }
private onChange(event: any) { private onChange(event: any) {
@ -1039,63 +965,6 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
updateDraftForConversation({ conversationKey: this.props.selectedConversationKey, draft }); 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 }) { private onEmojiClick({ colons }: { colons: string }) {
const messageBox = this.textarea.current; const messageBox = this.textarea.current;
if (!messageBox) { if (!messageBox) {
@ -1106,7 +975,7 @@ class SessionCompositionBoxInner extends React.Component<Props, State> {
const currentSelectionStart = Number(messageBox.selectionStart); const currentSelectionStart = Number(messageBox.selectionStart);
const realSelectionStart = this.getSelectionBasedOnMentions(currentSelectionStart); const realSelectionStart = getSelectionBasedOnMentions(draft, currentSelectionStart);
const before = draft.slice(0, realSelectionStart); const before = draft.slice(0, realSelectionStart);
const end = draft.slice(realSelectionStart); const end = draft.slice(realSelectionStart);
@ -1146,10 +1015,11 @@ const mapStateToProps = (state: StateType) => {
quotedMessageProps: getQuotedMessage(state), quotedMessageProps: getQuotedMessage(state),
selectedConversation: getSelectedConversation(state), selectedConversation: getSelectedConversation(state),
selectedConversationKey: getSelectedConversationKey(state), selectedConversationKey: getSelectedConversationKey(state),
typingEnabled: getIsTypingEnabled(state),
theme: getTheme(state), theme: getTheme(state),
}; };
}; };
const smart = connect(mapStateToProps); const smart = connect(mapStateToProps);
export const SessionCompositionBox = smart(SessionCompositionBoxInner); export const CompositionBox = smart(CompositionBoxInner);

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

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

@ -22,7 +22,9 @@ import {
} from '../state/ducks/modalDialog'; } from '../state/ducks/modalDialog';
import { import {
createOrUpdateItem, createOrUpdateItem,
getItemById,
getMessageById, getMessageById,
hasLinkPreviewPopupBeenDisplayed,
lastAvatarUploadTimestamp, lastAvatarUploadTimestamp,
removeAllMessagesInConversation, removeAllMessagesInConversation,
} from '../data/data'; } from '../data/data';
@ -388,3 +390,44 @@ export async function replyToMessage(messageId: string) {
window.inboxStore?.dispatch(quoteMessage(undefined)); 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);
}

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

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

@ -13,7 +13,7 @@ import {
PropsForDataExtractionNotification, PropsForDataExtractionNotification,
} from '../../models/messageType'; } from '../../models/messageType';
import { LightBoxOptions } from '../../components/session/conversation/SessionConversation'; 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 { QuotedAttachmentType } from '../../components/conversation/Quote';
import { perfEnd, perfStart } from '../../session/utils/Performance'; import { perfEnd, perfStart } from '../../session/utils/Performance';
import { omit } from 'lodash'; import { omit } from 'lodash';

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

@ -21,7 +21,7 @@ import {
ConversationHeaderTitleProps, ConversationHeaderTitleProps,
} from '../../components/conversation/ConversationHeader'; } from '../../components/conversation/ConversationHeader';
import { LightBoxOptions } from '../../components/session/conversation/SessionConversation'; 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 { getConversationController } from '../../session/conversations';
import { UserUtils } from '../../session/utils'; import { UserUtils } from '../../session/utils';
import { MessageAvatarSelectorProps } from '../../components/conversation/message/MessageAvatar'; import { MessageAvatarSelectorProps } from '../../components/conversation/message/MessageAvatar';
@ -188,6 +188,22 @@ export const getCallIsInFullScreen = createSelector(
(state: ConversationsStateType): boolean => state.callIsInFullScreen (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 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 * Returns false if the current conversation selected is not a group conversation, or none are selected

@ -1,5 +1,5 @@
import { createSelector } from 'reselect'; 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 { StagedAttachmentsStateType } from '../ducks/stagedAttachments';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { getSelectedConversationKey } from './conversations'; import { getSelectedConversationKey } from './conversations';

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

Loading…
Cancel
Save