diff --git a/CHANGES.md b/CHANGES.md index 819e98284f..56d38667fe 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -16,9 +16,11 @@ Improvements 🙌: - Room member profile: Add action to create (or open) a DM (#2310) - Prepare changelog for F-Droid (#2296) - Add graphic resources for F-Droid (#812, #2220) + - Highlight text in the body of the displayed result (#2200) Bugfix 🐛: - Messages encrypted with no way to decrypt after SDK update from 0.18 to 1.0.0 (#2252) + - Search Result | scroll jumps after pagination (#2238) Translations 🗣: - 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 index 10dc9254d8..201e9a4f82 100644 --- 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 @@ -52,8 +52,6 @@ class SearchFragment @Inject constructor( 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?) { @@ -70,12 +68,6 @@ class SearchFragment @Inject constructor( searchResultRecycler.configureWith(controller, showDivider = false) (searchResultRecycler.layoutManager as? LinearLayoutManager)?.stackFromEnd = true controller.listener = this - - controller.addModelBuildListener { - pendingScrollToPosition?.let { - searchResultRecycler.smoothScrollToPosition(it) - } - } } override fun onDestroy() { @@ -100,10 +92,8 @@ class SearchFragment @Inject constructor( } } } else { - pendingScrollToPosition = (state.lastBatchSize - 1).coerceAtLeast(0) - - stateView.state = StateView.State.Content controller.setData(state) + stateView.state = StateView.State.Content } } 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 index c917c4557d..b927fb5ff3 100644 --- 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 @@ -16,16 +16,24 @@ package im.vector.app.features.home.room.detail.search +import android.graphics.Typeface +import android.text.Spannable +import android.text.SpannableString +import android.text.style.StyleSpan +import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.TypedEpoxyController import com.airbnb.epoxy.VisibilityState +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.loadingItem -import im.vector.app.core.ui.list.genericItemHeader +import im.vector.app.core.epoxy.noResultItem +import im.vector.app.core.resources.StringProvider +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.Content 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 @@ -33,6 +41,7 @@ import javax.inject.Inject class SearchResultController @Inject constructor( private val session: Session, private val avatarRenderer: AvatarRenderer, + private val stringProvider: StringProvider, private val dateFormatter: VectorDateFormatter ) : TypedEpoxyController() { @@ -52,6 +61,8 @@ class SearchResultController @Inject constructor( override fun buildModels(data: SearchViewState?) { data ?: return + val searchItems = buildSearchResultItems(data) + if (data.hasMoreResult) { loadingItem { // Always use a different id, because we can be notified several times of visibility state changed @@ -62,35 +73,85 @@ class SearchResultController @Inject constructor( } } } + } else { + if (searchItems.isEmpty()) { + // All returned results by the server has been filtered out and there is no more result + noResultItem { + id("noResult") + text(stringProvider.getString(R.string.no_result_placeholder)) + } + } else { + noResultItem { + id("noMoreResult") + text(stringProvider.getString(R.string.no_more_results)) + } + } } - buildSearchResultItems(data.searchResult) + searchItems.forEach { add(it) } } - private fun buildSearchResultItems(events: List) { + /** + * @return the list of EpoxyModel (date items and search result items), or an empty list if all items have been filtered out + */ + private fun buildSearchResultItems(data: SearchViewState): List> { var lastDate: Calendar? = null + val result = mutableListOf>() + + data.searchResult.forEach { eventAndSender -> + val event = eventAndSender.event + + @Suppress("UNCHECKED_CAST") + // Take new content first + val text = ((event.content?.get("m.new_content") as? Content) ?: event.content)?.get("body") as? String ?: return@forEach + val spannable = setHighLightedText(text, data.highlights) ?: return@forEach - 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)) - } + GenericItemHeader_() + .id(eventDate.hashCode()) + .text(dateFormatter.format(eventDate.timeInMillis, DateFormatKind.EDIT_HISTORY_HEADER)) + .let { result.add(it) } } 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) } + SearchResultItem_() + .id(eventAndSender.event.eventId) + .avatarRenderer(avatarRenderer) + .formattedDate(dateFormatter.format(event.originServerTs, DateFormatKind.MESSAGE_SIMPLE)) + .spannable(spannable) + .sender(eventAndSender.sender + ?: eventAndSender.event.senderId?.let { session.getUser(it) }?.toMatrixItem()) + .listener { listener?.onItemClicked(eventAndSender.event) } + .let { result.add(it) } + } + + return result + } + + /** + * Highlight the text. If the text is not found, return null to ignore this result + * See https://github.com/matrix-org/synapse/issues/8686 + */ + private fun setHighLightedText(text: String, highlights: List): Spannable? { + val wordToSpan: Spannable = SpannableString(text) + var found = false + highlights.forEach { highlight -> + var searchFromIndex = 0 + while (searchFromIndex < text.length) { + val indexOfHighlight = text.indexOf(highlight, searchFromIndex, ignoreCase = true) + searchFromIndex = if (indexOfHighlight == -1) { + Integer.MAX_VALUE + } else { + // bold + found = true + wordToSpan.setSpan(StyleSpan(Typeface.BOLD), indexOfHighlight, indexOfHighlight + highlight.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + indexOfHighlight + 1 + } } } + return wordToSpan.takeIf { found } } } 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 index 10407c64e0..a3e5983c3a 100644 --- 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 @@ -21,23 +21,20 @@ 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 formattedDate: String? = null + @EpoxyAttribute lateinit var spannable: CharSequence @EpoxyAttribute var sender: MatrixItem? = null @EpoxyAttribute var listener: ClickListener? = null @@ -47,9 +44,8 @@ abstract class SearchResultItem : VectorEpoxyModel() { 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 + holder.timeView.text = formattedDate + holder.contentView.text = spannable } class Holder : VectorEpoxyHolder() { 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 index f61bcbd029..ab440f6b5f 100644 --- 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 @@ -145,6 +145,7 @@ class SearchViewModel @AssistedInject constructor( setState { copy( searchResult = accumulatedResult, + highlights = searchResult.highlights.orEmpty(), hasMoreResult = !nextBatch.isNullOrEmpty(), lastBatchSize = searchResult.results.orEmpty().size, asyncSearchRequest = Success(Unit) 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 index 9f700b6e31..41fecbb5e2 100644 --- 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 @@ -24,6 +24,7 @@ import org.matrix.android.sdk.api.session.search.EventAndSender data class SearchViewState( // Accumulated search result val searchResult: List = emptyList(), + val highlights: List = emptyList(), val hasMoreResult: Boolean = false, // Last batch size, will help RecyclerView to position itself val lastBatchSize: Int = 0, diff --git a/vector/src/main/res/layout/fragment_search.xml b/vector/src/main/res/layout/fragment_search.xml index 330e70d86b..84547a4355 100644 --- a/vector/src/main/res/layout/fragment_search.xml +++ b/vector/src/main/res/layout/fragment_search.xml @@ -8,8 +8,10 @@ \ No newline at end of file diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index c5a11d5ab4..45d9d40ba6 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -173,6 +173,7 @@ No conversations You didn’t allow Element to access your local contacts No results + No more results No identity server configured.