[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:
Fanchao Liu 2024-07-01 17:31:03 +10:00 committed by GitHub
parent a260717d42
commit fec67e282a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 992 additions and 337 deletions

View File

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

View File

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

View File

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

View File

@ -29,8 +29,8 @@ import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup.LayoutParams
import android.view.WindowManager
import android.widget.RelativeLayout
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@ -46,6 +46,7 @@ import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager
@ -67,8 +68,6 @@ import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.mentions.Mention
import org.session.libsession.messaging.mentions.MentionsManager
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.applyExpiryMode
import org.session.libsession.messaging.messages.control.DataExtractionNotification
@ -118,7 +117,8 @@ import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate
import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidatesView
import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidateAdapter
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
@ -215,6 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
@Inject lateinit var storage: Storage
@Inject lateinit var reactionDb: ReactionDatabase
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
@Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory
private val screenshotObserver by lazy {
ScreenshotObserver(this, Handler(Looper.getMainLooper())) {
@ -228,7 +229,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
.get(LinkPreviewViewModel::class.java)
}
private val viewModel: ConversationViewModel by viewModels {
private val threadId: Long by lazy {
var threadId = intent.getLongExtra(THREAD_ID, -1L)
if (threadId == -1L) {
intent.getParcelableExtra<Address>(ADDRESS)?.let { it ->
@ -248,6 +250,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
} ?: finish()
}
threadId
}
private val viewModel: ConversationViewModel by viewModels {
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
}
private var actionMode: ActionMode? = null
@ -260,11 +267,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private var isLockViewExpanded = false
private var isShowingAttachmentOptions = false
// Mentions
private val mentions = mutableListOf<Mention>()
private var mentionCandidatesView: MentionCandidatesView? = null
private var previousText: CharSequence = ""
private var currentMentionStartIndex = -1
private var isShowingMentionCandidatesView = false
private val mentionViewModel: MentionViewModel by viewModels {
mentionViewModelFactory.create(threadId)
}
private val mentionCandidateAdapter = MentionCandidateAdapter {
mentionViewModel.onCandidateSelected(it.member.publicKey)
}
// Search
val searchViewModel: SearchViewModel by viewModels()
var searchViewItem: MenuItem? = null
@ -486,6 +494,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
}
setupMentionView()
}
private fun setupMentionView() {
binding?.conversationMentionCandidates?.let { view ->
view.adapter = mentionCandidateAdapter
view.itemAnimator = null
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
mentionViewModel.autoCompleteState
.collectLatest { state ->
mentionCandidateAdapter.candidates =
(state as? MentionViewModel.AutoCompleteState.Result)?.members.orEmpty()
}
}
}
binding?.inputBar?.setInputBarEditableFactory(mentionViewModel.editableFactory)
}
override fun onResume() {
@ -642,23 +671,19 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding.inputBar.delegate = this
binding.inputBarRecordingView.delegate = this
// GIF button
binding.gifButtonContainer.addView(gifButton)
gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
binding.gifButtonContainer.addView(gifButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
gifButton.onUp = { showGIFPicker() }
gifButton.snIsEnabled = false
// Document button
binding.documentButtonContainer.addView(documentButton)
documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
binding.documentButtonContainer.addView(documentButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
documentButton.onUp = { showDocumentPicker() }
documentButton.snIsEnabled = false
// Library button
binding.libraryButtonContainer.addView(libraryButton)
libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
binding.libraryButtonContainer.addView(libraryButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
libraryButton.onUp = { pickFromLibrary() }
libraryButton.snIsEnabled = false
// Camera button
binding.cameraButtonContainer.addView(cameraButton)
cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
binding.cameraButtonContainer.addView(cameraButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
cameraButton.onUp = { showCamera() }
cameraButton.snIsEnabled = false
}
@ -913,7 +938,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (textSecurePreferences.isLinkPreviewsEnabled()) {
linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0)
}
showOrHideMentionCandidatesIfNeeded(newContent)
if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty()
&& !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) {
LinkPreviewDialog {
@ -925,76 +949,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
private fun showOrHideMentionCandidatesIfNeeded(text: CharSequence) {
if (text.length < previousText.length) {
currentMentionStartIndex = -1
hideMentionCandidates()
val mentionsToRemove = mentions.filter { !text.contains(it.displayName) }
mentions.removeAll(mentionsToRemove)
}
if (text.isNotEmpty()) {
val lastCharIndex = text.lastIndex
val lastChar = text[lastCharIndex]
// Check if there is whitespace before the '@' or the '@' is the first character
val isCharacterBeforeLastWhiteSpaceOrStartOfLine: Boolean
if (text.length == 1) {
isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // Start of line
} else {
val charBeforeLast = text[lastCharIndex - 1]
isCharacterBeforeLastWhiteSpaceOrStartOfLine = Character.isWhitespace(charBeforeLast)
}
if (lastChar == '@' && isCharacterBeforeLastWhiteSpaceOrStartOfLine) {
currentMentionStartIndex = lastCharIndex
showOrUpdateMentionCandidatesIfNeeded()
} else if (Character.isWhitespace(lastChar) || lastChar == '@') { // the lastCharacter == "@" is to check for @@
currentMentionStartIndex = -1
hideMentionCandidates()
} else if (currentMentionStartIndex != -1) {
val query = text.substring(currentMentionStartIndex + 1) // + 1 to get rid of the "@"
showOrUpdateMentionCandidatesIfNeeded(query)
}
} else {
currentMentionStartIndex = -1
hideMentionCandidates()
}
previousText = text
}
private fun showOrUpdateMentionCandidatesIfNeeded(query: String = "") {
val additionalContentContainer = binding?.additionalContentContainer ?: return
val recipient = viewModel.recipient ?: return
if (!isShowingMentionCandidatesView) {
additionalContentContainer.removeAllViews()
val view = MentionCandidatesView(this).apply {
contentDescription = context.getString(R.string.AccessibilityId_mentions_list)
}
view.glide = glide
view.onCandidateSelected = { handleMentionSelected(it) }
additionalContentContainer.addView(view)
val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
this.mentionCandidatesView = view
view.show(candidates, viewModel.threadId)
} else {
val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
this.mentionCandidatesView!!.setMentionCandidates(candidates)
}
isShowingMentionCandidatesView = true
}
private fun hideMentionCandidates() {
if (isShowingMentionCandidatesView) {
val mentionCandidatesView = mentionCandidatesView ?: return
val animation = ValueAnimator.ofObject(FloatEvaluator(), mentionCandidatesView.alpha, 0.0f)
animation.duration = 250L
animation.addUpdateListener { animator ->
mentionCandidatesView.alpha = animator.animatedValue as Float
if (animator.animatedFraction == 1.0f) { binding?.additionalContentContainer?.removeAllViews() }
}
animation.start()
}
isShowingMentionCandidatesView = false
}
override fun toggleAttachmentOptions() {
val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f
val allButtonContainers = listOfNotNull(
@ -1510,18 +1464,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
return hitRect.contains(x, y)
}
private fun handleMentionSelected(mention: Mention) {
val binding = binding ?: return
if (currentMentionStartIndex == -1) { return }
mentions.add(mention)
val previousText = binding.inputBar.text
val newText = previousText.substring(0, currentMentionStartIndex) + "@" + mention.displayName + " "
binding.inputBar.text = newText
binding.inputBar.setSelection(newText.length)
currentMentionStartIndex = -1
hideMentionCandidates()
this.previousText = newText
}
override fun scrollToMessageIfPossible(timestamp: Long) {
val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return
@ -1620,10 +1562,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding?.inputBar?.text = ""
binding?.inputBar?.cancelQuoteDraft()
binding?.inputBar?.cancelLinkPreviewDraft()
// Clear mentions
previousText = ""
currentMentionStartIndex = -1
mentions.clear()
// Put the message in the database
message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true)
// Send it
@ -1668,10 +1606,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding?.inputBar?.text = ""
binding?.inputBar?.cancelQuoteDraft()
binding?.inputBar?.cancelLinkPreviewDraft()
// Clear mentions
previousText = ""
currentMentionStartIndex = -1
mentions.clear()
// Reset the attachment manager
attachmentManager.clear()
// Reset attachments button if needed
@ -2105,17 +2039,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// region General
private fun getMessageBody(): String {
var result = binding?.inputBar?.text?.trim() ?: return ""
for (mention in mentions) {
try {
val startIndex = result.indexOf("@" + mention.displayName)
val endIndex = startIndex + mention.displayName.count() + 1 // + 1 to include the "@"
result = result.substring(0, startIndex) + "@" + mention.publicKey + result.substring(endIndex)
} catch (exception: Exception) {
Log.d("Loki", "Failed to process mention due to error: $exception")
}
}
return result
return mentionViewModel.normalizeMessageBody()
}
// endregion

View File

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

View File

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

View File

@ -1,42 +1,14 @@
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.RelativeLayout
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
import org.session.libsession.messaging.mentions.Mention
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
class MentionCandidateView : RelativeLayout {
private lateinit var binding: ViewMentionCandidateV2Binding
var candidate = Mention("", "")
set(newValue) { field = newValue; update() }
var glide: GlideRequests? = null
var openGroupServer: String? = null
var openGroupRoom: String? = null
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
binding = ViewMentionCandidateV2Binding.inflate(LayoutInflater.from(context), this, true)
}
private fun update() = with(binding) {
mentionCandidateNameTextView.text = candidate.displayName
profilePictureView.publicKey = candidate.publicKey
profilePictureView.displayName = candidate.displayName
profilePictureView.additionalPublicKey = null
profilePictureView.update()
if (openGroupServer != null && openGroupRoom != null) {
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey)
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
} else {
moderatorIconImageView.visibility = View.GONE
}
}
}
fun ViewMentionCandidateV2Binding.update(candidate: MentionViewModel.Candidate) {
mentionCandidateNameTextView.text = candidate.nameHighlighted
profilePictureView.publicKey = candidate.member.publicKey
profilePictureView.displayName = candidate.member.name
profilePictureView.additionalPublicKey = null
profilePictureView.update()
moderatorIconImageView.visibility = if (candidate.member.isModerator) View.VISIBLE else View.GONE
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,9 +3,12 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import org.json.JSONArray
import org.session.libsession.messaging.open_groups.GroupMember
import org.session.libsession.messaging.open_groups.GroupMemberRole
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.util.asSequence
import java.util.EnumSet
class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
@ -51,6 +54,19 @@ class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
return mappings.map { it.role }
}
fun getGroupMembersRoles(groupId: String, memberIDs: Collection<String>): Map<String, List<GroupMemberRole>> {
val sql = """
SELECT * FROM $TABLE_NAME
WHERE $GROUP_ID = ? AND $PROFILE_ID IN (SELECT value FROM json_each(?))
""".trimIndent()
return readableDatabase.rawQuery(sql, groupId, JSONArray(memberIDs).toString()).use { cursor ->
cursor.asSequence()
.map { readGroupMember(it) }
.groupBy(keySelector = { it.profileId }, valueTransform = { it.role })
}
}
fun setGroupMembers(members: List<GroupMember>) {
writableDatabase.beginTransaction()
try {

View File

@ -218,6 +218,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return cursor
}
fun getRecentChatMemberIDs(threadID: Long, limit: Int): List<String> {
val sql = """
SELECT DISTINCT $ADDRESS FROM $TABLE_NAME
WHERE $THREAD_ID = ?
ORDER BY $DATE_SENT DESC
LIMIT $limit
""".trimIndent()
return databaseHelper.readableDatabase.rawQuery(sql, threadID).use { cursor ->
cursor.asSequence()
.map { it.getString(0) }
.toList()
}
}
val expireStartedMessages: Reader
get() {
val where = "$EXPIRE_STARTED > 0"

View File

@ -4,6 +4,7 @@ import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import androidx.core.database.getStringOrNull
import org.json.JSONArray
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsignal.utilities.Base64
@ -41,6 +42,15 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
}
}
fun getContacts(sessionIDs: Collection<String>): List<Contact> {
val database = databaseHelper.readableDatabase
return database.getAll(
sessionContactTable,
"$sessionID IN (SELECT value FROM json_each(?))",
arrayOf(JSONArray(sessionIDs).toString())
) { cursor -> contactFromCursor(cursor) }
}
fun getAllContacts(): Set<Contact> {
val database = databaseHelper.readableDatabase
return database.getAll(sessionContactTable, null, null) { cursor ->

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
<androidx.constraintlayout.widget.ConstraintLayout
android:focusable="false"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
@ -13,6 +13,9 @@
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/conversationRecyclerView"
app:layout_constraintStart_toStartOf="parent"
android:background="?colorPrimary"
app:contentInsetStart="0dp">
@ -31,9 +34,11 @@
android:focusable="false"
android:id="@+id/conversationRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@+id/typingIndicatorViewContainer"
android:layout_below="@id/toolbar" />
android:layout_height="0dp"
app:layout_constraintVertical_weight="1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@+id/typingIndicatorViewContainer"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
<org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorViewContainer
@ -42,20 +47,27 @@
android:layout_width="match_parent"
android:layout_height="36dp"
android:visibility="gone"
android:layout_above="@+id/textSendAfterApproval"
tools:visibility="visible"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/conversationRecyclerView"
app:layout_constraintBottom_toTopOf="@+id/textSendAfterApproval"
/>
<org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar
android:id="@+id/inputBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true" />
tools:layout_height="60dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageRequestBar"
app:layout_constraintBottom_toBottomOf="parent"
/>
<org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar
android:id="@+id/searchBottomBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
app:layout_constraintBottom_toBottomOf="parent"
android:visibility="gone"/>
<FrameLayout
@ -75,11 +87,18 @@
android:inflatedId="@+id/conversation_reaction_scrubber"
android:layout="@layout/conversation_reaction_scrubber"/>
<FrameLayout
android:id="@+id/additionalContentContainer"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/conversation_mention_candidates"
android:clipToOutline="true"
android:contentDescription="@string/AccessibilityId_mentions_list"
tools:listitem="@layout/view_mention_candidate_v2"
android:background="@drawable/mention_candidate_view_background"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignBottom="@+id/conversationRecyclerView"/>
tools:visibility="gone"
app:layout_constraintHeight_max="176dp"
app:layout_constraintBottom_toBottomOf="@+id/conversationRecyclerView" />
<LinearLayout
android:id="@+id/attachmentOptionsContainer"
@ -87,19 +106,19 @@
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/small_spacing"
android:elevation="8dp"
android:layout_alignParentStart="true"
android:layout_alignParentBottom="true"
android:layout_marginBottom="60dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@+id/inputBar"
android:layout_marginBottom="16dp"
android:orientation="vertical">
<RelativeLayout
<FrameLayout
android:id="@+id/gifButtonContainer"
android:layout_width="@dimen/input_bar_button_expanded_size"
android:layout_height="@dimen/input_bar_button_expanded_size"
android:contentDescription="@string/AccessibilityId_gif_button"
android:alpha="0" />
<RelativeLayout
<FrameLayout
android:id="@+id/documentButtonContainer"
android:layout_marginTop="8dp"
android:layout_width="@dimen/input_bar_button_expanded_size"
@ -107,7 +126,7 @@
android:contentDescription="@string/AccessibilityId_documents_folder"
android:alpha="0" />
<RelativeLayout
<FrameLayout
android:id="@+id/libraryButtonContainer"
android:layout_marginTop="8dp"
android:layout_width="@dimen/input_bar_button_expanded_size"
@ -115,7 +134,7 @@
android:contentDescription="@string/AccessibilityId_images_folder"
android:alpha="0" />
<RelativeLayout
<FrameLayout
android:id="@+id/cameraButtonContainer"
android:layout_marginTop="8dp"
android:layout_width="@dimen/input_bar_button_expanded_size"
@ -129,22 +148,26 @@
android:id="@+id/textSendAfterApproval"
android:text="@string/ConversationActivity_send_after_approval"
android:visibility="gone"
tools:visibility="visible"
android:textAlignment="center"
android:textColor="@color/classic_light_2"
android:padding="22dp"
android:textSize="12sp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignWithParentIfMissing="true"
android:layout_above="@id/messageRequestBar"/>
tools:text="You'll be able to send"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/typingIndicatorViewContainer"
app:layout_constraintBottom_toTopOf="@+id/messageRequestBar" />
<RelativeLayout
android:id="@+id/scrollToBottomButton"
tools:visibility="visible"
android:visibility="gone"
android:layout_width="40dp"
android:layout_height="50dp"
android:layout_alignParentEnd="true"
android:layout_above="@+id/messageRequestBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/messageRequestBar"
android:layout_alignWithParentIfMissing="true"
android:layout_marginEnd="12dp"
android:layout_marginBottom="32dp">
@ -197,14 +220,14 @@
android:layout_height="wrap_content"
android:layout_marginBottom="-12dp"
android:visibility="gone"
android:layout_alignParentBottom="true" />
app:layout_constraintBottom_toBottomOf="parent" />
<RelativeLayout
<FrameLayout
android:id="@+id/blockedBanner"
android:contentDescription="@string/AccessibilityId_blocked_banner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/toolbar"
app:layout_constraintTop_toBottomOf="@+id/toolbar"
android:background="@color/destructive"
android:visibility="gone"
tools:visibility="visible">
@ -214,20 +237,20 @@
android:contentDescription="@string/AccessibilityId_blocked_banner_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_gravity="center"
android:layout_margin="@dimen/medium_spacing"
android:textColor="@color/white"
android:textSize="@dimen/small_font_size"
android:textStyle="bold"
tools:text="Elon is blocked. Unblock them?" />
</RelativeLayout>
</FrameLayout>
<RelativeLayout
<FrameLayout
android:id="@+id/outdatedBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/blockedBanner"
app:layout_constraintTop_toBottomOf="@+id/blockedBanner"
android:background="@color/outdated_client_banner_background_color"
android:visibility="gone"
tools:visibility="visible">
@ -237,14 +260,14 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:layout_centerInParent="true"
android:layout_gravity="center"
android:layout_marginVertical="@dimen/very_small_spacing"
android:layout_marginHorizontal="@dimen/medium_spacing"
android:textColor="@color/black"
android:textSize="@dimen/tiny_font_size"
tools:text="This user's client is outdated, things may not work as expected" />
</RelativeLayout>
</FrameLayout>
<TextView
android:padding="@dimen/medium_spacing"
@ -254,7 +277,7 @@
android:id="@+id/placeholderText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/blockedBanner"
app:layout_constraintTop_toBottomOf="@+id/outdatedBanner"
android:elevation="8dp"
tools:text="@string/activity_conversation_empty_state_default"
/>
@ -263,11 +286,12 @@
android:id="@+id/messageRequestBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_above="@id/inputBar"
app:layout_constraintBottom_toTopOf="@+id/inputBar"
app:layout_constraintTop_toBottomOf="@+id/textSendAfterApproval"
android:layout_marginBottom="@dimen/large_spacing"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
tools:visibility="gone">
<TextView
android:id="@+id/messageRequestBlock"
@ -321,4 +345,4 @@
</LinearLayout>
</RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="44dp"
android:background="@drawable/mention_candidate_view_background">
xmlns:tools="http://schemas.android.com/tools">
<LinearLayout
android:layout_width="match_parent"
@ -42,6 +41,7 @@
android:textSize="@dimen/small_font_size"
android:textColor="?android:textColorPrimary"
android:maxLines="1"
tools:text="Alice"
android:contentDescription="@string/AccessibilityId_contact_mentions"
android:ellipsize="end" />

View File

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

View File

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

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8" ?>
<manifest>
</manifest>

View File

@ -0,0 +1,3 @@
manifest=TestAndroidManifest.xml
sdk=34
application=android.app.Application

View File

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