Skip to content

Commit

Permalink
feat: Show drafted message [WPB-10991] (#18574)
Browse files Browse the repository at this point in the history
* feat: Show drafted message [WPB-10991]

* improvements

* draft message improvements

* implementation

* feat(InputBar): enhance RichTextEditor with draft saving and emoji transformation

* refactor(InputBar): remove unnecessary key prop from RichTextEditor component

---------

Co-authored-by: Olaf Sulich <olafsulich@gmail.com>
  • Loading branch information
przemvs and olafsulich authored Jan 14, 2025
1 parent 105b1e2 commit 549fed0
Show file tree
Hide file tree
Showing 14 changed files with 318 additions and 37 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"switch-path": "1.2.0",
"tsyringe": "4.8.0",
"underscore": "1.13.7",
"use-debounce": "^10.0.4",
"uuid": "11.0.3",
"webgl-utils.js": "1.1.0",
"webrtc-adapter": "9.0.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@

import React, {
useEffect,
useMemo,
useRef,
useState,
MouseEvent as ReactMouseEvent,
Expand All @@ -31,6 +30,7 @@ import cx from 'classnames';

import {Avatar, AVATAR_SIZE, GroupAvatar} from 'Components/Avatar';
import {UserBlockedBadge} from 'Components/Badge';
import {CellDescription} from 'Components/ConversationListCell/components/CellDescription';
import {UserInfo} from 'Components/UserInfo';
import {useKoSubscribableChildren} from 'Util/ComponentUtil';
import {isKey, isOneOfKeys, KEY} from 'Util/KeyboardUtil';
Expand All @@ -39,7 +39,6 @@ import {noop, setContextMenuPosition} from 'Util/util';

import {StatusIcon} from './components/StatusIcon';

import {generateCellState} from '../../conversation/ConversationCellState';
import type {Conversation} from '../../entity/Conversation';
import {MediaType} from '../../media/MediaType';

Expand Down Expand Up @@ -105,8 +104,6 @@ export const ConversationListCell = ({
rightClick(conversation, event);
};

const cellState = useMemo(() => generateCellState(conversation), [unreadState, mutedState, isRequest]);

const onClickJoinCall = (event: React.MouseEvent) => {
event.preventDefault();
onJoinCall(conversation, MediaType.AUDIO);
Expand Down Expand Up @@ -201,16 +198,13 @@ export const ConversationListCell = ({
</span>
)}

{cellState.description && (
<span
className={cx('conversation-list-cell-description', {
'conversation-list-cell-description--active': isActive,
})}
data-uie-name="secondary-line"
>
{cellState.description}
</span>
)}
<CellDescription
conversation={conversation}
mutedState={mutedState}
isActive={isActive}
isRequest={isRequest}
unreadState={unreadState}
/>
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Wire
* Copyright (C) 2025 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import {CSSObject} from '@emotion/react';

export const iconStyle: CSSObject = {
verticalAlign: 'middle',
marginRight: '8px',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Wire
* Copyright (C) 2025 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import {useMemo} from 'react';

import cx from 'classnames';

import * as Icon from 'Components/Icon';
import {DraftState, generateConversationInputStorageKey} from 'Components/InputBar/util/DraftStateUtil';
import {useLocalStorage} from 'Hooks/useLocalStorage';

import {iconStyle} from './CellDescription.style';

import {generateCellState} from '../../../../conversation/ConversationCellState';
import {Conversation, UnreadState} from '../../../../entity/Conversation';

interface Props {
conversation: Conversation;
mutedState: number;
isActive: boolean;
isRequest: boolean;
unreadState: UnreadState;
}

export const CellDescription = ({conversation, mutedState, isActive, isRequest, unreadState}: Props) => {
const cellState = useMemo(() => generateCellState(conversation), [unreadState, mutedState, isRequest]);

const storageKey = generateConversationInputStorageKey(conversation);
// Hardcoded __amplify__ because of StorageUtil saving as __amplify__<storage_key>
const [store] = useLocalStorage<{data?: DraftState}>(`__amplify__${storageKey}`);

const draftMessage = store?.data?.plainMessage;
const currentConversationDraftMessage = isActive ? '' : draftMessage;

if (!cellState.description && !currentConversationDraftMessage) {
return null;
}

return (
<span
className={cx('conversation-list-cell-description', {
'conversation-list-cell-description--active': isActive,
})}
data-uie-name="secondary-line"
>
{!cellState.description && currentConversationDraftMessage && <Icon.DraftMessageIcon css={iconStyle} />}
{cellState.description || currentConversationDraftMessage}
</span>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Wire
* Copyright (C) 2025 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

export * from './CellDescription';
14 changes: 11 additions & 3 deletions src/script/components/InputBar/InputBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {CONVERSATION_TYPING_INDICATOR_MODE} from 'src/script/user/TypingIndicato
import {useKoSubscribableChildren} from 'Util/ComponentUtil';
import {KEY} from 'Util/KeyboardUtil';
import {t} from 'Util/LocalizerUtil';
import {sanitizeMarkdown} from 'Util/MarkdownUtil';
import {formatLocale, TIME_IN_MILLIS} from 'Util/TimeUtil';
import {getFileExtension} from 'Util/util';

Expand Down Expand Up @@ -186,7 +187,7 @@ export const InputBar = ({
? textValue.length > 0
: textValue.length > 0 && textValue.length <= CONFIG.GIPHY_TEXT_LENGTH;

const shouldReplaceEmoji = useUserPropertyValue(
const shouldReplaceEmoji = useUserPropertyValue<boolean>(
() => propertiesRepository.getPreference(PROPERTIES_TYPE.EMOJI.REPLACE_INLINE),
WebAppEvents.PROPERTIES.UPDATE.EMOJI.REPLACE_INLINE,
);
Expand Down Expand Up @@ -459,8 +460,15 @@ export const InputBar = ({
};
}, []);

const saveDraft = async (editorState: string) => {
await saveDraftState(storageRepository, conversation, editorState, replyMessageEntity?.id, editedMessage?.id);
const saveDraft = async (editorState: string, plainMessage: string) => {
await saveDraftState({
storageRepository,
conversation,
editorState,
plainMessage: sanitizeMarkdown(plainMessage),
replyId: replyMessageEntity?.id,
editedMessageId: editedMessage?.id,
});
};

const loadDraft = async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,16 @@ import {EditedMessagePlugin} from './plugins/EditedMessagePlugin/EditedMessagePl
import {EmojiPickerPlugin} from './plugins/EmojiPickerPlugin';
import {GlobalEventsPlugin} from './plugins/GlobalEventsPlugin/GlobalEventsPlugin';
import {HistoryPlugin} from './plugins/HistoryPlugin/HistoryPlugin';
import {findAndTransformEmoji, ReplaceEmojiPlugin} from './plugins/InlineEmojiReplacementPlugin';
import {ReplaceEmojiPlugin} from './plugins/InlineEmojiReplacementPlugin';
import {ListItemTabIndentationPlugin} from './plugins/ListIndentationPlugin/ListIndentationPlugin';
import {ListMaxIndentLevelPlugin} from './plugins/ListMaxIndentLevelPlugin/ListMaxIndentLevelPlugin';
import {MentionsPlugin} from './plugins/MentionsPlugin';
import {ReplaceCarriageReturnPlugin} from './plugins/ReplaceCarriageReturnPlugin/ReplaceCarriageReturnPlugin';
import {SendPlugin} from './plugins/SendPlugin/SendPlugin';
import {markdownTransformers} from './utils/markdownTransformers';
import {parseMentions} from './utils/parseMentions';
import {transformMessage} from './utils/transformMessage';
import {useEditorDraftState} from './utils/useEditorDraftState';

import {MentionEntity} from '../../../../message/MentionEntity';

Expand All @@ -64,14 +66,14 @@ export type RichTextContent = {

interface RichTextEditorProps {
placeholder: string;
replaceEmojis?: boolean;
replaceEmojis: boolean;
editedMessage?: ContentMessage;
children: ReactElement;
hasLocalEphemeralTimer: boolean;
showFormatToolbar: boolean;
showMarkdownPreview: boolean;
getMentionCandidates: (search?: string | null) => User[];
saveDraftState: (editor: string) => void;
saveDraftState: (editor: string, plainMessage: string) => void;
loadDraftState: () => Promise<DraftState>;
onUpdate: (content: RichTextContent) => void;
onArrowUp: () => void;
Expand Down Expand Up @@ -105,20 +107,28 @@ export const RichTextEditor = ({
const emojiPickerOpen = useRef<boolean>(true);
const mentionsOpen = useRef<boolean>(true);

const handleChange = (editorState: EditorState) => {
saveDraftState(JSON.stringify(editorState.toJSON()));
const {saveDraft} = useEditorDraftState({
editorRef,
saveDraftState,
replaceEmojis,
});

const handleChange = (editorState: EditorState) => {
editorState.read(() => {
if (!editorRef.current) {
return;
}

const markdown = $convertToMarkdownString(markdownTransformers);

const text = transformMessage({replaceEmojis, markdown});

onUpdate({
text: replaceEmojis ? findAndTransformEmoji(markdown) : markdown,
mentions: parseMentions(editorRef.current!, markdown, getMentionCandidates()),
text,
mentions: parseMentions(editorRef.current, markdown, getMentionCandidates()),
});

saveDraft();
});
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Wire
* Copyright (C) 2025 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import {findAndTransformEmoji} from '../plugins/InlineEmojiReplacementPlugin';

export const transformMessage = ({replaceEmojis, markdown}: {replaceEmojis: boolean; markdown: string}) => {
return replaceEmojis ? findAndTransformEmoji(markdown) : markdown;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Wire
* Copyright (C) 2025 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import {useEffect, RefObject, useCallback} from 'react';

import {$convertToMarkdownString} from '@lexical/markdown';
import {LexicalEditor} from 'lexical';
import {useDebouncedCallback} from 'use-debounce';

import {markdownTransformers} from './markdownTransformers';
import {transformMessage} from './transformMessage';

const DRAFT_SAVE_DELAY = 800;

interface UseEditorDraftStateProps {
editorRef: RefObject<LexicalEditor | null>;
saveDraftState: (editorState: string, plainMessage: string) => void;
replaceEmojis: boolean;
}

export const useEditorDraftState = ({editorRef, saveDraftState, replaceEmojis}: UseEditorDraftStateProps) => {
const saveDraft = useCallback(() => {
const editor = editorRef.current;
if (!editor) {
return;
}

editor.getEditorState().read(() => {
const markdown = $convertToMarkdownString(markdownTransformers);
saveDraftState(JSON.stringify(editor.getEditorState().toJSON()), transformMessage({replaceEmojis, markdown}));
});
}, [editorRef, saveDraftState, replaceEmojis]);

const debouncedSaveDraftState = useDebouncedCallback(saveDraft, DRAFT_SAVE_DELAY);

useEffect(() => {
return () => {
debouncedSaveDraftState.flush();
};
}, [debouncedSaveDraftState, saveDraft]);

return {
saveDraft: debouncedSaveDraftState,
};
};
Loading

0 comments on commit 549fed0

Please sign in to comment.