瀏覽代碼

Implement global message search

Signed-off-by: Álvaro Brey <alvaro.brey@nextcloud.com>
Álvaro Brey 2 年之前
父節點
當前提交
1d632f3c96
共有 20 個文件被更改,包括 1021 次插入37 次删除
  1. 79 0
      app/src/main/java/com/nextcloud/talk/adapters/items/LoadMoreResultsItem.kt
  2. 115 0
      app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt
  3. 36 0
      app/src/main/java/com/nextcloud/talk/adapters/items/MessagesTextHeaderItem.kt
  4. 10 0
      app/src/main/java/com/nextcloud/talk/api/NcApi.java
  5. 162 36
      app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java
  6. 99 0
      app/src/main/java/com/nextcloud/talk/controllers/util/MessageSearchHelper.kt
  7. 7 0
      app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt
  8. 31 0
      app/src/main/java/com/nextcloud/talk/models/domain/SearchMessageEntry.kt
  9. 48 0
      app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchEntry.kt
  10. 38 0
      app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchOCS.kt
  11. 35 0
      app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchOverall.kt
  12. 43 0
      app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchResponseData.kt
  13. 26 0
      app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepository.kt
  14. 83 0
      app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepositoryImpl.kt
  15. 6 1
      app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java
  16. 34 0
      app/src/main/java/com/nextcloud/talk/utils/Debouncer.kt
  17. 25 0
      app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.java
  18. 62 0
      app/src/main/res/layout/rv_item_load_more.xml
  19. 80 0
      app/src/main/res/layout/rv_item_search_message.xml
  20. 2 0
      app/src/main/res/values/strings.xml

+ 79 - 0
app/src/main/java/com/nextcloud/talk/adapters/items/LoadMoreResultsItem.kt

@@ -0,0 +1,79 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Álvaro Brey
+ * Copyright (C) 2022 Álvaro Brey
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.talk.adapters.items
+
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import com.nextcloud.talk.R
+import com.nextcloud.talk.databinding.RvItemLoadMoreBinding
+import eu.davidea.flexibleadapter.FlexibleAdapter
+import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
+import eu.davidea.flexibleadapter.items.IFilterable
+import eu.davidea.flexibleadapter.items.IFlexible
+import eu.davidea.viewholders.FlexibleViewHolder
+
+object LoadMoreResultsItem :
+    AbstractFlexibleItem<LoadMoreResultsItem.ViewHolder>(),
+    IFilterable<String> {
+
+    // layout is used as view type for uniqueness
+    const val VIEW_TYPE: Int = R.layout.rv_item_load_more
+
+    class ViewHolder(view: View, adapter: FlexibleAdapter<*>) :
+        FlexibleViewHolder(view, adapter) {
+        var binding: RvItemLoadMoreBinding
+
+        init {
+            binding = RvItemLoadMoreBinding.bind(view)
+        }
+    }
+
+    override fun getLayoutRes(): Int = R.layout.rv_item_load_more
+
+    override fun createViewHolder(
+        view: View,
+        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>
+    ): ViewHolder = ViewHolder(view, adapter)
+
+    override fun bindViewHolder(
+        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
+        holder: ViewHolder,
+        position: Int,
+        payloads: MutableList<Any>?
+    ) {
+        // nothing, it's immutable
+    }
+
+    override fun filter(constraint: String?): Boolean = true
+
+    override fun getItemViewType(): Int {
+        return VIEW_TYPE
+    }
+
+    override fun equals(other: Any?): Boolean {
+        return other is LoadMoreResultsItem
+    }
+
+    override fun hashCode(): Int {
+        return 0
+    }
+}

+ 115 - 0
app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt

@@ -0,0 +1,115 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Álvaro Brey
+ * Copyright (C) 2022 Álvaro Brey
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.talk.adapters.items
+
+import android.content.Context
+import android.text.SpannableString
+import android.view.View
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import com.nextcloud.talk.R
+import com.nextcloud.talk.databinding.RvItemSearchMessageBinding
+import com.nextcloud.talk.models.database.UserEntity
+import com.nextcloud.talk.models.domain.SearchMessageEntry
+import com.nextcloud.talk.utils.DisplayUtils
+import eu.davidea.flexibleadapter.FlexibleAdapter
+import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
+import eu.davidea.flexibleadapter.items.IFilterable
+import eu.davidea.flexibleadapter.items.IFlexible
+import eu.davidea.flexibleadapter.items.ISectionable
+import eu.davidea.viewholders.FlexibleViewHolder
+
+data class MessageResultItem constructor(
+    private val context: Context,
+    private val currentUser: UserEntity,
+    val messageEntry: SearchMessageEntry,
+    private val showHeader: Boolean
+) :
+    AbstractFlexibleItem<MessageResultItem.ViewHolder>(),
+    IFilterable<String>,
+    ISectionable<MessageResultItem.ViewHolder, GenericTextHeaderItem> {
+
+    class ViewHolder(view: View, adapter: FlexibleAdapter<*>) :
+        FlexibleViewHolder(view, adapter) {
+        var binding: RvItemSearchMessageBinding
+
+        init {
+            binding = RvItemSearchMessageBinding.bind(view)
+        }
+    }
+
+    override fun getLayoutRes(): Int = R.layout.rv_item_search_message
+
+    override fun createViewHolder(
+        view: View,
+        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>
+    ): ViewHolder = ViewHolder(view, adapter)
+
+    override fun bindViewHolder(
+        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
+        holder: ViewHolder,
+        position: Int,
+        payloads: MutableList<Any>?
+    ) {
+        holder.binding.conversationTitle.text = messageEntry.title
+        bindMessageExcerpt(holder)
+        loadImage(holder)
+    }
+
+    private fun bindMessageExcerpt(holder: ViewHolder) {
+        val messageSpannable = SpannableString(messageEntry.messageExcerpt)
+        val highlightColor = ContextCompat.getColor(context, R.color.colorPrimary)
+        val highlightedSpan = DisplayUtils.searchAndColor(messageSpannable, messageEntry.searchTerm, highlightColor)
+        holder.binding.messageExcerpt.text = highlightedSpan
+    }
+
+    private fun loadImage(holder: ViewHolder) {
+        DisplayUtils.loadAvatarPlaceholder(holder.binding.thumbnail)
+        if (messageEntry.thumbnailURL != null) {
+            val imageRequest = DisplayUtils.getImageRequestForUrl(
+                messageEntry.thumbnailURL,
+                currentUser
+            )
+            DisplayUtils.loadImage(holder.binding.thumbnail, imageRequest)
+        }
+    }
+
+    override fun filter(constraint: String?): Boolean = true
+
+    override fun getItemViewType(): Int {
+        return VIEW_TYPE
+    }
+
+    companion object {
+        // layout is used as view type for uniqueness
+        const val VIEW_TYPE: Int = R.layout.rv_item_search_message
+    }
+
+    override fun getHeader(): GenericTextHeaderItem = MessagesTextHeaderItem(context)
+        .apply {
+            isHidden = showHeader // FlexibleAdapter needs this hack for some reason
+        }
+
+    override fun setHeader(header: GenericTextHeaderItem?) {
+        // nothing, header is always the same
+    }
+}

