mirror of
https://github.com/oxen-io/session-android.git
synced 2024-12-24 00:37:47 +00:00
[SES-2018] Refactor mention (#1510)
* Refactor mention * Fixes robolectric test problem * Fixes tests * Naming and comments * Naming * Dispatcher --------- Co-authored-by: fanchao <git@fanchao.dev>
This commit is contained in:
parent
a260717d42
commit
fec67e282a
@ -368,8 +368,11 @@ dependencies {
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
|
||||
androidTestUtil 'androidx.test:orchestrator:1.4.2'
|
||||
|
||||
testImplementation 'org.robolectric:robolectric:4.4'
|
||||
testImplementation 'org.robolectric:shadows-multidex:4.4'
|
||||
testImplementation 'org.robolectric:robolectric:4.12.2'
|
||||
testImplementation 'org.robolectric:shadows-multidex:4.12.2'
|
||||
testImplementation 'org.conscrypt:conscrypt-openjdk-uber:2.5.2' // For Robolectric
|
||||
testImplementation 'app.cash.turbine:turbine:1.1.0'
|
||||
|
||||
|
||||
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
|
||||
implementation 'androidx.compose.ui:ui:1.5.2'
|
||||
|
@ -7,10 +7,8 @@ import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewUserBinding
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
|
||||
|
@ -21,7 +21,6 @@ import org.session.libsession.messaging.open_groups.OpenGroup
|
||||
import org.session.libsession.utilities.ExpirationUtil
|
||||
import org.session.libsession.utilities.modifyLayoutParams
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase
|
||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
@ -78,7 +77,6 @@ class ConversationActionBarView @JvmOverloads constructor(
|
||||
binding.profilePictureView.layoutParams = resources.getDimensionPixelSize(
|
||||
if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size
|
||||
).let { LayoutParams(it, it) }
|
||||
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadId, context)
|
||||
update(recipient, openGroup, config)
|
||||
}
|
||||
|
||||
|
@ -29,8 +29,8 @@ import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import android.view.WindowManager
|
||||
import android.widget.RelativeLayout
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@ -46,6 +46,7 @@ import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.loader.app.LoaderManager
|
||||
import androidx.loader.content.Loader
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@ -67,8 +68,6 @@ import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
||||
import org.session.libsession.messaging.jobs.JobQueue
|
||||
import org.session.libsession.messaging.mentions.Mention
|
||||
import org.session.libsession.messaging.mentions.MentionsManager
|
||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||
import org.session.libsession.messaging.messages.applyExpiryMode
|
||||
import org.session.libsession.messaging.messages.control.DataExtractionNotification
|
||||
@ -118,7 +117,8 @@ import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog
|
||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton
|
||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate
|
||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate
|
||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidatesView
|
||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidateAdapter
|
||||
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback
|
||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate
|
||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
|
||||
@ -215,6 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
@Inject lateinit var storage: Storage
|
||||
@Inject lateinit var reactionDb: ReactionDatabase
|
||||
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
|
||||
@Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory
|
||||
|
||||
private val screenshotObserver by lazy {
|
||||
ScreenshotObserver(this, Handler(Looper.getMainLooper())) {
|
||||
@ -228,7 +229,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
|
||||
.get(LinkPreviewViewModel::class.java)
|
||||
}
|
||||
private val viewModel: ConversationViewModel by viewModels {
|
||||
|
||||
private val threadId: Long by lazy {
|
||||
var threadId = intent.getLongExtra(THREAD_ID, -1L)
|
||||
if (threadId == -1L) {
|
||||
intent.getParcelableExtra<Address>(ADDRESS)?.let { it ->
|
||||
@ -248,6 +250,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
} ?: finish()
|
||||
}
|
||||
|
||||
threadId
|
||||
}
|
||||
|
||||
private val viewModel: ConversationViewModel by viewModels {
|
||||
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
|
||||
}
|
||||
private var actionMode: ActionMode? = null
|
||||
@ -260,11 +267,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
private var isLockViewExpanded = false
|
||||
private var isShowingAttachmentOptions = false
|
||||
// Mentions
|
||||
private val mentions = mutableListOf<Mention>()
|
||||
private var mentionCandidatesView: MentionCandidatesView? = null
|
||||
private var previousText: CharSequence = ""
|
||||
private var currentMentionStartIndex = -1
|
||||
private var isShowingMentionCandidatesView = false
|
||||
private val mentionViewModel: MentionViewModel by viewModels {
|
||||
mentionViewModelFactory.create(threadId)
|
||||
}
|
||||
private val mentionCandidateAdapter = MentionCandidateAdapter {
|
||||
mentionViewModel.onCandidateSelected(it.member.publicKey)
|
||||
}
|
||||
// Search
|
||||
val searchViewModel: SearchViewModel by viewModels()
|
||||
var searchViewItem: MenuItem? = null
|
||||
@ -486,6 +494,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupMentionView()
|
||||
}
|
||||
|
||||
private fun setupMentionView() {
|
||||
binding?.conversationMentionCandidates?.let { view ->
|
||||
view.adapter = mentionCandidateAdapter
|
||||
view.itemAnimator = null
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
mentionViewModel.autoCompleteState
|
||||
.collectLatest { state ->
|
||||
mentionCandidateAdapter.candidates =
|
||||
(state as? MentionViewModel.AutoCompleteState.Result)?.members.orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding?.inputBar?.setInputBarEditableFactory(mentionViewModel.editableFactory)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@ -642,23 +671,19 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
binding.inputBar.delegate = this
|
||||
binding.inputBarRecordingView.delegate = this
|
||||
// GIF button
|
||||
binding.gifButtonContainer.addView(gifButton)
|
||||
gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
||||
binding.gifButtonContainer.addView(gifButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
gifButton.onUp = { showGIFPicker() }
|
||||
gifButton.snIsEnabled = false
|
||||
// Document button
|
||||
binding.documentButtonContainer.addView(documentButton)
|
||||
documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
||||
binding.documentButtonContainer.addView(documentButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
documentButton.onUp = { showDocumentPicker() }
|
||||
documentButton.snIsEnabled = false
|
||||
// Library button
|
||||
binding.libraryButtonContainer.addView(libraryButton)
|
||||
libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
||||
binding.libraryButtonContainer.addView(libraryButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
libraryButton.onUp = { pickFromLibrary() }
|
||||
libraryButton.snIsEnabled = false
|
||||
// Camera button
|
||||
binding.cameraButtonContainer.addView(cameraButton)
|
||||
cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
||||
binding.cameraButtonContainer.addView(cameraButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
cameraButton.onUp = { showCamera() }
|
||||
cameraButton.snIsEnabled = false
|
||||
}
|
||||
@ -913,7 +938,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
if (textSecurePreferences.isLinkPreviewsEnabled()) {
|
||||
linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0)
|
||||
}
|
||||
showOrHideMentionCandidatesIfNeeded(newContent)
|
||||
if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty()
|
||||
&& !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) {
|
||||
LinkPreviewDialog {
|
||||
@ -925,76 +949,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
}
|
||||
|
||||
private fun showOrHideMentionCandidatesIfNeeded(text: CharSequence) {
|
||||
if (text.length < previousText.length) {
|
||||
currentMentionStartIndex = -1
|
||||
hideMentionCandidates()
|
||||
val mentionsToRemove = mentions.filter { !text.contains(it.displayName) }
|
||||
mentions.removeAll(mentionsToRemove)
|
||||
}
|
||||
if (text.isNotEmpty()) {
|
||||
val lastCharIndex = text.lastIndex
|
||||
val lastChar = text[lastCharIndex]
|
||||
// Check if there is whitespace before the '@' or the '@' is the first character
|
||||
val isCharacterBeforeLastWhiteSpaceOrStartOfLine: Boolean
|
||||
if (text.length == 1) {
|
||||
isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // Start of line
|
||||
} else {
|
||||
val charBeforeLast = text[lastCharIndex - 1]
|
||||
isCharacterBeforeLastWhiteSpaceOrStartOfLine = Character.isWhitespace(charBeforeLast)
|
||||
}
|
||||
if (lastChar == '@' && isCharacterBeforeLastWhiteSpaceOrStartOfLine) {
|
||||
currentMentionStartIndex = lastCharIndex
|
||||
showOrUpdateMentionCandidatesIfNeeded()
|
||||
} else if (Character.isWhitespace(lastChar) || lastChar == '@') { // the lastCharacter == "@" is to check for @@
|
||||
currentMentionStartIndex = -1
|
||||
hideMentionCandidates()
|
||||
} else if (currentMentionStartIndex != -1) {
|
||||
val query = text.substring(currentMentionStartIndex + 1) // + 1 to get rid of the "@"
|
||||
showOrUpdateMentionCandidatesIfNeeded(query)
|
||||
}
|
||||
} else {
|
||||
currentMentionStartIndex = -1
|
||||
hideMentionCandidates()
|
||||
}
|
||||
previousText = text
|
||||
}
|
||||
|
||||
private fun showOrUpdateMentionCandidatesIfNeeded(query: String = "") {
|
||||
val additionalContentContainer = binding?.additionalContentContainer ?: return
|
||||
val recipient = viewModel.recipient ?: return
|
||||
if (!isShowingMentionCandidatesView) {
|
||||
additionalContentContainer.removeAllViews()
|
||||
val view = MentionCandidatesView(this).apply {
|
||||
contentDescription = context.getString(R.string.AccessibilityId_mentions_list)
|
||||
}
|
||||
view.glide = glide
|
||||
view.onCandidateSelected = { handleMentionSelected(it) }
|
||||
additionalContentContainer.addView(view)
|
||||
val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
|
||||
this.mentionCandidatesView = view
|
||||
view.show(candidates, viewModel.threadId)
|
||||
} else {
|
||||
val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
|
||||
this.mentionCandidatesView!!.setMentionCandidates(candidates)
|
||||
}
|
||||
isShowingMentionCandidatesView = true
|
||||
}
|
||||
|
||||
private fun hideMentionCandidates() {
|
||||
if (isShowingMentionCandidatesView) {
|
||||
val mentionCandidatesView = mentionCandidatesView ?: return
|
||||
val animation = ValueAnimator.ofObject(FloatEvaluator(), mentionCandidatesView.alpha, 0.0f)
|
||||
animation.duration = 250L
|
||||
animation.addUpdateListener { animator ->
|
||||
mentionCandidatesView.alpha = animator.animatedValue as Float
|
||||
if (animator.animatedFraction == 1.0f) { binding?.additionalContentContainer?.removeAllViews() }
|
||||
}
|
||||
animation.start()
|
||||
}
|
||||
isShowingMentionCandidatesView = false
|
||||
}
|
||||
|
||||
override fun toggleAttachmentOptions() {
|
||||
val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f
|
||||
val allButtonContainers = listOfNotNull(
|
||||
@ -1510,18 +1464,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
return hitRect.contains(x, y)
|
||||
}
|
||||
|
||||
private fun handleMentionSelected(mention: Mention) {
|
||||
val binding = binding ?: return
|
||||
if (currentMentionStartIndex == -1) { return }
|
||||
mentions.add(mention)
|
||||
val previousText = binding.inputBar.text
|
||||
val newText = previousText.substring(0, currentMentionStartIndex) + "@" + mention.displayName + " "
|
||||
binding.inputBar.text = newText
|
||||
binding.inputBar.setSelection(newText.length)
|
||||
currentMentionStartIndex = -1
|
||||
hideMentionCandidates()
|
||||
this.previousText = newText
|
||||
}
|
||||
|
||||
override fun scrollToMessageIfPossible(timestamp: Long) {
|
||||
val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return
|
||||
@ -1620,10 +1562,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
binding?.inputBar?.text = ""
|
||||
binding?.inputBar?.cancelQuoteDraft()
|
||||
binding?.inputBar?.cancelLinkPreviewDraft()
|
||||
// Clear mentions
|
||||
previousText = ""
|
||||
currentMentionStartIndex = -1
|
||||
mentions.clear()
|
||||
// Put the message in the database
|
||||
message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true)
|
||||
// Send it
|
||||
@ -1668,10 +1606,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
binding?.inputBar?.text = ""
|
||||
binding?.inputBar?.cancelQuoteDraft()
|
||||
binding?.inputBar?.cancelLinkPreviewDraft()
|
||||
// Clear mentions
|
||||
previousText = ""
|
||||
currentMentionStartIndex = -1
|
||||
mentions.clear()
|
||||
// Reset the attachment manager
|
||||
attachmentManager.clear()
|
||||
// Reset attachments button if needed
|
||||
@ -2105,17 +2039,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
|
||||
// region General
|
||||
private fun getMessageBody(): String {
|
||||
var result = binding?.inputBar?.text?.trim() ?: return ""
|
||||
for (mention in mentions) {
|
||||
try {
|
||||
val startIndex = result.indexOf("@" + mention.displayName)
|
||||
val endIndex = startIndex + mention.displayName.count() + 1 // + 1 to include the "@"
|
||||
result = result.substring(0, startIndex) + "@" + mention.publicKey + result.substring(endIndex)
|
||||
} catch (exception: Exception) {
|
||||
Log.d("Loki", "Failed to process mention due to error: $exception")
|
||||
}
|
||||
}
|
||||
return result
|
||||
return mentionViewModel.normalizeMessageBody()
|
||||
}
|
||||
// endregion
|
||||
|
||||
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.PointF
|
||||
import android.net.Uri
|
||||
import android.text.Editable
|
||||
import android.text.InputType
|
||||
import android.text.TextWatcher
|
||||
import android.util.AttributeSet
|
||||
@ -224,8 +225,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
||||
binding.inputBarEditText.addTextChangedListener(textWatcher)
|
||||
}
|
||||
|
||||
fun setSelection(index: Int) {
|
||||
binding.inputBarEditText.setSelection(index)
|
||||
fun setInputBarEditableFactory(factory: Editable.Factory) {
|
||||
binding.inputBarEditText.setEditableFactory(factory)
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
@ -0,0 +1,44 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
|
||||
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||
|
||||
class MentionCandidateAdapter(
|
||||
private val onCandidateSelected: ((MentionViewModel.Candidate) -> Unit)
|
||||
) : RecyclerView.Adapter<MentionCandidateAdapter.ViewHolder>() {
|
||||
var candidates = listOf<MentionViewModel.Candidate>()
|
||||
set(newValue) {
|
||||
if (field != newValue) {
|
||||
val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
override fun getOldListSize() = field.size
|
||||
override fun getNewListSize() = newValue.size
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int)
|
||||
= field[oldItemPosition].member.publicKey == newValue[newItemPosition].member.publicKey
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int)
|
||||
= field[oldItemPosition] == newValue[newItemPosition]
|
||||
})
|
||||
|
||||
field = newValue
|
||||
result.dispatchUpdatesTo(this)
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(val binding: ViewMentionCandidateV2Binding)
|
||||
: RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return ViewHolder(ViewMentionCandidateV2Binding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = candidates.size
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val candidate = candidates[position]
|
||||
holder.binding.update(candidate)
|
||||
holder.binding.root.setOnClickListener { onCandidateSelected(candidate) }
|
||||
}
|
||||
}
|
@ -1,42 +1,14 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.RelativeLayout
|
||||
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
|
||||
import org.session.libsession.messaging.mentions.Mention
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||
|
||||
class MentionCandidateView : RelativeLayout {
|
||||
private lateinit var binding: ViewMentionCandidateV2Binding
|
||||
var candidate = Mention("", "")
|
||||
set(newValue) { field = newValue; update() }
|
||||
var glide: GlideRequests? = null
|
||||
var openGroupServer: String? = null
|
||||
var openGroupRoom: String? = null
|
||||
|
||||
constructor(context: Context) : this(context, null)
|
||||
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
|
||||
|
||||
private fun initialize() {
|
||||
binding = ViewMentionCandidateV2Binding.inflate(LayoutInflater.from(context), this, true)
|
||||
}
|
||||
|
||||
private fun update() = with(binding) {
|
||||
mentionCandidateNameTextView.text = candidate.displayName
|
||||
profilePictureView.publicKey = candidate.publicKey
|
||||
profilePictureView.displayName = candidate.displayName
|
||||
profilePictureView.additionalPublicKey = null
|
||||
profilePictureView.update()
|
||||
if (openGroupServer != null && openGroupRoom != null) {
|
||||
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey)
|
||||
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
|
||||
} else {
|
||||
moderatorIconImageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
fun ViewMentionCandidateV2Binding.update(candidate: MentionViewModel.Candidate) {
|
||||
mentionCandidateNameTextView.text = candidate.nameHighlighted
|
||||
profilePictureView.publicKey = candidate.member.publicKey
|
||||
profilePictureView.displayName = candidate.member.name
|
||||
profilePictureView.additionalPublicKey = null
|
||||
profilePictureView.update()
|
||||
moderatorIconImageView.visibility = if (candidate.member.isModerator) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
@ -1,90 +0,0 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.ListView
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.messaging.mentions.Mention
|
||||
import org.thoughtcrime.securesms.database.LokiThreadDatabase
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.util.toPx
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MentionCandidatesView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : ListView(context, attrs, defStyleAttr) {
|
||||
private var candidates = listOf<Mention>()
|
||||
set(newValue) { field = newValue; snAdapter.candidates = newValue }
|
||||
var glide: GlideRequests? = null
|
||||
set(newValue) { field = newValue; snAdapter.glide = newValue }
|
||||
var openGroupServer: String? = null
|
||||
set(newValue) { field = newValue; snAdapter.openGroupServer = openGroupServer }
|
||||
var openGroupRoom: String? = null
|
||||
set(newValue) { field = newValue; snAdapter.openGroupRoom = openGroupRoom }
|
||||
var onCandidateSelected: ((Mention) -> Unit)? = null
|
||||
|
||||
@Inject lateinit var threadDb: LokiThreadDatabase
|
||||
|
||||
private val snAdapter by lazy { Adapter(context) }
|
||||
|
||||
private class Adapter(private val context: Context) : BaseAdapter() {
|
||||
var candidates = listOf<Mention>()
|
||||
set(newValue) { field = newValue; notifyDataSetChanged() }
|
||||
var glide: GlideRequests? = null
|
||||
var openGroupServer: String? = null
|
||||
var openGroupRoom: String? = null
|
||||
|
||||
override fun getCount(): Int { return candidates.count() }
|
||||
override fun getItemId(position: Int): Long { return position.toLong() }
|
||||
override fun getItem(position: Int): Mention { return candidates[position] }
|
||||
|
||||
override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View {
|
||||
val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context).apply {
|
||||
contentDescription = context.getString(R.string.AccessibilityId_contact)
|
||||
}
|
||||
val mentionCandidate = getItem(position)
|
||||
cell.glide = glide
|
||||
cell.candidate = mentionCandidate
|
||||
cell.openGroupServer = openGroupServer
|
||||
cell.openGroupRoom = openGroupRoom
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
|
||||
constructor(context: Context) : this(context, null)
|
||||
|
||||
init {
|
||||
clipToOutline = true
|
||||
adapter = snAdapter
|
||||
snAdapter.candidates = candidates
|
||||
setOnItemClickListener { _, _, position, _ ->
|
||||
onCandidateSelected?.invoke(candidates[position])
|
||||
}
|
||||
}
|
||||
|
||||
fun show(candidates: List<Mention>, threadID: Long) {
|
||||
val openGroup = threadDb.getOpenGroupChat(threadID)
|
||||
if (openGroup != null) {
|
||||
openGroupServer = openGroup.server
|
||||
openGroupRoom = openGroup.room
|
||||
}
|
||||
setMentionCandidates(candidates)
|
||||
}
|
||||
|
||||
fun setMentionCandidates(candidates: List<Mention>) {
|
||||
this.candidates = candidates
|
||||
val layoutParams = this.layoutParams as ViewGroup.LayoutParams
|
||||
layoutParams.height = toPx(Math.min(candidates.count(), 4) * 44, resources)
|
||||
this.layoutParams = layoutParams
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
val layoutParams = this.layoutParams as ViewGroup.LayoutParams
|
||||
layoutParams.height = 0
|
||||
this.layoutParams = layoutParams
|
||||
}
|
||||
}
|
@ -0,0 +1,188 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.mention
|
||||
|
||||
import android.text.Selection
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.core.text.getSpans
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
|
||||
private const val SEARCH_QUERY_DEBOUNCE_MILLS = 100L
|
||||
|
||||
/**
|
||||
* A subclass of [SpannableStringBuilder] that provides a way to observe the mention search query,
|
||||
* and also manages the [MentionSpan] in a way that treats the mention span as a whole.
|
||||
*/
|
||||
class MentionEditable : SpannableStringBuilder() {
|
||||
private val queryChangeNotification = MutableSharedFlow<Unit>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_LATEST
|
||||
)
|
||||
|
||||
fun observeMentionSearchQuery(): Flow<SearchQuery?> {
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
return queryChangeNotification
|
||||
.debounce(SEARCH_QUERY_DEBOUNCE_MILLS)
|
||||
.onStart { emit(Unit) }
|
||||
.map { mentionSearchQuery }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
data class SearchQuery(
|
||||
val mentionSymbolStartAt: Int,
|
||||
val query: String
|
||||
)
|
||||
|
||||
val mentionSearchQuery: SearchQuery?
|
||||
get() {
|
||||
val cursorPosition = Selection.getSelectionStart(this)
|
||||
|
||||
// First, make sure we are not selecting text
|
||||
if (cursorPosition != Selection.getSelectionEnd(this)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Make sure we don't already have a mention span at the cursor position
|
||||
if (getSpans(cursorPosition, cursorPosition, MentionSpan::class.java).isNotEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Find the mention symbol '@' before the cursor position
|
||||
val symbolIndex = findEligibleMentionSymbolIndexBefore(cursorPosition - 1)
|
||||
if (symbolIndex < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// The query starts after the symbol '@' and ends at a whitespace, @ or the end
|
||||
val queryStart = symbolIndex + 1
|
||||
var queryEnd = indexOfStartingAt(queryStart) { it.isWhitespace() || it == '@' }
|
||||
if (queryEnd < 0) {
|
||||
queryEnd = length
|
||||
}
|
||||
|
||||
return SearchQuery(
|
||||
mentionSymbolStartAt = symbolIndex,
|
||||
query = subSequence(queryStart, queryEnd).toString()
|
||||
)
|
||||
}
|
||||
|
||||
override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) {
|
||||
var normalisedStart = start
|
||||
var normalisedEnd = end
|
||||
|
||||
val isSelectionStart = what == Selection.SELECTION_START
|
||||
val isSelectionEnd = what == Selection.SELECTION_END
|
||||
|
||||
if (isSelectionStart || isSelectionEnd) {
|
||||
assert(start == end) { "Selection spans must have zero length" }
|
||||
val selection = start
|
||||
|
||||
val mentionSpan = getSpans<MentionSpan>(selection, selection).firstOrNull()
|
||||
if (mentionSpan != null) {
|
||||
val spanStart = getSpanStart(mentionSpan)
|
||||
val spanEnd = getSpanEnd(mentionSpan)
|
||||
|
||||
if (isSelectionStart && selection != spanEnd) {
|
||||
// A selection start will only be adjusted to the start of the mention span,
|
||||
// if the selection start is not at the end the mention span. (A selection start
|
||||
// at the end of the mention span is considered an escape path from the mention span)
|
||||
normalisedStart = spanStart
|
||||
normalisedEnd = normalisedStart
|
||||
} else if (isSelectionEnd && selection != spanStart) {
|
||||
normalisedEnd = spanEnd
|
||||
normalisedStart = normalisedEnd
|
||||
}
|
||||
}
|
||||
|
||||
queryChangeNotification.tryEmit(Unit)
|
||||
}
|
||||
|
||||
super.setSpan(what, normalisedStart, normalisedEnd, flags)
|
||||
}
|
||||
|
||||
override fun removeSpan(what: Any?) {
|
||||
super.removeSpan(what)
|
||||
queryChangeNotification.tryEmit(Unit)
|
||||
}
|
||||
|
||||
// The only method we need to override
|
||||
override fun replace(st: Int, en: Int, source: CharSequence?, start: Int, end: Int): MentionEditable {
|
||||
// Make sure the mention span is treated like a whole
|
||||
var normalisedStart = st
|
||||
var normalisedEnd = en
|
||||
|
||||
if (st != en) {
|
||||
// Find the mention span that intersects with the replaced range, and expand the range to include it,
|
||||
// this does not apply to insertion operation (st == en)
|
||||
for (mentionSpan in getSpans(st, en, MentionSpan::class.java)) {
|
||||
val mentionStart = getSpanStart(mentionSpan)
|
||||
val mentionEnd = getSpanEnd(mentionSpan)
|
||||
|
||||
if (mentionStart < normalisedStart) {
|
||||
normalisedStart = mentionStart
|
||||
}
|
||||
if (mentionEnd > normalisedEnd) {
|
||||
normalisedEnd = mentionEnd
|
||||
}
|
||||
|
||||
removeSpan(mentionSpan)
|
||||
}
|
||||
}
|
||||
|
||||
super.replace(normalisedStart, normalisedEnd, source, start, end)
|
||||
queryChangeNotification.tryEmit(Unit)
|
||||
return this
|
||||
}
|
||||
|
||||
fun addMention(member: MentionViewModel.Member, replaceRange: IntRange) {
|
||||
val replaceWith = "@${member.name} "
|
||||
replace(replaceRange.first, replaceRange.last, replaceWith)
|
||||
setSpan(
|
||||
MentionSpan(member),
|
||||
replaceRange.first,
|
||||
replaceRange.first + replaceWith.length - 1,
|
||||
SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
|
||||
override fun delete(st: Int, en: Int) = replace(st, en, "", 0, 0)
|
||||
|
||||
private fun findEligibleMentionSymbolIndexBefore(offset: Int): Int {
|
||||
if (isEmpty()) {
|
||||
return -1
|
||||
}
|
||||
|
||||
var i = offset.coerceIn(indices)
|
||||
while (i >= 0) {
|
||||
val c = get(i)
|
||||
if (c == '@') {
|
||||
// Make sure there is no more '@' before this one or it's disqualified
|
||||
if (i > 0 && get(i - 1) == '@') {
|
||||
return -1
|
||||
}
|
||||
|
||||
return i
|
||||
} else if (c.isWhitespace()) {
|
||||
break
|
||||
}
|
||||
i--
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
private fun CharSequence.indexOfStartingAt(offset: Int, predicate: (Char) -> Boolean): Int {
|
||||
var i = offset.coerceIn(0..length)
|
||||
while (i < length) {
|
||||
if (predicate(get(i))) {
|
||||
return i
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.mention
|
||||
|
||||
/**
|
||||
* A span that represents a mention in the text.
|
||||
*/
|
||||
class MentionSpan(
|
||||
val member: MentionViewModel.Member
|
||||
)
|
@ -0,0 +1,274 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.mention
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.graphics.Typeface
|
||||
import android.text.Editable
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.style.StyleSpan
|
||||
import androidx.core.text.getSpans
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders.Conversation
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase
|
||||
import org.thoughtcrime.securesms.database.GroupMemberDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||
import org.thoughtcrime.securesms.database.SessionContactDatabase
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.util.observeChanges
|
||||
|
||||
/**
|
||||
* A ViewModel that provides the mention search functionality for a text input.
|
||||
*
|
||||
* To use this ViewModel, you (a view) will need to:
|
||||
* 1. Observe the [autoCompleteState] to get the mention search results.
|
||||
* 2. Set the EditText's editable factory to [editableFactory], via [android.widget.EditText.setEditableFactory]
|
||||
*/
|
||||
class MentionViewModel(
|
||||
threadID: Long,
|
||||
contentResolver: ContentResolver,
|
||||
threadDatabase: ThreadDatabase,
|
||||
groupDatabase: GroupDatabase,
|
||||
mmsDatabase: MmsDatabase,
|
||||
contactDatabase: SessionContactDatabase,
|
||||
memberDatabase: GroupMemberDatabase,
|
||||
storage: Storage,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : ViewModel() {
|
||||
private val editable = MentionEditable()
|
||||
|
||||
/**
|
||||
* A factory that creates a new [Editable] instance that is backed by the same source of truth
|
||||
* used by this viewModel.
|
||||
*/
|
||||
val editableFactory = object : Editable.Factory() {
|
||||
override fun newEditable(source: CharSequence?): Editable {
|
||||
if (source === editable) {
|
||||
return source
|
||||
}
|
||||
|
||||
if (source != null) {
|
||||
editable.replace(0, editable.length, source)
|
||||
}
|
||||
|
||||
return editable
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
private val members: StateFlow<List<Member>?> =
|
||||
(contentResolver.observeChanges(Conversation.getUriForThread(threadID)) as Flow<Any?>)
|
||||
.debounce(500L)
|
||||
.onStart { emit(Unit) }
|
||||
.mapLatest {
|
||||
val recipient = checkNotNull(threadDatabase.getRecipientForThreadId(threadID)) {
|
||||
"Recipient not found for thread ID: $threadID"
|
||||
}
|
||||
|
||||
val memberIDs = when {
|
||||
recipient.isClosedGroupRecipient -> {
|
||||
groupDatabase.getGroupMemberAddresses(recipient.address.toGroupString(), false)
|
||||
.map { it.serialize() }
|
||||
}
|
||||
|
||||
recipient.isCommunityRecipient -> mmsDatabase.getRecentChatMemberIDs(threadID, 20)
|
||||
recipient.isContactRecipient -> listOf(recipient.address.serialize())
|
||||
else -> listOf()
|
||||
}
|
||||
|
||||
val moderatorIDs = if (recipient.isCommunityRecipient) {
|
||||
val groupId = storage.getOpenGroup(threadID)?.id
|
||||
if (groupId.isNullOrBlank()) {
|
||||
emptySet()
|
||||
} else {
|
||||
memberDatabase.getGroupMembersRoles(groupId, memberIDs)
|
||||
.mapNotNullTo(hashSetOf()) { (memberId, roles) ->
|
||||
memberId.takeIf { roles.any { it.isModerator } }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
val contactContext = if (recipient.isCommunityRecipient) {
|
||||
Contact.ContactContext.OPEN_GROUP
|
||||
} else {
|
||||
Contact.ContactContext.REGULAR
|
||||
}
|
||||
|
||||
contactDatabase.getContacts(memberIDs).map { contact ->
|
||||
Member(
|
||||
publicKey = contact.sessionID,
|
||||
name = contact.displayName(contactContext).orEmpty(),
|
||||
isModerator = contact.sessionID in moderatorIDs,
|
||||
)
|
||||
}
|
||||
}
|
||||
.flowOn(dispatcher)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(10_000L), null)
|
||||
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val autoCompleteState: StateFlow<AutoCompleteState> = editable
|
||||
.observeMentionSearchQuery()
|
||||
.flatMapLatest { query ->
|
||||
if (query == null) {
|
||||
return@flatMapLatest flowOf(AutoCompleteState.Idle)
|
||||
}
|
||||
|
||||
members.mapLatest { members ->
|
||||
if (members == null) {
|
||||
return@mapLatest AutoCompleteState.Loading
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Default) {
|
||||
val filtered = if (query.query.isBlank()) {
|
||||
members.mapTo(mutableListOf()) { Candidate(it, it.name, 0) }
|
||||
} else {
|
||||
members.mapNotNullTo(mutableListOf()) { searchAndHighlight(it, query.query) }
|
||||
}
|
||||
|
||||
filtered.sortWith(Candidate.MENTION_LIST_COMPARATOR)
|
||||
AutoCompleteState.Result(filtered, query.query)
|
||||
}
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), AutoCompleteState.Idle)
|
||||
|
||||
private fun searchAndHighlight(
|
||||
haystack: Member,
|
||||
needle: String
|
||||
): Candidate? {
|
||||
val startIndex = haystack.name.indexOf(needle, ignoreCase = true)
|
||||
|
||||
return if (startIndex >= 0) {
|
||||
val endIndex = startIndex + needle.length
|
||||
val spanned = SpannableStringBuilder(haystack.name)
|
||||
spanned.setSpan(
|
||||
StyleSpan(Typeface.BOLD),
|
||||
startIndex,
|
||||
endIndex,
|
||||
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
Candidate(member = haystack, nameHighlighted = spanned, matchScore = startIndex)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun onCandidateSelected(candidatePublicKey: String) {
|
||||
val query = editable.mentionSearchQuery ?: return
|
||||
val autoCompleteState = autoCompleteState.value as? AutoCompleteState.Result ?: return
|
||||
val candidate = autoCompleteState.members.find { it.member.publicKey == candidatePublicKey } ?: return
|
||||
|
||||
editable.addMention(
|
||||
candidate.member,
|
||||
query.mentionSymbolStartAt .. (query.mentionSymbolStartAt + query.query.length + 1)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a message body, normalize it by replacing the display name following '@' with their public key.
|
||||
*
|
||||
* As "@123456" is the standard format for mentioning a user, this method will replace "@Alice" with "@123456"
|
||||
*/
|
||||
fun normalizeMessageBody(): String {
|
||||
val spansWithRanges = editable.getSpans<MentionSpan>()
|
||||
.mapTo(mutableListOf()) { span ->
|
||||
span to (editable.getSpanStart(span)..editable.getSpanEnd(span))
|
||||
}
|
||||
|
||||
spansWithRanges.sortBy { it.second.first }
|
||||
|
||||
val sb = StringBuilder()
|
||||
var offset = 0
|
||||
for ((span, range) in spansWithRanges) {
|
||||
// Add content before the mention span
|
||||
sb.append(editable, offset, range.first)
|
||||
|
||||
// Replace the mention span with "@public key"
|
||||
sb.append('@').append(span.member.publicKey).append(' ')
|
||||
|
||||
offset = range.last + 1
|
||||
}
|
||||
|
||||
// Add the remaining content
|
||||
sb.append(editable, offset, editable.length)
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
data class Member(
|
||||
val publicKey: String,
|
||||
val name: String,
|
||||
val isModerator: Boolean,
|
||||
)
|
||||
|
||||
data class Candidate(
|
||||
val member: Member,
|
||||
// The name with the matching keyword highlighted.
|
||||
val nameHighlighted: CharSequence,
|
||||
// The score of matching the query keyword. Lower is better.
|
||||
val matchScore: Int,
|
||||
) {
|
||||
companion object {
|
||||
val MENTION_LIST_COMPARATOR = compareBy<Candidate> { it.matchScore }
|
||||
.then(compareBy { it.member.name })
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface AutoCompleteState {
|
||||
object Idle : AutoCompleteState
|
||||
object Loading : AutoCompleteState
|
||||
data class Result(val members: List<Candidate>, val query: String) : AutoCompleteState
|
||||
object Error : AutoCompleteState
|
||||
}
|
||||
|
||||
@dagger.assisted.AssistedFactory
|
||||
interface AssistedFactory {
|
||||
fun create(threadId: Long): Factory
|
||||
}
|
||||
|
||||
class Factory @AssistedInject constructor(
|
||||
@Assisted private val threadId: Long,
|
||||
private val contentResolver: ContentResolver,
|
||||
private val threadDatabase: ThreadDatabase,
|
||||
private val groupDatabase: GroupDatabase,
|
||||
private val mmsDatabase: MmsDatabase,
|
||||
private val contactDatabase: SessionContactDatabase,
|
||||
private val storage: Storage,
|
||||
private val memberDatabase: GroupMemberDatabase,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return MentionViewModel(
|
||||
threadID = threadId,
|
||||
contentResolver = contentResolver,
|
||||
threadDatabase = threadDatabase,
|
||||
groupDatabase = groupDatabase,
|
||||
mmsDatabase = mmsDatabase,
|
||||
contactDatabase = contactDatabase,
|
||||
memberDatabase = memberDatabase,
|
||||
storage = storage,
|
||||
) as T
|
||||
}
|
||||
}
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.utilities
|
||||
|
||||
import android.content.Context
|
||||
import org.session.libsession.messaging.mentions.MentionsManager
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
|
||||
object MentionManagerUtilities {
|
||||
|
||||
fun populateUserPublicKeyCacheIfNeeded(threadID: Long, context: Context) {
|
||||
val result = mutableSetOf<String>()
|
||||
val recipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID) ?: return
|
||||
if (recipient.address.isClosedGroup) {
|
||||
val members = DatabaseComponent.get(context).groupDatabase().getGroupMembers(recipient.address.toGroupString(), false).map { it.address.serialize() }
|
||||
result.addAll(members)
|
||||
} else {
|
||||
val messageDatabase = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
val reader = messageDatabase.readerFor(messageDatabase.getConversation(threadID, true, 0, 200))
|
||||
var record: MessageRecord? = reader.next
|
||||
while (record != null) {
|
||||
result.add(record.individualRecipient.address.serialize())
|
||||
try {
|
||||
record = reader.next
|
||||
} catch (exception: Exception) {
|
||||
record = null
|
||||
}
|
||||
}
|
||||
reader.close()
|
||||
result.add(TextSecurePreferences.getLocalNumber(context)!!)
|
||||
}
|
||||
MentionsManager.userPublicKeyCache[threadID] = result
|
||||
}
|
||||
}
|
@ -3,9 +3,12 @@ package org.thoughtcrime.securesms.database
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import org.json.JSONArray
|
||||
import org.session.libsession.messaging.open_groups.GroupMember
|
||||
import org.session.libsession.messaging.open_groups.GroupMemberRole
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
import org.thoughtcrime.securesms.util.asSequence
|
||||
import java.util.EnumSet
|
||||
|
||||
class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
|
||||
|
||||
@ -51,6 +54,19 @@ class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
||||
return mappings.map { it.role }
|
||||
}
|
||||
|
||||
fun getGroupMembersRoles(groupId: String, memberIDs: Collection<String>): Map<String, List<GroupMemberRole>> {
|
||||
val sql = """
|
||||
SELECT * FROM $TABLE_NAME
|
||||
WHERE $GROUP_ID = ? AND $PROFILE_ID IN (SELECT value FROM json_each(?))
|
||||
""".trimIndent()
|
||||
|
||||
return readableDatabase.rawQuery(sql, groupId, JSONArray(memberIDs).toString()).use { cursor ->
|
||||
cursor.asSequence()
|
||||
.map { readGroupMember(it) }
|
||||
.groupBy(keySelector = { it.profileId }, valueTransform = { it.role })
|
||||
}
|
||||
}
|
||||
|
||||
fun setGroupMembers(members: List<GroupMember>) {
|
||||
writableDatabase.beginTransaction()
|
||||
try {
|
||||
|
@ -218,6 +218,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
return cursor
|
||||
}
|
||||
|
||||
fun getRecentChatMemberIDs(threadID: Long, limit: Int): List<String> {
|
||||
val sql = """
|
||||
SELECT DISTINCT $ADDRESS FROM $TABLE_NAME
|
||||
WHERE $THREAD_ID = ?
|
||||
ORDER BY $DATE_SENT DESC
|
||||
LIMIT $limit
|
||||
""".trimIndent()
|
||||
|
||||
return databaseHelper.readableDatabase.rawQuery(sql, threadID).use { cursor ->
|
||||
cursor.asSequence()
|
||||
.map { it.getString(0) }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
val expireStartedMessages: Reader
|
||||
get() {
|
||||
val where = "$EXPIRE_STARTED > 0"
|
||||
|
@ -4,6 +4,7 @@ import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import androidx.core.database.getStringOrNull
|
||||
import org.json.JSONArray
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.utilities.SessionId
|
||||
import org.session.libsignal.utilities.Base64
|
||||
@ -41,6 +42,15 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
|
||||
}
|
||||
}
|
||||
|
||||
fun getContacts(sessionIDs: Collection<String>): List<Contact> {
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.getAll(
|
||||
sessionContactTable,
|
||||
"$sessionID IN (SELECT value FROM json_each(?))",
|
||||
arrayOf(JSONArray(sessionIDs).toString())
|
||||
) { cursor -> contactFromCursor(cursor) }
|
||||
}
|
||||
|
||||
fun getAllContacts(): Set<Contact> {
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.getAll(sessionContactTable, null, null) { cursor ->
|
||||
|
@ -162,13 +162,7 @@ object OpenGroupManager {
|
||||
val memberDatabase = DatabaseComponent.get(context).groupMemberDatabase()
|
||||
val standardRoles = memberDatabase.getGroupMemberRoles(groupId, standardPublicKey)
|
||||
val blindedRoles = blindedPublicKey?.let { memberDatabase.getGroupMemberRoles(groupId, it) } ?: emptyList()
|
||||
|
||||
// roles to check against
|
||||
val moderatorRoles = listOf(
|
||||
GroupMemberRole.MODERATOR, GroupMemberRole.ADMIN,
|
||||
GroupMemberRole.HIDDEN_MODERATOR, GroupMemberRole.HIDDEN_ADMIN
|
||||
)
|
||||
return standardRoles.any { it in moderatorRoles } || blindedRoles.any { it in moderatorRoles }
|
||||
return standardRoles.any { it.isModerator } || blindedRoles.any { it.isModerator }
|
||||
}
|
||||
|
||||
}
|
@ -56,7 +56,6 @@ import org.session.libsignal.utilities.Util;
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.thoughtcrime.securesms.contacts.ContactUtil;
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2;
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities;
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities;
|
||||
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
|
||||
import org.thoughtcrime.securesms.database.LokiThreadDatabase;
|
||||
@ -348,7 +347,6 @@ public class DefaultMessageNotifier implements MessageNotifier {
|
||||
|
||||
builder.setThread(notifications.get(0).getRecipient());
|
||||
builder.setMessageCount(notificationState.getMessageCount());
|
||||
MentionManagerUtilities.INSTANCE.populateUserPublicKeyCacheIfNeeded(notifications.get(0).getThreadId(),context);
|
||||
|
||||
// TODO: Removing highlighting mentions in the notification because this context is the libsession one which
|
||||
// TODO: doesn't have access to the `R.attr.message_sent_text_color` and `R.attr.message_received_text_color`
|
||||
|
@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:focusable="false"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
@ -13,6 +13,9 @@
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@+id/conversationRecyclerView"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
android:background="?colorPrimary"
|
||||
app:contentInsetStart="0dp">
|
||||
|
||||
@ -31,9 +34,11 @@
|
||||
android:focusable="false"
|
||||
android:id="@+id/conversationRecyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_above="@+id/typingIndicatorViewContainer"
|
||||
android:layout_below="@id/toolbar" />
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintVertical_weight="1"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@+id/typingIndicatorViewContainer"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar" />
|
||||
|
||||
|
||||
<org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorViewContainer
|
||||
@ -42,20 +47,27 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="36dp"
|
||||
android:visibility="gone"
|
||||
android:layout_above="@+id/textSendAfterApproval"
|
||||
tools:visibility="visible"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/conversationRecyclerView"
|
||||
app:layout_constraintBottom_toTopOf="@+id/textSendAfterApproval"
|
||||
/>
|
||||
|
||||
<org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar
|
||||
android:id="@+id/inputBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true" />
|
||||
tools:layout_height="60dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/messageRequestBar"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
/>
|
||||
|
||||
<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"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<FrameLayout
|
||||
@ -75,11 +87,18 @@
|
||||
android:inflatedId="@+id/conversation_reaction_scrubber"
|
||||
android:layout="@layout/conversation_reaction_scrubber"/>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/additionalContentContainer"
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/conversation_mention_candidates"
|
||||
android:clipToOutline="true"
|
||||
android:contentDescription="@string/AccessibilityId_mentions_list"
|
||||
tools:listitem="@layout/view_mention_candidate_v2"
|
||||
android:background="@drawable/mention_candidate_view_background"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignBottom="@+id/conversationRecyclerView"/>
|
||||
tools:visibility="gone"
|
||||
app:layout_constraintHeight_max="176dp"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/conversationRecyclerView" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/attachmentOptionsContainer"
|
||||
@ -87,19 +106,19 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/small_spacing"
|
||||
android:elevation="8dp"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_marginBottom="60dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@+id/inputBar"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<RelativeLayout
|
||||
<FrameLayout
|
||||
android:id="@+id/gifButtonContainer"
|
||||
android:layout_width="@dimen/input_bar_button_expanded_size"
|
||||
android:layout_height="@dimen/input_bar_button_expanded_size"
|
||||
android:contentDescription="@string/AccessibilityId_gif_button"
|
||||
android:alpha="0" />
|
||||
|
||||
<RelativeLayout
|
||||
<FrameLayout
|
||||
android:id="@+id/documentButtonContainer"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_width="@dimen/input_bar_button_expanded_size"
|
||||
@ -107,7 +126,7 @@
|
||||
android:contentDescription="@string/AccessibilityId_documents_folder"
|
||||
android:alpha="0" />
|
||||
|
||||
<RelativeLayout
|
||||
<FrameLayout
|
||||
android:id="@+id/libraryButtonContainer"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_width="@dimen/input_bar_button_expanded_size"
|
||||
@ -115,7 +134,7 @@
|
||||
android:contentDescription="@string/AccessibilityId_images_folder"
|
||||
android:alpha="0" />
|
||||
|
||||
<RelativeLayout
|
||||
<FrameLayout
|
||||
android:id="@+id/cameraButtonContainer"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_width="@dimen/input_bar_button_expanded_size"
|
||||
@ -129,22 +148,26 @@
|
||||
android:id="@+id/textSendAfterApproval"
|
||||
android:text="@string/ConversationActivity_send_after_approval"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@color/classic_light_2"
|
||||
android:padding="22dp"
|
||||
android:textSize="12sp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignWithParentIfMissing="true"
|
||||
android:layout_above="@id/messageRequestBar"/>
|
||||
tools:text="You'll be able to send"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/typingIndicatorViewContainer"
|
||||
app:layout_constraintBottom_toTopOf="@+id/messageRequestBar" />
|
||||
|
||||
<RelativeLayout
|
||||
android:id="@+id/scrollToBottomButton"
|
||||
tools:visibility="visible"
|
||||
android:visibility="gone"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_above="@+id/messageRequestBar"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@+id/messageRequestBar"
|
||||
android:layout_alignWithParentIfMissing="true"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:layout_marginBottom="32dp">
|
||||
@ -197,14 +220,14 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="-12dp"
|
||||
android:visibility="gone"
|
||||
android:layout_alignParentBottom="true" />
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
<RelativeLayout
|
||||
<FrameLayout
|
||||
android:id="@+id/blockedBanner"
|
||||
android:contentDescription="@string/AccessibilityId_blocked_banner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@+id/toolbar"
|
||||
app:layout_constraintTop_toBottomOf="@+id/toolbar"
|
||||
android:background="@color/destructive"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible">
|
||||
@ -214,20 +237,20 @@
|
||||
android:contentDescription="@string/AccessibilityId_blocked_banner_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerInParent="true"
|
||||
android:layout_gravity="center"
|
||||
android:layout_margin="@dimen/medium_spacing"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="@dimen/small_font_size"
|
||||
android:textStyle="bold"
|
||||
tools:text="Elon is blocked. Unblock them?" />
|
||||
|
||||
</RelativeLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<RelativeLayout
|
||||
<FrameLayout
|
||||
android:id="@+id/outdatedBanner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@+id/blockedBanner"
|
||||
app:layout_constraintTop_toBottomOf="@+id/blockedBanner"
|
||||
android:background="@color/outdated_client_banner_background_color"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible">
|
||||
@ -237,14 +260,14 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:layout_centerInParent="true"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginVertical="@dimen/very_small_spacing"
|
||||
android:layout_marginHorizontal="@dimen/medium_spacing"
|
||||
android:textColor="@color/black"
|
||||
android:textSize="@dimen/tiny_font_size"
|
||||
tools:text="This user's client is outdated, things may not work as expected" />
|
||||
|
||||
</RelativeLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:padding="@dimen/medium_spacing"
|
||||
@ -254,7 +277,7 @@
|
||||
android:id="@+id/placeholderText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@+id/blockedBanner"
|
||||
app:layout_constraintTop_toBottomOf="@+id/outdatedBanner"
|
||||
android:elevation="8dp"
|
||||
tools:text="@string/activity_conversation_empty_state_default"
|
||||
/>
|
||||
@ -263,11 +286,12 @@
|
||||
android:id="@+id/messageRequestBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_above="@id/inputBar"
|
||||
app:layout_constraintBottom_toTopOf="@+id/inputBar"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textSendAfterApproval"
|
||||
android:layout_marginBottom="@dimen/large_spacing"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible">
|
||||
tools:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/messageRequestBlock"
|
||||
@ -321,4 +345,4 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</RelativeLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
@ -1,9 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:background="@drawable/mention_candidate_view_background">
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@ -42,6 +41,7 @@
|
||||
android:textSize="@dimen/small_font_size"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:maxLines="1"
|
||||
tools:text="Alice"
|
||||
android:contentDescription="@string/AccessibilityId_contact_mentions"
|
||||
android:ellipsize="end" />
|
||||
|
||||
|
@ -0,0 +1,115 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.text.Editable
|
||||
import android.text.Selection
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.thoughtcrime.securesms.conversation.v2.mention.MentionEditable
|
||||
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MentionEditableTest {
|
||||
private lateinit var mentionEditable: MentionEditable
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mentionEditable = MentionEditable()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not have query when there is no 'at' symbol`() = runTest {
|
||||
mentionEditable.observeMentionSearchQuery().test {
|
||||
assertThat(awaitItem()).isNull()
|
||||
mentionEditable.simulateTyping("Some text")
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should have empty query after typing 'at' symbol`() = runTest {
|
||||
mentionEditable.observeMentionSearchQuery().test {
|
||||
assertThat(awaitItem()).isNull()
|
||||
|
||||
mentionEditable.simulateTyping("Some text")
|
||||
expectNoEvents()
|
||||
|
||||
mentionEditable.simulateTyping("@")
|
||||
assertThat(awaitItem())
|
||||
.isEqualTo(MentionEditable.SearchQuery(9, ""))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should have some query after typing words following 'at' symbol`() = runTest {
|
||||
mentionEditable.observeMentionSearchQuery().test {
|
||||
assertThat(awaitItem()).isNull()
|
||||
|
||||
mentionEditable.simulateTyping("Some text")
|
||||
expectNoEvents()
|
||||
|
||||
mentionEditable.simulateTyping("@words")
|
||||
assertThat(awaitItem())
|
||||
.isEqualTo(MentionEditable.SearchQuery(9, "words"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should cancel query after a whitespace or another 'at' is typed`() = runTest {
|
||||
mentionEditable.observeMentionSearchQuery().test {
|
||||
assertThat(awaitItem()).isNull()
|
||||
|
||||
mentionEditable.simulateTyping("@words")
|
||||
assertThat(awaitItem())
|
||||
.isEqualTo(MentionEditable.SearchQuery(0, "words"))
|
||||
|
||||
mentionEditable.simulateTyping(" ")
|
||||
assertThat(awaitItem())
|
||||
.isNull()
|
||||
|
||||
mentionEditable.simulateTyping("@query@")
|
||||
assertThat(awaitItem())
|
||||
.isEqualTo(MentionEditable.SearchQuery(13, ""))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should move pass the whole span while moving cursor around mentioned block `() {
|
||||
mentionEditable.append("Mention @user here")
|
||||
mentionEditable.addMention(MentionViewModel.Member("user", "User", false), 8..14)
|
||||
|
||||
// Put cursor right before @user, it should then select nothing
|
||||
Selection.setSelection(mentionEditable, 8)
|
||||
assertThat(mentionEditable.selection()).isEqualTo(intArrayOf(8, 8))
|
||||
|
||||
// Put cursor right after '@', it should then select the whole @user
|
||||
Selection.setSelection(mentionEditable, 9)
|
||||
assertThat(mentionEditable.selection()).isEqualTo(intArrayOf(8, 13))
|
||||
|
||||
// Put cursor right after @user, it should then select nothing
|
||||
Selection.setSelection(mentionEditable, 13)
|
||||
assertThat(mentionEditable.selection()).isEqualTo(intArrayOf(13, 13))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should delete the whole mention block while deleting only part of it`() {
|
||||
mentionEditable.append("Mention @user here")
|
||||
mentionEditable.addMention(MentionViewModel.Member("user", "User", false), 8..14)
|
||||
|
||||
mentionEditable.delete(8, 9)
|
||||
assertThat(mentionEditable.toString()).isEqualTo("Mention here")
|
||||
}
|
||||
}
|
||||
|
||||
private fun CharSequence.selection(): IntArray {
|
||||
return intArrayOf(Selection.getSelectionStart(this), Selection.getSelectionEnd(this))
|
||||
}
|
||||
|
||||
private fun Editable.simulateTyping(text: String) {
|
||||
this.append(text)
|
||||
Selection.setSelection(this, this.length)
|
||||
}
|
@ -0,0 +1,185 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.text.Selection
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.doAnswer
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.mock
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.open_groups.GroupMemberRole
|
||||
import org.session.libsession.messaging.open_groups.OpenGroup
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.MainCoroutineRule
|
||||
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MentionViewModelTest {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@get:Rule
|
||||
val mainCoroutineRule = MainCoroutineRule()
|
||||
|
||||
private lateinit var mentionViewModel: MentionViewModel
|
||||
|
||||
private val threadID = 123L
|
||||
|
||||
private data class MemberInfo(
|
||||
val name: String,
|
||||
val pubKey: String,
|
||||
val roles: List<GroupMemberRole>
|
||||
)
|
||||
|
||||
private val threadMembers = listOf(
|
||||
MemberInfo("Alice", "pubkey1", listOf(GroupMemberRole.ADMIN)),
|
||||
MemberInfo("Bob", "pubkey2", listOf(GroupMemberRole.STANDARD)),
|
||||
MemberInfo("Charlie", "pubkey3", listOf(GroupMemberRole.MODERATOR)),
|
||||
MemberInfo("David", "pubkey4", listOf(GroupMemberRole.HIDDEN_ADMIN)),
|
||||
MemberInfo("Eve", "pubkey5", listOf(GroupMemberRole.HIDDEN_MODERATOR)),
|
||||
MemberInfo("李云海", "pubkey6", listOf(GroupMemberRole.ZOOMBIE)),
|
||||
)
|
||||
|
||||
private val memberContacts = threadMembers.map { m ->
|
||||
Contact(m.pubKey).also {
|
||||
it.name = m.name
|
||||
}
|
||||
}
|
||||
|
||||
private val openGroup = OpenGroup(
|
||||
server = "",
|
||||
room = "",
|
||||
id = "open_group_id_1",
|
||||
name = "Open Group",
|
||||
publicKey = "",
|
||||
imageId = null,
|
||||
infoUpdates = 0,
|
||||
canWrite = true
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
mentionViewModel = MentionViewModel(
|
||||
threadID,
|
||||
contentResolver = mock { },
|
||||
threadDatabase = mock {
|
||||
on { getRecipientForThreadId(threadID) } doAnswer {
|
||||
mock<Recipient> {
|
||||
on { isClosedGroupRecipient } doReturn false
|
||||
on { isCommunityRecipient } doReturn true
|
||||
on { isContactRecipient } doReturn false
|
||||
}
|
||||
}
|
||||
},
|
||||
groupDatabase = mock {
|
||||
},
|
||||
mmsDatabase = mock {
|
||||
on { getRecentChatMemberIDs(eq(threadID), any()) } doAnswer {
|
||||
val limit = it.arguments[1] as Int
|
||||
threadMembers.take(limit).map { m -> m.pubKey }
|
||||
}
|
||||
},
|
||||
contactDatabase = mock {
|
||||
on { getContacts(any()) } doAnswer {
|
||||
val ids = it.arguments[0] as Collection<String>
|
||||
memberContacts.filter { contact -> contact.sessionID in ids }
|
||||
}
|
||||
},
|
||||
memberDatabase = mock {
|
||||
on { getGroupMembersRoles(eq(openGroup.id), any()) } doAnswer {
|
||||
val memberIDs = it.arguments[1] as Collection<String>
|
||||
memberIDs.associateWith { id ->
|
||||
threadMembers.first { m -> m.pubKey == id }.roles
|
||||
}
|
||||
}
|
||||
},
|
||||
storage = mock {
|
||||
on { getOpenGroup(threadID) } doReturn openGroup
|
||||
},
|
||||
dispatcher = StandardTestDispatcher()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should show candidates after 'at' symbol`() = runTest {
|
||||
mentionViewModel.autoCompleteState.test {
|
||||
assertThat(awaitItem())
|
||||
.isEqualTo(MentionViewModel.AutoCompleteState.Idle)
|
||||
|
||||
val editable = mentionViewModel.editableFactory.newEditable("")
|
||||
editable.append("Hello @")
|
||||
expectNoEvents() // Nothing should happen before cursor is put after @
|
||||
Selection.setSelection(editable, editable.length)
|
||||
|
||||
assertThat(awaitItem())
|
||||
.isEqualTo(MentionViewModel.AutoCompleteState.Loading)
|
||||
|
||||
// Should show all the candidates
|
||||
awaitItem().let { result ->
|
||||
assertThat(result)
|
||||
.isInstanceOf(MentionViewModel.AutoCompleteState.Result::class.java)
|
||||
result as MentionViewModel.AutoCompleteState.Result
|
||||
|
||||
assertThat(result.members).isEqualTo(threadMembers.mapIndexed { index, m ->
|
||||
val name =
|
||||
memberContacts[index].displayName(Contact.ContactContext.OPEN_GROUP).orEmpty()
|
||||
|
||||
MentionViewModel.Candidate(
|
||||
MentionViewModel.Member(m.pubKey, name, m.roles.any { it.isModerator }),
|
||||
name,
|
||||
0
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// Continue typing to filter candidates
|
||||
editable.append("li")
|
||||
Selection.setSelection(editable, editable.length)
|
||||
|
||||
// Should show only Alice and Charlie
|
||||
awaitItem().let { result ->
|
||||
assertThat(result)
|
||||
.isInstanceOf(MentionViewModel.AutoCompleteState.Result::class.java)
|
||||
result as MentionViewModel.AutoCompleteState.Result
|
||||
|
||||
assertThat(result.members[0].member.name).isEqualTo("Alice (pubk...key1)")
|
||||
assertThat(result.members[1].member.name).isEqualTo("Charlie (pubk...key3)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should have normalised message with candidates selected`() = runTest {
|
||||
mentionViewModel.autoCompleteState.test {
|
||||
assertThat(awaitItem())
|
||||
.isEqualTo(MentionViewModel.AutoCompleteState.Idle)
|
||||
|
||||
val editable = mentionViewModel.editableFactory.newEditable("")
|
||||
editable.append("Hi @")
|
||||
Selection.setSelection(editable, editable.length)
|
||||
|
||||
assertThat(awaitItem())
|
||||
.isEqualTo(MentionViewModel.AutoCompleteState.Loading)
|
||||
|
||||
// Select a candidate now
|
||||
assertThat(awaitItem())
|
||||
.isInstanceOf(MentionViewModel.AutoCompleteState.Result::class.java)
|
||||
mentionViewModel.onCandidateSelected("pubkey1")
|
||||
|
||||
// Should have normalised message with selected candidate
|
||||
assertThat(mentionViewModel.normalizeMessageBody())
|
||||
.isEqualTo("Hi @pubkey1 ")
|
||||
}
|
||||
}
|
||||
}
|
4
app/src/test/resources/TestAndroidManifest.xml
Normal file
4
app/src/test/resources/TestAndroidManifest.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
3
app/src/test/resources/robolectric.properties
Normal file
3
app/src/test/resources/robolectric.properties
Normal file
@ -0,0 +1,3 @@
|
||||
manifest=TestAndroidManifest.xml
|
||||
sdk=34
|
||||
application=android.app.Application
|
@ -6,6 +6,11 @@ data class GroupMember(
|
||||
val role: GroupMemberRole
|
||||
)
|
||||
|
||||
enum class GroupMemberRole {
|
||||
STANDARD, ZOOMBIE, MODERATOR, ADMIN, HIDDEN_MODERATOR, HIDDEN_ADMIN
|
||||
enum class GroupMemberRole(val isModerator: Boolean = false) {
|
||||
STANDARD,
|
||||
ZOOMBIE,
|
||||
MODERATOR(true),
|
||||
ADMIN(true),
|
||||
HIDDEN_MODERATOR(true),
|
||||
HIDDEN_ADMIN(true),
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user