diff --git a/package.json b/package.json
index 2710170c506..88cb079543c 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/script/components/ConversationListCell/ConversationListCell.tsx b/src/script/components/ConversationListCell/ConversationListCell.tsx
index 9234ace1824..16bd1d76930 100644
--- a/src/script/components/ConversationListCell/ConversationListCell.tsx
+++ b/src/script/components/ConversationListCell/ConversationListCell.tsx
@@ -19,7 +19,6 @@
import React, {
useEffect,
- useMemo,
useRef,
useState,
MouseEvent as ReactMouseEvent,
@@ -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';
@@ -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';
@@ -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);
@@ -201,16 +198,13 @@ export const ConversationListCell = ({
)}
- {cellState.description && (
-
- {cellState.description}
-
- )}
+
diff --git a/src/script/components/ConversationListCell/components/CellDescription/CellDescription.style.ts b/src/script/components/ConversationListCell/components/CellDescription/CellDescription.style.ts
new file mode 100644
index 00000000000..ec6c06b35b2
--- /dev/null
+++ b/src/script/components/ConversationListCell/components/CellDescription/CellDescription.style.ts
@@ -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',
+};
diff --git a/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx b/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx
new file mode 100644
index 00000000000..95638d9d69c
--- /dev/null
+++ b/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx
@@ -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__
+ const [store] = useLocalStorage<{data?: DraftState}>(`__amplify__${storageKey}`);
+
+ const draftMessage = store?.data?.plainMessage;
+ const currentConversationDraftMessage = isActive ? '' : draftMessage;
+
+ if (!cellState.description && !currentConversationDraftMessage) {
+ return null;
+ }
+
+ return (
+
+ {!cellState.description && currentConversationDraftMessage && }
+ {cellState.description || currentConversationDraftMessage}
+
+ );
+};
diff --git a/src/script/components/ConversationListCell/components/CellDescription/index.tsx b/src/script/components/ConversationListCell/components/CellDescription/index.tsx
new file mode 100644
index 00000000000..eb825e22aec
--- /dev/null
+++ b/src/script/components/ConversationListCell/components/CellDescription/index.tsx
@@ -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';
diff --git a/src/script/components/InputBar/InputBar.tsx b/src/script/components/InputBar/InputBar.tsx
index bc6c79e498e..01489904a82 100644
--- a/src/script/components/InputBar/InputBar.tsx
+++ b/src/script/components/InputBar/InputBar.tsx
@@ -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';
@@ -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(
() => propertiesRepository.getPreference(PROPERTIES_TYPE.EMOJI.REPLACE_INLINE),
WebAppEvents.PROPERTIES.UPDATE.EMOJI.REPLACE_INLINE,
);
@@ -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 () => {
diff --git a/src/script/components/InputBar/components/RichTextEditor/RichTextEditor.tsx b/src/script/components/InputBar/components/RichTextEditor/RichTextEditor.tsx
index e911c13d07f..bfd2e952500 100644
--- a/src/script/components/InputBar/components/RichTextEditor/RichTextEditor.tsx
+++ b/src/script/components/InputBar/components/RichTextEditor/RichTextEditor.tsx
@@ -46,7 +46,7 @@ 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';
@@ -54,6 +54,8 @@ import {ReplaceCarriageReturnPlugin} from './plugins/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';
@@ -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;
onUpdate: (content: RichTextContent) => void;
onArrowUp: () => void;
@@ -105,9 +107,13 @@ export const RichTextEditor = ({
const emojiPickerOpen = useRef(true);
const mentionsOpen = useRef(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;
@@ -115,10 +121,14 @@ export const RichTextEditor = ({
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();
});
};
diff --git a/src/script/components/InputBar/components/RichTextEditor/utils/transformMessage.ts b/src/script/components/InputBar/components/RichTextEditor/utils/transformMessage.ts
new file mode 100644
index 00000000000..2984b10c42a
--- /dev/null
+++ b/src/script/components/InputBar/components/RichTextEditor/utils/transformMessage.ts
@@ -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;
+};
diff --git a/src/script/components/InputBar/components/RichTextEditor/utils/useEditorDraftState.ts b/src/script/components/InputBar/components/RichTextEditor/utils/useEditorDraftState.ts
new file mode 100644
index 00000000000..facf73ae535
--- /dev/null
+++ b/src/script/components/InputBar/components/RichTextEditor/utils/useEditorDraftState.ts
@@ -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;
+ 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,
+ };
+};
diff --git a/src/script/components/InputBar/util/DraftStateUtil.ts b/src/script/components/InputBar/util/DraftStateUtil.ts
index 0f8129dc83d..33ae7c8907f 100644
--- a/src/script/components/InputBar/util/DraftStateUtil.ts
+++ b/src/script/components/InputBar/util/DraftStateUtil.ts
@@ -26,24 +26,38 @@ export interface DraftState {
editorState: string | null;
messageReply?: ContentMessage;
editedMessage?: ContentMessage;
+ plainMessage?: string;
}
-const generateConversationInputStorageKey = (conversationEntity: Conversation): string =>
+export const generateConversationInputStorageKey = (conversationEntity: Conversation): string =>
`${StorageKey.CONVERSATION.INPUT}|${conversationEntity.id}`;
-export const saveDraftState = async (
- storageRepository: StorageRepository,
- conversation: Conversation,
- editorState: string,
- replyMessageId?: string,
- editedMessageId?: string,
-): Promise => {
+type SaveDraftState = {
+ storageRepository: StorageRepository;
+ conversation: Conversation;
+ editorState: string;
+ plainMessage: string;
+ replyId?: string;
+ editedMessageId?: string;
+};
+
+export const saveDraftState = async ({
+ storageRepository,
+ conversation,
+ editorState,
+ plainMessage,
+ replyId,
+ editedMessageId,
+}: SaveDraftState): Promise => {
// we only save state for newly written messages
const storageKey = generateConversationInputStorageKey(conversation);
- await storageRepository.storageService.saveToSimpleStorage(storageKey, {
+ await storageRepository.storageService.saveToSimpleStorage<
+ Omit
+ >(storageKey, {
editorState,
- replyId: replyMessageId,
+ plainMessage,
+ replyId,
editedMessageId,
});
};
@@ -81,5 +95,5 @@ export const loadDraftState = async (
editedMessage = await loadMessage(editedMessageId);
}
- return {...storageValue, messageReply, editedMessage};
+ return {...storageValue, messageReply, editedMessage, plainMessage: storageValue?.plainMessage || ''};
};
diff --git a/src/script/entity/Conversation.ts b/src/script/entity/Conversation.ts
index 7e51e23faeb..e25f9dd4d15 100644
--- a/src/script/entity/Conversation.ts
+++ b/src/script/entity/Conversation.ts
@@ -63,7 +63,7 @@ import {ContentState, useAppState} from '../page/useAppState';
import {ConversationRecord} from '../storage/record/ConversationRecord';
import {TeamState} from '../team/TeamState';
-interface UnreadState {
+export interface UnreadState {
allEvents: Message[];
allMessages: ContentMessage[];
calls: CallMessage[];
diff --git a/src/script/hooks/useLocalStorage.ts b/src/script/hooks/useLocalStorage.ts
new file mode 100644
index 00000000000..e46a825b348
--- /dev/null
+++ b/src/script/hooks/useLocalStorage.ts
@@ -0,0 +1,48 @@
+/*
+ * 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 {useSyncExternalStore} from 'react';
+
+const parseJSON = (key: string, value: string | null): Value | null => {
+ try {
+ return value === null ? null : JSON.parse(value);
+ } catch {
+ console.error(`Error parsing JSON for key "${key}"`);
+ return null;
+ }
+};
+
+export const useLocalStorage = (key: string) => {
+ const setLocalStorage = (newValue: T): void => {
+ const serializedValue = JSON.stringify(newValue);
+ window.localStorage.setItem(key, serializedValue);
+ window.dispatchEvent(new StorageEvent('storage', {key, newValue: serializedValue}));
+ };
+
+ const getSnapshot = () => localStorage.getItem(key);
+
+ const subscribe = (listener: () => void) => {
+ window.addEventListener('storage', listener);
+ return () => window.removeEventListener('storage', listener);
+ };
+
+ const store = useSyncExternalStore(subscribe, getSnapshot);
+
+ return [parseJSON(key, store), setLocalStorage] as const;
+};
diff --git a/src/script/page/LeftSidebar/panels/Conversations/Conversations.tsx b/src/script/page/LeftSidebar/panels/Conversations/Conversations.tsx
index 0fa90a7d61d..7719b9ef94e 100644
--- a/src/script/page/LeftSidebar/panels/Conversations/Conversations.tsx
+++ b/src/script/page/LeftSidebar/panels/Conversations/Conversations.tsx
@@ -26,6 +26,7 @@ import {useShallow} from 'zustand/react/shallow';
import {useMatchMedia} from '@wireapp/react-ui-kit';
import {WebAppEvents} from '@wireapp/webapp-events';
+import {useConversationFocus} from 'Hooks/useConversationFocus';
import {IntegrationRepository} from 'src/script/integration/IntegrationRepository';
import {Preferences} from 'src/script/page/LeftSidebar/panels/Preferences';
import {StartUI} from 'src/script/page/LeftSidebar/panels/StartUI';
@@ -55,7 +56,6 @@ import {ConversationRepository} from '../../../../conversation/ConversationRepos
import {ConversationState} from '../../../../conversation/ConversationState';
import type {Conversation} from '../../../../entity/Conversation';
import {User} from '../../../../entity/User';
-import {useConversationFocus} from '../../../../hooks/useConversationFocus';
import {PreferenceNotificationRepository} from '../../../../notification/PreferenceNotificationRepository';
import {PropertiesRepository} from '../../../../properties/PropertiesRepository';
import {generateConversationUrl} from '../../../../router/routeGenerator';
diff --git a/yarn.lock b/yarn.lock
index 15232a983d3..bfe79195f78 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -18175,6 +18175,15 @@ __metadata:
languageName: node
linkType: hard
+"use-debounce@npm:^10.0.4":
+ version: 10.0.4
+ resolution: "use-debounce@npm:10.0.4"
+ peerDependencies:
+ react: "*"
+ checksum: 10/e193bbed307204b655abab545704ca608b23db10680f299f4b9b48b935a9fd68b68827c79de3bf5517010524bfa15709bf6b0ab1f915d6305b5d2ce27bf11ad7
+ languageName: node
+ linkType: hard
+
"use-isomorphic-layout-effect@npm:^1.1.2":
version: 1.1.2
resolution: "use-isomorphic-layout-effect@npm:1.1.2"
@@ -18884,6 +18893,7 @@ __metadata:
tsyringe: "npm:4.8.0"
typescript: "npm:5.5.2"
underscore: "npm:1.13.7"
+ use-debounce: "npm:^10.0.4"
uuid: "npm:11.0.3"
webgl-utils.js: "npm:1.1.0"
webpack: "npm:5.97.1"