+ 36 - 0
app/src/main/java/com/nextcloud/talk/adapters/items/MessagesTextHeaderItem.kt

@@ -0,0 +1,36 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Álvaro Brey
+ * Copyright (C) 2022 Álvaro Brey
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.talk.adapters.items
+
+import android.content.Context
+import com.nextcloud.talk.R
+
+class MessagesTextHeaderItem(context: Context) : GenericTextHeaderItem(context.getString(R.string.messages)) {
+    companion object {
+        /**
+         * "Random" value, just has to be different than other view types
+         */
+        const val VIEW_TYPE = 1120391230
+    }
+
+    override fun getItemViewType(): Int = VIEW_TYPE
+}

+ 10 - 0
app/src/main/java/com/nextcloud/talk/api/NcApi.java

@@ -45,6 +45,7 @@ import com.nextcloud.talk.models.json.signaling.SignalingOverall;
 import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOverall;
 import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOverall;
 import com.nextcloud.talk.models.json.status.StatusOverall;
 import com.nextcloud.talk.models.json.status.StatusOverall;
 import com.nextcloud.talk.models.json.statuses.StatusesOverall;
 import com.nextcloud.talk.models.json.statuses.StatusesOverall;
+import com.nextcloud.talk.models.json.unifiedsearch.UnifiedSearchOverall;
 import com.nextcloud.talk.models.json.userprofile.UserProfileFieldsOverall;
 import com.nextcloud.talk.models.json.userprofile.UserProfileFieldsOverall;
 import com.nextcloud.talk.models.json.userprofile.UserProfileOverall;
 import com.nextcloud.talk.models.json.userprofile.UserProfileOverall;
 
 
@@ -519,4 +520,13 @@ public interface NcApi {
     Observable<ReactionsOverall> getReactions(@Header("Authorization") String authorization,
     Observable<ReactionsOverall> getReactions(@Header("Authorization") String authorization,
                                               @Url String url,
                                               @Url String url,
                                               @Query("reaction") String reaction);
                                               @Query("reaction") String reaction);
+
+    // TODO use path params instead of passing URL
+    @GET
+    Observable<UnifiedSearchOverall> performUnifiedSearch(@Header("Authorization") String authorization,
+                                                          @Url String url,
+                                                          @Query("term") String term,
+                                                          @Query("from") String fromUrl,
+                                                          @Query("limit") Integer limit,
+                                                          @Query("cursor") Integer cursor);
 }
 }

+ 162 - 36
app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java

@@ -68,9 +68,13 @@ import com.nextcloud.talk.R;
 import com.nextcloud.talk.activities.MainActivity;
 import com.nextcloud.talk.activities.MainActivity;
 import com.nextcloud.talk.adapters.items.ConversationItem;
 import com.nextcloud.talk.adapters.items.ConversationItem;
 import com.nextcloud.talk.adapters.items.GenericTextHeaderItem;
 import com.nextcloud.talk.adapters.items.GenericTextHeaderItem;
+import com.nextcloud.talk.adapters.items.LoadMoreResultsItem;
+import com.nextcloud.talk.adapters.items.MessageResultItem;
+import com.nextcloud.talk.adapters.items.MessagesTextHeaderItem;
 import com.nextcloud.talk.api.NcApi;
 import com.nextcloud.talk.api.NcApi;
 import com.nextcloud.talk.application.NextcloudTalkApplication;
 import com.nextcloud.talk.application.NextcloudTalkApplication;
 import com.nextcloud.talk.controllers.base.BaseController;
 import com.nextcloud.talk.controllers.base.BaseController;
+import com.nextcloud.talk.controllers.util.MessageSearchHelper;
 import com.nextcloud.talk.events.ConversationsListFetchDataEvent;
 import com.nextcloud.talk.events.ConversationsListFetchDataEvent;
 import com.nextcloud.talk.events.EventStatus;
 import com.nextcloud.talk.events.EventStatus;
 import com.nextcloud.talk.interfaces.ConversationMenuInterface;
 import com.nextcloud.talk.interfaces.ConversationMenuInterface;
@@ -80,15 +84,18 @@ import com.nextcloud.talk.jobs.DeleteConversationWorker;
 import com.nextcloud.talk.jobs.UploadAndShareFilesWorker;
 import com.nextcloud.talk.jobs.UploadAndShareFilesWorker;
 import com.nextcloud.talk.models.database.CapabilitiesUtil;
 import com.nextcloud.talk.models.database.CapabilitiesUtil;
 import com.nextcloud.talk.models.database.UserEntity;
 import com.nextcloud.talk.models.database.UserEntity;
+import com.nextcloud.talk.models.domain.SearchMessageEntry;
 import com.nextcloud.talk.models.json.conversations.Conversation;
 import com.nextcloud.talk.models.json.conversations.Conversation;
 import com.nextcloud.talk.models.json.status.Status;
 import com.nextcloud.talk.models.json.status.Status;
 import com.nextcloud.talk.models.json.statuses.StatusesOverall;
 import com.nextcloud.talk.models.json.statuses.StatusesOverall;
