Skip to content

Commit

Permalink
[4607] [V5] Populate user mentions from the backend (#4647)
Browse files Browse the repository at this point in the history
* - Resolve merge conflicts

* [4607] Implement debounced server member queries for the message composer

* - [4607] Update the changelog

* - [4607] Fix a mention bug and add constants

* - [4607] Implement querying members online for mention matches when `MessageInputView` fails to find the matching member from local state data

* - [4607] Overload the `DefaultUserLookupHandler` constructor using `@JvmOverloads` after adding a new parameter with a default value so that Java users using the constructor don't see a breaking change.

* - [4607] Update tests so they wait until all operations have finished

* - [4607] Set the debounce time to 300 so we provide the same experience as with `MessageInputView`

* - [4607] Apply code review suggestion

* - [4607] Apply code review suggestion

---------

Co-authored-by: filbabic <[email protected]>
  • Loading branch information
MarinTolic and filbabic authored Jan 31, 2023
1 parent 54d1385 commit f1f9e52
Show file tree
Hide file tree
Showing 7 changed files with 198 additions and 14 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,12 @@
### 🐞 Fixed

### ⬆️ Improved
- The default implementation of `MessageInputView` will now query channel members from the server if a mention lookup fails to find the matching channel member using the data available in the local state. [#4647](https://github.com/GetStream/stream-chat-android/pull/4647)

### ✅ Added
- Added a feature flag to `ChatUI` called `showThreadSeparatorInEmptyThread`. You can use this to enable a thread separator if the thread is empty. [#4629](https://github.com/GetStream/stream-chat-android/pull/4629)
- Added the `messageLimit` parameter to MessageListViewModel and MessageListViewModelFactory. [#4634](https://github.com/GetStream/stream-chat-android/pull/4634)
- Added lambda parameter `queryMembersOnline` to `DefaultUserLookupHandler`. The lambda parameter is used internally by `DefaultUserLookupHandler.handleUserLookup()` when no matches could be found inside the list of users contained by `DefaultUserLookupHandler.users`. It should be used to query members from the server and return the results. [#4647](https://github.com/GetStream/stream-chat-android/pull/4647)
- Added the feature flag boolean `navigateToThreadViaNotification` to `MessageListViewModel` and `MessageListViewModelFactory`. If it is set to true and a thread message has been received via push notification, clicking on the notification will make the SDK automatically navigate to the thread. If set to false, the SDK will always navigate to the channel containing the thread without navigating to the thread itself. [#4612](https://github.com/GetStream/stream-chat-android/pull/4612)

### ⚠️ Changed
Expand Down Expand Up @@ -136,6 +138,7 @@
## stream-chat-android-ui-common
### ✅ Added
- Exposed a way to allow you to include the current user avatar in the Channel avatar [#4561](https://github.com/GetStream/stream-chat-android/pull/4561)
- `MessageComposerController` will now query the server for a list of channel members if the input contains a mention symbol (@) and no user name matching the expression after the symbol @ was found in the local state containing a list of channel members. [#4647](https://github.com/GetStream/stream-chat-android/pull/4647)

# November 23th, 2022 - 5.11.8
## stream-chat-android-client
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import io.getstream.chat.android.test.TestCoroutineExtension
import io.getstream.chat.android.test.asCall
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.`should be equal to`
import org.amshove.kluent.`should be instance of`
Expand Down Expand Up @@ -314,7 +315,10 @@ internal class MessageComposerViewModelTest {
.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.messageComposerState.value.mentionSuggestions.size `should be equal to` 2
viewModel.mentionSuggestions.value.size `should be equal to` 2
Expand All @@ -329,8 +333,13 @@ internal class MessageComposerViewModelTest {
.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(viewModel.mentionSuggestions.value.first())
advanceUntilIdle()

viewModel.messageComposerState.value.mentionSuggestions.size `should be equal to` 0
viewModel.mentionSuggestions.value.size `should be equal to` 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ import com.getstream.sdk.chat.utils.extensions.isModerationFailed
import com.getstream.sdk.chat.utils.typing.DefaultTypingUpdatesBuffer
import com.getstream.sdk.chat.utils.typing.TypingUpdatesBuffer
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.client.api.models.querysort.QuerySortByField
import io.getstream.chat.android.client.call.Call
import io.getstream.chat.android.client.extensions.cidToTypeAndId
import io.getstream.chat.android.client.models.Attachment
import io.getstream.chat.android.client.models.Channel
import io.getstream.chat.android.client.models.ChannelCapabilities
import io.getstream.chat.android.client.models.Command
import io.getstream.chat.android.client.models.Filters
import io.getstream.chat.android.client.models.Message
import io.getstream.chat.android.client.models.User
import io.getstream.chat.android.common.state.Edit
Expand All @@ -40,15 +42,19 @@ import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider
import io.getstream.chat.android.offline.extensions.watchChannelAsState
import io.getstream.chat.android.offline.plugin.state.channel.ChannelState
import io.getstream.chat.android.uiutils.extension.containsLinks
import io.getstream.logging.StreamLog
import io.getstream.logging.TaggedLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
Expand Down Expand Up @@ -81,6 +87,12 @@ public class MessageComposerController(
private val maxAttachmentSize: Long = AttachmentConstants.MAX_UPLOAD_FILE_SIZE,
) {

/**
* The logger used to print to errors, warnings, information
* and other things to log.
*/
private val logger: TaggedLogger = StreamLog.getLogger("Chat:MessageComposerController")

/**
* Creates a [CoroutineScope] that allows us to cancel the ongoing work when the parent
* ViewModel is disposed.
Expand Down Expand Up @@ -320,10 +332,20 @@ public class MessageComposerController(
/**
* Sets up the observing operations for various composer states.
*/
@OptIn(FlowPreview::class)
private fun setupComposerState() {
input.onEach { input ->
state.value = state.value.copy(inputValue = input)
}.launchIn(scope)

if (canSendTypingUpdates.value) {
typingUpdatesBuffer.onKeystroke()
}
handleCommandSuggestions()
handleValidationErrors()
}.debounce(ComputeMentionSuggestionsDebounceTime)
.onEach {
handleMentionSuggestions()
}.launchIn(scope)

selectedAttachments.onEach { selectedAttachments ->
state.value = state.value.copy(attachments = selectedAttachments)
Expand Down Expand Up @@ -369,13 +391,6 @@ public class MessageComposerController(
*/
public fun setMessageInput(value: String) {
this.input.value = value

if (canSendTypingUpdates.value) {
typingUpdatesBuffer.onKeystroke()
}
handleMentionSuggestions()
handleCommandSuggestions()
handleValidationErrors()
}

/**
Expand Down Expand Up @@ -679,16 +694,85 @@ public class MessageComposerController(
/**
* Shows the mention suggestion list popup if necessary.
*/
private fun handleMentionSuggestions() {
private suspend fun handleMentionSuggestions() {
val containsMention = MentionPattern.matcher(messageText).find()

mentionSuggestions.value = if (containsMention) {
users.filter { it.name.contains(messageText.substringAfterLast("@"), true) }
logger.v { "[handleMentionSuggestions] Input contains the mention prefix @." }
val userNameContains = messageText.substringAfterLast("@")

val localMentions = users.filter { it.name.contains(userNameContains, true) }

when {
localMentions.isNotEmpty() -> {
logger.v { "[handleMentionSuggestions] Mention found in the local state." }
localMentions
}
userNameContains.count() > 1 -> {
logger.v { "[handleMentionSuggestions] Querying the server for members who match the mention." }
val (channelType, channelId) = channelId.cidToTypeAndId()

queryMembersByUserNameContains(
channelType = channelType,
channelId = channelId,
contains = userNameContains
)
}
else -> emptyList()
}
} else {
emptyList()
}
}

/**
* Queries the backend for channel members whose username contains the string represented by the argument
* [contains].
*
* @param channelType The type of channel we are querying for members.
* @param channelId The ID of the channel we are querying for members.
* @param contains The string for which we are querying the backend in order to see if it is contained
* within a member's username.
*
* @return A list of users whose username contains the string represented by [contains] or an empty list in case
* no usernames contain the given string.
*/
private suspend fun queryMembersByUserNameContains(
channelType: String,
channelId: String,
contains: String,
): List<User> {
logger.v { "[queryMembersByUserNameContains] Querying the backend for members." }

val result = chatClient.queryMembers(
channelType = channelType,
channelId = channelId,
offset = queryMembersRequestOffset,
limit = queryMembersMemberLimit,
filter = Filters.autocomplete(
fieldName = "name",
value = contains
),
sort = QuerySortByField(),
members = listOf()
).await()

return if (result.isSuccess) {
result.data()
.filter { it.user.name.contains(contains, true) }
.map { it.user }
} else {
val error = result.error()

logger.e {
"[queryMembersByUserNameContains] Could not query members: " +
"${error.message}"
}

emptyList()
}
}

/**
* Shows the command suggestion list popup if necessary.
*/
Expand Down Expand Up @@ -778,5 +862,22 @@ public class MessageComposerController(
private const val DefaultMessageLimit: Int = 30

private const val OneSecond = 1000L

/**
* The amount of time we debounce computing mention suggestions.
* We debounce those computations in the case of being unable to find mentions from local data, we will query
* the BE for members.
*/
private const val ComputeMentionSuggestionsDebounceTime = 300L

/**
* Pagination offset for the member query.
*/
private const val queryMembersRequestOffset: Int = 0

/**
* The upper limit of members the query is allowed to return.
*/
private const val queryMembersMemberLimit: Int = 30
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2306,8 +2306,10 @@ public abstract interface class io/getstream/chat/android/ui/message/input/Messa
}

public final class io/getstream/chat/android/ui/message/input/MessageInputView$DefaultUserLookupHandler : io/getstream/chat/android/ui/message/input/MessageInputView$UserLookupHandler {
public fun <init> (Ljava/util/List;)V
public fun <init> (Ljava/util/List;Lio/getstream/chat/android/ui/message/input/transliteration/StreamTransliterator;)V
public synthetic fun <init> (Ljava/util/List;Lio/getstream/chat/android/ui/message/input/transliteration/StreamTransliterator;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Ljava/util/List;Lio/getstream/chat/android/ui/message/input/transliteration/StreamTransliterator;Lkotlin/jvm/functions/Function2;)V
public synthetic fun <init> (Ljava/util/List;Lio/getstream/chat/android/ui/message/input/transliteration/StreamTransliterator;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getUsers ()Ljava/util/List;
public fun handleUserLookup (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun setUsers (Ljava/util/List;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ import com.getstream.sdk.chat.utils.extensions.isModerationFailed
import com.getstream.sdk.chat.utils.typing.DefaultTypingUpdatesBuffer
import com.getstream.sdk.chat.utils.typing.TypingUpdatesBuffer
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.client.api.models.querysort.QuerySortByField
import io.getstream.chat.android.client.call.enqueue
import io.getstream.chat.android.client.extensions.cidToTypeAndId
import io.getstream.chat.android.client.models.Attachment
import io.getstream.chat.android.client.models.Channel
import io.getstream.chat.android.client.models.Command
import io.getstream.chat.android.client.models.Filters
import io.getstream.chat.android.client.models.Member
import io.getstream.chat.android.client.models.Message
import io.getstream.chat.android.client.models.User
Expand Down Expand Up @@ -405,6 +407,52 @@ public class MessageInputViewModel @JvmOverloads constructor(
}
}

/**
* Queries the backend for channel members whose username contains the string represented by the argument
* [contains].
*
* @param contains The string for which we are querying the backend in order to see if it is contained
* within a member's username.
*
* @return A list of users whose username contains the string represented by [contains] or an empty list in case
* no usernames contain the given string.
*/
internal suspend fun queryMembersByUserNameContains(
contains: String,
): List<User> {
logger.v { "[queryMembersByUserNameContains] Querying the backend for members." }

val (channelType, channelId) = cid.cidToTypeAndId()

val result = chatClient.queryMembers(
channelType = channelType,
channelId = channelId,
offset = QUERY_MEMBERS_REQUEST_OFFSET,
limit = QUERY_MEMBERS_REQUEST_MEMBER_LIMIT,
filter = Filters.autocomplete(
fieldName = "name",
value = contains
),
sort = QuerySortByField(),
members = listOf()
).await()

return if (result.isSuccess) {
result.data()
.filter { it.user.name.contains(contains, true) }
.map { it.user }
} else {
val error = result.error()

logger.e {
"[queryMembersByUserNameContains] Could not query members: " +
"${error.message}"
}

emptyList()
}
}

/**
* Performs hygiene events such as clearing typing updates
* when the used leaves the messages screen.
Expand All @@ -420,5 +468,15 @@ public class MessageInputViewModel @JvmOverloads constructor(
* The default limit for messages that will be requested.
*/
private const val DEFAULT_MESSAGES_LIMIT: Int = 30

/**
* Pagination offset for the member query.
*/
private const val QUERY_MEMBERS_REQUEST_OFFSET: Int = 0

/**
* The upper limit of members the query is allowed to return.
*/
private const val QUERY_MEMBERS_REQUEST_MEMBER_LIMIT: Int = 30
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1303,14 +1303,23 @@ public class MessageInputView : ConstraintLayout {
* Tt uses levenshtein approximation so typos are included in the search. It is possible to choose a transliteration
* in the class to conversions between languages are possible. It uses https://unicode-org.github.io/icu/userguide/icu4j/
* for transliteration
*
* @param users The primary list of users used when searching for user metion matches. Usually this is populated
* by local state data.
* @param streamTransliterator Handles transliteration.
* @param queryMembersOnline This method is invoked internally within the body of [handleUserLookup]] if no
* matches were found within [users]. Use it to query the server for members and return a result.
*/
public class DefaultUserLookupHandler(
public class DefaultUserLookupHandler @JvmOverloads constructor(
public var users: List<User>,
private var streamTransliterator: StreamTransliterator = DefaultStreamTransliterator(),
private val queryMembersOnline: suspend (query: String) -> List<User> = { emptyList() },
) : UserLookupHandler {

override suspend fun handleUserLookup(query: String): List<User> {
return searchUsers(users, query, streamTransliterator)
return searchUsers(users, query, streamTransliterator).ifEmpty {
queryMembersOnline(query)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ public fun MessageInputViewModel.bindView(
view: MessageInputView,
lifecycleOwner: LifecycleOwner,
) {
val handler = MessageInputView.DefaultUserLookupHandler(emptyList())
val handler = MessageInputView.DefaultUserLookupHandler(emptyList()) { query ->
queryMembersByUserNameContains(query)
}
view.setUserLookupHandler(handler)
members.observe(lifecycleOwner) { members ->
handler.users = members.map(Member::user)
Expand Down

0 comments on commit f1f9e52

Please sign in to comment.