mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-23 10:05:15 +00:00
Merge branch 'od' into on-2
This commit is contained in:
commit
7111bb7725
@ -368,8 +368,11 @@ dependencies {
|
|||||||
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
|
||||||
androidTestUtil 'androidx.test:orchestrator:1.4.2'
|
androidTestUtil 'androidx.test:orchestrator:1.4.2'
|
||||||
|
|
||||||
testImplementation 'org.robolectric:robolectric:4.4'
|
testImplementation 'org.robolectric:robolectric:4.12.2'
|
||||||
testImplementation 'org.robolectric:shadows-multidex:4.4'
|
testImplementation 'org.robolectric:shadows-multidex:4.12.2'
|
||||||
|
testImplementation 'org.conscrypt:conscrypt-openjdk-uber:2.5.2' // For Robolectric
|
||||||
|
testImplementation 'app.cash.turbine:turbine:1.1.0'
|
||||||
|
|
||||||
|
|
||||||
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
|
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
|
||||||
implementation "androidx.compose.ui:ui:$composeVersion"
|
implementation "androidx.compose.ui:ui:$composeVersion"
|
||||||
|
@ -5,7 +5,6 @@ import kotlinx.coroutines.asExecutor
|
|||||||
import nl.komponents.kovenant.Kovenant
|
import nl.komponents.kovenant.Kovenant
|
||||||
import nl.komponents.kovenant.jvm.asDispatcher
|
import nl.komponents.kovenant.jvm.asDispatcher
|
||||||
import org.session.libsignal.utilities.Log
|
import org.session.libsignal.utilities.Log
|
||||||
import org.session.libsignal.utilities.ThreadUtils
|
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
object AppContext {
|
object AppContext {
|
||||||
|
@ -216,7 +216,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
DatabaseModule.init(this);
|
DatabaseModule.init(this);
|
||||||
MessagingModuleConfiguration.configure(this);
|
MessagingModuleConfiguration.configure(this);
|
||||||
super.onCreate();
|
super.onCreate();
|
||||||
|
|
||||||
messagingModuleConfiguration = new MessagingModuleConfiguration(
|
messagingModuleConfiguration = new MessagingModuleConfiguration(
|
||||||
this,
|
this,
|
||||||
storage,
|
storage,
|
||||||
@ -504,15 +503,23 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Method to clear the local data - returns true on success otherwise false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all local profile data and message history then restart the app after a brief delay.
|
||||||
|
* @return true on success, false otherwise.
|
||||||
|
*/
|
||||||
@SuppressLint("ApplySharedPref")
|
@SuppressLint("ApplySharedPref")
|
||||||
public void clearAllData() {
|
public boolean clearAllData() {
|
||||||
TextSecurePreferences.clearAll(this);
|
TextSecurePreferences.clearAll(this);
|
||||||
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();
|
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();
|
||||||
if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) {
|
if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) {
|
||||||
Log.d("Loki", "Failed to delete database.");
|
Log.d("Loki", "Failed to delete database.");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
configFactory.keyPairChanged();
|
configFactory.keyPairChanged();
|
||||||
Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200));
|
Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void restartApplication() {
|
public void restartApplication() {
|
||||||
|
@ -114,7 +114,7 @@ class MediaGalleryAdapter extends StickyHeaderGridAdapter {
|
|||||||
Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment());
|
Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment());
|
||||||
|
|
||||||
if (slide != null) {
|
if (slide != null) {
|
||||||
thumbnailView.setImageResource(glideRequests, slide, false, null);
|
thumbnailView.setImageResource(glideRequests, slide, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord));
|
thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord));
|
||||||
|
@ -7,10 +7,8 @@ import android.view.View
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.ViewUserBinding
|
import network.loki.messenger.databinding.ViewUserBinding
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
|
||||||
import org.session.libsession.messaging.contacts.Contact
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
|
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||||
|
|
||||||
|
@ -21,7 +21,6 @@ import org.session.libsession.messaging.open_groups.OpenGroup
|
|||||||
import org.session.libsession.utilities.ExpirationUtil
|
import org.session.libsession.utilities.ExpirationUtil
|
||||||
import org.session.libsession.utilities.modifyLayoutParams
|
import org.session.libsession.utilities.modifyLayoutParams
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
|
|
||||||
import org.thoughtcrime.securesms.database.GroupDatabase
|
import org.thoughtcrime.securesms.database.GroupDatabase
|
||||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
||||||
import org.thoughtcrime.securesms.util.DateUtils
|
import org.thoughtcrime.securesms.util.DateUtils
|
||||||
@ -78,7 +77,6 @@ class ConversationActionBarView @JvmOverloads constructor(
|
|||||||
binding.profilePictureView.layoutParams = resources.getDimensionPixelSize(
|
binding.profilePictureView.layoutParams = resources.getDimensionPixelSize(
|
||||||
if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size
|
if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size
|
||||||
).let { LayoutParams(it, it) }
|
).let { LayoutParams(it, it) }
|
||||||
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadId, context)
|
|
||||||
update(recipient, openGroup, config)
|
update(recipient, openGroup, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,115 @@
|
|||||||
|
package org.thoughtcrime.securesms.conversation.v2
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
import org.session.libsession.database.MessageDataProvider
|
||||||
|
import org.session.libsession.database.StorageProtocol
|
||||||
|
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
||||||
|
import org.session.libsession.messaging.jobs.AttachmentUploadJob
|
||||||
|
import org.session.libsession.messaging.jobs.JobQueue
|
||||||
|
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
|
||||||
|
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||||
|
import org.session.libsignal.utilities.Log
|
||||||
|
import org.thoughtcrime.securesms.util.flatten
|
||||||
|
import org.thoughtcrime.securesms.util.timedBuffer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [AttachmentDownloadHandler] is responsible for handling attachment download requests. These
|
||||||
|
* requests will go through different level of checking before they are queued for download.
|
||||||
|
*
|
||||||
|
* To use this handler, call [onAttachmentDownloadRequest] with the attachment that needs to be
|
||||||
|
* downloaded. The call to [onAttachmentDownloadRequest] is cheap and can be called multiple times.
|
||||||
|
*/
|
||||||
|
class AttachmentDownloadHandler(
|
||||||
|
private val storage: StorageProtocol,
|
||||||
|
private val messageDataProvider: MessageDataProvider,
|
||||||
|
jobQueue: JobQueue = JobQueue.shared,
|
||||||
|
scope: CoroutineScope = CoroutineScope(Dispatchers.Default) + SupervisorJob(),
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val BUFFER_TIMEOUT_MILLS = 500L
|
||||||
|
private const val BUFFER_MAX_ITEMS = 10
|
||||||
|
private const val LOG_TAG = "AttachmentDownloadHelper"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val downloadRequests = Channel<DatabaseAttachment>(UNLIMITED)
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch(Dispatchers.Default) {
|
||||||
|
downloadRequests
|
||||||
|
.receiveAsFlow()
|
||||||
|
.timedBuffer(BUFFER_TIMEOUT_MILLS, BUFFER_MAX_ITEMS)
|
||||||
|
.map(::filterEligibleAttachments)
|
||||||
|
.flatten()
|
||||||
|
.collect { attachment ->
|
||||||
|
jobQueue.add(
|
||||||
|
AttachmentDownloadJob(
|
||||||
|
attachmentID = attachment.attachmentId.rowId,
|
||||||
|
databaseMessageID = attachment.mmsId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter attachments that are eligible for creating download jobs.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
private fun filterEligibleAttachments(attachments: List<DatabaseAttachment>): List<DatabaseAttachment> {
|
||||||
|
val pendingAttachmentIDs = storage
|
||||||
|
.getAllPendingJobs(AttachmentDownloadJob.KEY, AttachmentUploadJob.KEY)
|
||||||
|
.values
|
||||||
|
.mapNotNull {
|
||||||
|
(it as? AttachmentUploadJob)?.attachmentID
|
||||||
|
?: (it as? AttachmentDownloadJob)?.attachmentID
|
||||||
|
}
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
|
||||||
|
return attachments.filter { attachment ->
|
||||||
|
eligibleForDownloadTask(
|
||||||
|
attachment,
|
||||||
|
pendingAttachmentIDs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the attachment is eligible for download task.
|
||||||
|
*/
|
||||||
|
private fun eligibleForDownloadTask(
|
||||||
|
attachment: DatabaseAttachment,
|
||||||
|
pendingJobsAttachmentRowIDs: Set<Long>,
|
||||||
|
): Boolean {
|
||||||
|
if (attachment.attachmentId.rowId in pendingJobsAttachmentRowIDs) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val threadID = storage.getThreadIdForMms(attachment.mmsId)
|
||||||
|
|
||||||
|
return AttachmentDownloadJob.eligibleForDownload(
|
||||||
|
threadID, storage, messageDataProvider, attachment.mmsId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun onAttachmentDownloadRequest(attachment: DatabaseAttachment) {
|
||||||
|
if (attachment.transferState != AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING) {
|
||||||
|
Log.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"Attachment ${attachment.attachmentId} is not pending, skipping download"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadRequests.trySend(attachment)
|
||||||
|
}
|
||||||
|
}
|
@ -29,8 +29,8 @@ import android.view.Menu
|
|||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewGroup.LayoutParams
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.widget.RelativeLayout
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.ActivityResult
|
import androidx.activity.result.ActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@ -46,6 +46,7 @@ import androidx.lifecycle.Observer
|
|||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.flowWithLifecycle
|
import androidx.lifecycle.flowWithLifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.loader.app.LoaderManager
|
import androidx.loader.app.LoaderManager
|
||||||
import androidx.loader.content.Loader
|
import androidx.loader.content.Loader
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
@ -67,8 +68,6 @@ import org.session.libsession.messaging.MessagingModuleConfiguration
|
|||||||
import org.session.libsession.messaging.contacts.Contact
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
||||||
import org.session.libsession.messaging.jobs.JobQueue
|
import org.session.libsession.messaging.jobs.JobQueue
|
||||||
import org.session.libsession.messaging.mentions.Mention
|
|
||||||
import org.session.libsession.messaging.mentions.MentionsManager
|
|
||||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||||
import org.session.libsession.messaging.messages.applyExpiryMode
|
import org.session.libsession.messaging.messages.applyExpiryMode
|
||||||
import org.session.libsession.messaging.messages.control.DataExtractionNotification
|
import org.session.libsession.messaging.messages.control.DataExtractionNotification
|
||||||
@ -117,7 +116,8 @@ import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog
|
|||||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton
|
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton
|
||||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate
|
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate
|
||||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate
|
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate
|
||||||
import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidatesView
|
import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidateAdapter
|
||||||
|
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback
|
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback
|
||||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate
|
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate
|
||||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
|
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
|
||||||
@ -215,6 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
@Inject lateinit var storage: Storage
|
@Inject lateinit var storage: Storage
|
||||||
@Inject lateinit var reactionDb: ReactionDatabase
|
@Inject lateinit var reactionDb: ReactionDatabase
|
||||||
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
|
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
|
||||||
|
@Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory
|
||||||
|
|
||||||
private val screenshotObserver by lazy {
|
private val screenshotObserver by lazy {
|
||||||
ScreenshotObserver(this, Handler(Looper.getMainLooper())) {
|
ScreenshotObserver(this, Handler(Looper.getMainLooper())) {
|
||||||
@ -228,7 +229,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
|
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
|
||||||
.get(LinkPreviewViewModel::class.java)
|
.get(LinkPreviewViewModel::class.java)
|
||||||
}
|
}
|
||||||
private val viewModel: ConversationViewModel by viewModels {
|
|
||||||
|
private val threadId: Long by lazy {
|
||||||
var threadId = intent.getLongExtra(THREAD_ID, -1L)
|
var threadId = intent.getLongExtra(THREAD_ID, -1L)
|
||||||
if (threadId == -1L) {
|
if (threadId == -1L) {
|
||||||
intent.getParcelableExtra<Address>(ADDRESS)?.let { it ->
|
intent.getParcelableExtra<Address>(ADDRESS)?.let { it ->
|
||||||
@ -248,6 +250,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
} ?: finish()
|
} ?: finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
threadId
|
||||||
|
}
|
||||||
|
|
||||||
|
private val viewModel: ConversationViewModel by viewModels {
|
||||||
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
|
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
|
||||||
}
|
}
|
||||||
private var actionMode: ActionMode? = null
|
private var actionMode: ActionMode? = null
|
||||||
@ -260,11 +267,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
private var isLockViewExpanded = false
|
private var isLockViewExpanded = false
|
||||||
private var isShowingAttachmentOptions = false
|
private var isShowingAttachmentOptions = false
|
||||||
// Mentions
|
// Mentions
|
||||||
private val mentions = mutableListOf<Mention>()
|
private val mentionViewModel: MentionViewModel by viewModels {
|
||||||
private var mentionCandidatesView: MentionCandidatesView? = null
|
mentionViewModelFactory.create(threadId)
|
||||||
private var previousText: CharSequence = ""
|
}
|
||||||
private var currentMentionStartIndex = -1
|
private val mentionCandidateAdapter = MentionCandidateAdapter {
|
||||||
private var isShowingMentionCandidatesView = false
|
mentionViewModel.onCandidateSelected(it.member.publicKey)
|
||||||
|
}
|
||||||
// Search
|
// Search
|
||||||
val searchViewModel: SearchViewModel by viewModels()
|
val searchViewModel: SearchViewModel by viewModels()
|
||||||
var searchViewItem: MenuItem? = null
|
var searchViewItem: MenuItem? = null
|
||||||
@ -325,11 +333,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
onDeselect(message, position, it)
|
onDeselect(message, position, it)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAttachmentNeedsDownload = { attachmentId, mmsId ->
|
onAttachmentNeedsDownload = viewModel::onAttachmentDownloadRequest,
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
glide = glide,
|
glide = glide,
|
||||||
lifecycleCoroutineScope = lifecycleScope
|
lifecycleCoroutineScope = lifecycleScope
|
||||||
)
|
)
|
||||||
@ -486,6 +490,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupMentionView()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupMentionView() {
|
||||||
|
binding?.conversationMentionCandidates?.let { view ->
|
||||||
|
view.adapter = mentionCandidateAdapter
|
||||||
|
view.itemAnimator = null
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
mentionViewModel.autoCompleteState
|
||||||
|
.collectLatest { state ->
|
||||||
|
mentionCandidateAdapter.candidates =
|
||||||
|
(state as? MentionViewModel.AutoCompleteState.Result)?.members.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding?.inputBar?.setInputBarEditableFactory(mentionViewModel.editableFactory)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
@ -642,23 +667,19 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
binding.inputBar.delegate = this
|
binding.inputBar.delegate = this
|
||||||
binding.inputBarRecordingView.delegate = this
|
binding.inputBarRecordingView.delegate = this
|
||||||
// GIF button
|
// GIF button
|
||||||
binding.gifButtonContainer.addView(gifButton)
|
binding.gifButtonContainer.addView(gifButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||||
gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
|
||||||
gifButton.onUp = { showGIFPicker() }
|
gifButton.onUp = { showGIFPicker() }
|
||||||
gifButton.snIsEnabled = false
|
gifButton.snIsEnabled = false
|
||||||
// Document button
|
// Document button
|
||||||
binding.documentButtonContainer.addView(documentButton)
|
binding.documentButtonContainer.addView(documentButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||||
documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
|
||||||
documentButton.onUp = { showDocumentPicker() }
|
documentButton.onUp = { showDocumentPicker() }
|
||||||
documentButton.snIsEnabled = false
|
documentButton.snIsEnabled = false
|
||||||
// Library button
|
// Library button
|
||||||
binding.libraryButtonContainer.addView(libraryButton)
|
binding.libraryButtonContainer.addView(libraryButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||||
libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
|
||||||
libraryButton.onUp = { pickFromLibrary() }
|
libraryButton.onUp = { pickFromLibrary() }
|
||||||
libraryButton.snIsEnabled = false
|
libraryButton.snIsEnabled = false
|
||||||
// Camera button
|
// Camera button
|
||||||
binding.cameraButtonContainer.addView(cameraButton)
|
binding.cameraButtonContainer.addView(cameraButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||||
cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
|
||||||
cameraButton.onUp = { showCamera() }
|
cameraButton.onUp = { showCamera() }
|
||||||
cameraButton.snIsEnabled = false
|
cameraButton.snIsEnabled = false
|
||||||
}
|
}
|
||||||
@ -910,7 +931,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
if (textSecurePreferences.isLinkPreviewsEnabled()) {
|
if (textSecurePreferences.isLinkPreviewsEnabled()) {
|
||||||
linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0)
|
linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0)
|
||||||
}
|
}
|
||||||
showOrHideMentionCandidatesIfNeeded(newContent)
|
|
||||||
if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty()
|
if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty()
|
||||||
&& !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) {
|
&& !textSecurePreferences.isLinkPreviewsEnabled() && !textSecurePreferences.hasSeenLinkPreviewSuggestionDialog()) {
|
||||||
LinkPreviewDialog {
|
LinkPreviewDialog {
|
||||||
@ -922,76 +942,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showOrHideMentionCandidatesIfNeeded(text: CharSequence) {
|
|
||||||
if (text.length < previousText.length) {
|
|
||||||
currentMentionStartIndex = -1
|
|
||||||
hideMentionCandidates()
|
|
||||||
val mentionsToRemove = mentions.filter { !text.contains(it.displayName) }
|
|
||||||
mentions.removeAll(mentionsToRemove)
|
|
||||||
}
|
|
||||||
if (text.isNotEmpty()) {
|
|
||||||
val lastCharIndex = text.lastIndex
|
|
||||||
val lastChar = text[lastCharIndex]
|
|
||||||
// Check if there is whitespace before the '@' or the '@' is the first character
|
|
||||||
val isCharacterBeforeLastWhiteSpaceOrStartOfLine: Boolean
|
|
||||||
if (text.length == 1) {
|
|
||||||
isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // Start of line
|
|
||||||
} else {
|
|
||||||
val charBeforeLast = text[lastCharIndex - 1]
|
|
||||||
isCharacterBeforeLastWhiteSpaceOrStartOfLine = Character.isWhitespace(charBeforeLast)
|
|
||||||
}
|
|
||||||
if (lastChar == '@' && isCharacterBeforeLastWhiteSpaceOrStartOfLine) {
|
|
||||||
currentMentionStartIndex = lastCharIndex
|
|
||||||
showOrUpdateMentionCandidatesIfNeeded()
|
|
||||||
} else if (Character.isWhitespace(lastChar) || lastChar == '@') { // the lastCharacter == "@" is to check for @@
|
|
||||||
currentMentionStartIndex = -1
|
|
||||||
hideMentionCandidates()
|
|
||||||
} else if (currentMentionStartIndex != -1) {
|
|
||||||
val query = text.substring(currentMentionStartIndex + 1) // + 1 to get rid of the "@"
|
|
||||||
showOrUpdateMentionCandidatesIfNeeded(query)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
currentMentionStartIndex = -1
|
|
||||||
hideMentionCandidates()
|
|
||||||
}
|
|
||||||
previousText = text
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showOrUpdateMentionCandidatesIfNeeded(query: String = "") {
|
|
||||||
val additionalContentContainer = binding?.additionalContentContainer ?: return
|
|
||||||
val recipient = viewModel.recipient ?: return
|
|
||||||
if (!isShowingMentionCandidatesView) {
|
|
||||||
additionalContentContainer.removeAllViews()
|
|
||||||
val view = MentionCandidatesView(this).apply {
|
|
||||||
contentDescription = context.getString(R.string.AccessibilityId_mentions_list)
|
|
||||||
}
|
|
||||||
view.glide = glide
|
|
||||||
view.onCandidateSelected = { handleMentionSelected(it) }
|
|
||||||
additionalContentContainer.addView(view)
|
|
||||||
val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
|
|
||||||
this.mentionCandidatesView = view
|
|
||||||
view.show(candidates, viewModel.threadId)
|
|
||||||
} else {
|
|
||||||
val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
|
|
||||||
this.mentionCandidatesView!!.setMentionCandidates(candidates)
|
|
||||||
}
|
|
||||||
isShowingMentionCandidatesView = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hideMentionCandidates() {
|
|
||||||
if (isShowingMentionCandidatesView) {
|
|
||||||
val mentionCandidatesView = mentionCandidatesView ?: return
|
|
||||||
val animation = ValueAnimator.ofObject(FloatEvaluator(), mentionCandidatesView.alpha, 0.0f)
|
|
||||||
animation.duration = 250L
|
|
||||||
animation.addUpdateListener { animator ->
|
|
||||||
mentionCandidatesView.alpha = animator.animatedValue as Float
|
|
||||||
if (animator.animatedFraction == 1.0f) { binding?.additionalContentContainer?.removeAllViews() }
|
|
||||||
}
|
|
||||||
animation.start()
|
|
||||||
}
|
|
||||||
isShowingMentionCandidatesView = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun toggleAttachmentOptions() {
|
override fun toggleAttachmentOptions() {
|
||||||
val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f
|
val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f
|
||||||
val allButtonContainers = listOfNotNull(
|
val allButtonContainers = listOfNotNull(
|
||||||
@ -1507,18 +1457,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
return hitRect.contains(x, y)
|
return hitRect.contains(x, y)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleMentionSelected(mention: Mention) {
|
|
||||||
val binding = binding ?: return
|
|
||||||
if (currentMentionStartIndex == -1) { return }
|
|
||||||
mentions.add(mention)
|
|
||||||
val previousText = binding.inputBar.text
|
|
||||||
val newText = previousText.substring(0, currentMentionStartIndex) + "@" + mention.displayName + " "
|
|
||||||
binding.inputBar.text = newText
|
|
||||||
binding.inputBar.setSelection(newText.length)
|
|
||||||
currentMentionStartIndex = -1
|
|
||||||
hideMentionCandidates()
|
|
||||||
this.previousText = newText
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun scrollToMessageIfPossible(timestamp: Long) {
|
override fun scrollToMessageIfPossible(timestamp: Long) {
|
||||||
val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return
|
val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return
|
||||||
@ -1615,10 +1553,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
binding?.inputBar?.text = ""
|
binding?.inputBar?.text = ""
|
||||||
binding?.inputBar?.cancelQuoteDraft()
|
binding?.inputBar?.cancelQuoteDraft()
|
||||||
binding?.inputBar?.cancelLinkPreviewDraft()
|
binding?.inputBar?.cancelLinkPreviewDraft()
|
||||||
// Clear mentions
|
|
||||||
previousText = ""
|
|
||||||
currentMentionStartIndex = -1
|
|
||||||
mentions.clear()
|
|
||||||
// Put the message in the database
|
// Put the message in the database
|
||||||
message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true)
|
message.id = smsDb.insertMessageOutbox(viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, null, true)
|
||||||
// Send it
|
// Send it
|
||||||
@ -1663,10 +1597,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
binding?.inputBar?.text = ""
|
binding?.inputBar?.text = ""
|
||||||
binding?.inputBar?.cancelQuoteDraft()
|
binding?.inputBar?.cancelQuoteDraft()
|
||||||
binding?.inputBar?.cancelLinkPreviewDraft()
|
binding?.inputBar?.cancelLinkPreviewDraft()
|
||||||
// Clear mentions
|
|
||||||
previousText = ""
|
|
||||||
currentMentionStartIndex = -1
|
|
||||||
mentions.clear()
|
|
||||||
// Reset the attachment manager
|
// Reset the attachment manager
|
||||||
attachmentManager.clear()
|
attachmentManager.clear()
|
||||||
// Reset attachments button if needed
|
// Reset attachments button if needed
|
||||||
@ -1953,7 +1883,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
val messageIterator = sortedMessages.iterator()
|
val messageIterator = sortedMessages.iterator()
|
||||||
while (messageIterator.hasNext()) {
|
while (messageIterator.hasNext()) {
|
||||||
val message = messageIterator.next()
|
val message = messageIterator.next()
|
||||||
val body = MentionUtilities.highlightMentions(message.body, viewModel.threadId, this)
|
val body = MentionUtilities.highlightMentions(
|
||||||
|
text = message.body,
|
||||||
|
formatOnly = true, // no styling here, only text formatting
|
||||||
|
threadID = viewModel.threadId,
|
||||||
|
context = this
|
||||||
|
)
|
||||||
|
|
||||||
if (TextUtils.isEmpty(body)) { continue }
|
if (TextUtils.isEmpty(body)) { continue }
|
||||||
if (messageSize > 1) {
|
if (messageSize > 1) {
|
||||||
val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp)
|
val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp)
|
||||||
@ -2094,17 +2030,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
|||||||
|
|
||||||
// region General
|
// region General
|
||||||
private fun getMessageBody(): String {
|
private fun getMessageBody(): String {
|
||||||
var result = binding?.inputBar?.text?.trim() ?: return ""
|
return mentionViewModel.normalizeMessageBody()
|
||||||
for (mention in mentions) {
|
|
||||||
try {
|
|
||||||
val startIndex = result.indexOf("@" + mention.displayName)
|
|
||||||
val endIndex = startIndex + mention.displayName.count() + 1 // + 1 to include the "@"
|
|
||||||
result = result.substring(0, startIndex) + "@" + mention.publicKey + result.substring(endIndex)
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
Log.d("Loki", "Failed to process mention due to error: $exception")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
|
@ -19,6 +19,7 @@ import kotlinx.coroutines.isActive
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import org.session.libsession.messaging.contacts.Contact
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
|
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||||
import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView
|
import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView
|
||||||
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
|
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
|
||||||
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
|
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
|
||||||
@ -40,7 +41,7 @@ class ConversationAdapter(
|
|||||||
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit,
|
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit,
|
||||||
private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit,
|
private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit,
|
||||||
private val onDeselect: (MessageRecord, Int) -> Unit,
|
private val onDeselect: (MessageRecord, Int) -> Unit,
|
||||||
private val onAttachmentNeedsDownload: (Long, Long) -> Unit,
|
private val onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
|
||||||
private val glide: GlideRequests,
|
private val glide: GlideRequests,
|
||||||
lifecycleCoroutineScope: LifecycleCoroutineScope
|
lifecycleCoroutineScope: LifecycleCoroutineScope
|
||||||
) : CursorRecyclerViewAdapter<ViewHolder>(context, cursor) {
|
) : CursorRecyclerViewAdapter<ViewHolder>(context, cursor) {
|
||||||
|
@ -1,46 +1,44 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2
|
package org.thoughtcrime.securesms.conversation.v2
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
|
||||||
import com.goterl.lazysodium.utils.KeyPair
|
import com.goterl.lazysodium.utils.KeyPair
|
||||||
|
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.session.libsession.database.MessageDataProvider
|
||||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||||
import org.session.libsession.messaging.open_groups.OpenGroup
|
import org.session.libsession.messaging.open_groups.OpenGroup
|
||||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||||
|
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||||
import org.session.libsession.messaging.utilities.AccountId
|
import org.session.libsession.messaging.utilities.AccountId
|
||||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||||
import org.session.libsession.utilities.Address
|
import org.session.libsession.utilities.Address
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.session.libsignal.utilities.IdPrefix
|
import org.session.libsignal.utilities.IdPrefix
|
||||||
import org.session.libsignal.utilities.Log
|
import org.session.libsignal.utilities.Log
|
||||||
|
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||||
import org.thoughtcrime.securesms.audio.AudioSlidePlayer
|
import org.thoughtcrime.securesms.audio.AudioSlidePlayer
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.database.Storage
|
import org.thoughtcrime.securesms.database.Storage
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||||
import org.thoughtcrime.securesms.repository.ConversationRepository
|
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class ConversationViewModel(
|
class ConversationViewModel(
|
||||||
val threadId: Long,
|
val threadId: Long,
|
||||||
val edKeyPair: KeyPair?,
|
val edKeyPair: KeyPair?,
|
||||||
private val repository: ConversationRepository,
|
private val repository: ConversationRepository,
|
||||||
private val storage: Storage
|
private val storage: Storage,
|
||||||
|
private val messageDataProvider: MessageDataProvider,
|
||||||
|
database: MmsDatabase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val showSendAfterApprovalText: Boolean
|
val showSendAfterApprovalText: Boolean
|
||||||
@ -92,6 +90,11 @@ class ConversationViewModel(
|
|||||||
// allow reactions if the open group is null (normal conversations) or the open group's capabilities include reactions
|
// allow reactions if the open group is null (normal conversations) or the open group's capabilities include reactions
|
||||||
get() = (openGroup == null || OpenGroupApi.Capability.REACTIONS.name.lowercase() in serverCapabilities)
|
get() = (openGroup == null || OpenGroupApi.Capability.REACTIONS.name.lowercase() in serverCapabilities)
|
||||||
|
|
||||||
|
private val attachmentDownloadHandler = AttachmentDownloadHandler(
|
||||||
|
storage = storage,
|
||||||
|
messageDataProvider = messageDataProvider,
|
||||||
|
scope = viewModelScope,
|
||||||
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
@ -242,7 +245,7 @@ class ConversationViewModel(
|
|||||||
currentUiState.copy(uiMessages = messages)
|
currentUiState.copy(uiMessages = messages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun messageShown(messageId: Long) {
|
fun messageShown(messageId: Long) {
|
||||||
_uiState.update { currentUiState ->
|
_uiState.update { currentUiState ->
|
||||||
val messages = currentUiState.uiMessages.filterNot { it.id == messageId }
|
val messages = currentUiState.uiMessages.filterNot { it.id == messageId }
|
||||||
@ -265,6 +268,10 @@ class ConversationViewModel(
|
|||||||
storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) }
|
storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onAttachmentDownloadRequest(attachment: DatabaseAttachment) {
|
||||||
|
attachmentDownloadHandler.onAttachmentDownloadRequest(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
@dagger.assisted.AssistedFactory
|
@dagger.assisted.AssistedFactory
|
||||||
interface AssistedFactory {
|
interface AssistedFactory {
|
||||||
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
|
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
|
||||||
@ -275,11 +282,20 @@ class ConversationViewModel(
|
|||||||
@Assisted private val threadId: Long,
|
@Assisted private val threadId: Long,
|
||||||
@Assisted private val edKeyPair: KeyPair?,
|
@Assisted private val edKeyPair: KeyPair?,
|
||||||
private val repository: ConversationRepository,
|
private val repository: ConversationRepository,
|
||||||
private val storage: Storage
|
private val storage: Storage,
|
||||||
|
private val mmsDatabase: MmsDatabase,
|
||||||
|
private val messageDataProvider: MessageDataProvider,
|
||||||
) : ViewModelProvider.Factory {
|
) : ViewModelProvider.Factory {
|
||||||
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
return ConversationViewModel(threadId, edKeyPair, repository, storage) as T
|
return ConversationViewModel(
|
||||||
|
threadId = threadId,
|
||||||
|
edKeyPair = edKeyPair,
|
||||||
|
repository = repository,
|
||||||
|
storage = storage,
|
||||||
|
messageDataProvider = messageDataProvider,
|
||||||
|
database = mmsDatabase
|
||||||
|
) as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -50,6 +50,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
|
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
|
||||||
|
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||||
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
|
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
|
||||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||||
import org.thoughtcrime.securesms.database.Storage
|
import org.thoughtcrime.securesms.database.Storage
|
||||||
@ -146,7 +147,7 @@ fun MessageDetails(
|
|||||||
onResend: (() -> Unit)? = null,
|
onResend: (() -> Unit)? = null,
|
||||||
onDelete: () -> Unit = {},
|
onDelete: () -> Unit = {},
|
||||||
onClickImage: (Int) -> Unit = {},
|
onClickImage: (Int) -> Unit = {},
|
||||||
onAttachmentNeedsDownload: (Long, Long) -> Unit = { _, _ -> }
|
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit = { _ -> }
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
@ -124,7 +124,7 @@ class MessageDetailsViewModel @Inject constructor(
|
|||||||
if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) {
|
if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) {
|
||||||
// Restart download here (on IO thread)
|
// Restart download here (on IO thread)
|
||||||
(slide.asAttachment() as? DatabaseAttachment)?.let { attachment ->
|
(slide.asAttachment() as? DatabaseAttachment)?.let { attachment ->
|
||||||
onAttachmentNeedsDownload(attachment.attachmentId.rowId, state.mmsRecord.getId())
|
onAttachmentNeedsDownload(attachment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,9 +137,9 @@ class MessageDetailsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAttachmentNeedsDownload(attachmentId: Long, mmsId: Long) {
|
fun onAttachmentNeedsDownload(attachment: DatabaseAttachment) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId))
|
JobQueue.shared.add(AttachmentDownloadJob(attachment.attachmentId.rowId, attachment.mmsId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,7 +48,7 @@ class AlbumThumbnailView : RelativeLayout {
|
|||||||
|
|
||||||
// region Interaction
|
// region Interaction
|
||||||
|
|
||||||
fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient, onAttachmentNeedsDownload: (Long, Long) -> Unit) {
|
fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient, onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit) {
|
||||||
val rawXInt = event.rawX.toInt()
|
val rawXInt = event.rawX.toInt()
|
||||||
val rawYInt = event.rawY.toInt()
|
val rawYInt = event.rawY.toInt()
|
||||||
val eventRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt)
|
val eventRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt)
|
||||||
@ -63,7 +63,7 @@ class AlbumThumbnailView : RelativeLayout {
|
|||||||
if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) {
|
if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) {
|
||||||
// Restart download here (on IO thread)
|
// Restart download here (on IO thread)
|
||||||
(slide.asAttachment() as? DatabaseAttachment)?.let { attachment ->
|
(slide.asAttachment() as? DatabaseAttachment)?.let { attachment ->
|
||||||
onAttachmentNeedsDownload(attachment.attachmentId.rowId, mms.getId())
|
onAttachmentNeedsDownload(attachment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (slide.isInProgress) return@forEach
|
if (slide.isInProgress) return@forEach
|
||||||
@ -104,7 +104,7 @@ class AlbumThumbnailView : RelativeLayout {
|
|||||||
// iterate binding
|
// iterate binding
|
||||||
slides.take(MAX_ALBUM_DISPLAY_SIZE).forEachIndexed { position, slide ->
|
slides.take(MAX_ALBUM_DISPLAY_SIZE).forEachIndexed { position, slide ->
|
||||||
val thumbnailView = getThumbnailView(position)
|
val thumbnailView = getThumbnailView(position)
|
||||||
thumbnailView.setImageResource(glideRequests, slide, isPreview = false, mms = message)
|
thumbnailView.setImageResource(glideRequests, slide, isPreview = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,10 +31,10 @@ class LinkPreviewDraftView : LinearLayout {
|
|||||||
// Hide the loader and show the content view
|
// Hide the loader and show the content view
|
||||||
binding.linkPreviewDraftContainer.isVisible = true
|
binding.linkPreviewDraftContainer.isVisible = true
|
||||||
binding.linkPreviewDraftLoader.isVisible = false
|
binding.linkPreviewDraftLoader.isVisible = false
|
||||||
binding.thumbnailImageView.root.radius = toPx(4, resources)
|
binding.thumbnailImageView.root.setRoundedCorners(toPx(4, resources))
|
||||||
if (linkPreview.getThumbnail().isPresent) {
|
if (linkPreview.getThumbnail().isPresent) {
|
||||||
// This internally fetches the thumbnail
|
// This internally fetches the thumbnail
|
||||||
binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, null)
|
binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false)
|
||||||
}
|
}
|
||||||
binding.linkPreviewDraftTitleTextView.text = linkPreview.title
|
binding.linkPreviewDraftTitleTextView.text = linkPreview.title
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.PointF
|
import android.graphics.PointF
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.text.Editable
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
@ -227,8 +228,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
|||||||
binding.inputBarEditText.addTextChangedListener(listener)
|
binding.inputBarEditText.addTextChangedListener(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSelection(index: Int) {
|
fun setInputBarEditableFactory(factory: Editable.Factory) {
|
||||||
binding.inputBarEditText.setSelection(index)
|
binding.inputBarEditText.setEditableFactory(factory)
|
||||||
}
|
}
|
||||||
// endregion
|
// endregion
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,44 @@
|
|||||||
|
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
|
||||||
|
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||||
|
|
||||||
|
class MentionCandidateAdapter(
|
||||||
|
private val onCandidateSelected: ((MentionViewModel.Candidate) -> Unit)
|
||||||
|
) : RecyclerView.Adapter<MentionCandidateAdapter.ViewHolder>() {
|
||||||
|
var candidates = listOf<MentionViewModel.Candidate>()
|
||||||
|
set(newValue) {
|
||||||
|
if (field != newValue) {
|
||||||
|
val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||||
|
override fun getOldListSize() = field.size
|
||||||
|
override fun getNewListSize() = newValue.size
|
||||||
|
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int)
|
||||||
|
= field[oldItemPosition].member.publicKey == newValue[newItemPosition].member.publicKey
|
||||||
|
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int)
|
||||||
|
= field[oldItemPosition] == newValue[newItemPosition]
|
||||||
|
})
|
||||||
|
|
||||||
|
field = newValue
|
||||||
|
result.dispatchUpdatesTo(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewHolder(val binding: ViewMentionCandidateV2Binding)
|
||||||
|
: RecyclerView.ViewHolder(binding.root)
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
return ViewHolder(ViewMentionCandidateV2Binding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = candidates.size
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
|
val candidate = candidates[position]
|
||||||
|
holder.binding.update(candidate)
|
||||||
|
holder.binding.root.setOnClickListener { onCandidateSelected(candidate) }
|
||||||
|
}
|
||||||
|
}
|
@ -1,42 +1,14 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
|
package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.RelativeLayout
|
|
||||||
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
|
import network.loki.messenger.databinding.ViewMentionCandidateV2Binding
|
||||||
import org.session.libsession.messaging.mentions.Mention
|
import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
|
||||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
|
||||||
|
|
||||||
class MentionCandidateView : RelativeLayout {
|
fun ViewMentionCandidateV2Binding.update(candidate: MentionViewModel.Candidate) {
|
||||||
private lateinit var binding: ViewMentionCandidateV2Binding
|
mentionCandidateNameTextView.text = candidate.nameHighlighted
|
||||||
var candidate = Mention("", "")
|
profilePictureView.publicKey = candidate.member.publicKey
|
||||||
set(newValue) { field = newValue; update() }
|
profilePictureView.displayName = candidate.member.name
|
||||||
var glide: GlideRequests? = null
|
profilePictureView.additionalPublicKey = null
|
||||||
var openGroupServer: String? = null
|
profilePictureView.update()
|
||||||
var openGroupRoom: String? = null
|
moderatorIconImageView.visibility = if (candidate.member.isModerator) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -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.accountID,
|
||||||
|
name = contact.displayName(contactContext).orEmpty(),
|
||||||
|
isModerator = contact.accountID 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -41,7 +41,7 @@ class LinkPreviewView : LinearLayout {
|
|||||||
// Thumbnail
|
// Thumbnail
|
||||||
if (linkPreview.getThumbnail().isPresent) {
|
if (linkPreview.getThumbnail().isPresent) {
|
||||||
// This internally fetches the thumbnail
|
// This internally fetches the thumbnail
|
||||||
binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message)
|
binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false)
|
||||||
binding.thumbnailImageView.root.loadIndicator.isVisible = false
|
binding.thumbnailImageView.root.loadIndicator.isVisible = false
|
||||||
}
|
}
|
||||||
// Title
|
// Title
|
||||||
|
@ -80,7 +80,15 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
|
|||||||
binding.quoteViewAuthorTextView.text = authorDisplayName
|
binding.quoteViewAuthorTextView.text = authorDisplayName
|
||||||
binding.quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage))
|
binding.quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage))
|
||||||
// Body
|
// Body
|
||||||
binding.quoteViewBodyTextView.text = if (isOpenGroupInvitation) resources.getString(R.string.open_group_invitation_view__open_group_invitation) else MentionUtilities.highlightMentions((body ?: "").toSpannable(), threadID, context)
|
binding.quoteViewBodyTextView.text = if (isOpenGroupInvitation)
|
||||||
|
resources.getString(R.string.open_group_invitation_view__open_group_invitation)
|
||||||
|
else MentionUtilities.highlightMentions(
|
||||||
|
text = (body ?: "").toSpannable(),
|
||||||
|
isOutgoingMessage = isOutgoingMessage,
|
||||||
|
isQuote = true,
|
||||||
|
threadID = threadID,
|
||||||
|
context = context
|
||||||
|
)
|
||||||
binding.quoteViewBodyTextView.setTextColor(getTextColor(isOutgoingMessage))
|
binding.quoteViewBodyTextView.setTextColor(getTextColor(isOutgoingMessage))
|
||||||
// Accent line / attachment preview
|
// Accent line / attachment preview
|
||||||
val hasAttachments = (attachments != null && attachments.asAttachments().isNotEmpty()) && !isOriginalMissing
|
val hasAttachments = (attachments != null && attachments.asAttachments().isNotEmpty()) && !isOriginalMissing
|
||||||
@ -108,8 +116,9 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
|
|||||||
attachments.thumbnailSlide != null -> {
|
attachments.thumbnailSlide != null -> {
|
||||||
val slide = attachments.thumbnailSlide!!
|
val slide = attachments.thumbnailSlide!!
|
||||||
// This internally fetches the thumbnail
|
// This internally fetches the thumbnail
|
||||||
binding.quoteViewAttachmentThumbnailImageView.root.radius = toPx(4, resources)
|
binding.quoteViewAttachmentThumbnailImageView
|
||||||
binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false, null)
|
.root.setRoundedCorners(toPx(4, resources))
|
||||||
|
binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false)
|
||||||
binding.quoteViewAttachmentThumbnailImageView.root.isVisible = true
|
binding.quoteViewAttachmentThumbnailImageView.root.isVisible = true
|
||||||
binding.quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.Slide_video) else resources.getString(R.string.Slide_image)
|
binding.quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.Slide_video) else resources.getString(R.string.Slide_image)
|
||||||
}
|
}
|
||||||
|
@ -66,7 +66,7 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
thread: Recipient,
|
thread: Recipient,
|
||||||
searchQuery: String? = null,
|
searchQuery: String? = null,
|
||||||
contactIsTrusted: Boolean = true,
|
contactIsTrusted: Boolean = true,
|
||||||
onAttachmentNeedsDownload: (Long, Long) -> Unit,
|
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
|
||||||
suppressThumbnails: Boolean = false
|
suppressThumbnails: Boolean = false
|
||||||
) {
|
) {
|
||||||
// Background
|
// Background
|
||||||
@ -135,19 +135,11 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
if (message is MmsMessageRecord) {
|
if (message is MmsMessageRecord) {
|
||||||
message.slideDeck.asAttachments().forEach { attach ->
|
message.slideDeck.asAttachments().forEach { attach ->
|
||||||
val dbAttachment = attach as? DatabaseAttachment ?: return@forEach
|
val dbAttachment = attach as? DatabaseAttachment ?: return@forEach
|
||||||
val attachmentId = dbAttachment.attachmentId.rowId
|
onAttachmentNeedsDownload(dbAttachment)
|
||||||
if (attach.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING
|
|
||||||
&& MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) {
|
|
||||||
onAttachmentNeedsDownload(attachmentId, dbAttachment.mmsId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
message.linkPreviews.forEach { preview ->
|
message.linkPreviews.forEach { preview ->
|
||||||
val previewThumbnail = preview.getThumbnail().orNull() as? DatabaseAttachment ?: return@forEach
|
val previewThumbnail = preview.getThumbnail().orNull() as? DatabaseAttachment ?: return@forEach
|
||||||
val attachmentId = previewThumbnail.attachmentId.rowId
|
onAttachmentNeedsDownload(previewThumbnail)
|
||||||
if (previewThumbnail.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING
|
|
||||||
&& MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) {
|
|
||||||
onAttachmentNeedsDownload(attachmentId, previewThumbnail.mmsId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -282,7 +274,12 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
fun getBodySpans(context: Context, message: MessageRecord, searchQuery: String?): Spannable {
|
fun getBodySpans(context: Context, message: MessageRecord, searchQuery: String?): Spannable {
|
||||||
var body = message.body.toSpannable()
|
var body = message.body.toSpannable()
|
||||||
|
|
||||||
body = MentionUtilities.highlightMentions(body, message.isOutgoing, message.threadId, context)
|
body = MentionUtilities.highlightMentions(
|
||||||
|
text = body,
|
||||||
|
isOutgoingMessage = message.isOutgoing,
|
||||||
|
threadID = message.threadId,
|
||||||
|
context = context
|
||||||
|
)
|
||||||
body = SearchUtil.getHighlightedSpan(Locale.getDefault(),
|
body = SearchUtil.getHighlightedSpan(Locale.getDefault(),
|
||||||
{ BackgroundColorSpan(Color.WHITE) }, body, searchQuery)
|
{ BackgroundColorSpan(Color.WHITE) }, body, searchQuery)
|
||||||
body = SearchUtil.getHighlightedSpan(Locale.getDefault(),
|
body = SearchUtil.getHighlightedSpan(Locale.getDefault(),
|
||||||
|
@ -34,6 +34,7 @@ import network.loki.messenger.databinding.ViewstubVisibleMessageMarkerContainerB
|
|||||||
import org.session.libsession.messaging.contacts.Contact
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
import org.session.libsession.messaging.contacts.Contact.ContactContext
|
import org.session.libsession.messaging.contacts.Contact.ContactContext
|
||||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||||
|
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||||
import org.session.libsession.utilities.Address
|
import org.session.libsession.utilities.Address
|
||||||
import org.session.libsession.utilities.ViewUtil
|
import org.session.libsession.utilities.ViewUtil
|
||||||
import org.session.libsession.utilities.getColorFromAttr
|
import org.session.libsession.utilities.getColorFromAttr
|
||||||
@ -145,7 +146,7 @@ class VisibleMessageView : FrameLayout {
|
|||||||
senderAccountID: String,
|
senderAccountID: String,
|
||||||
lastSeen: Long,
|
lastSeen: Long,
|
||||||
delegate: VisibleMessageViewDelegate? = null,
|
delegate: VisibleMessageViewDelegate? = null,
|
||||||
onAttachmentNeedsDownload: (Long, Long) -> Unit,
|
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
|
||||||
lastSentMessageId: Long
|
lastSentMessageId: Long
|
||||||
) {
|
) {
|
||||||
replyDisabled = message.isOpenGroupInvitation
|
replyDisabled = message.isOpenGroupInvitation
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,7 +1,7 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2.utilities
|
package org.thoughtcrime.securesms.conversation.v2.utilities
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
import android.text.Spannable
|
import android.text.Spannable
|
||||||
import android.text.SpannableString
|
import android.text.SpannableString
|
||||||
@ -9,43 +9,60 @@ import android.text.style.BackgroundColorSpan
|
|||||||
import android.text.style.ForegroundColorSpan
|
import android.text.style.ForegroundColorSpan
|
||||||
import android.text.style.StyleSpan
|
import android.text.style.StyleSpan
|
||||||
import android.util.Range
|
import android.util.Range
|
||||||
import androidx.appcompat.widget.ThemeUtils
|
|
||||||
import androidx.core.content.res.ResourcesCompat
|
import androidx.core.content.res.ResourcesCompat
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import nl.komponents.kovenant.combine.Tuple2
|
import nl.komponents.kovenant.combine.Tuple2
|
||||||
import org.session.libsession.messaging.contacts.Contact
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
|
import org.session.libsession.messaging.open_groups.OpenGroup
|
||||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
import org.session.libsession.utilities.ThemeUtil
|
import org.session.libsession.utilities.ThemeUtil
|
||||||
import org.session.libsignal.utilities.Log
|
import org.session.libsession.utilities.getColorFromAttr
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||||
import org.thoughtcrime.securesms.util.UiModeUtilities
|
import org.thoughtcrime.securesms.util.RoundedBackgroundSpan
|
||||||
import org.thoughtcrime.securesms.util.getAccentColor
|
import org.thoughtcrime.securesms.util.getAccentColor
|
||||||
import org.thoughtcrime.securesms.util.getColorResourceIdFromAttr
|
import org.thoughtcrime.securesms.util.toPx
|
||||||
import org.thoughtcrime.securesms.util.getMessageTextColourAttr
|
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
object MentionUtilities {
|
object MentionUtilities {
|
||||||
|
|
||||||
@JvmStatic
|
private val pattern by lazy { Pattern.compile("@[0-9a-fA-F]*") }
|
||||||
fun highlightMentions(text: CharSequence, threadID: Long, context: Context): String {
|
|
||||||
return highlightMentions(text, false, threadID, context).toString() // isOutgoingMessage is irrelevant
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlights mentions in a given text.
|
||||||
|
*
|
||||||
|
* @param text The text to highlight mentions in.
|
||||||
|
* @param isOutgoingMessage Whether the message is outgoing.
|
||||||
|
* @param isQuote Whether the message is a quote.
|
||||||
|
* @param formatOnly Whether to only format the mentions. If true we only format the text itself,
|
||||||
|
* for example resolving an accountID to a username. If false we also apply styling, like colors and background.
|
||||||
|
* @param threadID The ID of the thread the message belongs to.
|
||||||
|
* @param context The context to use.
|
||||||
|
* @return A SpannableString with highlighted mentions.
|
||||||
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun highlightMentions(text: CharSequence, isOutgoingMessage: Boolean, threadID: Long, context: Context): SpannableString {
|
fun highlightMentions(
|
||||||
|
text: CharSequence,
|
||||||
|
isOutgoingMessage: Boolean = false,
|
||||||
|
isQuote: Boolean = false,
|
||||||
|
formatOnly: Boolean = false,
|
||||||
|
threadID: Long,
|
||||||
|
context: Context
|
||||||
|
): SpannableString {
|
||||||
@Suppress("NAME_SHADOWING") var text = text
|
@Suppress("NAME_SHADOWING") var text = text
|
||||||
val pattern = Pattern.compile("@[0-9a-fA-F]*")
|
|
||||||
var matcher = pattern.matcher(text)
|
var matcher = pattern.matcher(text)
|
||||||
val mentions = mutableListOf<Tuple2<Range<Int>, String>>()
|
val mentions = mutableListOf<Tuple2<Range<Int>, String>>()
|
||||||
var startIndex = 0
|
var startIndex = 0
|
||||||
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
|
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
|
||||||
val openGroup = DatabaseComponent.get(context).storage().getOpenGroup(threadID)
|
val openGroup by lazy { DatabaseComponent.get(context).storage().getOpenGroup(threadID) }
|
||||||
|
|
||||||
|
// format the mention text
|
||||||
if (matcher.find(startIndex)) {
|
if (matcher.find(startIndex)) {
|
||||||
while (true) {
|
while (true) {
|
||||||
val publicKey = text.subSequence(matcher.start() + 1, matcher.end()).toString() // +1 to get rid of the @
|
val publicKey = text.subSequence(matcher.start() + 1, matcher.end()).toString() // +1 to get rid of the @
|
||||||
val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, publicKey, it.publicKey) } ?: false
|
val isYou = isYou(publicKey, userPublicKey, openGroup)
|
||||||
val userDisplayName: String? = if (publicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey) {
|
val userDisplayName: String? = if (isYou) {
|
||||||
context.getString(R.string.MessageRecord_you)
|
context.getString(R.string.MessageRecord_you)
|
||||||
} else {
|
} else {
|
||||||
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)
|
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)
|
||||||
@ -53,7 +70,8 @@ object MentionUtilities {
|
|||||||
contact?.displayName(context)
|
contact?.displayName(context)
|
||||||
}
|
}
|
||||||
if (userDisplayName != null) {
|
if (userDisplayName != null) {
|
||||||
text = text.subSequence(0, matcher.start()).toString() + "@" + userDisplayName + text.subSequence(matcher.end(), text.length)
|
val mention = "@$userDisplayName"
|
||||||
|
text = text.subSequence(0, matcher.start()).toString() + mention + text.subSequence(matcher.end(), text.length)
|
||||||
val endIndex = matcher.start() + 1 + userDisplayName.length
|
val endIndex = matcher.start() + 1 + userDisplayName.length
|
||||||
startIndex = endIndex
|
startIndex = endIndex
|
||||||
mentions.add(Tuple2(Range.create(matcher.start(), endIndex), publicKey))
|
mentions.add(Tuple2(Range.create(matcher.start(), endIndex), publicKey))
|
||||||
@ -66,37 +84,83 @@ object MentionUtilities {
|
|||||||
}
|
}
|
||||||
val result = SpannableString(text)
|
val result = SpannableString(text)
|
||||||
|
|
||||||
var mentionTextColour: Int? = null
|
// apply styling if required
|
||||||
// In dark themes..
|
// Normal text color: black in dark mode and primary text color for light mode
|
||||||
if (ThemeUtil.isDarkTheme(context)) {
|
val mainTextColor by lazy {
|
||||||
// ..we use the standard outgoing message colour for outgoing messages..
|
if (ThemeUtil.isDarkTheme(context)) context.getColor(R.color.black)
|
||||||
if (isOutgoingMessage) {
|
else context.getColorFromAttr(android.R.attr.textColorPrimary)
|
||||||
val mentionTextColourAttributeId = getMessageTextColourAttr(true)
|
|
||||||
val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId)
|
|
||||||
mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme)
|
|
||||||
}
|
|
||||||
else // ..but we use the accent colour for incoming messages (i.e., someone mentioning us)..
|
|
||||||
{
|
|
||||||
mentionTextColour = context.getAccentColor()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else // ..while in light themes we always just use the incoming or outgoing message text colour for mentions.
|
|
||||||
{
|
|
||||||
val mentionTextColourAttributeId = getMessageTextColourAttr(isOutgoingMessage)
|
|
||||||
val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId)
|
|
||||||
mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (mention in mentions) {
|
// Highlighted text color: primary/accent in dark mode and primary text color for light mode
|
||||||
result.setSpan(ForegroundColorSpan(mentionTextColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
val highlightedTextColor by lazy {
|
||||||
result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
if (ThemeUtil.isDarkTheme(context)) context.getAccentColor()
|
||||||
|
else context.getColorFromAttr(android.R.attr.textColorPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
// If we're using a light theme then we change the background colour of the mention to be the accent colour
|
if(!formatOnly) {
|
||||||
if (ThemeUtil.isLightTheme(context)) {
|
for (mention in mentions) {
|
||||||
val backgroundColour = context.getAccentColor();
|
val backgroundColor: Int?
|
||||||
result.setSpan(BackgroundColorSpan(backgroundColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
val foregroundColor: Int?
|
||||||
|
|
||||||
|
// quotes
|
||||||
|
if(isQuote) {
|
||||||
|
backgroundColor = null
|
||||||
|
// the text color has different rule depending if the message is incoming or outgoing
|
||||||
|
foregroundColor = if(isOutgoingMessage) null else highlightedTextColor
|
||||||
|
}
|
||||||
|
// incoming message mentioning you
|
||||||
|
else if (isYou(mention.second, userPublicKey, openGroup)) {
|
||||||
|
backgroundColor = context.getAccentColor()
|
||||||
|
foregroundColor = mainTextColor
|
||||||
|
}
|
||||||
|
// outgoing message
|
||||||
|
else if (isOutgoingMessage) {
|
||||||
|
backgroundColor = null
|
||||||
|
foregroundColor = mainTextColor
|
||||||
|
}
|
||||||
|
// incoming messages mentioning someone else
|
||||||
|
else {
|
||||||
|
backgroundColor = null
|
||||||
|
// accent color for dark themes and primary text for light
|
||||||
|
foregroundColor = highlightedTextColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply the background, if any
|
||||||
|
backgroundColor?.let { background ->
|
||||||
|
result.setSpan(
|
||||||
|
RoundedBackgroundSpan(
|
||||||
|
context = context,
|
||||||
|
textColor = mainTextColor,
|
||||||
|
backgroundColor = background
|
||||||
|
),
|
||||||
|
mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply the foreground, if any
|
||||||
|
foregroundColor?.let {
|
||||||
|
result.setSpan(
|
||||||
|
ForegroundColorSpan(it),
|
||||||
|
mention.first.lower,
|
||||||
|
mention.first.upper,
|
||||||
|
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply bold on the mention
|
||||||
|
result.setSpan(
|
||||||
|
StyleSpan(Typeface.BOLD),
|
||||||
|
mention.first.lower,
|
||||||
|
mention.first.upper,
|
||||||
|
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isYou(mentionedPublicKey: String, userPublicKey: String, openGroup: OpenGroup?): Boolean {
|
||||||
|
val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, mentionedPublicKey, it.publicKey) } ?: false
|
||||||
|
return mentionedPublicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey
|
||||||
|
}
|
||||||
}
|
}
|
@ -2,10 +2,13 @@ package org.thoughtcrime.securesms.conversation.v2.utilities
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Outline
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewOutlineProvider
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
@ -21,18 +24,17 @@ import org.session.libsignal.utilities.ListenableFuture
|
|||||||
import org.session.libsignal.utilities.SettableFuture
|
import org.session.libsignal.utilities.SettableFuture
|
||||||
import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget
|
import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget
|
||||||
import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget
|
import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget
|
||||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
|
||||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri
|
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequest
|
import org.thoughtcrime.securesms.mms.GlideRequest
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||||
import org.thoughtcrime.securesms.mms.Slide
|
import org.thoughtcrime.securesms.mms.Slide
|
||||||
import kotlin.Boolean
|
|
||||||
import kotlin.Int
|
|
||||||
import kotlin.getValue
|
|
||||||
import kotlin.lazy
|
|
||||||
import kotlin.let
|
|
||||||
|
|
||||||
open class ThumbnailView: FrameLayout {
|
open class ThumbnailView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = 0
|
||||||
|
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val WIDTH = 0
|
private const val WIDTH = 0
|
||||||
private const val HEIGHT = 1
|
private const val HEIGHT = 1
|
||||||
@ -41,30 +43,29 @@ open class ThumbnailView: FrameLayout {
|
|||||||
private val binding: ThumbnailViewBinding by lazy { ThumbnailViewBinding.bind(this) }
|
private val binding: ThumbnailViewBinding by lazy { ThumbnailViewBinding.bind(this) }
|
||||||
|
|
||||||
// region Lifecycle
|
// region Lifecycle
|
||||||
constructor(context: Context) : super(context) { initialize(null) }
|
|
||||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) }
|
|
||||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) }
|
|
||||||
|
|
||||||
val loadIndicator: View by lazy { binding.thumbnailLoadIndicator }
|
val loadIndicator: View by lazy { binding.thumbnailLoadIndicator }
|
||||||
|
|
||||||
private val dimensDelegate = ThumbnailDimensDelegate()
|
private val dimensDelegate = ThumbnailDimensDelegate()
|
||||||
|
|
||||||
private var slide: Slide? = null
|
private var slide: Slide? = null
|
||||||
var radius: Int = 0
|
|
||||||
|
|
||||||
private fun initialize(attrs: AttributeSet?) {
|
init {
|
||||||
if (attrs != null) {
|
attrs?.let { context.theme.obtainStyledAttributes(it, R.styleable.ThumbnailView, 0, 0) }
|
||||||
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0)
|
?.apply {
|
||||||
|
dimensDelegate.setBounds(
|
||||||
|
getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0),
|
||||||
|
getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0),
|
||||||
|
getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0),
|
||||||
|
getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0)
|
||||||
|
)
|
||||||
|
|
||||||
dimensDelegate.setBounds(typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0),
|
setRoundedCorners(
|
||||||
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0),
|
getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0)
|
||||||
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0),
|
)
|
||||||
typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0))
|
|
||||||
|
|
||||||
radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0)
|
recycle()
|
||||||
|
}
|
||||||
typedArray.recycle()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||||
@ -84,114 +85,118 @@ open class ThumbnailView: FrameLayout {
|
|||||||
|
|
||||||
private fun getDefaultWidth() = maxOf(layoutParams?.width ?: 0, 0)
|
private fun getDefaultWidth() = maxOf(layoutParams?.width ?: 0, 0)
|
||||||
private fun getDefaultHeight() = maxOf(layoutParams?.height ?: 0, 0)
|
private fun getDefaultHeight() = maxOf(layoutParams?.height ?: 0, 0)
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Interaction
|
// region Interaction
|
||||||
fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord?): ListenableFuture<Boolean> {
|
fun setRoundedCorners(radius: Int){
|
||||||
return setImageResource(glide, slide, isPreview, 0, 0, mms)
|
// create an outline provider and clip the whole view to that shape
|
||||||
}
|
// that way we can round the image and the background ( and any other artifacts that the view may contain )
|
||||||
|
val mOutlineProvider = object : ViewOutlineProvider() {
|
||||||
fun setImageResource(glide: GlideRequests, slide: Slide,
|
override fun getOutline(view: View, outline: Outline) {
|
||||||
isPreview: Boolean, naturalWidth: Int,
|
// all corners
|
||||||
naturalHeight: Int, mms: MmsMessageRecord?): ListenableFuture<Boolean> {
|
outline.setRoundRect(0, 0, view.width, view.height, radius.toFloat())
|
||||||
|
}
|
||||||
val currentSlide = this.slide
|
|
||||||
|
|
||||||
binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() &&
|
|
||||||
(slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview))
|
|
||||||
|
|
||||||
if (equals(currentSlide, slide)) {
|
|
||||||
// don't re-load slide
|
|
||||||
return SettableFuture(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
outlineProvider = mOutlineProvider
|
||||||
|
clipToOutline = true
|
||||||
|
}
|
||||||
|
|
||||||
if (currentSlide != null && currentSlide.fastPreflightId != null && currentSlide.fastPreflightId == slide.fastPreflightId) {
|
fun setImageResource(
|
||||||
// not reloading slide for fast preflight
|
glide: GlideRequests,
|
||||||
this.slide = slide
|
slide: Slide,
|
||||||
|
isPreview: Boolean
|
||||||
|
): ListenableFuture<Boolean> = setImageResource(glide, slide, isPreview, 0, 0)
|
||||||
|
|
||||||
|
fun setImageResource(
|
||||||
|
glide: GlideRequests, slide: Slide,
|
||||||
|
isPreview: Boolean, naturalWidth: Int,
|
||||||
|
naturalHeight: Int
|
||||||
|
): ListenableFuture<Boolean> {
|
||||||
|
binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() &&
|
||||||
|
(slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview))
|
||||||
|
|
||||||
|
if (equals(this.slide, slide)) {
|
||||||
|
// don't re-load slide
|
||||||
|
return SettableFuture(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.slide = slide
|
this.slide = slide
|
||||||
|
|
||||||
binding.thumbnailLoadIndicator.isVisible = slide.isInProgress
|
binding.thumbnailLoadIndicator.isVisible = slide.isInProgress
|
||||||
binding.thumbnailDownloadIcon.isVisible = slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED
|
binding.thumbnailDownloadIcon.isVisible =
|
||||||
|
slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED
|
||||||
|
|
||||||
dimensDelegate.setDimens(naturalWidth, naturalHeight)
|
dimensDelegate.setDimens(naturalWidth, naturalHeight)
|
||||||
invalidate()
|
invalidate()
|
||||||
|
|
||||||
val result = SettableFuture<Boolean>()
|
return SettableFuture<Boolean>().also {
|
||||||
|
when {
|
||||||
when {
|
slide.thumbnailUri != null -> {
|
||||||
slide.thumbnailUri != null -> {
|
buildThumbnailGlideRequest(glide, slide).into(
|
||||||
buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, result))
|
GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, it)
|
||||||
}
|
)
|
||||||
slide.hasPlaceholder() -> {
|
}
|
||||||
buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, null, result))
|
slide.hasPlaceholder() -> {
|
||||||
}
|
buildPlaceholderGlideRequest(glide, slide).into(
|
||||||
else -> {
|
GlideBitmapListeningTarget(binding.thumbnailImage, null, it)
|
||||||
glide.clear(binding.thumbnailImage)
|
)
|
||||||
result.set(false)
|
}
|
||||||
|
else -> {
|
||||||
|
glide.clear(binding.thumbnailImage)
|
||||||
|
it.set(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun buildThumbnailGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest<Drawable> {
|
private fun buildThumbnailGlideRequest(
|
||||||
|
glide: GlideRequests,
|
||||||
|
slide: Slide
|
||||||
|
): GlideRequest<Drawable> = glide.load(DecryptableUri(slide.thumbnailUri!!))
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
|
.overrideDimensions()
|
||||||
|
.transition(DrawableTransitionOptions.withCrossFade())
|
||||||
|
.transform(CenterCrop())
|
||||||
|
.missingThumbnailPicture(slide.isInProgress)
|
||||||
|
|
||||||
val dimens = dimensDelegate.resourceSize()
|
private fun buildPlaceholderGlideRequest(
|
||||||
|
glide: GlideRequests,
|
||||||
val request = glide.load(DecryptableUri(slide.thumbnailUri!!))
|
slide: Slide
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
): GlideRequest<Bitmap> = glide.asBitmap()
|
||||||
.let { request ->
|
.load(slide.getPlaceholderRes(context.theme))
|
||||||
if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) {
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
request.override(getDefaultWidth(), getDefaultHeight())
|
.overrideDimensions()
|
||||||
} else {
|
.fitCenter()
|
||||||
request.override(dimens[WIDTH], dimens[HEIGHT])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.transition(DrawableTransitionOptions.withCrossFade())
|
|
||||||
.centerCrop()
|
|
||||||
|
|
||||||
return if (slide.isInProgress) request else request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun buildPlaceholderGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest<Bitmap> {
|
|
||||||
|
|
||||||
val dimens = dimensDelegate.resourceSize()
|
|
||||||
|
|
||||||
return glide.asBitmap()
|
|
||||||
.load(slide.getPlaceholderRes(context.theme))
|
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
|
||||||
.let { request ->
|
|
||||||
if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) {
|
|
||||||
request.override(getDefaultWidth(), getDefaultHeight())
|
|
||||||
} else {
|
|
||||||
request.override(dimens[WIDTH], dimens[HEIGHT])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.fitCenter()
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun clear(glideRequests: GlideRequests) {
|
open fun clear(glideRequests: GlideRequests) {
|
||||||
glideRequests.clear(binding.thumbnailImage)
|
glideRequests.clear(binding.thumbnailImage)
|
||||||
slide = null
|
slide = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setImageResource(glideRequests: GlideRequests, uri: Uri): ListenableFuture<Boolean> {
|
fun setImageResource(
|
||||||
val future = SettableFuture<Boolean>()
|
glideRequests: GlideRequests,
|
||||||
|
uri: Uri
|
||||||
|
): ListenableFuture<Boolean> = glideRequests.load(DecryptableUri(uri))
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
|
.transition(DrawableTransitionOptions.withCrossFade())
|
||||||
|
.transform(CenterCrop())
|
||||||
|
.intoDrawableTargetAsFuture()
|
||||||
|
|
||||||
var request: GlideRequest<Drawable> = glideRequests.load(DecryptableUri(uri))
|
private fun GlideRequest<Drawable>.intoDrawableTargetAsFuture() =
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
SettableFuture<Boolean>().also {
|
||||||
.transition(DrawableTransitionOptions.withCrossFade())
|
binding.run {
|
||||||
|
GlideDrawableListeningTarget(thumbnailImage, thumbnailLoadIndicator, it)
|
||||||
request = if (radius > 0) {
|
}.let { into(it) }
|
||||||
request.transforms(CenterCrop(), RoundedCorners(radius))
|
|
||||||
} else {
|
|
||||||
request.transforms(CenterCrop())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request.into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, future))
|
private fun <T> GlideRequest<T>.overrideDimensions() =
|
||||||
|
dimensDelegate.resourceSize().takeIf { 0 !in it }
|
||||||
|
?.let { override(it[WIDTH], it[HEIGHT]) }
|
||||||
|
?: override(getDefaultWidth(), getDefaultHeight())
|
||||||
|
}
|
||||||
|
|
||||||
return future
|
private fun <T> GlideRequest<T>.missingThumbnailPicture(
|
||||||
}
|
inProgress: Boolean
|
||||||
}
|
) = takeIf { inProgress } ?: apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture))
|
||||||
|
@ -3,9 +3,12 @@ package org.thoughtcrime.securesms.database
|
|||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import org.json.JSONArray
|
||||||
import org.session.libsession.messaging.open_groups.GroupMember
|
import org.session.libsession.messaging.open_groups.GroupMember
|
||||||
import org.session.libsession.messaging.open_groups.GroupMemberRole
|
import org.session.libsession.messaging.open_groups.GroupMemberRole
|
||||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||||
|
import org.thoughtcrime.securesms.util.asSequence
|
||||||
|
import java.util.EnumSet
|
||||||
|
|
||||||
class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
|
class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
|
||||||
|
|
||||||
@ -51,6 +54,19 @@ class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
|||||||
return mappings.map { it.role }
|
return mappings.map { it.role }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getGroupMembersRoles(groupId: String, memberIDs: Collection<String>): Map<String, List<GroupMemberRole>> {
|
||||||
|
val sql = """
|
||||||
|
SELECT * FROM $TABLE_NAME
|
||||||
|
WHERE $GROUP_ID = ? AND $PROFILE_ID IN (SELECT value FROM json_each(?))
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
return readableDatabase.rawQuery(sql, groupId, JSONArray(memberIDs).toString()).use { cursor ->
|
||||||
|
cursor.asSequence()
|
||||||
|
.map { readGroupMember(it) }
|
||||||
|
.groupBy(keySelector = { it.profileId }, valueTransform = { it.role })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setGroupMembers(members: List<GroupMember>) {
|
fun setGroupMembers(members: List<GroupMember>) {
|
||||||
writableDatabase.beginTransaction()
|
writableDatabase.beginTransaction()
|
||||||
try {
|
try {
|
||||||
|
@ -218,6 +218,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
|||||||
return cursor
|
return cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getRecentChatMemberIDs(threadID: Long, limit: Int): List<String> {
|
||||||
|
val sql = """
|
||||||
|
SELECT DISTINCT $ADDRESS FROM $TABLE_NAME
|
||||||
|
WHERE $THREAD_ID = ?
|
||||||
|
ORDER BY $DATE_SENT DESC
|
||||||
|
LIMIT $limit
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
return databaseHelper.readableDatabase.rawQuery(sql, threadID).use { cursor ->
|
||||||
|
cursor.asSequence()
|
||||||
|
.map { it.getString(0) }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val expireStartedMessages: Reader
|
val expireStartedMessages: Reader
|
||||||
get() {
|
get() {
|
||||||
val where = "$EXPIRE_STARTED > 0"
|
val where = "$EXPIRE_STARTED > 0"
|
||||||
|
@ -4,6 +4,7 @@ import android.content.ContentValues
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
|
import org.json.JSONArray
|
||||||
import org.session.libsession.messaging.contacts.Contact
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
import org.session.libsession.messaging.utilities.AccountId
|
import org.session.libsession.messaging.utilities.AccountId
|
||||||
import org.session.libsignal.utilities.Base64
|
import org.session.libsignal.utilities.Base64
|
||||||
@ -41,6 +42,15 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getContacts(sessionIDs: Collection<String>): List<Contact> {
|
||||||
|
val database = databaseHelper.readableDatabase
|
||||||
|
return database.getAll(
|
||||||
|
sessionContactTable,
|
||||||
|
"$accountID IN (SELECT value FROM json_each(?))",
|
||||||
|
arrayOf(JSONArray(sessionIDs).toString())
|
||||||
|
) { cursor -> contactFromCursor(cursor) }
|
||||||
|
}
|
||||||
|
|
||||||
fun getAllContacts(): Set<Contact> {
|
fun getAllContacts(): Set<Contact> {
|
||||||
val database = databaseHelper.readableDatabase
|
val database = databaseHelper.readableDatabase
|
||||||
return database.getAll(sessionContactTable, null, null) { cursor ->
|
return database.getAll(sessionContactTable, null, null) { cursor ->
|
||||||
|
@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database
|
|||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import org.json.JSONArray
|
||||||
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
||||||
import org.session.libsession.messaging.jobs.AttachmentUploadJob
|
import org.session.libsession.messaging.jobs.AttachmentUploadJob
|
||||||
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
|
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
|
||||||
@ -50,14 +51,18 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
|
|||||||
databaseHelper.writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID ))
|
databaseHelper.writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID ))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAllJobs(type: String): Map<String, Job?> {
|
fun getAllJobs(vararg types: String): Map<String, Job?> {
|
||||||
val database = databaseHelper.readableDatabase
|
val database = databaseHelper.readableDatabase
|
||||||
return database.getAll(sessionJobTable, "$jobType = ?", arrayOf( type )) { cursor ->
|
return database.getAll(
|
||||||
|
sessionJobTable,
|
||||||
|
"$jobType IN (SELECT value FROM json_each(?))", // Use json_each to bypass limitation of SQLite's IN operator binding
|
||||||
|
arrayOf( JSONArray(types).toString() )
|
||||||
|
) { cursor ->
|
||||||
val jobID = cursor.getString(jobID)
|
val jobID = cursor.getString(jobID)
|
||||||
try {
|
try {
|
||||||
jobID to jobFromCursor(cursor)
|
jobID to jobFromCursor(cursor)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("Loki", "Error deserializing job of type: $type.", e)
|
Log.e("Loki", "Error deserializing job of type: $types.", e)
|
||||||
jobID to null
|
jobID to null
|
||||||
}
|
}
|
||||||
}.toMap()
|
}.toMap()
|
||||||
|
@ -397,8 +397,8 @@ open class Storage(
|
|||||||
DatabaseComponent.get(context).sessionJobDatabase().markJobAsFailedPermanently(jobId)
|
DatabaseComponent.get(context).sessionJobDatabase().markJobAsFailedPermanently(jobId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAllPendingJobs(type: String): Map<String, Job?> {
|
override fun getAllPendingJobs(vararg types: String): Map<String, Job?> {
|
||||||
return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(type)
|
return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(*types)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? {
|
override fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? {
|
||||||
|
@ -162,13 +162,7 @@ object OpenGroupManager {
|
|||||||
val memberDatabase = DatabaseComponent.get(context).groupMemberDatabase()
|
val memberDatabase = DatabaseComponent.get(context).groupMemberDatabase()
|
||||||
val standardRoles = memberDatabase.getGroupMemberRoles(groupId, standardPublicKey)
|
val standardRoles = memberDatabase.getGroupMemberRoles(groupId, standardPublicKey)
|
||||||
val blindedRoles = blindedPublicKey?.let { memberDatabase.getGroupMemberRoles(groupId, it) } ?: emptyList()
|
val blindedRoles = blindedPublicKey?.let { memberDatabase.getGroupMemberRoles(groupId, it) } ?: emptyList()
|
||||||
|
return standardRoles.any { it.isModerator } || blindedRoles.any { it.isModerator }
|
||||||
// roles to check against
|
|
||||||
val moderatorRoles = listOf(
|
|
||||||
GroupMemberRole.MODERATOR, GroupMemberRole.ADMIN,
|
|
||||||
GroupMemberRole.HIDDEN_MODERATOR, GroupMemberRole.HIDDEN_ADMIN
|
|
||||||
)
|
|
||||||
return standardRoles.any { it in moderatorRoles } || blindedRoles.any { it in moderatorRoles }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -103,7 +103,12 @@ class ConversationView : LinearLayout {
|
|||||||
R.drawable.ic_notifications_mentions
|
R.drawable.ic_notifications_mentions
|
||||||
}
|
}
|
||||||
binding.muteIndicatorImageView.setImageResource(drawableRes)
|
binding.muteIndicatorImageView.setImageResource(drawableRes)
|
||||||
binding.snippetTextView.text = highlightMentions(thread.getSnippet(), thread.threadId, context)
|
binding.snippetTextView.text = highlightMentions(
|
||||||
|
text = thread.getSnippet(),
|
||||||
|
formatOnly = true, // no styling here, only text formatting
|
||||||
|
threadID = thread.threadId,
|
||||||
|
context = context
|
||||||
|
)
|
||||||
binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
|
binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
|
||||||
binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE
|
binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE
|
||||||
if (isTyping) {
|
if (isTyping) {
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
package org.thoughtcrime.securesms.mediapreview;
|
package org.thoughtcrime.securesms.mediapreview;
|
||||||
|
|
||||||
|
import static org.thoughtcrime.securesms.util.GeneralUtilitiesKt.toPx;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
@ -151,6 +153,8 @@ public class MediaRailAdapter extends RecyclerView.Adapter<MediaRailAdapter.Medi
|
|||||||
{
|
{
|
||||||
image.setImageResource(glideRequests, media.getUri());
|
image.setImageResource(glideRequests, media.getUri());
|
||||||
image.setOnClickListener(v -> railItemListener.onRailItemClicked(distanceFromActive));
|
image.setOnClickListener(v -> railItemListener.onRailItemClicked(distanceFromActive));
|
||||||
|
// set the rounded corners
|
||||||
|
image.setRoundedCorners(toPx(5, image.getResources()));
|
||||||
|
|
||||||
outline.setVisibility(isActive ? View.VISIBLE : View.GONE);
|
outline.setVisibility(isActive ? View.VISIBLE : View.GONE);
|
||||||
|
|
||||||
|
@ -39,7 +39,13 @@ class MessageRequestView : LinearLayout {
|
|||||||
binding.displayNameTextView.text = senderDisplayName
|
binding.displayNameTextView.text = senderDisplayName
|
||||||
binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date)
|
binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date)
|
||||||
val rawSnippet = thread.getDisplayBody(context)
|
val rawSnippet = thread.getDisplayBody(context)
|
||||||
val snippet = highlightMentions(rawSnippet, thread.threadId, context)
|
val snippet = highlightMentions(
|
||||||
|
text = rawSnippet,
|
||||||
|
formatOnly = true, // no styling here, only text formatting
|
||||||
|
threadID = thread.threadId,
|
||||||
|
context = context
|
||||||
|
)
|
||||||
|
|
||||||
binding.snippetTextView.text = snippet
|
binding.snippetTextView.text = snippet
|
||||||
|
|
||||||
post {
|
post {
|
||||||
|
@ -56,7 +56,6 @@ import org.session.libsignal.utilities.Util;
|
|||||||
import org.thoughtcrime.securesms.ApplicationContext;
|
import org.thoughtcrime.securesms.ApplicationContext;
|
||||||
import org.thoughtcrime.securesms.contacts.ContactUtil;
|
import org.thoughtcrime.securesms.contacts.ContactUtil;
|
||||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2;
|
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2;
|
||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities;
|
|
||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities;
|
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities;
|
||||||
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
|
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
|
||||||
import org.thoughtcrime.securesms.database.LokiThreadDatabase;
|
import org.thoughtcrime.securesms.database.LokiThreadDatabase;
|
||||||
@ -348,7 +347,6 @@ public class DefaultMessageNotifier implements MessageNotifier {
|
|||||||
|
|
||||||
builder.setThread(notifications.get(0).getRecipient());
|
builder.setThread(notifications.get(0).getRecipient());
|
||||||
builder.setMessageCount(notificationState.getMessageCount());
|
builder.setMessageCount(notificationState.getMessageCount());
|
||||||
MentionManagerUtilities.INSTANCE.populateUserPublicKeyCacheIfNeeded(notifications.get(0).getThreadId(),context);
|
|
||||||
|
|
||||||
// TODO: Removing highlighting mentions in the notification because this context is the libsession one which
|
// TODO: Removing highlighting mentions in the notification because this context is the libsession one which
|
||||||
// TODO: doesn't have access to the `R.attr.message_sent_text_color` and `R.attr.message_received_text_color`
|
// TODO: doesn't have access to the `R.attr.message_sent_text_color` and `R.attr.message_received_text_color`
|
||||||
@ -444,13 +442,30 @@ public class DefaultMessageNotifier implements MessageNotifier {
|
|||||||
while(iterator.hasPrevious()) {
|
while(iterator.hasPrevious()) {
|
||||||
NotificationItem item = iterator.previous();
|
NotificationItem item = iterator.previous();
|
||||||
builder.addMessageBody(item.getIndividualRecipient(), item.getRecipient(),
|
builder.addMessageBody(item.getIndividualRecipient(), item.getRecipient(),
|
||||||
MentionUtilities.highlightMentions(item.getText(), item.getThreadId(), context));
|
MentionUtilities.highlightMentions(
|
||||||
|
item.getText() != null ? item.getText() : "",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true, // no styling here, only text formatting
|
||||||
|
item.getThreadId(),
|
||||||
|
context
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signal) {
|
if (signal) {
|
||||||
builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate());
|
builder.setAlarms(notificationState.getRingtone(context), notificationState.getVibrate());
|
||||||
|
CharSequence text = notifications.get(0).getText();
|
||||||
builder.setTicker(notifications.get(0).getIndividualRecipient(),
|
builder.setTicker(notifications.get(0).getIndividualRecipient(),
|
||||||
MentionUtilities.highlightMentions(notifications.get(0).getText(), notifications.get(0).getThreadId(), context));
|
MentionUtilities.highlightMentions(
|
||||||
|
text != null ? text : "",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true, // no styling here, only text formatting
|
||||||
|
notifications.get(0).getThreadId(),
|
||||||
|
context
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag);
|
builder.putStringExtra(LATEST_MESSAGE_ID_TAG, messageIdTag);
|
||||||
|
@ -4,12 +4,13 @@ import android.app.Dialog
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.core.view.isGone
|
import android.widget.Toast
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.DialogFragment
|
import androidx.fragment.app.DialogFragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Dispatchers.Main
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@ -24,17 +25,26 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
|||||||
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
||||||
|
|
||||||
class ClearAllDataDialog : DialogFragment() {
|
class ClearAllDataDialog : DialogFragment() {
|
||||||
|
private val TAG = "ClearAllDataDialog"
|
||||||
|
|
||||||
private lateinit var binding: DialogClearAllDataBinding
|
private lateinit var binding: DialogClearAllDataBinding
|
||||||
|
|
||||||
enum class Steps {
|
private enum class Steps {
|
||||||
INFO_PROMPT,
|
INFO_PROMPT,
|
||||||
NETWORK_PROMPT,
|
NETWORK_PROMPT,
|
||||||
DELETING
|
DELETING,
|
||||||
|
RETRY_LOCAL_DELETE_ONLY_PROMPT
|
||||||
}
|
}
|
||||||
|
|
||||||
var clearJob: Job? = null
|
// Rather than passing a bool around we'll use an enum to clarify our intent
|
||||||
|
private enum class DeletionScope {
|
||||||
|
DeleteLocalDataOnly,
|
||||||
|
DeleteBothLocalAndNetworkData
|
||||||
|
}
|
||||||
|
|
||||||
var step = Steps.INFO_PROMPT
|
private var clearJob: Job? = null
|
||||||
|
|
||||||
|
private var step = Steps.INFO_PROMPT
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
updateUI()
|
updateUI()
|
||||||
@ -46,8 +56,8 @@ class ClearAllDataDialog : DialogFragment() {
|
|||||||
|
|
||||||
private fun createView(): View {
|
private fun createView(): View {
|
||||||
binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext()))
|
binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext()))
|
||||||
val device = radioOption("deviceOnly", R.string.dialog_clear_all_data_clear_device_only)
|
val device = radioOption("deviceOnly", R.string.clearDeviceOnly)
|
||||||
val network = radioOption("deviceAndNetwork", R.string.dialog_clear_all_data_clear_device_and_network)
|
val network = radioOption("deviceAndNetwork", R.string.clearDeviceAndNetwork)
|
||||||
var selectedOption: RadioOption<String> = device
|
var selectedOption: RadioOption<String> = device
|
||||||
val optionAdapter = RadioOptionAdapter { selectedOption = it }
|
val optionAdapter = RadioOptionAdapter { selectedOption = it }
|
||||||
binding.recyclerView.apply {
|
binding.recyclerView.apply {
|
||||||
@ -57,18 +67,21 @@ class ClearAllDataDialog : DialogFragment() {
|
|||||||
setHasFixedSize(true)
|
setHasFixedSize(true)
|
||||||
}
|
}
|
||||||
optionAdapter.submitList(listOf(device, network))
|
optionAdapter.submitList(listOf(device, network))
|
||||||
|
|
||||||
binding.cancelButton.setOnClickListener {
|
binding.cancelButton.setOnClickListener {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.clearAllDataButton.setOnClickListener {
|
binding.clearAllDataButton.setOnClickListener {
|
||||||
when(step) {
|
when (step) {
|
||||||
Steps.INFO_PROMPT -> if (selectedOption == network) {
|
Steps.INFO_PROMPT -> if (selectedOption == network) {
|
||||||
step = Steps.NETWORK_PROMPT
|
step = Steps.NETWORK_PROMPT
|
||||||
} else {
|
} else {
|
||||||
clearAllData(false)
|
clearAllData(DeletionScope.DeleteLocalDataOnly)
|
||||||
}
|
}
|
||||||
Steps.NETWORK_PROMPT -> clearAllData(true)
|
Steps.NETWORK_PROMPT -> clearAllData(DeletionScope.DeleteBothLocalAndNetworkData)
|
||||||
Steps.DELETING -> { /* do nothing intentionally */ }
|
Steps.DELETING -> { /* do nothing intentionally */ }
|
||||||
|
Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT -> clearAllData(DeletionScope.DeleteLocalDataOnly)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return binding.root
|
return binding.root
|
||||||
@ -86,8 +99,13 @@ class ClearAllDataDialog : DialogFragment() {
|
|||||||
binding.dialogDescriptionText.setText(R.string.dialog_clear_all_data_clear_device_and_network_confirmation)
|
binding.dialogDescriptionText.setText(R.string.dialog_clear_all_data_clear_device_and_network_confirmation)
|
||||||
}
|
}
|
||||||
Steps.DELETING -> { /* do nothing intentionally */ }
|
Steps.DELETING -> { /* do nothing intentionally */ }
|
||||||
|
Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT -> {
|
||||||
|
binding.dialogDescriptionText.setText(R.string.clearDataErrorDescriptionGeneric)
|
||||||
|
binding.clearAllDataButton.text = getString(R.string.clearDevice)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
binding.recyclerView.isGone = step == Steps.NETWORK_PROMPT
|
|
||||||
|
binding.recyclerView.isVisible = step == Steps.INFO_PROMPT
|
||||||
binding.cancelButton.isVisible = !isLoading
|
binding.cancelButton.isVisible = !isLoading
|
||||||
binding.clearAllDataButton.isVisible = !isLoading
|
binding.clearAllDataButton.isVisible = !isLoading
|
||||||
binding.progressBar.isVisible = isLoading
|
binding.progressBar.isVisible = isLoading
|
||||||
@ -97,45 +115,55 @@ class ClearAllDataDialog : DialogFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearAllData(deleteNetworkMessages: Boolean) {
|
private suspend fun performDeleteLocalDataOnlyStep() {
|
||||||
clearJob = lifecycleScope.launch(Dispatchers.IO) {
|
try {
|
||||||
val previousStep = step
|
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get()
|
||||||
withContext(Dispatchers.Main) {
|
} catch (e: Exception) {
|
||||||
step = Steps.DELETING
|
Log.e(TAG, "Failed to force sync when deleting data", e)
|
||||||
|
withContext(Main) {
|
||||||
|
Toast.makeText(ApplicationContext.getInstance(requireContext()), R.string.errorUnknown, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
|
return
|
||||||
if (!deleteNetworkMessages) {
|
}
|
||||||
try {
|
ApplicationContext.getInstance(context).clearAllData().let { success ->
|
||||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(requireContext()).get()
|
withContext(Main) {
|
||||||
} catch (e: Exception) {
|
if (success) {
|
||||||
Log.e("Loki", "Failed to force sync", e)
|
|
||||||
}
|
|
||||||
ApplicationContext.getInstance(context).clearAllData()
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
dismiss()
|
dismiss()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(ApplicationContext.getInstance(requireContext()), R.string.errorUnknown, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// finish
|
}
|
||||||
val result = try {
|
}
|
||||||
val openGroups = DatabaseComponent.get(requireContext()).lokiThreadDatabase().getAllOpenGroups()
|
|
||||||
openGroups.map { it.value.server }.toSet().forEach { server ->
|
|
||||||
OpenGroupApi.deleteAllInboxMessages(server).get()
|
|
||||||
}
|
|
||||||
SnodeAPI.deleteAllMessages().get()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result == null || result.values.any { !it } || result.isEmpty()) {
|
private fun clearAllData(deletionScope: DeletionScope) {
|
||||||
// didn't succeed (at least one)
|
step = Steps.DELETING
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
step = previousStep
|
clearJob = lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
when (deletionScope) {
|
||||||
|
DeletionScope.DeleteLocalDataOnly -> {
|
||||||
|
performDeleteLocalDataOnlyStep()
|
||||||
|
}
|
||||||
|
DeletionScope.DeleteBothLocalAndNetworkData -> {
|
||||||
|
val deletionResultMap: Map<String, Boolean>? = try {
|
||||||
|
val openGroups = DatabaseComponent.get(requireContext()).lokiThreadDatabase().getAllOpenGroups()
|
||||||
|
openGroups.map { it.value.server }.toSet().forEach { server ->
|
||||||
|
OpenGroupApi.deleteAllInboxMessages(server).get()
|
||||||
|
}
|
||||||
|
SnodeAPI.deleteAllMessages().get()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to delete network messages - offering user option to delete local data only.", e)
|
||||||
|
null
|
||||||
}
|
}
|
||||||
} else if (result.values.all { it }) {
|
|
||||||
// don't force sync because all the messages are deleted?
|
// If one or more deletions failed then inform the user and allow them to clear the device only if they wish..
|
||||||
ApplicationContext.getInstance(context).clearAllData()
|
if (deletionResultMap == null || deletionResultMap.values.any { !it } || deletionResultMap.isEmpty()) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Main) { step = Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT }
|
||||||
dismiss()
|
}
|
||||||
|
else if (deletionResultMap.values.all { it }) {
|
||||||
|
// ..otherwise if the network data deletion was successful proceed to delete the local data as well.
|
||||||
|
ApplicationContext.getInstance(context).clearAllData()
|
||||||
|
withContext(Main) { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,44 @@
|
|||||||
|
package org.thoughtcrime.securesms.util
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.asFlow
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.flatMapConcat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buffers items from the flow and emits them in batches. The batch will have size [maxItems] and
|
||||||
|
* time [timeoutMillis] limit.
|
||||||
|
*/
|
||||||
|
fun <T> Flow<T>.timedBuffer(timeoutMillis: Long, maxItems: Int): Flow<List<T>> {
|
||||||
|
return channelFlow {
|
||||||
|
val buffer = mutableListOf<T>()
|
||||||
|
var bufferBeganAt = -1L
|
||||||
|
|
||||||
|
collectLatest { value ->
|
||||||
|
if (buffer.isEmpty()) {
|
||||||
|
bufferBeganAt = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.add(value)
|
||||||
|
|
||||||
|
if (buffer.size < maxItems) {
|
||||||
|
// If the buffer is not full, wait until the time limit is reached.
|
||||||
|
// The delay here, as a suspension point, will be cancelled by `collectLatest`,
|
||||||
|
// if another item is collected while we are waiting for the `delay` to complete.
|
||||||
|
// Once the delay is cancelled, another round of `collectLatest` will be restarted.
|
||||||
|
delay((System.currentTimeMillis() + timeoutMillis - bufferBeganAt).coerceAtLeast(0L))
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we reach here, it's either the buffer is full, or the timeout has been reached:
|
||||||
|
// send out the buffer and reset the state
|
||||||
|
send(buffer.toList())
|
||||||
|
buffer.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun <T> Flow<Iterable<T>>.flatten(): Flow<T> = flatMapConcat { it.asFlow() }
|
@ -0,0 +1,61 @@
|
|||||||
|
package org.thoughtcrime.securesms.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.text.style.ReplacementSpan
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Span that draws text with a rounded background.
|
||||||
|
*
|
||||||
|
* @param textColor - The color of the text.
|
||||||
|
* @param backgroundColor - The color of the background.
|
||||||
|
* @param cornerRadius - The corner radius of the background in pixels. Defaults to 8dp.
|
||||||
|
* @param paddingHorizontal - The horizontal padding of the text in pixels. Defaults to 3dp.
|
||||||
|
* @param paddingVertical - The vertical padding of the text in pixels. Defaults to 3dp.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
class RoundedBackgroundSpan(
|
||||||
|
context: Context,
|
||||||
|
private val textColor: Int,
|
||||||
|
private val backgroundColor: Int,
|
||||||
|
private val cornerRadius: Float = toPx(8, context.resources).toFloat(), // setting some Session defaults
|
||||||
|
private val paddingHorizontal: Float = toPx(3, context.resources).toFloat(),
|
||||||
|
private val paddingVertical: Float = toPx(3, context.resources).toFloat()
|
||||||
|
) : ReplacementSpan() {
|
||||||
|
|
||||||
|
override fun draw(
|
||||||
|
canvas: Canvas, text: CharSequence, start: Int, end: Int,
|
||||||
|
x: Float, top: Int, y: Int, bottom: Int, paint: Paint
|
||||||
|
) {
|
||||||
|
// the top needs to take into account the font and the required vertical padding
|
||||||
|
val newTop = y + paint.fontMetrics.ascent - paddingVertical
|
||||||
|
val newBottom = y + paint.fontMetrics.descent + paddingVertical
|
||||||
|
val rect = RectF(
|
||||||
|
x,
|
||||||
|
newTop,
|
||||||
|
x + measureText(paint, text, start, end) + 2 * paddingHorizontal,
|
||||||
|
newBottom
|
||||||
|
)
|
||||||
|
paint.color = backgroundColor
|
||||||
|
|
||||||
|
canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint)
|
||||||
|
paint.color = textColor
|
||||||
|
canvas.drawText(text, start, end, x + paddingHorizontal, y.toFloat(), paint)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSize(
|
||||||
|
paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?
|
||||||
|
): Int {
|
||||||
|
return (paint.measureText(text, start, end) + 2 * paddingHorizontal).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun measureText(
|
||||||
|
paint: Paint, text: CharSequence, start: Int, end: Int
|
||||||
|
): Float {
|
||||||
|
return paint.measureText(text, start, end)
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<RelativeLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:focusable="false"
|
android:focusable="false"
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
@ -13,6 +13,9 @@
|
|||||||
android:id="@+id/toolbar"
|
android:id="@+id/toolbar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/conversationRecyclerView"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
android:background="?colorPrimary"
|
android:background="?colorPrimary"
|
||||||
app:contentInsetStart="0dp">
|
app:contentInsetStart="0dp">
|
||||||
|
|
||||||
@ -31,9 +34,11 @@
|
|||||||
android:focusable="false"
|
android:focusable="false"
|
||||||
android:id="@+id/conversationRecyclerView"
|
android:id="@+id/conversationRecyclerView"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="0dp"
|
||||||
android:layout_above="@+id/typingIndicatorViewContainer"
|
app:layout_constraintVertical_weight="1"
|
||||||
android:layout_below="@id/toolbar" />
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/typingIndicatorViewContainer"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/toolbar" />
|
||||||
|
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorViewContainer
|
<org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorViewContainer
|
||||||
@ -42,20 +47,27 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="36dp"
|
android:layout_height="36dp"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
android:layout_above="@+id/textSendAfterApproval"
|
tools:visibility="visible"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/conversationRecyclerView"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/textSendAfterApproval"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar
|
<org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar
|
||||||
android:id="@+id/inputBar"
|
android:id="@+id/inputBar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentBottom="true" />
|
tools:layout_height="60dp"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/messageRequestBar"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
/>
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar
|
<org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar
|
||||||
android:id="@+id/searchBottomBar"
|
android:id="@+id/searchBottomBar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentBottom="true"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
android:visibility="gone"/>
|
android:visibility="gone"/>
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
@ -75,11 +87,18 @@
|
|||||||
android:inflatedId="@+id/conversation_reaction_scrubber"
|
android:inflatedId="@+id/conversation_reaction_scrubber"
|
||||||
android:layout="@layout/conversation_reaction_scrubber"/>
|
android:layout="@layout/conversation_reaction_scrubber"/>
|
||||||
|
|
||||||
<FrameLayout
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/additionalContentContainer"
|
android:id="@+id/conversation_mention_candidates"
|
||||||
|
android:clipToOutline="true"
|
||||||
|
android:contentDescription="@string/AccessibilityId_mentions_list"
|
||||||
|
tools:listitem="@layout/view_mention_candidate_v2"
|
||||||
|
android:background="@drawable/mention_candidate_view_background"
|
||||||
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignBottom="@+id/conversationRecyclerView"/>
|
tools:visibility="gone"
|
||||||
|
app:layout_constraintHeight_max="176dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@+id/conversationRecyclerView" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/attachmentOptionsContainer"
|
android:id="@+id/attachmentOptionsContainer"
|
||||||
@ -87,19 +106,19 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="@dimen/small_spacing"
|
android:layout_marginStart="@dimen/small_spacing"
|
||||||
android:elevation="8dp"
|
android:elevation="8dp"
|
||||||
android:layout_alignParentStart="true"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
android:layout_alignParentBottom="true"
|
app:layout_constraintBottom_toTopOf="@+id/inputBar"
|
||||||
android:layout_marginBottom="60dp"
|
android:layout_marginBottom="16dp"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<RelativeLayout
|
<FrameLayout
|
||||||
android:id="@+id/gifButtonContainer"
|
android:id="@+id/gifButtonContainer"
|
||||||
android:layout_width="@dimen/input_bar_button_expanded_size"
|
android:layout_width="@dimen/input_bar_button_expanded_size"
|
||||||
android:layout_height="@dimen/input_bar_button_expanded_size"
|
android:layout_height="@dimen/input_bar_button_expanded_size"
|
||||||
android:contentDescription="@string/AccessibilityId_gif_button"
|
android:contentDescription="@string/AccessibilityId_gif_button"
|
||||||
android:alpha="0" />
|
android:alpha="0" />
|
||||||
|
|
||||||
<RelativeLayout
|
<FrameLayout
|
||||||
android:id="@+id/documentButtonContainer"
|
android:id="@+id/documentButtonContainer"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_width="@dimen/input_bar_button_expanded_size"
|
android:layout_width="@dimen/input_bar_button_expanded_size"
|
||||||
@ -107,7 +126,7 @@
|
|||||||
android:contentDescription="@string/AccessibilityId_documents_folder"
|
android:contentDescription="@string/AccessibilityId_documents_folder"
|
||||||
android:alpha="0" />
|
android:alpha="0" />
|
||||||
|
|
||||||
<RelativeLayout
|
<FrameLayout
|
||||||
android:id="@+id/libraryButtonContainer"
|
android:id="@+id/libraryButtonContainer"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_width="@dimen/input_bar_button_expanded_size"
|
android:layout_width="@dimen/input_bar_button_expanded_size"
|
||||||
@ -115,7 +134,7 @@
|
|||||||
android:contentDescription="@string/AccessibilityId_images_folder"
|
android:contentDescription="@string/AccessibilityId_images_folder"
|
||||||
android:alpha="0" />
|
android:alpha="0" />
|
||||||
|
|
||||||
<RelativeLayout
|
<FrameLayout
|
||||||
android:id="@+id/cameraButtonContainer"
|
android:id="@+id/cameraButtonContainer"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_width="@dimen/input_bar_button_expanded_size"
|
android:layout_width="@dimen/input_bar_button_expanded_size"
|
||||||
@ -129,22 +148,26 @@
|
|||||||
android:id="@+id/textSendAfterApproval"
|
android:id="@+id/textSendAfterApproval"
|
||||||
android:text="@string/ConversationActivity_send_after_approval"
|
android:text="@string/ConversationActivity_send_after_approval"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible"
|
||||||
android:textAlignment="center"
|
android:textAlignment="center"
|
||||||
android:textColor="@color/classic_light_2"
|
android:textColor="@color/classic_light_2"
|
||||||
android:padding="22dp"
|
android:padding="22dp"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignWithParentIfMissing="true"
|
tools:text="You'll be able to send"
|
||||||
android:layout_above="@id/messageRequestBar"/>
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/typingIndicatorViewContainer"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/messageRequestBar" />
|
||||||
|
|
||||||
<RelativeLayout
|
<RelativeLayout
|
||||||
android:id="@+id/scrollToBottomButton"
|
android:id="@+id/scrollToBottomButton"
|
||||||
|
tools:visibility="visible"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
android:layout_width="40dp"
|
android:layout_width="40dp"
|
||||||
android:layout_height="50dp"
|
android:layout_height="50dp"
|
||||||
android:layout_alignParentEnd="true"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
android:layout_above="@+id/messageRequestBar"
|
app:layout_constraintBottom_toTopOf="@+id/messageRequestBar"
|
||||||
android:layout_alignWithParentIfMissing="true"
|
android:layout_alignWithParentIfMissing="true"
|
||||||
android:layout_marginEnd="12dp"
|
android:layout_marginEnd="12dp"
|
||||||
android:layout_marginBottom="32dp">
|
android:layout_marginBottom="32dp">
|
||||||
@ -197,14 +220,14 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="-12dp"
|
android:layout_marginBottom="-12dp"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
android:layout_alignParentBottom="true" />
|
app:layout_constraintBottom_toBottomOf="parent" />
|
||||||
|
|
||||||
<RelativeLayout
|
<FrameLayout
|
||||||
android:id="@+id/blockedBanner"
|
android:id="@+id/blockedBanner"
|
||||||
android:contentDescription="@string/AccessibilityId_blocked_banner"
|
android:contentDescription="@string/AccessibilityId_blocked_banner"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_below="@+id/toolbar"
|
app:layout_constraintTop_toBottomOf="@+id/toolbar"
|
||||||
android:background="@color/destructive"
|
android:background="@color/destructive"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
tools:visibility="visible">
|
tools:visibility="visible">
|
||||||
@ -214,20 +237,20 @@
|
|||||||
android:contentDescription="@string/AccessibilityId_blocked_banner_text"
|
android:contentDescription="@string/AccessibilityId_blocked_banner_text"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_centerInParent="true"
|
android:layout_gravity="center"
|
||||||
android:layout_margin="@dimen/medium_spacing"
|
android:layout_margin="@dimen/medium_spacing"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
android:textSize="@dimen/small_font_size"
|
android:textSize="@dimen/small_font_size"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
tools:text="Elon is blocked. Unblock them?" />
|
tools:text="Elon is blocked. Unblock them?" />
|
||||||
|
|
||||||
</RelativeLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
<RelativeLayout
|
<FrameLayout
|
||||||
android:id="@+id/outdatedBanner"
|
android:id="@+id/outdatedBanner"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_below="@+id/blockedBanner"
|
app:layout_constraintTop_toBottomOf="@+id/blockedBanner"
|
||||||
android:background="@color/outdated_client_banner_background_color"
|
android:background="@color/outdated_client_banner_background_color"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
tools:visibility="visible">
|
tools:visibility="visible">
|
||||||
@ -237,14 +260,14 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center_horizontal"
|
android:gravity="center_horizontal"
|
||||||
android:layout_centerInParent="true"
|
android:layout_gravity="center"
|
||||||
android:layout_marginVertical="@dimen/very_small_spacing"
|
android:layout_marginVertical="@dimen/very_small_spacing"
|
||||||
android:layout_marginHorizontal="@dimen/medium_spacing"
|
android:layout_marginHorizontal="@dimen/medium_spacing"
|
||||||
android:textColor="@color/black"
|
android:textColor="@color/black"
|
||||||
android:textSize="@dimen/tiny_font_size"
|
android:textSize="@dimen/tiny_font_size"
|
||||||
tools:text="This user's client is outdated, things may not work as expected" />
|
tools:text="This user's client is outdated, things may not work as expected" />
|
||||||
|
|
||||||
</RelativeLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:padding="@dimen/medium_spacing"
|
android:padding="@dimen/medium_spacing"
|
||||||
@ -254,7 +277,7 @@
|
|||||||
android:id="@+id/placeholderText"
|
android:id="@+id/placeholderText"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_below="@+id/blockedBanner"
|
app:layout_constraintTop_toBottomOf="@+id/outdatedBanner"
|
||||||
android:elevation="8dp"
|
android:elevation="8dp"
|
||||||
android:contentDescription="@string/AccessibilityId_control_message"
|
android:contentDescription="@string/AccessibilityId_control_message"
|
||||||
tools:text="@string/activity_conversation_empty_state_default"
|
tools:text="@string/activity_conversation_empty_state_default"
|
||||||
@ -264,11 +287,12 @@
|
|||||||
android:id="@+id/messageRequestBar"
|
android:id="@+id/messageRequestBar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_above="@id/inputBar"
|
app:layout_constraintBottom_toTopOf="@+id/inputBar"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/textSendAfterApproval"
|
||||||
android:layout_marginBottom="@dimen/large_spacing"
|
android:layout_marginBottom="@dimen/large_spacing"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
tools:visibility="visible">
|
tools:visibility="gone">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/messageRequestBlock"
|
android:id="@+id/messageRequestBlock"
|
||||||
@ -322,4 +346,4 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</RelativeLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
@ -9,11 +9,6 @@
|
|||||||
<include layout="@layout/thumbnail_view"
|
<include layout="@layout/thumbnail_view"
|
||||||
android:id="@+id/album_cell_1"
|
android:id="@+id/album_cell_1"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"/>
|
||||||
app:minWidth="@dimen/media_bubble_min_width"
|
|
||||||
app:maxWidth="@dimen/media_bubble_max_width"
|
|
||||||
app:minHeight="@dimen/media_bubble_min_height"
|
|
||||||
app:maxHeight="@dimen/media_bubble_max_height"
|
|
||||||
app:thumbnail_radius="1dp"/>
|
|
||||||
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
@ -10,14 +10,12 @@
|
|||||||
<include layout="@layout/thumbnail_view"
|
<include layout="@layout/thumbnail_view"
|
||||||
android:id="@+id/album_cell_1"
|
android:id="@+id/album_cell_1"
|
||||||
android:layout_width="@dimen/album_2_cell_width"
|
android:layout_width="@dimen/album_2_cell_width"
|
||||||
android:layout_height="@dimen/album_2_total_height"
|
android:layout_height="@dimen/album_2_total_height"/>
|
||||||
app:thumbnail_radius="0dp"/>
|
|
||||||
|
|
||||||
<include layout="@layout/thumbnail_view"
|
<include layout="@layout/thumbnail_view"
|
||||||
android:id="@+id/album_cell_2"
|
android:id="@+id/album_cell_2"
|
||||||
android:layout_width="@dimen/album_2_cell_width"
|
android:layout_width="@dimen/album_2_cell_width"
|
||||||
android:layout_height="@dimen/album_2_total_height"
|
android:layout_height="@dimen/album_2_total_height"
|
||||||
android:layout_gravity="end"
|
android:layout_gravity="end"/>
|
||||||
app:thumbnail_radius="0dp"/>
|
|
||||||
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
@ -9,15 +9,13 @@
|
|||||||
<include layout="@layout/thumbnail_view"
|
<include layout="@layout/thumbnail_view"
|
||||||
android:id="@+id/album_cell_1"
|
android:id="@+id/album_cell_1"
|
||||||
android:layout_width="@dimen/album_3_cell_width_big"
|
android:layout_width="@dimen/album_3_cell_width_big"
|
||||||
android:layout_height="@dimen/album_3_total_height"
|
android:layout_height="@dimen/album_3_total_height"/>
|
||||||
app:thumbnail_radius="0dp"/>
|
|
||||||
|
|
||||||
<include layout="@layout/thumbnail_view"
|
<include layout="@layout/thumbnail_view"
|
||||||
android:id="@+id/album_cell_2"
|
android:id="@+id/album_cell_2"
|
||||||
android:layout_width="@dimen/album_3_cell_size_small"
|
android:layout_width="@dimen/album_3_cell_size_small"
|
||||||
android:layout_height="@dimen/album_3_cell_size_small"
|
android:layout_height="@dimen/album_3_cell_size_small"
|
||||||
android:layout_gravity="end|top"
|
android:layout_gravity="end|top"/>
|
||||||
app:thumbnail_radius="0dp"/>
|
|
||||||
|
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
@ -29,8 +27,7 @@
|
|||||||
android:id="@+id/album_cell_3"
|
android:id="@+id/album_cell_3"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_gravity="center_horizontal|bottom"
|
android:layout_gravity="center_horizontal|bottom"/>
|
||||||
app:thumbnail_radius="0dp"/>
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
tools:visibility="visible"
|
tools:visibility="visible"
|
||||||
|
@ -12,9 +12,7 @@
|
|||||||
android:id="@+id/rail_item_image"
|
android:id="@+id/rail_item_image"
|
||||||
android:layout_width="56dp"
|
android:layout_width="56dp"
|
||||||
android:layout_height="56dp"
|
android:layout_height="56dp"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"/>
|
||||||
android:background="@drawable/mediarail_media_outline"
|
|
||||||
app:thumbnail_radius="5dp"/>
|
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/rail_item_outline"
|
android:id="@+id/rail_item_outline"
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<RelativeLayout
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="44dp"
|
android:layout_height="44dp"
|
||||||
android:background="@drawable/mention_candidate_view_background">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@ -42,6 +41,7 @@
|
|||||||
android:textSize="@dimen/small_font_size"
|
android:textSize="@dimen/small_font_size"
|
||||||
android:textColor="?android:textColorPrimary"
|
android:textColor="?android:textColorPrimary"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
|
tools:text="Alice"
|
||||||
android:contentDescription="@string/AccessibilityId_contact_mentions"
|
android:contentDescription="@string/AccessibilityId_contact_mentions"
|
||||||
android:ellipsize="end" />
|
android:ellipsize="end" />
|
||||||
|
|
||||||
|
@ -646,8 +646,6 @@
|
|||||||
<string name="dialog_clear_all_data_explanation">این گزینه به طور دائم پیامها، جلسات و مخاطبین شما را حذف میکند.</string>
|
<string name="dialog_clear_all_data_explanation">این گزینه به طور دائم پیامها، جلسات و مخاطبین شما را حذف میکند.</string>
|
||||||
<string name="dialog_clear_all_data_network_explanation">آیا فقط میخواهید این دستگاه را پاک کنید یا میخواهید کل اکانت را پاک کنید؟</string>
|
<string name="dialog_clear_all_data_network_explanation">آیا فقط میخواهید این دستگاه را پاک کنید یا میخواهید کل اکانت را پاک کنید؟</string>
|
||||||
<string name="dialog_clear_all_data_message">این کار پیامها و مخاطبین شما را برای همیشه حذف میکند. آیا میخواهید فقط این دستگاه را پاک کنید یا داتا خود را از شبکه نیز حذف کنید?</string>
|
<string name="dialog_clear_all_data_message">این کار پیامها و مخاطبین شما را برای همیشه حذف میکند. آیا میخواهید فقط این دستگاه را پاک کنید یا داتا خود را از شبکه نیز حذف کنید?</string>
|
||||||
<string name="dialog_clear_all_data_clear_device_only">فقط پاک کردن دستگاه</string>
|
|
||||||
<string name="dialog_clear_all_data_clear_device_and_network">پاک کردن دستگاه و شبکه</string>
|
|
||||||
<string name="dialog_clear_all_data_clear_device_and_network_confirmation">آیا مطمئن هستید که می خواهید داتا های خود را از شبکه حذف کنید؟ اگر ادامه دهید، نمیتوانید پیامها یا مخاطبین خود را بازیابی کنید.</string>
|
<string name="dialog_clear_all_data_clear_device_and_network_confirmation">آیا مطمئن هستید که می خواهید داتا های خود را از شبکه حذف کنید؟ اگر ادامه دهید، نمیتوانید پیامها یا مخاطبین خود را بازیابی کنید.</string>
|
||||||
<string name="dialog_clear_all_data_clear">پاک</string>
|
<string name="dialog_clear_all_data_clear">پاک</string>
|
||||||
<string name="dialog_clear_all_data_local_only">فقط حذف شود</string>
|
<string name="dialog_clear_all_data_local_only">فقط حذف شود</string>
|
||||||
|
@ -649,8 +649,6 @@
|
|||||||
<string name="dialog_clear_all_data_explanation">Cela supprimera définitivement vos messages, vos sessions et vos contacts.</string>
|
<string name="dialog_clear_all_data_explanation">Cela supprimera définitivement vos messages, vos sessions et vos contacts.</string>
|
||||||
<string name="dialog_clear_all_data_network_explanation">Souhaitez-vous effacer seulement cet appareil ou supprimer l\'ensemble de votre compte ?</string>
|
<string name="dialog_clear_all_data_network_explanation">Souhaitez-vous effacer seulement cet appareil ou supprimer l\'ensemble de votre compte ?</string>
|
||||||
<string name="dialog_clear_all_data_message">Cela supprimera définitivement vos messages, sessions et contacts. Voulez-vous uniquement effacer cet appareil ou supprimer l\'intégralité de votre compte ?</string>
|
<string name="dialog_clear_all_data_message">Cela supprimera définitivement vos messages, sessions et contacts. Voulez-vous uniquement effacer cet appareil ou supprimer l\'intégralité de votre compte ?</string>
|
||||||
<string name="dialog_clear_all_data_clear_device_only">Effacer l\'appareil uniquement</string>
|
|
||||||
<string name="dialog_clear_all_data_clear_device_and_network">Effacer l\'appareil et le réseau</string>
|
|
||||||
<string name="dialog_clear_all_data_clear_device_and_network_confirmation">Êtes-vous sûr de vouloir supprimer vos données du réseau ? Si vous continuez, vous ne pourrez pas restaurer vos messages ou vos contacts.</string>
|
<string name="dialog_clear_all_data_clear_device_and_network_confirmation">Êtes-vous sûr de vouloir supprimer vos données du réseau ? Si vous continuez, vous ne pourrez pas restaurer vos messages ou vos contacts.</string>
|
||||||
<string name="dialog_clear_all_data_clear">Effacer</string>
|
<string name="dialog_clear_all_data_clear">Effacer</string>
|
||||||
<string name="dialog_clear_all_data_local_only">Effacer seulement</string>
|
<string name="dialog_clear_all_data_local_only">Effacer seulement</string>
|
||||||
|
@ -649,8 +649,6 @@
|
|||||||
<string name="dialog_clear_all_data_explanation">Cela supprimera définitivement vos messages, vos sessions et vos contacts.</string>
|
<string name="dialog_clear_all_data_explanation">Cela supprimera définitivement vos messages, vos sessions et vos contacts.</string>
|
||||||
<string name="dialog_clear_all_data_network_explanation">Souhaitez-vous effacer seulement cet appareil ou supprimer l\'ensemble de votre compte ?</string>
|
<string name="dialog_clear_all_data_network_explanation">Souhaitez-vous effacer seulement cet appareil ou supprimer l\'ensemble de votre compte ?</string>
|
||||||
<string name="dialog_clear_all_data_message">Cela supprimera définitivement vos messages, sessions et contacts. Voulez-vous uniquement effacer cet appareil ou supprimer l\'intégralité de votre compte ?</string>
|
<string name="dialog_clear_all_data_message">Cela supprimera définitivement vos messages, sessions et contacts. Voulez-vous uniquement effacer cet appareil ou supprimer l\'intégralité de votre compte ?</string>
|
||||||
<string name="dialog_clear_all_data_clear_device_only">Effacer l\'appareil uniquement</string>
|
|
||||||
<string name="dialog_clear_all_data_clear_device_and_network">Effacer l\'appareil et le réseau</string>
|
|
||||||
<string name="dialog_clear_all_data_clear_device_and_network_confirmation">Êtes-vous sûr de vouloir supprimer vos données du réseau ? Si vous continuez, vous ne pourrez pas restaurer vos messages ou vos contacts.</string>
|
<string name="dialog_clear_all_data_clear_device_and_network_confirmation">Êtes-vous sûr de vouloir supprimer vos données du réseau ? Si vous continuez, vous ne pourrez pas restaurer vos messages ou vos contacts.</string>
|
||||||
<string name="dialog_clear_all_data_clear">Effacer</string>
|
<string name="dialog_clear_all_data_clear">Effacer</string>
|
||||||
<string name="dialog_clear_all_data_local_only">Effacer seulement</string>
|
<string name="dialog_clear_all_data_local_only">Effacer seulement</string>
|
||||||
|
@ -822,8 +822,6 @@
|
|||||||
<string name="dialog_clear_all_data_explanation">This will permanently delete your messages, sessions, and contacts.</string>
|
<string name="dialog_clear_all_data_explanation">This will permanently delete your messages, sessions, and contacts.</string>
|
||||||
<string name="dialog_clear_all_data_network_explanation">Would you like to clear only this device, or delete your entire account?</string>
|
<string name="dialog_clear_all_data_network_explanation">Would you like to clear only this device, or delete your entire account?</string>
|
||||||
<string name="dialog_clear_all_data_message">This will permanently delete your messages, sessions, and contacts. Would you like to clear only this device, or delete your entire account?</string>
|
<string name="dialog_clear_all_data_message">This will permanently delete your messages, sessions, and contacts. Would you like to clear only this device, or delete your entire account?</string>
|
||||||
<string name="dialog_clear_all_data_clear_device_only">Clear Device Only</string>
|
|
||||||
<string name="dialog_clear_all_data_clear_device_and_network">Clear Device and Network</string>
|
|
||||||
<string name="dialog_clear_all_data_clear_device_and_network_confirmation">Are you sure you want to delete your data from the network? If you continue you will not be able to restore your messages or contacts.</string>
|
<string name="dialog_clear_all_data_clear_device_and_network_confirmation">Are you sure you want to delete your data from the network? If you continue you will not be able to restore your messages or contacts.</string>
|
||||||
<string name="dialog_clear_all_data_clear">Clear</string>
|
<string name="dialog_clear_all_data_clear">Clear</string>
|
||||||
<string name="dialog_clear_all_data_local_only">Delete Only</string>
|
<string name="dialog_clear_all_data_local_only">Delete Only</string>
|
||||||
|
@ -23,6 +23,7 @@ import org.session.libsession.utilities.recipients.Recipient
|
|||||||
import org.session.libsignal.utilities.Log
|
import org.session.libsignal.utilities.Log
|
||||||
import org.thoughtcrime.securesms.BaseViewModelTest
|
import org.thoughtcrime.securesms.BaseViewModelTest
|
||||||
import org.thoughtcrime.securesms.NoOpLogger
|
import org.thoughtcrime.securesms.NoOpLogger
|
||||||
|
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||||
import org.thoughtcrime.securesms.database.Storage
|
import org.thoughtcrime.securesms.database.Storage
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
import org.thoughtcrime.securesms.repository.ConversationRepository
|
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||||
@ -32,6 +33,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
|
|||||||
|
|
||||||
private val repository = mock<ConversationRepository>()
|
private val repository = mock<ConversationRepository>()
|
||||||
private val storage = mock<Storage>()
|
private val storage = mock<Storage>()
|
||||||
|
private val mmsDatabase = mock<MmsDatabase>()
|
||||||
|
|
||||||
private val threadId = 123L
|
private val threadId = 123L
|
||||||
private val edKeyPair = mock<KeyPair>()
|
private val edKeyPair = mock<KeyPair>()
|
||||||
@ -39,7 +41,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
|
|||||||
private lateinit var messageRecord: MessageRecord
|
private lateinit var messageRecord: MessageRecord
|
||||||
|
|
||||||
private val viewModel: ConversationViewModel by lazy {
|
private val viewModel: ConversationViewModel by lazy {
|
||||||
ConversationViewModel(threadId, edKeyPair, repository, storage)
|
ConversationViewModel(threadId, edKeyPair, repository, storage, mock(), mmsDatabase)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
|
@ -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,184 @@
|
|||||||
|
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.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.accountID 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,52 @@
|
|||||||
|
package org.thoughtcrime.securesms.util
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.toCollection
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FlowUtilsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timedBuffer should emit buffer when it's full`() = runTest {
|
||||||
|
// Given
|
||||||
|
val flow = flowOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
|
||||||
|
val timeoutMillis = 1000L
|
||||||
|
val maxItems = 5
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = flow.timedBuffer(timeoutMillis, maxItems).toList()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
assertEquals(listOf(1, 2, 3, 4, 5), result[0])
|
||||||
|
assertEquals(listOf(6, 7, 8, 9, 10), result[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Test
|
||||||
|
fun `timedBuffer should emit buffer when timeout expires`() = runTest {
|
||||||
|
// Given
|
||||||
|
val flow = flow {
|
||||||
|
emit(1)
|
||||||
|
emit(2)
|
||||||
|
emit(3)
|
||||||
|
testScheduler.advanceTimeBy(200L)
|
||||||
|
emit(4)
|
||||||
|
}
|
||||||
|
val timeoutMillis = 100L
|
||||||
|
val maxItems = 5
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = flow.timedBuffer(timeoutMillis, maxItems).toList()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
assertEquals(listOf(1, 2, 3), result[0])
|
||||||
|
assertEquals(listOf(4), result[1])
|
||||||
|
}
|
||||||
|
}
|
4
app/src/test/resources/TestAndroidManifest.xml
Normal file
4
app/src/test/resources/TestAndroidManifest.xml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<manifest>
|
||||||
|
|
||||||
|
</manifest>
|
3
app/src/test/resources/robolectric.properties
Normal file
3
app/src/test/resources/robolectric.properties
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
manifest=TestAndroidManifest.xml
|
||||||
|
sdk=34
|
||||||
|
application=android.app.Application
|
@ -53,7 +53,7 @@ interface StorageProtocol {
|
|||||||
fun persistJob(job: Job)
|
fun persistJob(job: Job)
|
||||||
fun markJobAsSucceeded(jobId: String)
|
fun markJobAsSucceeded(jobId: String)
|
||||||
fun markJobAsFailedPermanently(jobId: String)
|
fun markJobAsFailedPermanently(jobId: String)
|
||||||
fun getAllPendingJobs(type: String): Map<String,Job?>
|
fun getAllPendingJobs(vararg types: String): Map<String,Job?>
|
||||||
fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob?
|
fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob?
|
||||||
fun getMessageSendJob(messageSendJobID: String): MessageSendJob?
|
fun getMessageSendJob(messageSendJobID: String): MessageSendJob?
|
||||||
fun getMessageReceiveJob(messageReceiveJobID: String): Job?
|
fun getMessageReceiveJob(messageReceiveJobID: String): Job?
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
package org.session.libsession.messaging.jobs
|
package org.session.libsession.messaging.jobs
|
||||||
|
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
|
import org.session.libsession.database.MessageDataProvider
|
||||||
|
import org.session.libsession.database.StorageProtocol
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||||
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
|
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
|
||||||
@ -40,6 +42,36 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
|
|||||||
// Keys used for database storage
|
// Keys used for database storage
|
||||||
private val ATTACHMENT_ID_KEY = "attachment_id"
|
private val ATTACHMENT_ID_KEY = "attachment_id"
|
||||||
private val TS_INCOMING_MESSAGE_ID_KEY = "tsIncoming_message_id"
|
private val TS_INCOMING_MESSAGE_ID_KEY = "tsIncoming_message_id"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the attachment in the given message is eligible for download.
|
||||||
|
*
|
||||||
|
* Note that this function only checks for the eligibility of the attachment in the sense
|
||||||
|
* of whether the download is allowed, it does not check if the download has already taken
|
||||||
|
* place.
|
||||||
|
*/
|
||||||
|
fun eligibleForDownload(threadID: Long,
|
||||||
|
storage: StorageProtocol,
|
||||||
|
messageDataProvider: MessageDataProvider,
|
||||||
|
databaseMessageID: Long): Boolean {
|
||||||
|
val threadRecipient = storage.getRecipientForThread(threadID) ?: return false
|
||||||
|
|
||||||
|
// if we are the sender we are always eligible
|
||||||
|
val selfSend = messageDataProvider.isMmsOutgoing(databaseMessageID)
|
||||||
|
if (selfSend) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// you can't be eligible without a sender
|
||||||
|
val sender = messageDataProvider.getIndividualRecipientForMms(databaseMessageID)?.address?.serialize()
|
||||||
|
?: return false
|
||||||
|
|
||||||
|
// you can't be eligible without a contact entry
|
||||||
|
val contact = storage.getContactWithAccountID(sender) ?: return false
|
||||||
|
|
||||||
|
// we are eligible if we are receiving a group message or the contact is trusted
|
||||||
|
return threadRecipient.isGroupRecipient || contact.isTrusted
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun execute(dispatcherName: String) {
|
override suspend fun execute(dispatcherName: String) {
|
||||||
@ -88,21 +120,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val threadRecipient = storage.getRecipientForThread(threadID)
|
if (!eligibleForDownload(threadID, storage, messageDataProvider, databaseMessageID)) {
|
||||||
val selfSend = messageDataProvider.isMmsOutgoing(databaseMessageID)
|
|
||||||
val sender = if (selfSend) {
|
|
||||||
storage.getUserPublicKey()
|
|
||||||
} else {
|
|
||||||
messageDataProvider.getIndividualRecipientForMms(databaseMessageID)?.address?.serialize()
|
|
||||||
}
|
|
||||||
val contact = sender?.let { storage.getContactWithAccountID(it) }
|
|
||||||
if (threadRecipient == null || sender == null || (contact == null && !selfSend)) {
|
|
||||||
handleFailure(Error.NoSender, null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!threadRecipient.isGroupRecipient && contact?.isTrusted != true && storage.getUserPublicKey() != sender) {
|
|
||||||
// if we aren't receiving a group message, a message from ourselves (self-send) and the contact sending is not trusted:
|
|
||||||
// do not continue, but do not fail
|
|
||||||
handleFailure(Error.NoSender, null)
|
handleFailure(Error.NoSender, null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -61,7 +61,7 @@ data class ConfigurationSyncJob(val destination: Destination): Job {
|
|||||||
SharedConfigurationMessage(config.protoKindFor(), data, seqNo) to config
|
SharedConfigurationMessage(config.protoKindFor(), data, seqNo) to config
|
||||||
}.map { (message, config) ->
|
}.map { (message, config) ->
|
||||||
// return a list of batch request objects
|
// return a list of batch request objects
|
||||||
val snodeMessage = MessageSender.buildWrappedMessageToSnode(destination, message, true)
|
val snodeMessage = MessageSender.buildConfigMessageToSnode(destination.destinationPublicKey(), message)
|
||||||
val authenticated = SnodeAPI.buildAuthenticatedStoreBatchInfo(
|
val authenticated = SnodeAPI.buildAuthenticatedStoreBatchInfo(
|
||||||
destination.destinationPublicKey(),
|
destination.destinationPublicKey(),
|
||||||
config.configNamespace(),
|
config.configNamespace(),
|
||||||
|
@ -102,7 +102,7 @@ class JobQueue : JobDelegate {
|
|||||||
execute(dispatcherName)
|
execute(dispatcherName)
|
||||||
}
|
}
|
||||||
catch (e: Exception) {
|
catch (e: Exception) {
|
||||||
Log.d(dispatcherName, "unhandledJobException: ${javaClass.simpleName} (id: $id)")
|
Log.d(dispatcherName, "unhandledJobException: ${javaClass.simpleName} (id: $id)", e)
|
||||||
this@JobQueue.handleJobFailed(this, dispatcherName, e)
|
this@JobQueue.handleJobFailed(this, dispatcherName, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,11 @@ data class GroupMember(
|
|||||||
val role: GroupMemberRole
|
val role: GroupMemberRole
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class GroupMemberRole {
|
enum class GroupMemberRole(val isModerator: Boolean = false) {
|
||||||
STANDARD, ZOOMBIE, MODERATOR, ADMIN, HIDDEN_MODERATOR, HIDDEN_ADMIN
|
STANDARD,
|
||||||
|
ZOOMBIE,
|
||||||
|
MODERATOR(true),
|
||||||
|
ADMIN(true),
|
||||||
|
HIDDEN_MODERATOR(true),
|
||||||
|
HIDDEN_ADMIN(true),
|
||||||
}
|
}
|
||||||
|
@ -81,6 +81,15 @@ object MessageSender {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun buildConfigMessageToSnode(destinationPubKey: String, message: SharedConfigurationMessage): SnodeMessage {
|
||||||
|
return SnodeMessage(
|
||||||
|
destinationPubKey,
|
||||||
|
Base64.encodeBytes(message.data),
|
||||||
|
ttl = message.ttl,
|
||||||
|
SnodeAPI.nowWithOffset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// One-on-One Chats & Closed Groups
|
// One-on-One Chats & Closed Groups
|
||||||
@Throws(Exception::class)
|
@Throws(Exception::class)
|
||||||
fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage {
|
fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage {
|
||||||
|
@ -25,6 +25,7 @@ import org.session.libsession.snode.RawResponse
|
|||||||
import org.session.libsession.snode.SnodeAPI
|
import org.session.libsession.snode.SnodeAPI
|
||||||
import org.session.libsession.snode.SnodeModule
|
import org.session.libsession.snode.SnodeModule
|
||||||
import org.session.libsession.utilities.ConfigFactoryProtocol
|
import org.session.libsession.utilities.ConfigFactoryProtocol
|
||||||
|
import org.session.libsignal.utilities.Base64
|
||||||
import org.session.libsignal.utilities.Log
|
import org.session.libsignal.utilities.Log
|
||||||
import org.session.libsignal.utilities.Namespace
|
import org.session.libsignal.utilities.Namespace
|
||||||
import org.session.libsignal.utilities.Snode
|
import org.session.libsignal.utilities.Snode
|
||||||
@ -126,37 +127,26 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti
|
|||||||
private fun processConfig(snode: Snode, rawMessages: RawResponse, namespace: Int, forConfigObject: ConfigBase?) {
|
private fun processConfig(snode: Snode, rawMessages: RawResponse, namespace: Int, forConfigObject: ConfigBase?) {
|
||||||
if (forConfigObject == null) return
|
if (forConfigObject == null) return
|
||||||
|
|
||||||
val messages = SnodeAPI.parseRawMessagesResponse(
|
val messages = rawMessages["messages"] as? List<*>
|
||||||
rawMessages,
|
val processed = if (!messages.isNullOrEmpty()) {
|
||||||
snode,
|
SnodeAPI.updateLastMessageHashValueIfPossible(snode, userPublicKey, messages, namespace)
|
||||||
userPublicKey,
|
SnodeAPI.removeDuplicates(userPublicKey, messages, namespace, true).mapNotNull { messageBody ->
|
||||||
namespace,
|
val rawMessageAsJSON = messageBody as? Map<*, *> ?: return@mapNotNull null
|
||||||
updateLatestHash = true,
|
val hashValue = rawMessageAsJSON["hash"] as? String ?: return@mapNotNull null
|
||||||
updateStoredHashes = true,
|
val b64EncodedBody = rawMessageAsJSON["data"] as? String ?: return@mapNotNull null
|
||||||
)
|
val timestamp = rawMessageAsJSON["t"] as? Long ?: SnodeAPI.nowWithOffset
|
||||||
|
val body = Base64.decode(b64EncodedBody)
|
||||||
|
Triple(body, hashValue, timestamp)
|
||||||
|
}
|
||||||
|
} else emptyList()
|
||||||
|
|
||||||
if (messages.isEmpty()) {
|
if (processed.isEmpty()) return
|
||||||
// no new messages to process
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var latestMessageTimestamp: Long? = null
|
var latestMessageTimestamp: Long? = null
|
||||||
messages.forEach { (envelope, hash) ->
|
processed.forEach { (body, hash, timestamp) ->
|
||||||
try {
|
try {
|
||||||
val (message, _) = MessageReceiver.parse(data = envelope.toByteArray(),
|
forConfigObject.merge(hash to body)
|
||||||
// assume no groups in personal poller messages
|
latestMessageTimestamp = if (timestamp > (latestMessageTimestamp ?: 0L)) { timestamp } else { latestMessageTimestamp }
|
||||||
openGroupServerID = null, currentClosedGroups = emptySet()
|
|
||||||
)
|
|
||||||
// sanity checks
|
|
||||||
if (message !is SharedConfigurationMessage) {
|
|
||||||
Log.w("Loki", "shared config message handled in configs wasn't SharedConfigurationMessage but was ${message.javaClass.simpleName}")
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
val merged = forConfigObject.merge(hash!! to message.data).firstOrNull { it == hash }
|
|
||||||
if (merged != null) {
|
|
||||||
// We successfully merged the hash, we can now update the timestamp
|
|
||||||
latestMessageTimestamp = if ((message.sentTimestamp ?: 0L) > (latestMessageTimestamp ?: 0L)) { message.sentTimestamp } else { latestMessageTimestamp }
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("Loki", e)
|
Log.e("Loki", e)
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,6 @@ import org.session.libsignal.utilities.Snode
|
|||||||
import org.session.libsignal.utilities.ThreadUtils
|
import org.session.libsignal.utilities.ThreadUtils
|
||||||
import org.session.libsignal.utilities.recover
|
import org.session.libsignal.utilities.recover
|
||||||
import org.session.libsignal.utilities.toHexString
|
import org.session.libsignal.utilities.toHexString
|
||||||
import java.util.Date
|
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
import kotlin.collections.set
|
import kotlin.collections.set
|
||||||
|
|
||||||
|
@ -829,7 +829,7 @@ object SnodeAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>, namespace: Int) {
|
fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>, namespace: Int) {
|
||||||
val lastMessageAsJSON = rawMessages.lastOrNull() as? Map<*, *>
|
val lastMessageAsJSON = rawMessages.lastOrNull() as? Map<*, *>
|
||||||
val hashValue = lastMessageAsJSON?.get("hash") as? String
|
val hashValue = lastMessageAsJSON?.get("hash") as? String
|
||||||
if (hashValue != null) {
|
if (hashValue != null) {
|
||||||
@ -839,7 +839,7 @@ object SnodeAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int, updateStoredHashes: Boolean): List<*> {
|
fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int, updateStoredHashes: Boolean): List<*> {
|
||||||
val originalMessageHashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf()
|
val originalMessageHashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf()
|
||||||
val receivedMessageHashValues = originalMessageHashValues.toMutableSet()
|
val receivedMessageHashValues = originalMessageHashValues.toMutableSet()
|
||||||
val result = rawMessages.filter { rawMessage ->
|
val result = rawMessages.filter { rawMessage ->
|
||||||
|
@ -73,4 +73,10 @@
|
|||||||
<string name="ConversationItem_group_action_left">%1$s has left the group.</string>
|
<string name="ConversationItem_group_action_left">%1$s has left the group.</string>
|
||||||
<!-- RecipientProvider -->
|
<!-- RecipientProvider -->
|
||||||
<string name="RecipientProvider_unnamed_group">Unnamed group</string>
|
<string name="RecipientProvider_unnamed_group">Unnamed group</string>
|
||||||
|
|
||||||
|
<string name="clearDataErrorDescriptionGeneric">An unknown error occurred and your data was not deleted. Do you want to delete your data from just this device instead?</string>
|
||||||
|
<string name="errorUnknown">An unknown error occurred.</string>
|
||||||
|
<string name="clearDevice">Clear Device</string>
|
||||||
|
<string name="clearDeviceOnly">Clear device only</string>
|
||||||
|
<string name="clearDeviceAndNetwork">Clear device and network</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
@ -4,7 +4,6 @@ import android.os.Process
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import java.util.concurrent.ExecutorService
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.LinkedBlockingQueue
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
import java.util.concurrent.SynchronousQueue
|
|
||||||
import java.util.concurrent.ThreadPoolExecutor
|
import java.util.concurrent.ThreadPoolExecutor
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.coroutines.EmptyCoroutineContext
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
|
Loading…
Reference in New Issue
Block a user