From f358a6c2a3214ed0ae2508cd4e64a3b82bdd4c31 Mon Sep 17 00:00:00 2001 From: Przemyslaw Jozwik Date: Thu, 9 Jan 2025 14:30:07 +0100 Subject: [PATCH 1/6] feat: Show drafted message [WPB-10991] --- app-config/package.json | 4 +- .../ConversationListCell.tsx | 22 ++-- .../CellDescription/CellDescription.style.ts | 25 ++++ .../CellDescription/CellDescription.tsx | 83 +++++++++++++ .../components/CellDescription/index.tsx | 20 +++ src/script/components/InputBar/InputBar.tsx | 1 + .../InputBar/util/DraftStateUtil.ts | 22 +++- .../RichTextEditor/RichTextEditor.tsx | 19 +-- .../conversation/ConversationCellState.ts | 114 ++++++++++++------ src/script/entity/Conversation.ts | 2 +- src/script/hooks/useMessageDraftState.ts | 59 +++++++++ 11 files changed, 308 insertions(+), 63 deletions(-) create mode 100644 src/script/components/ConversationListCell/components/CellDescription/CellDescription.style.ts create mode 100644 src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx create mode 100644 src/script/components/ConversationListCell/components/CellDescription/index.tsx create mode 100644 src/script/hooks/useMessageDraftState.ts diff --git a/app-config/package.json b/app-config/package.json index f7d3f12bd47..3c250179746 100644 --- a/app-config/package.json +++ b/app-config/package.json @@ -1,6 +1,6 @@ { "dependencies": { - "wire-web-config-default-master": "https://github.com/wireapp/wire-web-config-wire#v0.32.1", - "wire-web-config-default-staging": "https://github.com/wireapp/wire-web-config-default#v0.32.1" + "wire-web-config-default-master": "https://github.com/wireapp/wire-web-config-wire#v0.32.2", + "wire-web-config-default-staging": "https://github.com/wireapp/wire-web-config-default#v0.32.2" } } 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..0e8ceccf632 --- /dev/null +++ b/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx @@ -0,0 +1,83 @@ +/* + * 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 {useCallback, useEffect, useMemo} from 'react'; + +import cx from 'classnames'; +import {container} from 'tsyringe'; + +import * as Icon from 'Components/Icon'; +import {generateConversationInputStorageKey, getDraftTextMessageContent} from 'Components/InputBar/util/DraftStateUtil'; +import {useMessageDraftState} from 'Hooks/useMessageDraftState'; + +import {iconStyle} from './CellDescription.style'; + +import {generateCellState} from '../../../../conversation/ConversationCellState'; +import {Conversation, UnreadState} from '../../../../entity/Conversation'; +import {StorageService} from '../../../../storage'; + +interface Props { + conversation: Conversation; + mutedState: number; + isActive: boolean; + isRequest: boolean; + unreadState: UnreadState; +} + +export const CellDescription = ({conversation, mutedState, isActive, isRequest, unreadState}: Props) => { + const storageService = container.resolve(StorageService); + const {draftMessage} = useMessageDraftState(); + const currentConversationDraftMessage = isActive ? '' : draftMessage[conversation.id]; + + const cellState = useMemo( + () => generateCellState(conversation, currentConversationDraftMessage), + [unreadState, mutedState, isRequest, currentConversationDraftMessage], + ); + + const getDraftMessageContent = useCallback(async () => { + const storageKey = generateConversationInputStorageKey(conversation); + const storageValue = await storageService.loadFromSimpleStorage(storageKey); + + if (typeof storageValue === 'undefined') { + return; + } + + getDraftTextMessageContent(conversation, storageValue.editorState); + }, [conversation, storageService]); + + useEffect(() => { + void getDraftMessageContent(); + }, [getDraftMessageContent]); + + if (!cellState.description) { + return null; + } + + return ( + + {currentConversationDraftMessage && } + {cellState.description} + + ); +}; 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 ecd73797558..2bcace9bfee 100644 --- a/src/script/components/InputBar/InputBar.tsx +++ b/src/script/components/InputBar/InputBar.tsx @@ -599,6 +599,7 @@ export const InputBar = ({ {!isSelfUserRemoved && !pastedFile && ( { editorRef.current = lexical; }} diff --git a/src/script/components/InputBar/util/DraftStateUtil.ts b/src/script/components/InputBar/util/DraftStateUtil.ts index 0f8129dc83d..de508dcec14 100644 --- a/src/script/components/InputBar/util/DraftStateUtil.ts +++ b/src/script/components/InputBar/util/DraftStateUtil.ts @@ -17,6 +17,10 @@ * */ +import {$getRoot, createEditor} from 'lexical'; + +import {useMessageDraftState} from 'Hooks/useMessageDraftState'; + import {MessageRepository} from '../../../conversation/MessageRepository'; import {Conversation} from '../../../entity/Conversation'; import {ContentMessage} from '../../../entity/message/ContentMessage'; @@ -28,7 +32,7 @@ export interface DraftState { editedMessage?: ContentMessage; } -const generateConversationInputStorageKey = (conversationEntity: Conversation): string => +export const generateConversationInputStorageKey = (conversationEntity: Conversation): string => `${StorageKey.CONVERSATION.INPUT}|${conversationEntity.id}`; export const saveDraftState = async ( @@ -83,3 +87,19 @@ export const loadDraftState = async ( return {...storageValue, messageReply, editedMessage}; }; + +export const getDraftTextMessageContent = (conversation: Conversation, storageValue: string) => { + const config = { + namespace: `DraftEditor-${conversation.id}`, + theme: {}, + onError: console.error, + }; + + const editor = createEditor(config); + const editorState = editor.parseEditorState(storageValue); + const textContent = editorState.read(() => $getRoot().getTextContent()); + + const {setDraftMessage} = useMessageDraftState.getState(); + + setDraftMessage(conversation.id, textContent); +}; diff --git a/src/script/components/RichTextEditor/RichTextEditor.tsx b/src/script/components/RichTextEditor/RichTextEditor.tsx index e87253d87d4..d2dc8371658 100644 --- a/src/script/components/RichTextEditor/RichTextEditor.tsx +++ b/src/script/components/RichTextEditor/RichTextEditor.tsx @@ -38,6 +38,7 @@ import cx from 'classnames'; import {LexicalEditor, EditorState, $nodesOfType} from 'lexical'; import {DraftState} from 'Components/InputBar/util/DraftStateUtil'; +import {useMessageDraftState} from 'Hooks/useMessageDraftState'; import {ContentMessage} from 'src/script/entity/message/ContentMessage'; import {User} from 'src/script/entity/User'; import {getLogger} from 'Util/Logger'; @@ -56,6 +57,7 @@ import {MentionsPlugin} from './plugins/MentionsPlugin'; import {ReplaceCarriageReturnPlugin} from './plugins/ReplaceCarriageReturnPlugin/ReplaceCarriageReturnPlugin'; import {SendPlugin} from './plugins/SendPlugin'; +import {Conversation} from '../../entity/Conversation'; import {MentionEntity} from '../../message/MentionEntity'; const theme = { @@ -93,6 +95,7 @@ export type RichTextContent = { const logger = getLogger('LexicalInput'); interface RichTextEditorProps { + conversation: Conversation; placeholder: string; replaceEmojis?: boolean; editedMessage?: ContentMessage; @@ -158,6 +161,7 @@ const editorConfig: InitialConfigType = { }; export const RichTextEditor = ({ + conversation, placeholder, children, hasLocalEphemeralTimer, @@ -175,23 +179,22 @@ export const RichTextEditor = ({ onSend, onSetup = () => {}, }: RichTextEditorProps) => { - const editorRef = useRef(null); const emojiPickerOpen = useRef(true); const mentionsOpen = useRef(true); - const handleChange = (editorState: EditorState) => { + const {setDraftMessage} = useMessageDraftState(); + + const handleChange = (editorState: EditorState, editor: LexicalEditor) => { saveDraftState(JSON.stringify(editorState.toJSON())); editorState.read(() => { - if (!editorRef.current) { - return; - } - const markdown = $convertToMarkdownString(TRANSFORMERS); + setDraftMessage(conversation.id, replaceEmojis ? findAndTransformEmoji(markdown) : markdown); + onUpdate({ text: replaceEmojis ? findAndTransformEmoji(markdown) : markdown, - mentions: parseMentions(editorRef.current!, markdown, getMentionCandidates()), + mentions: parseMentions(editor, markdown, getMentionCandidates()), }); }); }; @@ -204,7 +207,7 @@ export const RichTextEditor = ({ { - editorRef.current = editor; + // editorRef.current = editor; onSetup(editor!); }} /> diff --git a/src/script/conversation/ConversationCellState.ts b/src/script/conversation/ConversationCellState.ts index e32a77dfc8a..6721cdb252e 100644 --- a/src/script/conversation/ConversationCellState.ts +++ b/src/script/conversation/ConversationCellState.ts @@ -141,6 +141,20 @@ const _generateSummaryDescription = (activities: Record): .join(', '); }; +const _matchAlertState = (conversation: Conversation) => { + const { + calls: unreadCalls, + pings: unreadPings, + selfMentions: unreadSelfMentions, + selfReplies: unreadSelfReplies, + } = conversation.unreadState(); + + const hasUnreadActivities = + unreadCalls.length > 0 || unreadPings.length > 0 || unreadSelfMentions.length > 0 || unreadSelfReplies.length > 0; + + return hasUnreadActivities; +}; + const _getStateAlert = { description: (conversationEntity: Conversation) => _accumulateSummary(conversationEntity, true), icon: (conversationEntity: Conversation): ConversationStatusIcon | void => { @@ -167,19 +181,7 @@ const _getStateAlert = { return ConversationStatusIcon.UNREAD_PING; } }, - match: (conversationEntity: Conversation) => { - const { - calls: unreadCalls, - pings: unreadPings, - selfMentions: unreadSelfMentions, - selfReplies: unreadSelfReplies, - } = conversationEntity.unreadState(); - - const hasUnreadActivities = - unreadCalls.length > 0 || unreadPings.length > 0 || unreadSelfMentions.length > 0 || unreadSelfReplies.length > 0; - - return hasUnreadActivities; - }, + match: (conversationEntity: Conversation) => _matchAlertState(conversationEntity), }; const _getStateDefault = { @@ -187,6 +189,14 @@ const _getStateDefault = { icon: () => ConversationStatusIcon.NONE, }; +const _matchGroupActivityState = (conversation: Conversation) => { + const lastMessageEntity = conversation.getNewestMessage(); + const isExpectedType = lastMessageEntity ? lastMessageEntity.isMember() || lastMessageEntity.isSystem() : false; + const unreadEvents = conversation.unreadState().allEvents; + + return conversation.isGroup() && unreadEvents.length > 0 && isExpectedType; +}; + const _getStateGroupActivity = { description: (conversationEntity: Conversation): string => { const lastMessageEntity = conversationEntity.getNewestMessage(); @@ -255,13 +265,11 @@ const _getStateGroupActivity = { ? ConversationStatusIcon.UNREAD_MESSAGES : ConversationStatusIcon.MUTED; }, - match: (conversationEntity: Conversation) => { - const lastMessageEntity = conversationEntity.getNewestMessage(); - const isExpectedType = lastMessageEntity ? lastMessageEntity.isMember() || lastMessageEntity.isSystem() : false; - const unreadEvents = conversationEntity.unreadState().allEvents; + match: (conversationEntity: Conversation) => _matchGroupActivityState(conversationEntity), +}; - return conversationEntity.isGroup() && unreadEvents.length > 0 && isExpectedType; - }, +const _matchMutedState = (conversation: Conversation) => { + return !conversation.showNotificationsEverything(); }; const _getStateMuted = { @@ -284,7 +292,11 @@ const _getStateMuted = { return ConversationStatusIcon.MUTED; }, - match: (conversationEntity: Conversation) => !conversationEntity.showNotificationsEverything(), + match: (conversationEntity: Conversation) => _matchMutedState(conversationEntity), +}; + +const _matchRemovedState = (conversation: Conversation) => { + return conversation.isSelfUserRemoved(); }; const _getStateRemoved = { @@ -306,7 +318,13 @@ const _getStateRemoved = { return ''; }, icon: () => ConversationStatusIcon.UNREAD_MESSAGES, - match: (conversationEntity: Conversation) => conversationEntity.isSelfUserRemoved(), + match: (conversationEntity: Conversation) => _matchRemovedState(conversationEntity), +}; + +const _matchUnreadMessageState = (conversation: Conversation) => { + const {allMessages, systemMessages} = conversation.unreadState(); + const hasUnreadMessages = [...allMessages, ...systemMessages].length > 0; + return hasUnreadMessages; }; const _getStateUnreadMessage = { @@ -366,11 +384,16 @@ const _getStateUnreadMessage = { return ''; }, icon: () => ConversationStatusIcon.UNREAD_MESSAGES, - match: (conversationEntity: Conversation) => { - const {allMessages, systemMessages} = conversationEntity.unreadState(); - const hasUnreadMessages = [...allMessages, ...systemMessages].length > 0; - return hasUnreadMessages; - }, + match: (conversationEntity: Conversation) => _matchUnreadMessageState(conversationEntity), +}; + +const _matchUserNameState = (conversation: Conversation) => { + const lastMessageEntity = conversation.getNewestMessage(); + const isMemberJoin = + lastMessageEntity && lastMessageEntity.isMember() && (lastMessageEntity as MemberMessage).isMemberJoin(); + const isEmpty1to1Conversation = conversation.is1to1() && isMemberJoin; + + return conversation.isRequest() || !!isEmpty1to1Conversation; }; const _getStateUserName = { @@ -384,20 +407,34 @@ const _getStateUserName = { return ConversationStatusIcon.PENDING_CONNECTION; } }, - match: (conversationEntity: Conversation): boolean => { - const lastMessageEntity = conversationEntity.getNewestMessage(); - const isMemberJoin = - lastMessageEntity && lastMessageEntity.isMember() && (lastMessageEntity as MemberMessage).isMemberJoin(); - const isEmpty1to1Conversation = conversationEntity.is1to1() && isMemberJoin; + match: (conversationEntity: Conversation): boolean => _matchUserNameState(conversationEntity), +}; - return conversationEntity.isRequest() || isEmpty1to1Conversation; +const _getStateDraftMessage = (draftState: any) => ({ + description: (): string => draftState || '', + icon: () => ConversationStatusIcon.NONE, + match: (conversationEntity: Conversation): boolean => { + const matchedAlertState = _matchAlertState(conversationEntity); + const matchedGroupActivityState = _matchGroupActivityState(conversationEntity); + const matchedRemovedState = _matchRemovedState(conversationEntity); + const matchedUnreadMessageState = _matchUnreadMessageState(conversationEntity); + const matchedUserNameState = _matchUserNameState(conversationEntity); + + return !( + matchedAlertState || + matchedGroupActivityState || + matchedRemovedState || + matchedUnreadMessageState || + matchedUserNameState + ); }, -}; +}); export const generateCellState = ( conversationEntity: Conversation, + draftState?: any, ): {description: string; icon: ConversationStatusIcon | void} => { - const states = [ + const iconStates = [ _getStateRemoved, _getStateMuted, _getStateAlert, @@ -406,10 +443,13 @@ export const generateCellState = ( _getStateUserName, ]; - const matchingState = states.find(state => state.match(conversationEntity)) || _getStateDefault; + const descriptionStates = [_getStateDraftMessage(draftState), ...iconStates]; + + const matchingDescriptionState = descriptionStates.find(state => state.match(conversationEntity)) || _getStateDefault; + const matchingIconState = iconStates.find(state => state.match(conversationEntity)) || _getStateDefault; return { - description: matchingState.description(conversationEntity), - icon: matchingState.icon(conversationEntity), + description: matchingDescriptionState.description(conversationEntity), + icon: matchingIconState.icon(conversationEntity), }; }; 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/useMessageDraftState.ts b/src/script/hooks/useMessageDraftState.ts new file mode 100644 index 00000000000..11733f9c51c --- /dev/null +++ b/src/script/hooks/useMessageDraftState.ts @@ -0,0 +1,59 @@ +/* + * Wire + * Copyright (C) 2022 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/. + * + */ + +/* + * Wire + * Copyright (C) 2024 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 {create} from 'zustand'; + +type MessageDraftState = { + draftMessage: Record; + setDraftMessage: (conversationId: string, message: string) => void; +}; + +export const useMessageDraftState = create((set, get) => ({ + draftMessage: {}, + setDraftMessage: (conversationId: string, message: string) => { + const previousContentState = get().draftMessage; + + set(state => ({ + ...state, + draftMessage: { + ...previousContentState, + [conversationId]: message, + }, + })); + }, +})); From 6b358ef45fae24b159437b0a23a4872c2f1a2705 Mon Sep 17 00:00:00 2001 From: Przemyslaw Jozwik Date: Fri, 10 Jan 2025 12:06:04 +0100 Subject: [PATCH 2/6] improvements --- .../CellDescription/CellDescription.tsx | 11 +- .../RichTextEditor/RichTextEditor.tsx | 1 - .../conversation/ConversationCellState.ts | 114 ++++++------------ 3 files changed, 41 insertions(+), 85 deletions(-) diff --git a/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx b/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx index 0e8ceccf632..96e6f950684 100644 --- a/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx +++ b/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx @@ -45,10 +45,7 @@ export const CellDescription = ({conversation, mutedState, isActive, isRequest, const {draftMessage} = useMessageDraftState(); const currentConversationDraftMessage = isActive ? '' : draftMessage[conversation.id]; - const cellState = useMemo( - () => generateCellState(conversation, currentConversationDraftMessage), - [unreadState, mutedState, isRequest, currentConversationDraftMessage], - ); + const cellState = useMemo(() => generateCellState(conversation), [unreadState, mutedState, isRequest]); const getDraftMessageContent = useCallback(async () => { const storageKey = generateConversationInputStorageKey(conversation); @@ -65,7 +62,7 @@ export const CellDescription = ({conversation, mutedState, isActive, isRequest, void getDraftMessageContent(); }, [getDraftMessageContent]); - if (!cellState.description) { + if (!cellState.description && !currentConversationDraftMessage) { return null; } @@ -76,8 +73,8 @@ export const CellDescription = ({conversation, mutedState, isActive, isRequest, })} data-uie-name="secondary-line" > - {currentConversationDraftMessage && } - {cellState.description} + {!cellState.description && currentConversationDraftMessage && } + {cellState.description || currentConversationDraftMessage} ); }; diff --git a/src/script/components/RichTextEditor/RichTextEditor.tsx b/src/script/components/RichTextEditor/RichTextEditor.tsx index d2dc8371658..c32665c3e9c 100644 --- a/src/script/components/RichTextEditor/RichTextEditor.tsx +++ b/src/script/components/RichTextEditor/RichTextEditor.tsx @@ -207,7 +207,6 @@ export const RichTextEditor = ({ { - // editorRef.current = editor; onSetup(editor!); }} /> diff --git a/src/script/conversation/ConversationCellState.ts b/src/script/conversation/ConversationCellState.ts index 6721cdb252e..e32a77dfc8a 100644 --- a/src/script/conversation/ConversationCellState.ts +++ b/src/script/conversation/ConversationCellState.ts @@ -141,20 +141,6 @@ const _generateSummaryDescription = (activities: Record): .join(', '); }; -const _matchAlertState = (conversation: Conversation) => { - const { - calls: unreadCalls, - pings: unreadPings, - selfMentions: unreadSelfMentions, - selfReplies: unreadSelfReplies, - } = conversation.unreadState(); - - const hasUnreadActivities = - unreadCalls.length > 0 || unreadPings.length > 0 || unreadSelfMentions.length > 0 || unreadSelfReplies.length > 0; - - return hasUnreadActivities; -}; - const _getStateAlert = { description: (conversationEntity: Conversation) => _accumulateSummary(conversationEntity, true), icon: (conversationEntity: Conversation): ConversationStatusIcon | void => { @@ -181,7 +167,19 @@ const _getStateAlert = { return ConversationStatusIcon.UNREAD_PING; } }, - match: (conversationEntity: Conversation) => _matchAlertState(conversationEntity), + match: (conversationEntity: Conversation) => { + const { + calls: unreadCalls, + pings: unreadPings, + selfMentions: unreadSelfMentions, + selfReplies: unreadSelfReplies, + } = conversationEntity.unreadState(); + + const hasUnreadActivities = + unreadCalls.length > 0 || unreadPings.length > 0 || unreadSelfMentions.length > 0 || unreadSelfReplies.length > 0; + + return hasUnreadActivities; + }, }; const _getStateDefault = { @@ -189,14 +187,6 @@ const _getStateDefault = { icon: () => ConversationStatusIcon.NONE, }; -const _matchGroupActivityState = (conversation: Conversation) => { - const lastMessageEntity = conversation.getNewestMessage(); - const isExpectedType = lastMessageEntity ? lastMessageEntity.isMember() || lastMessageEntity.isSystem() : false; - const unreadEvents = conversation.unreadState().allEvents; - - return conversation.isGroup() && unreadEvents.length > 0 && isExpectedType; -}; - const _getStateGroupActivity = { description: (conversationEntity: Conversation): string => { const lastMessageEntity = conversationEntity.getNewestMessage(); @@ -265,11 +255,13 @@ const _getStateGroupActivity = { ? ConversationStatusIcon.UNREAD_MESSAGES : ConversationStatusIcon.MUTED; }, - match: (conversationEntity: Conversation) => _matchGroupActivityState(conversationEntity), -}; + match: (conversationEntity: Conversation) => { + const lastMessageEntity = conversationEntity.getNewestMessage(); + const isExpectedType = lastMessageEntity ? lastMessageEntity.isMember() || lastMessageEntity.isSystem() : false; + const unreadEvents = conversationEntity.unreadState().allEvents; -const _matchMutedState = (conversation: Conversation) => { - return !conversation.showNotificationsEverything(); + return conversationEntity.isGroup() && unreadEvents.length > 0 && isExpectedType; + }, }; const _getStateMuted = { @@ -292,11 +284,7 @@ const _getStateMuted = { return ConversationStatusIcon.MUTED; }, - match: (conversationEntity: Conversation) => _matchMutedState(conversationEntity), -}; - -const _matchRemovedState = (conversation: Conversation) => { - return conversation.isSelfUserRemoved(); + match: (conversationEntity: Conversation) => !conversationEntity.showNotificationsEverything(), }; const _getStateRemoved = { @@ -318,13 +306,7 @@ const _getStateRemoved = { return ''; }, icon: () => ConversationStatusIcon.UNREAD_MESSAGES, - match: (conversationEntity: Conversation) => _matchRemovedState(conversationEntity), -}; - -const _matchUnreadMessageState = (conversation: Conversation) => { - const {allMessages, systemMessages} = conversation.unreadState(); - const hasUnreadMessages = [...allMessages, ...systemMessages].length > 0; - return hasUnreadMessages; + match: (conversationEntity: Conversation) => conversationEntity.isSelfUserRemoved(), }; const _getStateUnreadMessage = { @@ -384,16 +366,11 @@ const _getStateUnreadMessage = { return ''; }, icon: () => ConversationStatusIcon.UNREAD_MESSAGES, - match: (conversationEntity: Conversation) => _matchUnreadMessageState(conversationEntity), -}; - -const _matchUserNameState = (conversation: Conversation) => { - const lastMessageEntity = conversation.getNewestMessage(); - const isMemberJoin = - lastMessageEntity && lastMessageEntity.isMember() && (lastMessageEntity as MemberMessage).isMemberJoin(); - const isEmpty1to1Conversation = conversation.is1to1() && isMemberJoin; - - return conversation.isRequest() || !!isEmpty1to1Conversation; + match: (conversationEntity: Conversation) => { + const {allMessages, systemMessages} = conversationEntity.unreadState(); + const hasUnreadMessages = [...allMessages, ...systemMessages].length > 0; + return hasUnreadMessages; + }, }; const _getStateUserName = { @@ -407,34 +384,20 @@ const _getStateUserName = { return ConversationStatusIcon.PENDING_CONNECTION; } }, - match: (conversationEntity: Conversation): boolean => _matchUserNameState(conversationEntity), -}; - -const _getStateDraftMessage = (draftState: any) => ({ - description: (): string => draftState || '', - icon: () => ConversationStatusIcon.NONE, match: (conversationEntity: Conversation): boolean => { - const matchedAlertState = _matchAlertState(conversationEntity); - const matchedGroupActivityState = _matchGroupActivityState(conversationEntity); - const matchedRemovedState = _matchRemovedState(conversationEntity); - const matchedUnreadMessageState = _matchUnreadMessageState(conversationEntity); - const matchedUserNameState = _matchUserNameState(conversationEntity); - - return !( - matchedAlertState || - matchedGroupActivityState || - matchedRemovedState || - matchedUnreadMessageState || - matchedUserNameState - ); + const lastMessageEntity = conversationEntity.getNewestMessage(); + const isMemberJoin = + lastMessageEntity && lastMessageEntity.isMember() && (lastMessageEntity as MemberMessage).isMemberJoin(); + const isEmpty1to1Conversation = conversationEntity.is1to1() && isMemberJoin; + + return conversationEntity.isRequest() || isEmpty1to1Conversation; }, -}); +}; export const generateCellState = ( conversationEntity: Conversation, - draftState?: any, ): {description: string; icon: ConversationStatusIcon | void} => { - const iconStates = [ + const states = [ _getStateRemoved, _getStateMuted, _getStateAlert, @@ -443,13 +406,10 @@ export const generateCellState = ( _getStateUserName, ]; - const descriptionStates = [_getStateDraftMessage(draftState), ...iconStates]; - - const matchingDescriptionState = descriptionStates.find(state => state.match(conversationEntity)) || _getStateDefault; - const matchingIconState = iconStates.find(state => state.match(conversationEntity)) || _getStateDefault; + const matchingState = states.find(state => state.match(conversationEntity)) || _getStateDefault; return { - description: matchingDescriptionState.description(conversationEntity), - icon: matchingIconState.icon(conversationEntity), + description: matchingState.description(conversationEntity), + icon: matchingState.icon(conversationEntity), }; }; From 688532e27ecbb7f113ccb978f19192186a4b7d96 Mon Sep 17 00:00:00 2001 From: Przemyslaw Jozwik Date: Mon, 13 Jan 2025 12:14:13 +0100 Subject: [PATCH 3/6] draft message improvements --- .../CellDescription/CellDescription.tsx | 31 +++------- src/script/components/InputBar/InputBar.tsx | 14 +++-- .../InputBar/util/DraftStateUtil.ts | 25 ++------ .../RichTextEditor/RichTextEditor.tsx | 12 +--- src/script/hooks/useLocalStorage.ts | 38 ++++++++++++ src/script/hooks/useMessageDraftState.ts | 59 ------------------- 6 files changed, 63 insertions(+), 116 deletions(-) create mode 100644 src/script/hooks/useLocalStorage.ts delete mode 100644 src/script/hooks/useMessageDraftState.ts diff --git a/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx b/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx index 96e6f950684..0e5df91f39f 100644 --- a/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx +++ b/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx @@ -17,20 +17,18 @@ * */ -import {useCallback, useEffect, useMemo} from 'react'; +import {useMemo} from 'react'; import cx from 'classnames'; -import {container} from 'tsyringe'; import * as Icon from 'Components/Icon'; -import {generateConversationInputStorageKey, getDraftTextMessageContent} from 'Components/InputBar/util/DraftStateUtil'; -import {useMessageDraftState} from 'Hooks/useMessageDraftState'; +import {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'; -import {StorageService} from '../../../../storage'; interface Props { conversation: Conversation; @@ -41,26 +39,15 @@ interface Props { } export const CellDescription = ({conversation, mutedState, isActive, isRequest, unreadState}: Props) => { - const storageService = container.resolve(StorageService); - const {draftMessage} = useMessageDraftState(); - const currentConversationDraftMessage = isActive ? '' : draftMessage[conversation.id]; - const cellState = useMemo(() => generateCellState(conversation), [unreadState, mutedState, isRequest]); - const getDraftMessageContent = useCallback(async () => { - const storageKey = generateConversationInputStorageKey(conversation); - const storageValue = await storageService.loadFromSimpleStorage(storageKey); - - if (typeof storageValue === 'undefined') { - return; - } - - getDraftTextMessageContent(conversation, storageValue.editorState); - }, [conversation, storageService]); + const storageKey = generateConversationInputStorageKey(conversation); + // Hardcoded __amplify__ because of StorageUtil saving as __amplify__ + const [store] = useLocalStorage(`__amplify__${storageKey}`); - useEffect(() => { - void getDraftMessageContent(); - }, [getDraftMessageContent]); + const parsedStore = store ? JSON.parse(store) : {}; + const draftMessage = parsedStore?.data?.plainMessage; + const currentConversationDraftMessage = isActive ? '' : draftMessage; if (!cellState.description && !currentConversationDraftMessage) { return null; diff --git a/src/script/components/InputBar/InputBar.tsx b/src/script/components/InputBar/InputBar.tsx index 2bcace9bfee..168fc001fea 100644 --- a/src/script/components/InputBar/InputBar.tsx +++ b/src/script/components/InputBar/InputBar.tsx @@ -127,7 +127,7 @@ export const InputBar = ({ 'isSelfUserRemoved', 'is1to1', ]); - const {isOutgoingRequest, isIncomingRequest} = useKoSubscribableChildren(connection, [ + const {isOutgoingRequest, isIncomingRequest} = useKoSubscribableChildren(connection!, [ 'isOutgoingRequest', 'isIncomingRequest', ]); @@ -459,8 +459,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, + replyMessageEntity?.id, + editedMessage?.id, + ); }; const loadDraft = async () => { @@ -599,7 +606,6 @@ export const InputBar = ({ {!isSelfUserRemoved && !pastedFile && ( { editorRef.current = lexical; }} diff --git a/src/script/components/InputBar/util/DraftStateUtil.ts b/src/script/components/InputBar/util/DraftStateUtil.ts index de508dcec14..75cb76cbd13 100644 --- a/src/script/components/InputBar/util/DraftStateUtil.ts +++ b/src/script/components/InputBar/util/DraftStateUtil.ts @@ -17,10 +17,6 @@ * */ -import {$getRoot, createEditor} from 'lexical'; - -import {useMessageDraftState} from 'Hooks/useMessageDraftState'; - import {MessageRepository} from '../../../conversation/MessageRepository'; import {Conversation} from '../../../entity/Conversation'; import {ContentMessage} from '../../../entity/message/ContentMessage'; @@ -30,6 +26,7 @@ export interface DraftState { editorState: string | null; messageReply?: ContentMessage; editedMessage?: ContentMessage; + plainMessage?: string; } export const generateConversationInputStorageKey = (conversationEntity: Conversation): string => @@ -39,6 +36,7 @@ export const saveDraftState = async ( storageRepository: StorageRepository, conversation: Conversation, editorState: string, + plainMessage: string, replyMessageId?: string, editedMessageId?: string, ): Promise => { @@ -47,6 +45,7 @@ export const saveDraftState = async ( await storageRepository.storageService.saveToSimpleStorage(storageKey, { editorState, + plainMessage, replyId: replyMessageId, editedMessageId, }); @@ -85,21 +84,5 @@ export const loadDraftState = async ( editedMessage = await loadMessage(editedMessageId); } - return {...storageValue, messageReply, editedMessage}; -}; - -export const getDraftTextMessageContent = (conversation: Conversation, storageValue: string) => { - const config = { - namespace: `DraftEditor-${conversation.id}`, - theme: {}, - onError: console.error, - }; - - const editor = createEditor(config); - const editorState = editor.parseEditorState(storageValue); - const textContent = editorState.read(() => $getRoot().getTextContent()); - - const {setDraftMessage} = useMessageDraftState.getState(); - - setDraftMessage(conversation.id, textContent); + return {...storageValue, messageReply, editedMessage, plainMessage: storageValue?.plainMessage || ''}; }; diff --git a/src/script/components/RichTextEditor/RichTextEditor.tsx b/src/script/components/RichTextEditor/RichTextEditor.tsx index c32665c3e9c..6fd16f07dad 100644 --- a/src/script/components/RichTextEditor/RichTextEditor.tsx +++ b/src/script/components/RichTextEditor/RichTextEditor.tsx @@ -38,7 +38,6 @@ import cx from 'classnames'; import {LexicalEditor, EditorState, $nodesOfType} from 'lexical'; import {DraftState} from 'Components/InputBar/util/DraftStateUtil'; -import {useMessageDraftState} from 'Hooks/useMessageDraftState'; import {ContentMessage} from 'src/script/entity/message/ContentMessage'; import {User} from 'src/script/entity/User'; import {getLogger} from 'Util/Logger'; @@ -57,7 +56,6 @@ import {MentionsPlugin} from './plugins/MentionsPlugin'; import {ReplaceCarriageReturnPlugin} from './plugins/ReplaceCarriageReturnPlugin/ReplaceCarriageReturnPlugin'; import {SendPlugin} from './plugins/SendPlugin'; -import {Conversation} from '../../entity/Conversation'; import {MentionEntity} from '../../message/MentionEntity'; const theme = { @@ -95,7 +93,6 @@ export type RichTextContent = { const logger = getLogger('LexicalInput'); interface RichTextEditorProps { - conversation: Conversation; placeholder: string; replaceEmojis?: boolean; editedMessage?: ContentMessage; @@ -103,7 +100,7 @@ interface RichTextEditorProps { hasLocalEphemeralTimer: boolean; showFormatToolbar: boolean; getMentionCandidates: (search?: string | null) => User[]; - saveDraftState: (editor: string) => void; + saveDraftState: (editor: string, plainMessage: string) => void; loadDraftState: () => Promise; onUpdate: (content: RichTextContent) => void; onArrowUp: () => void; @@ -161,7 +158,6 @@ const editorConfig: InitialConfigType = { }; export const RichTextEditor = ({ - conversation, placeholder, children, hasLocalEphemeralTimer, @@ -182,15 +178,11 @@ export const RichTextEditor = ({ const emojiPickerOpen = useRef(true); const mentionsOpen = useRef(true); - const {setDraftMessage} = useMessageDraftState(); - const handleChange = (editorState: EditorState, editor: LexicalEditor) => { - saveDraftState(JSON.stringify(editorState.toJSON())); - editorState.read(() => { const markdown = $convertToMarkdownString(TRANSFORMERS); - setDraftMessage(conversation.id, replaceEmojis ? findAndTransformEmoji(markdown) : markdown); + saveDraftState(JSON.stringify(editorState.toJSON()), replaceEmojis ? findAndTransformEmoji(markdown) : markdown); onUpdate({ text: replaceEmojis ? findAndTransformEmoji(markdown) : markdown, diff --git a/src/script/hooks/useLocalStorage.ts b/src/script/hooks/useLocalStorage.ts new file mode 100644 index 00000000000..e3096b86e3b --- /dev/null +++ b/src/script/hooks/useLocalStorage.ts @@ -0,0 +1,38 @@ +/* + * 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'; + +export const useLocalStorage = (key: string) => { + const setLocalStorage = (newValue: string) => { + window.localStorage.setItem('sidebar', newValue); + window.dispatchEvent(new StorageEvent('storage', {key, newValue})); + }; + + const getSnapshot = () => localStorage.getItem(key); + + const subscribe = (listener: () => void) => { + window.addEventListener('storage', listener); + return () => window.removeEventListener('storage', listener); + }; + + const store = useSyncExternalStore(subscribe, getSnapshot); + + return [store, setLocalStorage] as const; +}; diff --git a/src/script/hooks/useMessageDraftState.ts b/src/script/hooks/useMessageDraftState.ts deleted file mode 100644 index 11733f9c51c..00000000000 --- a/src/script/hooks/useMessageDraftState.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Wire - * Copyright (C) 2022 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/. - * - */ - -/* - * Wire - * Copyright (C) 2024 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 {create} from 'zustand'; - -type MessageDraftState = { - draftMessage: Record; - setDraftMessage: (conversationId: string, message: string) => void; -}; - -export const useMessageDraftState = create((set, get) => ({ - draftMessage: {}, - setDraftMessage: (conversationId: string, message: string) => { - const previousContentState = get().draftMessage; - - set(state => ({ - ...state, - draftMessage: { - ...previousContentState, - [conversationId]: message, - }, - })); - }, -})); From 68d7a25045b3265d7605273085b497bafa91207b Mon Sep 17 00:00:00 2001 From: Przemyslaw Jozwik Date: Mon, 13 Jan 2025 14:31:20 +0100 Subject: [PATCH 4/6] implementation --- .../CellDescription/CellDescription.tsx | 7 ++--- src/script/components/InputBar/InputBar.tsx | 11 ++++--- .../InputBar/util/DraftStateUtil.ts | 31 +++++++++++++------ src/script/hooks/useLocalStorage.ts | 20 +++++++++--- .../panels/Conversations/Conversations.tsx | 2 +- 5 files changed, 46 insertions(+), 25 deletions(-) diff --git a/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx b/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx index 0e5df91f39f..95638d9d69c 100644 --- a/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx +++ b/src/script/components/ConversationListCell/components/CellDescription/CellDescription.tsx @@ -22,7 +22,7 @@ import {useMemo} from 'react'; import cx from 'classnames'; import * as Icon from 'Components/Icon'; -import {generateConversationInputStorageKey} from 'Components/InputBar/util/DraftStateUtil'; +import {DraftState, generateConversationInputStorageKey} from 'Components/InputBar/util/DraftStateUtil'; import {useLocalStorage} from 'Hooks/useLocalStorage'; import {iconStyle} from './CellDescription.style'; @@ -43,10 +43,9 @@ export const CellDescription = ({conversation, mutedState, isActive, isRequest, const storageKey = generateConversationInputStorageKey(conversation); // Hardcoded __amplify__ because of StorageUtil saving as __amplify__ - const [store] = useLocalStorage(`__amplify__${storageKey}`); + const [store] = useLocalStorage<{data?: DraftState}>(`__amplify__${storageKey}`); - const parsedStore = store ? JSON.parse(store) : {}; - const draftMessage = parsedStore?.data?.plainMessage; + const draftMessage = store?.data?.plainMessage; const currentConversationDraftMessage = isActive ? '' : draftMessage; if (!cellState.description && !currentConversationDraftMessage) { diff --git a/src/script/components/InputBar/InputBar.tsx b/src/script/components/InputBar/InputBar.tsx index 351b49e70cf..fb2c6a611b5 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'; @@ -460,14 +461,14 @@ export const InputBar = ({ }, []); const saveDraft = async (editorState: string, plainMessage: string) => { - await saveDraftState( + await saveDraftState({ storageRepository, conversation, editorState, - plainMessage, - replyMessageEntity?.id, - editedMessage?.id, - ); + plainMessage: sanitizeMarkdown(plainMessage), + replyId: replyMessageEntity?.id, + editedMessageId: editedMessage?.id, + }); }; const loadDraft = async () => { diff --git a/src/script/components/InputBar/util/DraftStateUtil.ts b/src/script/components/InputBar/util/DraftStateUtil.ts index 75cb76cbd13..33ae7c8907f 100644 --- a/src/script/components/InputBar/util/DraftStateUtil.ts +++ b/src/script/components/InputBar/util/DraftStateUtil.ts @@ -32,21 +32,32 @@ export interface DraftState { export const generateConversationInputStorageKey = (conversationEntity: Conversation): string => `${StorageKey.CONVERSATION.INPUT}|${conversationEntity.id}`; -export const saveDraftState = async ( - storageRepository: StorageRepository, - conversation: Conversation, - editorState: string, - plainMessage: 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, plainMessage, - replyId: replyMessageId, + replyId, editedMessageId, }); }; diff --git a/src/script/hooks/useLocalStorage.ts b/src/script/hooks/useLocalStorage.ts index e3096b86e3b..e46a825b348 100644 --- a/src/script/hooks/useLocalStorage.ts +++ b/src/script/hooks/useLocalStorage.ts @@ -19,10 +19,20 @@ import {useSyncExternalStore} from 'react'; -export const useLocalStorage = (key: string) => { - const setLocalStorage = (newValue: string) => { - window.localStorage.setItem('sidebar', newValue); - window.dispatchEvent(new StorageEvent('storage', {key, newValue})); +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); @@ -34,5 +44,5 @@ export const useLocalStorage = (key: string) => { const store = useSyncExternalStore(subscribe, getSnapshot); - return [store, setLocalStorage] as const; + 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'; From 04055a86fafed647a516061454c453b73cc607a2 Mon Sep 17 00:00:00 2001 From: Olaf Sulich Date: Tue, 14 Jan 2025 08:48:19 +0100 Subject: [PATCH 5/6] feat(InputBar): enhance RichTextEditor with draft saving and emoji transformation --- package.json | 1 + src/script/components/InputBar/InputBar.tsx | 3 +- .../RichTextEditor/RichTextEditor.tsx | 28 +++++++-- .../RichTextEditor/utils/transformMessage.ts | 24 ++++++++ .../utils/useEditorDraftState.ts | 61 +++++++++++++++++++ yarn.lock | 10 +++ 6 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 src/script/components/InputBar/components/RichTextEditor/utils/transformMessage.ts create mode 100644 src/script/components/InputBar/components/RichTextEditor/utils/useEditorDraftState.ts 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/InputBar/InputBar.tsx b/src/script/components/InputBar/InputBar.tsx index fb2c6a611b5..b109347187a 100644 --- a/src/script/components/InputBar/InputBar.tsx +++ b/src/script/components/InputBar/InputBar.tsx @@ -187,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, ); @@ -614,6 +614,7 @@ export const InputBar = ({ {!isSelfUserRemoved && !pastedFile && ( { editorRef.current = lexical; }} diff --git a/src/script/components/InputBar/components/RichTextEditor/RichTextEditor.tsx b/src/script/components/InputBar/components/RichTextEditor/RichTextEditor.tsx index 192b3e3fa82..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,7 +66,7 @@ export type RichTextContent = { interface RichTextEditorProps { placeholder: string; - replaceEmojis?: boolean; + replaceEmojis: boolean; editedMessage?: ContentMessage; children: ReactElement; hasLocalEphemeralTimer: boolean; @@ -101,19 +103,32 @@ export const RichTextEditor = ({ onSend, onSetup = () => {}, }: RichTextEditorProps) => { + const editorRef = useRef(null); const emojiPickerOpen = useRef(true); const mentionsOpen = useRef(true); - const handleChange = (editorState: EditorState, editor: LexicalEditor) => { + const {saveDraft} = useEditorDraftState({ + editorRef, + saveDraftState, + replaceEmojis, + }); + + const handleChange = (editorState: EditorState) => { editorState.read(() => { + if (!editorRef.current) { + return; + } + const markdown = $convertToMarkdownString(markdownTransformers); - saveDraftState(JSON.stringify(editorState.toJSON()), replaceEmojis ? findAndTransformEmoji(markdown) : markdown); + const text = transformMessage({replaceEmojis, markdown}); onUpdate({ - text: replaceEmojis ? findAndTransformEmoji(markdown) : markdown, - mentions: parseMentions(editor, markdown, getMentionCandidates()), + text, + mentions: parseMentions(editorRef.current, markdown, getMentionCandidates()), }); + + saveDraft(); }); }; @@ -125,6 +140,7 @@ export const RichTextEditor = ({ { + editorRef.current = editor; onSetup(editor!); }} /> 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/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" From 2b13e227ce1f4328203a39479a1c01d636c553df Mon Sep 17 00:00:00 2001 From: Olaf Sulich Date: Tue, 14 Jan 2025 08:49:30 +0100 Subject: [PATCH 6/6] refactor(InputBar): remove unnecessary key prop from RichTextEditor component --- src/script/components/InputBar/InputBar.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/script/components/InputBar/InputBar.tsx b/src/script/components/InputBar/InputBar.tsx index b109347187a..01489904a82 100644 --- a/src/script/components/InputBar/InputBar.tsx +++ b/src/script/components/InputBar/InputBar.tsx @@ -614,7 +614,6 @@ export const InputBar = ({ {!isSelfUserRemoved && !pastedFile && ( { editorRef.current = lexical; }}