diff --git a/CHANGELOG.md b/CHANGELOG.md index fac2c68640f..fd172a1d32c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ ### ⬆️ Improved ### ✅ Added +- Add `MentionStyleFactory` for customizing mentions in the message composer. [#5984](https://github.com/GetStream/stream-chat-android/pull/5984) ### ⚠️ Changed diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt index 7cc2ccb3599..490aa39026b 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt @@ -64,6 +64,7 @@ import io.getstream.chat.android.compose.sample.feature.channel.isGroupChannel import io.getstream.chat.android.compose.sample.ui.channel.DirectChannelInfoActivity import io.getstream.chat.android.compose.sample.ui.channel.GroupChannelInfoActivity import io.getstream.chat.android.compose.sample.ui.component.CustomChatComponentFactory +import io.getstream.chat.android.compose.sample.ui.component.CustomMentionStyleFactory import io.getstream.chat.android.compose.sample.ui.location.LocationPickerTabFactory import io.getstream.chat.android.compose.sample.vm.SharedLocationViewModelFactory import io.getstream.chat.android.compose.state.mediagallerypreview.MediaGalleryPreviewResultType @@ -80,6 +81,7 @@ import io.getstream.chat.android.compose.ui.messages.attachments.factory.Attachm import io.getstream.chat.android.compose.ui.messages.composer.MessageComposer import io.getstream.chat.android.compose.ui.messages.list.MessageList import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.ComposerInputFieldTheme import io.getstream.chat.android.compose.ui.theme.MessageComposerTheme import io.getstream.chat.android.compose.ui.theme.MessageOptionsTheme import io.getstream.chat.android.compose.ui.theme.MessageTheme @@ -145,7 +147,13 @@ class MessagesActivity : ComponentActivity() { val colors = if (isInDarkMode) StreamColors.defaultDarkColors() else StreamColors.defaultColors() val typography = StreamTypography.defaultTypography() val shapes = StreamShapes.defaultShapes() - val messageComposerTheme = MessageComposerTheme.defaultTheme(isInDarkMode, typography, shapes, colors) + val messageComposerTheme = MessageComposerTheme + .defaultTheme(isInDarkMode, typography, shapes, colors) + .copy( + inputField = ComposerInputFieldTheme.defaultTheme( + mentionStyleFactory = CustomMentionStyleFactory(colors.primaryAccent), + ), + ) val ownMessageTheme = MessageTheme.defaultOwnTheme(isInDarkMode, typography, shapes, colors) val attachmentsPickerTabFactories = AttachmentsPickerTabFactories.defaultFactories() + LocationPickerTabFactory(viewModelFactory = SharedLocationViewModelFactory(cid)) diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/CustomMentionStyleFactory.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/CustomMentionStyleFactory.kt new file mode 100644 index 00000000000..6e732d26e37 --- /dev/null +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/CustomMentionStyleFactory.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2014-2025 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.sample.ui.component + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import io.getstream.chat.android.compose.ui.theme.MentionStyleFactory +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention + +/** + * A custom implementation of [MentionStyleFactory] that applies a specific color to user mentions. + * + * @param color The color to apply to user mentions. + */ +class CustomMentionStyleFactory(private val color: Color) : MentionStyleFactory { + + override fun styleFor(mention: Mention): SpanStyle? = when (mention) { + is Mention.User -> SpanStyle(color = color) + else -> null + } +} 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 344e7798bfb..392d2b00ef5 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -1669,7 +1669,7 @@ public final class io/getstream/chat/android/compose/ui/components/composer/Cool } public final class io/getstream/chat/android/compose/ui/components/composer/InputFieldKt { - public static final fun InputField (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZILandroidx/compose/foundation/BorderStroke;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/text/KeyboardOptions;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun InputField (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZILandroidx/compose/foundation/BorderStroke;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/text/KeyboardOptions;Landroidx/compose/ui/text/input/VisualTransformation;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V } public final class io/getstream/chat/android/compose/ui/components/composer/MessageInputKt { @@ -3367,24 +3367,27 @@ public final class io/getstream/chat/android/compose/ui/theme/ComposerCancelIcon public final class io/getstream/chat/android/compose/ui/theme/ComposerInputFieldTheme { public static final field $stable I public static final field Companion Lio/getstream/chat/android/compose/ui/theme/ComposerInputFieldTheme$Companion; - public synthetic fun (Landroidx/compose/ui/graphics/Shape;JLandroidx/compose/ui/text/TextStyle;JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Landroidx/compose/ui/graphics/Shape;JLandroidx/compose/ui/text/TextStyle;JLio/getstream/chat/android/compose/ui/theme/MentionStyleFactory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Landroidx/compose/ui/graphics/Shape;JLandroidx/compose/ui/text/TextStyle;JLio/getstream/chat/android/compose/ui/theme/MentionStyleFactory;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Landroidx/compose/ui/graphics/Shape; public final fun component2-0d7_KjU ()J public final fun component3 ()Landroidx/compose/ui/text/TextStyle; public final fun component4-0d7_KjU ()J - public final fun copy-3bbok98 (Landroidx/compose/ui/graphics/Shape;JLandroidx/compose/ui/text/TextStyle;J)Lio/getstream/chat/android/compose/ui/theme/ComposerInputFieldTheme; - public static synthetic fun copy-3bbok98$default (Lio/getstream/chat/android/compose/ui/theme/ComposerInputFieldTheme;Landroidx/compose/ui/graphics/Shape;JLandroidx/compose/ui/text/TextStyle;JILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/ComposerInputFieldTheme; + public final fun component5 ()Lio/getstream/chat/android/compose/ui/theme/MentionStyleFactory; + public final fun copy-wffgcV4 (Landroidx/compose/ui/graphics/Shape;JLandroidx/compose/ui/text/TextStyle;JLio/getstream/chat/android/compose/ui/theme/MentionStyleFactory;)Lio/getstream/chat/android/compose/ui/theme/ComposerInputFieldTheme; + public static synthetic fun copy-wffgcV4$default (Lio/getstream/chat/android/compose/ui/theme/ComposerInputFieldTheme;Landroidx/compose/ui/graphics/Shape;JLandroidx/compose/ui/text/TextStyle;JLio/getstream/chat/android/compose/ui/theme/MentionStyleFactory;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/ComposerInputFieldTheme; public fun equals (Ljava/lang/Object;)Z public final fun getBackgroundColor-0d7_KjU ()J public final fun getBorderShape ()Landroidx/compose/ui/graphics/Shape; public final fun getCursorBrushColor-0d7_KjU ()J + public final fun getMentionStyleFactory ()Lio/getstream/chat/android/compose/ui/theme/MentionStyleFactory; public final fun getTextStyle ()Landroidx/compose/ui/text/TextStyle; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class io/getstream/chat/android/compose/ui/theme/ComposerInputFieldTheme$Companion { - public final fun defaultTheme (Lio/getstream/chat/android/compose/ui/theme/StreamTypography;Lio/getstream/chat/android/compose/ui/theme/StreamShapes;Lio/getstream/chat/android/compose/ui/theme/StreamColors;Landroidx/compose/runtime/Composer;II)Lio/getstream/chat/android/compose/ui/theme/ComposerInputFieldTheme; + public final fun defaultTheme (Lio/getstream/chat/android/compose/ui/theme/StreamTypography;Lio/getstream/chat/android/compose/ui/theme/StreamShapes;Lio/getstream/chat/android/compose/ui/theme/StreamColors;Lio/getstream/chat/android/compose/ui/theme/MentionStyleFactory;Landroidx/compose/runtime/Composer;II)Lio/getstream/chat/android/compose/ui/theme/ComposerInputFieldTheme; } public final class io/getstream/chat/android/compose/ui/theme/ComposerLinkPreviewTheme { @@ -3458,6 +3461,15 @@ public final class io/getstream/chat/android/compose/ui/theme/IconStyle { public fun toString ()Ljava/lang/String; } +public abstract interface class io/getstream/chat/android/compose/ui/theme/MentionStyleFactory { + public static final field Companion Lio/getstream/chat/android/compose/ui/theme/MentionStyleFactory$Companion; + public abstract fun styleFor (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;)Landroidx/compose/ui/text/SpanStyle; +} + +public final class io/getstream/chat/android/compose/ui/theme/MentionStyleFactory$Companion { + public final fun getNoStyle ()Lio/getstream/chat/android/compose/ui/theme/MentionStyleFactory; +} + public final class io/getstream/chat/android/compose/ui/theme/MessageBackgroundShapes { public static final field $stable I public fun (Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;)V @@ -4992,6 +5004,7 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/MessageC public final fun seekRecordingTo (F)V public final fun selectCommand (Lio/getstream/chat/android/models/Command;)V public final fun selectMention (Lio/getstream/chat/android/models/User;)V + public final fun selectMention (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention;)V public final fun sendMessage (Lio/getstream/chat/android/models/Message;Lio/getstream/result/call/Call$Callback;)V public static synthetic fun sendMessage$default (Lio/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel;Lio/getstream/chat/android/models/Message;Lio/getstream/result/call/Call$Callback;ILjava/lang/Object;)V public final fun sendRecording ()V diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/InputField.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/InputField.kt index 3eb0dc5bbe3..0c956f93b19 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/InputField.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/InputField.kt @@ -37,18 +37,23 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.theme.ChatTheme -import io.getstream.chat.android.compose.ui.util.buildAnnotatedMessageText +import io.getstream.chat.android.compose.ui.theme.ComposerInputFieldTheme +import io.getstream.chat.android.compose.ui.theme.StreamColors +import io.getstream.chat.android.compose.ui.theme.StreamTypography +import io.getstream.chat.android.compose.ui.util.buildAnnotatedInputText /** * Custom input field that we use for our UI. It's fairly simple - shows a basic input with clipped @@ -65,6 +70,8 @@ import io.getstream.chat.android.compose.ui.util.buildAnnotatedMessageText * @param border The [BorderStroke] that will appear around the input field. * @param innerPadding The padding inside the input field, around the label or input. * @param keyboardOptions The [KeyboardOptions] to be applied to the input. + * @param visualTransformation The [VisualTransformation] to be applied to the input. By default, it applies text + * styling and link styling. * @param decorationBox Composable function that represents the input field decoration as it's filled with content. */ @Composable @@ -77,6 +84,11 @@ public fun InputField( border: BorderStroke = BorderStroke(1.dp, ChatTheme.colors.borders), innerPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp), keyboardOptions: KeyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + visualTransformation: VisualTransformation = DefaultInputFieldVisualTransformation( + inputFieldTheme = ChatTheme.messageComposerTheme.inputField, + typography = ChatTheme.typography, + colors = ChatTheme.colors, + ), decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit, ) { var textState by remember { mutableStateOf(TextFieldValue(text = value)) } @@ -94,8 +106,6 @@ public fun InputField( } val theme = ChatTheme.messageComposerTheme.inputField - val typography = ChatTheme.typography - val colors = ChatTheme.colors val description = stringResource(id = R.string.stream_compose_cd_message_input) BasicTextField( @@ -113,19 +123,7 @@ public fun InputField( onValueChange(it.text) } }, - visualTransformation = { - val styledText = buildAnnotatedMessageText( - text = it.text, - textColor = theme.textStyle.color, - textFontStyle = typography.body.fontStyle, - linkStyle = TextStyle( - color = colors.primaryAccent, - textDecoration = TextDecoration.Underline, - ), - mentionsColor = colors.primaryAccent, - ) - TransformedText(styledText, OffsetMapping.Identity) - }, + visualTransformation = visualTransformation, textStyle = theme.textStyle, cursorBrush = SolidColor(theme.cursorBrushColor), decorationBox = { innerTextField -> decorationBox(innerTextField) }, @@ -136,6 +134,36 @@ public fun InputField( ) } +/** + * Default visual transformation for the [InputField] composable. + * Applies text styling and link styling to the input text. + * + * @param inputFieldTheme The theme for the input field. + * @param typography The typography styles to be used. + * @param colors The color palette to be used. + */ +private class DefaultInputFieldVisualTransformation( + val inputFieldTheme: ComposerInputFieldTheme, + val typography: StreamTypography, + val colors: StreamColors, +) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val textColor = inputFieldTheme.textStyle.color + val fontStyle = typography.body.fontStyle + val linkStyle = TextStyle( + color = colors.primaryAccent, + textDecoration = TextDecoration.Underline, + ) + val transformed = buildAnnotatedInputText( + text = text.text, + textColor = textColor, + textFontStyle = fontStyle, + linkStyle = linkStyle, + ) + return TransformedText(transformed, OffsetMapping.Identity) + } +} + @Preview @Composable private fun InputFieldPreview() { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/MessageInput.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/MessageInput.kt index 1cf6e05fcfa..6b800989676 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/MessageInput.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/MessageInput.kt @@ -30,11 +30,22 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.ComposerInputFieldTheme +import io.getstream.chat.android.compose.ui.theme.StreamColors +import io.getstream.chat.android.compose.ui.theme.StreamTypography +import io.getstream.chat.android.compose.ui.util.buildAnnotatedInputText import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.ui.common.feature.messages.composer.capabilities.canSendMessage +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 @@ -70,6 +81,13 @@ public fun MessageInput( val (value, attachments, activeAction) = messageComposerState val canSendMessage = messageComposerState.canSendMessage() + val visualTransformation = MessageInputVisualTransformation( + inputFieldTheme = ChatTheme.messageComposerTheme.inputField, + typography = ChatTheme.typography, + colors = ChatTheme.colors, + mentions = messageComposerState.selectedMentions, + ) + InputField( modifier = modifier, value = value, @@ -78,6 +96,7 @@ public fun MessageInput( enabled = canSendMessage, innerPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), keyboardOptions = keyboardOptions, + visualTransformation = visualTransformation, decorationBox = { innerTextField -> Column { if (activeAction is Reply) { @@ -125,6 +144,40 @@ public fun MessageInput( ) } +/** + * Visual transformation applied to the message input field. + * Applies text styling, link styling, and mention styling to the input text. + * + * @param inputFieldTheme The theme for the input field. + * @param typography The typography styles to be used. + * @param colors The color palette to be used. + * @param mentions The set of mentions to be styled in the input text. + */ +private class MessageInputVisualTransformation( + val inputFieldTheme: ComposerInputFieldTheme, + val typography: StreamTypography, + val colors: StreamColors, + val mentions: Set, +) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val textColor = inputFieldTheme.textStyle.color + val fontStyle = typography.body.fontStyle + val linkStyle = TextStyle( + color = colors.primaryAccent, + textDecoration = TextDecoration.Underline, + ) + val transformed = buildAnnotatedInputText( + text = text.text, + textColor = textColor, + textFontStyle = fontStyle, + linkStyle = linkStyle, + mentions = mentions, + mentionStyleFactory = inputFieldTheme.mentionStyleFactory, + ) + return TransformedText(transformed, OffsetMapping.Identity) + } +} + /** * The default number of lines allowed in the input. The message input will become scrollable after * this threshold is exceeded. diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionInput.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionInput.kt index ba35ba2d0e8..29388e548dd 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionInput.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionInput.kt @@ -59,7 +59,7 @@ import androidx.compose.ui.unit.sp import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.StreamTypography -import io.getstream.chat.android.compose.ui.util.buildAnnotatedMessageText +import io.getstream.chat.android.compose.ui.util.buildAnnotatedInputText /** * Custom input field that we use for our Poll option UI. It's fairly simple - shows a basic input with clipped @@ -122,7 +122,7 @@ public fun PollOptionInput( } }, visualTransformation = { - val styledText = buildAnnotatedMessageText( + val styledText = buildAnnotatedInputText( text = it.text, textColor = textColor, textFontStyle = typography.body.fontStyle, @@ -130,7 +130,6 @@ public fun PollOptionInput( color = colors.primaryAccent, textDecoration = TextDecoration.Underline, ), - mentionsColor = colors.primaryAccent, ) TransformedText(styledText, OffsetMapping.Identity) }, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/MessageComposerTheme.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/MessageComposerTheme.kt index 25f5ea7c4da..e2c547c002b 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/MessageComposerTheme.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/MessageComposerTheme.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextOverflow @@ -33,6 +34,7 @@ import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.theme.messages.composer.AudioRecordingTheme import io.getstream.chat.android.compose.ui.theme.messages.composer.attachments.AttachmentsPreviewTheme +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention /** * Represents the theming for the message composer. @@ -187,12 +189,14 @@ public data class ComposerLinkPreviewTheme( * @param backgroundColor The background color for the input field. * @param textStyle The text style for the input field. * @param cursorBrushColor The color for the cursor in the input field. + * @param mentionStyleFactory Factory for customization of the mention styles. */ public data class ComposerInputFieldTheme( val borderShape: Shape, val backgroundColor: Color, val textStyle: TextStyle, val cursorBrushColor: Color, + val mentionStyleFactory: MentionStyleFactory = MentionStyleFactory.NoStyle, ) { public companion object { @@ -204,6 +208,7 @@ public data class ComposerInputFieldTheme( true -> StreamColors.defaultDarkColors() else -> StreamColors.defaultColors() }, + mentionStyleFactory: MentionStyleFactory = MentionStyleFactory.NoStyle, ): ComposerInputFieldTheme { return ComposerInputFieldTheme( borderShape = shapes.inputField, @@ -213,11 +218,36 @@ public data class ComposerInputFieldTheme( textDirection = TextDirection.Content, ), cursorBrushColor = colors.primaryAccent, + mentionStyleFactory = mentionStyleFactory, ) } } } +/** + * Factory interface to provide custom styles for mentions. + */ +public interface MentionStyleFactory { + + /** + * Returns the [SpanStyle] to be applied for the given [mention], or null to apply no special style. + * + * @param mention The mention for which to get the style. + * @return The [SpanStyle] to be applied, or null. + */ + public fun styleFor(mention: Mention): SpanStyle? + + public companion object { + + /** + * A mention style factory that doesn't apply any styles. + */ + public val NoStyle: MentionStyleFactory = object : MentionStyleFactory { + override fun styleFor(mention: Mention): SpanStyle? = null + } + } +} + /** * Defines the theming options for the different composer actions. * diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/TextUtils.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/TextUtils.kt index bba19a44838..a091b86cf43 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/TextUtils.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/TextUtils.kt @@ -27,6 +27,8 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.core.util.PatternsCompat +import io.getstream.chat.android.compose.ui.theme.MentionStyleFactory +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention import java.util.regex.Pattern internal typealias AnnotationTag = String @@ -46,6 +48,18 @@ internal const val AnnotationTagEmail: AnnotationTag = "EMAIL" */ internal const val AnnotationTagMention: AnnotationTag = "MENTION" +/** + * Builds an [AnnotatedString] from a given text, applying styles and annotations for links and mentions. + * Used in message bubbles. + * + * @param text The input text to be transformed into an [AnnotatedString]. + * @param textColor The color to be applied to the regular text. + * @param textFontStyle The font style to be applied to the regular text. + * @param linkStyle The text style to be applied to links within the text. + * @param mentionsColor The color to be applied to mentions within the text. + * @param mentionedUserNames A list of usernames that are mentioned in the text. + * @param builder An optional lambda to apply additional styles or annotations. + */ @SuppressLint("RestrictedApi") internal fun buildAnnotatedMessageText( text: String, @@ -96,6 +110,69 @@ internal fun buildAnnotatedMessageText( } } +/** + * + * Builds an [AnnotatedString] from a given text, applying styles and annotations for links and mentions. + * Used in message input fields. + * + * @param text The input text to be transformed into an [AnnotatedString]. + * @param textColor The color to be applied to the regular text. + * @param textFontStyle The font style to be applied to the regular text. + * @param linkStyle The text style to be applied to links within the text. + * @param mentions A set of [Mention] objects representing the mentions in the text. + * @param mentionStyleFactory A factory to provide styles for mentions. + * @param builder An optional lambda to apply additional styles or annotations. + */ +@SuppressLint("RestrictedApi") +internal fun buildAnnotatedInputText( + text: String, + textColor: Color, + textFontStyle: FontStyle?, + linkStyle: TextStyle, + mentions: Set = emptySet(), + mentionStyleFactory: MentionStyleFactory = MentionStyleFactory.NoStyle, + builder: (AnnotatedString.Builder).() -> Unit = {}, +): AnnotatedString { + return buildAnnotatedString { + // First we add the whole text to the [AnnotatedString] and style it as a regular text. + append(text) + addStyle( + SpanStyle( + fontStyle = textFontStyle, + color = textColor, + ), + start = 0, + end = text.length, + ) + + // Then for each available link in the text, we add a different style, to represent the links, + // as well as add a String annotation to it. This gives us the ability to open the URL on click. + linkify( + text = text, + tag = AnnotationTagUrl, + pattern = PatternsCompat.AUTOLINK_WEB_URL, + matchFilter = Linkify.sUrlMatchFilter, + schemes = URL_SCHEMES, + textStyle = linkStyle, + ) + linkify( + text = text, + tag = AnnotationTagEmail, + pattern = PatternsCompat.AUTOLINK_EMAIL_ADDRESS, + schemes = EMAIL_SCHEMES, + textStyle = linkStyle, + ) + tagMentions( + text = text, + mentions = mentions, + mentionStyleFactory = mentionStyleFactory, + ) + + // Finally, we apply any additional styling that was passed in. + builder(this) + } +} + /** * Transforms a given [String] containing bold (...) tags to an [AnnotatedString] to be rendered in Compose * components. @@ -183,6 +260,24 @@ private fun AnnotatedString.Builder.tagUser( } } +private fun AnnotatedString.Builder.tagMentions( + text: String, + mentions: Set, + mentionStyleFactory: MentionStyleFactory, +) { + mentions.forEach { mention -> + val start = text.indexOf(mention.display) + val end = start + mention.display.length + if (start < 0) return@forEach + + val style = mentionStyleFactory.styleFor(mention) + if (style != null) { + addStyle(style, start - 1, end) // -1 to include the @ symbol + addStringAnnotation(AnnotationTagMention, mention.display, start - 1, end) // -1 to include the @ symbol + } + } +} + internal fun String.ensureLowercaseScheme(schemes: List): String = schemes.fold(this) { acc, scheme -> acc.replace(scheme, scheme.lowercase(), ignoreCase = true) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt index c3ff2d52e29..f7430920a9e 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt @@ -25,6 +25,7 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.PollConfig import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.common.feature.messages.composer.MessageComposerController +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.MessageAction import io.getstream.chat.android.ui.common.state.messages.MessageMode @@ -226,6 +227,16 @@ public class MessageComposerViewModel( */ public fun selectMention(user: User): Unit = messageComposerController.selectMention(user) + /** + * 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. + * + * @param mention The mention that is used for the autocomplete. + */ + public fun selectMention(mention: Mention): Unit = messageComposerController.selectMention(mention) + /** * Switches the message composer to the command input mode. * diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/TextUtilsKtTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/TextUtilsKtTest.kt index e4a2d65f062..a1e0a31e8f6 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/TextUtilsKtTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/util/TextUtilsKtTest.kt @@ -16,7 +16,16 @@ package io.getstream.chat.android.compose.ui.util -import org.amshove.kluent.`should be equal to` +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import io.getstream.chat.android.compose.ui.theme.MentionStyleFactory +import io.getstream.chat.android.models.User +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +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 @@ -30,7 +39,110 @@ internal class TextUtilsKtTest { schemes: List, expectedResult: String, ) { - url.ensureLowercaseScheme(schemes) `should be equal to` expectedResult + assertEquals(expectedResult, url.ensureLowercaseScheme(schemes)) + } + + @Test + fun `buildAnnotatedMessageText should annotate URLs, emails, and mentions correctly`() { + // Given + val text = "Check out https://getstream.io and contact support@getstream.io or ask @John for help" + val textColor = Color.Black + val textFontStyle = FontStyle.Normal + val linkStyle = TextStyle(color = Color.Blue) + val mentionsColor = Color.Red + val mentionedUserNames = listOf("John") + + // When + val result = buildAnnotatedMessageText( + text = text, + textColor = textColor, + textFontStyle = textFontStyle, + linkStyle = linkStyle, + mentionsColor = mentionsColor, + mentionedUserNames = mentionedUserNames, + ) + + // Then + assertEquals(text, result.text) + + // Verify URL annotation + val urlAnnotations = result.getStringAnnotations(AnnotationTagUrl, 0, text.length) + assertEquals(1, urlAnnotations.size) + assertEquals("https://getstream.io", urlAnnotations[0].item) + assertEquals(10, urlAnnotations[0].start) // Position of "https://getstream.io" + assertEquals(30, urlAnnotations[0].end) + + // Verify email annotation + val emailAnnotations = result.getStringAnnotations(AnnotationTagEmail, 0, text.length) + assertEquals(1, emailAnnotations.size) + assertEquals("mailto:support@getstream.io", emailAnnotations[0].item) + assertEquals(43, emailAnnotations[0].start) // Position of "support@getstream.io" + assertEquals(63, emailAnnotations[0].end) + + // Verify mention annotation + val mentionAnnotations = result.getStringAnnotations(AnnotationTagMention, 0, text.length) + assertEquals(1, mentionAnnotations.size) + assertEquals("John", mentionAnnotations[0].item) + assertEquals(71, mentionAnnotations[0].start) // Position of "@John" (includes @) + assertEquals(76, mentionAnnotations[0].end) + } + + @Test + fun `buildAnnotatedInputText should annotate URLs, emails, and mentions correctly`() { + // Given + val text = "Visit https://example.com or email test@example.com and mention @Alice" + val textColor = Color.Black + val textFontStyle = FontStyle.Normal + val linkStyle = TextStyle(color = Color.Blue, fontWeight = FontWeight.Bold) + val user = User(id = "alice-id", name = "Alice") + val mentions = setOf(Mention.User(user)) + val mentionStyleFactory = object : MentionStyleFactory { + override fun styleFor(mention: Mention) = androidx.compose.ui.text.SpanStyle( + color = Color.Magenta, + fontWeight = FontWeight.Bold, + ) + } + + // When + val result = buildAnnotatedInputText( + text = text, + textColor = textColor, + textFontStyle = textFontStyle, + linkStyle = linkStyle, + mentions = mentions, + mentionStyleFactory = mentionStyleFactory, + ) + + // Then + assertEquals(text, result.text) + + // Verify URL annotation + val urlAnnotations = result.getStringAnnotations(AnnotationTagUrl, 0, text.length) + assertEquals(1, urlAnnotations.size) + assertEquals("https://example.com", urlAnnotations[0].item) + assertEquals(6, urlAnnotations[0].start) // Position of "https://example.com" + assertEquals(25, urlAnnotations[0].end) + + // Verify email annotation + val emailAnnotations = result.getStringAnnotations(AnnotationTagEmail, 0, text.length) + assertEquals(1, emailAnnotations.size) + assertEquals("mailto:test@example.com", emailAnnotations[0].item) + assertEquals(35, emailAnnotations[0].start) // Position of "test@example.com" + assertEquals(51, emailAnnotations[0].end) + + // Verify mention annotation + val mentionAnnotations = result.getStringAnnotations(AnnotationTagMention, 0, text.length) + assertEquals(1, mentionAnnotations.size) + assertEquals("Alice", mentionAnnotations[0].item) + assertEquals(64, mentionAnnotations[0].start) // Position of "@Alice" (includes @) + assertEquals(70, mentionAnnotations[0].end) + + // Verify mention styling was applied + val styles = result.spanStyles + val mentionStyle = styles.firstOrNull { + it.start == 64 && it.end == 70 && it.item.color == Color.Magenta + } + assertNotNull(mentionStyle) } companion object { 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 0e456d970dc..7ea47b94f19 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 @@ -42,6 +42,8 @@ import io.getstream.chat.android.test.TestCoroutineExtension import io.getstream.chat.android.test.asCall import io.getstream.chat.android.ui.common.feature.messages.composer.MessageComposerController import io.getstream.chat.android.ui.common.feature.messages.composer.mention.DefaultUserLookupHandler +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.MentionType import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.MessageMode import io.getstream.chat.android.ui.common.state.messages.Reply @@ -356,6 +358,33 @@ internal class MessageComposerViewModelTest { viewModel.input.value `should be equal to` "@Jc Miñarro " } + @Test + fun `Given message composer When selecting a custom mention Should populate the input with the mention`() = + runTest { + val customMention = object : Mention { + override val type: MentionType = MentionType("custom") + override val display: String = "Custom Mention" + } + + val viewModel = Fixture() + .givenCurrentUser() + .givenChannelQuery() + .givenChannelState(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 + // all operations have finished before checking state. + viewModel.setMessageInput("@") + advanceUntilIdle() + + viewModel.selectMention(customMention) + advanceUntilIdle() + + viewModel.messageComposerState.value.mentionSuggestions.size `should be equal to` 0 + viewModel.mentionSuggestions.value.size `should be equal to` 0 + viewModel.input.value `should be equal to` "@Custom Mention " + } + private class Fixture( private val chatClient: ChatClient = mock(), private val channelId: String = "messaging:123", 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 f5db60e8287..7bb50455fed 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 @@ -616,6 +616,42 @@ public final class io/getstream/chat/android/ui/common/feature/messages/composer public fun handleUserLookup (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public abstract interface class io/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention { + public abstract fun getDisplay ()Ljava/lang/String; + public abstract fun getType ()Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionType; +} + +public final class io/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention$User : io/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention { + public static final field $stable I + public fun (Lio/getstream/chat/android/models/User;)V + public final fun component1 ()Lio/getstream/chat/android/models/User; + public final fun copy (Lio/getstream/chat/android/models/User;)Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention$User; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention$User;Lio/getstream/chat/android/models/User;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention$User; + public fun equals (Ljava/lang/Object;)Z + public fun getDisplay ()Ljava/lang/String; + public fun getType ()Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionType; + public final fun getUser ()Lio/getstream/chat/android/models/User; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionType { + public static final field $stable I + public static final field Companion Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionType$Companion; + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionType; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionType;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionType; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionType$Companion { + public final fun getUser ()Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionType; +} + public final class io/getstream/chat/android/ui/common/feature/messages/composer/mention/RemoteUserLookupHandler : io/getstream/chat/android/ui/common/feature/messages/composer/mention/UserLookupHandler { public static final field $stable I public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;)V @@ -2050,7 +2086,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;Ljava/util/List;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;)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;Ljava/util/List;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;Z)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;Ljava/util/List;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 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;Ljava/util/List;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;ZZILkotlin/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;Ljava/util/List;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 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;Ljava/util/List;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;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()Z public final fun component11 ()Ljava/util/Set; @@ -2059,6 +2096,7 @@ public final class io/getstream/chat/android/ui/common/state/messages/composer/M public final fun component14 ()Lio/getstream/chat/android/ui/common/state/messages/composer/RecordingState; public final fun component15 ()Z public final fun component16 ()Z + public final fun component17 ()Ljava/util/Set; 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; @@ -2067,8 +2105,8 @@ public final class io/getstream/chat/android/ui/common/state/messages/composer/M public final fun component7 ()Ljava/util/List; 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;Ljava/util/List;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)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;Ljava/util/List;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;ZZILjava/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;Ljava/util/List;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/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;Ljava/util/List;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;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 getAlsoSendToChannel ()Z @@ -2084,6 +2122,7 @@ public final class io/getstream/chat/android/ui/common/state/messages/composer/M public final fun getOwnCapabilities ()Ljava/util/Set; public final fun getPollsEnabled ()Z 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 getValidationErrors ()Ljava/util/List; public fun hashCode ()I 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 6cf54f80406..c04aec807c1 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 @@ -32,6 +32,7 @@ import io.getstream.chat.android.models.PollConfig import io.getstream.chat.android.models.User import io.getstream.chat.android.state.extensions.globalStateFlow import io.getstream.chat.android.state.plugin.state.global.GlobalState +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention import io.getstream.chat.android.ui.common.feature.messages.composer.mention.UserLookupHandler import io.getstream.chat.android.ui.common.feature.messages.composer.typing.TypingSuggester import io.getstream.chat.android.ui.common.feature.messages.composer.typing.TypingSuggestionOptions @@ -77,6 +78,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map 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 @@ -360,7 +362,7 @@ public class MessageComposerController( /** * Represents the selected mentions based on the message suggestion list. */ - private val selectedMentions: MutableSet = mutableSetOf() + private val selectedMentions: MutableSet = mutableSetOf() private val mentionSuggester = TypingSuggester( TypingSuggestionOptions(symbol = MENTION_START_SYMBOL), @@ -785,13 +787,13 @@ public class MessageComposerController( * * @return [MutableList] of user IDs of mentioned users. */ - private fun filterMentions(selectedMentions: Set, message: String): MutableList { + 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 = selectedMentions.filter { - text.contains("@${it.name.lowercase()}") - }.map { it.id } - + val remainingMentions = userMentions.filter { + text.contains("@${it.user.name.lowercase()}") + }.map { it.user.id } this.selectedMentions.clear() return remainingMentions.toMutableList() } @@ -833,11 +835,24 @@ public class MessageComposerController( * @param user The user that is used to autocomplete the mention. */ public fun selectMention(user: User) { - val username = user.name.ifEmpty { user.id } - val augmentedMessageText = "${messageText.substringBeforeLast("@")}@$username " + selectMention(Mention.User(user)) + } + /** + * 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. + * + * @param mention The mention that is used for the autocomplete. + */ + public fun selectMention(mention: Mention) { + val display = mention.display + val augmentedMessageText = "${messageText.substringBeforeLast("@")}@$display " setMessageInputInternal(augmentedMessageText, MessageInput.Source.MentionSelected) - selectedMentions += user + + selectedMentions += mention + state.update { it.copy(selectedMentions = selectedMentions) } } /** diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention.kt new file mode 100644 index 00000000000..f456f7e11e9 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/Mention.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2014-2025 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 + +/** + * Defines a type of a mention inside the message composer. + * + * By default, only the "user" mention type is defined, which represents user mentions (e.g. "@John Doe"). + * You can extend this class to define custom mention types if needed. + * + * @param value The name of the mention type. + */ +public data class MentionType(public val value: String) { + + public companion object { + + /** + * Predefined mention type for user mentions (ex. "@John Doe"). + */ + public val user: MentionType = MentionType("user") + } +} + +/** + * Represents a mention token inside the message composer. + * + * By default, only user mentions are supported. + * You can extend this interface to define custom mentions if needed. + */ +public interface Mention { + + /** + * The type of the mention. + * + * @see MentionType + */ + public val type: MentionType + + /** + * The display text of the mention. + */ + public val display: String + + /** + * Represents a user mention inside the message composer. + * + * @param user The user being mentioned. + */ + public data class User(public val user: io.getstream.chat.android.models.User) : Mention { + override val type: MentionType = MentionType.user + override val display: String = user.name + } +} 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 6b5b2b2078a..8a977585c24 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 @@ -21,6 +21,7 @@ import io.getstream.chat.android.models.ChannelCapabilities import io.getstream.chat.android.models.Command import io.getstream.chat.android.models.LinkPreview 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.state.messages.MessageAction import io.getstream.chat.android.ui.common.state.messages.MessageMode @@ -45,6 +46,7 @@ import io.getstream.chat.android.ui.common.state.messages.MessageMode * @param pollsEnabled Indicator if polls are enabled for the current channel. * @param sendEnabled Whether the send action is enabled or not. If true, the send button is enabled and input file is * 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. */ public data class MessageComposerState @JvmOverloads constructor( val inputValue: String = "", @@ -63,4 +65,5 @@ public data class MessageComposerState @JvmOverloads constructor( val recording: RecordingState = RecordingState.Idle, val pollsEnabled: Boolean = false, val sendEnabled: Boolean = true, + val selectedMentions: Set = emptySet(), ) diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTests.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTests.kt index fc319d600e7..a70cf6c7217 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTests.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTests.kt @@ -29,12 +29,16 @@ import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.User import io.getstream.chat.android.state.plugin.state.global.GlobalState import io.getstream.chat.android.test.TestCoroutineExtension +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention +import io.getstream.chat.android.ui.common.feature.messages.composer.mention.MentionType +import io.getstream.chat.android.ui.common.state.messages.MessageInput import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.runTest import org.amshove.kluent.`should be` import org.amshove.kluent.`should be equal to` +import org.amshove.kluent.`should contain` import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.mockito.kotlin.doReturn @@ -114,6 +118,123 @@ internal class MessageComposerControllerTests { controller.state.value.pollsEnabled `should be` true } + @Test + fun `Given user mention When selectMention called Then message input is autocompleted with user name`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings(mock()) + .givenAudioPlayer(mock()) + .givenClientState(User("uid1")) + .givenGlobalState() + .givenChannelState() + .get() + val user = User(id = "user1", name = "John Doe") + controller.setMessageInput("Hello @") + + // When + controller.selectMention(user) + + // Then + controller.messageInput.value.text `should be equal to` "Hello @John Doe " + controller.state.value.selectedMentions.size `should be equal to` 1 + controller.state.value.selectedMentions `should contain` Mention.User(user) + } + + @Test + fun `Given partial mention text When selectMention called Then partial text is replaced with full name`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings(mock()) + .givenAudioPlayer(mock()) + .givenClientState(User("uid1")) + .givenGlobalState() + .givenChannelState() + .get() + val user = User(id = "user1", name = "John Doe") + controller.setMessageInput("Hello @jo") + + // When + controller.selectMention(user) + + // Then + controller.messageInput.value.text `should be equal to` "Hello @John Doe " + } + + @Test + fun `Given custom mention When selectMention called Then message input is autocompleted with display text`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings(mock()) + .givenAudioPlayer(mock()) + .givenClientState(User("uid1")) + .givenGlobalState() + .givenChannelState() + .get() + val customMention = CustomTestMention("Channel Name") + controller.setMessageInput("Notify @") + + // When + controller.selectMention(customMention) + + // Then + controller.messageInput.value.text `should be equal to` "Notify @Channel Name " + controller.state.value.selectedMentions.size `should be equal to` 1 + controller.state.value.selectedMentions `should contain` customMention + } + + @Test + fun `Given multiple mentions When selectMention called multiple times Then all mentions are tracked`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings(mock()) + .givenAudioPlayer(mock()) + .givenClientState(User("uid1")) + .givenGlobalState() + .givenChannelState() + .get() + val user1 = User(id = "user1", name = "John Doe") + val user2 = User(id = "user2", name = "Jane Smith") + + // When + controller.setMessageInput("Hello @") + controller.selectMention(user1) + controller.setMessageInput("Hello @John Doe and @") + controller.selectMention(user2) + + // Then + controller.messageInput.value.text `should be equal to` "Hello @John Doe and @Jane Smith " + controller.state.value.selectedMentions.size `should be equal to` 2 + } + + @Test + fun `Given message input source is MentionSelected When selectMention called Then source is set to MentionSelected`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings(mock()) + .givenAudioPlayer(mock()) + .givenClientState(User("uid1")) + .givenGlobalState() + .givenChannelState() + .get() + val user = User(id = "user1", name = "John Doe") + controller.setMessageInput("Hello @") + + // When + controller.selectMention(user) + + // Then + controller.messageInput.value.source `should be equal to` MessageInput.Source.MentionSelected + } + + /** + * Custom test implementation of [Mention] for testing purposes. + */ + private data class CustomTestMention( + override val display: String, + ) : Mention { + override val type: MentionType = MentionType("channel") + } + private class Fixture( private val chatClient: ChatClient = mock(), private val cid: String = CID, diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionTest.kt new file mode 100644 index 00000000000..42c7d9a66c4 --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/mention/MentionTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2014-2025 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 org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class MentionTest { + + @Test + fun `Given MentionType user when accessing value then return expected string`() { + assertEquals("user", MentionType.user.value) + } + + @Test + fun `Given User mention when accessing type and display then return expected values`() { + val user = io.getstream.chat.android.models.User(id = "user1", name = "John Doe") + val mention = Mention.User(user) + + assertEquals(MentionType.user, mention.type) + assertEquals("John Doe", mention.display) + } +}