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"