+import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository;
 import com.nextcloud.talk.ui.dialog.ChooseAccountDialogFragment;
 import com.nextcloud.talk.ui.dialog.ChooseAccountDialogFragment;
 import com.nextcloud.talk.ui.dialog.ConversationsListBottomDialog;
 import com.nextcloud.talk.ui.dialog.ConversationsListBottomDialog;
 import com.nextcloud.talk.utils.ApiUtils;
 import com.nextcloud.talk.utils.ApiUtils;
 import com.nextcloud.talk.utils.AttendeePermissionsUtil;
 import com.nextcloud.talk.utils.AttendeePermissionsUtil;
 import com.nextcloud.talk.utils.ClosedInterfaceImpl;
 import com.nextcloud.talk.utils.ClosedInterfaceImpl;
 import com.nextcloud.talk.utils.ConductorRemapping;
 import com.nextcloud.talk.utils.ConductorRemapping;
+import com.nextcloud.talk.utils.Debouncer;
 import com.nextcloud.talk.utils.DisplayUtils;
 import com.nextcloud.talk.utils.DisplayUtils;
 import com.nextcloud.talk.utils.UriUtils;
 import com.nextcloud.talk.utils.UriUtils;
 import com.nextcloud.talk.utils.bundle.BundleKeys;
 import com.nextcloud.talk.utils.bundle.BundleKeys;
@@ -131,6 +138,8 @@ import butterknife.BindView;
 import eu.davidea.flexibleadapter.FlexibleAdapter;
 import eu.davidea.flexibleadapter.FlexibleAdapter;
 import eu.davidea.flexibleadapter.common.SmoothScrollLinearLayoutManager;
 import eu.davidea.flexibleadapter.common.SmoothScrollLinearLayoutManager;
 import eu.davidea.flexibleadapter.items.AbstractFlexibleItem;
 import eu.davidea.flexibleadapter.items.AbstractFlexibleItem;
+import eu.davidea.flexibleadapter.items.IHeader;
+import io.reactivex.Observable;
 import io.reactivex.Observer;
 import io.reactivex.Observer;
 import io.reactivex.android.schedulers.AndroidSchedulers;
 import io.reactivex.android.schedulers.AndroidSchedulers;
 import io.reactivex.disposables.Disposable;
 import io.reactivex.disposables.Disposable;
@@ -145,6 +154,10 @@ public class ConversationsListController extends BaseController implements Searc
     public static final int ID_DELETE_CONVERSATION_DIALOG = 0;
     public static final int ID_DELETE_CONVERSATION_DIALOG = 0;
     public static final int UNREAD_BUBBLE_DELAY = 2500;
     public static final int UNREAD_BUBBLE_DELAY = 2500;
     private static final String KEY_SEARCH_QUERY = "ContactsController.searchQuery";
     private static final String KEY_SEARCH_QUERY = "ContactsController.searchQuery";
+
+    public static final int SEARCH_DEBOUNCE_INTERVAL_MS = 300;
+    public static final int SEARCH_MIN_CHARS = 2;
+
     private final Bundle bundle;
     private final Bundle bundle;
     @Inject
     @Inject
     UserUtils userUtils;
     UserUtils userUtils;
@@ -161,6 +174,9 @@ public class ConversationsListController extends BaseController implements Searc
     @Inject
     @Inject
     AppPreferences appPreferences;
     AppPreferences appPreferences;
 
 
+    @Inject
+    UnifiedSearchRepository unifiedSearchRepository;
+
     @BindView(R.id.recycler_view)
     @BindView(R.id.recycler_view)
     RecyclerView recyclerView;
     RecyclerView recyclerView;
 
 
@@ -220,6 +236,10 @@ public class ConversationsListController extends BaseController implements Searc
 
 
     private HashMap<String, Status> userStatuses = new HashMap<>();
     private HashMap<String, Status> userStatuses = new HashMap<>();
 
 
+    private Debouncer searchDebouncer = new Debouncer(SEARCH_DEBOUNCE_INTERVAL_MS);
+
+    private MessageSearchHelper searchHelper;
+
     public ConversationsListController(Bundle bundle) {
     public ConversationsListController(Bundle bundle) {
         super();
         super();
         setHasOptionsMenu(true);
         setHasOptionsMenu(true);
@@ -306,6 +326,8 @@ public class ConversationsListController extends BaseController implements Searc
                 return;
                 return;
             }
             }
 
 
