[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>pull/1528/head
parent
a260717d42
commit
fec67e282a
@ -0,0 +1,44 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
|
||||
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||
|
||||
class MentionCandidateAdapter(
|
||||
private val onCandidateSelected: ((MentionViewModel.Candidate) -> Unit)
|
||||
) : RecyclerView.Adapter<MentionCandidateAdapter.ViewHolder>() {
|
||||
var candidates = listOf<MentionViewModel.Candidate>()
|
||||
set(newValue) {
|
||||
if (field != newValue) {
|
||||
val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
override fun getOldListSize() = field.size
|
||||
override fun getNewListSize() = newValue.size
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int)
|
||||
= field[oldItemPosition].member.publicKey == newValue[newItemPosition].member.publicKey
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int)
|
||||
= field[oldItemPosition] == newValue[newItemPosition]
|
||||
})
|
||||
|
||||
field = newValue
|
||||
result.dispatchUpdatesTo(this)
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(val binding: ViewMentionCandidateV2Binding)
|
||||
: RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return ViewHolder(ViewMentionCandidateV2Binding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = candidates.size
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val candidate = candidates[position]
|
||||
holder.binding.update(candidate)
|
||||
holder.binding.root.setOnClickListener { onCandidateSelected(candidate) }
|
||||
}
|
||||
}
|
@ -1,42 +1,14 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.RelativeLayout
|
||||
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
|
||||
import org.session.libsession.messaging.mentions.Mention
|
||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||
|
||||
fun ViewMentionCandidateV2Binding.update(candidate: MentionViewModel.Candidate) {
|
||||
mentionCandidateNameTextView.text = candidate.nameHighlighted
|
||||
profilePictureView.publicKey = candidate.member.publicKey
|
||||
profilePictureView.displayName = candidate.member.name
|
||||
profilePictureView.additionalPublicKey = null
|
||||
profilePictureView.update()
|
||||
moderatorIconImageView.visibility = if (candidate.member.isModerator) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
@ -1,90 +0,0 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.ListView
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.messaging.mentions.Mention
|
||||
import org.thoughtcrime.securesms.database.LokiThreadDatabase
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.util.toPx
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MentionCandidatesView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : ListView(context, attrs, defStyleAttr) {
|
||||
private var candidates = listOf<Mention>()
|
||||
set(newValue) { field = newValue; snAdapter.candidates = newValue }
|
||||
var glide: GlideRequests? = null
|
||||
set(newValue) { field = newValue; snAdapter.glide = newValue }
|
||||
var openGroupServer: String? = null
|
||||
set(newValue) { field = newValue; snAdapter.openGroupServer = openGroupServer }
|
||||
var openGroupRoom: String? = null
|
||||
set(newValue) { field = newValue; snAdapter.openGroupRoom = openGroupRoom }
|
||||
var onCandidateSelected: ((Mention) -> Unit)? = null
|
||||
|
||||
@Inject lateinit var threadDb: LokiThreadDatabase
|
||||
|
||||
private val snAdapter by lazy { Adapter(context) }
|
||||
|
||||
private class Adapter(private val context: Context) : BaseAdapter() {
|
||||
var candidates = listOf<Mention>()
|
||||
set(newValue) { field = newValue; notifyDataSetChanged() }
|
||||
var glide: GlideRequests? = null
|
||||
var openGroupServer: String? = null
|
||||
var openGroupRoom: String? = null
|
||||
|
||||
override fun getCount(): Int { return candidates.count() }
|
||||
override fun getItemId(position: Int): Long { return position.toLong() }
|
||||
override fun getItem(position: Int): Mention { return candidates[position] }
|
||||
|
||||
override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View {
|
||||
val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context).apply {
|
||||
contentDescription = context.getString(R.string.AccessibilityId_contact)
|
||||
}
|
||||
val mentionCandidate = getItem(position)
|
||||
cell.glide = glide
|
||||
cell.candidate = mentionCandidate
|
||||
cell.openGroupServer = openGroupServer
|
||||
cell.openGroupRoom = openGroupRoom
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
|
||||
constructor(context: Context) : this(context, null)
|
||||
|
||||
init {
|
||||
clipToOutline = true
|
||||
adapter = snAdapter
|
||||
snAdapter.candidates = candidates
|
||||
setOnItemClickListener { _, _, position, _ ->
|
||||
onCandidateSelected?.invoke(candidates[position])
|
||||
}
|
||||
}
|
||||
|
||||
fun show(candidates: List<Mention>, threadID: Long) {
|
||||
val openGroup = threadDb.getOpenGroupChat(threadID)
|
||||
if (openGroup != null) {
|
||||
openGroupServer = openGroup.server
|
||||
openGroupRoom = openGroup.room
|
||||
}
|
||||
setMentionCandidates(candidates)
|
||||
}
|
||||
|
||||
fun setMentionCandidates(candidates: List<Mention>) {
|
||||
this.candidates = candidates
|
||||
val layoutParams = this.layoutParams as ViewGroup.LayoutParams
|
||||
layoutParams.height = toPx(Math.min(candidates.count(), 4) * 44, resources)
|
||||
this.layoutParams = layoutParams
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
val layoutParams = this.layoutParams as ViewGroup.LayoutParams
|
||||
layoutParams.height = 0
|
||||
this.layoutParams = layoutParams
|
||||
}
|
||||
}
|
@ -0,0 +1,188 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.mention
|
||||
|
||||
import android.text.Selection
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.core.text.getSpans
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
|
||||
private const val SEARCH_QUERY_DEBOUNCE_MILLS = 100L
|
||||
|
||||
/**
|
||||
* A subclass of [SpannableStringBuilder] that provides a way to observe the mention search query,
|
||||
* and also manages the [MentionSpan] in a way that treats the mention span as a whole.
|
||||
*/
|
||||
class MentionEditable : SpannableStringBuilder() {
|
||||
private val queryChangeNotification = MutableSharedFlow<Unit>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_LATEST
|
||||
)
|
||||
|
||||
fun observeMentionSearchQuery(): Flow<SearchQuery?> {
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
return queryChangeNotification
|
||||
.debounce(SEARCH_QUERY_DEBOUNCE_MILLS)
|
||||
.onStart { emit(Unit) }
|
||||
.map { mentionSearchQuery }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
data class SearchQuery(
|
||||
val mentionSymbolStartAt: Int,
|
||||
val query: String
|
||||
)
|
||||
|
||||
val mentionSearchQuery: SearchQuery?
|
||||
get() {
|
||||
val cursorPosition = Selection.getSelectionStart(this)
|
||||
|
||||
// First, make sure we are not selecting text
|
||||
if (cursorPosition != Selection.getSelectionEnd(this)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Make sure we don't already have a mention span at the cursor position
|
||||
if (getSpans(cursorPosition, cursorPosition, MentionSpan::class.java).isNotEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Find the mention symbol '@' before the cursor position
|
||||
val symbolIndex = findEligibleMentionSymbolIndexBefore(cursorPosition - 1)
|
||||
if (symbolIndex < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// The query starts after the symbol '@' and ends at a whitespace, @ or the end
|
||||
val queryStart = symbolIndex + 1
|
||||
var queryEnd = indexOfStartingAt(queryStart) { it.isWhitespace() || it == '@' }
|
||||
if (queryEnd < 0) {
|
||||
queryEnd = length
|
||||
}
|
||||
|
||||
return SearchQuery(
|
||||
mentionSymbolStartAt = symbolIndex,
|
||||
query = subSequence(queryStart, queryEnd).toString()
|
||||
)
|
||||
}
|
||||
|
||||
override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) {
|
||||
var normalisedStart = start
|
||||
var normalisedEnd = end
|
||||
|
||||
val isSelectionStart = what == Selection.SELECTION_START
|
||||
val isSelectionEnd = what == Selection.SELECTION_END
|
||||
|
||||
if (isSelectionStart || isSelectionEnd) {
|
||||
assert(start == end) { "Selection spans must have zero length" }
|
||||
val selection = start
|
||||
|
||||
val mentionSpan = getSpans<MentionSpan>(selection, selection).firstOrNull()
|
||||
if (mentionSpan != null) {
|
||||
val spanStart = getSpanStart(mentionSpan)
|
||||
val spanEnd = getSpanEnd(mentionSpan)
|
||||
|
||||
if (isSelectionStart && selection != spanEnd) {
|
||||
// A selection start will only be adjusted to the start of the mention span,
|
||||
// if the selection start is not at the end the mention span. (A selection start
|
||||
// at the end of the mention span is considered an escape path from the mention span)
|
||||
normalisedStart = spanStart
|
||||
normalisedEnd = normalisedStart
|
||||
} else if (isSelectionEnd && selection != spanStart) {
|
||||
normalisedEnd = spanEnd
|
||||
normalisedStart = normalisedEnd
|
||||
}
|
||||
}
|
||||
|
||||
queryChangeNotification.tryEmit(Unit)
|
||||
}
|
||||
|
||||
super.setSpan(what, normalisedStart, normalisedEnd, flags)
|
||||
}
|
||||
|
||||
override fun removeSpan(what: Any?) {
|
||||
super.removeSpan(what)
|
||||
queryChangeNotification.tryEmit(Unit)
|
||||
}
|
||||
|
||||
// The only method we need to override
|
||||
override fun replace(st: Int, en: Int, source: CharSequence?, start: Int, end: Int): MentionEditable {
|
||||
// Make sure the mention span is treated like a whole
|
||||
var normalisedStart = st
|
||||
var normalisedEnd = en
|
||||
|
||||
if (st != en) {
|
||||
// Find the mention span that intersects with the replaced range, and expand the range to include it,
|
||||
// this does not apply to insertion operation (st == en)
|
||||
for (mentionSpan in getSpans(st, en, MentionSpan::class.java)) {
|
||||
val mentionStart = getSpanStart(mentionSpan)
|
||||
val mentionEnd = getSpanEnd(mentionSpan)
|
||||
|
||||
if (mentionStart < normalisedStart) {
|
||||
normalisedStart = mentionStart
|
||||
}
|
||||
if (mentionEnd > normalisedEnd) {
|
||||
normalisedEnd = mentionEnd
|
||||
}
|
||||
|
||||
removeSpan(mentionSpan)
|
||||
}
|
||||
}
|
||||
|
||||
super.replace(normalisedStart, normalisedEnd, source, start, end)
|
||||
queryChangeNotification.tryEmit(Unit)
|
||||
return this
|
||||
}
|
||||
|
||||
fun addMention(member: MentionViewModel.Member, replaceRange: IntRange) {
|
||||
val replaceWith = "@${member.name} "
|
||||
replace(replaceRange.first, replaceRange.last, replaceWith)
|
||||
setSpan(
|
||||
MentionSpan(member),
|
||||
replaceRange.first,
|
||||
replaceRange.first + replaceWith.length - 1,
|
||||
SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
|
||||
override fun delete(st: Int, en: Int) = replace(st, en, "", 0, 0)
|
||||
|
||||
private fun findEligibleMentionSymbolIndexBefore(offset: Int): Int {
|
||||
if (isEmpty()) {
|
||||
return -1
|
||||
}
|
||||
|
||||
var i = offset.coerceIn(indices)
|
||||
while (i >= 0) {
|
||||
val c = get(i)
|
||||
if (c == '@') {
|
||||
// Make sure there is no more '@' before this one or it's disqualified
|
||||
if (i > 0 && get(i - 1) == '@') {
|
||||
return -1
|
||||
}
|
||||
|
||||
return i
|
||||
} else if (c.isWhitespace()) {
|
||||
break
|
||||
}
|
||||
i--
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
private fun CharSequence.indexOfStartingAt(offset: Int, predicate: (Char) -> Boolean): Int {
|
||||
var i = offset.coerceIn(0..length)
|
||||
while (i < length) {
|
||||
if (predicate(get(i))) {
|
||||
return i
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.mention
|
||||
|
||||
/**
|
||||
* A span that represents a mention in the text.
|
||||
*/
|
||||
class MentionSpan(
|
||||
val member: MentionViewModel.Member
|
||||
)
|
@ -0,0 +1,274 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.mention
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.graphics.Typeface
|
||||
import android.text.Editable
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.style.StyleSpan
|
||||
import androidx.core.text.getSpans
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders.Conversation
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase
|
||||
import org.thoughtcrime.securesms.database.GroupMemberDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||
import org.thoughtcrime.securesms.database.SessionContactDatabase
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.util.observeChanges
|
||||
|
||||
/**
|
||||
* A ViewModel that provides the mention search functionality for a text input.
|
||||
*
|
||||
* To use this ViewModel, you (a view) will need to:
|
||||
* 1. Observe the [autoCompleteState] to get the mention search results.
|
||||
* 2. Set the EditText's editable factory to [editableFactory], via [android.widget.EditText.setEditableFactory]
|
||||
*/
|
||||
class MentionViewModel(
|
||||
threadID: Long,
|
||||
contentResolver: ContentResolver,
|
||||
threadDatabase: ThreadDatabase,
|
||||
groupDatabase: GroupDatabase,
|
||||
mmsDatabase: MmsDatabase,
|
||||
contactDatabase: SessionContactDatabase,
|
||||
memberDatabase: GroupMemberDatabase,
|
||||
storage: Storage,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : ViewModel() {
|
||||
private val editable = MentionEditable()
|
||||
|
||||
/**
|
||||
* A factory that creates a new [Editable] instance that is backed by the same source of truth
|
||||
* used by this viewModel.
|
||||
*/
|
||||
val editableFactory = object : Editable.Factory() {
|
||||
override fun newEditable(source: CharSequence?): Editable {
|
||||
if (source === editable) {
|
||||
return source
|
||||
}
|
||||
|
||||
if (source != null) {
|
||||
editable.replace(0, editable.length, source)
|
||||
}
|
||||
|
||||
return editable
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
private val members: StateFlow<List<Member>?> =
|
||||
(contentResolver.observeChanges(Conversation.getUriForThread(threadID)) as Flow<Any?>)
|
||||
.debounce(500L)
|
||||
.onStart { emit(Unit) }
|
||||
.mapLatest {
|
||||
val recipient = checkNotNull(threadDatabase.getRecipientForThreadId(threadID)) {
|
||||
"Recipient not found for thread ID: $threadID"
|
||||
}
|
||||
|
||||
val memberIDs = when {
|
||||
recipient.isClosedGroupRecipient -> {
|
||||
groupDatabase.getGroupMemberAddresses(recipient.address.toGroupString(), false)
|
||||
.map { it.serialize() }
|
||||
}
|
||||
|
||||
recipient.isCommunityRecipient -> mmsDatabase.getRecentChatMemberIDs(threadID, 20)
|
||||
recipient.isContactRecipient -> listOf(recipient.address.serialize())
|
||||
else -> listOf()
|
||||
}
|
||||
|
||||
val moderatorIDs = if (recipient.isCommunityRecipient) {
|
||||
val groupId = storage.getOpenGroup(threadID)?.id
|
||||
if (groupId.isNullOrBlank()) {
|
||||
emptySet()
|
||||
} else {
|
||||
memberDatabase.getGroupMembersRoles(groupId, memberIDs)
|
||||
.mapNotNullTo(hashSetOf()) { (memberId, roles) ->
|
||||
memberId.takeIf { roles.any { it.isModerator } }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
val contactContext = if (recipient.isCommunityRecipient) {
|
||||
Contact.ContactContext.OPEN_GROUP
|
||||
} else {
|
||||
Contact.ContactContext.REGULAR
|
||||
}
|
||||
|
||||
contactDatabase.getContacts(memberIDs).map { contact ->
|
||||
Member(
|
||||
publicKey = contact.sessionID,
|
||||
name = contact.displayName(contactContext).orEmpty(),
|
||||
isModerator = contact.sessionID in moderatorIDs,
|
||||
)
|
||||
}
|
||||
}
|
||||
.flowOn(dispatcher)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(10_000L), null)
|
||||
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val autoCompleteState: StateFlow<AutoCompleteState> = editable
|
||||
.observeMentionSearchQuery()
|
||||
.flatMapLatest { query ->
|
||||
if (query == null) {
|
||||
return@flatMapLatest flowOf(AutoCompleteState.Idle)
|
||||
}
|
||||
|
||||
members.mapLatest { members ->
|
||||
if (members == null) {
|
||||
return@mapLatest AutoCompleteState.Loading
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Default) {
|
||||
val filtered = if (query.query.isBlank()) {
|
||||
members.mapTo(mutableListOf()) { Candidate(it, it.name, 0) }
|
||||
} else {
|
||||
members.mapNotNullTo(mutableListOf()) { searchAndHighlight(it, query.query) }
|
||||
}
|
||||
|
||||
filtered.sortWith(Candidate.MENTION_LIST_COMPARATOR)
|
||||
AutoCompleteState.Result(filtered, query.query)
|
||||
}
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), AutoCompleteState.Idle)
|
||||
|
||||
private fun searchAndHighlight(
|
||||
haystack: Member,
|
||||
needle: String
|
||||
): Candidate? {
|
||||
val startIndex = haystack.name.indexOf(needle, ignoreCase = true)
|
||||
|
||||
return if (startIndex >= 0) {
|
||||
val endIndex = startIndex + needle.length
|
||||
val spanned = SpannableStringBuilder(haystack.name)
|
||||
spanned.setSpan(
|
||||
StyleSpan(Typeface.BOLD),
|
||||
startIndex,
|
||||
endIndex,
|
||||
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
Candidate(member = haystack, nameHighlighted = spanned, matchScore = startIndex)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun onCandidateSelected(candidatePublicKey: String) {
|
||||
val query = editable.mentionSearchQuery ?: return
|
||||
val autoCompleteState = autoCompleteState.value as? AutoCompleteState.Result ?: return
|
||||
val candidate = autoCompleteState.members.find { it.member.publicKey == candidatePublicKey } ?: return
|
||||
|
||||
editable.addMention(
|
||||
candidate.member,
|
||||
query.mentionSymbolStartAt .. (query.mentionSymbolStartAt + query.query.length + 1)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a message body, normalize it by replacing the display name following '@' with their public key.
|
||||
*
|
||||
* As "@123456" is the standard format for mentioning a user, this method will replace "@Alice" with "@123456"
|
||||
*/
|
||||
fun normalizeMessageBody(): String {
|
||||
val spansWithRanges = editable.getSpans<MentionSpan>()
|
||||
.mapTo(mutableListOf()) { span ->
|
||||
span to (editable.getSpanStart(span)..editable.getSpanEnd(span))
|
||||
}
|
||||
|
||||
spansWithRanges.sortBy { it.second.first }
|
||||
|
||||
val sb = StringBuilder()
|
||||
var offset = 0
|
||||
for ((span, range) in spansWithRanges) {
|
||||
// Add content before the mention span
|
||||
sb.append(editable, offset, range.first)
|
||||
|
||||
// Replace the mention span with "@public key"
|
||||
sb.append('@').append(span.member.publicKey).append(' ')
|
||||
|
||||
offset = range.last + 1
|
||||
}
|
||||
|
||||
// Add the remaining content
|
||||
sb.append(editable, offset, editable.length)
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
data class Member(
|
||||
val publicKey: String,
|
||||
val name: String,
|
||||
val isModerator: Boolean,
|
||||
)
|
||||
|
||||
data class Candidate(
|
||||
val member: Member,
|
||||
// The name with the matching keyword highlighted.
|
||||
val nameHighlighted: CharSequence,
|
||||
// The score of matching the query keyword. Lower is better.
|
||||
val matchScore: Int,
|
||||
) {
|
||||
companion object {
|
||||
val MENTION_LIST_COMPARATOR = compareBy<Candidate> { it.matchScore }
|
||||
.then(compareBy { it.member.name })
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface AutoCompleteState {
|
||||
object Idle : AutoCompleteState
|
||||
object Loading : AutoCompleteState
|
||||
data class Result(val members: List<Candidate>, val query: String) : AutoCompleteState
|
||||
object Error : AutoCompleteState
|
||||
}
|
||||
|
||||
@dagger.assisted.AssistedFactory
|
||||
interface AssistedFactory {
|
||||
fun create(threadId: Long): Factory
|
||||
}
|
||||
|
||||
class Factory @AssistedInject constructor(
|
||||
@Assisted private val threadId: Long,
|
||||
private val contentResolver: ContentResolver,
|
||||
private val threadDatabase: ThreadDatabase,
|
||||
private val groupDatabase: GroupDatabase,
|
||||
private val mmsDatabase: MmsDatabase,
|
||||
private val contactDatabase: SessionContactDatabase,
|
||||
private val storage: Storage,
|
||||
private val memberDatabase: GroupMemberDatabase,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return MentionViewModel(
|
||||
threadID = threadId,
|
||||
contentResolver = contentResolver,
|
||||
threadDatabase = threadDatabase,
|
||||
groupDatabase = groupDatabase,
|
||||
mmsDatabase = mmsDatabase,
|
||||
contactDatabase = contactDatabase,
|
||||
memberDatabase = memberDatabase,
|
||||
storage = storage,
|
||||
) as T
|
||||
}
|
||||
}
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.utilities
|
||||
|
||||
import android.content.Context
|
||||
import org.session.libsession.messaging.mentions.MentionsManager
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
|
||||
object MentionManagerUtilities {
|
||||
|
||||
fun populateUserPublicKeyCacheIfNeeded(threadID: Long, context: Context) {
|
||||
val result = mutableSetOf<String>()
|
||||
val recipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID) ?: return
|
||||
if (recipient.address.isClosedGroup) {
|
||||
val members = DatabaseComponent.get(context).groupDatabase().getGroupMembers(recipient.address.toGroupString(), false).map { it.address.serialize() }
|
||||
result.addAll(members)
|
||||
} else {
|
||||
val messageDatabase = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
val reader = messageDatabase.readerFor(messageDatabase.getConversation(threadID, true, 0, 200))
|
||||
var record: MessageRecord? = reader.next
|
||||
while (record != null) {
|
||||
result.add(record.individualRecipient.address.serialize())
|
||||
try {
|
||||
record = reader.next
|
||||
} catch (exception: Exception) {
|
||||
record = null
|
||||
}
|
||||
}
|
||||
reader.close()
|
||||
result.add(TextSecurePreferences.getLocalNumber(context)!!)
|
||||
}
|
||||
MentionsManager.userPublicKeyCache[threadID] = result
|
||||
}
|
||||
}
|
@ -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 ")
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
@ -0,0 +1,3 @@
|
||||
manifest=TestAndroidManifest.xml
|
||||
sdk=34
|
||||
application=android.app.Application
|
Loading…
Reference in New Issue