From 8dce98c53827187d1bc13542b1609c652e50e2a1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 20 Dec 2019 02:15:48 +0100 Subject: [PATCH] Autocompletion: group (including pills for groups) --- .../android/api/session/group/GroupService.kt | 7 +++ .../matrix/android/api/util/MatrixItem.kt | 3 + .../session/group/DefaultGroupService.kt | 8 +++ .../session/room/send/pills/TextPillsUtils.kt | 2 - .../group/AutocompleteGroupPresenter.kt | 61 +++++++++++++++++++ .../group/AutocompleteRoomController.kt | 48 +++++++++++++++ .../home/room/detail/RoomDetailFragment.kt | 57 +++++++++++++++++ .../detail/composer/TextComposerAction.kt | 1 + .../detail/composer/TextComposerViewModel.kt | 32 +++++++++- .../detail/composer/TextComposerViewState.kt | 4 +- .../riotx/features/html/MxLinkTagHandler.kt | 4 +- 11 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotx/features/autocomplete/group/AutocompleteGroupPresenter.kt create mode 100644 vector/src/main/java/im/vector/riotx/features/autocomplete/group/AutocompleteRoomController.kt diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/group/GroupService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/group/GroupService.kt index ff63d1a9e7..2d55d0be57 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/group/GroupService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/group/GroupService.kt @@ -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] diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/MatrixItem.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/MatrixItem.kt index 1ca7237d3d..4c8082b77e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/MatrixItem.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/MatrixItem.kt @@ -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 { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGroupService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGroupService.kt index be059038f3..192c6fe40c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGroupService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/DefaultGroupService.kt @@ -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> { return monarchy.findAllMappedWithChanges( { realm -> GroupSummaryEntity.where(realm).isNotEmpty(GroupSummaryEntityFields.DISPLAY_NAME) }, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/TextPillsUtils.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/TextPillsUtils.kt index b512f27602..1a7b8228b9 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/TextPillsUtils.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/TextPillsUtils.kt @@ -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 diff --git a/vector/src/main/java/im/vector/riotx/features/autocomplete/group/AutocompleteGroupPresenter.kt b/vector/src/main/java/im/vector/riotx/features/autocomplete/group/AutocompleteGroupPresenter.kt new file mode 100644 index 0000000000..822ce451e7 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/autocomplete/group/AutocompleteGroupPresenter.kt @@ -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(context), AutocompleteClickListener { + + 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>) { + if (groups is Success) { + controller.setData(groups()) + } + } + + interface Callback { + fun onQueryGroups(query: CharSequence?) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/autocomplete/group/AutocompleteRoomController.kt b/vector/src/main/java/im/vector/riotx/features/autocomplete/group/AutocompleteRoomController.kt new file mode 100644 index 0000000000..5d0d43d9ea --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/autocomplete/group/AutocompleteRoomController.kt @@ -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>() { + + var listener: AutocompleteClickListener? = null + + @Inject lateinit var avatarRenderer: AvatarRenderer + + override fun buildModels(data: List?) { + if (data.isNullOrEmpty()) { + return + } + data.forEach { groupSummary -> + autocompleteMatrixItem { + id(groupSummary.groupId) + matrixItem(groupSummary.toMatrixItem()) + avatarRenderer(avatarRenderer) + clickListener { _ -> + listener?.onItemClick(groupSummary) + } + } + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 0d20ce851e..cb54aba651 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -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(composerLayout.composerEditText) + .with(CharPolicy('+', true)) + .with(autocompleteGroupPresenter) + .with(elevation) + .with(backgroundDrawable) + .with(object : AutocompleteCallback { + 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(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) { @@ -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 -> { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerAction.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerAction.kt index 9f94b086a2..0f5bf2a8c5 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerAction.kt @@ -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() } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerViewModel.kt index c69ab7c5da..9d6d9aab2e 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerViewModel.kt @@ -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>() private val roomsQueryObservable = BehaviorRelay.create>() + private val groupsQueryObservable = BehaviorRelay.create>() @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, Option, List>( room.rx().liveRoomMemberIds(), @@ -124,4 +133,23 @@ class TextComposerViewModel @AssistedInject constructor(@Assisted initialState: ) } } + + private fun observeGroupsQuery() { + Observable.combineLatest, Option, List>( + 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 + ) + } + } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerViewState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerViewState.kt index 671a7ac556..e863970afe 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerViewState.kt @@ -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> = Uninitialized, - val asyncRooms: Async> = Uninitialized + val asyncRooms: Async> = Uninitialized, + val asyncGroups: Async> = Uninitialized ) : MvRxState { constructor(args: RoomDetailArgs) : this(roomId = args.roomId) diff --git a/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt b/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt index 7e5a82060a..ff85a4d2cb 100644 --- a/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt +++ b/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt @@ -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 }