+            searchHelper = new MessageSearchHelper(currentUser, unifiedSearchRepository);
+
             credentials = ApiUtils.getCredentials(currentUser.getUsername(), currentUser.getToken());
             credentials = ApiUtils.getCredentials(currentUser.getUsername(), currentUser.getToken());
             if (getActivity() != null && getActivity() instanceof MainActivity) {
             if (getActivity() != null && getActivity() instanceof MainActivity) {
                 loadUserAvatar(((MainActivity) getActivity()).binding.switchAccountButton);
                 loadUserAvatar(((MainActivity) getActivity()).binding.switchAccountButton);
@@ -419,6 +441,11 @@ public class ConversationsListController extends BaseController implements Searc
                     adapter.setHeadersShown(false);
                     adapter.setHeadersShown(false);
                     adapter.updateDataSet(conversationItems, false);
                     adapter.updateDataSet(conversationItems, false);
                     adapter.hideAllHeaders();
                     adapter.hideAllHeaders();
+                    if (searchHelper != null) {
+                        // cancel any pending searches
+                        searchHelper.cancelSearch();
+                        swipeRefreshLayout.setRefreshing(false);
+                    }
                     if (swipeRefreshLayout != null) {
                     if (swipeRefreshLayout != null) {
                         swipeRefreshLayout.setEnabled(true);
                         swipeRefreshLayout.setEnabled(true);
                     }
                     }
@@ -427,8 +454,8 @@ public class ConversationsListController extends BaseController implements Searc
                     MainActivity activity = (MainActivity) getActivity();
                     MainActivity activity = (MainActivity) getActivity();
                     if (activity != null) {
                     if (activity != null) {
                         activity.binding.appBar.setStateListAnimator(AnimatorInflater.loadStateListAnimator(
                         activity.binding.appBar.setStateListAnimator(AnimatorInflater.loadStateListAnimator(
-                            activity.binding.appBar.getContext(),
-                            R.animator.appbar_elevation_off)
+                                                                         activity.binding.appBar.getContext(),
+                                                                         R.animator.appbar_elevation_off)
                                                                     );
                                                                     );
                         activity.binding.toolbar.setVisibility(View.GONE);
                         activity.binding.toolbar.setVisibility(View.GONE);
                         activity.binding.searchToolbar.setVisibility(View.VISIBLE);
                         activity.binding.searchToolbar.setVisibility(View.VISIBLE);
@@ -845,20 +872,73 @@ public class ConversationsListController extends BaseController implements Searc
     }
     }
 
 
     @Override
     @Override
-    public boolean onQueryTextChange(String newText) {
-        if (adapter.hasNewFilter(newText) || !TextUtils.isEmpty(searchQuery)) {
-            if (!TextUtils.isEmpty(searchQuery)) {
-                adapter.setFilter(searchQuery);
-                searchQuery = "";
-                adapter.filterItems();
-            } else {
-                adapter.setFilter(newText);
-                adapter.filterItems(300);
-            }
+    public boolean onQueryTextChange(final String newText) {
+        if (!TextUtils.isEmpty(searchQuery)) {
+            final String filter = searchQuery;
+            searchQuery = "";
+            performFilterAndSearch(filter);
+        } else if (adapter.hasNewFilter(newText)) {
+            new Handler();
+            searchDebouncer.debounce(() -> {
+                performFilterAndSearch(newText);
+            });
         }
         }
         return true;
         return true;
     }
     }
 
 
+    private void performFilterAndSearch(String filter) {
+        if (filter.length() >= SEARCH_MIN_CHARS) {
+            clearMessageSearchResults();
+            adapter.setFilter(filter);
+            adapter.filterItems();
+            startMessageSearch(filter);
+        } else {
+            resetSearchResults();
+        }
+    }
+
+    private void resetSearchResults() {
+        clearMessageSearchResults();
+        adapter.setFilter("");
+        adapter.filterItems();
+    }
+
+    private void clearMessageSearchResults() {
+        final IHeader firstHeader = adapter.getSectionHeader(0);
+        if (firstHeader != null && firstHeader.getItemViewType() == MessagesTextHeaderItem.VIEW_TYPE) {
+            adapter.removeSection(firstHeader);
+        } else {
+            adapter.removeItemsOfType(MessageResultItem.VIEW_TYPE);
+        }
+        adapter.removeItemsOfType(LoadMoreResultsItem.VIEW_TYPE);
+    }
+
+    @SuppressLint("CheckResult") // handled by helper
+    private void startMessageSearch(final String search) {
+        swipeRefreshLayout.setRefreshing(true);
+        searchHelper
+            .startMessageSearch(search)
+            .subscribeOn(Schedulers.io())
+            .observeOn(AndroidSchedulers.mainThread())
+            .subscribe(
+                this::onMessageSearchResult,
+                this::onMessageSearchError);
+    }
+
+    @SuppressLint("CheckResult") // handled by helper
+    private void loadMoreMessages() {
+        swipeRefreshLayout.setRefreshing(true);
+        final Observable<MessageSearchHelper.MessageSearchResults> observable = searchHelper.loadMore();
+        if (observable != null) {
+            observable
+                .observeOn(AndroidSchedulers.mainThread())
+                .subscribe(
+                    this::onMessageSearchResult,
+                    this::onMessageSearchError);
+        }
+    }
+
+
     @Override
     @Override
     public boolean onQueryTextSubmit(String query) {
     public boolean onQueryTextSubmit(String query) {
         return onQueryTextChange(query);
         return onQueryTextChange(query);
@@ -871,36 +951,57 @@ public class ConversationsListController extends BaseController implements Searc
 
 
     @Override
     @Override
     public boolean onItemClick(View view, int position) {
     public boolean onItemClick(View view, int position) {
-        try {
-            selectedConversation = ((ConversationItem) Objects.requireNonNull(adapter.getItem(position))).getModel();
+        final AbstractFlexibleItem item = adapter.getItem(position);
+        if (item instanceof ConversationItem) {
+            showConversation(((ConversationItem) Objects.requireNonNull(item)).getModel());
+        } else if (item instanceof MessageResultItem) {
+            MessageResultItem messageItem = (MessageResultItem) item;
+            String conversationToken = messageItem.getMessageEntry().getConversationToken();
+            showConversationByToken(conversationToken);
+        } else if (item instanceof LoadMoreResultsItem) {
+            loadMoreMessages();
+        }
 
 
-            if (selectedConversation != null && getActivity() != null) {
-                boolean hasChatPermission =
-                    new AttendeePermissionsUtil(selectedConversation.getPermissions()).hasChatPermission(currentUser);
+        return true;
+    }
 
 
-                if (showShareToScreen) {
-                    if (hasChatPermission && !isReadOnlyConversation(selectedConversation)) {
-                        handleSharedData();
-                        showShareToScreen = false;
-                    } else {
-                        Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show();
-                    }
-                } else if (forwardMessage) {
-                    if (hasChatPermission && !isReadOnlyConversation(selectedConversation)) {
-                        openConversation(bundle.getString(BundleKeys.INSTANCE.getKEY_FORWARD_MSG_TEXT()));
-                        forwardMessage = false;
-                    } else {
-                        Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show();
-                    }
+    private void showConversationByToken(String conversationToken) {
+        Conversation conversation = null;
+        for (AbstractFlexibleItem absItem : conversationItems) {
+            ConversationItem conversationItem = ((ConversationItem) absItem);
+            if (conversationItem.getModel().getToken().equals(conversationToken)) {
+                conversation = conversationItem.getModel();
+            }
+        }
+        if (conversation != null) {
+            showConversation(conversation);
+        }
+    }
+
+    private void showConversation(@Nullable final Conversation conversation) {
+        selectedConversation = conversation;
+        if (selectedConversation != null && getActivity() != null) {
+            boolean hasChatPermission =
+                new AttendeePermissionsUtil(selectedConversation.getPermissions()).hasChatPermission(currentUser);
+
+            if (showShareToScreen) {
+                if (hasChatPermission && !isReadOnlyConversation(selectedConversation)) {
+                    handleSharedData();
+                    showShareToScreen = false;
+                } else {
+                    Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show();
+                }
+            } else if (forwardMessage) {
+                if (hasChatPermission && !isReadOnlyConversation(selectedConversation)) {
+                    openConversation(bundle.getString(BundleKeys.INSTANCE.getKEY_FORWARD_MSG_TEXT()));
+                    forwardMessage = false;
                 } else {
                 } else {
-                    openConversation();
+                    Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show();
                 }
                 }
+            } else {
+                openConversation();
             }
             }
-        } catch (ClassCastException e) {
-            Log.w(TAG, "failed to cast clicked item to ConversationItem. Most probably a heading was clicked. This is" +
-                " just ignored.", e);
         }
         }
-        return true;
     }
     }
 
 
     private Boolean isReadOnlyConversation(Conversation conversation) {
     private Boolean isReadOnlyConversation(Conversation conversation) {
@@ -1274,4 +1375,29 @@ public class ConversationsListController extends BaseController implements Searc
     public AppBarLayoutType getAppBarLayoutType() {
     public AppBarLayoutType getAppBarLayoutType() {
         return AppBarLayoutType.SEARCH_BAR;
         return AppBarLayoutType.SEARCH_BAR;
     }
     }
+
+    public void onMessageSearchResult(@NonNull MessageSearchHelper.MessageSearchResults results) {
+        if (searchView.getQuery().length() > 0) {
+            clearMessageSearchResults();
+            final List<SearchMessageEntry> entries = results.getMessages();
+            if (entries.size() > 0) {
+                List<AbstractFlexibleItem> adapterItems = new ArrayList<>();
+                for (int i = 0; i < entries.size(); i++) {
+                    final boolean showHeader = i == 0;
+                    adapterItems.add(new MessageResultItem(context, currentUser, entries.get(i), showHeader));
+                }
+                if (results.getHasMore()) {
+                    adapterItems.add(LoadMoreResultsItem.INSTANCE);
+                }
+                adapter.addItems(0, adapterItems);
+                recyclerView.scrollToPosition(0);
+            }
+        }
+        swipeRefreshLayout.setRefreshing(false);
+    }
+
+    public void onMessageSearchError(@NonNull Throwable throwable) {
+        handleHttpExceptions(throwable);
+        swipeRefreshLayout.setRefreshing(false);
+    }
 }
 }

+ 99 - 0
app/src/main/java/com/nextcloud/talk/controllers/util/MessageSearchHelper.kt

@@ -0,0 +1,99 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Álvaro Brey
+ * Copyright (C) 2022 Álvaro Brey
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.talk.controllers.util
+
+import android.util.Log
+import com.nextcloud.talk.models.database.UserEntity
+import com.nextcloud.talk.models.domain.SearchMessageEntry
+import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository
+import io.reactivex.Observable
+import io.reactivex.disposables.Disposable
+
+class MessageSearchHelper(
+    private val user: UserEntity,
+    private val unifiedSearchRepository: UnifiedSearchRepository,
+) {
+
+    data class MessageSearchResults(val messages: List<SearchMessageEntry>, val hasMore: Boolean)
+
+    private var unifiedSearchDisposable: Disposable? = null
+    private var previousSearch: String? = null
+    private var previousCursor: Int = 0
+    private var previousResults: List<SearchMessageEntry> = emptyList()
+
+    fun startMessageSearch(search: String): Observable<MessageSearchResults> {
+        return doSearch(search)
+    }
+
+    fun loadMore(): Observable<MessageSearchResults>? {
+        previousSearch?.let {
+            return doSearch(it, previousCursor)
+        }
+        return null
+    }
+
+    fun cancelSearch() {
+        disposeIfPossible()
+    }
+
+    private fun doSearch(search: String, cursor: Int = 0): Observable<MessageSearchResults> {
+        resetResultsIfNeeded(search)
+        disposeIfPossible()
+        return unifiedSearchRepository.searchMessages(user, search, cursor)
+            .map { results ->
+                previousSearch = search
+                previousCursor = results.cursor
+                previousResults = previousResults + results.entries
+                MessageSearchResults(previousResults, results.hasMore)
+            }
+            .doOnSubscribe {
+                unifiedSearchDisposable = it
+            }
+            .doOnError { throwable ->
+                Log.e(TAG, "message search - ERROR", throwable)
+                resetCachedData()
+                disposeIfPossible()
+            }
+            .doOnComplete(this::disposeIfPossible)
+    }
+
+    private fun resetResultsIfNeeded(search: String) {
+        if (search != previousSearch) {
+            resetCachedData()
+        }
+    }
+
+    private fun resetCachedData() {
+        previousSearch = null
+        previousCursor = 0
+        previousResults = emptyList()
+    }
+
+    private fun disposeIfPossible() {
+        unifiedSearchDisposable?.dispose()
+        unifiedSearchDisposable = null
+    }
+
+    companion object {
+        private val TAG = MessageSearchHelper::class.simpleName
+    }
+}

+ 7 - 0
app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt

@@ -22,6 +22,8 @@
 package com.nextcloud.talk.dagger.modules
 package com.nextcloud.talk.dagger.modules
 
 
 import com.nextcloud.talk.api.NcApi
 import com.nextcloud.talk.api.NcApi
+import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository
+import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepositoryImpl
 import com.nextcloud.talk.shareditems.repositories.SharedItemsRepository
 import com.nextcloud.talk.shareditems.repositories.SharedItemsRepository
 import com.nextcloud.talk.shareditems.repositories.SharedItemsRepositoryImpl
 import com.nextcloud.talk.shareditems.repositories.SharedItemsRepositoryImpl
 import dagger.Module
 import dagger.Module
@@ -33,4 +35,9 @@ class RepositoryModule {
     fun provideSharedItemsRepository(ncApi: NcApi): SharedItemsRepository {
     fun provideSharedItemsRepository(ncApi: NcApi): SharedItemsRepository {
         return SharedItemsRepositoryImpl(ncApi)
         return SharedItemsRepositoryImpl(ncApi)
     }
     }
+
+    @Provides
+    fun provideUnifiedSearchRepository(ncApi: NcApi): UnifiedSearchRepository {
+        return UnifiedSearchRepositoryImpl(ncApi)
+    }
 }
 }

+ 31 - 0
app/src/main/java/com/nextcloud/talk/models/domain/SearchMessageEntry.kt

@@ -0,0 +1,31 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Álvaro Brey
+ * Copyright (C) 2022 Álvaro Brey
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.talk.models.domain
+
+data class SearchMessageEntry(
+    val searchTerm: String,
+    val thumbnailURL: String?,
+    val title: String,
+    val messageExcerpt: String,
+    val conversationToken: String,
+    val messageId: String?
+)

+ 48 - 0
app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchEntry.kt

@@ -0,0 +1,48 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Álvaro Brey
+ * Copyright (C) 2022 Álvaro Brey
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.talk.models.json.unifiedsearch
+
+import android.os.Parcelable
+import com.bluelinelabs.logansquare.annotation.JsonField
+import com.bluelinelabs.logansquare.annotation.JsonObject
+import kotlinx.android.parcel.Parcelize
+
+@Parcelize
+@JsonObject
+data class UnifiedSearchEntry(
+    @JsonField(name = ["thumbnailUrl"])
+    var thumbnailUrl: String?,
+    @JsonField(name = ["title"])
+    var title: String?,
+    @JsonField(name = ["subline"])
+    var subline: String?,
+    @JsonField(name = ["resourceUrl"])
+    var resourceUrl: String?,
+    @JsonField(name = ["icon"])
+    var icon: String?,
+    @JsonField(name = ["rounded"])
+    var rounded: Boolean?,
+    @JsonField(name = ["attributes"])
+    var attributes: Map<String, String>?,
+) : Parcelable {
+    constructor() : this(null, null, null, null, null, null, null)
+}

+ 38 - 0
app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchOCS.kt

@@ -0,0 +1,38 @@
+/*
+ *   Nextcloud Talk application
+ *
+ *   @author Marcel Hibbe
+ *   Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
+ *
+ *   This program is free software: you can redistribute it and/or modify
+ *   it under the terms of the GNU General Public License as published by
+ *   the Free Software Foundation, either version 3 of the License, or
+ *   at your option) any later version.
+ *
+ *   This program is distributed in the hope that it will be useful,
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *   GNU General Public License for more details.
+ *
+ *   You should have received a copy of the GNU General Public License
+ *   along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.talk.models.json.unifiedsearch
+
+import android.os.Parcelable
+import com.bluelinelabs.logansquare.annotation.JsonField
+import com.bluelinelabs.logansquare.annotation.JsonObject
+import com.nextcloud.talk.models.json.generic.GenericMeta
+import kotlinx.android.parcel.Parcelize
+
+@Parcelize
+@JsonObject
+data class UnifiedSearchOCS(
+    @JsonField(name = ["meta"])
+    var meta: GenericMeta?,
+    @JsonField(name = ["data"])
+    var data: UnifiedSearchResponseData?
+) : Parcelable {
+    // Empty constructor needed for JsonObject
+    constructor() : this(null, null)
+}

+ 35 - 0
app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchOverall.kt

@@ -0,0 +1,35 @@
+/*
+ *   Nextcloud Talk application
+ *
+ *   @author Marcel Hibbe
+ *   Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
+ *
+ *   This program is free software: you can redistribute it and/or modify
+ *   it under the terms of the GNU General Public License as published by
+ *   the Free Software Foundation, either version 3 of the License, or
+ *   at your option) any later version.
+ *
+ *   This program is distributed in the hope that it will be useful,
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *   GNU General Public License for more details.
+ *
+ *   You should have received a copy of the GNU General Public License
+ *   along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.talk.models.json.unifiedsearch
+
+import android.os.Parcelable
+import com.bluelinelabs.logansquare.annotation.JsonField
+import com.bluelinelabs.logansquare.annotation.JsonObject
+import kotlinx.android.parcel.Parcelize
+
+@Parcelize
+@JsonObject
+data class UnifiedSearchOverall(
+    @JsonField(name = ["ocs"])
+    var ocs: UnifiedSearchOCS?
+) : Parcelable {
+    // Empty constructor needed for JsonObject
+    constructor() : this(null)
+}

+ 43 - 0
app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchResponseData.kt

@@ -0,0 +1,43 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Álvaro Brey
+ * Copyright (C) 2022 Álvaro Brey
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.talk.models.json.unifiedsearch
+
+import android.os.Parcelable
+import com.bluelinelabs.logansquare.annotation.JsonField
+import com.bluelinelabs.logansquare.annotation.JsonObject
+import kotlinx.android.parcel.Parcelize
+
+@Parcelize
+@JsonObject
+data class UnifiedSearchResponseData(
+    @JsonField(name = ["name"])
+    var name: String?,
+    @JsonField(name = ["isPaginated"])
+    var paginated: Boolean?,
+    @JsonField(name = ["entries"])
+    var entries: List<UnifiedSearchEntry>?,
+    @JsonField(name = ["cursor"])
+    var cursor: Int?
+) : Parcelable {
+    // empty constructor needed for JsonObject
+    constructor() : this(null, null, null, null)
+}

+ 26 - 0
app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepository.kt

@@ -0,0 +1,26 @@
+package com.nextcloud.talk.repositories.unifiedsearch
+
+import com.nextcloud.talk.models.database.UserEntity
+import com.nextcloud.talk.models.domain.SearchMessageEntry
+import io.reactivex.Observable
+
+interface UnifiedSearchRepository {
+    data class UnifiedSearchResults<T>(
+        val cursor: Int,
+        val hasMore: Boolean,
+        val entries: List<T>
+    )
+
+    fun searchMessages(
+        userEntity: UserEntity,
+        searchTerm: String,
+        cursor: Int = 0,
+        limit: Int = DEFAULT_PAGE_SIZE
+    ): Observable<UnifiedSearchResults<SearchMessageEntry>>
+
+    fun searchInRoom(text: String, roomId: String): Observable<List<SearchMessageEntry>>
+
+    companion object {
+        private const val DEFAULT_PAGE_SIZE = 5
+    }
+}

+ 83 - 0
app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepositoryImpl.kt

@@ -0,0 +1,83 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Álvaro Brey
+ * Copyright (C) 2022 Álvaro Brey
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.talk.repositories.unifiedsearch
+
+import com.nextcloud.talk.api.NcApi
+import com.nextcloud.talk.models.database.UserEntity
+import com.nextcloud.talk.models.domain.SearchMessageEntry
+import com.nextcloud.talk.models.json.unifiedsearch.UnifiedSearchEntry
+import com.nextcloud.talk.models.json.unifiedsearch.UnifiedSearchResponseData
+import com.nextcloud.talk.utils.ApiUtils
+import io.reactivex.Observable
+
+class UnifiedSearchRepositoryImpl(private val api: NcApi) : UnifiedSearchRepository {
+
+    override fun searchMessages(
+        userEntity: UserEntity,
+        searchTerm: String,
+        cursor: Int,
+        limit: Int
+    ): Observable<UnifiedSearchRepository.UnifiedSearchResults<SearchMessageEntry>> {
+        val apiObservable = api.performUnifiedSearch(
+            ApiUtils.getCredentials(userEntity.username, userEntity.token),
+            ApiUtils.getUrlForUnifiedSearch(userEntity.baseUrl, PROVIDER_TALK_MESSAGE),
+            searchTerm,
+            null,
+            limit,
+            cursor
+        )
+        return apiObservable.map { mapToMessageResults(it.ocs?.data!!, searchTerm, limit) }
+    }
+
+    override fun searchInRoom(text: String, roomId: String): Observable<List<SearchMessageEntry>> {
+        TODO()
+    }
+
+    companion object {
+        private const val PROVIDER_TALK_MESSAGE = "talk-message"
+        private const val PROVIDER_TALK_MESSAGE_CURRENT = "talk-message-current"
+
+        private const val ATTRIBUTE_CONVERSATION = "conversation"
+        private const val ATTRIBUTE_MESSAGE_ID = "messageId"
+
+        private fun mapToMessageResults(data: UnifiedSearchResponseData, searchTerm: String, limit: Int):
+            UnifiedSearchRepository.UnifiedSearchResults<SearchMessageEntry> {
+            val entries = data.entries?.map { it -> mapToMessage(it, searchTerm) }
+            val cursor = data.cursor ?: 0
+            val hasMore = entries?.size == limit
+            return UnifiedSearchRepository.UnifiedSearchResults(cursor, hasMore, entries ?: emptyList())
+        }
+
+        private fun mapToMessage(unifiedSearchEntry: UnifiedSearchEntry, searchTerm: String): SearchMessageEntry {
+            val conversation = unifiedSearchEntry.attributes?.get(ATTRIBUTE_CONVERSATION)!!
+            val messageId = unifiedSearchEntry.attributes?.get(ATTRIBUTE_MESSAGE_ID)
+            return SearchMessageEntry(
+                searchTerm,
+                unifiedSearchEntry.thumbnailUrl,
+                unifiedSearchEntry.title!!,
+                unifiedSearchEntry.subline!!,
+                conversation,
+                messageId
+            )
+        }
+    }
+}

+ 6 - 1
app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java

@@ -32,10 +32,11 @@ import com.nextcloud.talk.models.RetrofitBucket;
 import com.nextcloud.talk.models.database.CapabilitiesUtil;
 import com.nextcloud.talk.models.database.CapabilitiesUtil;
 import com.nextcloud.talk.models.database.UserEntity;
 import com.nextcloud.talk.models.database.UserEntity;
 
 
+import org.jetbrains.annotations.NotNull;
+
 import java.util.HashMap;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Map;
 
 
-import androidx.annotation.DimenRes;
 import androidx.annotation.Nullable;
 import androidx.annotation.Nullable;
 import okhttp3.Credentials;
 import okhttp3.Credentials;
 
 
@@ -456,4 +457,8 @@ public class ApiUtils {
         return baseUrl + ocsApiVersion + spreedApiVersion + "/reaction/" + roomToken + "/" + messageId;
         return baseUrl + ocsApiVersion + spreedApiVersion + "/reaction/" + roomToken + "/" + messageId;
     }
     }
 
 
+    @NotNull
+    public static String getUrlForUnifiedSearch(@NotNull String baseUrl, @NotNull String providerId) {
+        return baseUrl + ocsApiVersion + "/search/providers/" + providerId + "/search";
+    }
 }
 }

+ 34 - 0
app/src/main/java/com/nextcloud/talk/utils/Debouncer.kt

@@ -0,0 +1,34 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Álvaro Brey
+ * Copyright (C) 2022 Álvaro Brey
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.talk.utils
+
+import android.os.Handler
+import android.os.Looper
+
+class Debouncer(var delay: Long) {
+    private val handler = Handler(Looper.getMainLooper())
+
+    fun debounce(runnable: Runnable) {
+        handler.removeCallbacksAndMessages(null) // clear handler
+        handler.postDelayed(runnable, delay)
+    }
+}

+ 25 - 0
app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.java

@@ -36,6 +36,7 @@ import android.graphics.Typeface;
 import android.graphics.drawable.Animatable;
 import android.graphics.drawable.Animatable;
 import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.Drawable;
 import android.graphics.drawable.Drawable;
+import android.graphics.drawable.LayerDrawable;
 import android.graphics.drawable.VectorDrawable;
 import android.graphics.drawable.VectorDrawable;
 import android.net.Uri;
 import android.net.Uri;
 import android.os.Build;
 import android.os.Build;
@@ -592,6 +593,30 @@ public class DisplayUtils {
         avatarImageView.setController(draweeController);
         avatarImageView.setController(draweeController);
     }
     }
 
 
+    public static void loadAvatarPlaceholder(final SimpleDraweeView targetView) {
+        final Context context = targetView.getContext();
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            Drawable[] layers = new Drawable[2];
+            layers[0] = ContextCompat.getDrawable(context, R.drawable.ic_launcher_background);
+            layers[1] = ContextCompat.getDrawable(context, R.drawable.ic_launcher_foreground);
+            LayerDrawable layerDrawable = new LayerDrawable(layers);
+
+            targetView.getHierarchy().setPlaceholderImage(
+                DisplayUtils.getRoundedDrawable(layerDrawable));
+        } else {
+            targetView.getHierarchy().setPlaceholderImage(R.mipmap.ic_launcher);
+        }
+    }
+
+    public static void loadImage(final SimpleDraweeView targetView, final ImageRequest imageRequest) {
+        final DraweeController newController = Fresco.newDraweeControllerBuilder()
+            .setOldController(targetView.getController())
+            .setAutoPlayAnimations(true)
+            .setImageRequest(imageRequest)
+            .build();
+        targetView.setController(newController);
+    }
+
     public static @StringRes
     public static @StringRes
     int getSortOrderStringId(FileSortOrder sortOrder) {
     int getSortOrderStringId(FileSortOrder sortOrder) {
         switch (sortOrder.name) {
         switch (sortOrder.name) {

+ 62 - 0
app/src/main/res/layout/rv_item_load_more.xml

@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Talk application
+  ~
+  ~ @author Mario Danic
+  ~ @author Andy Scherzinger
+  ~ Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
+  ~ Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
+  ~
+  ~ This program is free software: you can redistribute it and/or modify
+  ~ it under the terms of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ at your option) any later version.
+  ~
+  ~ This program is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  ~ GNU General Public License for more details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+  ~
+  ~
+  ~
+  ~ Adapted from https://github.com/stfalcon-studio/ChatKit/blob/master/chatkit/src/main/res/layout/item_dialog.xml
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:clickable="true"
+    android:focusable="true"
+    android:background="?android:attr/selectableItemBackground"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:layout_margin="@dimen/double_margin_between_elements"
+    tools:background="@color/white">
+
+    <Space
+        android:id="@+id/load_more_spacer"
+        android:layout_width="@dimen/small_item_height"
+        android:layout_height="@dimen/small_item_height"
+        android:importantForAccessibility="no"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:roundAsCircle="true" />
+
+    <TextView
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginHorizontal="@dimen/double_margin_between_elements"
+        android:includeFontPadding="false"
+        android:maxLines="1"
+        android:textColor="@color/textColorMaxContrast"
+        android:textSize="@dimen/two_line_primary_text_size"
+        android:text="@string/load_more_results"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toEndOf="@+id/load_more_spacer"
+        app:layout_constraintTop_toTopOf="parent" />
+
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 80 - 0
app/src/main/res/layout/rv_item_search_message.xml

@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Talk application
+  ~
+  ~ @author Mario Danic
+  ~ @author Andy Scherzinger
+  ~ Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
+  ~ Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
+  ~
+  ~ This program is free software: you can redistribute it and/or modify
+  ~ it under the terms of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ at your option) any later version.
+  ~
+  ~ This program is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  ~ GNU General Public License for more details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+  ~
+  ~
+  ~
+  ~ Adapted from https://github.com/stfalcon-studio/ChatKit/blob/master/chatkit/src/main/res/layout/item_dialog.xml
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:clickable="true"
+    android:focusable="true"
+    android:background="?attr/selectableItemBackground"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:layout_margin="@dimen/double_margin_between_elements"
+    tools:background="@color/white">
+
+    <com.facebook.drawee.view.SimpleDraweeView
+        android:id="@+id/thumbnail"
+        android:layout_width="@dimen/small_item_height"
+        android:layout_height="@dimen/small_item_height"
+        android:importantForAccessibility="no"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:roundAsCircle="true" />
+
+    <androidx.emoji.widget.EmojiTextView
+        android:id="@+id/conversation_title"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginHorizontal="@dimen/double_margin_between_elements"
+        android:layout_marginTop="2dp"
+        android:ellipsize="end"
+        android:includeFontPadding="false"
+        android:maxLines="1"
+        android:textColor="@color/conversation_item_header"
+        android:textSize="@dimen/two_line_primary_text_size"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toEndOf="@+id/thumbnail"
+        app:layout_constraintTop_toTopOf="parent"
+        tools:text="Message title goes here" />
+
+    <androidx.emoji.widget.EmojiTextView
+        android:id="@+id/message_excerpt"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="6dp"
+        android:ellipsize="end"
+        android:gravity="start|top"
+        android:lines="1"
+        android:singleLine="true"
+        android:textColor="@color/textColorMaxContrast"
+        android:textSize="14sp"
+        app:layout_constraintEnd_toEndOf="@id/conversation_title"
+        app:layout_constraintStart_toStartOf="@+id/conversation_title"
+        app:layout_constraintTop_toBottomOf="@id/conversation_title"
+        tools:text="...this is a message result from unified search, which includes ellipses..." />
+
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 2 - 0
app/src/main/res/values/strings.xml

@@ -523,5 +523,7 @@
     <string name="reactions_tab_all">All</string>
     <string name="reactions_tab_all">All</string>
     <string name="send_without_notification">Send without notification</string>
     <string name="send_without_notification">Send without notification</string>
     <string name="call_without_notification">Call without notification</string>
     <string name="call_without_notification">Call without notification</string>
+    <string name="messages">Messages</string>
+    <string name="load_more_results">Load more results</string>
 
 
 </resources>
 </resources>