Implement global message search

Signed-off-by: Álvaro Brey <alvaro.brey@nextcloud.com>
This commit is contained in:
Álvaro Brey 2022-05-12 13:32:18 +02:00
parent 6718cf7663
commit 1d632f3c96
No known key found for this signature in database
GPG Key ID: 2585783189A62105
20 changed files with 1025 additions and 41 deletions

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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.status.StatusOverall;
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.UserProfileOverall;
@ -519,4 +520,13 @@ public interface NcApi {
Observable<ReactionsOverall> getReactions(@Header("Authorization") String authorization,
@Url String url,
@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);
}

View File

@ -68,9 +68,13 @@ import com.nextcloud.talk.R;
import com.nextcloud.talk.activities.MainActivity;
import com.nextcloud.talk.adapters.items.ConversationItem;
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.application.NextcloudTalkApplication;
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.EventStatus;
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.models.database.CapabilitiesUtil;
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.status.Status;
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.ConversationsListBottomDialog;
import com.nextcloud.talk.utils.ApiUtils;
import com.nextcloud.talk.utils.AttendeePermissionsUtil;
import com.nextcloud.talk.utils.ClosedInterfaceImpl;
import com.nextcloud.talk.utils.ConductorRemapping;
import com.nextcloud.talk.utils.Debouncer;
import com.nextcloud.talk.utils.DisplayUtils;
import com.nextcloud.talk.utils.UriUtils;
import com.nextcloud.talk.utils.bundle.BundleKeys;
@ -131,6 +138,8 @@ import butterknife.BindView;
import eu.davidea.flexibleadapter.FlexibleAdapter;
import eu.davidea.flexibleadapter.common.SmoothScrollLinearLayoutManager;
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem;
import eu.davidea.flexibleadapter.items.IHeader;
import io.reactivex.Observable;
import io.reactivex.Observer;
import io.reactivex.android.schedulers.AndroidSchedulers;
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 UNREAD_BUBBLE_DELAY = 2500;
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;
@Inject
UserUtils userUtils;
@ -161,6 +174,9 @@ public class ConversationsListController extends BaseController implements Searc
@Inject
AppPreferences appPreferences;
@Inject
UnifiedSearchRepository unifiedSearchRepository;
@BindView(R.id.recycler_view)
RecyclerView recyclerView;
@ -220,6 +236,10 @@ public class ConversationsListController extends BaseController implements Searc
private HashMap<String, Status> userStatuses = new HashMap<>();
private Debouncer searchDebouncer = new Debouncer(SEARCH_DEBOUNCE_INTERVAL_MS);
private MessageSearchHelper searchHelper;
public ConversationsListController(Bundle bundle) {
super();
setHasOptionsMenu(true);
@ -306,6 +326,8 @@ public class ConversationsListController extends BaseController implements Searc
return;
}
searchHelper = new MessageSearchHelper(currentUser, unifiedSearchRepository);
credentials = ApiUtils.getCredentials(currentUser.getUsername(), currentUser.getToken());
if (getActivity() != null && getActivity() instanceof MainActivity) {
loadUserAvatar(((MainActivity) getActivity()).binding.switchAccountButton);
@ -419,6 +441,11 @@ public class ConversationsListController extends BaseController implements Searc
adapter.setHeadersShown(false);
adapter.updateDataSet(conversationItems, false);
adapter.hideAllHeaders();
if (searchHelper != null) {
// cancel any pending searches
searchHelper.cancelSearch();
swipeRefreshLayout.setRefreshing(false);
}
if (swipeRefreshLayout != null) {
swipeRefreshLayout.setEnabled(true);
}
@ -427,8 +454,8 @@ public class ConversationsListController extends BaseController implements Searc
MainActivity activity = (MainActivity) getActivity();
if (activity != null) {
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.searchToolbar.setVisibility(View.VISIBLE);
@ -845,20 +872,73 @@ public class ConversationsListController extends BaseController implements Searc
}
@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;
}
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
public boolean onQueryTextSubmit(String query) {
return onQueryTextChange(query);
@ -871,38 +951,59 @@ public class ConversationsListController extends BaseController implements Searc
@Override
public boolean onItemClick(View view, int position) {
try {
selectedConversation = ((ConversationItem) Objects.requireNonNull(adapter.getItem(position))).getModel();
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 {
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);
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();
}
return true;
}
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 {
Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show();
}
} else {
openConversation();
}
}
}
private Boolean isReadOnlyConversation(Conversation conversation) {
return conversation.getConversationReadOnlyState() ==
Conversation.ConversationReadOnlyState.CONVERSATION_READ_ONLY;
@ -1274,4 +1375,29 @@ public class ConversationsListController extends BaseController implements Searc
public AppBarLayoutType getAppBarLayoutType() {
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);
}
}

View File

@ -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
}
}

View File

@ -22,6 +22,8 @@
package com.nextcloud.talk.dagger.modules
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.SharedItemsRepositoryImpl
import dagger.Module
@ -33,4 +35,9 @@ class RepositoryModule {
fun provideSharedItemsRepository(ncApi: NcApi): SharedItemsRepository {
return SharedItemsRepositoryImpl(ncApi)
}
@Provides
fun provideUnifiedSearchRepository(ncApi: NcApi): UnifiedSearchRepository {
return UnifiedSearchRepositoryImpl(ncApi)
}
}

View File

@ -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?
)

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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
)
}
}
}

View File

@ -32,10 +32,11 @@ import com.nextcloud.talk.models.RetrofitBucket;
import com.nextcloud.talk.models.database.CapabilitiesUtil;
import com.nextcloud.talk.models.database.UserEntity;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.Map;
import androidx.annotation.DimenRes;
import androidx.annotation.Nullable;
import okhttp3.Credentials;
@ -456,4 +457,8 @@ public class ApiUtils {
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";
}
}

View File

@ -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)
}
}

View File

@ -36,6 +36,7 @@ import android.graphics.Typeface;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.graphics.drawable.VectorDrawable;
import android.net.Uri;
import android.os.Build;
@ -592,6 +593,30 @@ public class DisplayUtils {
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
int getSortOrderStringId(FileSortOrder sortOrder) {
switch (sortOrder.name) {

View File

@ -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>

View File

@ -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>

View File

@ -523,5 +523,7 @@
<string name="reactions_tab_all">All</string>
<string name="send_without_notification">Send 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>