diff --git a/CHANGES.md b/CHANGES.md index 697aa17b35..85eb76f5f2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,7 @@ Changes in Element 1.0.9 (2020-XX-XX) =================================================== Features ✨: + - Search messages in a room - phase 1 (#2110) - Hide encrypted history (before user is invited). Can be shown if wanted in developer settings Improvements 🙌: @@ -11,6 +12,7 @@ Improvements 🙌: - Allow user to reset cross signing if he has no way to recover (#2052) - Create home shortcut for any room (#1525) - Can't confirm email due to killing by Android (#2021) + - Filter room member (and banned users) by name (#2184) Bugfix 🐛: - Improve support for image/audio/video/file selection with intent changes (#1376) @@ -20,7 +22,7 @@ Translations 🗣: - SDK API changes ⚠️: - - + - Search messages in a room by using Session.searchService() or Room.search() Build 🧱: - Use Update Gradle Wrapper Action diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/search/SearchMessagesTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/search/SearchMessagesTest.kt new file mode 100644 index 0000000000..8ae8ef1b44 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/search/SearchMessagesTest.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2020 New Vector Ltd + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.session.search + +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.InstrumentedTest +import org.matrix.android.sdk.api.MatrixCallback +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import org.matrix.android.sdk.api.session.search.SearchResult +import org.matrix.android.sdk.common.CommonTestHelper +import org.matrix.android.sdk.common.CryptoTestHelper +import org.matrix.android.sdk.common.TestConstants +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +class SearchMessagesTest : InstrumentedTest { + + private val MESSAGE = "Lorem ipsum dolor sit amet" + + private val commonTestHelper = CommonTestHelper(context()) + private val cryptoTestHelper = CryptoTestHelper(commonTestHelper) + + @Test + fun sendTextMessageAndSearchPartOfItUsingSession() { + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false) + val aliceSession = cryptoTestData.firstSession + val aliceRoomId = cryptoTestData.roomId + aliceSession.cryptoService().setWarnOnUnknownDevices(false) + val roomFromAlicePOV = aliceSession.getRoom(aliceRoomId)!! + val aliceTimeline = roomFromAlicePOV.createTimeline(null, TimelineSettings(10)) + aliceTimeline.start() + + commonTestHelper.sendTextMessage( + roomFromAlicePOV, + MESSAGE, + 2) + + run { + var lock = CountDownLatch(1) + + val eventListener = commonTestHelper.createEventListener(lock) { snapshot -> + snapshot.count { it.root.content.toModel()?.body?.startsWith(MESSAGE).orFalse() } == 2 + } + + aliceTimeline.addListener(eventListener) + commonTestHelper.await(lock) + + lock = CountDownLatch(1) + aliceSession + .searchService() + .search( + searchTerm = "lore", + limit = 10, + includeProfile = true, + afterLimit = 0, + beforeLimit = 10, + orderByRecent = true, + nextBatch = null, + roomId = aliceRoomId, + callback = object : MatrixCallback { + override fun onSuccess(data: SearchResult) { + super.onSuccess(data) + assertTrue(data.results?.size == 2) + assertTrue( + data.results + ?.all { + (it.event.content?.get("body") as? String)?.startsWith(MESSAGE).orFalse() + }.orFalse() + ) + lock.countDown() + } + + override fun onFailure(failure: Throwable) { + super.onFailure(failure) + fail(failure.localizedMessage) + lock.countDown() + } + } + ) + lock.await(TestConstants.timeOutMillis, TimeUnit.MILLISECONDS) + + aliceTimeline.removeAllListeners() + cryptoTestData.cleanUp(commonTestHelper) + } + + aliceSession.startSync(true) + } + + @Test + fun sendTextMessageAndSearchPartOfItUsingRoom() { + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false) + val aliceSession = cryptoTestData.firstSession + val aliceRoomId = cryptoTestData.roomId + aliceSession.cryptoService().setWarnOnUnknownDevices(false) + val roomFromAlicePOV = aliceSession.getRoom(aliceRoomId)!! + val aliceTimeline = roomFromAlicePOV.createTimeline(null, TimelineSettings(10)) + aliceTimeline.start() + + commonTestHelper.sendTextMessage( + roomFromAlicePOV, + MESSAGE, + 2) + + run { + var lock = CountDownLatch(1) + + val eventListener = commonTestHelper.createEventListener(lock) { snapshot -> + snapshot.count { it.root.content.toModel()?.body?.startsWith(MESSAGE).orFalse() } == 2 + } + + aliceTimeline.addListener(eventListener) + commonTestHelper.await(lock) + + lock = CountDownLatch(1) + roomFromAlicePOV + .search( + searchTerm = "lore", + limit = 10, + includeProfile = true, + afterLimit = 0, + beforeLimit = 10, + orderByRecent = true, + nextBatch = null, + callback = object : MatrixCallback { + override fun onSuccess(data: SearchResult) { + super.onSuccess(data) + assertTrue(data.results?.size == 2) + assertTrue( + data.results + ?.all { + (it.event.content?.get("body") as? String)?.startsWith(MESSAGE).orFalse() + }.orFalse() + ) + lock.countDown() + } + + override fun onFailure(failure: Throwable) { + super.onFailure(failure) + fail(failure.localizedMessage) + lock.countDown() + } + } + ) + lock.await(TestConstants.timeOutMillis, TimeUnit.MILLISECONDS) + + aliceTimeline.removeAllListeners() + cryptoTestData.cleanUp(commonTestHelper) + } + + aliceSession.startSync(true) + } +} diff --git a/matrix-sdk-android/src/main/AndroidManifest.xml b/matrix-sdk-android/src/main/AndroidManifest.xml index 52238f824c..eeb944d955 100644 --- a/matrix-sdk-android/src/main/AndroidManifest.xml +++ b/matrix-sdk-android/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ @@ -7,6 +8,15 @@ + + + diff --git a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt index 86d59b630b..014a244bf8 100644 --- a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt @@ -52,6 +52,7 @@ import im.vector.app.features.home.HomeDrawerFragment import im.vector.app.features.home.LoadingFragment import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment import im.vector.app.features.home.room.detail.RoomDetailFragment +import im.vector.app.features.home.room.detail.search.SearchFragment import im.vector.app.features.home.room.list.RoomListFragment import im.vector.app.features.login.LoginCaptchaFragment import im.vector.app.features.login.LoginFragment @@ -570,4 +571,9 @@ interface FragmentModule { @IntoMap @FragmentKey(RoomBannedMemberListFragment::class) fun bindRoomBannedMemberListFragment(fragment: RoomBannedMemberListFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(SearchFragment::class) + fun bindSearchFragment(fragment: SearchFragment): Fragment } diff --git a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt index d337ec7977..17ec00952b 100644 --- a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt +++ b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt @@ -38,6 +38,7 @@ import im.vector.app.features.home.HomeActivity import im.vector.app.features.home.HomeModule import im.vector.app.features.home.room.detail.RoomDetailActivity import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet +import im.vector.app.features.home.room.detail.search.SearchActivity import im.vector.app.features.home.room.detail.timeline.action.MessageActionsBottomSheet import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryBottomSheet import im.vector.app.features.home.room.detail.timeline.reactions.ViewReactionsBottomSheet @@ -142,6 +143,7 @@ interface ScreenComponent { fun inject(activity: VectorCallActivity) fun inject(activity: VectorAttachmentViewerActivity) fun inject(activity: VectorJitsiActivity) + fun inject(activity: SearchActivity) /* ========================================================================================== * BottomSheets diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index 3b4795b965..51aeda2aab 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -673,10 +673,22 @@ class RoomDetailFragment @Inject constructor( roomDetailViewModel.handle(RoomDetailAction.EndCall) true } + R.id.search -> { + handleSearchAction() + true + } else -> super.onOptionsItemSelected(item) } } + private fun handleSearchAction() { + if (session.getRoom(roomDetailArgs.roomId)?.isEncrypted() == false) { + navigator.openSearch(requireContext(), roomDetailArgs.roomId) + } else { + showDialogWithMessage(getString(R.string.search_is_not_supported_in_e2e_room)) + } + } + private fun handleCallRequest(item: MenuItem) = withState(roomDetailViewModel) { state -> val roomSummary = state.asyncRoomSummary.invoke() ?: return@withState val isVideoCall = item.itemId == R.id.video_call diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt index 9d52933168..1b5e928843 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt @@ -538,6 +538,7 @@ class RoomDetailViewModel @AssistedInject constructor( R.id.voice_call, R.id.video_call -> true // always show for discoverability R.id.hangup_call -> webRtcPeerConnectionManager.currentCall != null + R.id.search -> true else -> false } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchAction.kt new file mode 100644 index 0000000000..36d22f1914 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchAction.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2020 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.app.features.home.room.detail.search + +import im.vector.app.core.platform.VectorViewModelAction + +sealed class SearchAction : VectorViewModelAction { + data class SearchWith(val searchTerm: String) : SearchAction() + object LoadMore : SearchAction() + object Retry : SearchAction() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchActivity.kt new file mode 100644 index 0000000000..f85dccbb27 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchActivity.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2020 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.app.features.home.room.detail.search + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.widget.SearchView +import com.airbnb.mvrx.MvRx +import im.vector.app.R +import im.vector.app.core.di.ScreenComponent +import im.vector.app.core.extensions.addFragment +import im.vector.app.core.platform.VectorBaseActivity +import kotlinx.android.synthetic.main.activity_search.* + +class SearchActivity : VectorBaseActivity() { + + private val searchFragment: SearchFragment? + get() { + return supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) as? SearchFragment + } + + override fun getLayoutRes() = R.layout.activity_search + + override fun injectWith(injector: ScreenComponent) { + super.injectWith(injector) + injector.inject(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + configureToolbar(searchToolbar) + } + + override fun initUiAndData() { + if (isFirstCreation()) { + val fragmentArgs: SearchArgs = intent?.extras?.getParcelable(MvRx.KEY_ARG) ?: return + addFragment(R.id.searchFragmentContainer, SearchFragment::class.java, fragmentArgs, FRAGMENT_TAG) + } + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + searchFragment?.search(query) + return true + } + + override fun onQueryTextChange(newText: String): Boolean { + return true + } + }) + // Open the keyboard immediately + searchView.requestFocus() + } + + companion object { + private const val FRAGMENT_TAG = "SearchFragment" + + fun newIntent(context: Context, args: SearchArgs): Intent { + return Intent(context, SearchActivity::class.java).apply { + // If we do that we will have the same room two times on the stack. Let's allow infinite stack for the moment. + // flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + putExtra(MvRx.KEY_ARG, args) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchFragment.kt new file mode 100644 index 0000000000..666f5b3d38 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchFragment.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2020 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.app.features.home.room.detail.search + +import android.os.Bundle +import android.os.Parcelable +import android.view.View +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.LinearLayoutManager +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.args +import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.extensions.cleanup +import im.vector.app.core.extensions.configureWith +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.trackItemsVisibilityChange +import im.vector.app.core.platform.StateView +import im.vector.app.core.platform.VectorBaseFragment +import kotlinx.android.parcel.Parcelize +import kotlinx.android.synthetic.main.fragment_search.* +import org.matrix.android.sdk.api.session.events.model.Event +import javax.inject.Inject + +@Parcelize +data class SearchArgs( + val roomId: String +) : Parcelable + +class SearchFragment @Inject constructor( + val viewModelFactory: SearchViewModel.Factory, + private val controller: SearchResultController +) : VectorBaseFragment(), StateView.EventCallback, SearchResultController.Listener { + + private val fragmentArgs: SearchArgs by args() + private val searchViewModel: SearchViewModel by fragmentViewModel() + + private var pendingScrollToPosition: Int? = null + + override fun getLayoutResId() = R.layout.fragment_search + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + stateView.contentView = searchResultRecycler + stateView.eventCallback = this + + configureRecyclerView() + } + + private fun configureRecyclerView() { + searchResultRecycler.trackItemsVisibilityChange() + searchResultRecycler.configureWith(controller, showDivider = false) + (searchResultRecycler.layoutManager as? LinearLayoutManager)?.stackFromEnd = true + controller.listener = this + + controller.addModelBuildListener { + pendingScrollToPosition?.let { + searchResultRecycler.smoothScrollToPosition(it) + } + } + } + + override fun onDestroy() { + super.onDestroy() + searchResultRecycler?.cleanup() + controller.listener = null + } + + override fun invalidate() = withState(searchViewModel) { state -> + if (state.searchResult.isNullOrEmpty()) { + when (state.asyncSearchRequest) { + is Loading -> { + stateView.state = StateView.State.Loading + } + is Fail -> { + stateView.state = StateView.State.Error(errorFormatter.toHumanReadable(state.asyncSearchRequest.error)) + } + is Success -> { + stateView.state = StateView.State.Empty( + title = getString(R.string.search_no_results), + image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_search_no_results)) + } + } + } else { + pendingScrollToPosition = (state.lastBatchSize - 1).coerceAtLeast(0) + + stateView.state = StateView.State.Content + controller.setData(state) + } + } + + fun search(query: String) { + view?.hideKeyboard() + searchViewModel.handle(SearchAction.SearchWith(query)) + } + + override fun onRetryClicked() { + searchViewModel.handle(SearchAction.Retry) + } + + override fun onItemClicked(event: Event) { + event.roomId?.let { + navigator.openRoom(requireContext(), it, event.eventId) + } + } + + override fun loadMore() { + searchViewModel.handle(SearchAction.LoadMore) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt new file mode 100644 index 0000000000..c917c4557d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2020 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.app.features.home.room.detail.search + +import com.airbnb.epoxy.TypedEpoxyController +import com.airbnb.epoxy.VisibilityState +import im.vector.app.core.date.DateFormatKind +import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.core.epoxy.loadingItem +import im.vector.app.core.ui.list.genericItemHeader +import im.vector.app.features.home.AvatarRenderer +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.search.EventAndSender +import org.matrix.android.sdk.api.util.toMatrixItem +import java.util.Calendar +import javax.inject.Inject + +class SearchResultController @Inject constructor( + private val session: Session, + private val avatarRenderer: AvatarRenderer, + private val dateFormatter: VectorDateFormatter +) : TypedEpoxyController() { + + var listener: Listener? = null + + private var idx = 0 + + interface Listener { + fun onItemClicked(event: Event) + fun loadMore() + } + + init { + setData(null) + } + + override fun buildModels(data: SearchViewState?) { + data ?: return + + if (data.hasMoreResult) { + loadingItem { + // Always use a different id, because we can be notified several times of visibility state changed + id("loadMore${idx++}") + onVisibilityStateChanged { _, _, visibilityState -> + if (visibilityState == VisibilityState.VISIBLE) { + listener?.loadMore() + } + } + } + } + + buildSearchResultItems(data.searchResult) + } + + private fun buildSearchResultItems(events: List) { + var lastDate: Calendar? = null + + events.forEach { eventAndSender -> + val eventDate = Calendar.getInstance().apply { + timeInMillis = eventAndSender.event.originServerTs ?: System.currentTimeMillis() + } + if (lastDate?.get(Calendar.DAY_OF_YEAR) != eventDate.get(Calendar.DAY_OF_YEAR)) { + genericItemHeader { + id(eventDate.hashCode()) + text(dateFormatter.format(eventDate.timeInMillis, DateFormatKind.EDIT_HISTORY_HEADER)) + } + } + lastDate = eventDate + + searchResultItem { + id(eventAndSender.event.eventId) + avatarRenderer(avatarRenderer) + dateFormatter(dateFormatter) + event(eventAndSender.event) + sender(eventAndSender.sender + ?: eventAndSender.event.senderId?.let { session.getUser(it) }?.toMatrixItem()) + listener { listener?.onItemClicked(eventAndSender.event) } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt new file mode 100644 index 0000000000..10407c64e0 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2020 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.app.features.home.room.detail.search + +import android.widget.ImageView +import android.widget.TextView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.date.DateFormatKind +import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.core.epoxy.ClickListener +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.core.epoxy.onClick +import im.vector.app.core.extensions.setTextOrHide +import im.vector.app.features.home.AvatarRenderer +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.util.MatrixItem + +@EpoxyModelClass(layout = R.layout.item_search_result) +abstract class SearchResultItem : VectorEpoxyModel() { + + @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer + @EpoxyAttribute var dateFormatter: VectorDateFormatter? = null + @EpoxyAttribute lateinit var event: Event + @EpoxyAttribute var sender: MatrixItem? = null + @EpoxyAttribute var listener: ClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + + holder.view.onClick(listener) + sender?.let { avatarRenderer.render(it, holder.avatarImageView) } + holder.memberNameView.setTextOrHide(sender?.getBestName()) + holder.timeView.text = dateFormatter?.format(event.originServerTs, DateFormatKind.MESSAGE_SIMPLE) + // TODO Improve that (use formattedBody, etc.) + holder.contentView.text = event.content?.get("body") as? String + } + + class Holder : VectorEpoxyHolder() { + val avatarImageView by bind(R.id.messageAvatarImageView) + val memberNameView by bind(R.id.messageMemberNameView) + val timeView by bind(R.id.messageTimeView) + val contentView by bind(R.id.messageContentView) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewEvents.kt new file mode 100644 index 0000000000..6f07cb765c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewEvents.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2020 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.app.features.home.room.detail.search + +import im.vector.app.core.platform.VectorViewEvents + +sealed class SearchViewEvents : VectorViewEvents { + data class Failure(val throwable: Throwable) : SearchViewEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewModel.kt new file mode 100644 index 0000000000..21a2d18e71 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewModel.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2020 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.app.features.home.room.detail.search + +import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.ViewModelContext +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.platform.VectorViewModel +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.search.SearchResult +import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.internal.util.awaitCallback + +class SearchViewModel @AssistedInject constructor( + @Assisted private val initialState: SearchViewState, + session: Session +) : VectorViewModel(initialState) { + + private var room: Room? = session.getRoom(initialState.roomId) + + private var currentTask: Cancelable? = null + + private var nextBatch: String? = null + + @AssistedInject.Factory + interface Factory { + fun create(initialState: SearchViewState): SearchViewModel + } + + companion object : MvRxViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: SearchViewState): SearchViewModel? { + val fragment: SearchFragment = (viewModelContext as FragmentViewModelContext).fragment() + return fragment.viewModelFactory.create(state) + } + } + + override fun handle(action: SearchAction) { + when (action) { + is SearchAction.SearchWith -> handleSearchWith(action) + is SearchAction.LoadMore -> handleLoadMore() + is SearchAction.Retry -> handleRetry() + }.exhaustive + } + + private fun handleSearchWith(action: SearchAction.SearchWith) { + if (action.searchTerm.isNotEmpty()) { + setState { + copy( + searchResult = emptyList(), + hasMoreResult = false, + lastBatchSize = 0, + searchTerm = action.searchTerm + ) + } + startSearching(false) + } + } + + private fun handleLoadMore() { + startSearching(true) + } + + private fun handleRetry() { + startSearching(false) + } + + private fun startSearching(isNextBatch: Boolean) = withState { state -> + if (state.searchTerm == null) return@withState + + // There is no batch to retrieve + if (isNextBatch && nextBatch == null) return@withState + + // Show full screen loading just for the clean search + if (!isNextBatch) { + setState { + copy( + asyncSearchRequest = Loading() + ) + } + } + + currentTask?.cancel() + + viewModelScope.launch { + try { + val result = awaitCallback { + currentTask = room?.search( + searchTerm = state.searchTerm, + nextBatch = nextBatch, + orderByRecent = true, + beforeLimit = 0, + afterLimit = 0, + includeProfile = true, + limit = 20, + callback = it + ) + } + onSearchResultSuccess(result) + } catch (failure: Throwable) { + if (failure is Failure.Cancelled) return@launch + + _viewEvents.post(SearchViewEvents.Failure(failure)) + setState { + copy( + asyncSearchRequest = Fail(failure) + ) + } + } + } + } + + private fun onSearchResultSuccess(searchResult: SearchResult) = withState { state -> + val accumulatedResult = searchResult.results.orEmpty().plus(state.searchResult) + + // Note: We do not care about the highlights for the moment, but it will be the same algorithm + + nextBatch = searchResult.nextBatch + + setState { + copy( + searchResult = accumulatedResult, + hasMoreResult = !nextBatch.isNullOrEmpty(), + lastBatchSize = searchResult.results.orEmpty().size, + asyncSearchRequest = Success(Unit) + ) + } + } + + override fun onCleared() { + currentTask?.cancel() + super.onCleared() + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewState.kt new file mode 100644 index 0000000000..9f700b6e31 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewState.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2020 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.app.features.home.room.detail.search + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.Uninitialized +import org.matrix.android.sdk.api.session.search.EventAndSender + +data class SearchViewState( + // Accumulated search result + val searchResult: List = emptyList(), + val hasMoreResult: Boolean = false, + // Last batch size, will help RecyclerView to position itself + val lastBatchSize: Int = 0, + val searchTerm: String? = null, + val roomId: String = "", + // Current pagination request + val asyncSearchRequest: Async = Uninitialized +) : MvRxState { + + constructor(args: SearchArgs) : this(roomId = args.roomId) +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListNameFilter.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListNameFilter.kt index 4c4a9755d2..6e787b8b95 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListNameFilter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListNameFilter.kt @@ -25,7 +25,7 @@ class RoomListNameFilter @Inject constructor() : Predicate { var filter: String = "" override fun test(roomSummary: RoomSummary): Boolean { - if (filter.isBlank()) { + if (filter.isEmpty()) { // No filter return true } diff --git a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt index 3a8d302fc7..5ad600dfff 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt @@ -43,6 +43,8 @@ import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.debug.DebugMenuActivity import im.vector.app.features.home.room.detail.RoomDetailActivity import im.vector.app.features.home.room.detail.RoomDetailArgs +import im.vector.app.features.home.room.detail.search.SearchActivity +import im.vector.app.features.home.room.detail.search.SearchArgs import im.vector.app.features.home.room.detail.widget.WidgetRequestCodes import im.vector.app.features.home.room.filtered.FilteredRoomsActivity import im.vector.app.features.invite.InviteUsersToRoomActivity @@ -329,6 +331,11 @@ class DefaultNavigator @Inject constructor( } } + override fun openSearch(context: Context, roomId: String) { + val intent = SearchActivity.newIntent(context, SearchArgs(roomId)) + context.startActivity(intent) + } + private fun startActivity(context: Context, intent: Intent, buildTask: Boolean) { if (buildTask) { val stackBuilder = TaskStackBuilder.create(context) diff --git a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt index e92fea267f..b2efb1e931 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt @@ -109,4 +109,6 @@ interface Navigator { view: View, inMemory: List = emptyList(), options: ((MutableList>) -> Unit)?) + + fun openSearch(context: Context, roomId: String) } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedListMemberAction.kt b/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedListMemberAction.kt index 9af554e883..67cacf4541 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedListMemberAction.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedListMemberAction.kt @@ -22,4 +22,5 @@ import im.vector.app.core.platform.VectorViewModelAction sealed class RoomBannedListMemberAction : VectorViewModelAction { data class QueryInfo(val roomMemberSummary: RoomMemberSummary) : RoomBannedListMemberAction() data class UnBanUser(val roomMemberSummary: RoomMemberSummary) : RoomBannedListMemberAction() + data class Filter(val filter: String) : RoomBannedListMemberAction() } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedListMemberViewModel.kt b/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedListMemberViewModel.kt index b65b3de807..d1acd80c25 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedListMemberViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedListMemberViewModel.kt @@ -35,6 +35,7 @@ import org.matrix.android.sdk.internal.util.awaitCallback import org.matrix.android.sdk.rx.rx import org.matrix.android.sdk.rx.unwrap import im.vector.app.R +import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider import im.vector.app.features.powerlevel.PowerLevelsObservableFactory @@ -90,6 +91,15 @@ class RoomBannedListMemberViewModel @AssistedInject constructor(@Assisted initia when (action) { is RoomBannedListMemberAction.QueryInfo -> onQueryBanInfo(action.roomMemberSummary) is RoomBannedListMemberAction.UnBanUser -> unBanUser(action.roomMemberSummary) + is RoomBannedListMemberAction.Filter -> handleFilter(action) + }.exhaustive + } + + private fun handleFilter(action: RoomBannedListMemberAction.Filter) { + setState { + copy( + filter = action.filter + ) } } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedMemberListController.kt b/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedMemberListController.kt index a65a30441d..2a0c787a7a 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedMemberListController.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedMemberListController.kt @@ -17,8 +17,6 @@ package im.vector.app.features.roomprofile.banned import com.airbnb.epoxy.TypedEpoxyController -import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary -import org.matrix.android.sdk.api.util.toMatrixItem import im.vector.app.R import im.vector.app.core.epoxy.dividerItem import im.vector.app.core.epoxy.profiles.buildProfileSection @@ -28,11 +26,15 @@ import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.StringProvider import im.vector.app.core.ui.list.genericFooterItem import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.roomprofile.members.RoomMemberSummaryFilter +import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary +import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject class RoomBannedMemberListController @Inject constructor( private val avatarRenderer: AvatarRenderer, private val stringProvider: StringProvider, + private val roomMemberSummaryFilter: RoomMemberSummaryFilter, colorProvider: ColorProvider ) : TypedEpoxyController() { @@ -63,34 +65,37 @@ class RoomBannedMemberListController @Inject constructor( } else { buildProfileSection(quantityString) - bannedList.join( - each = { _, roomMember -> - val actionInProgress = data.onGoingModerationAction.contains(roomMember.userId) - profileMatrixItemWithProgress { - id(roomMember.userId) - matrixItem(roomMember.toMatrixItem()) - avatarRenderer(avatarRenderer) - apply { - if (actionInProgress) { - inProgress(true) - editable(false) - } else { - inProgress(false) - editable(true) - clickListener { _ -> - callback?.onUnbanClicked(roomMember) + roomMemberSummaryFilter.filter = data.filter + bannedList + .filter { roomMemberSummaryFilter.test(it) } + .join( + each = { _, roomMember -> + val actionInProgress = data.onGoingModerationAction.contains(roomMember.userId) + profileMatrixItemWithProgress { + id(roomMember.userId) + matrixItem(roomMember.toMatrixItem()) + avatarRenderer(avatarRenderer) + apply { + if (actionInProgress) { + inProgress(true) + editable(false) + } else { + inProgress(false) + editable(true) + clickListener { _ -> + callback?.onUnbanClicked(roomMember) + } + } } } + }, + between = { _, roomMemberBefore -> + dividerItem { + id("divider_${roomMemberBefore.userId}") + color(dividerColor) + } } - } - }, - between = { _, roomMemberBefore -> - dividerItem { - id("divider_${roomMemberBefore.userId}") - color(dividerColor) - } - } - ) + ) } } } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedMemberListFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedMemberListFragment.kt index 480deea6af..84eccccfae 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedMemberListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedMemberListFragment.kt @@ -19,6 +19,8 @@ package im.vector.app.features.roomprofile.banned import android.os.Bundle import android.view.View import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.SearchView +import androidx.core.view.isVisible import com.airbnb.mvrx.args import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState @@ -53,6 +55,7 @@ class RoomBannedMemberListFragment @Inject constructor( super.onViewCreated(view, savedInstanceState) roomMemberListController.callback = this setupToolbar(roomSettingsToolbar) + setupSearchView() recyclerView.configureWith(roomMemberListController, hasFixedSize = true) viewModel.observeViewEvents { @@ -84,6 +87,21 @@ class RoomBannedMemberListFragment @Inject constructor( super.onDestroyView() } + private fun setupSearchView() { + searchViewAppBarLayout.isVisible = true + searchView.queryHint = getString(R.string.search_banned_user_hint) + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + return true + } + + override fun onQueryTextChange(newText: String): Boolean { + viewModel.handle(RoomBannedListMemberAction.Filter(newText)) + return true + } + }) + } + override fun invalidate() = withState(viewModel) { viewState -> roomMemberListController.setData(viewState) renderRoomSummary(viewState) diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedMemberListViewState.kt b/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedMemberListViewState.kt index ee8e534b1f..6d8bb2f42a 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedMemberListViewState.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/banned/RoomBannedMemberListViewState.kt @@ -27,6 +27,7 @@ data class RoomBannedMemberListViewState( val roomId: String, val roomSummary: Async = Uninitialized, val bannedMemberSummaries: Async> = Uninitialized, + val filter: String = "", val onGoingModerationAction: List = emptyList(), val canUserBan: Boolean = false ) : MvRxState { diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListAction.kt b/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListAction.kt index c61dcb98f5..342a2e8585 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListAction.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListAction.kt @@ -20,4 +20,5 @@ import im.vector.app.core.platform.VectorViewModelAction sealed class RoomMemberListAction : VectorViewModelAction { data class RevokeThreePidInvite(val stateKey: String) : RoomMemberListAction() + data class FilterMemberList(val searchTerm: String) : RoomMemberListAction() } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListController.kt b/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListController.kt index 59b1bca26f..71ac7fcec4 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListController.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListController.kt @@ -17,12 +17,6 @@ package im.vector.app.features.roomprofile.members import com.airbnb.epoxy.TypedEpoxyController -import org.matrix.android.sdk.api.session.events.model.Event -import org.matrix.android.sdk.api.session.events.model.toModel -import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary -import org.matrix.android.sdk.api.session.room.model.RoomThirdPartyInviteContent -import org.matrix.android.sdk.api.util.MatrixItem -import org.matrix.android.sdk.api.util.toMatrixItem import im.vector.app.R import im.vector.app.core.epoxy.dividerItem import im.vector.app.core.epoxy.profiles.buildProfileSection @@ -31,17 +25,24 @@ import im.vector.app.core.extensions.join import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.StringProvider import im.vector.app.features.home.AvatarRenderer +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary +import org.matrix.android.sdk.api.session.room.model.RoomThirdPartyInviteContent +import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject class RoomMemberListController @Inject constructor( private val avatarRenderer: AvatarRenderer, private val stringProvider: StringProvider, + private val roomMemberSummaryFilter: RoomMemberSummaryFilter, colorProvider: ColorProvider ) : TypedEpoxyController() { interface Callback { fun onRoomMemberClicked(roomMember: RoomMemberSummary) - fun onThreePidInvites(event: Event) + fun onThreePidInviteClicked(event: Event) } private val dividerColor = colorProvider.getColorFromAttribute(R.attr.vctr_list_divider_color) @@ -53,17 +54,29 @@ class RoomMemberListController @Inject constructor( } override fun buildModels(data: RoomMemberListViewState?) { - val roomMembersByPowerLevel = data?.roomMemberSummaries?.invoke() ?: return - val threePidInvites = data.threePidInvites().orEmpty() + data ?: return + + roomMemberSummaryFilter.filter = data.filter + + val roomMembersByPowerLevel = data.roomMemberSummaries.invoke() ?: return + val threePidInvites = data.threePidInvites() + ?.filter { event -> + event.content.toModel() + ?.takeIf { + data.filter.isEmpty() || it.displayName.contains(data.filter, ignoreCase = true) + } != null + } + .orEmpty() var threePidInvitesDone = threePidInvites.isEmpty() for ((powerLevelCategory, roomMemberList) in roomMembersByPowerLevel) { - if (roomMemberList.isEmpty()) { + val filteredRoomMemberList = roomMemberList.filter { roomMemberSummaryFilter.test(it) } + if (filteredRoomMemberList.isEmpty()) { continue } if (powerLevelCategory == RoomMemberListCategories.USER && !threePidInvitesDone) { - // If there is not regular invite, display threepid invite before the regular user + // If there is no regular invite, display threepid invite before the regular user buildProfileSection( stringProvider.getString(RoomMemberListCategories.INVITE.titleRes) ) @@ -75,7 +88,7 @@ class RoomMemberListController @Inject constructor( buildProfileSection( stringProvider.getString(powerLevelCategory.titleRes) ) - roomMemberList.join( + filteredRoomMemberList.join( each = { _, roomMember -> profileMatrixItem { id(roomMember.userId) @@ -94,12 +107,13 @@ class RoomMemberListController @Inject constructor( } } ) - if (powerLevelCategory == RoomMemberListCategories.INVITE) { + if (powerLevelCategory == RoomMemberListCategories.INVITE && !threePidInvitesDone) { // Display the threepid invite after the regular invite dividerItem { id("divider_threepidinvites") color(dividerColor) } + buildThreePidInvites(data) threePidInvitesDone = true } @@ -128,7 +142,7 @@ class RoomMemberListController @Inject constructor( avatarRenderer(avatarRenderer) editable(data.actionsPermissions.canRevokeThreePidInvite) clickListener { _ -> - callback?.onThreePidInvites(event) + callback?.onThreePidInviteClicked(event) } } } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListFragment.kt index 46d0f35b95..67577e866e 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListFragment.kt @@ -21,6 +21,8 @@ import android.view.Menu import android.view.MenuItem import android.view.View import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.SearchView +import androidx.core.view.isVisible import com.airbnb.mvrx.args import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState @@ -72,12 +74,28 @@ class RoomMemberListFragment @Inject constructor( super.onViewCreated(view, savedInstanceState) roomMemberListController.callback = this setupToolbar(roomSettingsToolbar) + setupSearchView() recyclerView.configureWith(roomMemberListController, hasFixedSize = true) viewModel.selectSubscribe(this, RoomMemberListViewState::actionsPermissions) { invalidateOptionsMenu() } } + private fun setupSearchView() { + searchViewAppBarLayout.isVisible = true + searchView.queryHint = getString(R.string.search_members_hint) + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + return true + } + + override fun onQueryTextChange(newText: String): Boolean { + viewModel.handle(RoomMemberListAction.FilterMemberList(newText)) + return true + } + }) + } + override fun onDestroyView() { recyclerView.cleanup() super.onDestroyView() @@ -92,7 +110,7 @@ class RoomMemberListFragment @Inject constructor( navigator.openRoomMemberProfile(roomMember.userId, roomId = roomProfileArgs.roomId, context = requireActivity()) } - override fun onThreePidInvites(event: Event) { + override fun onThreePidInviteClicked(event: Event) { // Display a dialog to revoke invite if power level is high enough val content = event.content.toModel() ?: return val stateKey = event.stateKey ?: return diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListViewModel.kt b/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListViewModel.kt index fedf1729ad..9e402c675b 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListViewModel.kt @@ -188,6 +188,7 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState override fun handle(action: RoomMemberListAction) { when (action) { is RoomMemberListAction.RevokeThreePidInvite -> handleRevokeThreePidInvite(action) + is RoomMemberListAction.FilterMemberList -> handleFilterMemberList(action) }.exhaustive } @@ -201,4 +202,12 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState ) } } + + private fun handleFilterMemberList(action: RoomMemberListAction.FilterMemberList) { + setState { + copy( + filter = action.searchTerm + ) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListViewState.kt b/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListViewState.kt index 4d21aa103f..787ae920e1 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListViewState.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListViewState.kt @@ -31,6 +31,7 @@ data class RoomMemberListViewState( val roomId: String, val roomSummary: Async = Uninitialized, val roomMemberSummaries: Async = Uninitialized, + val filter: String = "", val threePidInvites: Async> = Uninitialized, val trustLevelMap: Async> = Uninitialized, val actionsPermissions: ActionPermissions = ActionPermissions() diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberSummaryFilter.kt b/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberSummaryFilter.kt new file mode 100644 index 0000000000..e2cc3f7b99 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberSummaryFilter.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2020 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.app.features.roomprofile.members + +import io.reactivex.functions.Predicate +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary +import javax.inject.Inject + +class RoomMemberSummaryFilter @Inject constructor() : Predicate { + var filter: String = "" + + override fun test(roomMemberSummary: RoomMemberSummary): Boolean { + if (filter.isEmpty()) { + // No filter + return true + } + + return roomMemberSummary.displayName?.contains(filter, ignoreCase = true).orFalse() + // We should maybe exclude the domain from the userId + || roomMemberSummary.userId.contains(filter, ignoreCase = true) + } +} diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsFragment.kt index b3f5047df4..876b585a59 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsFragment.kt @@ -23,23 +23,21 @@ import com.airbnb.mvrx.args import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState import com.google.android.material.tabs.TabLayoutMediator -import org.matrix.android.sdk.api.util.toMatrixItem import im.vector.app.R import im.vector.app.core.extensions.exhaustive import im.vector.app.core.intent.getMimeTypeFromUri import im.vector.app.core.platform.VectorBaseFragment -import im.vector.app.core.resources.StringProvider import im.vector.app.core.utils.saveMedia import im.vector.app.core.utils.shareMedia import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.notifications.NotificationUtils import im.vector.app.features.roomprofile.RoomProfileArgs import kotlinx.android.synthetic.main.fragment_room_uploads.* +import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject class RoomUploadsFragment @Inject constructor( private val viewModelFactory: RoomUploadsViewModel.Factory, - private val stringProvider: StringProvider, private val avatarRenderer: AvatarRenderer, private val notificationUtils: NotificationUtils ) : VectorBaseFragment(), RoomUploadsViewModel.Factory by viewModelFactory { @@ -58,8 +56,8 @@ class RoomUploadsFragment @Inject constructor( TabLayoutMediator(roomUploadsTabs, roomUploadsViewPager) { tab, position -> when (position) { - 0 -> tab.text = stringProvider.getString(R.string.uploads_media_title) - 1 -> tab.text = stringProvider.getString(R.string.uploads_files_title) + 0 -> tab.text = getString(R.string.uploads_media_title) + 1 -> tab.text = getString(R.string.uploads_files_title) } }.attach() @@ -70,7 +68,7 @@ class RoomUploadsFragment @Inject constructor( is RoomUploadsViewEvents.FileReadyForSharing -> { shareMedia(requireContext(), it.file, getMimeTypeFromUri(requireContext(), it.file.toUri())) } - is RoomUploadsViewEvents.FileReadyForSaving -> { + is RoomUploadsViewEvents.FileReadyForSaving -> { saveMedia( context = requireContext(), file = it.file, @@ -79,7 +77,7 @@ class RoomUploadsFragment @Inject constructor( notificationUtils = notificationUtils ) } - is RoomUploadsViewEvents.Failure -> showFailure(it.throwable) + is RoomUploadsViewEvents.Failure -> showFailure(it.throwable) }.exhaustive } } diff --git a/vector/src/main/res/drawable/ic_search_no_results.xml b/vector/src/main/res/drawable/ic_search_no_results.xml new file mode 100644 index 0000000000..24c0f00131 --- /dev/null +++ b/vector/src/main/res/drawable/ic_search_no_results.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/activity_search.xml b/vector/src/main/res/layout/activity_search.xml new file mode 100644 index 0000000000..2268bf932c --- /dev/null +++ b/vector/src/main/res/layout/activity_search.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/fragment_room_setting_generic.xml b/vector/src/main/res/layout/fragment_room_setting_generic.xml index aa86ee342b..07744436ea 100644 --- a/vector/src/main/res/layout/fragment_room_setting_generic.xml +++ b/vector/src/main/res/layout/fragment_room_setting_generic.xml @@ -53,16 +53,53 @@ - + app:layout_constraintTop_toBottomOf="@+id/roomSettingsToolbar"> + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_search.xml b/vector/src/main/res/layout/fragment_search.xml new file mode 100644 index 0000000000..330e70d86b --- /dev/null +++ b/vector/src/main/res/layout/fragment_search.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_search_result.xml b/vector/src/main/res/layout/item_search_result.xml new file mode 100644 index 0000000000..73f949f777 --- /dev/null +++ b/vector/src/main/res/layout/item_search_result.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/menu/menu_timeline.xml b/vector/src/main/res/menu/menu_timeline.xml index 6cdbbed424..e0566ccf6d 100644 --- a/vector/src/main/res/menu/menu_timeline.xml +++ b/vector/src/main/res/menu/menu_timeline.xml @@ -3,6 +3,11 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> + + Search Filter room members + Filter banned users No results ROOMS MESSAGES PEOPLE FILES + Searching in encrypted rooms is not supported yet. JOIN @@ -1850,11 +1852,11 @@ "IGNORE USER" "Content reported" - "This content was reported.\n\nIf you don't want to see any more content from this user, you can block him to hide his messages" + "This content was reported.\n\nIf you don't want to see any more content from this user, you can ignore them to hide their messages." "Reported as spam" - "This content was reported as spam.\n\nIf you don't want to see any more content from this user, you can block him to hide his messages" + "This content was reported as spam.\n\nIf you don't want to see any more content from this user, you can ignore them to hide their messages." "Reported as inappropriate" - "This content was reported as inappropriate.\n\nIf you don't want to see any more content from this user, you can block him to hide his messages" + "This content was reported as inappropriate.\n\nIf you don't want to see any more content from this user, you can ignore them to hide their messages." Element needs permission to save your E2E keys on disk.\n\nPlease allow access on the next pop-up to be able to export your keys manually.