Autocompletion: group (including pills for groups)

This commit is contained in:
Benoit Marty 2019-12-20 02:15:48 +01:00
parent 543c07fd69
commit 8dce98c538
11 changed files with 220 additions and 7 deletions

View File

@ -31,6 +31,13 @@ interface GroupService {
*/
fun getGroup(groupId: String): Group?
/**
* Get a groupSummary from a groupId
* @param groupId the groupId to look for.
* @return the groupSummary with groupId or null
*/
fun getGroupSummary(groupId: String): GroupSummary?
/**
* Get a live list of group summaries. This list is refreshed as soon as the data changes.
* @return the [LiveData] of [GroupSummary]

View File

@ -74,6 +74,9 @@ sealed class MatrixItem(
init {
if (BuildConfig.DEBUG) checkId()
}
// Best name is the id, and we keep the displayName of the room for the case we need the first letter
override fun getBestName() = id
}
open fun getBestName(): String {

View File

@ -25,6 +25,7 @@ import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.GroupSummaryEntity
import im.vector.matrix.android.internal.database.model.GroupSummaryEntityFields
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.util.fetchCopyMap
import javax.inject.Inject
internal class DefaultGroupService @Inject constructor(private val monarchy: Monarchy) : GroupService {
@ -33,6 +34,13 @@ internal class DefaultGroupService @Inject constructor(private val monarchy: Mon
return null
}
override fun getGroupSummary(groupId: String): GroupSummary? {
return monarchy.fetchCopyMap(
{ realm -> GroupSummaryEntity.where(realm, groupId).findFirst() },
{ it, _ -> it.asDomain() }
)
}
override fun liveGroupSummaries(): LiveData<List<GroupSummary>> {
return monarchy.findAllMappedWithChanges(
{ realm -> GroupSummaryEntity.where(realm).isNotEmpty(GroupSummaryEntityFields.DISPLAY_NAME) },

View File

@ -23,8 +23,6 @@ import javax.inject.Inject
/**
* Utility class to detect special span in CharSequence and turn them into
* formatted text to send them as a Matrix messages.
*
* For now only support UserMentionSpans (TODO rooms, room aliases, etc...)
*/
internal class TextPillsUtils @Inject constructor(
private val mentionLinkSpecComparator: MentionLinkSpecComparator

View File

@ -0,0 +1,61 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.autocomplete.group
import android.content.Context
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Success
import com.otaliastudios.autocomplete.RecyclerViewPresenter
import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.riotx.features.autocomplete.AutocompleteClickListener
import javax.inject.Inject
class AutocompleteGroupPresenter @Inject constructor(context: Context,
private val controller: AutocompleteGroupController
) : RecyclerViewPresenter<GroupSummary>(context), AutocompleteClickListener<GroupSummary> {
var callback: Callback? = null
init {
controller.listener = this
}
override fun instantiateAdapter(): RecyclerView.Adapter<*> {
// Also remove animation
recyclerView?.itemAnimator = null
return controller.adapter
}
override fun onItemClick(t: GroupSummary) {
dispatchClick(t)
}
override fun onQuery(query: CharSequence?) {
callback?.onQueryGroups(query)
}
fun render(groups: Async<List<GroupSummary>>) {
if (groups is Success) {
controller.setData(groups())
}
}
interface Callback {
fun onQueryGroups(query: CharSequence?)
}
}

View File

@ -0,0 +1,48 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.autocomplete.group
import com.airbnb.epoxy.TypedEpoxyController
import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.matrix.android.api.util.toMatrixItem
import im.vector.riotx.features.autocomplete.AutocompleteClickListener
import im.vector.riotx.features.autocomplete.autocompleteMatrixItem
import im.vector.riotx.features.home.AvatarRenderer
import javax.inject.Inject
class AutocompleteGroupController @Inject constructor() : TypedEpoxyController<List<GroupSummary>>() {
var listener: AutocompleteClickListener<GroupSummary>? = null
@Inject lateinit var avatarRenderer: AvatarRenderer
override fun buildModels(data: List<GroupSummary>?) {
if (data.isNullOrEmpty()) {
return
}
data.forEach { groupSummary ->
autocompleteMatrixItem {
id(groupSummary.groupId)
matrixItem(groupSummary.toMatrixItem())
avatarRenderer(avatarRenderer)
clickListener { _ ->
listener?.onItemClick(groupSummary)
}
}
}
}
}

View File

@ -59,6 +59,7 @@ import im.vector.matrix.android.api.permalinks.PermalinkFactory
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.message.*
@ -85,6 +86,7 @@ import im.vector.riotx.features.attachments.AttachmentsHelper
import im.vector.riotx.features.attachments.ContactAttachment
import im.vector.riotx.features.autocomplete.command.AutocompleteCommandPresenter
import im.vector.riotx.features.autocomplete.command.CommandAutocompletePolicy
import im.vector.riotx.features.autocomplete.group.AutocompleteGroupPresenter
import im.vector.riotx.features.autocomplete.room.AutocompleteRoomPresenter
import im.vector.riotx.features.autocomplete.user.AutocompleteUserPresenter
import im.vector.riotx.features.command.Command
@ -144,6 +146,7 @@ class RoomDetailFragment @Inject constructor(
private val autocompleteCommandPresenter: AutocompleteCommandPresenter,
private val autocompleteUserPresenter: AutocompleteUserPresenter,
private val autocompleteRoomPresenter: AutocompleteRoomPresenter,
private val autocompleteGroupPresenter: AutocompleteGroupPresenter,
private val permalinkHandler: PermalinkHandler,
private val notificationDrawerManager: NotificationDrawerManager,
val roomDetailViewModelFactory: RoomDetailViewModel.Factory,
@ -155,6 +158,7 @@ class RoomDetailFragment @Inject constructor(
TimelineEventController.Callback,
AutocompleteUserPresenter.Callback,
AutocompleteRoomPresenter.Callback,
AutocompleteGroupPresenter.Callback,
VectorInviteView.Callback,
JumpToReadMarkerView.Callback,
AttachmentTypeSelectorView.Callback,
@ -633,6 +637,52 @@ class RoomDetailFragment @Inject constructor(
})
.build()
autocompleteGroupPresenter.callback = this
Autocomplete.on<GroupSummary>(composerLayout.composerEditText)
.with(CharPolicy('+', true))
.with(autocompleteGroupPresenter)
.with(elevation)
.with(backgroundDrawable)
.with(object : AutocompleteCallback<GroupSummary> {
override fun onPopupItemClicked(editable: Editable, item: GroupSummary): Boolean {
// Detect last '+' and remove it
var startIndex = editable.lastIndexOf("+")
if (startIndex == -1) {
startIndex = 0
}
// Detect next word separator
var endIndex = editable.indexOf(" ", startIndex)
if (endIndex == -1) {
endIndex = editable.length
}
// Replace the word by its completion
val matrixItem = item.toMatrixItem()
val displayName = matrixItem.getBestName()
// with a trailing space
editable.replace(startIndex, endIndex, "$displayName ")
// Add the span
val span = PillImageSpan(
glideRequests,
avatarRenderer,
requireContext(),
matrixItem
)
span.bind(composerLayout.composerEditText)
editable.setSpan(span, startIndex, startIndex + displayName.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
return true
}
override fun onPopupVisibilityChanged(shown: Boolean) {
}
})
.build()
autocompleteUserPresenter.callback = this
Autocomplete.on<User>(composerLayout.composerEditText)
.with(CharPolicy('@', true))
@ -776,6 +826,7 @@ class RoomDetailFragment @Inject constructor(
private fun renderTextComposerState(state: TextComposerViewState) {
autocompleteUserPresenter.render(state.asyncUsers)
autocompleteRoomPresenter.render(state.asyncRooms)
autocompleteGroupPresenter.render(state.asyncGroups)
}
private fun renderTombstoneEventHandling(async: Async<String>) {
@ -1114,6 +1165,12 @@ class RoomDetailFragment @Inject constructor(
textComposerViewModel.handle(TextComposerAction.QueryRooms(query))
}
// AutocompleteGroupPresenter.Callback
override fun onQueryGroups(query: CharSequence?) {
textComposerViewModel.handle(TextComposerAction.QueryGroups(query))
}
private fun handleActions(action: EventSharedAction) {
when (action) {
is EventSharedAction.AddReaction -> {

View File

@ -21,4 +21,5 @@ import im.vector.riotx.core.platform.VectorViewModelAction
sealed class TextComposerAction : VectorViewModelAction {
data class QueryUsers(val query: CharSequence?) : TextComposerAction()
data class QueryRooms(val query: CharSequence?) : TextComposerAction()
data class QueryGroups(val query: CharSequence?) : TextComposerAction()
}

View File

@ -24,6 +24,7 @@ import com.jakewharton.rxrelay2.BehaviorRelay
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.user.model.User
import im.vector.matrix.rx.rx
@ -43,6 +44,7 @@ class TextComposerViewModel @AssistedInject constructor(@Assisted initialState:
private val usersQueryObservable = BehaviorRelay.create<Option<AutocompleteQuery>>()
private val roomsQueryObservable = BehaviorRelay.create<Option<AutocompleteQuery>>()
private val groupsQueryObservable = BehaviorRelay.create<Option<AutocompleteQuery>>()
@AssistedInject.Factory
interface Factory {
@ -61,12 +63,14 @@ class TextComposerViewModel @AssistedInject constructor(@Assisted initialState:
init {
observeUsersQuery()
observeRoomsQuery()
observeGroupsQuery()
}
override fun handle(action: TextComposerAction) {
when (action) {
is TextComposerAction.QueryUsers -> handleQueryUsers(action)
is TextComposerAction.QueryRooms -> handleQueryRooms(action)
is TextComposerAction.QueryUsers -> handleQueryUsers(action)
is TextComposerAction.QueryRooms -> handleQueryRooms(action)
is TextComposerAction.QueryGroups -> handleQueryGroups(action)
}
}
@ -81,6 +85,11 @@ class TextComposerViewModel @AssistedInject constructor(@Assisted initialState:
roomsQueryObservable.accept(query)
}
private fun handleQueryGroups(action: TextComposerAction.QueryGroups) {
val query = Option.fromNullable(action.query)
groupsQueryObservable.accept(query)
}
private fun observeUsersQuery() {
Observable.combineLatest<List<String>, Option<AutocompleteQuery>, List<User>>(
room.rx().liveRoomMemberIds(),
@ -124,4 +133,23 @@ class TextComposerViewModel @AssistedInject constructor(@Assisted initialState:
)
}
}
private fun observeGroupsQuery() {
Observable.combineLatest<List<GroupSummary>, Option<AutocompleteQuery>, List<GroupSummary>>(
session.rx().liveGroupSummaries(),
groupsQueryObservable.throttleLast(300, TimeUnit.MILLISECONDS),
BiFunction { groupSummaries, query ->
val filter = query.orNull() ?: ""
groupSummaries
.filter {
it.groupId.contains(filter, ignoreCase = true)
}
.sortedBy { it.displayName }
}
).execute { async ->
copy(
asyncGroups = async
)
}
}
}

View File

@ -19,13 +19,15 @@ package im.vector.riotx.features.home.room.detail.composer
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.features.home.room.detail.RoomDetailArgs
data class TextComposerViewState(val roomId: String,
val asyncUsers: Async<List<User>> = Uninitialized,
val asyncRooms: Async<List<RoomSummary>> = Uninitialized
val asyncRooms: Async<List<RoomSummary>> = Uninitialized,
val asyncGroups: Async<List<GroupSummary>> = Uninitialized
) : MvRxState {
constructor(args: RoomDetailArgs) : this(roomId = args.roomId)

View File

@ -54,8 +54,8 @@ class MxLinkTagHandler(private val glideRequests: GlideRequests,
}
}
is PermalinkData.GroupLink -> {
// TODO val group = sessionHolder.getSafeActiveSession()?.getGroup(permalinkData.groupId)
MatrixItem.RoomItem(permalinkData.groupId /* TODO Group display name and avatar */)
val group = sessionHolder.getSafeActiveSession()?.getGroupSummary(permalinkData.groupId)
MatrixItem.GroupItem(permalinkData.groupId, group?.displayName, group?.avatarUrl)
}
else -> null
}