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