diff --git a/bindings/wysiwyg-ffi/src/ffi_composer_model.rs b/bindings/wysiwyg-ffi/src/ffi_composer_model.rs index bf7021883..b9b9e8132 100644 --- a/bindings/wysiwyg-ffi/src/ffi_composer_model.rs +++ b/bindings/wysiwyg-ffi/src/ffi_composer_model.rs @@ -49,6 +49,16 @@ impl ComposerModel { Ok(Arc::new(ComposerUpdate::from(update))) } + pub fn set_custom_suggestion_patterns( + self: &Arc, + custom_suggestion_patterns: Vec, + ) { + self.inner + .lock() + .unwrap() + .set_custom_suggestion_patterns(custom_suggestion_patterns) + } + pub fn get_content_as_html(self: &Arc) -> String { self.inner.lock().unwrap().get_content_as_html().to_string() } @@ -139,11 +149,13 @@ impl ComposerModel { self: &Arc, new_text: String, suggestion: SuggestionPattern, + append_space: bool, ) -> Arc { Arc::new(ComposerUpdate::from( self.inner.lock().unwrap().replace_text_suggestion( Utf16String::from_str(&new_text), wysiwyg::SuggestionPattern::from(suggestion), + append_space, ), )) } diff --git a/bindings/wysiwyg-ffi/src/ffi_composer_update.rs b/bindings/wysiwyg-ffi/src/ffi_composer_update.rs index e4967c441..369e98321 100644 --- a/bindings/wysiwyg-ffi/src/ffi_composer_update.rs +++ b/bindings/wysiwyg-ffi/src/ffi_composer_update.rs @@ -129,6 +129,25 @@ mod test { ) } + #[test] + fn menu_action_is_updated_for_custom_suggestion() { + let model = Arc::new(ComposerModel::new()); + model.set_custom_suggestion_patterns(vec![":)".into()]); + let update = model.replace_text("That's great! :)".into()); + + assert_eq!( + update.menu_action(), + MenuAction::Suggestion { + suggestion_pattern: SuggestionPattern { + key: crate::PatternKey::Custom(":)".into()), + text: ":)".into(), + start: 14, + end: 16, + } + }, + ) + } + #[test] fn test_replace_whole_suggestion_with_mention_ffi() { let mut model = Arc::new(ComposerModel::new()); @@ -229,7 +248,7 @@ mod test { #[test] fn test_replace_text_with_escaped_html_in_mention_ffi() { - let mut model = Arc::new(ComposerModel::new()); + let model = Arc::new(ComposerModel::new()); model.replace_text("hello ".into()); let update = model.replace_text("@alic".into()); diff --git a/bindings/wysiwyg-ffi/src/ffi_pattern_key.rs b/bindings/wysiwyg-ffi/src/ffi_pattern_key.rs index ecb5b07ed..753732295 100644 --- a/bindings/wysiwyg-ffi/src/ffi_pattern_key.rs +++ b/bindings/wysiwyg-ffi/src/ffi_pattern_key.rs @@ -17,6 +17,7 @@ pub enum PatternKey { At, Hash, Slash, + Custom(String), } impl From for PatternKey { @@ -25,6 +26,7 @@ impl From for PatternKey { wysiwyg::PatternKey::At => Self::At, wysiwyg::PatternKey::Hash => Self::Hash, wysiwyg::PatternKey::Slash => Self::Slash, + wysiwyg::PatternKey::Custom(key) => Self::Custom(key), } } } @@ -35,6 +37,7 @@ impl From for wysiwyg::PatternKey { PatternKey::At => Self::At, PatternKey::Hash => Self::Hash, PatternKey::Slash => Self::Slash, + PatternKey::Custom(key) => Self::Custom(key), } } } diff --git a/bindings/wysiwyg-wasm/src/lib.rs b/bindings/wysiwyg-wasm/src/lib.rs index 3709ba7c9..1b5b30f74 100644 --- a/bindings/wysiwyg-wasm/src/lib.rs +++ b/bindings/wysiwyg-wasm/src/lib.rs @@ -92,6 +92,20 @@ impl ToUtf16TupleVec for js_sys::Map { } } +trait ToStringVec { + fn into_vec(self) -> Vec; +} + +impl ToStringVec for js_sys::Array { + fn into_vec(self) -> Vec { + let mut vec = vec![]; + self.for_each(&mut |element, _, _| { + vec.push(element.as_string().unwrap()); + }); + vec + } +} + #[wasm_bindgen] #[derive(Default)] pub struct ComposerModel { @@ -185,10 +199,12 @@ impl ComposerModel { &mut self, new_text: &str, suggestion: &SuggestionPattern, + append_space: bool, ) -> ComposerUpdate { ComposerUpdate::from(self.inner.replace_text_suggestion( Utf16String::from_str(new_text), wysiwyg::SuggestionPattern::from(suggestion.clone()), + append_space, )) } @@ -316,6 +332,15 @@ impl ComposerModel { )) } + pub fn set_custom_suggestion_patterns( + &mut self, + custom_suggestion_patterns: js_sys::Array, + ) { + self.inner.set_custom_suggestion_patterns( + custom_suggestion_patterns.into_vec(), + ); + } + /// Creates an at-room mention node and inserts it into the composer at the current selection pub fn insert_at_room_mention( &mut self, @@ -687,28 +712,52 @@ impl From for wysiwyg::SuggestionPattern { #[wasm_bindgen] #[derive(Clone)] -pub enum PatternKey { +pub enum PatternKeyType { At, Hash, Slash, + Custom, +} + +#[derive(Clone)] +#[wasm_bindgen(getter_with_clone)] +pub struct PatternKey { + pub key_type: PatternKeyType, + pub custom_key_value: Option, } impl From for PatternKey { fn from(inner: wysiwyg::PatternKey) -> Self { match inner { - wysiwyg::PatternKey::At => Self::At, - wysiwyg::PatternKey::Hash => Self::Hash, - wysiwyg::PatternKey::Slash => Self::Slash, + wysiwyg::PatternKey::At => Self { + key_type: PatternKeyType::At, + custom_key_value: None, + }, + wysiwyg::PatternKey::Hash => Self { + key_type: PatternKeyType::Hash, + custom_key_value: None, + }, + wysiwyg::PatternKey::Slash => Self { + key_type: PatternKeyType::Slash, + custom_key_value: None, + }, + wysiwyg::PatternKey::Custom(key) => Self { + key_type: PatternKeyType::Custom, + custom_key_value: Some(key), + }, } } } impl From for wysiwyg::PatternKey { fn from(key: PatternKey) -> Self { - match key { - PatternKey::At => Self::At, - PatternKey::Hash => Self::Hash, - PatternKey::Slash => Self::Slash, + match key.key_type { + PatternKeyType::At => Self::At, + PatternKeyType::Hash => Self::Hash, + PatternKeyType::Slash => Self::Slash, + PatternKeyType::Custom => { + Self::Custom(key.custom_key_value.unwrap()) + } } } } diff --git a/crates/wysiwyg/src/composer_model/base.rs b/crates/wysiwyg/src/composer_model/base.rs index be12eccd2..c83fca57f 100644 --- a/crates/wysiwyg/src/composer_model/base.rs +++ b/crates/wysiwyg/src/composer_model/base.rs @@ -24,7 +24,7 @@ use crate::{ ComposerAction, ComposerUpdate, DomHandle, Location, ToHtml, ToMarkdown, ToTree, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; #[derive(Clone, Default)] pub struct ComposerModel @@ -42,6 +42,9 @@ where /// The states of the buttons for each action e.g. bold, undo pub(crate) action_states: HashMap, + + /// Suggestion patterns provided by the client at runtime + pub(crate) custom_suggestion_patterns: HashSet, } impl ComposerModel @@ -54,6 +57,7 @@ where previous_states: Vec::new(), next_states: Vec::new(), action_states: HashMap::new(), // TODO: Calculate state based on ComposerState + custom_suggestion_patterns: HashSet::new(), }; instance.compute_menu_state(MenuStateComputeType::AlwaysUpdate); instance @@ -65,6 +69,7 @@ where previous_states: Vec::new(), next_states: Vec::new(), action_states: HashMap::new(), // TODO: Calculate state based on ComposerState + custom_suggestion_patterns: HashSet::new(), } } @@ -85,6 +90,7 @@ where previous_states: Vec::new(), next_states: Vec::new(), action_states: HashMap::new(), // TODO: Calculate state based on ComposerState + custom_suggestion_patterns: HashSet::new(), }; model.compute_menu_state(MenuStateComputeType::AlwaysUpdate); Self::post_process_dom(&mut model.state.dom); @@ -125,6 +131,14 @@ where self.set_content_from_html(&html) } + pub fn set_custom_suggestion_patterns( + &mut self, + custom_suggestion_patterns: Vec, + ) { + self.custom_suggestion_patterns = + HashSet::from_iter(custom_suggestion_patterns) + } + pub fn action_states(&self) -> &HashMap { &self.action_states } diff --git a/crates/wysiwyg/src/composer_model/menu_action.rs b/crates/wysiwyg/src/composer_model/menu_action.rs index aba997088..8d8b8b813 100644 --- a/crates/wysiwyg/src/composer_model/menu_action.rs +++ b/crates/wysiwyg/src/composer_model/menu_action.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::collections::HashSet; + use crate::{ dom::{ unicode_string::{UnicodeStr, UnicodeStringExt}, @@ -37,7 +39,12 @@ where return MenuAction::None; } let (raw_text, start, end) = self.extended_text(range); - if let Some((key, text)) = Self::pattern_for_text(raw_text, start) { + + if let Some((key, text)) = Self::pattern_for_text( + raw_text, + start, + &self.custom_suggestion_patterns, + ) { MenuAction::Suggestion(SuggestionPattern { key, text, @@ -79,14 +86,19 @@ where fn pattern_for_text( mut text: S, start_location: usize, + custom_suggestion_patterns: &HashSet, ) -> Option<(PatternKey, String)> { - let Some(first_char) = text.pop_first() else { - return None; - }; - let Some(key) = PatternKey::from_char(first_char) else { + let Some(key) = PatternKey::from_string_and_suggestions( + text.to_string(), + custom_suggestion_patterns, + ) else { return None; }; + if key.is_static_pattern() { + text.pop_first(); + } + // Exclude slash patterns that are not at the beginning of the document // and any selection that contains inner whitespaces. if (key == PatternKey::Slash && start_location > 0) diff --git a/crates/wysiwyg/src/composer_model/replace_text.rs b/crates/wysiwyg/src/composer_model/replace_text.rs index 7101eecd1..1586b3c79 100644 --- a/crates/wysiwyg/src/composer_model/replace_text.rs +++ b/crates/wysiwyg/src/composer_model/replace_text.rs @@ -49,10 +49,16 @@ where &mut self, new_text: S, suggestion: SuggestionPattern, + append_space: bool, ) -> ComposerUpdate { self.push_state_to_history(); - self.do_replace_text_in(new_text, suggestion.start, suggestion.end); - self.do_replace_text(" ".into()) + let replace_suggestion_update = + self.do_replace_text_in(new_text, suggestion.start, suggestion.end); + if append_space { + self.do_replace_text(" ".into()) + } else { + replace_suggestion_update + } } #[deprecated(since = "0.20.0")] diff --git a/crates/wysiwyg/src/pattern_key.rs b/crates/wysiwyg/src/pattern_key.rs index 750b2ef35..9afd5f1d8 100644 --- a/crates/wysiwyg/src/pattern_key.rs +++ b/crates/wysiwyg/src/pattern_key.rs @@ -12,16 +12,32 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::collections::HashSet; + #[derive(Clone, Debug, PartialEq, Eq)] pub enum PatternKey { At, Hash, Slash, + Custom(String), } impl PatternKey { - pub(crate) fn from_char(char: char) -> Option { - match char { + pub(crate) fn is_static_pattern(&self) -> bool { + matches!(self, Self::At | Self::Hash | Self::Slash) + } + + pub(crate) fn from_string_and_suggestions( + string: String, + custom_suggestion_patterns: &HashSet, + ) -> Option { + if custom_suggestion_patterns.contains(&string) { + return Some(Self::Custom(string)); + } + let Some(first_char) = string.chars().nth(0) else { + return None; + }; + match first_char { '\u{0040}' => Some(Self::At), '\u{0023}' => Some(Self::Hash), '\u{002F}' => Some(Self::Slash), diff --git a/crates/wysiwyg/src/tests.rs b/crates/wysiwyg/src/tests.rs index a23867121..b9d946baf 100644 --- a/crates/wysiwyg/src/tests.rs +++ b/crates/wysiwyg/src/tests.rs @@ -16,6 +16,7 @@ pub mod test_characters; pub mod test_deleting; +pub mod test_emoji_replacement; pub mod test_formatting; pub mod test_get_link_action; pub mod test_links; diff --git a/crates/wysiwyg/src/tests/test_emoji_replacement.rs b/crates/wysiwyg/src/tests/test_emoji_replacement.rs new file mode 100644 index 000000000..bbaa388fe --- /dev/null +++ b/crates/wysiwyg/src/tests/test_emoji_replacement.rs @@ -0,0 +1,33 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use widestring::Utf16String; + +use crate::{ + tests::testutils_composer_model::tx, ComposerModel, MenuAction, PatternKey, +}; + +#[test] +fn can_do_plain_text_to_empji_replacement() { + let mut model: ComposerModel = ComposerModel::new(); + model.set_custom_suggestion_patterns(vec![":)".into()]); + let update = model.replace_text("Hey That's great! :)".into()); + let MenuAction::Suggestion(suggestion) = update.menu_action else { + panic!("No suggestion pattern found") + }; + assert_eq!(suggestion.key, PatternKey::Custom(":)".into()),); + model.replace_text_suggestion("🙂".into(), suggestion, false); + + assert_eq!(tx(&model), "Hey That's great! 🙂|"); +} diff --git a/crates/wysiwyg/src/tests/test_suggestions.rs b/crates/wysiwyg/src/tests/test_suggestions.rs index 5bf40645f..ea13ccd1b 100644 --- a/crates/wysiwyg/src/tests/test_suggestions.rs +++ b/crates/wysiwyg/src/tests/test_suggestions.rs @@ -23,6 +23,6 @@ fn test_replace_text_suggestion() { let MenuAction::Suggestion(suggestion) = update.menu_action else { panic!("No suggestion pattern found") }; - model.replace_text_suggestion("/invite".into(), suggestion); + model.replace_text_suggestion("/invite".into(), suggestion, true); assert_eq!(tx(&model), "/invite |"); } diff --git a/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/SuggestionsView.kt b/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/SuggestionsView.kt index 7db44b34d..bc01ac813 100644 --- a/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/SuggestionsView.kt +++ b/platforms/android/example-compose/src/main/java/io/element/wysiwyg/compose/SuggestionsView.kt @@ -75,10 +75,12 @@ private fun processSuggestion(suggestion: MenuAction.Suggestion, roomMemberSugge val slashCommands = listOf("leave", "shrug").map(Mention::SlashCommand) val everyone = Mention.NotifyEveryone val names = when (suggestion.suggestionPattern.key) { - PatternKey.AT -> people + everyone - PatternKey.HASH -> rooms - PatternKey.SLASH -> slashCommands + PatternKey.At -> people + everyone + PatternKey.Hash -> rooms + PatternKey.Slash -> slashCommands + is PatternKey.Custom -> listOf() } + val suggestions = names .filter { it.display.contains(text) } roomMemberSuggestions.clear() diff --git a/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/viewmodel/EditorViewModel.kt b/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/viewmodel/EditorViewModel.kt index 9680da065..858f4bf72 100644 --- a/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/viewmodel/EditorViewModel.kt +++ b/platforms/android/library/src/main/java/io/element/android/wysiwyg/internal/viewmodel/EditorViewModel.kt @@ -300,6 +300,7 @@ internal class EditorViewModel( composer?.replaceTextSuggestion( suggestion = suggestion, newText = action.value, + appendSpace = true ) }.onFailure( ::onComposerFailure diff --git a/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/viewmodel/EditorViewModelTest.kt b/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/viewmodel/EditorViewModelTest.kt index 344e2ddeb..1233dcfd1 100644 --- a/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/viewmodel/EditorViewModelTest.kt +++ b/platforms/android/library/src/test/kotlin/io/element/android/wysiwyg/viewmodel/EditorViewModelTest.kt @@ -297,7 +297,7 @@ internal class EditorViewModelTest { val name = "jonny" val url = "https://matrix.to/#/@test:matrix.org" val suggestionPattern = - SuggestionPattern(PatternKey.AT, text = "jonny", 0.toUInt(), 5.toUInt()) + SuggestionPattern(PatternKey.At, text = "jonny", 0.toUInt(), 5.toUInt()) composer.givenReplaceTextResult(MockComposerUpdateFactory.create( menuAction = MenuAction.Suggestion(suggestionPattern) )) @@ -315,7 +315,7 @@ internal class EditorViewModelTest { @Test fun `when process insert @room mention at suggestion action, it returns a text update`() { val suggestionPattern = - SuggestionPattern(PatternKey.AT, text = "room", 0.toUInt(), 4.toUInt()) + SuggestionPattern(PatternKey.At, text = "room", 0.toUInt(), 4.toUInt()) composer.givenReplaceTextResult(MockComposerUpdateFactory.create( menuAction = MenuAction.Suggestion(suggestionPattern) )) diff --git a/platforms/ios/example/Wysiwyg/Views/WysiwygSuggestionList.swift b/platforms/ios/example/Wysiwyg/Views/WysiwygSuggestionList.swift index adbc47740..ca6a7f133 100644 --- a/platforms/ios/example/Wysiwyg/Views/WysiwygSuggestionList.swift +++ b/platforms/ios/example/Wysiwyg/Views/WysiwygSuggestionList.swift @@ -73,6 +73,8 @@ struct WysiwygSuggestionList: View { .accessibilityIdentifier(command.accessibilityIdentifier) } } + case .custom: + EmptyView() } } .padding(.horizontal, 8) diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift index fb8b4a8ec..7afee0780 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Components/ComposerModelWrapper.swift @@ -113,7 +113,7 @@ final class ComposerModelWrapper: ComposerModelWrapperProtocol { } func replaceTextSuggestion(newText: String, suggestion: SuggestionPattern) -> ComposerUpdate { - execute { try $0.replaceTextSuggestion(newText: newText, suggestion: suggestion) } + execute { try $0.replaceTextSuggestion(newText: newText, suggestion: suggestion, appendSpace: true) } } func backspace() -> ComposerUpdate { diff --git a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Extensions/PatternKey.swift b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Extensions/PatternKey.swift index 2e5f1ab7a..898485883 100644 --- a/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Extensions/PatternKey.swift +++ b/platforms/ios/lib/WysiwygComposer/Sources/WysiwygComposer/Extensions/PatternKey.swift @@ -22,7 +22,7 @@ public extension PatternKey { return .user case .hash: return .room - case .slash: + case .slash, .custom: return nil } } diff --git a/platforms/web/.eslintignore b/platforms/web/.eslintignore index e49372cd4..82c6d9576 100644 --- a/platforms/web/.eslintignore +++ b/platforms/web/.eslintignore @@ -9,4 +9,5 @@ vite.demo.config.ts scripts cypress example-wysiwyg -coverage \ No newline at end of file +coverage +.eslintignore diff --git a/platforms/web/lib/composer.ts b/platforms/web/lib/composer.ts index ff6bfac7b..8c61feeae 100644 --- a/platforms/web/lib/composer.ts +++ b/platforms/web/lib/composer.ts @@ -55,6 +55,7 @@ export function processInput( editor: HTMLElement, suggestion: SuggestionPattern | null, inputEventProcessor?: InputEventProcessor, + emojiSuggestions?: Map, ): ComposerUpdate | null | undefined { const event = processEvent( e, @@ -120,6 +121,7 @@ export function processInput( composerModel.replace_text_suggestion( event.data, suggestion, + true, ), 'replace_text_suggestion', ); @@ -174,6 +176,11 @@ export function processInput( return action(composerModel.ordered_list(), 'ordered_list'); case 'insertLineBreak': case 'insertParagraph': + insertAnyEmojiSuggestions( + composerModel, + suggestion, + emojiSuggestions, + ); return action(composerModel.enter(), 'enter'); case 'insertReplacementText': { // Remove br tag @@ -191,6 +198,13 @@ export function processInput( case 'insertFromComposition': case 'insertText': if (event.data) { + if (event.data == ' ') { + insertAnyEmojiSuggestions( + composerModel, + suggestion, + emojiSuggestions, + ); + } return action( composerModel.replace_text(event.data), 'replace_text', @@ -231,4 +245,22 @@ export function processInput( console.error(e); return null; } + + function insertAnyEmojiSuggestions( + composerModel: ComposerModel, + suggestion: SuggestionPattern | null, + emojiSuggestions?: Map, + ): void { + if ( + emojiSuggestions && + suggestion && + suggestion.key.key_type == 3 && + suggestion.key.custom_key_value + ) { + const emoji = emojiSuggestions.get(suggestion.key.custom_key_value); + if (emoji) { + composerModel.replace_text_suggestion(emoji, suggestion, false); + } + } + } } diff --git a/platforms/web/lib/constants.ts b/platforms/web/lib/constants.ts index d884cf648..83f6fabfb 100644 --- a/platforms/web/lib/constants.ts +++ b/platforms/web/lib/constants.ts @@ -32,4 +32,4 @@ export const ACTION_TYPES = [ 'unindent', ] as const; -export const SUGGESTIONS = ['@', '#', '/'] as const; +export const SUGGESTIONS = ['@', '#', '/', ''] as const; diff --git a/platforms/web/lib/suggestion.test.tsx b/platforms/web/lib/suggestion.test.tsx index 693a01acd..0e4a12e78 100644 --- a/platforms/web/lib/suggestion.test.tsx +++ b/platforms/web/lib/suggestion.test.tsx @@ -25,7 +25,9 @@ import { describe('getSuggestionChar', () => { it('returns the expected character', () => { SUGGESTIONS.forEach((suggestionCharacter, index) => { - const suggestion = { key: index } as unknown as SuggestionPattern; + const suggestion = { + key: { key_type: index }, + } as unknown as SuggestionPattern; expect(getSuggestionChar(suggestion)).toBe(suggestionCharacter); }); }); @@ -38,15 +40,21 @@ describe('getSuggestionChar', () => { describe('getSuggestionType', () => { it('returns the expected type for a user or room mention', () => { - const userSuggestion = { key: 0 } as unknown as SuggestionPattern; - const roomSuggestion = { key: 1 } as unknown as SuggestionPattern; + const userSuggestion = { + key: { key_type: 0 }, + } as unknown as SuggestionPattern; + const roomSuggestion = { + key: { key_type: 1 }, + } as unknown as SuggestionPattern; expect(getSuggestionType(userSuggestion)).toBe('mention'); expect(getSuggestionType(roomSuggestion)).toBe('mention'); }); it('returns the expected type for a slash command', () => { - const slashSuggestion = { key: 2 } as unknown as SuggestionPattern; + const slashSuggestion = { + key: { key_type: 2 }, + } as unknown as SuggestionPattern; expect(getSuggestionType(slashSuggestion)).toBe('command'); }); @@ -68,7 +76,11 @@ describe('mapSuggestion', () => { free: () => {}, start: 1, end: 2, - key: 0, + key: { + free: () => {}, + key_type: 0, + custom_key_value: undefined, + }, text: 'some text', }; diff --git a/platforms/web/lib/suggestion.ts b/platforms/web/lib/suggestion.ts index 10fdde428..fe3dad1ae 100644 --- a/platforms/web/lib/suggestion.ts +++ b/platforms/web/lib/suggestion.ts @@ -21,18 +21,20 @@ import { MappedSuggestion, SuggestionChar, SuggestionType } from './types'; export function getSuggestionChar( suggestion: SuggestionPattern, ): SuggestionChar { - return SUGGESTIONS[suggestion.key] || ''; + return SUGGESTIONS[suggestion.key.key_type] || ''; } export function getSuggestionType( suggestion: SuggestionPattern, ): SuggestionType { - switch (suggestion.key) { + switch (suggestion.key.key_type) { case 0: case 1: return 'mention'; case 2: return 'command'; + case 3: + return 'custom'; default: return 'unknown'; } diff --git a/platforms/web/lib/testUtils/Editor.tsx b/platforms/web/lib/testUtils/Editor.tsx index 9c4e33187..769ff85c7 100644 --- a/platforms/web/lib/testUtils/Editor.tsx +++ b/platforms/web/lib/testUtils/Editor.tsx @@ -23,15 +23,22 @@ interface EditorProps { initialContent?: string; inputEventProcessor?: InputEventProcessor; actionsRef?: MutableRefObject; + emojiSuggestions?: Map; } export const Editor = forwardRef(function Editor( - { initialContent, inputEventProcessor, actionsRef }: EditorProps, + { + initialContent, + inputEventProcessor, + actionsRef, + emojiSuggestions, + }: EditorProps, forwardRef, ): JSX.Element { const { ref, isWysiwygReady, wysiwyg, actionStates, content } = useWysiwyg({ initialContent, inputEventProcessor, + emojiSuggestions, }); if (actionsRef) actionsRef.current = wysiwyg; diff --git a/platforms/web/lib/types.ts b/platforms/web/lib/types.ts index 982b0f200..c7d94606c 100644 --- a/platforms/web/lib/types.ts +++ b/platforms/web/lib/types.ts @@ -66,7 +66,8 @@ export type InputEventProcessor = ( ) => WysiwygEvent | null; export type SuggestionChar = (typeof SUGGESTIONS)[number] | ''; -export type SuggestionType = 'mention' | 'command' | 'unknown'; +export type SuggestionType = 'mention' | 'command' | 'custom' | 'unknown'; + export type MappedSuggestion = { keyChar: SuggestionChar; text: string; diff --git a/platforms/web/lib/useComposerModel.ts b/platforms/web/lib/useComposerModel.ts index a4d217ba4..1a2b1d85c 100644 --- a/platforms/web/lib/useComposerModel.ts +++ b/platforms/web/lib/useComposerModel.ts @@ -57,6 +57,7 @@ export async function initOnce(): Promise { export function useComposerModel( editorRef: RefObject, initialContent?: string, + customSuggestionPatterns?: Array, ): { composerModel: ComposerModel | null; onError: (initialContent?: string) => Promise; @@ -69,6 +70,7 @@ export function useComposerModel( async (initialContent?: string) => { await initOnce(); + let contentModel: ComposerModel; if (initialContent) { try { const newModel = new_composer_model_from_html( @@ -76,7 +78,7 @@ export function useComposerModel( 0, initialContent.length, ); - setComposerModel(newModel); + contentModel = newModel; if (editorRef.current) { // we need to use the rust model as the source of truth, to allow it to do things @@ -91,13 +93,19 @@ export function useComposerModel( } } catch (e) { // if the initialisation fails, due to a parsing failure of the html, fallback to an empty composer - setComposerModel(new_composer_model()); + contentModel = new_composer_model(); } } else { - setComposerModel(new_composer_model()); + contentModel = new_composer_model(); } + if (customSuggestionPatterns) { + contentModel.set_custom_suggestion_patterns( + customSuggestionPatterns, + ); + } + setComposerModel(contentModel); }, - [setComposerModel, editorRef], + [setComposerModel, editorRef, customSuggestionPatterns], ); useEffect(() => { diff --git a/platforms/web/lib/useListeners/event.ts b/platforms/web/lib/useListeners/event.ts index 70f60b404..e58e91a0b 100644 --- a/platforms/web/lib/useListeners/event.ts +++ b/platforms/web/lib/useListeners/event.ts @@ -184,6 +184,7 @@ export function handleInput( formattingFunctions: FormattingFunctions, suggestion: SuggestionPattern | null, inputEventProcessor?: InputEventProcessor, + emojiSuggestions?: Map, ): | { content?: string; @@ -199,6 +200,7 @@ export function handleInput( editor, suggestion, inputEventProcessor, + emojiSuggestions, ); if (update) { const repl = update.text_update().replace_all; diff --git a/platforms/web/lib/useListeners/useListeners.ts b/platforms/web/lib/useListeners/useListeners.ts index 93bcd5363..b0f95e49e 100644 --- a/platforms/web/lib/useListeners/useListeners.ts +++ b/platforms/web/lib/useListeners/useListeners.ts @@ -43,6 +43,7 @@ export function useListeners( formattingFunctions: FormattingFunctions, onError: (content?: string) => void, inputEventProcessor?: InputEventProcessor, + emojiSuggestions?: Map, ): { areListenersReady: boolean; content: string | null; @@ -90,6 +91,7 @@ export function useListeners( formattingFunctions, state.suggestion, inputEventProcessor, + emojiSuggestions, ); if (res) { @@ -231,6 +233,7 @@ export function useListeners( }; }, [ editorRef, + emojiSuggestions, composerModel, formattingFunctions, modelRef, diff --git a/platforms/web/lib/useWysiwyg.test.tsx b/platforms/web/lib/useWysiwyg.test.tsx index 0a6a788e0..1ac8c846b 100644 --- a/platforms/web/lib/useWysiwyg.test.tsx +++ b/platforms/web/lib/useWysiwyg.test.tsx @@ -146,6 +146,28 @@ describe('useWysiwyg', () => { expect(mention).toHaveAttribute('style', testStyle); }); + test('Typing plain text converts to emoji', async () => { + const emojiSuggestions = new Map([[':)', '🙂']]); + render( + , + ); + + const textbox = screen.getByRole('textbox'); + await waitFor(() => + expect(textbox).toHaveAttribute('contentEditable', 'true'), + ); + fireEvent.input(textbox, { + data: 'test :)', + inputType: 'insertText', + }); + fireEvent.input(textbox, { + data: ' ', + inputType: 'insertText', + }); + + await expect(textbox).toHaveTextContent('test 🙂'); + }); + test('Create wysiwyg with initial content', async () => { // Given const content = 'foo
bar'; diff --git a/platforms/web/lib/useWysiwyg.ts b/platforms/web/lib/useWysiwyg.ts index be22fa336..dabc8e4ad 100644 --- a/platforms/web/lib/useWysiwyg.ts +++ b/platforms/web/lib/useWysiwyg.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { RefObject, useEffect, useMemo, useRef } from 'react'; +import { RefObject, useEffect, useMemo, useRef, useState } from 'react'; import { AllActionStates, @@ -60,6 +60,7 @@ export type WysiwygProps = { isAutoFocusEnabled?: boolean; inputEventProcessor?: InputEventProcessor; initialContent?: string; + emojiSuggestions?: Map; }; export type UseWysiwyg = { @@ -78,13 +79,25 @@ export type UseWysiwyg = { messageContent: string | null; }; +function getEmojiKeys(emojiSuggestions?: Map): string[] { + const keys = emojiSuggestions?.keys(); + return keys ? Array.from(keys) : []; +} + export function useWysiwyg(wysiwygProps?: WysiwygProps): UseWysiwyg { const ref = useEditor(); const modelRef = useRef(null); + const [emojiKeys, setEmojiKeys] = useState( + getEmojiKeys(wysiwygProps?.emojiSuggestions), + ); + useEffect(() => { + setEmojiKeys(getEmojiKeys(wysiwygProps?.emojiSuggestions)); + }, [wysiwygProps?.emojiSuggestions]); const { composerModel, onError } = useComposerModel( ref, wysiwygProps?.initialContent, + emojiKeys, ); const { testRef, utilities: testUtilities } = useTestCases( ref, @@ -102,6 +115,7 @@ export function useWysiwyg(wysiwygProps?: WysiwygProps): UseWysiwyg { formattingFunctions, onError, wysiwygProps?.inputEventProcessor, + wysiwygProps?.emojiSuggestions, ); useEditorFocus(ref, wysiwygProps?.isAutoFocusEnabled); diff --git a/platforms/web/src/App.tsx b/platforms/web/src/App.tsx index a6e5c8515..1714549eb 100644 --- a/platforms/web/src/App.tsx +++ b/platforms/web/src/App.tsx @@ -55,7 +55,7 @@ function Button({ onClick, imagePath, alt, state }: ButtonProps): ReactElement { ); } - +const emojiSuggestions = new Map([[':)', '🙂']]); function App(): ReactElement { const [enterToSend, setEnterToSend] = useState(true); @@ -87,6 +87,7 @@ function App(): ReactElement { useWysiwyg({ isAutoFocusEnabled: true, inputEventProcessor, + emojiSuggestions: emojiSuggestions, }); const onEnterToSendChanged = (): void => {