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 4fed773ae2..1ca7237d3d 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 @@ -62,6 +62,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 } data class GroupItem(override val id: String, @@ -73,7 +76,7 @@ sealed class MatrixItem( } } - fun getBestName(): String { + open fun getBestName(): String { return displayName?.takeIf { it.isNotBlank() } ?: id } @@ -95,7 +98,7 @@ sealed class MatrixItem( } fun firstLetterOfDisplayName(): String { - return getBestName() + return (displayName?.takeIf { it.isNotBlank() } ?: id) .let { dn -> var startIndex = 0 val initial = dn[startIndex] @@ -138,4 +141,5 @@ sealed class MatrixItem( fun User.toMatrixItem() = MatrixItem.UserItem(userId, displayName, avatarUrl) fun GroupSummary.toMatrixItem() = MatrixItem.GroupItem(groupId, displayName, avatarUrl) fun RoomSummary.toMatrixItem() = MatrixItem.RoomItem(roomId, displayName, avatarUrl) +fun RoomSummary.toRoomAliasMatrixItem() = MatrixItem.RoomAliasItem(canonicalAlias ?: roomId, displayName, avatarUrl) fun PublicRoom.toMatrixItem() = MatrixItem.RoomItem(roomId, name, avatarUrl) diff --git a/vector/src/main/java/im/vector/riotx/features/autocomplete/user/AutocompleteUserItem.kt b/vector/src/main/java/im/vector/riotx/features/autocomplete/AutocompleteMatrixItem.kt similarity index 71% rename from vector/src/main/java/im/vector/riotx/features/autocomplete/user/AutocompleteUserItem.kt rename to vector/src/main/java/im/vector/riotx/features/autocomplete/AutocompleteMatrixItem.kt index 8581ba8e2c..d5eb90a62c 100644 --- a/vector/src/main/java/im/vector/riotx/features/autocomplete/user/AutocompleteUserItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/autocomplete/AutocompleteMatrixItem.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package im.vector.riotx.features.autocomplete.user +package im.vector.riotx.features.autocomplete import android.view.View import android.widget.ImageView @@ -25,23 +25,27 @@ import im.vector.matrix.android.api.util.MatrixItem import im.vector.riotx.R import im.vector.riotx.core.epoxy.VectorEpoxyHolder import im.vector.riotx.core.epoxy.VectorEpoxyModel +import im.vector.riotx.core.extensions.setTextOrHide import im.vector.riotx.features.home.AvatarRenderer -@EpoxyModelClass(layout = R.layout.item_autocomplete_user) -abstract class AutocompleteUserItem : VectorEpoxyModel() { +@EpoxyModelClass(layout = R.layout.item_autocomplete_matrix_item) +abstract class AutocompleteMatrixItem : VectorEpoxyModel() { @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer @EpoxyAttribute lateinit var matrixItem: MatrixItem + @EpoxyAttribute var subName: String? = null @EpoxyAttribute var clickListener: View.OnClickListener? = null override fun bind(holder: Holder) { holder.view.setOnClickListener(clickListener) holder.nameView.text = matrixItem.getBestName() + holder.subNameView.setTextOrHide(subName) avatarRenderer.render(matrixItem, holder.avatarImageView) } class Holder : VectorEpoxyHolder() { - val nameView by bind(R.id.userAutocompleteName) - val avatarImageView by bind(R.id.userAutocompleteAvatar) + val nameView by bind(R.id.matrixItemAutocompleteName) + val subNameView by bind(R.id.matrixItemAutocompleteSubname) + val avatarImageView by bind(R.id.matrixItemAutocompleteAvatar) } } diff --git a/vector/src/main/java/im/vector/riotx/features/autocomplete/room/AutocompleteRoomController.kt b/vector/src/main/java/im/vector/riotx/features/autocomplete/room/AutocompleteRoomController.kt new file mode 100644 index 0000000000..51285b02b7 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/autocomplete/room/AutocompleteRoomController.kt @@ -0,0 +1,49 @@ +/* + * 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.room + +import com.airbnb.epoxy.TypedEpoxyController +import im.vector.matrix.android.api.session.room.model.RoomSummary +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 AutocompleteRoomController @Inject constructor() : TypedEpoxyController>() { + + var listener: AutocompleteClickListener? = null + + @Inject lateinit var avatarRenderer: AvatarRenderer + + override fun buildModels(data: List?) { + if (data.isNullOrEmpty()) { + return + } + data.forEach { roomSummary -> + autocompleteMatrixItem { + id(roomSummary.roomId) + matrixItem(roomSummary.toMatrixItem()) + subName(roomSummary.canonicalAlias) + avatarRenderer(avatarRenderer) + clickListener { _ -> + listener?.onItemClick(roomSummary) + } + } + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/autocomplete/room/AutocompleteRoomPresenter.kt b/vector/src/main/java/im/vector/riotx/features/autocomplete/room/AutocompleteRoomPresenter.kt new file mode 100644 index 0000000000..e2b4f38e19 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/autocomplete/room/AutocompleteRoomPresenter.kt @@ -0,0 +1,59 @@ +/* + * 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.room + +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.room.model.RoomSummary +import im.vector.riotx.features.autocomplete.AutocompleteClickListener +import javax.inject.Inject + +class AutocompleteRoomPresenter @Inject constructor(context: Context, + private val controller: AutocompleteRoomController +) : RecyclerViewPresenter(context), AutocompleteClickListener { + + var callback: Callback? = null + + init { + controller.listener = this + } + + override fun instantiateAdapter(): RecyclerView.Adapter<*> { + return controller.adapter + } + + override fun onItemClick(t: RoomSummary) { + dispatchClick(t) + } + + override fun onQuery(query: CharSequence?) { + callback?.onQueryRooms(query) + } + + fun render(rooms: Async>) { + if (rooms is Success) { + controller.setData(rooms()) + } + } + + interface Callback { + fun onQueryRooms(query: CharSequence?) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/autocomplete/user/AutocompleteUserController.kt b/vector/src/main/java/im/vector/riotx/features/autocomplete/user/AutocompleteUserController.kt index 8f0090001f..53a87fe27a 100644 --- a/vector/src/main/java/im/vector/riotx/features/autocomplete/user/AutocompleteUserController.kt +++ b/vector/src/main/java/im/vector/riotx/features/autocomplete/user/AutocompleteUserController.kt @@ -20,6 +20,7 @@ import com.airbnb.epoxy.TypedEpoxyController import im.vector.matrix.android.api.session.user.model.User 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 @@ -34,7 +35,7 @@ class AutocompleteUserController @Inject constructor() : TypedEpoxyController
  • - autocompleteUserItem { + autocompleteMatrixItem { id(user.userId) matrixItem(user.toMatrixItem()) avatarRenderer(avatarRenderer) 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 49f23f7f2c..0d20ce851e 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 @@ -60,6 +60,7 @@ 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.room.model.Membership +import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.message.* import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.api.session.room.timeline.Timeline @@ -68,6 +69,7 @@ import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent import im.vector.matrix.android.api.session.user.model.User import im.vector.matrix.android.api.util.MatrixItem import im.vector.matrix.android.api.util.toMatrixItem +import im.vector.matrix.android.api.util.toRoomAliasMatrixItem import im.vector.riotx.R import im.vector.riotx.core.dialogs.withColoredButton import im.vector.riotx.core.epoxy.LayoutManagerStateRestorer @@ -83,6 +85,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.room.AutocompleteRoomPresenter import im.vector.riotx.features.autocomplete.user.AutocompleteUserPresenter import im.vector.riotx.features.command.Command import im.vector.riotx.features.home.AvatarRenderer @@ -140,6 +143,7 @@ class RoomDetailFragment @Inject constructor( private val commandAutocompletePolicy: CommandAutocompletePolicy, private val autocompleteCommandPresenter: AutocompleteCommandPresenter, private val autocompleteUserPresenter: AutocompleteUserPresenter, + private val autocompleteRoomPresenter: AutocompleteRoomPresenter, private val permalinkHandler: PermalinkHandler, private val notificationDrawerManager: NotificationDrawerManager, val roomDetailViewModelFactory: RoomDetailViewModel.Factory, @@ -150,6 +154,7 @@ class RoomDetailFragment @Inject constructor( VectorBaseFragment(), TimelineEventController.Callback, AutocompleteUserPresenter.Callback, + AutocompleteRoomPresenter.Callback, VectorInviteView.Callback, JumpToReadMarkerView.Callback, AttachmentTypeSelectorView.Callback, @@ -582,6 +587,52 @@ class RoomDetailFragment @Inject constructor( }) .build() + autocompleteRoomPresenter.callback = this + Autocomplete.on(composerLayout.composerEditText) + .with(CharPolicy('#', true)) + .with(autocompleteRoomPresenter) + .with(elevation) + .with(backgroundDrawable) + .with(object : AutocompleteCallback { + override fun onPopupItemClicked(editable: Editable, item: RoomSummary): 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.toRoomAliasMatrixItem() + 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)) @@ -724,6 +775,7 @@ class RoomDetailFragment @Inject constructor( private fun renderTextComposerState(state: TextComposerViewState) { autocompleteUserPresenter.render(state.asyncUsers) + autocompleteRoomPresenter.render(state.asyncRooms) } private fun renderTombstoneEventHandling(async: Async) { @@ -1056,6 +1108,12 @@ class RoomDetailFragment @Inject constructor( textComposerViewModel.handle(TextComposerAction.QueryUsers(query)) } + // AutocompleteRoomPresenter.Callback + + override fun onQueryRooms(query: CharSequence?) { + textComposerViewModel.handle(TextComposerAction.QueryRooms(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 5d60fa1cef..9f94b086a2 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 @@ -20,4 +20,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() } 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 88548e12b4..c69ab7c5da 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.room.model.RoomSummary import im.vector.matrix.android.api.session.user.model.User import im.vector.matrix.rx.rx import im.vector.riotx.core.platform.VectorViewModel @@ -32,16 +33,16 @@ import io.reactivex.Observable import io.reactivex.functions.BiFunction import java.util.concurrent.TimeUnit -typealias AutocompleteUserQuery = CharSequence +typealias AutocompleteQuery = CharSequence class TextComposerViewModel @AssistedInject constructor(@Assisted initialState: TextComposerViewState, private val session: Session ) : VectorViewModel(initialState) { private val room = session.getRoom(initialState.roomId)!! - private val roomId = initialState.roomId - private val usersQueryObservable = BehaviorRelay.create>() + private val usersQueryObservable = BehaviorRelay.create>() + private val roomsQueryObservable = BehaviorRelay.create>() @AssistedInject.Factory interface Factory { @@ -59,11 +60,13 @@ class TextComposerViewModel @AssistedInject constructor(@Assisted initialState: init { observeUsersQuery() + observeRoomsQuery() } override fun handle(action: TextComposerAction) { when (action) { is TextComposerAction.QueryUsers -> handleQueryUsers(action) + is TextComposerAction.QueryRooms -> handleQueryRooms(action) } } @@ -72,8 +75,14 @@ class TextComposerViewModel @AssistedInject constructor(@Assisted initialState: usersQueryObservable.accept(query) } + + private fun handleQueryRooms(action: TextComposerAction.QueryRooms) { + val query = Option.fromNullable(action.query) + roomsQueryObservable.accept(query) + } + private fun observeUsersQuery() { - Observable.combineLatest, Option, List>( + Observable.combineLatest, Option, List>( room.rx().liveRoomMemberIds(), usersQueryObservable.throttleLast(300, TimeUnit.MILLISECONDS), BiFunction { roomMemberIds, query -> @@ -87,6 +96,7 @@ class TextComposerViewModel @AssistedInject constructor(@Assisted initialState: it.displayName?.startsWith(prefix = filter, ignoreCase = true) ?: false } } + .sortedBy { it.displayName } } ).execute { async -> copy( @@ -94,4 +104,24 @@ class TextComposerViewModel @AssistedInject constructor(@Assisted initialState: ) } } + + private fun observeRoomsQuery() { + Observable.combineLatest, Option, List>( + session.rx().liveRoomSummaries(), + roomsQueryObservable.throttleLast(300, TimeUnit.MILLISECONDS), + BiFunction { roomSummaries, query -> + val filter = query.orNull() ?: "" + // Keep only room with a canonical alias + roomSummaries + .filter { + it.canonicalAlias?.contains(filter, ignoreCase = true) == true + } + .sortedBy { it.displayName } + } + ).execute { async -> + copy( + asyncRooms = 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 b2cec09096..671a7ac556 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,11 +19,13 @@ 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.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 asyncUsers: Async> = Uninitialized, + val asyncRooms: Async> = Uninitialized ) : MvRxState { constructor(args: RoomDetailArgs) : this(roomId = args.roomId) diff --git a/vector/src/main/res/layout/item_autocomplete_matrix_item.xml b/vector/src/main/res/layout/item_autocomplete_matrix_item.xml new file mode 100644 index 0000000000..9696a08bb5 --- /dev/null +++ b/vector/src/main/res/layout/item_autocomplete_matrix_item.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_autocomplete_user.xml b/vector/src/main/res/layout/item_autocomplete_user.xml deleted file mode 100644 index f2fdb354a9..0000000000 --- a/vector/src/main/res/layout/item_autocomplete_user.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - \ No newline at end of file