mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-30 13:35:18 +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'
|
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
|
||||||
androidTestUtil 'androidx.test:orchestrator:1.4.2'
|
androidTestUtil 'androidx.test:orchestrator:1.4.2'
|
||||||
|
|
||||||
testImplementation 'org.robolectric:robolectric:4.4'
|
testImplementation 'org.robolectric:robolectric:4.12.2'
|
||||||
testImplementation 'org.robolectric:shadows-multidex:4.4'
|
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 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
|
||||||
implementation 'androidx.compose.ui:ui:1.5.2'
|
implementation 'androidx.compose.ui:ui:1.5.2'
|
||||||
|
@ -7,10 +7,8 @@ import android.view.View
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.ViewUserBinding
|
import network.loki.messenger.databinding.ViewUserBinding
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
|
||||||
import org.session.libsession.messaging.contacts.Contact
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
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.dependencies.DatabaseComponent
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
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.ExpirationUtil
|
||||||
import org.session.libsession.utilities.modifyLayoutParams
|
import org.session.libsession.utilities.modifyLayoutParams
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
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.GroupDatabase
|
||||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
||||||
import org.thoughtcrime.securesms.util.DateUtils
|
import org.thoughtcrime.securesms.util.DateUtils
|
||||||
@ -78,7 +77,6 @@ class ConversationActionBarView @JvmOverloads constructor(
|
|||||||
binding.profilePictureView.layoutParams = resources.getDimensionPixelSize(
|
binding.profilePictureView.layoutParams = resources.getDimensionPixelSize(
|
||||||
if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size
|
if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size
|
||||||
).let { LayoutParams(it, it) }
|
).let { LayoutParams(it, it) }
|
||||||
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadId, context)
|
|
||||||
update(recipient, openGroup, config)
|
update(recipient, openGroup, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,8 +29,8 @@ import android.view.Menu
|
|||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewGroup.LayoutParams
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.widget.RelativeLayout
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.ActivityResult
|
import androidx.activity.result.ActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@ -46,6 +46,7 @@ import androidx.lifecycle.Observer
|
|||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.flowWithLifecycle
|
import androidx.lifecycle.flowWithLifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.loader.app.LoaderManager
|
import androidx.loader.app.LoaderManager
|
||||||
import androidx.loader.content.Loader
|
import androidx.loader.content.Loader
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
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.contacts.Contact
|
||||||
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
||||||
import org.session.libsession.messaging.jobs.JobQueue
|
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.ExpirationConfiguration
|
||||||
import org.session.libsession.messaging.messages.applyExpiryMode
|
import org.session.libsession.messaging.messages.applyExpiryMode
|
||||||
import org.session.libsession.messaging.messages.control.DataExtractionNotification
|
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.InputBarButton
|
||||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate
|
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.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.ConversationActionModeCallback
|
||||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate
|
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate
|
||||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
|
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
|
||||||
@ -215,6 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
@Inject lateinit var storage: Storage
|
@Inject lateinit var storage: Storage
|
||||||
@Inject lateinit var reactionDb: ReactionDatabase
|
@Inject lateinit var reactionDb: ReactionDatabase
|
||||||
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
|
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
|
||||||
|
@Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory
|
||||||
|
|
||||||
private val screenshotObserver by lazy {
|
private val screenshotObserver by lazy {
|
||||||
ScreenshotObserver(this, Handler(Looper.getMainLooper())) {
|
ScreenshotObserver(this, Handler(Looper.getMainLooper())) {
|
||||||
@ -228,7 +229,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
|
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
|
||||||
.get(LinkPreviewViewModel::class.java)
|
.get(LinkPreviewViewModel::class.java)
|
||||||
}
|
}
|
||||||
private val viewModel: ConversationViewModel by viewModels {
|
|
||||||
|
private val threadId: Long by lazy {
|
||||||
var threadId = intent.getLongExtra(THREAD_ID, -1L)
|
var threadId = intent.getLongExtra(THREAD_ID, -1L)
|
||||||
if (threadId == -1L) {
|
if (threadId == -1L) {
|
||||||
intent.getParcelableExtra<Address>(ADDRESS)?.let { it ->
|
intent.getParcelableExtra<Address>(ADDRESS)?.let { it ->
|
||||||
@ -248,6 +250,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
} ?: finish()
|
} ?: finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
threadId
|
||||||
|
}
|
||||||
|
|
||||||
|
private val viewModel: ConversationViewModel by viewModels {
|
||||||
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
|
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
|
||||||
}
|
}
|
||||||
private var actionMode: ActionMode? = null
|
private var actionMode: ActionMode? = null
|
||||||
@ -260,11 +267,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
private var isLockViewExpanded = false
|
private var isLockViewExpanded = false
|
||||||
private var isShowingAttachmentOptions = false
|
private var isShowingAttachmentOptions = false
|
||||||
// Mentions
|
// Mentions
|
||||||
private val mentions = mutableListOf<Mention>()
|
private val mentionViewModel: MentionViewModel by viewModels {
|
||||||
private var mentionCandidatesView: MentionCandidatesView? = null
|
mentionViewModelFactory.create(threadId)
|
||||||
private var previousText: CharSequence = ""
|
}
|
||||||
private var currentMentionStartIndex = -1
|
private val mentionCandidateAdapter = MentionCandidateAdapter {
|
||||||
private var isShowingMentionCandidatesView = false
|
mentionViewModel.onCandidateSelected(it.member.publicKey)
|
||||||
|
}
|
||||||
// Search
|
// Search
|
||||||
val searchViewModel: SearchViewModel by viewModels()
|
val searchViewModel: SearchViewModel by viewModels()
|
||||||
var searchViewItem: MenuItem? = null
|
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() {
|
override fun onResume() {
|
||||||
@ -642,23 +671,19 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
binding.inputBar.delegate = this
|
binding.inputBar.delegate = this
|
||||||
binding.inputBarRecordingView.delegate = this
|
binding.inputBarRecordingView.delegate = this
|
||||||
// GIF button
|
// GIF button
|
||||||
binding.gifButtonContainer.addView(gifButton)
|
binding.gifButtonContainer.addView(gifButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||||
gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
|
||||||
gifButton.onUp = { showGIFPicker() }
|
gifButton.onUp = { showGIFPicker() }
|
||||||
gifButton.snIsEnabled = false
|
gifButton.snIsEnabled = false
|
||||||
// Document button
|
// Document button
|
||||||
binding.documentButtonContainer.addView(documentButton)
|
binding.documentButtonContainer.addView(documentButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||||
documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
|
||||||
documentButton.onUp = { showDocumentPicker() }
|
documentButton.onUp = { showDocumentPicker() }
|
||||||
documentButton.snIsEnabled = false
|
documentButton.snIsEnabled = false
|
||||||
// Library button
|
// Library button
|
||||||
binding.libraryButtonContainer.addView(libraryButton)
|
binding.libraryButtonContainer.addView(libraryButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||||
libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
|
||||||
libraryButton.onUp = { pickFromLibrary() }
|
libraryButton.onUp = { pickFromLibrary() }
|
||||||
libraryButton.snIsEnabled = false
|
libraryButton.snIsEnabled = false
|
||||||
// Camera button
|
// Camera button
|
||||||
binding.cameraButtonContainer.addView(cameraButton)
|
binding.cameraButtonContainer.addView(cameraButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||||
cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
|
||||||
cameraButton.onUp = { showCamera() }
|
cameraButton.onUp = { showCamera() }
|
||||||
cameraButton.snIsEnabled = false
|
cameraButton.snIsEnabled = false
|
||||||
}
|
}
|
||||||
@ -913,7 +938,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
if (textSecurePreferences.isLinkPreviewsEnabled()) {
|
if (textSecurePreferences.isLinkPreviewsEnabled()) {
|
||||||
linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0)
|
linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0)
|
||||||
}
|
}
|
||||||
showOrHideMentionCandidatesIfNeeded(newContent)
|
|
||||||
if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty()
|
if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty()
|
||||||
&& !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) {
|
&& !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) {
|
||||||
LinkPreviewDialog {
|
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() {
|
override fun toggleAttachmentOptions() {
|
||||||
val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f
|
val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f
|
||||||
val allButtonContainers = listOfNotNull(
|
val allButtonContainers = listOfNotNull(
|
||||||
@ -1510,18 +1464,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
return hitRect.contains(x, y)
|
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) {
|
override fun scrollToMessageIfPossible(timestamp: Long) {
|
||||||
val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return
|
val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return
|
||||||
@ -1620,10 +1562,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
binding?.inputBar?.text = ""
|
binding?.inputBar?.text = ""
|
||||||
binding?.inputBar?.cancelQuoteDraft()
|
binding?.inputBar?.cancelQuoteDraft()
|
||||||
binding?.inputBar?.cancelLinkPreviewDraft()
|
binding?.inputBar?.cancelLinkPreviewDraft()
|
||||||
// Clear mentions
|
|
||||||
previousText = ""
|
|
||||||
currentMentionStartIndex = -1
|
|
||||||
mentions.clear()
|
|
||||||
// Put the message in the database
|
// Put the message in the database
|
||||||
message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true)
|
message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true)
|
||||||
// Send it
|
// Send it
|
||||||
@ -1668,10 +1606,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
binding?.inputBar?.text = ""
|
binding?.inputBar?.text = ""
|
||||||
binding?.inputBar?.cancelQuoteDraft()
|
binding?.inputBar?.cancelQuoteDraft()
|
||||||
binding?.inputBar?.cancelLinkPreviewDraft()
|
binding?.inputBar?.cancelLinkPreviewDraft()
|
||||||
// Clear mentions
|
|
||||||
previousText = ""
|
|
||||||
currentMentionStartIndex = -1
|
|
||||||
mentions.clear()
|
|
||||||
// Reset the attachment manager
|
// Reset the attachment manager
|
||||||
attachmentManager.clear()
|
attachmentManager.clear()
|
||||||
// Reset attachments button if needed
|
// Reset attachments button if needed
|
||||||
@ -2105,17 +2039,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
|
|
||||||
// region General
|
// region General
|
||||||
private fun getMessageBody(): String {
|
private fun getMessageBody(): String {
|
||||||
var result = binding?.inputBar?.text?.trim() ?: return ""
|
return mentionViewModel.normalizeMessageBody()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.PointF
|
import android.graphics.PointF
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.text.Editable
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
@ -224,8 +225,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
|||||||
binding.inputBarEditText.addTextChangedListener(textWatcher)
|
binding.inputBarEditText.addTextChangedListener(textWatcher)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSelection(index: Int) {
|
fun setInputBarEditableFactory(factory: Editable.Factory) {
|
||||||
binding.inputBarEditText.setSelection(index)
|
binding.inputBarEditText.setEditableFactory(factory)
|
||||||
}
|
}
|
||||||
// endregion
|
// 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
|
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.view.View
|
||||||
import android.widget.RelativeLayout
|
|
||||||
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
|
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
|
||||||
import org.session.libsession.messaging.mentions.Mention
|
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
|
||||||
|
|
||||||
class MentionCandidateView : RelativeLayout {
|
fun ViewMentionCandidateV2Binding.update(candidate: MentionViewModel.Candidate) {
|
||||||
private lateinit var binding: ViewMentionCandidateV2Binding
|
mentionCandidateNameTextView.text = candidate.nameHighlighted
|
||||||
var candidate = Mention("", "")
|
profilePictureView.publicKey = candidate.member.publicKey
|
||||||
set(newValue) { field = newValue; update() }
|
profilePictureView.displayName = candidate.member.name
|
||||||
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.additionalPublicKey = null
|
||||||
profilePictureView.update()
|
profilePictureView.update()
|
||||||
if (openGroupServer != null && openGroupRoom != null) {
|
moderatorIconImageView.visibility = if (candidate.member.isModerator) View.VISIBLE else View.GONE
|
||||||
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey)
|
|
||||||
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
|
|
||||||
} else {
|
|
||||||
moderatorIconImageView.visibility = 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.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import org.json.JSONArray
|
||||||
import org.session.libsession.messaging.open_groups.GroupMember
|
import org.session.libsession.messaging.open_groups.GroupMember
|
||||||
import org.session.libsession.messaging.open_groups.GroupMemberRole
|
import org.session.libsession.messaging.open_groups.GroupMemberRole
|
||||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
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) {
|
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 }
|
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>) {
|
fun setGroupMembers(members: List<GroupMember>) {
|
||||||
writableDatabase.beginTransaction()
|
writableDatabase.beginTransaction()
|
||||||
try {
|
try {
|
||||||
|
@ -218,6 +218,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
return cursor
|
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
|
val expireStartedMessages: Reader
|
||||||
get() {
|
get() {
|
||||||
val where = "$EXPIRE_STARTED > 0"
|
val where = "$EXPIRE_STARTED > 0"
|
||||||
|
@ -4,6 +4,7 @@ import android.content.ContentValues
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
|
import org.json.JSONArray
|
||||||
import org.session.libsession.messaging.contacts.Contact
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
import org.session.libsession.messaging.utilities.SessionId
|
import org.session.libsession.messaging.utilities.SessionId
|
||||||
import org.session.libsignal.utilities.Base64
|
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> {
|
fun getAllContacts(): Set<Contact> {
|
||||||
val database = databaseHelper.readableDatabase
|
val database = databaseHelper.readableDatabase
|
||||||
return database.getAll(sessionContactTable, null, null) { cursor ->
|
return database.getAll(sessionContactTable, null, null) { cursor ->
|
||||||
|
@ -162,13 +162,7 @@ object OpenGroupManager {
|
|||||||
val memberDatabase = DatabaseComponent.get(context).groupMemberDatabase()
|
val memberDatabase = DatabaseComponent.get(context).groupMemberDatabase()
|
||||||
val standardRoles = memberDatabase.getGroupMemberRoles(groupId, standardPublicKey)
|
val standardRoles = memberDatabase.getGroupMemberRoles(groupId, standardPublicKey)
|
||||||
val blindedRoles = blindedPublicKey?.let { memberDatabase.getGroupMemberRoles(groupId, it) } ?: emptyList()
|
val blindedRoles = blindedPublicKey?.let { memberDatabase.getGroupMemberRoles(groupId, it) } ?: emptyList()
|
||||||
|
return standardRoles.any { it.isModerator } || blindedRoles.any { it.isModerator }
|
||||||
// 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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -56,7 +56,6 @@ import org.session.libsignal.utilities.Util;
|
|||||||
import org.thoughtcrime.securesms.ApplicationContext;
|
import org.thoughtcrime.securesms.ApplicationContext;
|
||||||
import org.thoughtcrime.securesms.contacts.ContactUtil;
|
import org.thoughtcrime.securesms.contacts.ContactUtil;
|
||||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2;
|
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.conversation.v2.utilities.MentionUtilities;
|
||||||
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
|
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
|
||||||
import org.thoughtcrime.securesms.database.LokiThreadDatabase;
|
import org.thoughtcrime.securesms.database.LokiThreadDatabase;
|
||||||
@ -348,7 +347,6 @@ public class DefaultMessageNotifier implements MessageNotifier {
|
|||||||
|
|
||||||
builder.setThread(notifications.get(0).getRecipient());
|
builder.setThread(notifications.get(0).getRecipient());
|
||||||
builder.setMessageCount(notificationState.getMessageCount());
|
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: 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`
|
// 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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<RelativeLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:focusable="false"
|
android:focusable="false"
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
@ -13,6 +13,9 @@
|
|||||||
android:id="@+id/toolbar"
|
android:id="@+id/toolbar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/conversationRecyclerView"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
android:background="?colorPrimary"
|
android:background="?colorPrimary"
|
||||||
app:contentInsetStart="0dp">
|
app:contentInsetStart="0dp">
|
||||||
|
|
||||||
@ -31,9 +34,11 @@
|
|||||||
android:focusable="false"
|
android:focusable="false"
|
||||||
android:id="@+id/conversationRecyclerView"
|
android:id="@+id/conversationRecyclerView"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="0dp"
|
||||||
android:layout_above="@+id/typingIndicatorViewContainer"
|
app:layout_constraintVertical_weight="1"
|
||||||
android:layout_below="@id/toolbar" />
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/typingIndicatorViewContainer"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/toolbar" />
|
||||||
|
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorViewContainer
|
<org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorViewContainer
|
||||||
@ -42,20 +47,27 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="36dp"
|
android:layout_height="36dp"
|
||||||
android:visibility="gone"
|
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
|
<org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar
|
||||||
android:id="@+id/inputBar"
|
android:id="@+id/inputBar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
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
|
<org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar
|
||||||
android:id="@+id/searchBottomBar"
|
android:id="@+id/searchBottomBar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentBottom="true"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
android:visibility="gone"/>
|
android:visibility="gone"/>
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
@ -75,11 +87,18 @@
|
|||||||
android:inflatedId="@+id/conversation_reaction_scrubber"
|
android:inflatedId="@+id/conversation_reaction_scrubber"
|
||||||
android:layout="@layout/conversation_reaction_scrubber"/>
|
android:layout="@layout/conversation_reaction_scrubber"/>
|
||||||
|
|
||||||
<FrameLayout
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/additionalContentContainer"
|
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_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
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
|
<LinearLayout
|
||||||
android:id="@+id/attachmentOptionsContainer"
|
android:id="@+id/attachmentOptionsContainer"
|
||||||
@ -87,19 +106,19 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="@dimen/small_spacing"
|
android:layout_marginStart="@dimen/small_spacing"
|
||||||
android:elevation="8dp"
|
android:elevation="8dp"
|
||||||
android:layout_alignParentStart="true"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
android:layout_alignParentBottom="true"
|
app:layout_constraintBottom_toTopOf="@+id/inputBar"
|
||||||
android:layout_marginBottom="60dp"
|
android:layout_marginBottom="16dp"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<RelativeLayout
|
<FrameLayout
|
||||||
android:id="@+id/gifButtonContainer"
|
android:id="@+id/gifButtonContainer"
|
||||||
android:layout_width="@dimen/input_bar_button_expanded_size"
|
android:layout_width="@dimen/input_bar_button_expanded_size"
|
||||||
android:layout_height="@dimen/input_bar_button_expanded_size"
|
android:layout_height="@dimen/input_bar_button_expanded_size"
|
||||||
android:contentDescription="@string/AccessibilityId_gif_button"
|
android:contentDescription="@string/AccessibilityId_gif_button"
|
||||||
android:alpha="0" />
|
android:alpha="0" />
|
||||||
|
|
||||||
<RelativeLayout
|
<FrameLayout
|
||||||
android:id="@+id/documentButtonContainer"
|
android:id="@+id/documentButtonContainer"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_width="@dimen/input_bar_button_expanded_size"
|
android:layout_width="@dimen/input_bar_button_expanded_size"
|
||||||
@ -107,7 +126,7 @@
|
|||||||
android:contentDescription="@string/AccessibilityId_documents_folder"
|
android:contentDescription="@string/AccessibilityId_documents_folder"
|
||||||
android:alpha="0" />
|
android:alpha="0" />
|
||||||
|
|
||||||
<RelativeLayout
|
<FrameLayout
|
||||||
android:id="@+id/libraryButtonContainer"
|
android:id="@+id/libraryButtonContainer"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_width="@dimen/input_bar_button_expanded_size"
|
android:layout_width="@dimen/input_bar_button_expanded_size"
|
||||||
@ -115,7 +134,7 @@
|
|||||||
android:contentDescription="@string/AccessibilityId_images_folder"
|
android:contentDescription="@string/AccessibilityId_images_folder"
|
||||||
android:alpha="0" />
|
android:alpha="0" />
|
||||||
|
|
||||||
<RelativeLayout
|
<FrameLayout
|
||||||
android:id="@+id/cameraButtonContainer"
|
android:id="@+id/cameraButtonContainer"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_width="@dimen/input_bar_button_expanded_size"
|
android:layout_width="@dimen/input_bar_button_expanded_size"
|
||||||
@ -129,22 +148,26 @@
|
|||||||
android:id="@+id/textSendAfterApproval"
|
android:id="@+id/textSendAfterApproval"
|
||||||
android:text="@string/ConversationActivity_send_after_approval"
|
android:text="@string/ConversationActivity_send_after_approval"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible"
|
||||||
android:textAlignment="center"
|
android:textAlignment="center"
|
||||||
android:textColor="@color/classic_light_2"
|
android:textColor="@color/classic_light_2"
|
||||||
android:padding="22dp"
|
android:padding="22dp"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignWithParentIfMissing="true"
|
tools:text="You'll be able to send"
|
||||||
android:layout_above="@id/messageRequestBar"/>
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/typingIndicatorViewContainer"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/messageRequestBar" />
|
||||||
|
|
||||||
<RelativeLayout
|
<RelativeLayout
|
||||||
android:id="@+id/scrollToBottomButton"
|
android:id="@+id/scrollToBottomButton"
|
||||||
|
tools:visibility="visible"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
android:layout_width="40dp"
|
android:layout_width="40dp"
|
||||||
android:layout_height="50dp"
|
android:layout_height="50dp"
|
||||||
android:layout_alignParentEnd="true"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
android:layout_above="@+id/messageRequestBar"
|
app:layout_constraintBottom_toTopOf="@+id/messageRequestBar"
|
||||||
android:layout_alignWithParentIfMissing="true"
|
android:layout_alignWithParentIfMissing="true"
|
||||||
android:layout_marginEnd="12dp"
|
android:layout_marginEnd="12dp"
|
||||||
android:layout_marginBottom="32dp">
|
android:layout_marginBottom="32dp">
|
||||||
@ -197,14 +220,14 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="-12dp"
|
android:layout_marginBottom="-12dp"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
android:layout_alignParentBottom="true" />
|
app:layout_constraintBottom_toBottomOf="parent" />
|
||||||
|
|
||||||
<RelativeLayout
|
<FrameLayout
|
||||||
android:id="@+id/blockedBanner"
|
android:id="@+id/blockedBanner"
|
||||||
android:contentDescription="@string/AccessibilityId_blocked_banner"
|
android:contentDescription="@string/AccessibilityId_blocked_banner"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_below="@+id/toolbar"
|
app:layout_constraintTop_toBottomOf="@+id/toolbar"
|
||||||
android:background="@color/destructive"
|
android:background="@color/destructive"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
tools:visibility="visible">
|
tools:visibility="visible">
|
||||||
@ -214,20 +237,20 @@
|
|||||||
android:contentDescription="@string/AccessibilityId_blocked_banner_text"
|
android:contentDescription="@string/AccessibilityId_blocked_banner_text"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_centerInParent="true"
|
android:layout_gravity="center"
|
||||||
android:layout_margin="@dimen/medium_spacing"
|
android:layout_margin="@dimen/medium_spacing"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
android:textSize="@dimen/small_font_size"
|
android:textSize="@dimen/small_font_size"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
tools:text="Elon is blocked. Unblock them?" />
|
tools:text="Elon is blocked. Unblock them?" />
|
||||||
|
|
||||||
</RelativeLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
<RelativeLayout
|
<FrameLayout
|
||||||
android:id="@+id/outdatedBanner"
|
android:id="@+id/outdatedBanner"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
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:background="@color/outdated_client_banner_background_color"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
tools:visibility="visible">
|
tools:visibility="visible">
|
||||||
@ -237,14 +260,14 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center_horizontal"
|
android:gravity="center_horizontal"
|
||||||
android:layout_centerInParent="true"
|
android:layout_gravity="center"
|
||||||
android:layout_marginVertical="@dimen/very_small_spacing"
|
android:layout_marginVertical="@dimen/very_small_spacing"
|
||||||
android:layout_marginHorizontal="@dimen/medium_spacing"
|
android:layout_marginHorizontal="@dimen/medium_spacing"
|
||||||
android:textColor="@color/black"
|
android:textColor="@color/black"
|
||||||
android:textSize="@dimen/tiny_font_size"
|
android:textSize="@dimen/tiny_font_size"
|
||||||
tools:text="This user's client is outdated, things may not work as expected" />
|
tools:text="This user's client is outdated, things may not work as expected" />
|
||||||
|
|
||||||
</RelativeLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:padding="@dimen/medium_spacing"
|
android:padding="@dimen/medium_spacing"
|
||||||
@ -254,7 +277,7 @@
|
|||||||
android:id="@+id/placeholderText"
|
android:id="@+id/placeholderText"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_below="@+id/blockedBanner"
|
app:layout_constraintTop_toBottomOf="@+id/outdatedBanner"
|
||||||
android:elevation="8dp"
|
android:elevation="8dp"
|
||||||
tools:text="@string/activity_conversation_empty_state_default"
|
tools:text="@string/activity_conversation_empty_state_default"
|
||||||
/>
|
/>
|
||||||
@ -263,11 +286,12 @@
|
|||||||
android:id="@+id/messageRequestBar"
|
android:id="@+id/messageRequestBar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
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:layout_marginBottom="@dimen/large_spacing"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
tools:visibility="visible">
|
tools:visibility="gone">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/messageRequestBlock"
|
android:id="@+id/messageRequestBlock"
|
||||||
@ -321,4 +345,4 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</RelativeLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<RelativeLayout
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="44dp"
|
android:layout_height="44dp"
|
||||||
android:background="@drawable/mention_candidate_view_background">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@ -42,6 +41,7 @@
|
|||||||
android:textSize="@dimen/small_font_size"
|
android:textSize="@dimen/small_font_size"
|
||||||
android:textColor="?android:textColorPrimary"
|
android:textColor="?android:textColorPrimary"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
|
tools:text="Alice"
|
||||||
android:contentDescription="@string/AccessibilityId_contact_mentions"
|
android:contentDescription="@string/AccessibilityId_contact_mentions"
|
||||||
android:ellipsize="end" />
|
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
|
val role: GroupMemberRole
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class GroupMemberRole {
|
enum class GroupMemberRole(val isModerator: Boolean = false) {
|
||||||
STANDARD, ZOOMBIE, MODERATOR, ADMIN, HIDDEN_MODERATOR, HIDDEN_ADMIN
|
STANDARD,
|
||||||
|
ZOOMBIE,
|
||||||
|
MODERATOR(true),
|
||||||
|
ADMIN(true),
|
||||||
|
HIDDEN_MODERATOR(true),
|
||||||
|
HIDDEN_ADMIN(true),
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user