diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/MessageListPage.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/MessageListPage.kt index 1a1b1907fd9..c9fd64b9904 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/MessageListPage.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/MessageListPage.kt @@ -68,7 +68,7 @@ open class MessageListPage { val commandsButton get() = By.res("Stream_ComposerCommandsButton") val commandSuggestionList get() = By.res("Stream_CommandSuggestionList") val commandSuggestionListTitle get() = By.res("Stream_CommandSuggestionListTitle") - val userSuggestion get() = By.res("Stream_UserSuggestionItem") + val userSuggestion get() = By.res("Stream_SuggestionItem") val giphyButton get() = By.res("Stream_SuggestionListGiphyButton") val attachmentsButton get() = By.res("Stream_ComposerAttachmentsButton") val quotedMessage get() = By.res("Stream_QuotedMessage") diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt index 3240b08b71e..1edb2622266 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt @@ -258,6 +258,7 @@ class ChatsActivity : ComponentActivity() { ChatListContentMode.Channels, ChatListContentMode.Mentions, -> AppBottomBarOption.CHATS + ChatListContentMode.Threads -> AppBottomBarOption.THREADS } AppBottomBar( diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 161f8f73502..ce3d93b083a 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -2019,13 +2019,13 @@ public final class io/getstream/chat/android/compose/ui/messages/composer/Compos public final fun getLambda$278788378$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$309759556$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$419456890$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$562871987$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$851409439$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$974985920$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; } public final class io/getstream/chat/android/compose/ui/messages/composer/MessageComposerKt { - public static final fun MessageComposer (Lio/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function1;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;III)V - public static final fun MessageComposer (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function0;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;III)V + public static final fun MessageComposer (Lio/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function1;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;III)V + public static final fun MessageComposer (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function0;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;III)V } public final class io/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions { @@ -2142,10 +2142,16 @@ public final class io/getstream/chat/android/compose/ui/messages/composer/intern public final fun getLambda$1347000119$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } +public final class io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/ComposableSingletons$MentionSuggestionItemKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/ComposableSingletons$MentionSuggestionItemKt; + public fun ()V + public final fun getLambda$1112457019$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; +} + public final class io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/ComposableSingletons$UserSuggestionListKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/ComposableSingletons$UserSuggestionListKt; public fun ()V - public final fun getLambda$-2039880804$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1850153692$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/header/ChannelHeaderKt { @@ -3531,6 +3537,10 @@ public abstract interface class io/getstream/chat/android/compose/ui/theme/ChatC public fun MessageComposerSaveButton (Lio/getstream/chat/android/compose/ui/theme/MessageComposerSaveButtonParams;Landroidx/compose/runtime/Composer;I)V public fun MessageComposerSendButton (Lio/getstream/chat/android/compose/ui/theme/MessageComposerSendButtonParams;Landroidx/compose/runtime/Composer;I)V public fun MessageComposerSnackbar (Lio/getstream/chat/android/compose/ui/theme/MessageComposerSnackbarParams;Landroidx/compose/runtime/Composer;I)V + public fun MessageComposerSuggestionItem (Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemParams;Landroidx/compose/runtime/Composer;I)V + public fun MessageComposerSuggestionItemCenterContent (Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemCenterContentParams;Landroidx/compose/runtime/Composer;I)V + public fun MessageComposerSuggestionItemLeadingContent (Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemLeadingContentParams;Landroidx/compose/runtime/Composer;I)V + public fun MessageComposerSuggestionItemTrailingContent (Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemTrailingContentParams;Landroidx/compose/runtime/Composer;I)V public fun MessageComposerTrailingContent (Lio/getstream/chat/android/compose/ui/theme/MessageComposerTrailingContentParams;Landroidx/compose/runtime/Composer;I)V public fun MessageComposerUserSuggestionItem (Lio/getstream/chat/android/compose/ui/theme/MessageComposerUserSuggestionItemParams;Landroidx/compose/runtime/Composer;I)V public fun MessageComposerUserSuggestionItemCenterContent (Lio/getstream/chat/android/compose/ui/theme/MessageComposerUserSuggestionItemCenterContentParams;Landroidx/compose/runtime/Composer;I)V @@ -3723,6 +3733,10 @@ public final class io/getstream/chat/android/compose/ui/theme/ChatComponentFacto public static fun MessageComposerSaveButton (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/MessageComposerSaveButtonParams;Landroidx/compose/runtime/Composer;I)V public static fun MessageComposerSendButton (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/MessageComposerSendButtonParams;Landroidx/compose/runtime/Composer;I)V public static fun MessageComposerSnackbar (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/MessageComposerSnackbarParams;Landroidx/compose/runtime/Composer;I)V + public static fun MessageComposerSuggestionItem (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemParams;Landroidx/compose/runtime/Composer;I)V + public static fun MessageComposerSuggestionItemCenterContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemCenterContentParams;Landroidx/compose/runtime/Composer;I)V + public static fun MessageComposerSuggestionItemLeadingContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemLeadingContentParams;Landroidx/compose/runtime/Composer;I)V + public static fun MessageComposerSuggestionItemTrailingContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemTrailingContentParams;Landroidx/compose/runtime/Composer;I)V public static fun MessageComposerTrailingContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/MessageComposerTrailingContentParams;Landroidx/compose/runtime/Composer;I)V public static fun MessageComposerUserSuggestionItem (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/MessageComposerUserSuggestionItemParams;Landroidx/compose/runtime/Composer;I)V public static fun MessageComposerUserSuggestionItemCenterContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/MessageComposerUserSuggestionItemCenterContentParams;Landroidx/compose/runtime/Composer;I)V @@ -4849,8 +4863,8 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageComposerLin public final class io/getstream/chat/android/compose/ui/theme/MessageComposerParams { public static final field $stable I - public fun (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Lkotlin/jvm/functions/Function4;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)V - public synthetic fun (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Lkotlin/jvm/functions/Function4;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Lkotlin/jvm/functions/Function4;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Lkotlin/jvm/functions/Function4;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState; public final fun component10 ()Lkotlin/jvm/functions/Function1; public final fun component11 ()Lkotlin/jvm/functions/Function1; @@ -4863,6 +4877,7 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageComposerPar public final fun component18 ()Lkotlin/jvm/functions/Function1; public final fun component19 ()Lkotlin/jvm/functions/Function0; public final fun component2 ()Lkotlin/jvm/functions/Function4; + public final fun component20 ()Lkotlin/jvm/functions/Function1; public final fun component3 ()Landroidx/compose/ui/Modifier; public final fun component4 ()Z public final fun component5 ()Lkotlin/jvm/functions/Function2; @@ -4870,8 +4885,8 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageComposerPar public final fun component7 ()Ljava/lang/String; public final fun component8 ()Lkotlin/jvm/functions/Function0; public final fun component9 ()Ljava/lang/String; - public final fun copy (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Lkotlin/jvm/functions/Function4;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)Lio/getstream/chat/android/compose/ui/theme/MessageComposerParams; - public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageComposerParams;Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Lkotlin/jvm/functions/Function4;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageComposerParams; + public final fun copy (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Lkotlin/jvm/functions/Function4;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lio/getstream/chat/android/compose/ui/theme/MessageComposerParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageComposerParams;Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Lkotlin/jvm/functions/Function4;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/compose/ui/messages/composer/actions/AudioRecordingActions;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageComposerParams; public fun equals (Ljava/lang/Object;)Z public final fun getAttachmentsActionLabel ()Ljava/lang/String; public final fun getInput ()Lkotlin/jvm/functions/Function4; @@ -4885,6 +4900,7 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageComposerPar public final fun getOnCancelLinkPreviewClick ()Lkotlin/jvm/functions/Function0; public final fun getOnCommandSelected ()Lkotlin/jvm/functions/Function1; public final fun getOnLinkPreviewClick ()Lkotlin/jvm/functions/Function1; + public final fun getOnMentionSelected ()Lkotlin/jvm/functions/Function1; public final fun getOnSendMessage ()Lkotlin/jvm/functions/Function2; public final fun getOnUserSelected ()Lkotlin/jvm/functions/Function1; public final fun getOnValueChange ()Lkotlin/jvm/functions/Function1; @@ -4959,6 +4975,68 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageComposerSna public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemCenterContentParams { + public static final field $stable I + public fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Landroidx/compose/ui/Modifier;)V + public synthetic fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Landroidx/compose/ui/Modifier;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention; + public final fun component2 ()Landroidx/compose/ui/Modifier; + public final fun copy (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Landroidx/compose/ui/Modifier;)Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemCenterContentParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemCenterContentParams;Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Landroidx/compose/ui/Modifier;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemCenterContentParams; + public fun equals (Ljava/lang/Object;)Z + public final fun getMention ()Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention; + public final fun getModifier ()Landroidx/compose/ui/Modifier; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemLeadingContentParams { + public static final field $stable I + public fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Landroidx/compose/ui/Modifier;)V + public synthetic fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Landroidx/compose/ui/Modifier;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention; + public final fun component2 ()Landroidx/compose/ui/Modifier; + public final fun copy (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Landroidx/compose/ui/Modifier;)Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemLeadingContentParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemLeadingContentParams;Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Landroidx/compose/ui/Modifier;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemLeadingContentParams; + public fun equals (Ljava/lang/Object;)Z + public final fun getMention ()Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention; + public final fun getModifier ()Landroidx/compose/ui/Modifier; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemParams { + public static final field $stable I + public fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/models/User;)V + public synthetic fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/models/User;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention; + public final fun component2 ()Lkotlin/jvm/functions/Function1; + public final fun component3 ()Lio/getstream/chat/android/models/User; + public final fun copy (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/models/User;)Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemParams;Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/models/User;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemParams; + public fun equals (Ljava/lang/Object;)Z + public final fun getCurrentUser ()Lio/getstream/chat/android/models/User; + public final fun getMention ()Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention; + public final fun getOnMentionSelected ()Lkotlin/jvm/functions/Function1; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemTrailingContentParams { + public static final field $stable I + public fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Landroidx/compose/ui/Modifier;)V + public synthetic fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Landroidx/compose/ui/Modifier;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention; + public final fun component2 ()Landroidx/compose/ui/Modifier; + public final fun copy (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Landroidx/compose/ui/Modifier;)Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemTrailingContentParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemTrailingContentParams;Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;Landroidx/compose/ui/Modifier;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageComposerSuggestionItemTrailingContentParams; + public fun equals (Ljava/lang/Object;)Z + public final fun getMention ()Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention; + public final fun getModifier ()Landroidx/compose/ui/Modifier; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/compose/ui/theme/MessageComposerTrailingContentParams { public static final field $stable I public fun (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;)V diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt index 126a65d9f9e..cb343f3a7be 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt @@ -49,8 +49,8 @@ import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.components.composer.MessageInput import io.getstream.chat.android.compose.ui.messages.composer.actions.AudioRecordingActions import io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions.CommandSuggestionList +import io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions.MentionSuggestionList import io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions.SuggestionsMenu -import io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions.UserSuggestionList import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.ComposerConfig import io.getstream.chat.android.compose.ui.theme.LocalChatUiConfig @@ -73,6 +73,7 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User import io.getstream.chat.android.previewdata.PreviewCommandData import io.getstream.chat.android.previewdata.PreviewUserData +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.Reply import io.getstream.chat.android.ui.common.state.messages.composer.MessageComposerState @@ -99,7 +100,11 @@ import io.getstream.chat.android.ui.common.utils.MediaStringUtil * @param onCancelAction Handler for the cancel button on Message actions, such as Edit and Reply. * @param onLinkPreviewClick Handler when the user taps on a link preview. * @param onCancelLinkPreviewClick Handler when the user taps on the cancel link preview. - * @param onUserSelected Handler when the user taps on a user suggestion item. + * @param onUserSelected Legacy handler that fires only when the user taps a user suggestion item. + * Kept for backward compatibility; new callers should use [onMentionSelected], which receives every + * mention type including users. Note both callbacks fire on a user tap: a custom [onUserSelected] + * runs in addition to [onMentionSelected], so the default [onMentionSelected] still inserts the + * mention. To replace the default selection behavior, override [onMentionSelected]. * @param onCommandSelected Handler for every tap on a command suggestion item, including taps on * disabled items. The default emits [MessageComposerViewEvent.CommandUnavailable] on * [MessageComposerViewModel.events] when the command is not available for the current action; @@ -107,6 +112,8 @@ import io.getstream.chat.android.ui.common.utils.MediaStringUtil * @param onAlsoSendToChannelChange Handler when the "Also send to channel" checkbox is changed. * @param onActiveCommandDismiss Called when the user taps the dismiss button on the active command chip. * @param recordingActions The actions that can be performed on an audio recording. + * @param onMentionSelected Handler when the user taps any mention suggestion item. This is the + * canonical callback for all mention selections. * @param input Customizable composable that represents the input field for the composer, [MessageInput] by default. */ @Composable @@ -124,7 +131,7 @@ public fun MessageComposer( onCancelAction: () -> Unit = { viewModel.dismissMessageActions() }, onLinkPreviewClick: ((LinkPreview) -> Unit)? = null, onCancelLinkPreviewClick: (() -> Unit)? = { viewModel.cancelLinkPreview() }, - onUserSelected: (User) -> Unit = { viewModel.selectMention(it) }, + onUserSelected: (User) -> Unit = {}, onCommandSelected: (Command) -> Unit = viewModel::selectCommand, onAlsoSendToChannelChange: (Boolean) -> Unit = viewModel::setAlsoSendToChannel, onActiveCommandDismiss: () -> Unit = viewModel::clearActiveCommand, @@ -132,6 +139,7 @@ public fun MessageComposer( viewModel = viewModel, sendOnComplete = ChatTheme.config.composer.audioRecordingSendOnComplete, ), + onMentionSelected: (Mention) -> Unit = viewModel::selectMention, input: @Composable RowScope.(MessageComposerState) -> Unit = { state -> val inputFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { @@ -189,6 +197,7 @@ public fun MessageComposer( onSendMessage(messageWithData) }, onUserSelected = onUserSelected, + onMentionSelected = onMentionSelected, onCommandSelected = onCommandSelected, onAlsoSendToChannelSelected = onAlsoSendToChannelChange, onActiveCommandDismiss = onActiveCommandDismiss, @@ -235,9 +244,16 @@ internal val LocalMessageComposerSnackbarHostState = * @param onCancelAction Handler for the cancel button on Message actions, such as Edit and Reply. * @param onLinkPreviewClick Handler when the user taps on a link preview. * @param onCancelLinkPreviewClick Handler when the user taps on the cancel link preview. - * @param onUserSelected Handler when the user taps on a user suggestion item. + * @param onUserSelected Legacy handler that fires only when the user taps a user suggestion item. + * Kept for backward compatibility; new callers should use [onMentionSelected], which receives every + * mention type including users. Note both callbacks fire on a user tap: a custom [onUserSelected] + * runs in addition to [onMentionSelected], so the default [onMentionSelected] still inserts the + * mention. To replace the default selection behavior, override [onMentionSelected]. * @param onCommandSelected Handler when the user taps on a command suggestion item, including taps * on disabled items. + * @param onMentionSelected Handler when the user taps any mention suggestion item. Canonical + * callback for all mention selections. Must be wired when any non-user mention kind is enabled — + * otherwise non-user mentions silently no-op on tap. * @param onAlsoSendToChannelChange Handler when the "Also send to channel" checkbox is changed. * @param onActiveCommandDismiss Called when the user taps the dismiss button on the active command chip. * @param recordingActions The actions that can be performed on an audio recording. @@ -264,6 +280,7 @@ public fun MessageComposer( onAlsoSendToChannelChange: (Boolean) -> Unit = {}, onActiveCommandDismiss: () -> Unit = {}, recordingActions: AudioRecordingActions = AudioRecordingActions.None, + onMentionSelected: (Mention) -> Unit = {}, input: @Composable RowScope.(MessageComposerState) -> Unit = { state -> ChatTheme.componentFactory.MessageComposerInput( params = MessageComposerInputParams( @@ -285,7 +302,13 @@ public fun MessageComposer( }, ) { val validationErrors = messageComposerState.validationErrors - val userSuggestions = messageComposerState.mentionSuggestions + // Prefer the list with all mentions but fall back to the legacy user-only field for + // callers that construct MessageComposerState directly without going through the controller. + val suggestedMentions = messageComposerState.suggestedMentions + val legacyMentionSuggestions = messageComposerState.mentionSuggestions + val mentionSuggestions = remember(suggestedMentions, legacyMentionSuggestions) { + suggestedMentions.ifEmpty { legacyMentionSuggestions.map(Mention::User) } + } val commandSuggestions = messageComposerState.commandSuggestions val snackbarHostState = LocalMessageComposerSnackbarHostState.current ?: remember { SnackbarHostState() } @@ -295,12 +318,13 @@ public fun MessageComposer( ) MessageComposerSurface(modifier = modifier) { - if (userSuggestions.isNotEmpty()) { - SuggestionsMenu(contentMaxHeight = UserSuggestionsMaxHeight) { - UserSuggestionList( - users = userSuggestions, + if (mentionSuggestions.isNotEmpty()) { + SuggestionsMenu(contentMaxHeight = MentionSuggestionsMaxHeight) { + MentionSuggestionList( + mentions = mentionSuggestions, currentUser = messageComposerState.currentUser, onUserSelected = onUserSelected, + onMentionSelected = onMentionSelected, ) } } @@ -358,7 +382,7 @@ public fun MessageComposer( } } -private val UserSuggestionsMaxHeight = 176.dp +private val MentionSuggestionsMaxHeight = 176.dp private val CommandSuggestionsMaxHeight = 208.dp @StringRes @@ -368,11 +392,13 @@ private fun MessageComposerViewEvent.messageResOrNull(): Int? = when (this) { is Reply -> R.string.stream_compose_message_composer_command_unavailable_in_reply else -> null } + is MessageComposerViewEvent.CancelCommandRequired -> when (action) { is Edit -> R.string.stream_compose_message_composer_cancel_command_to_edit is Reply -> R.string.stream_compose_message_composer_cancel_command_to_reply else -> null } + else -> null } @@ -380,6 +406,7 @@ private fun MessageComposerViewEvent.snackbarVariant(): StreamSnackbarVariant = is MessageComposerViewEvent.CommandUnavailable, is MessageComposerViewEvent.CancelCommandRequired, -> StreamSnackbarVariant.Error + else -> StreamSnackbarVariant.Default } @@ -516,6 +543,14 @@ internal fun MessageComposerFixedStyleWithUserSuggestions() { PreviewUserData.user1, PreviewUserData.user5, ), + suggestedMentions = listOf( + Mention.Channel, + Mention.Here, + Mention.User(PreviewUserData.userWithOnlineStatus), + Mention.User(PreviewUserData.user1), + Mention.User(PreviewUserData.user5), + Mention.Role("admin"), + ), ), onSendMessage = { _, _ -> }, ) @@ -622,6 +657,14 @@ internal fun MessageComposerFloatingStyleWithUserSuggestions() { PreviewUserData.user1, PreviewUserData.user5, ), + suggestedMentions = listOf( + Mention.Channel, + Mention.Here, + Mention.User(PreviewUserData.userWithOnlineStatus), + Mention.User(PreviewUserData.user1), + Mention.User(PreviewUserData.user5), + Mention.Role("admin"), + ), ), onSendMessage = { _, _ -> }, ) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/MentionSuggestionItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/MentionSuggestionItem.kt new file mode 100644 index 00000000000..ec3e7143097 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/MentionSuggestionItem.kt @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * 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. + */ + +package io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.MessageComposerSuggestionItemCenterContentParams +import io.getstream.chat.android.compose.ui.theme.MessageComposerSuggestionItemLeadingContentParams +import io.getstream.chat.android.compose.ui.theme.MessageComposerSuggestionItemTrailingContentParams +import io.getstream.chat.android.compose.ui.theme.MessageComposerUserSuggestionItemCenterContentParams +import io.getstream.chat.android.compose.ui.theme.MessageComposerUserSuggestionItemLeadingContentParams +import io.getstream.chat.android.compose.ui.theme.MessageComposerUserSuggestionItemTrailingContentParams +import io.getstream.chat.android.compose.ui.theme.StreamTokens +import io.getstream.chat.android.compose.ui.theme.UserAvatarParams +import io.getstream.chat.android.compose.ui.util.clickable +import io.getstream.chat.android.models.User +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention +import io.getstream.chat.android.ui.common.R as UiCommonR + +/** + * Default impl of [io.getstream.chat.android.compose.ui.theme.ChatComponentFactory.MessageComposerUserSuggestionItem]. + * Wires user-specific factory slots into [SuggestionItemRow]. + */ +@Composable +@Suppress("DEPRECATION") +internal fun UserSuggestionItem( + user: User, + currentUser: User?, + onUserSelected: (User) -> Unit, + modifier: Modifier = Modifier, +) { + SuggestionItemRow( + modifier = modifier, + onClick = { onUserSelected(user) }, + leadingContent = { + ChatTheme.componentFactory.MessageComposerUserSuggestionItemLeadingContent( + params = MessageComposerUserSuggestionItemLeadingContentParams( + user = user, + currentUser = currentUser, + ), + ) + }, + centerContent = { + ChatTheme.componentFactory.MessageComposerUserSuggestionItemCenterContent( + params = MessageComposerUserSuggestionItemCenterContentParams( + modifier = Modifier.weight(1f), + user = user, + ), + ) + }, + trailingContent = { + ChatTheme.componentFactory.MessageComposerUserSuggestionItemTrailingContent( + params = MessageComposerUserSuggestionItemTrailingContentParams( + user = user, + ), + ) + }, + ) +} + +/** + * Default impl of [io.getstream.chat.android.compose.ui.theme.ChatComponentFactory.MessageComposerSuggestionItem]. + * Wires mention-specific factory slots into [SuggestionItemRow]. + */ +@Composable +internal fun MentionSuggestionItem( + mention: Mention, + onMentionSelected: (Mention) -> Unit, + modifier: Modifier = Modifier, +) { + SuggestionItemRow( + modifier = modifier, + onClick = { onMentionSelected(mention) }, + leadingContent = { + ChatTheme.componentFactory.MessageComposerSuggestionItemLeadingContent( + params = MessageComposerSuggestionItemLeadingContentParams( + mention = mention, + ), + ) + }, + centerContent = { + ChatTheme.componentFactory.MessageComposerSuggestionItemCenterContent( + params = MessageComposerSuggestionItemCenterContentParams( + modifier = Modifier.weight(1f), + mention = mention, + ), + ) + }, + trailingContent = { + ChatTheme.componentFactory.MessageComposerSuggestionItemTrailingContent( + params = MessageComposerSuggestionItemTrailingContentParams( + mention = mention, + ), + ) + }, + ) +} + +@Composable +internal fun SuggestionItemRow( + onClick: () -> Unit, + leadingContent: @Composable () -> Unit, + centerContent: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, + trailingContent: @Composable () -> Unit = {}, +) { + Row( + modifier = modifier + .fillMaxWidth() + .minimumInteractiveComponentSize() + .clickable { onClick() } + .padding( + vertical = StreamTokens.spacingXs, + horizontal = StreamTokens.spacingSm, + ) + .testTag("Stream_SuggestionItem"), + verticalAlignment = Alignment.CenterVertically, + ) { + leadingContent() + centerContent() + trailingContent() + } +} + +@Composable +internal fun DefaultMentionSuggestionItemLeadingContent( + mention: Mention, + modifier: Modifier = Modifier, +) { + when (mention) { + is Mention.User -> ChatTheme.componentFactory.UserAvatar( + params = UserAvatarParams( + modifier = modifier.size(AvatarSize.Medium), + user = mention.user, + showIndicator = false, + showBorder = true, + ), + ) + Mention.Channel, Mention.Here -> MentionIconAvatar( + modifier = modifier, + iconRes = UiCommonR.drawable.stream_design_ic_megaphone, + ) + is Mention.Role -> MentionIconAvatar( + modifier = modifier, + iconRes = UiCommonR.drawable.stream_design_ic_role, + ) + is Mention.Group -> MentionIconAvatar( + modifier = modifier, + iconRes = R.drawable.stream_design_ic_users, + ) + else -> Spacer(Modifier.size(AvatarSize.Medium)) + } +} + +@Composable +internal fun DefaultMentionSuggestionItemCenterContent( + modifier: Modifier, + mention: Mention, +) { + if (mention is Mention.User) { + Text( + modifier = modifier.padding(start = StreamTokens.spacingSm), + text = mention.user.name, + style = ChatTheme.typography.bodyDefault, + color = ChatTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + return + } + Column( + modifier = modifier.padding(start = StreamTokens.spacingSm), + verticalArrangement = Arrangement.spacedBy(StreamTokens.spacing3xs), + ) { + Text( + text = "@${mention.display}", + style = ChatTheme.typography.bodyDefault, + color = ChatTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + mentionSubtitle(mention)?.let { subtitle -> + Text( + text = subtitle, + style = ChatTheme.typography.metadataDefault, + color = ChatTheme.colors.textTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** + * Wraps a 16dp icon in the circular surface-subtle "avatar" treatment used for non-user mention + * suggestions, sized to match [AvatarSize.Medium] so the popup row aligns with user mentions. + */ +@Composable +private fun MentionIconAvatar( + @DrawableRes iconRes: Int, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(AvatarSize.Medium) + .clip(CircleShape) + .background(ChatTheme.colors.backgroundCoreSurfaceSubtle) + .border(width = 1.dp, color = ChatTheme.colors.borderCoreSubtle, shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(id = iconRes), + contentDescription = null, + tint = ChatTheme.colors.textPrimary, + ) + } +} + +@Composable +private fun mentionSubtitle(mention: Mention): String? = when (mention) { + Mention.Channel -> stringResource( + id = R.string.stream_compose_message_composer_mention_suggestion_channel_subtitle, + ) + + Mention.Here -> stringResource( + id = R.string.stream_compose_message_composer_mention_suggestion_here_subtitle, + ) + + is Mention.Role -> stringResource( + id = R.string.stream_compose_message_composer_mention_suggestion_role_subtitle, + mention.role, + ) + + is Mention.Group -> mention.group.description?.takeIf { it.isNotBlank() } + + else -> null +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/UserSuggestionItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/UserSuggestionItem.kt deleted file mode 100644 index a25e0e4c1d5..00000000000 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/UserSuggestionItem.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. - * - * Licensed under the Stream License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE - * - * 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. - */ - -package io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions - -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Text -import androidx.compose.material3.minimumInteractiveComponentSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextOverflow -import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize -import io.getstream.chat.android.compose.ui.theme.ChatTheme -import io.getstream.chat.android.compose.ui.theme.MessageComposerUserSuggestionItemCenterContentParams -import io.getstream.chat.android.compose.ui.theme.MessageComposerUserSuggestionItemLeadingContentParams -import io.getstream.chat.android.compose.ui.theme.MessageComposerUserSuggestionItemTrailingContentParams -import io.getstream.chat.android.compose.ui.theme.StreamTokens -import io.getstream.chat.android.compose.ui.theme.UserAvatarParams -import io.getstream.chat.android.compose.ui.util.clickable -import io.getstream.chat.android.models.User - -@Composable -internal fun UserSuggestionItem( - user: User, - currentUser: User?, - onUserSelected: (User) -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .fillMaxWidth() - .minimumInteractiveComponentSize() - .clickable { onUserSelected(user) } - .padding( - vertical = StreamTokens.spacingXs, - horizontal = StreamTokens.spacingSm, - ) - .testTag("Stream_UserSuggestionItem"), - verticalAlignment = Alignment.CenterVertically, - ) { - ChatTheme.componentFactory.MessageComposerUserSuggestionItemLeadingContent( - params = MessageComposerUserSuggestionItemLeadingContentParams( - user = user, - currentUser = currentUser, - ), - ) - ChatTheme.componentFactory.MessageComposerUserSuggestionItemCenterContent( - params = MessageComposerUserSuggestionItemCenterContentParams( - modifier = Modifier.weight(1f), - user = user, - ), - ) - ChatTheme.componentFactory.MessageComposerUserSuggestionItemTrailingContent( - params = MessageComposerUserSuggestionItemTrailingContentParams( - user = user, - ), - ) - } -} - -@Composable -internal fun DefaultUserSuggestionItemLeadingContent( - user: User, - modifier: Modifier = Modifier, -) { - ChatTheme.componentFactory.UserAvatar( - params = UserAvatarParams( - modifier = modifier.size(AvatarSize.Medium), - user = user, - showIndicator = false, - showBorder = true, - ), - ) -} - -@Composable -internal fun DefaultUserSuggestionItemCenterContent( - modifier: Modifier, - user: User, -) { - Text( - modifier = modifier.padding(start = StreamTokens.spacingSm), - text = user.name, - style = ChatTheme.typography.bodyDefault, - color = ChatTheme.colors.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) -} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/UserSuggestionList.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/UserSuggestionList.kt index 95327c0dd94..91f8df8a1d0 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/UserSuggestionList.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/UserSuggestionList.kt @@ -19,60 +19,71 @@ package io.getstream.chat.android.compose.ui.messages.composer.internal.suggesti import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import io.getstream.chat.android.compose.ui.theme.ChatPreviewTheme import io.getstream.chat.android.compose.ui.theme.ChatTheme -import io.getstream.chat.android.compose.ui.theme.MessageComposerUserSuggestionItemParams +import io.getstream.chat.android.compose.ui.theme.MessageComposerSuggestionItemParams import io.getstream.chat.android.compose.ui.theme.StreamTokens import io.getstream.chat.android.models.User import io.getstream.chat.android.previewdata.PreviewUserData +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention @Composable -internal fun UserSuggestionList( - users: List, +internal fun MentionSuggestionList( + mentions: List, currentUser: User? = null, onUserSelected: (User) -> Unit = {}, + onMentionSelected: (Mention) -> Unit = {}, ) { LazyColumn( modifier = Modifier .fillMaxWidth() - .testTag("Stream_UserSuggestionList"), + .testTag("Stream_MentionSuggestionList"), contentPadding = PaddingValues(vertical = StreamTokens.spacingXs), ) { - items( - items = users, - key = User::id, - ) { user -> - ChatTheme.componentFactory.MessageComposerUserSuggestionItem( - params = MessageComposerUserSuggestionItemParams( - user = user, + itemsIndexed( + items = mentions, + key = ::mentionKey, + ) { _, mention -> + ChatTheme.componentFactory.MessageComposerSuggestionItem( + params = MessageComposerSuggestionItemParams( + mention = mention, currentUser = currentUser, - onUserSelected = onUserSelected, + onMentionSelected = { selected -> + if (selected is Mention.User) onUserSelected(selected.user) + onMentionSelected(selected) + }, ), ) } } } +private fun mentionKey(index: Int, mention: Mention): String = when (mention) { + is Mention.User -> "user:${mention.user.id}" + is Mention.Channel -> "channel" + is Mention.Here -> "here" + is Mention.Role -> "role:${mention.role}" + is Mention.Group -> "group:${mention.group.id}" + else -> "custom:${mention.type.value}:$index" +} + @Composable @Preview(showBackground = true) -private fun MemberSuggestionListPreview() { +private fun MentionSuggestionListPreview() { ChatPreviewTheme { - UserSuggestionList() + MentionSuggestionList( + mentions = listOf( + Mention.Channel, + Mention.Here, + Mention.User(PreviewUserData.user1), + Mention.User(PreviewUserData.user2), + Mention.Role("admin"), + ), + ) } } - -@Composable -internal fun UserSuggestionList() { - UserSuggestionList( - users = listOf( - PreviewUserData.user1, - PreviewUserData.user2, - PreviewUserData.user3, - ), - ) -} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index 65879b6f0a4..8fc033c0ed5 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -130,8 +130,9 @@ import io.getstream.chat.android.compose.ui.messages.composer.internal.attachmen import io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions.CommandSuggestionItem import io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions.DefaultCommandSuggestionItemCenterContent import io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions.DefaultCommandSuggestionItemLeadingContent -import io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions.DefaultUserSuggestionItemCenterContent -import io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions.DefaultUserSuggestionItemLeadingContent +import io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions.DefaultMentionSuggestionItemCenterContent +import io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions.DefaultMentionSuggestionItemLeadingContent +import io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions.MentionSuggestionItem import io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions.UserSuggestionItem import io.getstream.chat.android.compose.ui.messages.header.DefaultChannelHeaderCenterContent import io.getstream.chat.android.compose.ui.messages.header.DefaultChannelHeaderLeadingContent @@ -170,6 +171,7 @@ import io.getstream.chat.android.compose.viewmodel.messages.AudioPlayerViewModel import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Reaction +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention import io.getstream.chat.android.ui.common.state.messages.MessageMode import io.getstream.chat.android.ui.common.state.messages.React import io.getstream.chat.android.ui.common.state.messages.composer.RecordingState @@ -1265,6 +1267,7 @@ public interface ChatComponentFactory { onLinkPreviewClick = params.onLinkPreviewClick, onCancelLinkPreviewClick = params.onCancelLinkPreviewClick, onUserSelected = params.onUserSelected, + onMentionSelected = params.onMentionSelected, onCommandSelected = params.onCommandSelected, onAlsoSendToChannelChange = params.onAlsoSendToChannelSelected, onActiveCommandDismiss = params.onActiveCommandDismiss, @@ -1299,10 +1302,13 @@ public interface ChatComponentFactory { /** * The default user suggestion item of the message composer. * - * Used in [io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions.UserSuggestionList]. - * * @param params Parameters for this component. */ + @Deprecated( + message = "Override MessageComposerSuggestionItem, which handles every Mention type, not only user mentions.", + replaceWith = ReplaceWith("MessageComposerSuggestionItem"), + level = DeprecationLevel.WARNING, + ) @Composable public fun MessageComposerUserSuggestionItem(params: MessageComposerUserSuggestionItemParams) { UserSuggestionItem( @@ -1312,6 +1318,80 @@ public interface ChatComponentFactory { ) } + /** + * The default suggestion item of the message composer. Handles every [Mention] type. User + * mentions delegate to [MessageComposerUserSuggestionItem] for backward compatibility. + * + * @param params Parameters for this component. + */ + @Suppress("DEPRECATION") + @Composable + public fun MessageComposerSuggestionItem(params: MessageComposerSuggestionItemParams) { + when (val mention = params.mention) { + is Mention.User -> MessageComposerUserSuggestionItem( + params = MessageComposerUserSuggestionItemParams( + user = mention.user, + currentUser = params.currentUser, + onUserSelected = { params.onMentionSelected(mention) }, + ), + ) + + else -> MentionSuggestionItem( + mention = mention, + onMentionSelected = params.onMentionSelected, + ) + } + } + + /** + * The default leading content for a non-user [Mention] suggestion item. Renders a placeholder + * icon; override to swap in mention-type-specific drawables. + * + * Used as part of [MessageComposerSuggestionItem]. + * + * @param params Parameters for this component. + */ + @Composable + public fun MessageComposerSuggestionItemLeadingContent( + params: MessageComposerSuggestionItemLeadingContentParams, + ) { + DefaultMentionSuggestionItemLeadingContent( + modifier = params.modifier, + mention = params.mention, + ) + } + + /** + * The default center content for a non-user [Mention] suggestion item. Renders `@`. + * + * Used as part of [MessageComposerSuggestionItem]. + * + * @param params Parameters for this component. + */ + @Composable + public fun MessageComposerSuggestionItemCenterContent( + params: MessageComposerSuggestionItemCenterContentParams, + ) { + DefaultMentionSuggestionItemCenterContent( + modifier = params.modifier, + mention = params.mention, + ) + } + + /** + * The default trailing content for a non-user [Mention] suggestion item. Empty by default; + * override to add a trailing element. + * + * Used as part of [MessageComposerSuggestionItem]. + * + * @param params Parameters for this component. + */ + @Composable + public fun MessageComposerSuggestionItemTrailingContent( + params: MessageComposerSuggestionItemTrailingContentParams, + ) { + } + /** * The default leading content of the user suggestion item of the message composer. * @@ -1319,13 +1399,20 @@ public interface ChatComponentFactory { * * @param params Parameters for this component. */ + @Deprecated( + message = "Override MessageComposerSuggestionItemLeadingContent, which handles every Mention type.", + replaceWith = ReplaceWith("MessageComposerSuggestionItemLeadingContent"), + level = DeprecationLevel.WARNING, + ) @Composable public fun MessageComposerUserSuggestionItemLeadingContent( params: MessageComposerUserSuggestionItemLeadingContentParams, ) { - DefaultUserSuggestionItemLeadingContent( - modifier = params.modifier, - user = params.user, + MessageComposerSuggestionItemLeadingContent( + params = MessageComposerSuggestionItemLeadingContentParams( + mention = Mention.User(params.user), + modifier = params.modifier, + ), ) } @@ -1336,13 +1423,20 @@ public interface ChatComponentFactory { * * @param params Parameters for this component. */ + @Deprecated( + message = "Override MessageComposerSuggestionItemCenterContent, which handles every Mention type.", + replaceWith = ReplaceWith("MessageComposerSuggestionItemCenterContent"), + level = DeprecationLevel.WARNING, + ) @Composable public fun MessageComposerUserSuggestionItemCenterContent( params: MessageComposerUserSuggestionItemCenterContentParams, ) { - DefaultUserSuggestionItemCenterContent( - modifier = params.modifier, - user = params.user, + MessageComposerSuggestionItemCenterContent( + params = MessageComposerSuggestionItemCenterContentParams( + mention = Mention.User(params.user), + modifier = params.modifier, + ), ) } @@ -1353,10 +1447,21 @@ public interface ChatComponentFactory { * * @param params Parameters for this component. */ + @Deprecated( + message = "Override MessageComposerSuggestionItemTrailingContent, which handles every Mention type.", + replaceWith = ReplaceWith("MessageComposerSuggestionItemTrailingContent"), + level = DeprecationLevel.WARNING, + ) @Composable public fun MessageComposerUserSuggestionItemTrailingContent( params: MessageComposerUserSuggestionItemTrailingContentParams, ) { + MessageComposerSuggestionItemTrailingContent( + params = MessageComposerSuggestionItemTrailingContentParams( + mention = Mention.User(params.user), + modifier = params.modifier, + ), + ) } /** diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt index 725d37ea607..7f6c59b291a 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt @@ -993,6 +993,7 @@ public class SwipeToReplyContentParams * @param recordingActions The actions to control the audio recording. * @param onLinkPreviewClick Action invoked when a link preview is clicked. * @param onCancelLinkPreviewClick Action invoked when the cancel link preview button is clicked. + * @param onMentionSelected Action invoked when a [Mention] is selected. */ public data class MessageComposerParams( val messageComposerState: MessageComposerState, @@ -1007,6 +1008,11 @@ public data class MessageComposerParams( val onValueChange: (String) -> Unit = {}, val onAttachmentRemoved: (Attachment) -> Unit = {}, val onCancelAction: () -> Unit = {}, + @Deprecated( + message = "Use onMentionSelected, which receives every mention type.", + replaceWith = ReplaceWith("onMentionSelected"), + level = DeprecationLevel.WARNING, + ) val onUserSelected: (User) -> Unit = {}, val onCommandSelected: (Command) -> Unit = {}, val onAlsoSendToChannelSelected: (Boolean) -> Unit = {}, @@ -1014,6 +1020,7 @@ public data class MessageComposerParams( val recordingActions: AudioRecordingActions = AudioRecordingActions.None, val onLinkPreviewClick: ((LinkPreview) -> Unit)? = null, val onCancelLinkPreviewClick: (() -> Unit)? = null, + val onMentionSelected: (Mention) -> Unit = {}, ) /** @@ -1044,6 +1051,52 @@ public data class MessageComposerUserSuggestionItemParams( val onUserSelected: (User) -> Unit, ) +/** + * Parameters for [ChatComponentFactory.MessageComposerSuggestionItem]. + * + * @param mention The [Mention] for which the suggestion is rendered. + * @param onMentionSelected Action invoked when the mention is selected. + * @param currentUser The currently logged in user, used when rendering a [Mention.User]. + */ +public data class MessageComposerSuggestionItemParams( + val mention: Mention, + val onMentionSelected: (Mention) -> Unit, + val currentUser: User? = null, +) + +/** + * Parameters for [ChatComponentFactory.MessageComposerSuggestionItemLeadingContent]. + * + * @param mention The mention for which the leading content is rendered. + * @param modifier Modifier for styling. + */ +public data class MessageComposerSuggestionItemLeadingContentParams( + val mention: Mention, + val modifier: Modifier = Modifier, +) + +/** + * Parameters for [ChatComponentFactory.MessageComposerSuggestionItemCenterContent]. + * + * @param mention The mention for which the center content is rendered. + * @param modifier Modifier for styling. + */ +public data class MessageComposerSuggestionItemCenterContentParams( + val mention: Mention, + val modifier: Modifier = Modifier, +) + +/** + * Parameters for [ChatComponentFactory.MessageComposerSuggestionItemTrailingContent]. + * + * @param mention The mention for which the trailing content is rendered. + * @param modifier Modifier for styling. + */ +public data class MessageComposerSuggestionItemTrailingContentParams( + val mention: Mention, + val modifier: Modifier = Modifier, +) + /** * Parameters for [ChatComponentFactory.MessageComposerUserSuggestionItemLeadingContent]. * diff --git a/stream-chat-android-compose/src/main/res/values-es/strings.xml b/stream-chat-android-compose/src/main/res/values-es/strings.xml index ae1b4efe6b7..5e0dbd750ed 100644 --- a/stream-chat-android-compose/src/main/res/values-es/strings.xml +++ b/stream-chat-android-compose/src/main/res/values-es/strings.xml @@ -64,6 +64,9 @@ "Cancelar" "silenciado" "fijado" + "Notificar a todos en esta conversación" + "Notificar a todos los miembros conectados en esta conversación" + "Notificar a todos los miembros %1$s" "Abrir conversación" "Abrir opciones de conversación" "Botón de reproducción" diff --git a/stream-chat-android-compose/src/main/res/values-fr/strings.xml b/stream-chat-android-compose/src/main/res/values-fr/strings.xml index b12540c68c1..6b50bae2322 100644 --- a/stream-chat-android-compose/src/main/res/values-fr/strings.xml +++ b/stream-chat-android-compose/src/main/res/values-fr/strings.xml @@ -64,6 +64,9 @@ "Annuler" "en sourdine" "épinglé" + "Notifier tout le monde dans cette conversation" + "Notifier tous les membres en ligne dans cette conversation" + "Notifier tous les membres %1$s" "Ouvrir la conversation" "Ouvrir les options de conversation" "Bouton de lecture" diff --git a/stream-chat-android-compose/src/main/res/values-hi/strings.xml b/stream-chat-android-compose/src/main/res/values-hi/strings.xml index 7516cc9e540..62cd97ddb35 100644 --- a/stream-chat-android-compose/src/main/res/values-hi/strings.xml +++ b/stream-chat-android-compose/src/main/res/values-hi/strings.xml @@ -124,6 +124,9 @@ "रद्द करें" "म्यूट किया गया" "पिन किया गया" + "इस बातचीत में सभी को सूचित करें" + "इस बातचीत के सभी ऑनलाइन सदस्यों को सूचित करें" + "सभी %1$s सदस्यों को सूचित करें" "बातचीत खोलें" "बातचीत के विकल्प खोलें" "चलाएँ बटन" diff --git a/stream-chat-android-compose/src/main/res/values-in/strings.xml b/stream-chat-android-compose/src/main/res/values-in/strings.xml index b56b5f6ec30..53e1cf8a0b4 100644 --- a/stream-chat-android-compose/src/main/res/values-in/strings.xml +++ b/stream-chat-android-compose/src/main/res/values-in/strings.xml @@ -64,6 +64,9 @@ "Batal" "dibisukan" "disematkan" + "Beri tahu semua orang di percakapan ini" + "Beri tahu semua anggota online di percakapan ini" + "Beri tahu semua anggota %1$s" "Buka percakapan" "Buka opsi percakapan" "Tombol putar" diff --git a/stream-chat-android-compose/src/main/res/values-it/strings.xml b/stream-chat-android-compose/src/main/res/values-it/strings.xml index d4ae7911b63..304f63db0c9 100644 --- a/stream-chat-android-compose/src/main/res/values-it/strings.xml +++ b/stream-chat-android-compose/src/main/res/values-it/strings.xml @@ -124,6 +124,9 @@ "Annulla" "silenziato" "fissato" + "Notifica tutti in questa conversazione" + "Notifica tutti i membri online in questa conversazione" + "Notifica tutti i membri %1$s" "Apri conversazione" "Apri opzioni conversazione" "Pulsante riproduci" diff --git a/stream-chat-android-compose/src/main/res/values-ja/strings.xml b/stream-chat-android-compose/src/main/res/values-ja/strings.xml index 59a7fb5d065..1d879dfc251 100644 --- a/stream-chat-android-compose/src/main/res/values-ja/strings.xml +++ b/stream-chat-android-compose/src/main/res/values-ja/strings.xml @@ -64,6 +64,9 @@ "キャンセル" "ミュート中" "ピン留め中" + "この会話の全員に通知します" + "この会話のオンラインメンバー全員に通知します" + "%1$s メンバー全員に通知します" "会話を開く" "会話のオプションを開く" "再生ボタン" diff --git a/stream-chat-android-compose/src/main/res/values-ko/strings.xml b/stream-chat-android-compose/src/main/res/values-ko/strings.xml index 0812fee37f0..aa45c1710a9 100644 --- a/stream-chat-android-compose/src/main/res/values-ko/strings.xml +++ b/stream-chat-android-compose/src/main/res/values-ko/strings.xml @@ -64,6 +64,9 @@ "취소" "음소거됨" "고정됨" + "이 대화의 모든 사람에게 알립니다" + "이 대화의 모든 온라인 멤버에게 알립니다" + "모든 %1$s 멤버에게 알립니다" "대화 열기" "대화 옵션 열기" "재생 버튼" diff --git a/stream-chat-android-compose/src/main/res/values/strings.xml b/stream-chat-android-compose/src/main/res/values/strings.xml index e616cb40364..3fc27571f4d 100644 --- a/stream-chat-android-compose/src/main/res/values/strings.xml +++ b/stream-chat-android-compose/src/main/res/values/strings.xml @@ -204,6 +204,9 @@ Commands not available while editing Cancel command to edit Cancel command to reply + Notify everyone in this channel + Notify every online member in this channel + Notify all %1$s members Also send in Channel File type is not supported Unsupported attachment diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/UserSuggestionListTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/MentionSuggestionListTest.kt similarity index 64% rename from stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/UserSuggestionListTest.kt rename to stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/MentionSuggestionListTest.kt index deb5b77ebfd..3efe5e79647 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/UserSuggestionListTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/MentionSuggestionListTest.kt @@ -20,10 +20,12 @@ import app.cash.paparazzi.DeviceConfig import app.cash.paparazzi.Paparazzi import com.android.ide.common.rendering.api.SessionParams import io.getstream.chat.android.compose.ui.PaparazziComposeTest +import io.getstream.chat.android.previewdata.PreviewUserData +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention import org.junit.Rule import org.junit.Test -internal class UserSuggestionListTest : PaparazziComposeTest { +internal class MentionSuggestionListTest : PaparazziComposeTest { @get:Rule override val paparazzi = Paparazzi( @@ -32,9 +34,18 @@ internal class UserSuggestionListTest : PaparazziComposeTest { ) @Test - fun `user suggestion list`() { + fun `mention suggestion list`() { snapshotWithDarkModeRow { - UserSuggestionList() + MentionSuggestionList( + mentions = listOf( + Mention.Channel, + Mention.Here, + Mention.User(PreviewUserData.user1), + Mention.User(PreviewUserData.user2), + Mention.User(PreviewUserData.user3), + Mention.Role("admin"), + ), + ) } } } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt index eded2f7c056..5acdf1031bf 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt @@ -29,6 +29,7 @@ import io.getstream.chat.android.models.App import io.getstream.chat.android.models.AppSettings import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelCapabilities import io.getstream.chat.android.models.ChannelData import io.getstream.chat.android.models.Command import io.getstream.chat.android.models.Config @@ -36,7 +37,9 @@ import io.getstream.chat.android.models.FileUploadConfig import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.Role import io.getstream.chat.android.models.User +import io.getstream.chat.android.models.UserGroup import io.getstream.chat.android.positiveRandomLong import io.getstream.chat.android.randomString import io.getstream.chat.android.test.TestCoroutineExtension @@ -60,7 +63,11 @@ import org.amshove.kluent.`should be equal to` import org.amshove.kluent.`should be instance of` import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn @@ -367,7 +374,10 @@ internal class MessageComposerViewModelTest { val viewModel = Fixture() .givenCurrentUser() .givenChannelQuery() - .givenChannelState(members = listOf(Member(user = user1), Member(user = user2))) + .givenChannelState( + channelData = channelDataWith(ChannelCapabilities.CREATE_MENTION), + members = listOf(Member(user = user1), Member(user = user2)), + ) .get() // Handling mentions on input changes is debounced so we advance time until idle to make sure @@ -384,7 +394,10 @@ internal class MessageComposerViewModelTest { val viewModel = Fixture() .givenCurrentUser() .givenChannelQuery() - .givenChannelState(members = listOf(Member(user = user1), Member(user = user2))) + .givenChannelState( + channelData = channelDataWith(ChannelCapabilities.CREATE_MENTION), + members = listOf(Member(user = user1), Member(user = user2)), + ) .get() // Handling mentions on input changes is debounced so we advance time until idle to make sure @@ -399,6 +412,31 @@ internal class MessageComposerViewModelTest { viewModel.messageInput.value.text `should be equal to` "@Jc Miñarro " } + @ParameterizedTest + @MethodSource("nonUserMentionCases") + fun `Given message composer When typing mention query Should surface the matching non-user Mention`( + capability: String, + query: String, + expectedMention: Mention, + roles: List, + groups: List, + ) = runTest { + val viewModel = Fixture() + .givenCurrentUser() + .givenChannelQuery() + .givenChannelState(channelData = channelDataWith(capability)) + .givenRoleSearchResult(roles) + .givenGroupSearchResult(groups) + .givenNoMemberQueryResult() + .get() + + viewModel.setMessageInput(query) + advanceUntilIdle() + + viewModel.messageComposerState.value.suggestedMentions `should be equal to` listOf(expectedMention) + viewModel.messageComposerState.value.mentionSuggestions.size `should be equal to` 0 + } + @Test fun `Given message composer When selecting a custom mention Should populate the input with the mention`() = runTest { @@ -515,6 +553,21 @@ internal class MessageComposerViewModelTest { whenever(chatClient.markMessageRead(any(), any(), any())) doReturn Unit.asCall() } + fun givenRoleSearchResult(roles: List) = apply { + whenever(chatClient.searchRoles(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .doReturn(roles.asCall()) + } + + fun givenGroupSearchResult(groups: List) = apply { + whenever(chatClient.searchUserGroups(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .doReturn(groups.asCall()) + } + + fun givenNoMemberQueryResult() = apply { + whenever(chatClient.queryMembers(any(), any(), any(), any(), any(), any(), any())) + .doReturn(emptyList().asCall()) + } + fun get(): MessageComposerViewModel { return MessageComposerViewModel( messageComposerController = MessageComposerController( @@ -536,6 +589,38 @@ internal class MessageComposerViewModelTest { companion object { + private fun channelDataWith(vararg capabilities: String) = ChannelData( + type = "messaging", + id = "123", + ownCapabilities = capabilities.toSet(), + ) + + @JvmStatic + fun nonUserMentionCases(): List { + val role = Role(name = "admin") + val group = UserGroup(id = "g1", name = "platform") + val noRoles = emptyList() + val noGroups = emptyList() + return listOf( + Arguments.of(ChannelCapabilities.NOTIFY_CHANNEL, "@", Mention.Channel, noRoles, noGroups), + Arguments.of(ChannelCapabilities.NOTIFY_HERE, "@", Mention.Here, noRoles, noGroups), + Arguments.of( + ChannelCapabilities.NOTIFY_ROLE, + "@admin", + Mention.Role(role.name), + listOf(role), + noGroups, + ), + Arguments.of( + ChannelCapabilities.NOTIFY_GROUP, + "@plat", + Mention.Group(group), + noRoles, + listOf(group), + ), + ) + } + val user1 = User( id = "Jc", name = "Jc Miñarro", diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions_MentionSuggestionListTest_mention_suggestion_list.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions_MentionSuggestionListTest_mention_suggestion_list.png new file mode 100644 index 00000000000..f1fcc13be17 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal.suggestions_MentionSuggestionListTest_mention_suggestion_list.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageComposerTest_fixed_style_with_user_suggestions.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageComposerTest_fixed_style_with_user_suggestions.png index 573fdf19733..a549a811b21 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageComposerTest_fixed_style_with_user_suggestions.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageComposerTest_fixed_style_with_user_suggestions.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageComposerTest_floating_style_with_user_suggestions_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageComposerTest_floating_style_with_user_suggestions_in_dark_mode.png index 4664259d4ef..0917c432d2c 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageComposerTest_floating_style_with_user_suggestions_in_dark_mode.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageComposerTest_floating_style_with_user_suggestions_in_dark_mode.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageComposerTest_floating_style_with_user_suggestions_in_light_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageComposerTest_floating_style_with_user_suggestions_in_light_mode.png index 3c5f119455c..9530e8126b6 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageComposerTest_floating_style_with_user_suggestions_in_light_mode.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageComposerTest_floating_style_with_user_suggestions_in_light_mode.png differ diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 6be77d925fa..df2e1060bd8 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -482,6 +482,7 @@ public final class io/getstream/chat/android/models/ChannelCapabilities { public static final field CAST_POLL_VOTE Ljava/lang/String; public static final field CONNECT_EVENTS Ljava/lang/String; public static final field CREATE_CALL Ljava/lang/String; + public static final field CREATE_MENTION Ljava/lang/String; public static final field DELETE_ANY_MESSAGE Ljava/lang/String; public static final field DELETE_CHANNEL Ljava/lang/String; public static final field DELETE_OWN_MESSAGE Ljava/lang/String; @@ -493,6 +494,10 @@ public final class io/getstream/chat/android/models/ChannelCapabilities { public static final field JOIN_CHANNEL Ljava/lang/String; public static final field LEAVE_CHANNEL Ljava/lang/String; public static final field MUTE_CHANNEL Ljava/lang/String; + public static final field NOTIFY_CHANNEL Ljava/lang/String; + public static final field NOTIFY_GROUP Ljava/lang/String; + public static final field NOTIFY_HERE Ljava/lang/String; + public static final field NOTIFY_ROLE Ljava/lang/String; public static final field PIN_MESSAGE Ljava/lang/String; public static final field QUOTE_MESSAGE Ljava/lang/String; public static final field READ_EVENTS Ljava/lang/String; diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelCapabilities.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelCapabilities.kt index fe1e4bde7f4..e004d0e6728 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelCapabilities.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelCapabilities.kt @@ -128,4 +128,19 @@ public object ChannelCapabilities { /** Ability to send a poll. */ public const val SEND_POLL: String = "send-poll" + + /** Ability to mention users by name (e.g. `@Alice`). */ + public const val CREATE_MENTION: String = "create-mention" + + /** Ability to use `@channel` to notify all channel members. */ + public const val NOTIFY_CHANNEL: String = "notify-channel" + + /** Ability to use `@here` to notify online channel members. */ + public const val NOTIFY_HERE: String = "notify-here" + + /** Ability to use role mentions (e.g. `@admin`, `@moderator`). */ + public const val NOTIFY_ROLE: String = "notify-role" + + /** Ability to use user-group mentions. */ + public const val NOTIFY_GROUP: String = "notify-group" } diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt index dc68cb799c2..4f77763b829 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt @@ -118,7 +118,7 @@ private object MessageComposerHandlingActionsSnippet { onValueChange = viewModel::setMessageInput, onAttachmentRemoved = viewModel::removeAttachment, onCancelAction = viewModel::dismissMessageActions, - onUserSelected = viewModel::selectMention, + onMentionSelected = viewModel::selectMention, onCommandSelected = viewModel::selectCommand, onAlsoSendToChannelChange = viewModel::setAlsoSendToChannel, ) diff --git a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api index 1e2c2cd3e64..16e4335c299 100644 --- a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api +++ b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api @@ -2359,7 +2359,8 @@ public final class io/getstream/chat/android/ui/common/state/messages/composer/M public fun (Ljava/lang/String;Ljava/util/List;Lio/getstream/chat/android/ui/common/state/messages/MessageAction;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lio/getstream/chat/android/models/LinkPreview;ILio/getstream/chat/android/ui/common/state/messages/MessageMode;ZLjava/util/Set;ZLio/getstream/chat/android/models/User;Lio/getstream/chat/android/ui/common/state/messages/composer/RecordingState;ZZ)V public fun (Ljava/lang/String;Ljava/util/List;Lio/getstream/chat/android/ui/common/state/messages/MessageAction;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lio/getstream/chat/android/models/LinkPreview;ILio/getstream/chat/android/ui/common/state/messages/MessageMode;ZLjava/util/Set;ZLio/getstream/chat/android/models/User;Lio/getstream/chat/android/ui/common/state/messages/composer/RecordingState;ZZLjava/util/Set;)V public fun (Ljava/lang/String;Ljava/util/List;Lio/getstream/chat/android/ui/common/state/messages/MessageAction;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lio/getstream/chat/android/models/LinkPreview;ILio/getstream/chat/android/ui/common/state/messages/MessageMode;ZLjava/util/Set;ZLio/getstream/chat/android/models/User;Lio/getstream/chat/android/ui/common/state/messages/composer/RecordingState;ZZLjava/util/Set;Lio/getstream/chat/android/models/Command;)V - public synthetic fun (Ljava/lang/String;Ljava/util/List;Lio/getstream/chat/android/ui/common/state/messages/MessageAction;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lio/getstream/chat/android/models/LinkPreview;ILio/getstream/chat/android/ui/common/state/messages/MessageMode;ZLjava/util/Set;ZLio/getstream/chat/android/models/User;Lio/getstream/chat/android/ui/common/state/messages/composer/RecordingState;ZZLjava/util/Set;Lio/getstream/chat/android/models/Command;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/util/List;Lio/getstream/chat/android/ui/common/state/messages/MessageAction;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lio/getstream/chat/android/models/LinkPreview;ILio/getstream/chat/android/ui/common/state/messages/MessageMode;ZLjava/util/Set;ZLio/getstream/chat/android/models/User;Lio/getstream/chat/android/ui/common/state/messages/composer/RecordingState;ZZLjava/util/Set;Lio/getstream/chat/android/models/Command;Ljava/util/List;)V + public synthetic fun (Ljava/lang/String;Ljava/util/List;Lio/getstream/chat/android/ui/common/state/messages/MessageAction;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lio/getstream/chat/android/models/LinkPreview;ILio/getstream/chat/android/ui/common/state/messages/MessageMode;ZLjava/util/Set;ZLio/getstream/chat/android/models/User;Lio/getstream/chat/android/ui/common/state/messages/composer/RecordingState;ZZLjava/util/Set;Lio/getstream/chat/android/models/Command;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()Z public final fun component11 ()Ljava/util/Set; @@ -2370,6 +2371,7 @@ public final class io/getstream/chat/android/ui/common/state/messages/composer/M public final fun component16 ()Z public final fun component17 ()Ljava/util/Set; public final fun component18 ()Lio/getstream/chat/android/models/Command; + public final fun component19 ()Ljava/util/List; public final fun component2 ()Ljava/util/List; public final fun component3 ()Lio/getstream/chat/android/ui/common/state/messages/MessageAction; public final fun component4 ()Ljava/util/List; @@ -2378,8 +2380,8 @@ public final class io/getstream/chat/android/ui/common/state/messages/composer/M public final fun component7 ()Lio/getstream/chat/android/models/LinkPreview; public final fun component8 ()I public final fun component9 ()Lio/getstream/chat/android/ui/common/state/messages/MessageMode; - public final fun copy (Ljava/lang/String;Ljava/util/List;Lio/getstream/chat/android/ui/common/state/messages/MessageAction;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lio/getstream/chat/android/models/LinkPreview;ILio/getstream/chat/android/ui/common/state/messages/MessageMode;ZLjava/util/Set;ZLio/getstream/chat/android/models/User;Lio/getstream/chat/android/ui/common/state/messages/composer/RecordingState;ZZLjava/util/Set;Lio/getstream/chat/android/models/Command;)Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Ljava/lang/String;Ljava/util/List;Lio/getstream/chat/android/ui/common/state/messages/MessageAction;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lio/getstream/chat/android/models/LinkPreview;ILio/getstream/chat/android/ui/common/state/messages/MessageMode;ZLjava/util/Set;ZLio/getstream/chat/android/models/User;Lio/getstream/chat/android/ui/common/state/messages/composer/RecordingState;ZZLjava/util/Set;Lio/getstream/chat/android/models/Command;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState; + public final fun copy (Ljava/lang/String;Ljava/util/List;Lio/getstream/chat/android/ui/common/state/messages/MessageAction;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lio/getstream/chat/android/models/LinkPreview;ILio/getstream/chat/android/ui/common/state/messages/MessageMode;ZLjava/util/Set;ZLio/getstream/chat/android/models/User;Lio/getstream/chat/android/ui/common/state/messages/composer/RecordingState;ZZLjava/util/Set;Lio/getstream/chat/android/models/Command;Ljava/util/List;)Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;Ljava/lang/String;Ljava/util/List;Lio/getstream/chat/android/ui/common/state/messages/MessageAction;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lio/getstream/chat/android/models/LinkPreview;ILio/getstream/chat/android/ui/common/state/messages/MessageMode;ZLjava/util/Set;ZLio/getstream/chat/android/models/User;Lio/getstream/chat/android/ui/common/state/messages/composer/RecordingState;ZZLjava/util/Set;Lio/getstream/chat/android/models/Command;Ljava/util/List;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState; public fun equals (Ljava/lang/Object;)Z public final fun getAction ()Lio/getstream/chat/android/ui/common/state/messages/MessageAction; public final fun getActiveCommand ()Lio/getstream/chat/android/models/Command; @@ -2398,6 +2400,7 @@ public final class io/getstream/chat/android/ui/common/state/messages/composer/M public final fun getRecording ()Lio/getstream/chat/android/ui/common/state/messages/composer/RecordingState; public final fun getSelectedMentions ()Ljava/util/Set; public final fun getSendEnabled ()Z + public final fun getSuggestedMentions ()Ljava/util/List; public final fun getValidationErrors ()Ljava/util/List; public fun hashCode ()I public fun toString ()Ljava/lang/String; diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt index 86954c367b4..cceb49d248c 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt @@ -34,8 +34,11 @@ import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.LinkPreview import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User +import io.getstream.chat.android.models.UserGroup import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.MentionLookupHandler import io.getstream.chat.android.ui.common.feature.messages.composer.mention.UserLookupHandler +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.mentionRegex import io.getstream.chat.android.ui.common.feature.messages.composer.typing.TypingSuggester import io.getstream.chat.android.ui.common.feature.messages.composer.typing.TypingSuggestionOptions import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI @@ -90,7 +93,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.io.File import java.util.Date import java.util.concurrent.TimeUnit @@ -274,10 +276,11 @@ public class MessageComposerController( /** UI state of the current composer input. */ public val messageInput: StateFlow = _messageInput.asStateFlow() - /** - * Represents the list of users in the channel. - */ - private var users: List = emptyList() + private val mentionLookupHandler = MentionLookupHandler( + chatClient = chatClient, + channelState = channelState, + userLookupHandler = userLookupHandler, + ) /** * Represents the list of available commands in the channel. @@ -294,6 +297,11 @@ public class MessageComposerController( */ private var linkPreviewJob: Job? = null + /** + * Represents the coroutine [Job] resolving mention suggestions for the current input. + */ + private var mentionSuggestionJob: Job? = null + /** * The URL whose link preview the user explicitly dismissed via [cancelLinkPreview]. * `null` when no preview has been dismissed. Reset when the detected URL changes @@ -371,12 +379,6 @@ public class MessageComposerController( ) }.launchIn(scope) - channelState - .filterNotNull() - .flatMapLatest { it.members }.onEach { members -> - users = members.map { it.user } - }.launchIn(scope) - channelState .filterNotNull() .flatMapLatest { combine(it.channelData, it.lastSentMessageDate, ::Pair) } @@ -406,7 +408,7 @@ public class MessageComposerController( handleCommandSuggestions() handleValidationErrors() }.debounce(TEXT_INPUT_DEBOUNCE_TIME).onEach { - scope.launch { handleMentionSuggestions() } + handleMentionSuggestions() linkPreviewJob?.cancel() linkPreviewJob = scope.launch { handleLinkPreview() } }.launchIn(scope) @@ -596,6 +598,7 @@ public class MessageComposerController( old is MessageMode.Normal && new is MessageMode.Normal -> true old is MessageMode.MessageThread && new is MessageMode.MessageThread -> old.parentMessage.id == new.parentMessage.id + else -> false } @@ -692,9 +695,11 @@ public class MessageComposerController( _selectedAttachments.value.containsKey(key) -> { _selectedAttachments.update { LinkedHashMap(it).also { map -> map.remove(key) } } } + _editModeAttachments.value.any(attachment::equals) -> { _editModeAttachments.update { it.filterNot(attachment::equals) } } + _recordingAttachment.value == attachment -> { _recordingAttachment.value = null } @@ -892,7 +897,11 @@ public class MessageComposerController( activeMessage.copy( text = trimmedMessage, attachments = attachments.toMutableList(), - mentionedUsersIds = mentions, + mentionedUsersIds = mentions.userIds, + mentionedChannel = mentions.channel, + mentionedHere = mentions.here, + mentionedRoles = mentions.roles, + mentionedGroups = mentions.groups, ) } else { Message( @@ -902,32 +911,55 @@ public class MessageComposerController( replyMessageId = replyMessage?.id, replyTo = replyMessage, attachments = attachments.toMutableList(), - mentionedUsersIds = mentions, + mentionedUsersIds = mentions.userIds, + mentionedChannel = mentions.channel, + mentionedHere = mentions.here, + mentionedRoles = mentions.roles, + mentionedGroups = mentions.groups, ) } } /** - * Filters the current input and the mentions the user selected from the suggestion list. Removes any mentions which - * are selected but no longer present in the input. - * - * @param selectedMentions The set of selected users from the suggestion list. - * @param message The current message input. - * - * @return [MutableList] of user IDs of mentioned users. - */ - private fun filterMentions(selectedMentions: Set, message: String): MutableList { - // Ignore custom, non-user mentions (for now) - val userMentions = selectedMentions.filterIsInstance() - val text = message.lowercase() - val remainingMentions = userMentions.filter { - text.contains("@${it.display.lowercase()}") - }.map { it.user.id } + * Drops any selected mention whose `@` is no longer present in [message] and returns + * the metadata to attach to the outgoing message. + */ + private fun filterMentions(selectedMentions: Set, message: String): FilteredMentions { + val userIds = mutableListOf() + val roles = mutableSetOf() + val groups = mutableMapOf() + var channel = false + var here = false + for (mention in selectedMentions) { + if (mention.tokens.none { mentionRegex(it).containsMatchIn(message) }) continue + when (mention) { + is Mention.User -> userIds += mention.user.id + Mention.Channel -> channel = true + Mention.Here -> here = true + is Mention.Role -> roles += mention.role + is Mention.Group -> groups[mention.group.id] = mention.group + else -> Unit // ignore custom mentions + } + } this.selectedMentions.clear() _state.update { it.copy(selectedMentions = emptySet()) } - return remainingMentions.toMutableList() + return FilteredMentions( + userIds = userIds, + channel = channel, + here = here, + roles = roles.toList(), + groups = groups.values.toList(), + ) } + private data class FilteredMentions( + val userIds: List, + val channel: Boolean, + val here: Boolean, + val roles: List, + val groups: List, + ) + /** * Updates the UI state when leaving the thread, to switch back to the [MessageMode.Normal], by * calling [setMessageMode]. @@ -984,8 +1016,8 @@ public class MessageComposerController( /** * Autocompletes the current text input with the mention from the selected mention. * - * IMPORTANT: The SDK supports only user mentions (see [Mention.User]). Custom mentions are purely visual, and will - * not be submitted to the server. + * Built-in [Mention] subclasses are submitted to the server on send via the corresponding + * fields on [io.getstream.chat.android.models.Message]. * * @param mention The mention that is used for the autocomplete. */ @@ -1215,26 +1247,29 @@ public class MessageComposerController( } /** - * Shows the mention suggestion list popup if necessary. + * Shows the mention suggestion list popup if necessary. Populates both + * [MessageComposerState.mentionSuggestions] (users only, legacy) and + * [MessageComposerState.suggestedMentions] (all mention types). */ private fun handleMentionSuggestions() { val currentInput = _messageInput.value + mentionSuggestionJob?.cancel() if (currentInput.source == MessageInput.Source.MentionSelected) { logger.v { "[handleMentionSuggestions] rejected (messageInput came from mention selection)" } - _state.update { it.copy(mentionSuggestions = emptyList()) } + _state.update { it.copy(mentionSuggestions = emptyList(), suggestedMentions = emptyList()) } return } - val inputText = currentInput.text - scope.launch(DispatcherProvider.IO) { - val suggestion = mentionSuggester.typingSuggestion(inputText) + mentionSuggestionJob = scope.launch(DispatcherProvider.IO) { + val suggestion = mentionSuggester.typingSuggestion(currentInput.text) logger.v { "[handleMentionSuggestions] suggestion: $suggestion" } - val result = if (suggestion != null) { - userLookupHandler.handleUserLookup(suggestion.text) - } else { - emptyList() - } - withContext(DispatcherProvider.Main) { - _state.update { it.copy(mentionSuggestions = result) } + + val mentions = suggestion?.let { mentionLookupHandler.handleMentionLookup(it.text) }.orEmpty() + val users = mentions.mapNotNull { (it as? Mention.User)?.user } + _state.update { + it.copy( + mentionSuggestions = users, + suggestedMentions = mentions, + ) } } } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/LocalUserLookupHandler.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/LocalUserLookupHandler.kt index aa981af35ee..be54ca0e90a 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/LocalUserLookupHandler.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/LocalUserLookupHandler.kt @@ -19,10 +19,12 @@ package io.getstream.chat.android.ui.common.feature.messages.composer.mention import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.state.state import io.getstream.chat.android.client.extensions.cidToTypeAndId +import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.common.feature.messages.composer.query.filter.DefaultUserQueryFilter import io.getstream.chat.android.ui.common.feature.messages.composer.query.filter.QueryFilter import io.getstream.log.taggedLogger +import kotlinx.coroutines.withContext /** * Local user lookup handler. It uses the local state to search for users. @@ -39,14 +41,14 @@ internal class LocalUserLookupHandler( private val logger by taggedLogger("Chat:UserLookupLocal") - override suspend fun handleUserLookup(query: String): List { + override suspend fun handleUserLookup(query: String): List = withContext(DispatcherProvider.IO) { try { if (DEBUG) logger.d { "[handleUserLookup] query: \"$query\"" } val (channelType, channelId) = channelCid.cidToTypeAndId() val channelState = chatClient.state.channel(channelType, channelId) val localUsers = channelState.members.value.map { it.user } val membersCount = channelState.membersCount.value - return when (membersCount == localUsers.size) { + when (membersCount == localUsers.size) { true -> filter.filter(localUsers, query).also { if (DEBUG) logger.v { "[handleUserLookup] found ${it.size} users" } } @@ -57,7 +59,7 @@ internal class LocalUserLookupHandler( } } catch (e: Exception) { logger.e(e) { "[handleUserLookup] failed: $e" } - return emptyList() + emptyList() } } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionLookupHandler.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionLookupHandler.kt new file mode 100644 index 00000000000..2b8e3cc066a --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionLookupHandler.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * 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. + */ + +package io.getstream.chat.android.ui.common.feature.messages.composer.mention + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.channel.state.ChannelState +import io.getstream.chat.android.models.ChannelCapabilities +import io.getstream.chat.android.models.Role +import io.getstream.chat.android.models.UserGroup +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.StateFlow + +/** + * Aggregates every source the composer needs for mention suggestions into a single ordered [List] of [Mention]. + * + * Non-user mentions are gated on a matching channel capability (e.g. [ChannelCapabilities.NOTIFY_CHANNEL]); + * types without the capability are skipped entirely (no API call, no result). User mentions are always looked up. + */ +internal class MentionLookupHandler( + private val chatClient: ChatClient, + private val channelState: StateFlow, + private val userLookupHandler: UserLookupHandler, +) { + + /** + * Returns the mention suggestions for [query] in popup order: + * `@channel`, `@here`, roles (alphabetical), groups (alphabetical), users + * (order is the responsibility of the configured [UserLookupHandler]). + */ + suspend fun handleMentionLookup(query: String): List = coroutineScope { + val capabilities = channelState.value?.channelData?.value?.ownCapabilities.orEmpty() + val getGroups = async { + if (ChannelCapabilities.NOTIFY_GROUP in capabilities) searchGroups(query) else emptyList() + } + // User suggestions are intentionally not gated on CREATE_MENTION: on Permissions V1 regular members lack + // that capability, yet user mentions still work since the server only enforces it on send under V2. + val getUsers = async { userLookupHandler.handleUserLookup(query) } + val getRoles = async { + if (ChannelCapabilities.NOTIFY_ROLE in capabilities) searchRoles(query) else emptyList() + } + + buildList { + if (ChannelCapabilities.NOTIFY_CHANNEL in capabilities && + Mention.Channel.display.matchesMentionQuery(query) + ) { + add(Mention.Channel) + } + if (ChannelCapabilities.NOTIFY_HERE in capabilities && + Mention.Here.display.matchesMentionQuery(query) + ) { + add(Mention.Here) + } + + getRoles.await().forEach { add(Mention.Role(it)) } + getGroups.await().forEach { add(Mention.Group(it)) } + getUsers.await().forEach { add(Mention.User(it)) } + } + } + + private suspend fun searchGroups(query: String): List = + if (query.isEmpty()) { + emptyList() + } else { + val team = channelState.value?.channelData?.value?.team?.takeIf(String::isNotEmpty) + chatClient.searchUserGroups(query = query, teamId = team).await() + .getOrNull() + .orEmpty() + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, UserGroup::name)) + } + + private suspend fun searchRoles(query: String): List = + if (query.isEmpty()) { + emptyList() + } else { + chatClient.searchRoles(query = query).await() + .getOrNull() + .orEmpty() + .mapTo(mutableSetOf(), Role::name) + .sortedWith(String.CASE_INSENSITIVE_ORDER) + } + + companion object { + private fun String.matchesMentionQuery(query: String): Boolean = + query.isEmpty() || startsWith(query, ignoreCase = true) + } +} diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/UserLookupHandler.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/UserLookupHandler.kt index 60899102d23..df38f291629 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/UserLookupHandler.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/UserLookupHandler.kt @@ -24,8 +24,7 @@ import io.getstream.chat.android.ui.common.feature.messages.composer.query.forma */ public fun interface UserLookupHandler { /** - * Performs users lookup by given [query] in suspend way. - * It's executed on background, so it can perform heavy operations. + * Performs users lookup by given [query]. * * @param query String as user input for lookup algorithm. * @return List of users as result of lookup. diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/query/filter/DefaultUserQueryFilter.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/query/filter/DefaultUserQueryFilter.kt index bf11f9568ae..0ef3d7383b4 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/query/filter/DefaultUserQueryFilter.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/query/filter/DefaultUserQueryFilter.kt @@ -28,9 +28,11 @@ import io.getstream.log.taggedLogger /** * Default [QueryFilter] for [User] objects used in mention suggestions. * - * Keeps only users whose normalized name (or id) contains the normalized query as a substring, - * then sorts results by match position so prefix matches appear first. Normalization applies - * lowercasing, diacritics removal, and optional transliteration. + * Matches by whitespace-tokenizing the query and the user's name (or id when blank): every word + * except the last must equal at least one name word, and the last word must be a prefix of at + * least one name word. The same name word may satisfy multiple query words. Results are sorted + * alphabetically by normalized name. Normalization applies lowercasing, diacritics removal, and + * optional transliteration. * * @param transliterator The transliterator to use for normalizing strings. */ @@ -48,15 +50,30 @@ public class DefaultUserQueryFilter( override fun filter(items: List, query: String): List { logger.d { "[filter] query: \"$query\", items.size: ${items.size}" } - val formattedQuery = queryFormatter.format(query) - if (formattedQuery.isEmpty()) return items + val queryTokens = queryFormatter.format(query).tokenize() return items - .mapNotNull { user -> - val formattedName = queryFormatter.format(query = user.name.ifBlank(user::id)) - val index = formattedName.indexOf(formattedQuery) - if (index >= 0) user to index else null - } - .sortedBy { (_, index) -> index } + .map { user -> user to queryFormatter.format(user.name.ifBlank(user::id)) } + .filter { (_, formattedName) -> matches(queryTokens, formattedName) } + .sortedBy { (_, formattedName) -> formattedName } .map { (user, _) -> user } } + + private fun matches(queryTokens: List, formattedName: String): Boolean { + if (queryTokens.isEmpty()) return true + val nameTokens = formattedName.tokenize() + val lastIndex = queryTokens.lastIndex + return queryTokens.withIndex().all { (i, token) -> + if (i == lastIndex) { + nameTokens.any { it.startsWith(token) } + } else { + nameTokens.any { it == token } + } + } + } + + private fun String.tokenize(): List = split(WHITESPACE).filter(String::isNotEmpty) + + private companion object { + private val WHITESPACE = "\\s+".toRegex() + } } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState.kt index f83b5c5f44c..cb508dbc6cf 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState.kt @@ -36,6 +36,7 @@ import io.getstream.chat.android.ui.common.state.messages.MessageMode * @param action The currently active [MessageAction]. * @param validationErrors The list of validation errors. * @param mentionSuggestions The list of users that can be used to autocomplete the mention. + * Prefer [suggestedMentions] for new code. * @param commandSuggestions The list of commands to be displayed in the command suggestion popup. * @param linkPreview The link found in [inputValue] to be previewed, or `null` when there is none. * @param coolDownTime The amount of time left until the user is allowed to send the next message. @@ -51,12 +52,19 @@ import io.getstream.chat.android.ui.common.state.messages.MessageMode * editable unless the user doesn't have proper [ChannelCapabilities] to send messages, otherwise it's disabled. * @param selectedMentions The list of selected mentions in the current input. * @param activeCommand The command that is currently active (selected from the suggestion popup). + * @param suggestedMentions [Mention]s to render in the suggestion popup. Prefer this over + * [mentionSuggestions] for new code. */ public data class MessageComposerState @JvmOverloads constructor( val inputValue: String = "", val attachments: List = emptyList(), val action: MessageAction? = null, val validationErrors: List = emptyList(), + @Deprecated( + message = "Use suggestedMentions, which carries every mention type.", + replaceWith = ReplaceWith("suggestedMentions"), + level = DeprecationLevel.WARNING, + ) val mentionSuggestions: List = emptyList(), val commandSuggestions: List = emptyList(), val linkPreview: LinkPreview? = null, @@ -71,4 +79,5 @@ public data class MessageComposerState @JvmOverloads constructor( val sendEnabled: Boolean = true, val selectedMentions: Set = emptySet(), val activeCommand: Command? = null, + val suggestedMentions: List = emptyList(), ) diff --git a/stream-chat-android-ui-common/src/main/res/drawable/stream_design_ic_megaphone.xml b/stream-chat-android-ui-common/src/main/res/drawable/stream_design_ic_megaphone.xml new file mode 100644 index 00000000000..7702a5ec1c9 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/res/drawable/stream_design_ic_megaphone.xml @@ -0,0 +1,30 @@ + + + + + diff --git a/stream-chat-android-ui-common/src/main/res/drawable/stream_design_ic_role.xml b/stream-chat-android-ui-common/src/main/res/drawable/stream_design_ic_role.xml new file mode 100644 index 00000000000..9b3fd3ea022 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/res/drawable/stream_design_ic_role.xml @@ -0,0 +1,29 @@ + + + + + diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt index eb5789e81c1..91579ce5531 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt @@ -36,8 +36,10 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageModerationAction import io.getstream.chat.android.models.MessageModerationDetails import io.getstream.chat.android.models.MessageType +import io.getstream.chat.android.models.Role import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.models.User +import io.getstream.chat.android.models.UserGroup import io.getstream.chat.android.randomAttachment import io.getstream.chat.android.randomCommand import io.getstream.chat.android.randomMessage @@ -75,6 +77,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn @@ -1678,6 +1681,60 @@ internal class MessageComposerControllerTest { assertEquals(emptyList(), message.mentionedUsersIds) } + @Test + fun `Given mentions of every type When buildNewMessage called Then message carries each mention field`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings(mock()) + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val user = User(id = "u1", name = "Alice") + val group = UserGroup(id = "g1", name = "platform") + controller.selectMention(Mention.User(user)) + controller.selectMention(Mention.Channel) + controller.selectMention(Mention.Here) + controller.selectMention(Mention.Role("admin")) + controller.selectMention(Mention.Group(group)) + + // When + val message = controller.buildNewMessage("@Alice @channel @here @admin @platform hi") + + // Then + assertEquals(listOf("u1"), message.mentionedUsersIds) + assertTrue(message.mentionedChannel) + assertTrue(message.mentionedHere) + assertEquals(listOf("admin"), message.mentionedRoles) + assertEquals(listOf(group), message.mentionedGroups) + } + + @Test + fun `Given mention tokens removed from text When buildNewMessage called Then those mention fields are cleared`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings(mock()) + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + controller.selectMention(Mention.Channel) + controller.selectMention(Mention.Here) + controller.selectMention(Mention.Role("admin")) + controller.selectMention(Mention.Group(UserGroup(id = "g1", name = "platform"))) + + // When — only @here survives in the final text + val message = controller.buildNewMessage("just @here") + + // Then + assertFalse(message.mentionedChannel) + assertTrue(message.mentionedHere) + assertTrue(message.mentionedRoles.isEmpty()) + assertTrue(message.mentionedGroups.isEmpty()) + } + @Test fun `Given an active command When clearData called Then activeCommand is null`() = runTest { // Given @@ -2814,6 +2871,10 @@ internal class MessageComposerControllerTest { fun get(): MessageComposerController { whenever(chatClient.inheritScope(any())) doReturn inheritedScope + whenever(chatClient.searchUserGroups(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) doReturn + emptyList().asCall() + whenever(chatClient.searchRoles(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) doReturn + emptyList().asCall() return MessageComposerController( channelCid = cid, diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionLookupHandlerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionLookupHandlerTest.kt new file mode 100644 index 00000000000..8de78ce4f44 --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionLookupHandlerTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * 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. + */ + +package io.getstream.chat.android.ui.common.feature.messages.composer.mention + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.channel.state.ChannelState +import io.getstream.chat.android.models.ChannelCapabilities +import io.getstream.chat.android.models.ChannelData +import io.getstream.chat.android.models.Role +import io.getstream.chat.android.models.User +import io.getstream.chat.android.models.UserGroup +import io.getstream.chat.android.test.asCall +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +internal class MentionLookupHandlerTest { + + @Test + fun `Empty query emits only channel and here without hitting the network`() = runTest { + val fixture = Fixture().build() + + val result = fixture.handler.handleMentionLookup(query = "") + + assertEquals(listOf(Mention.Channel, Mention.Here), result) + verify(fixture.chatClient, never()).searchUserGroups(any(), any(), any(), any(), any()) + verify(fixture.chatClient, never()).searchRoles(any(), any(), any(), any(), any()) + } + + @Test + fun `Prefix matching here but not channel emits only here`() = runTest { + val fixture = Fixture().build() + + val result = fixture.handler.handleMentionLookup(query = "he") + + assertEquals(listOf(Mention.Here), result) + } + + @Test + fun `Each matching source contributes a mention`() = runTest { + val group = UserGroup(id = "g1", name = "platform") + val role = Role(name = "admin") + val user = User(id = "u1", name = "Alice") + val fixture = Fixture() + .withGroupSearchResult(listOf(group)) + .withRoleSearchResult(listOf(role)) + .withUserLookupResult(listOf(user)) + .build() + + // 'c' matches `channel` (prefix) but not `here`. + val result = fixture.handler.handleMentionLookup(query = "c") + + assertEquals( + listOf( + Mention.Channel, + Mention.Role(role.name), + Mention.Group(group), + Mention.User(user), + ), + result, + ) + } + + @Test + fun `Non-user mentions without the matching capability are skipped`() = runTest { + val user = User(id = "u1", name = "Alice") + val fixture = Fixture() + .withGroupSearchResult(listOf(UserGroup(id = "g1", name = "platform"))) + .withRoleSearchResult(listOf(Role(name = "admin"))) + .withUserLookupResult(listOf(user)) + .withOwnCapabilities(emptySet()) + .build() + + val result = fixture.handler.handleMentionLookup(query = "a") + + assertEquals(listOf(Mention.User(user)), result) + verify(fixture.chatClient, never()).searchUserGroups(any(), any(), any(), any(), any()) + verify(fixture.chatClient, never()).searchRoles(any(), any(), any(), any(), any()) + } + + @Test + fun `Group search forwards the channel's team when present`() = runTest { + val fixture = Fixture().withTeam("ops").build() + + fixture.handler.handleMentionLookup(query = "plat") + + verify(fixture.chatClient).searchUserGroups(eq("plat"), anyOrNull(), eq("ops"), anyOrNull(), anyOrNull()) + } + + private class Fixture { + private val chatClient: ChatClient = mock() + private var groupSearchResult: List = emptyList() + private var roleSearchResult: List = emptyList() + private var userLookupResult: List = emptyList() + private var ownCapabilities: Set = setOf( + ChannelCapabilities.CREATE_MENTION, + ChannelCapabilities.NOTIFY_CHANNEL, + ChannelCapabilities.NOTIFY_HERE, + ChannelCapabilities.NOTIFY_ROLE, + ChannelCapabilities.NOTIFY_GROUP, + ) + private var team: String = "" + + fun withGroupSearchResult(groups: List) = apply { groupSearchResult = groups } + fun withRoleSearchResult(roles: List) = apply { roleSearchResult = roles } + fun withUserLookupResult(users: List) = apply { userLookupResult = users } + fun withOwnCapabilities(capabilities: Set) = apply { ownCapabilities = capabilities } + fun withTeam(team: String) = apply { this.team = team } + + fun build(): Bundle { + whenever(chatClient.searchUserGroups(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(groupSearchResult.asCall()) + whenever(chatClient.searchRoles(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(roleSearchResult.asCall()) + val channelData = ChannelData( + id = "c1", + type = "messaging", + team = team, + ownCapabilities = ownCapabilities, + ) + val state: ChannelState = mock() + whenever(state.channelData).thenReturn(MutableStateFlow(channelData)) + val handler = MentionLookupHandler( + chatClient = chatClient, + channelState = MutableStateFlow(state), + userLookupHandler = { userLookupResult }, + ) + return Bundle(chatClient, handler) + } + } + + private data class Bundle(val chatClient: ChatClient, val handler: MentionLookupHandler) +} diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/query/filter/DefaultUserQueryFilterTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/query/filter/DefaultUserQueryFilterTest.kt index 7d17333ace3..be44767128b 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/query/filter/DefaultUserQueryFilterTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/query/filter/DefaultUserQueryFilterTest.kt @@ -16,79 +16,122 @@ package io.getstream.chat.android.ui.common.feature.messages.composer.query.filter +import io.getstream.chat.android.models.User import io.getstream.chat.android.randomUser import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource internal class DefaultUserQueryFilterTest { private val filter = DefaultUserQueryFilter() - @Test - fun `empty query returns all users`() { - val users = listOf(user("Alice"), user("Bob")) - - assertEquals(listOf("Alice", "Bob"), filter.filter(users, "").names()) - } - - @Test - fun `no match returns empty list`() { - val users = listOf(user("Alice"), user("Bob")) - - assertEquals(emptyList(), filter.filter(users, "xyz").names()) - } - - @Test - fun `match is case insensitive`() { - val users = listOf(user("Aleksandar Apostolov"), user("Jc Minarro")) - - assertEquals(listOf("Jc Minarro"), filter.filter(users, "JC").names()) - } - - @Test - fun `match ignores diacritics`() { - val users = listOf(user("José"), user("Bob")) - - assertEquals(listOf("José"), filter.filter(users, "jose").names()) - } - - @Test - fun `short query only matches users containing that substring`() { - val users = listOf(user("Aleksandar Apostolov"), user("Jc Minarro")) - - assertEquals(listOf("Jc Minarro"), filter.filter(users, "jc").names()) - } - - @Test - fun `query does not fuzzy match unrelated names`() { - val users = listOf(user("Aleksandar Apostolov"), user("Ara"), user("Abel")) - - assertEquals(listOf("Aleksandar Apostolov"), filter.filter(users, "ale").names()) - } - - @Test - fun `query matches substring in any word`() { - val users = listOf(user("Alice Smith"), user("Bob Jones"), user("Charlie Smith")) - - assertEquals(listOf("Alice Smith", "Charlie Smith"), filter.filter(users, "smith").names()) - } - - @Test - fun `results are sorted by match position`() { - val users = listOf(user("Johann"), user("Anne"), user("Marianne")) - - assertEquals(listOf("Anne", "Johann", "Marianne"), filter.filter(users, "ann").names()) + @ParameterizedTest(name = "[{index}] {0}") + @MethodSource("matchCases") + fun `filter returns the expected matches`( + @Suppress("UNUSED_PARAMETER") description: String, + userNames: List, + query: String, + expected: List, + ) { + val users = userNames.map { randomUser(name = it) } + + assertEquals(expected, filter.filter(users, query).map(User::name)) } @Test fun `falls back to id when name is blank`() { - val users = listOf(randomUser(name = "", id = "alice123"), user("Bob")) + val users = listOf(randomUser(name = "Bob"), randomUser(name = "", id = "alice123")) assertEquals(listOf("alice123", "Bob"), filter.filter(users, "").map { it.name.ifBlank { it.id } }) assertEquals(listOf("alice123"), filter.filter(users, "alice").map { it.id }) } - private fun user(name: String) = randomUser(name = name) - - private fun List.names() = map { it.name } + private companion object { + + @Suppress("LongMethod") + @JvmStatic + fun matchCases(): List = listOf( + Arguments.of( + "empty query returns all users sorted alphabetically", + listOf("Charlie", "Alice", "Bob"), + "", + listOf("Alice", "Bob", "Charlie"), + ), + Arguments.of( + "no match returns empty list", + listOf("Alice", "Bob"), + "xyz", + emptyList(), + ), + Arguments.of( + "match is case insensitive", + listOf("Aleksandar Apostolov", "Jc Minarro"), + "JC", + listOf("Jc Minarro"), + ), + Arguments.of( + "match ignores diacritics", + listOf("José", "Bob"), + "jose", + listOf("José"), + ), + Arguments.of( + "last query word prefix-matches any name word", + listOf("Alice Smith", "Bob Jones", "Charlie Smith"), + "smith", + listOf("Alice Smith", "Charlie Smith"), + ), + Arguments.of( + "last query word must be a prefix, not a substring", + listOf("Hart", "Arnold", "Garrick"), + "ar", + listOf("Arnold"), + ), + Arguments.of( + "single-word prefix matches the only/last word", + listOf("First Last"), + "L", + listOf("First Last"), + ), + Arguments.of( + "full match plus prefix on the last word matches", + listOf("First Last"), + "First L", + listOf("First Last"), + ), + Arguments.of( + "full-match words may appear in any order", + listOf("First Last"), + "Last Fi", + listOf("First Last"), + ), + Arguments.of( + "non-final words require a full match, not a substring", + listOf("First Last"), + "t L", + emptyList(), + ), + Arguments.of( + "the same name word may satisfy multiple query words", + listOf("First Last"), + "first first", + listOf("First Last"), + ), + Arguments.of( + "the same name word may satisfy a full match and a final prefix", + listOf("First Last"), + "first f", + listOf("First Last"), + ), + Arguments.of( + "results are sorted alphabetically by normalized name", + listOf("Charlie Smith", "Alice Smith", "Bob Smith"), + "smith", + listOf("Alice Smith", "Bob Smith", "Charlie Smith"), + ), + ) + } } diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt index c72d7a56f2c..eb9c3f95db5 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt @@ -28,6 +28,7 @@ import io.getstream.chat.android.models.App import io.getstream.chat.android.models.AppSettings import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelCapabilities import io.getstream.chat.android.models.ChannelData import io.getstream.chat.android.models.Command import io.getstream.chat.android.models.Config @@ -317,7 +318,10 @@ internal class MessageComposerViewModelTest { val viewModel = Fixture() .givenCurrentUser() .givenChannelQuery() - .givenChannelState(members = listOf(Member(user = user1), Member(user = user2))) + .givenChannelState( + channelData = channelDataWith(ChannelCapabilities.CREATE_MENTION), + members = listOf(Member(user = user1), Member(user = user2)), + ) .get() // Handling mentions on input changes is debounced so we advance time until idle to make sure @@ -334,7 +338,10 @@ internal class MessageComposerViewModelTest { val viewModel = Fixture() .givenCurrentUser() .givenChannelQuery() - .givenChannelState(members = listOf(Member(user = user1), Member(user = user2))) + .givenChannelState( + channelData = channelDataWith(ChannelCapabilities.CREATE_MENTION), + members = listOf(Member(user = user1), Member(user = user2)), + ) .get() // Handling mentions on input changes is debounced so we advance time until idle to make sure @@ -444,6 +451,12 @@ internal class MessageComposerViewModelTest { companion object { + private fun channelDataWith(vararg capabilities: String) = ChannelData( + type = "messaging", + id = "123", + ownCapabilities = capabilities.toSet(), + ) + val user1 = User( id = "Jc", name = "Jc Miñarro",