diff --git a/app/build.gradle b/app/build.gradle
index eb2c16e953..aa3ad4e779 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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'
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt
index 36a8c1adf5..b46243eeb0 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt
@@ -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
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt
index 184869b9ad..8f2da7a733 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt
@@ -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)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
index d5e8c43fd9..7fdca6936e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
@@ -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)?.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()
- 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
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt
index c036ed38b4..2304149367 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt
@@ -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
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateAdapter.kt
new file mode 100644
index 0000000000..daed43ce74
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateAdapter.kt
@@ -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() {
+ var candidates = listOf()
+ 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) }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt
index 2d8f745967..f790e7f1c6 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt
@@ -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
- }
- }
-}
\ No newline at end of file
+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
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt
deleted file mode 100644
index e62f7f8f85..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt
+++ /dev/null
@@ -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()
- 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()
- 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, threadID: Long) {
- val openGroup = threadDb.getOpenGroupChat(threadID)
- if (openGroup != null) {
- openGroupServer = openGroup.server
- openGroupRoom = openGroup.room
- }
- setMentionCandidates(candidates)
- }
-
- fun setMentionCandidates(candidates: List) {
- 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
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionEditable.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionEditable.kt
new file mode 100644
index 0000000000..bc4b068b2a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionEditable.kt
@@ -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(
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_LATEST
+ )
+
+ fun observeMentionSearchQuery(): Flow {
+ @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(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
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionSpan.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionSpan.kt
new file mode 100644
index 0000000000..d7fa4d56cd
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionSpan.kt
@@ -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
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt
new file mode 100644
index 0000000000..fbb4d2231f
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt
@@ -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?> =
+ (contentResolver.observeChanges(Conversation.getUriForThread(threadID)) as Flow)
+ .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 = 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()
+ .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 { it.matchScore }
+ .then(compareBy { it.member.name })
+ }
+ }
+
+ sealed interface AutoCompleteState {
+ object Idle : AutoCompleteState
+ object Loading : AutoCompleteState
+ data class Result(val members: List, 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 create(modelClass: Class): T {
+ return MentionViewModel(
+ threadID = threadId,
+ contentResolver = contentResolver,
+ threadDatabase = threadDatabase,
+ groupDatabase = groupDatabase,
+ mmsDatabase = mmsDatabase,
+ contactDatabase = contactDatabase,
+ memberDatabase = memberDatabase,
+ storage = storage,
+ ) as T
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt
deleted file mode 100644
index ee1c7257c2..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionManagerUtilities.kt
+++ /dev/null
@@ -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()
- 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
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt
index ff44ef2c9a..e869f741c7 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt
@@ -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): Map> {
+ 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) {
writableDatabase.beginTransaction()
try {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt
index 5648cdace1..23a1af7ceb 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt
@@ -218,6 +218,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return cursor
}
+ fun getRecentChatMemberIDs(threadID: Long, limit: Int): List {
+ 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"
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt
index 49a6339368..778af6c01c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt
@@ -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): List {
+ 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 {
val database = databaseHelper.readableDatabase
return database.getAll(sessionContactTable, null, null) { cursor ->
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt
index 2754c70f69..01e1c514ff 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt
@@ -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 }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java
index 6aa514c8a9..8a891fb9b9 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java
@@ -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`
diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml
index 6fe0c4db60..4d38e2ab5b 100644
--- a/app/src/main/res/layout/activity_conversation_v2.xml
+++ b/app/src/main/res/layout/activity_conversation_v2.xml
@@ -1,5 +1,5 @@
-
@@ -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" />
+ tools:layout_height="60dp"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/messageRequestBar"
+ app:layout_constraintBottom_toBottomOf="parent"
+ />
-
+ tools:visibility="gone"
+ app:layout_constraintHeight_max="176dp"
+ app:layout_constraintBottom_toBottomOf="@+id/conversationRecyclerView" />
-
-
-
-
+ 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" />
@@ -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" />
-
@@ -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?" />
-
+
-
@@ -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" />
-
+
@@ -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">
-
+
diff --git a/app/src/main/res/layout/view_mention_candidate_v2.xml b/app/src/main/res/layout/view_mention_candidate_v2.xml
index f81dd73f84..a28c23fa15 100644
--- a/app/src/main/res/layout/view_mention_candidate_v2.xml
+++ b/app/src/main/res/layout/view_mention_candidate_v2.xml
@@ -1,9 +1,8 @@
-
+ xmlns:tools="http://schemas.android.com/tools">
diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionEditableTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionEditableTest.kt
new file mode 100644
index 0000000000..8ae4cb43bb
--- /dev/null
+++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionEditableTest.kt
@@ -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)
+}
diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt
new file mode 100644
index 0000000000..3b3dd53e1c
--- /dev/null
+++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/MentionViewModelTest.kt
@@ -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
+ )
+
+ 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 {
+ 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
+ memberContacts.filter { contact -> contact.sessionID in ids }
+ }
+ },
+ memberDatabase = mock {
+ on { getGroupMembersRoles(eq(openGroup.id), any()) } doAnswer {
+ val memberIDs = it.arguments[1] as Collection
+ 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 ")
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/resources/TestAndroidManifest.xml b/app/src/test/resources/TestAndroidManifest.xml
new file mode 100644
index 0000000000..afc09b82b7
--- /dev/null
+++ b/app/src/test/resources/TestAndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..1ec9805b2a
--- /dev/null
+++ b/app/src/test/resources/robolectric.properties
@@ -0,0 +1,3 @@
+manifest=TestAndroidManifest.xml
+sdk=34
+application=android.app.Application
\ No newline at end of file
diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt
index 8335e0a2da..47ec35e176 100644
--- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt
+++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt
@@ -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),
}