mirror of
https://github.com/oxen-io/session-android.git
synced 2025-04-16 11:21:26 +00:00
Merge pull request #622 from RyanRory/ui
Conversation Screen 2.0 - Search
This commit is contained in:
commit
9b513fa2ba
@ -22,6 +22,8 @@ import android.widget.RelativeLayout
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.ViewModelProviders
|
import androidx.lifecycle.ViewModelProviders
|
||||||
import androidx.loader.app.LoaderManager
|
import androidx.loader.app.LoaderManager
|
||||||
import androidx.loader.content.Loader
|
import androidx.loader.content.Loader
|
||||||
@ -53,9 +55,11 @@ import org.session.libsession.messaging.sending_receiving.MessageSender
|
|||||||
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
|
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
|
||||||
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
|
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
|
||||||
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
|
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
|
||||||
|
import org.session.libsession.utilities.Address
|
||||||
import org.session.libsession.utilities.Address.Companion.fromSerialized
|
import org.session.libsession.utilities.Address.Companion.fromSerialized
|
||||||
import org.session.libsession.utilities.MediaTypes
|
import org.session.libsession.utilities.MediaTypes
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
|
import org.session.libsession.utilities.concurrent.SimpleTask
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.session.libsession.utilities.recipients.RecipientModifiedListener
|
import org.session.libsession.utilities.recipients.RecipientModifiedListener
|
||||||
import org.session.libsignal.utilities.ListenableFuture
|
import org.session.libsignal.utilities.ListenableFuture
|
||||||
@ -74,6 +78,8 @@ import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCa
|
|||||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
|
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
|
||||||
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentViewDelegate
|
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentViewDelegate
|
||||||
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
|
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
|
||||||
|
import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar
|
||||||
|
import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel
|
||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager
|
import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||||
import org.thoughtcrime.securesms.database.DraftDatabase
|
import org.thoughtcrime.securesms.database.DraftDatabase
|
||||||
@ -108,8 +114,9 @@ import kotlin.math.*
|
|||||||
// price we pay is a bit of back and forth between the input bar and the conversation activity.
|
// price we pay is a bit of back and forth between the input bar and the conversation activity.
|
||||||
|
|
||||||
class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate,
|
class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate,
|
||||||
InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher,
|
InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher,
|
||||||
ConversationActionModeCallbackDelegate, VisibleMessageContentViewDelegate, RecipientModifiedListener {
|
ConversationActionModeCallbackDelegate, VisibleMessageContentViewDelegate, RecipientModifiedListener,
|
||||||
|
SearchBottomBar.EventListener {
|
||||||
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
|
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
|
||||||
private var linkPreviewViewModel: LinkPreviewViewModel? = null
|
private var linkPreviewViewModel: LinkPreviewViewModel? = null
|
||||||
private var threadID: Long = -1
|
private var threadID: Long = -1
|
||||||
@ -128,6 +135,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
private var previousText: CharSequence = ""
|
private var previousText: CharSequence = ""
|
||||||
private var currentMentionStartIndex = -1
|
private var currentMentionStartIndex = -1
|
||||||
private var isShowingMentionCandidatesView = false
|
private var isShowingMentionCandidatesView = false
|
||||||
|
// Search
|
||||||
|
var searchViewModel: SearchViewModel? = null
|
||||||
|
var searchViewItem: MenuItem? = null
|
||||||
|
|
||||||
private val isScrolledToBottom: Boolean
|
private val isScrolledToBottom: Boolean
|
||||||
get() {
|
get() {
|
||||||
@ -199,6 +209,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
getLatestOpenGroupInfoIfNeeded()
|
getLatestOpenGroupInfoIfNeeded()
|
||||||
setUpBlockedBanner()
|
setUpBlockedBanner()
|
||||||
setUpLinkPreviewObserver()
|
setUpLinkPreviewObserver()
|
||||||
|
searchBottomBar.setEventListener(this)
|
||||||
|
setUpSearchResultObserver()
|
||||||
scrollToFirstUnreadMessageIfNeeded()
|
scrollToFirstUnreadMessageIfNeeded()
|
||||||
markAllAsRead()
|
markAllAsRead()
|
||||||
showOrHideInputIfNeeded()
|
showOrHideInputIfNeeded()
|
||||||
@ -371,7 +383,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
||||||
ConversationMenuHelper.onPrepareOptionsMenu(menu, menuInflater, thread, this) { onOptionsItemSelected(it) }
|
ConversationMenuHelper.onPrepareOptionsMenu(menu, menuInflater, thread, threadID, this) { onOptionsItemSelected(it) }
|
||||||
super.onPrepareOptionsMenu(menu)
|
super.onPrepareOptionsMenu(menu)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -685,6 +697,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
val actionMode = this.actionMode
|
val actionMode = this.actionMode
|
||||||
val actionModeCallback = ConversationActionModeCallback(adapter, threadID, this)
|
val actionModeCallback = ConversationActionModeCallback(adapter, threadID, this)
|
||||||
actionModeCallback.delegate = this
|
actionModeCallback.delegate = this
|
||||||
|
searchViewItem?.collapseActionView()
|
||||||
if (actionMode == null) { // Nothing should be selected if this is the case
|
if (actionMode == null) { // Nothing should be selected if this is the case
|
||||||
adapter.toggleSelection(message, position)
|
adapter.toggleSelection(message, position)
|
||||||
this.actionMode = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
|
this.actionMode = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
|
||||||
@ -1168,4 +1181,46 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
draftDB.insertDrafts(threadID, drafts)
|
draftDB.insertDrafts(threadID, drafts)
|
||||||
}
|
}
|
||||||
// endregion
|
// endregion
|
||||||
}
|
|
||||||
|
// region Search
|
||||||
|
private fun setUpSearchResultObserver() {
|
||||||
|
val searchViewModel = ViewModelProvider(this).get(SearchViewModel::class.java)
|
||||||
|
this.searchViewModel = searchViewModel
|
||||||
|
searchViewModel.searchResults.observe(this, Observer { result: SearchViewModel.SearchResult? ->
|
||||||
|
if (result == null) return@Observer
|
||||||
|
if (result.getResults().isNotEmpty()) {
|
||||||
|
result.getResults()[result.position]?.let {
|
||||||
|
jumpToMessage(it.messageRecipient.address, it.receivedTimestampMs, Runnable { searchViewModel.onMissingResult() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.searchBottomBar.setData(result.position, result.getResults().size)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSearchQueryUpdated(query: String?) {
|
||||||
|
adapter.onSearchQueryUpdated(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSearchMoveUpPressed() {
|
||||||
|
this.searchViewModel?.onMoveUp()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSearchMoveDownPressed() {
|
||||||
|
this.searchViewModel?.onMoveDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jumpToMessage(author: Address, timestamp: Long, onMessageNotFound: Runnable?) {
|
||||||
|
SimpleTask.run(lifecycle, {
|
||||||
|
DatabaseFactory.getMmsSmsDatabase(this).getMessagePositionInConversation(threadID, timestamp, author)
|
||||||
|
}) { p: Int -> moveToMessagePosition(p, onMessageNotFound) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun moveToMessagePosition(position: Int, onMessageNotFound: Runnable?) {
|
||||||
|
if (position >= 0) {
|
||||||
|
conversationRecyclerView.scrollToPosition(position)
|
||||||
|
} else {
|
||||||
|
onMessageNotFound?.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
}
|
@ -22,6 +22,7 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr
|
|||||||
: CursorRecyclerViewAdapter<ViewHolder>(context, cursor) {
|
: CursorRecyclerViewAdapter<ViewHolder>(context, cursor) {
|
||||||
private val messageDB = DatabaseFactory.getMmsSmsDatabase(context)
|
private val messageDB = DatabaseFactory.getMmsSmsDatabase(context)
|
||||||
var selectedItems = mutableSetOf<MessageRecord>()
|
var selectedItems = mutableSetOf<MessageRecord>()
|
||||||
|
private var searchQuery: String? = null
|
||||||
var visibleMessageContentViewDelegate: VisibleMessageContentViewDelegate? = null
|
var visibleMessageContentViewDelegate: VisibleMessageContentViewDelegate? = null
|
||||||
|
|
||||||
sealed class ViewType(val rawValue: Int) {
|
sealed class ViewType(val rawValue: Int) {
|
||||||
@ -71,7 +72,7 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr
|
|||||||
view.snIsSelected = isSelected
|
view.snIsSelected = isSelected
|
||||||
view.messageTimestampTextView.isVisible = isSelected
|
view.messageTimestampTextView.isVisible = isSelected
|
||||||
val position = viewHolder.adapterPosition
|
val position = viewHolder.adapterPosition
|
||||||
view.bind(message, getMessageBefore(position, cursor), getMessageAfter(position, cursor), glide)
|
view.bind(message, getMessageBefore(position, cursor), getMessageAfter(position, cursor), glide, searchQuery)
|
||||||
view.onPress = { event -> onItemPress(message, viewHolder.adapterPosition, view, event) }
|
view.onPress = { event -> onItemPress(message, viewHolder.adapterPosition, view, event) }
|
||||||
view.onSwipeToReply = { onItemSwipeToReply(message, viewHolder.adapterPosition) }
|
view.onSwipeToReply = { onItemSwipeToReply(message, viewHolder.adapterPosition) }
|
||||||
view.onLongPress = { onItemLongPress(message, viewHolder.adapterPosition) }
|
view.onLongPress = { onItemLongPress(message, viewHolder.adapterPosition) }
|
||||||
@ -133,4 +134,9 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onSearchQueryUpdated(query: String?) {
|
||||||
|
this.searchQuery = query
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
}
|
}
|
@ -12,17 +12,20 @@ import android.os.AsyncTask
|
|||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuInflater
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.ColorInt
|
import androidx.annotation.ColorInt
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.appcompat.widget.SearchView
|
||||||
|
import androidx.appcompat.widget.SearchView.OnQueryTextListener
|
||||||
import androidx.core.content.pm.ShortcutInfoCompat
|
import androidx.core.content.pm.ShortcutInfoCompat
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import kotlinx.android.synthetic.main.activity_conversation_v2.*
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import org.session.libsession.avatars.ContactPhoto
|
|
||||||
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
|
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
|
||||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||||
import org.session.libsession.messaging.sending_receiving.leave
|
import org.session.libsession.messaging.sending_receiving.leave
|
||||||
@ -30,11 +33,9 @@ import org.session.libsession.utilities.ExpirationUtil
|
|||||||
import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID
|
import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.session.libsignal.utilities.Log
|
|
||||||
import org.session.libsignal.utilities.guava.Optional
|
import org.session.libsignal.utilities.guava.Optional
|
||||||
import org.session.libsignal.utilities.toHexString
|
import org.session.libsignal.utilities.toHexString
|
||||||
import org.thoughtcrime.securesms.*
|
import org.thoughtcrime.securesms.*
|
||||||
import org.thoughtcrime.securesms.conversation.ConversationActivity
|
|
||||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||||
import org.thoughtcrime.securesms.loki.activities.EditClosedGroupActivity
|
import org.thoughtcrime.securesms.loki.activities.EditClosedGroupActivity
|
||||||
@ -43,11 +44,10 @@ import org.thoughtcrime.securesms.loki.activities.SelectContactsActivity
|
|||||||
import org.thoughtcrime.securesms.loki.utilities.getColorWithID
|
import org.thoughtcrime.securesms.loki.utilities.getColorWithID
|
||||||
import org.thoughtcrime.securesms.util.BitmapUtil
|
import org.thoughtcrime.securesms.util.BitmapUtil
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.lang.ref.WeakReference
|
|
||||||
|
|
||||||
object ConversationMenuHelper {
|
object ConversationMenuHelper {
|
||||||
|
|
||||||
fun onPrepareOptionsMenu(menu: Menu, inflater: MenuInflater, thread: Recipient, context: Context, onOptionsItemSelected: (MenuItem) -> Unit) {
|
fun onPrepareOptionsMenu(menu: Menu, inflater: MenuInflater, thread: Recipient, threadId: Long, context: Context, onOptionsItemSelected: (MenuItem) -> Unit) {
|
||||||
// Prepare
|
// Prepare
|
||||||
menu.clear()
|
menu.clear()
|
||||||
val isOpenGroup = thread.isOpenGroupRecipient
|
val isOpenGroup = thread.isOpenGroupRecipient
|
||||||
@ -92,6 +92,49 @@ object ConversationMenuHelper {
|
|||||||
} else {
|
} else {
|
||||||
inflater.inflate(R.menu.menu_conversation_unmuted, menu)
|
inflater.inflate(R.menu.menu_conversation_unmuted, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
val searchViewItem = menu.findItem(R.id.menu_search)
|
||||||
|
(context as ConversationActivityV2).searchViewItem = searchViewItem
|
||||||
|
val searchView = searchViewItem.actionView as SearchView
|
||||||
|
val searchViewModel = context.searchViewModel!!
|
||||||
|
val queryListener = object : OnQueryTextListener {
|
||||||
|
override fun onQueryTextSubmit(query: String): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onQueryTextChange(query: String): Boolean {
|
||||||
|
searchViewModel.onQueryUpdated(query, threadId)
|
||||||
|
context.searchBottomBar.showLoading()
|
||||||
|
context.onSearchQueryUpdated(query)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
searchViewItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
|
||||||
|
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
|
||||||
|
searchView.setOnQueryTextListener(queryListener)
|
||||||
|
searchViewModel.onSearchOpened()
|
||||||
|
context.searchBottomBar.visibility = View.VISIBLE
|
||||||
|
context.searchBottomBar.setData(0, 0)
|
||||||
|
context.inputBar.visibility = View.GONE
|
||||||
|
for (i in 0 until menu.size()) {
|
||||||
|
if (menu.getItem(i) != searchViewItem) {
|
||||||
|
menu.getItem(i).isVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
|
||||||
|
searchView.setOnQueryTextListener(null)
|
||||||
|
searchViewModel.onSearchClosed()
|
||||||
|
context.searchBottomBar.visibility = View.GONE
|
||||||
|
context.inputBar.visibility = View.VISIBLE
|
||||||
|
context.onSearchQueryUpdated(null)
|
||||||
|
context.invalidateOptionsMenu()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onOptionItemSelected(context: Context, item: MenuItem, thread: Recipient): Boolean {
|
fun onOptionItemSelected(context: Context, item: MenuItem, thread: Recipient): Boolean {
|
||||||
@ -121,7 +164,8 @@ object ConversationMenuHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun search(context: Context) {
|
private fun search(context: Context) {
|
||||||
Toast.makeText(context, "Not yet implemented", Toast.LENGTH_LONG).show() // TODO: Implement
|
val searchViewModel = (context as ConversationActivityV2).searchViewModel!!
|
||||||
|
searchViewModel.onSearchOpened()
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
@SuppressLint("StaticFieldLeak")
|
||||||
|
@ -42,7 +42,7 @@ class LinkPreviewView : LinearLayout {
|
|||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Updating
|
// region Updating
|
||||||
fun bind(message: MmsMessageRecord, glide: GlideRequests, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean) {
|
fun bind(message: MmsMessageRecord, glide: GlideRequests, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean, searchQuery: String?) {
|
||||||
val linkPreview = message.linkPreviews.first()
|
val linkPreview = message.linkPreviews.first()
|
||||||
url = linkPreview.url
|
url = linkPreview.url
|
||||||
// Thumbnail
|
// Thumbnail
|
||||||
@ -60,7 +60,7 @@ class LinkPreviewView : LinearLayout {
|
|||||||
}
|
}
|
||||||
titleTextView.setTextColor(ResourcesCompat.getColor(resources, textColorID, context.theme))
|
titleTextView.setTextColor(ResourcesCompat.getColor(resources, textColorID, context.theme))
|
||||||
// Body
|
// Body
|
||||||
bodyTextView = VisibleMessageContentView.getBodyTextView(context, message)
|
bodyTextView = VisibleMessageContentView.getBodyTextView(context, message, searchQuery)
|
||||||
mainLinkPreviewContainer.addView(bodyTextView)
|
mainLinkPreviewContainer.addView(bodyTextView)
|
||||||
// Corner radii
|
// Corner radii
|
||||||
val cornerRadii = MessageBubbleUtilities.calculateRadii(context, isStartOfMessageCluster, isEndOfMessageCluster, message.isOutgoing)
|
val cornerRadii = MessageBubbleUtilities.calculateRadii(context, isStartOfMessageCluster, isEndOfMessageCluster, message.isOutgoing)
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2.messages
|
package org.thoughtcrime.securesms.conversation.v2.messages
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.text.style.BackgroundColorSpan
|
||||||
|
import android.text.style.ForegroundColorSpan
|
||||||
import android.text.method.LinkMovementMethod
|
import android.text.method.LinkMovementMethod
|
||||||
import android.text.style.ReplacementSpan
|
|
||||||
import android.text.style.URLSpan
|
import android.text.style.URLSpan
|
||||||
import android.text.util.Linkify
|
import android.text.util.Linkify
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.util.Log
|
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.annotation.ColorInt
|
import androidx.annotation.ColorInt
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
@ -39,6 +39,9 @@ import org.thoughtcrime.securesms.database.model.MessageRecord
|
|||||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||||
import org.thoughtcrime.securesms.loki.utilities.*
|
import org.thoughtcrime.securesms.loki.utilities.*
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||||
|
import org.thoughtcrime.securesms.util.SearchUtil
|
||||||
|
import org.thoughtcrime.securesms.util.SearchUtil.StyleFactory
|
||||||
|
import java.util.*
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class VisibleMessageContentView : LinearLayout {
|
class VisibleMessageContentView : LinearLayout {
|
||||||
@ -58,7 +61,7 @@ class VisibleMessageContentView : LinearLayout {
|
|||||||
|
|
||||||
// region Updating
|
// region Updating
|
||||||
fun bind(message: MessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean,
|
fun bind(message: MessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean,
|
||||||
glide: GlideRequests, maxWidth: Int, thread: Recipient) {
|
glide: GlideRequests, maxWidth: Int, thread: Recipient, searchQuery: String?) {
|
||||||
// Background
|
// Background
|
||||||
val background = getBackground(message.isOutgoing, isStartOfMessageCluster, isEndOfMessageCluster)
|
val background = getBackground(message.isOutgoing, isStartOfMessageCluster, isEndOfMessageCluster)
|
||||||
val colorID = if (message.isOutgoing) R.attr.message_sent_background_color else R.attr.message_received_background_color
|
val colorID = if (message.isOutgoing) R.attr.message_sent_background_color else R.attr.message_received_background_color
|
||||||
@ -72,7 +75,7 @@ class VisibleMessageContentView : LinearLayout {
|
|||||||
onContentDoubleTap = null
|
onContentDoubleTap = null
|
||||||
if (message is MmsMessageRecord && message.linkPreviews.isNotEmpty()) {
|
if (message is MmsMessageRecord && message.linkPreviews.isNotEmpty()) {
|
||||||
val linkPreviewView = LinkPreviewView(context)
|
val linkPreviewView = LinkPreviewView(context)
|
||||||
linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
|
linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster, searchQuery)
|
||||||
mainContainer.addView(linkPreviewView)
|
mainContainer.addView(linkPreviewView)
|
||||||
onContentClick = { event -> linkPreviewView.calculateHit(event) }
|
onContentClick = { event -> linkPreviewView.calculateHit(event) }
|
||||||
// Body text view is inside the link preview for layout convenience
|
// Body text view is inside the link preview for layout convenience
|
||||||
@ -86,7 +89,7 @@ class VisibleMessageContentView : LinearLayout {
|
|||||||
quoteView.bind(quote.author.toString(), quote.text, quote.attachment, thread,
|
quoteView.bind(quote.author.toString(), quote.text, quote.attachment, thread,
|
||||||
message.isOutgoing, maxContentWidth, message.isOpenGroupInvitation, message.threadId, glide)
|
message.isOutgoing, maxContentWidth, message.isOpenGroupInvitation, message.threadId, glide)
|
||||||
mainContainer.addView(quoteView)
|
mainContainer.addView(quoteView)
|
||||||
val bodyTextView = VisibleMessageContentView.getBodyTextView(context, message)
|
val bodyTextView = VisibleMessageContentView.getBodyTextView(context, message, searchQuery)
|
||||||
ViewUtil.setPaddingTop(bodyTextView, 0)
|
ViewUtil.setPaddingTop(bodyTextView, 0)
|
||||||
mainContainer.addView(bodyTextView)
|
mainContainer.addView(bodyTextView)
|
||||||
onContentClick = { event ->
|
onContentClick = { event ->
|
||||||
@ -128,7 +131,7 @@ class VisibleMessageContentView : LinearLayout {
|
|||||||
mainContainer.addView(openGroupInvitationView)
|
mainContainer.addView(openGroupInvitationView)
|
||||||
onContentClick = { openGroupInvitationView.joinOpenGroup() }
|
onContentClick = { openGroupInvitationView.joinOpenGroup() }
|
||||||
} else {
|
} else {
|
||||||
val bodyTextView = VisibleMessageContentView.getBodyTextView(context, message)
|
val bodyTextView = VisibleMessageContentView.getBodyTextView(context, message, searchQuery)
|
||||||
mainContainer.addView(bodyTextView)
|
mainContainer.addView(bodyTextView)
|
||||||
onContentClick = { event ->
|
onContentClick = { event ->
|
||||||
// intersectedModalSpans should only be a list of one item
|
// intersectedModalSpans should only be a list of one item
|
||||||
@ -162,7 +165,7 @@ class VisibleMessageContentView : LinearLayout {
|
|||||||
// region Convenience
|
// region Convenience
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun getBodyTextView(context: Context, message: MessageRecord): TextView {
|
fun getBodyTextView(context: Context, message: MessageRecord, searchQuery: String?): TextView {
|
||||||
val result = EmojiTextView(context)
|
val result = EmojiTextView(context)
|
||||||
val vPadding = context.resources.getDimension(R.dimen.small_spacing).toInt()
|
val vPadding = context.resources.getDimension(R.dimen.small_spacing).toInt()
|
||||||
val hPadding = toPx(12, context.resources)
|
val hPadding = toPx(12, context.resources)
|
||||||
@ -186,8 +189,11 @@ class VisibleMessageContentView : LinearLayout {
|
|||||||
body.removeSpan(urlSpan)
|
body.removeSpan(urlSpan)
|
||||||
body.setSpan(replacementSpan, start, end, flags)
|
body.setSpan(replacementSpan, start, end, flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
body = MentionUtilities.highlightMentions(body, message.isOutgoing, message.threadId, context)
|
body = MentionUtilities.highlightMentions(body, message.isOutgoing, message.threadId, context)
|
||||||
|
body = SearchUtil.getHighlightedSpan(Locale.getDefault(), StyleFactory { BackgroundColorSpan(Color.WHITE) }, body, searchQuery)
|
||||||
|
body = SearchUtil.getHighlightedSpan(Locale.getDefault(), StyleFactory { ForegroundColorSpan(Color.BLACK) }, body, searchQuery)
|
||||||
|
|
||||||
result.text = body
|
result.text = body
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
@ -71,7 +71,7 @@ class VisibleMessageView : LinearLayout {
|
|||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Updating
|
// region Updating
|
||||||
fun bind(message: MessageRecord, previous: MessageRecord?, next: MessageRecord?, glide: GlideRequests) {
|
fun bind(message: MessageRecord, previous: MessageRecord?, next: MessageRecord?, glide: GlideRequests, searchQuery: String?) {
|
||||||
val sender = message.individualRecipient
|
val sender = message.individualRecipient
|
||||||
val senderSessionID = sender.address.serialize()
|
val senderSessionID = sender.address.serialize()
|
||||||
val threadID = message.threadId
|
val threadID = message.threadId
|
||||||
@ -139,7 +139,7 @@ class VisibleMessageView : LinearLayout {
|
|||||||
var maxWidth = screenWidth - messageContentContainerLayoutParams.leftMargin - messageContentContainerLayoutParams.rightMargin
|
var maxWidth = screenWidth - messageContentContainerLayoutParams.leftMargin - messageContentContainerLayoutParams.rightMargin
|
||||||
if (profilePictureContainer.visibility != View.GONE) { maxWidth -= profilePictureContainer.width }
|
if (profilePictureContainer.visibility != View.GONE) { maxWidth -= profilePictureContainer.width }
|
||||||
// Populate content view
|
// Populate content view
|
||||||
messageContentView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster, glide, maxWidth, thread)
|
messageContentView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster, glide, maxWidth, thread, searchQuery)
|
||||||
messageContentView.delegate = contentViewDelegate
|
messageContentView.delegate = contentViewDelegate
|
||||||
onDoubleTap = { messageContentView.onContentDoubleTap?.invoke() }
|
onDoubleTap = { messageContentView.onContentDoubleTap?.invoke() }
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,62 @@
|
|||||||
|
package org.thoughtcrime.securesms.conversation.v2.search
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import kotlinx.android.synthetic.main.view_search_bottom_bar.view.*
|
||||||
|
import network.loki.messenger.R
|
||||||
|
|
||||||
|
|
||||||
|
class SearchBottomBar : LinearLayout {
|
||||||
|
private var eventListener: EventListener? = null
|
||||||
|
|
||||||
|
// region Lifecycle
|
||||||
|
constructor(context: Context) : super(context) { initialize() }
|
||||||
|
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
|
||||||
|
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
|
||||||
|
|
||||||
|
fun initialize() {
|
||||||
|
LayoutInflater.from(context).inflate(R.layout.view_search_bottom_bar, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setData(position: Int, count: Int) {
|
||||||
|
searchProgressWheel.visibility = GONE
|
||||||
|
searchUp.setOnClickListener { v: View? ->
|
||||||
|
if (eventListener != null) {
|
||||||
|
eventListener!!.onSearchMoveUpPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
searchDown.setOnClickListener { v: View? ->
|
||||||
|
if (eventListener != null) {
|
||||||
|
eventListener!!.onSearchMoveDownPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count > 0) {
|
||||||
|
searchPosition.text = resources.getString(R.string.ConversationActivity_search_position, position + 1, count)
|
||||||
|
} else {
|
||||||
|
searchPosition.text = ""
|
||||||
|
}
|
||||||
|
setViewEnabled(searchUp, position < count - 1)
|
||||||
|
setViewEnabled(searchDown, position > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showLoading() {
|
||||||
|
searchProgressWheel.visibility = VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setViewEnabled(view: View, enabled: Boolean) {
|
||||||
|
view.isEnabled = enabled
|
||||||
|
view.alpha = if (enabled) 1f else 0.25f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEventListener(eventListener: EventListener?) {
|
||||||
|
this.eventListener = eventListener
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventListener {
|
||||||
|
fun onSearchMoveUpPressed()
|
||||||
|
fun onSearchMoveDownPressed()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,111 @@
|
|||||||
|
package org.thoughtcrime.securesms.conversation.v2.search
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import org.session.libsession.utilities.Debouncer
|
||||||
|
import org.session.libsession.utilities.Util.runOnMain
|
||||||
|
import org.session.libsession.utilities.concurrent.SignalExecutors
|
||||||
|
import org.thoughtcrime.securesms.contacts.ContactAccessor
|
||||||
|
import org.thoughtcrime.securesms.database.CursorList
|
||||||
|
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||||
|
import org.thoughtcrime.securesms.search.SearchRepository
|
||||||
|
import org.thoughtcrime.securesms.search.model.MessageResult
|
||||||
|
import org.thoughtcrime.securesms.util.CloseableLiveData
|
||||||
|
import java.io.Closeable
|
||||||
|
|
||||||
|
|
||||||
|
class SearchViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val searchRepository: SearchRepository
|
||||||
|
private val result: CloseableLiveData<SearchResult>
|
||||||
|
private val debouncer: Debouncer
|
||||||
|
private var firstSearch = false
|
||||||
|
private var searchOpen = false
|
||||||
|
private var activeQuery: String? = null
|
||||||
|
private var activeThreadId: Long = 0
|
||||||
|
val searchResults: LiveData<SearchResult>
|
||||||
|
get() = result
|
||||||
|
|
||||||
|
fun onQueryUpdated(query: String, threadId: Long) {
|
||||||
|
if (query == activeQuery) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateQuery(query, threadId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onMissingResult() {
|
||||||
|
if (activeQuery != null) {
|
||||||
|
updateQuery(activeQuery!!, activeThreadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onMoveUp() {
|
||||||
|
debouncer.clear()
|
||||||
|
val messages = result.value!!.getResults() as CursorList<MessageResult?>
|
||||||
|
val position = Math.min(result.value!!.position + 1, messages.size - 1)
|
||||||
|
result.setValue(SearchResult(messages, position), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onMoveDown() {
|
||||||
|
debouncer.clear()
|
||||||
|
val messages = result.value!!.getResults() as CursorList<MessageResult?>
|
||||||
|
val position = Math.max(result.value!!.position - 1, 0)
|
||||||
|
result.setValue(SearchResult(messages, position), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSearchOpened() {
|
||||||
|
searchOpen = true
|
||||||
|
firstSearch = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSearchClosed() {
|
||||||
|
searchOpen = false
|
||||||
|
activeQuery = null
|
||||||
|
debouncer.clear()
|
||||||
|
result.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
result.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateQuery(query: String, threadId: Long) {
|
||||||
|
activeQuery = query
|
||||||
|
activeThreadId = threadId
|
||||||
|
debouncer.publish {
|
||||||
|
firstSearch = false
|
||||||
|
searchRepository.query(query, threadId) { messages: CursorList<MessageResult?> ->
|
||||||
|
runOnMain {
|
||||||
|
if (searchOpen && query == activeQuery) {
|
||||||
|
result.setValue(SearchResult(messages, 0))
|
||||||
|
} else {
|
||||||
|
messages.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchResult(private val results: CursorList<MessageResult?>, val position: Int) : Closeable {
|
||||||
|
|
||||||
|
fun getResults(): List<MessageResult?> {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
results.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
val context = application.applicationContext
|
||||||
|
result = CloseableLiveData()
|
||||||
|
debouncer = Debouncer(500)
|
||||||
|
searchRepository = SearchRepository(context,
|
||||||
|
DatabaseFactory.getSearchDatabase(context),
|
||||||
|
DatabaseFactory.getThreadDatabase(context),
|
||||||
|
ContactAccessor.getInstance(),
|
||||||
|
SignalExecutors.SERIAL)
|
||||||
|
}
|
||||||
|
}
|
@ -34,6 +34,13 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentBottom="true" />
|
android:layout_alignParentBottom="true" />
|
||||||
|
|
||||||
|
<org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar
|
||||||
|
android:id="@+id/searchBottomBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentBottom="true"
|
||||||
|
android:visibility="gone"/>
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/additionalContentContainer"
|
android:id="@+id/additionalContentContainer"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
70
app/src/main/res/layout/view_search_bottom_bar.xml
Normal file
70
app/src/main/res/layout/view_search_bottom_bar.xml
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:id="@+id/searchBottomBarConstraintLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="@dimen/input_bar_height"
|
||||||
|
android:background="@color/compose_view_background"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1px"
|
||||||
|
android:background="@color/separator" />
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/searchUp"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:padding="4dp"
|
||||||
|
android:background="?selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/ic_baseline_keyboard_arrow_up_24"
|
||||||
|
android:tint="@color/accent"
|
||||||
|
tools:ignore="UseAppTint" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/searchDown"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:padding="4dp"
|
||||||
|
android:layout_gravity="center_vertical"
|
||||||
|
android:background="?selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/ic_baseline_keyboard_arrow_down_24"
|
||||||
|
android:tint="@color/accent"
|
||||||
|
tools:ignore="UseAppTint" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/searchPosition"
|
||||||
|
style="@style/Signal.Text.Body"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_centerInParent="true"
|
||||||
|
android:text="37 of 73"
|
||||||
|
android:textStyle="bold"/>
|
||||||
|
|
||||||
|
<com.github.ybq.android.spinkit.SpinKitView
|
||||||
|
style="@style/SpinKitView.DoubleBounce"
|
||||||
|
android:id="@+id/searchProgressWheel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:background="@color/compose_view_background"
|
||||||
|
app:SpinKit_Color="?android:textColorPrimary"
|
||||||
|
android:visibility="gone"/>
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
Loading…
x
Reference in New Issue
Block a user