diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java index e3317ff1cd..027a319651 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java @@ -79,8 +79,7 @@ public class TypingStatusSender { ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); Recipient recipient = threadDatabase.getRecipientForThreadId(threadId); if (recipient == null) { return; } - // Loki - Check whether we want to send a typing indicator to this user - if (recipient != null && !SessionMetaProtocol.shouldSendTypingIndicator(recipient.getAddress())) { return; } + if (!SessionMetaProtocol.shouldSendTypingIndicator(recipient.getAddress())) { return; } TypingIndicator typingIndicator; if (typingStarted) { typingIndicator = new TypingIndicator(TypingIndicator.Kind.STARTED); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index c1f46a6bcf..d6db6ec16d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -102,7 +102,6 @@ import org.session.libsession.utilities.recipients.RecipientModifiedListener; import org.session.libsession.utilities.ExpirationUtil; import org.session.libsession.utilities.GroupUtil; import org.session.libsession.utilities.MediaTypes; -import org.session.libsession.utilities.SSKEnvironment; import org.session.libsession.utilities.ServiceUtil; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.Util; @@ -165,8 +164,8 @@ import org.thoughtcrime.securesms.loki.views.MentionCandidateSelectionView; import org.thoughtcrime.securesms.loki.views.ProfilePictureView; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mediasend.MediaSendActivity; -import org.thoughtcrime.securesms.mms.AttachmentManager; -import org.thoughtcrime.securesms.mms.AttachmentManager.MediaType; +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager; +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager.MediaType; import org.thoughtcrime.securesms.mms.AudioSlide; import org.thoughtcrime.securesms.mms.GifSlide; import org.thoughtcrime.securesms.mms.GlideApp; @@ -422,9 +421,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity return; } - if (!org.thoughtcrime.securesms.util.Util.isEmpty(composeText) || attachmentManager.isAttachmentPresent()) { + if (!org.thoughtcrime.securesms.util.Util.isEmpty(composeText)) { saveDraft(); - attachmentManager.clear(glideRequests, false); + attachmentManager.clear(); silentlySetComposeText(""); } @@ -1424,9 +1423,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity case AttachmentTypeSelector.ADD_SOUND: AttachmentManager.selectAudio(this, PICK_AUDIO); break; case AttachmentTypeSelector.ADD_CONTACT_INFO: - AttachmentManager.selectContactInfo(this, PICK_CONTACT); break; + break; case AttachmentTypeSelector.ADD_LOCATION: - AttachmentManager.selectLocation(this, PICK_LOCATION); break; + break; case AttachmentTypeSelector.TAKE_PHOTO: attachmentManager.capturePhoto(this, TAKE_PHOTO); break; case AttachmentTypeSelector.ADD_GIF: @@ -1620,7 +1619,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private String getMessage() throws InvalidMessageException { String result = composeText.getTextTrimmed(); - if (result.length() < 1 && !attachmentManager.isAttachmentPresent()) throw new InvalidMessageException(); + if (result.length() < 1) throw new InvalidMessageException(); for (Mention mention : mentions) { try { int startIndex = result.indexOf("@" + mention.getDisplayName()); @@ -1723,7 +1722,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity String message = getMessage(); boolean initiating = threadId == -1; boolean needsSplit = message.length() > characterCalculator.calculateCharacters(message).maxPrimaryMessageSize; - boolean isMediaMessage = attachmentManager.isAttachmentPresent() || + boolean isMediaMessage = false || // recipient.isGroupRecipient() || inputPanel.getQuote().isPresent() || linkPreviewViewModel.hasLinkPreview() || @@ -1785,7 +1784,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity ApplicationContext.getInstance(context).getTypingStatusSender().onTypingStopped(threadId); inputPanel.clearQuote(); - attachmentManager.clear(glideRequests, false); + attachmentManager.clear(); silentlySetComposeText(""); final long id = fragment.stageOutgoingMessage(outgoingMessage); @@ -1859,7 +1858,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity return; } - if (composeText.getText().length() == 0 && !attachmentManager.isAttachmentPresent()) { + if (composeText.getText().length() == 0) { buttonToggle.display(attachButton); quickAttachmentToggle.show(); inlineAttachmentToggle.hide(); @@ -1867,7 +1866,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity buttonToggle.display(sendButton); quickAttachmentToggle.hide(); - if (!attachmentManager.isAttachmentPresent() && !linkPreviewViewModel.hasLinkPreview()) { + if (!linkPreviewViewModel.hasLinkPreview()) { inlineAttachmentToggle.show(); } else { inlineAttachmentToggle.hide(); @@ -1876,7 +1875,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } private void updateLinkPreviewState() { - if (TextSecurePreferences.isLinkPreviewsEnabled(this) && !attachmentManager.isAttachmentPresent()) { + if (TextSecurePreferences.isLinkPreviewsEnabled(this)) { linkPreviewViewModel.onEnabled(); linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed(), composeText.getSelectionStart(), composeText.getSelectionEnd()); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 336645dfbb..0d70f24e0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -5,19 +5,26 @@ import android.animation.ValueAnimator import android.app.Activity import android.content.Context import android.content.Intent +import android.content.Intent import android.content.res.Resources import android.database.Cursor import android.graphics.Rect import android.graphics.Typeface import android.os.Bundle +import android.net.Uri +import android.os.* +import android.util.Log +import android.util.Pair import android.util.TypedValue import android.view.* import android.widget.RelativeLayout +import android.widget.Toast import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProviders import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import kotlinx.android.synthetic.main.activity_conversation_v2.* import kotlinx.android.synthetic.main.activity_conversation_v2.view.* import kotlinx.android.synthetic.main.activity_conversation_v2_action_bar.* @@ -29,11 +36,22 @@ import kotlinx.android.synthetic.main.view_input_bar_recording.view.* import network.loki.messenger.R import nl.komponents.kovenant.ui.successUi import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.mentions.Mention import org.session.libsession.messaging.mentions.MentionsManager +import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage +import org.session.libsession.messaging.messages.signal.OutgoingTextMessage +import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.sending_receiving.attachments.Attachment +import org.session.libsession.utilities.MediaTypes +import org.session.libsession.utilities.ServiceUtil import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.ListenableFuture import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.audio.AudioRecorder +import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher import org.thoughtcrime.securesms.conversation.v2.dialogs.* import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate @@ -42,10 +60,12 @@ import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCand import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.DraftDatabase import org.thoughtcrime.securesms.database.DraftDatabase.Drafts import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.giph.ui.GiphyActivity import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel.LinkPreviewState @@ -53,9 +73,15 @@ import org.thoughtcrime.securesms.loki.utilities.ActivityDispatcher import org.thoughtcrime.securesms.loki.utilities.push import org.thoughtcrime.securesms.loki.utilities.show import org.thoughtcrime.securesms.loki.utilities.toPx -import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.MediaSendActivity +import org.thoughtcrime.securesms.mms.* +import org.thoughtcrime.securesms.notifications.MarkReadReceiver +import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.MediaUtil import java.util.* +import java.util.concurrent.ExecutionException import kotlin.math.* // Some things that seemingly belong to the input bar (e.g. the voice message recording UI) are actually @@ -63,16 +89,28 @@ import kotlin.math.* // price we pay is a bit of back and forth between the input bar and the conversation activity. class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate, - InputBarRecordingViewDelegate, ConversationRecyclerViewDelegate, ActivityDispatcher { - private val scrollButtonFullVisibilityThreshold by lazy { toPx(120.0f, resources) } - private val scrollButtonNoVisibilityThreshold by lazy { toPx(20.0f, resources) } + InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher { private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private var linkPreviewViewModel: LinkPreviewViewModel? = null private var threadID: Long = -1 private var actionMode: ActionMode? = null + private var unreadCount = 0 + // Attachments + private val audioRecorder = AudioRecorder(this) + private val stopAudioHandler = Handler(Looper.getMainLooper()) + private val stopVoiceMessageRecordingTask = Runnable { sendVoiceMessage() } + private val attachmentManager by lazy { AttachmentManager(this, this) } private var isLockViewExpanded = false private var isShowingAttachmentOptions = false + // Mentions + private val mentions = mutableListOf() private var mentionCandidatesView: MentionCandidatesView? = null + private var previousText: CharSequence = "" + private var currentMentionStartIndex = -1 + private var isShowingMentionCandidatesView = false + + private val layoutManager: LinearLayoutManager + get() { return conversationRecyclerView.layoutManager as LinearLayoutManager } // TODO: Selected message background color // TODO: Overflow menu background + text color @@ -110,6 +148,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // region Settings companion object { const val THREAD_ID = "thread_id" + const val PICK_DOCUMENT = 2 + const val TAKE_PHOTO = 7 + const val PICK_GIF = 10 + const val PICK_FROM_LIBRARY = 12 } // endregion @@ -124,13 +166,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe restoreDraftIfNeeded() addOpenGroupGuidelinesIfNeeded() scrollToBottomButton.setOnClickListener { conversationRecyclerView.smoothScrollToPosition(0) } - updateUnreadCount() + unreadCount = DatabaseFactory.getMmsSmsDatabase(this).getUnreadCount(threadID) + updateUnreadCountIndicator() setUpTypingObserver() updateSubtitle() getLatestOpenGroupInfoIfNeeded() setUpBlockedBanner() setUpLinkPreviewObserver() - scrollToFirstUnreadMessage() + scrollToFirstUnreadMessageIfNeeded() markAllAsRead() } @@ -160,7 +203,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe conversationRecyclerView.adapter = adapter val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, true) conversationRecyclerView.layoutManager = layoutManager - conversationRecyclerView.delegate = this // Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will) LoaderManager.getInstance(this).restartLoader(0, null, object : LoaderManager.LoaderCallbacks { @@ -176,6 +218,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe adapter.changeCursor(null) } }) + conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + handleRecyclerViewScrolled() + } + }) } private fun setUpToolBar() { @@ -193,15 +241,19 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // GIF button gifButtonContainer.addView(gifButton) gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + gifButton.onUp = { showGIFPicker() } // Document button documentButtonContainer.addView(documentButton) documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + documentButton.onUp = { showDocumentPicker() } // Library button libraryButtonContainer.addView(libraryButton) libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + libraryButton.onUp = { pickFromLibrary() } // Camera button cameraButtonContainer.addView(cameraButton) cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + cameraButton.onUp = { showCamera() } } private fun restoreDraftIfNeeded() { @@ -230,6 +282,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe typingIndicatorViewContainer.setTypists(recipients) inputBarHeightChanged(inputBar.height) } + if (TextSecurePreferences.isTypingIndicatorsEnabled(this)) { + inputBar.inputBarEditText.addTextChangedListener(object : SimpleTextWatcher() { + + override fun onTextChanged(text: String?) { + ApplicationContext.getInstance(this@ConversationActivityV2).typingStatusSender.onTypingStarted(threadID) + } + }) + } } private fun getLatestOpenGroupInfoIfNeeded() { @@ -266,9 +326,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe }) } - private fun scrollToFirstUnreadMessage() { + private fun scrollToFirstUnreadMessageIfNeeded() { val lastSeenTimestamp = DatabaseFactory.getThreadDatabase(this).getLastSeenAndHasSent(threadID).first() val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return + if (lastSeenItemPosition <= 3) { return } conversationRecyclerView.scrollToPosition(lastSeenItemPosition) } @@ -286,7 +347,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // region Updating & Animation private fun markAllAsRead() { - DatabaseFactory.getThreadDatabase(this).setRead(threadID, true) + val messages = DatabaseFactory.getThreadDatabase(this).setRead(threadID, true) + if (thread.isGroupRecipient) { + for (message in messages) { + MarkReadReceiver.scheduleDeletion(this, message.expirationInfo) + } + } else { + MarkReadReceiver.process(this, messages) + } + ApplicationContext.getInstance(this).messageNotifier.updateNotification(this) } override fun inputBarHeightChanged(newValue: Int) { @@ -296,7 +365,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val typingIndicatorHeight = if (typingIndicatorViewContainer.isVisible) toPx(36, resources) else 0 // Recycler view val recyclerViewLayoutParams = conversationRecyclerView.layoutParams as RelativeLayout.LayoutParams - recyclerViewLayoutParams.bottomMargin = newValue + additionalContentContainer.height + typingIndicatorHeight + recyclerViewLayoutParams.bottomMargin = newValue + typingIndicatorHeight conversationRecyclerView.layoutParams = recyclerViewLayoutParams // Additional content container val additionalContentContainerLayoutParams = additionalContentContainer.layoutParams as RelativeLayout.LayoutParams @@ -317,40 +386,77 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun inputBarEditTextContentChanged(newContent: CharSequence) { linkPreviewViewModel?.onTextChanged(this, inputBar.text, 0, 0) - // TODO: Implement the full mention show/hide logic - if (newContent.contains("@")) { - showMentionCandidates() - } else { - hideMentionCandidates() - } + showOrHideMentionCandidatesIfNeeded(newContent) } - private fun showMentionCandidates() { - additionalContentContainer.removeAllViews() - val mentionCandidatesView = MentionCandidatesView(this) - mentionCandidatesView.glide = glide - additionalContentContainer.addView(mentionCandidatesView) - val mentionCandidates = MentionsManager.getMentionCandidates("", threadID, thread.isOpenGroupRecipient) - this.mentionCandidatesView = mentionCandidatesView - mentionCandidatesView.show(mentionCandidates, threadID) - mentionCandidatesView.alpha = 0.0f - val animation = ValueAnimator.ofObject(FloatEvaluator(), mentionCandidatesView.alpha, 1.0f) - animation.duration = 250L - animation.addUpdateListener { animator -> - mentionCandidatesView.alpha = animator.animatedValue as Float + private fun showOrHideMentionCandidatesIfNeeded(text: CharSequence) { + if (text.length < previousText.length) { + currentMentionStartIndex = -1 + hideMentionCandidates() + val mentionsToRemove = mentions.filter { !text.contains(it.displayName) } + mentions.removeAll(mentionsToRemove) } - animation.start() + 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) + } + } + previousText = text + } + + private fun showOrUpdateMentionCandidatesIfNeeded(query: String = "") { + if (!isShowingMentionCandidatesView) { + additionalContentContainer.removeAllViews() + val view = MentionCandidatesView(this) + view.glide = glide + view.onCandidateSelected = { handleMentionSelected(it) } + additionalContentContainer.addView(view) + val candidates = MentionsManager.getMentionCandidates(query, threadID, thread.isOpenGroupRecipient) + this.mentionCandidatesView = view + view.show(candidates, threadID) + view.alpha = 0.0f + val animation = ValueAnimator.ofObject(FloatEvaluator(), view.alpha, 1.0f) + animation.duration = 250L + animation.addUpdateListener { animator -> + view.alpha = animator.animatedValue as Float + } + animation.start() + } else { + val candidates = MentionsManager.getMentionCandidates(query, threadID, thread.isOpenGroupRecipient) + this.mentionCandidatesView!!.setMentionCandidates(candidates) + } + isShowingMentionCandidatesView = true } private fun hideMentionCandidates() { - 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) { additionalContentContainer.removeAllViews() } + 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) { additionalContentContainer.removeAllViews() } + } + animation.start() } - animation.start() + isShowingMentionCandidatesView = false } override fun toggleAttachmentOptions() { @@ -426,16 +532,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe animation.start() } - override fun handleConversationRecyclerViewBottomOffsetChanged(bottomOffset: Int) { - val rawAlpha = (bottomOffset.toFloat() - scrollButtonNoVisibilityThreshold) / - (scrollButtonFullVisibilityThreshold - scrollButtonNoVisibilityThreshold) - val alpha = max(min(rawAlpha, 1.0f), 0.0f) + private fun handleRecyclerViewScrolled() { + val position = layoutManager.findFirstCompletelyVisibleItemPosition() + val alpha = if (position > 0) 1.0f else 0.0f scrollToBottomButton.alpha = alpha - updateUnreadCount() + unreadCount = min(unreadCount, layoutManager.findFirstVisibleItemPosition()) + updateUnreadCountIndicator() } - private fun updateUnreadCount() { - val unreadCount = DatabaseFactory.getMmsSmsDatabase(this).getUnreadCount(threadID) + private fun updateUnreadCountIndicator() { val formattedUnreadCount = if (unreadCount < 100) unreadCount.toString() else "99+" unreadCountTextView.text = formattedUnreadCount val textSize = if (unreadCount < 100) 12.0f else 9.0f @@ -550,10 +655,20 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun onMicrophoneButtonUp(event: MotionEvent) { - if (isValidLockViewLocation(event.rawX.roundToInt(), event.rawY.roundToInt())) { + val x = event.rawX.roundToInt() + val y = event.rawY.roundToInt() + if (isValidLockViewLocation(x, y)) { inputBarRecordingView.lock() } else { - hideVoiceMessageUI() + val recordButtonOverlay = inputBarRecordingView.recordButtonOverlay + val location = IntArray(2) { 0 } + recordButtonOverlay.getLocationOnScreen(location) + val hitRect = Rect(location[0], location[1], location[0] + recordButtonOverlay.width, location[1] + recordButtonOverlay.height) + if (hitRect.contains(x, y)) { + sendVoiceMessage() + } else { + cancelVoiceMessage() + } } } @@ -570,9 +685,197 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun unblock() { // TODO: Implement } + + private fun handleMentionSelected(mention: Mention) { + if (currentMentionStartIndex == -1) { return } + mentions.add(mention) + val previousText = inputBar.text + val newText = previousText.substring(0, currentMentionStartIndex) + "@" + mention.displayName + " " + inputBar.text = newText + inputBar.inputBarEditText.setSelection(newText.length) + currentMentionStartIndex = -1 + hideMentionCandidates() + this.previousText = newText + } + + override fun sendMessage() { + // Create the message + val message = VisibleMessage() + message.sentTimestamp = System.currentTimeMillis() + message.text = getMessageBody() + val outgoingTextMessage = OutgoingTextMessage.from(message, thread) + // Clear the input bar + inputBar.text = "" + // Clear mentions + previousText = "" + currentMentionStartIndex = -1 + mentions.clear() + // Put the message in the database + message.id = DatabaseFactory.getSmsDatabase(this).insertMessageOutbox(threadID, outgoingTextMessage, false, message.sentTimestamp!!) { } + // Send it + MessageSender.send(message, thread.address) + // Send a typing stopped message + ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(threadID) + } + + private fun sendAttachments(attachments: List, body: String?) { + // TODO: Quotes & link previews + // Create the message + val message = VisibleMessage() + message.sentTimestamp = System.currentTimeMillis() + message.text = body + val outgoingTextMessage = OutgoingMediaMessage.from(message, thread, attachments, null, null) + // Clear the input bar + inputBar.text = "" + // Clear mentions + previousText = "" + currentMentionStartIndex = -1 + mentions.clear() + // Reset the attachment manager + attachmentManager.clear() + // Reset attachments button if needed + if (isShowingAttachmentOptions) { toggleAttachmentOptions() } + // Put the message in the database + message.id = DatabaseFactory.getMmsDatabase(this).insertMessageOutbox(outgoingTextMessage, threadID, false) { } + // Send it + MessageSender.send(message, thread.address, attachments, null, null) + // Send a typing stopped message + ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(threadID) + } + + private fun showGIFPicker() { + AttachmentManager.selectGif(this, ConversationActivityV2.PICK_GIF) + } + + private fun showDocumentPicker() { + AttachmentManager.selectDocument(this, ConversationActivityV2.PICK_DOCUMENT) + } + + private fun pickFromLibrary() { + AttachmentManager.selectGallery(this, ConversationActivityV2.PICK_FROM_LIBRARY, thread, inputBar.text.trim()) + } + + private fun showCamera() { + attachmentManager.capturePhoto(this, ConversationActivityV2.TAKE_PHOTO) + } + + override fun onAttachmentChanged() { + // Do nothing + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { + super.onActivityResult(requestCode, resultCode, intent) + val mediaPreppedListener = object : ListenableFuture.Listener { + + override fun onSuccess(result: Boolean?) { + sendAttachments(attachmentManager.buildSlideDeck().asAttachments(), null) + } + + override fun onFailure(e: ExecutionException?) { + Toast.makeText(this@ConversationActivityV2, R.string.activity_conversation_attachment_prep_failed, Toast.LENGTH_LONG).show() + } + } + when (requestCode) { + PICK_DOCUMENT -> { + val uri = intent?.data ?: return + prepMediaForSending(uri, AttachmentManager.MediaType.DOCUMENT).addListener(mediaPreppedListener) + } + TAKE_PHOTO -> { + val uri = attachmentManager.captureUri ?: return + prepMediaForSending(uri, AttachmentManager.MediaType.IMAGE).addListener(mediaPreppedListener) + } + PICK_GIF -> { + intent ?: return + val uri = intent.data ?: return + val type = AttachmentManager.MediaType.GIF + val width = intent.getIntExtra(GiphyActivity.EXTRA_WIDTH, 0) + val height = intent.getIntExtra(GiphyActivity.EXTRA_HEIGHT, 0) + prepMediaForSending(uri, type, width, height).addListener(mediaPreppedListener) + } + PICK_FROM_LIBRARY -> { + intent ?: return + val body = intent.getStringExtra(MediaSendActivity.EXTRA_MESSAGE) + val media = intent.getParcelableArrayListExtra(MediaSendActivity.EXTRA_MEDIA) ?: return + val slideDeck = SlideDeck() + for (item in media) { + when { + MediaUtil.isVideoType(item.mimeType) -> { + slideDeck.addSlide(VideoSlide(this, item.uri, 0, item.caption.orNull())) + } + MediaUtil.isGif(item.mimeType) -> { + slideDeck.addSlide(GifSlide(this, item.uri, 0, item.width, item.height, item.caption.orNull())) + } + MediaUtil.isImageType(item.mimeType) -> { + slideDeck.addSlide(ImageSlide(this, item.uri, 0, item.width, item.height, item.caption.orNull())) + } + else -> { + Log.d("Loki", "Asked to send an unexpected media type: '" + item.mimeType + "'. Skipping.") + } + } + } + sendAttachments(slideDeck.asAttachments(), body) + } + } + } + + private fun prepMediaForSending(uri: Uri, type: AttachmentManager.MediaType): ListenableFuture { + return prepMediaForSending(uri, type, null, null) + } + + private fun prepMediaForSending(uri: Uri, type: AttachmentManager.MediaType, width: Int?, height: Int?): ListenableFuture { + return attachmentManager.setMedia(glide, uri, type, MediaConstraints.getPushMediaConstraints(), width ?: 0, height ?: 0) + } + + override fun startRecordingVoiceMessage() { + showVoiceMessageUI() + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + audioRecorder.startRecording() + stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 60000) // Limit voice messages to 1 minute each + } + + override fun sendVoiceMessage() { + hideVoiceMessageUI() + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + val future = audioRecorder.stopRecording() + stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) + future.addListener(object : ListenableFuture.Listener> { + + override fun onSuccess(result: Pair) { + val audioSlide = AudioSlide(this@ConversationActivityV2, result.first, result.second!!, MediaTypes.AUDIO_AAC, true) + val slideDeck = SlideDeck() + slideDeck.addSlide(audioSlide) + sendAttachments(slideDeck.asAttachments(), null) + } + + override fun onFailure(e: ExecutionException) { + Toast.makeText(this@ConversationActivityV2, R.string.ConversationActivity_unable_to_record_audio, Toast.LENGTH_LONG).show() + } + }) + } + + override fun cancelVoiceMessage() { + hideVoiceMessageUI() + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + audioRecorder.stopRecording() + stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) + } // endregion // region General + private fun getMessageBody(): String { + var result = inputBar.inputBarEditText.text?.trim() ?: "" + 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.toString() + } + private fun saveDraft() { val text = inputBar.text.trim() if (text.isEmpty()) { return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRecyclerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRecyclerView.kt index 7d8957a065..1926024015 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRecyclerView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRecyclerView.kt @@ -17,7 +17,6 @@ class ConversationRecyclerView : RecyclerView { private val maxLongPressVelocityY = toPx(10, resources) private val minSwipeVelocityX = toPx(10, resources) private var velocityTracker: VelocityTracker? = null - var delegate: ConversationRecyclerViewDelegate? = null constructor(context: Context) : super(context) { initialize() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } @@ -25,18 +24,6 @@ class ConversationRecyclerView : RecyclerView { private fun initialize() { disableClipping() - addOnScrollListener(object : RecyclerView.OnScrollListener() { - private var bottomOffset = 0 - - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - // Do nothing - } - - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - bottomOffset += dy // FIXME: Not sure this is fully accurate, but it seems close enough - delegate?.handleConversationRecyclerViewBottomOffsetChanged(abs(bottomOffset)) - } - }) } override fun onInterceptTouchEvent(e: MotionEvent): Boolean { @@ -66,8 +53,3 @@ class ConversationRecyclerView : RecyclerView { return super.dispatchTouchEvent(e) } } - -interface ConversationRecyclerViewDelegate { - - fun handleConversationRecyclerViewBottomOffsetChanged(bottomOffset: Int) -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index 98ce5d630d..d00dc10f1e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -8,7 +8,6 @@ import android.view.MotionEvent import android.widget.RelativeLayout import androidx.core.view.isVisible import kotlinx.android.synthetic.main.view_input_bar.view.* -import kotlinx.android.synthetic.main.view_quote.view.* import network.loki.messenger.R import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.thoughtcrime.securesms.conversation.v2.components.LinkPreviewDraftView @@ -53,7 +52,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li // Microphone button microphoneOrSendButtonContainer.addView(microphoneButton) microphoneButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) - microphoneButton.onLongPress = { showVoiceMessageUI() } + microphoneButton.onLongPress = { startRecordingVoiceMessage() } microphoneButton.onMove = { delegate?.onMicrophoneButtonMove(it) } microphoneButton.onCancel = { delegate?.onMicrophoneButtonCancel(it) } microphoneButton.onUp = { delegate?.onMicrophoneButtonUp(it) } @@ -61,6 +60,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li microphoneOrSendButtonContainer.addView(sendButton) sendButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) sendButton.isVisible = false + sendButton.onUp = { delegate?.sendMessage() } // Edit text inputBarEditText.imeOptions = inputBarEditText.imeOptions or 16777216 // Always use incognito keyboard inputBarEditText.delegate = this @@ -92,8 +92,8 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li delegate?.toggleAttachmentOptions() } - private fun showVoiceMessageUI() { - delegate?.showVoiceMessageUI() + private fun startRecordingVoiceMessage() { + delegate?.startRecordingVoiceMessage() } // Drafting quotes and drafting link previews is mutually exclusive, i.e. you can't draft @@ -111,7 +111,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li // here to get the layout right. val maxContentWidth = (screenWidth - 2 * resources.getDimension(R.dimen.medium_spacing) - toPx(16, resources) - toPx(30, resources)).roundToInt() quoteView.bind(message.individualRecipient.address.toString(), message.body, attachments, - message.recipient, true, maxContentWidth, message.isOpenGroupInvitation) + message.recipient, true, maxContentWidth, message.isOpenGroupInvitation, message.threadId) // The 6 DP below is the padding the quote view applies to itself, which isn't included in the // intrinsic height calculation. val quoteViewIntrinsicHeight = quoteView.getIntrinsicHeight(maxContentWidth) + toPx(6, resources) @@ -159,7 +159,9 @@ interface InputBarDelegate { fun inputBarEditTextContentChanged(newContent: CharSequence) fun toggleAttachmentOptions() fun showVoiceMessageUI() + fun startRecordingVoiceMessage() fun onMicrophoneButtonMove(event: MotionEvent) fun onMicrophoneButtonCancel(event: MotionEvent) fun onMicrophoneButtonUp(event: MotionEvent) + fun sendMessage() } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt index a4da17434f..0a210d6ae5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt @@ -134,10 +134,14 @@ class InputBarRecordingView : RelativeLayout { } fadeInAnimation.start() recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_up, context.theme)) + recordButtonOverlay.setOnClickListener { delegate?.sendVoiceMessage() } + inputBarCancelButton.setOnClickListener { delegate?.cancelVoiceMessage() } } } interface InputBarRecordingViewDelegate { fun handleVoiceMessageUIHidden() + fun sendVoiceMessage() + fun cancelVoiceMessage() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt index a685dbc828..bbf97f0afd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt @@ -65,6 +65,10 @@ class MentionCandidatesView(context: Context, attrs: AttributeSet?, defStyleAttr openGroupServer = openGroup.server openGroupRoom = openGroup.room } + setMentionCandidates(candidates) + } + + fun setMentionCandidates(candidates: List) { this.candidates = candidates val layoutParams = this.layoutParams as ViewGroup.LayoutParams layoutParams.height = toPx(Math.min(candidates.count(), 4) * 44, resources) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index 490272d8e4..ab6c11dcb5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -1,11 +1,14 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context +import android.content.res.Resources import android.util.AttributeSet +import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.widget.LinearLayout import androidx.core.content.res.ResourcesCompat +import androidx.recyclerview.widget.RecyclerView import kotlinx.android.synthetic.main.view_control_message.view.* import network.loki.messenger.R import org.thoughtcrime.securesms.database.model.MessageRecord @@ -19,6 +22,7 @@ class ControlMessageView : LinearLayout { private fun initialize() { LayoutInflater.from(context).inflate(R.layout.view_control_message, this) + layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt index 19943c70be..00a98d1d33 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context +import android.graphics.Canvas import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.LayoutInflater @@ -9,12 +10,15 @@ import android.widget.LinearLayout import androidx.core.content.res.ResourcesCompat import kotlinx.android.synthetic.main.view_link_preview.view.* import network.loki.messenger.R +import org.thoughtcrime.securesms.components.CornerMask +import org.thoughtcrime.securesms.conversation.v2.utilities.MessageBubbleUtilities import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.loki.utilities.UiModeUtilities import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.ImageSlide class LinkPreviewView : LinearLayout { + private val cornerMask by lazy { CornerMask(this) } // region Lifecycle constructor(context: Context) : super(context) { initialize() } @@ -27,7 +31,7 @@ class LinkPreviewView : LinearLayout { // endregion // region Updating - fun bind(message: MmsMessageRecord, glide: GlideRequests, background: Drawable) { + fun bind(message: MmsMessageRecord, glide: GlideRequests, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean) { mainLinkPreviewContainer.background = background mainLinkPreviewContainer.outlineProvider = ViewOutlineProvider.BACKGROUND mainLinkPreviewContainer.clipToOutline = true @@ -48,6 +52,17 @@ class LinkPreviewView : LinearLayout { // Body val bodyTextView = VisibleMessageContentView.getBodyTextView(context, message) mainLinkPreviewContainer.addView(bodyTextView) + // Corner radii + val cornerRadii = MessageBubbleUtilities.calculateRadii(context, isStartOfMessageCluster, isEndOfMessageCluster, message.isOutgoing) + cornerMask.setTopLeftRadius(cornerRadii[0]) + cornerMask.setTopRightRadius(cornerRadii[1]) + cornerMask.setBottomRightRadius(cornerRadii[2]) + cornerMask.setBottomLeftRadius(cornerRadii[3]) + } + + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + cornerMask.mask(canvas) } fun recycle() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index 43c4f17412..e4ffa588f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt @@ -10,6 +10,7 @@ import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.annotation.ColorInt import androidx.core.content.res.ResourcesCompat +import androidx.core.text.toSpannable import androidx.core.view.isVisible import androidx.core.view.marginStart import kotlinx.android.synthetic.main.view_quote.view.* @@ -20,10 +21,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.loki.utilities.UiMode -import org.thoughtcrime.securesms.loki.utilities.UiModeUtilities -import org.thoughtcrime.securesms.loki.utilities.toDp -import org.thoughtcrime.securesms.loki.utilities.toPx +import org.thoughtcrime.securesms.loki.utilities.* import org.thoughtcrime.securesms.mms.SlideDeck import kotlin.math.max import kotlin.math.min @@ -106,7 +104,7 @@ class QuoteView : LinearLayout { // region Updating fun bind(authorPublicKey: String, body: String?, attachments: SlideDeck?, thread: Recipient, - isOutgoingMessage: Boolean, maxContentWidth: Int, isOpenGroupInvitation: Boolean) { + isOutgoingMessage: Boolean, maxContentWidth: Int, isOpenGroupInvitation: Boolean, threadID: Long) { val contactDB = DatabaseFactory.getSessionContactDatabase(context) // Reduce the max body text view line count to 2 if this is a group thread because // we'll be showing the author text view and we don't want the overall quote view height @@ -121,7 +119,7 @@ class QuoteView : LinearLayout { } quoteViewAuthorTextView.isVisible = thread.isGroupRecipient // Body - quoteViewBodyTextView.text = if (isOpenGroupInvitation) resources.getString(R.string.open_group_invitation_view__open_group_invitation) else body + quoteViewBodyTextView.text = if (isOpenGroupInvitation) resources.getString(R.string.open_group_invitation_view__open_group_invitation) else MentionUtilities.highlightMentions((body ?: "").toSpannable(), threadID, context); quoteViewBodyTextView.setTextColor(getTextColor(isOutgoingMessage)) // Accent line / attachment preview val hasAttachments = (attachments != null && attachments.asAttachments().isNotEmpty()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 0a9c2decb9..7a3aea223c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.graphics.Rect +import android.content.res.ColorStateList +import android.graphics.Color import android.graphics.drawable.Drawable import android.text.util.Linkify import android.util.AttributeSet @@ -58,7 +60,7 @@ class VisibleMessageContentView : LinearLayout { onContentClick = null if (message is MmsMessageRecord && message.linkPreviews.isNotEmpty()) { val linkPreviewView = LinkPreviewView(context) - linkPreviewView.bind(message, glide, background) + linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster) mainContainer.addView(linkPreviewView) // Body text view is inside the link preview for layout convenience } else if (message is MmsMessageRecord && message.quote != null) { @@ -69,14 +71,14 @@ class VisibleMessageContentView : LinearLayout { // here to get the layout right. val maxContentWidth = (maxWidth - 2 * resources.getDimension(R.dimen.medium_spacing) - toPx(16, resources)).roundToInt() quoteView.bind(quote.author.toString(), quote.text, quote.attachment, thread, - message.isOutgoing, maxContentWidth, message.isOpenGroupInvitation) + message.isOutgoing, maxContentWidth, message.isOpenGroupInvitation, message.threadId) mainContainer.addView(quoteView) val bodyTextView = VisibleMessageContentView.getBodyTextView(context, message) ViewUtil.setPaddingTop(bodyTextView, 0) mainContainer.addView(bodyTextView) } else if (message is MmsMessageRecord && message.slideDeck.audioSlide != null) { val voiceMessageView = VoiceMessageView(context) - voiceMessageView.bind(message, background) + voiceMessageView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster) mainContainer.addView(voiceMessageView) // We have to use onContentClick (rather than a click listener directly on the voice // message view) so as to not interfere with all the other gestures. @@ -148,11 +150,11 @@ class VisibleMessageContentView : LinearLayout { @ColorInt fun getTextColor(context: Context, message: MessageRecord): Int { - val uiMode = UiModeUtilities.getUserSelectedUiMode(context) + val isDayUiMode = UiModeUtilities.isDayUiMode(context) val colorID = if (message.isOutgoing) { - if (uiMode == UiMode.NIGHT) R.color.black else R.color.white + if (isDayUiMode) R.color.white else R.color.black } else { - if (uiMode == UiMode.NIGHT) R.color.white else R.color.black + if (isDayUiMode) R.color.black else R.color.white } return context.resources.getColorWithID(colorID, context.theme) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt index f85ebec0f6..111f251805 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context +import android.graphics.Canvas import android.graphics.drawable.Drawable import android.os.Handler import android.os.Looper @@ -12,12 +13,15 @@ import android.widget.RelativeLayout import androidx.core.view.isVisible import kotlinx.android.synthetic.main.view_voice_message.view.* import network.loki.messenger.R +import org.thoughtcrime.securesms.components.CornerMask +import org.thoughtcrime.securesms.conversation.v2.utilities.MessageBubbleUtilities import org.thoughtcrime.securesms.database.model.MmsMessageRecord import java.util.concurrent.TimeUnit import kotlin.math.roundToInt class VoiceMessageView : LinearLayout { private val snHandler = Handler(Looper.getMainLooper()) + private val cornerMask by lazy { CornerMask(this) } private var runnable: Runnable? = null private var mockIsPlaying = false private var mockProgress = 0L @@ -38,12 +42,14 @@ class VoiceMessageView : LinearLayout { // endregion // region Updating - fun bind(message: MmsMessageRecord, background: Drawable) { + fun bind(message: MmsMessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean) { val audio = message.slideDeck.audioSlide!! voiceMessageViewLoader.isVisible = audio.isPendingDownload - mainVoiceMessageViewContainer.background = background - mainVoiceMessageViewContainer.outlineProvider = ViewOutlineProvider.BACKGROUND - mainVoiceMessageViewContainer.clipToOutline = true + val cornerRadii = MessageBubbleUtilities.calculateRadii(context, isStartOfMessageCluster, isEndOfMessageCluster, message.isOutgoing) + cornerMask.setTopLeftRadius(cornerRadii[0]) + cornerMask.setTopRightRadius(cornerRadii[1]) + cornerMask.setBottomRightRadius(cornerRadii[2]) + cornerMask.setBottomLeftRadius(cornerRadii[3]) } private fun handleProgressChanged() { @@ -56,6 +62,11 @@ class VoiceMessageView : LinearLayout { progressView.layoutParams = layoutParams } + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + cornerMask.mask(canvas) + } + fun recycle() { // TODO: Implement } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java similarity index 58% rename from app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java index 153f07c168..a5298305a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.thoughtcrime.securesms.mms; +package org.thoughtcrime.securesms.conversation.v2.utilities; import android.Manifest; import android.annotation.SuppressLint; @@ -23,29 +23,31 @@ import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.database.Cursor; -import android.graphics.PorterDuff; import android.net.Uri; import android.os.AsyncTask; -import android.provider.ContactsContract; import android.provider.MediaStore; import android.provider.OpenableColumns; import android.text.TextUtils; import android.util.Pair; -import android.view.View; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.thoughtcrime.securesms.MediaPreviewActivity; -import org.thoughtcrime.securesms.loki.views.MessageAudioView; -import org.thoughtcrime.securesms.components.DocumentView; -import org.thoughtcrime.securesms.components.RemovableEditableMediaView; -import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView; import org.session.libsignal.utilities.NoExternalStorageException; import org.thoughtcrime.securesms.giph.ui.GiphyActivity; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.mediasend.MediaSendActivity; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.mms.DocumentSlide; +import org.thoughtcrime.securesms.mms.GifSlide; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.ImageSlide; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.mms.VideoSlide; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.providers.BlobProvider; import org.session.libsignal.utilities.ExternalStorageUtil; @@ -53,13 +55,8 @@ import org.thoughtcrime.securesms.util.FileProviderUtil; import org.thoughtcrime.securesms.util.MediaUtil; import org.session.libsignal.utilities.guava.Optional; -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsession.utilities.ThemeUtil; -import org.session.libsession.utilities.ViewUtil; -import org.session.libsession.utilities.Stub; import org.session.libsignal.utilities.ListenableFuture; -import org.session.libsignal.utilities.ListenableFuture.Listener; import org.session.libsignal.utilities.SettableFuture; import java.io.File; @@ -67,26 +64,18 @@ import java.io.IOException; import java.util.Iterator; import java.util.LinkedList; import java.util.List; -import java.util.concurrent.ExecutionException; import network.loki.messenger.R; import static android.provider.MediaStore.EXTRA_OUTPUT; - public class AttachmentManager { private final static String TAG = AttachmentManager.class.getSimpleName(); private final @NonNull Context context; - private final @NonNull Stub attachmentViewStub; private final @NonNull AttachmentListener attachmentListener; - private RemovableEditableMediaView removableMediaView; - private ThumbnailView thumbnail; - private MessageAudioView audioView; - private DocumentView documentView; - private @NonNull List garbage = new LinkedList<>(); private @NonNull Optional slide = Optional.absent(); private @Nullable Uri captureUri; @@ -94,51 +83,12 @@ public class AttachmentManager { public AttachmentManager(@NonNull Activity activity, @NonNull AttachmentListener listener) { this.context = activity; this.attachmentListener = listener; - this.attachmentViewStub = ViewUtil.findStubById(activity, R.id.attachment_editor_stub); } - private void inflateStub() { - if (!attachmentViewStub.resolved()) { - View root = attachmentViewStub.get(); - - this.thumbnail = ViewUtil.findById(root, R.id.attachment_thumbnail); - this.audioView = ViewUtil.findById(root, R.id.attachment_audio); - this.documentView = ViewUtil.findById(root, R.id.attachment_document); - this.removableMediaView = ViewUtil.findById(root, R.id.removable_media_view); - - removableMediaView.setRemoveClickListener(new RemoveButtonListener()); - thumbnail.setOnClickListener(new ThumbnailClickListener()); - documentView.getBackground().setColorFilter(ThemeUtil.getThemedColor(context, R.attr.conversation_item_bubble_background), PorterDuff.Mode.MULTIPLY); - } - } - - public void clear(@NonNull GlideRequests glideRequests, boolean animate) { - if (attachmentViewStub.resolved()) { - - if (animate) { - ViewUtil.fadeOut(attachmentViewStub.get(), 200).addListener(new Listener() { - @Override - public void onSuccess(Boolean result) { - thumbnail.clear(glideRequests); - attachmentViewStub.get().setVisibility(View.GONE); - attachmentListener.onAttachmentChanged(); - } - - @Override - public void onFailure(ExecutionException e) { - } - }); - } else { - thumbnail.clear(glideRequests); - attachmentViewStub.get().setVisibility(View.GONE); - attachmentListener.onAttachmentChanged(); - } - - markGarbage(getSlideUri()); - slide = Optional.absent(); - - audioView.cleanup(); - } + public void clear() { + markGarbage(getSlideUri()); + slide = Optional.absent(); + attachmentListener.onAttachmentChanged(); } public void cleanup() { @@ -190,16 +140,12 @@ public class AttachmentManager { final int width, final int height) { - inflateStub(); - final SettableFuture result = new SettableFuture<>(); new AsyncTask() { @Override protected void onPreExecute() { - thumbnail.clear(glideRequests); - thumbnail.showProgressSpinner(); - attachmentViewStub.get().setVisibility(View.VISIBLE); + } @Override @@ -222,35 +168,12 @@ public class AttachmentManager { @Override protected void onPostExecute(@Nullable final Slide slide) { if (slide == null) { - attachmentViewStub.get().setVisibility(View.GONE); - Toast.makeText(context, - R.string.ConversationActivity_sorry_there_was_an_error_setting_your_attachment, - Toast.LENGTH_SHORT).show(); result.set(false); } else if (!areConstraintsSatisfied(context, slide, constraints)) { - attachmentViewStub.get().setVisibility(View.GONE); - Toast.makeText(context, - R.string.ConversationActivity_attachment_exceeds_size_limits, - Toast.LENGTH_SHORT).show(); result.set(false); } else { setSlide(slide); - attachmentViewStub.get().setVisibility(View.VISIBLE); - - if (slide.hasAudio()) { - audioView.setAudio((AudioSlide) slide, false); - removableMediaView.display(audioView, false); - result.set(true); - } else if (slide.hasDocument()) { - documentView.setDocument((DocumentSlide) slide, false); - removableMediaView.display(documentView, false); - result.set(true); - } else { - Attachment attachment = slide.asAttachment(); - result.deferTo(thumbnail.setImageResource(glideRequests, slide, false, true, attachment.getWidth(), attachment.getHeight())); - removableMediaView.display(thumbnail, mediaType == MediaType.IMAGE); - } - + result.set(true); attachmentListener.onAttachmentChanged(); } } @@ -317,11 +240,8 @@ public class AttachmentManager { return result; } - public boolean isAttachmentPresent() { - return attachmentViewStub.resolved() && attachmentViewStub.get().getVisibility() == View.VISIBLE; - } - - public @NonNull SlideDeck buildSlideDeck() { + public @NonNull + SlideDeck buildSlideDeck() { SlideDeck deck = new SlideDeck(); if (slide.isPresent()) deck.addSlide(slide.get()); return deck; @@ -333,43 +253,16 @@ public class AttachmentManager { public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) { Permissions.with(activity) - .request(Manifest.permission.READ_EXTERNAL_STORAGE) - .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) - .onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode)) - .execute(); + .request(Manifest.permission.READ_EXTERNAL_STORAGE) + .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) + .onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode)) + .execute(); } public static void selectAudio(Activity activity, int requestCode) { selectMediaType(activity, "audio/*", null, requestCode); } - public static void selectContactInfo(Activity activity, int requestCode) { - Permissions.with(activity) - .request(Manifest.permission.WRITE_CONTACTS) - .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_contacts_permission_in_order_to_attach_contact_information)) - .onAllGranted(() -> { - Intent intent = new Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI); - activity.startActivityForResult(intent, requestCode); - }) - .execute(); - } - - public static void selectLocation(Activity activity, int requestCode) { - /* Loki - Enable again once we have location sharing - Permissions.with(activity) - .request(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) - .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_location_information_in_order_to_attach_a_location)) - .onAllGranted(() -> { - try { - activity.startActivityForResult(new PlacePicker.IntentBuilder().build(activity), requestCode); - } catch (GooglePlayServicesRepairableException | GooglePlayServicesNotAvailableException e) { - Log.w(TAG, e); - } - }) - .execute(); - */ - } - public static void selectGif(Activity activity, int requestCode) { Intent intent = new Intent(activity, GiphyActivity.class); intent.putExtra(GiphyActivity.EXTRA_IS_MMS, false); @@ -386,28 +279,25 @@ public class AttachmentManager { public void capturePhoto(Activity activity, int requestCode) { Permissions.with(activity) - .request(Manifest.permission.CAMERA) - .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_camera_permission_in_order_to_take_photos_but_it_has_been_permanently_denied)) - .onAllGranted(() -> { - try { - File captureFile = File.createTempFile( - "conversation-capture", - ".jpg", - ExternalStorageUtil.getImageDir(activity)); - Uri captureUri = FileProviderUtil.getUriFor(context, captureFile); - Intent captureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); - captureIntent.putExtra(EXTRA_OUTPUT, captureUri); - captureIntent.setFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - if (captureIntent.resolveActivity(activity.getPackageManager()) != null) { - Log.d(TAG, "captureUri path is " + captureUri.getPath()); - this.captureUri = captureUri; - activity.startActivityForResult(captureIntent, requestCode); - } - } catch (IOException | NoExternalStorageException e) { - throw new RuntimeException("Error creating image capture intent.", e); - } - }) - .execute(); + .request(Manifest.permission.CAMERA) + .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_camera_permission_in_order_to_take_photos_but_it_has_been_permanently_denied)) + .onAllGranted(() -> { + try { + File captureFile = File.createTempFile("conversation-capture", ".jpg", ExternalStorageUtil.getImageDir(activity)); + Uri captureUri = FileProviderUtil.getUriFor(context, captureFile); + Intent captureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + captureIntent.putExtra(EXTRA_OUTPUT, captureUri); + captureIntent.setFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + if (captureIntent.resolveActivity(activity.getPackageManager()) != null) { + Log.d(TAG, "captureUri path is " + captureUri.getPath()); + this.captureUri = captureUri; + activity.startActivityForResult(captureIntent, requestCode); + } + } catch (IOException | NoExternalStorageException e) { + throw new RuntimeException("Error creating image capture intent.", e); + } + }) + .execute(); } private static void selectMediaType(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode) { @@ -445,34 +335,6 @@ public class AttachmentManager { constraints.canResize(slide.asAttachment()); } - private void previewImageDraft(final @NonNull Slide slide) { - if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) { - Intent intent = new Intent(context, MediaPreviewActivity.class); - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, slide.asAttachment().getSize()); - intent.putExtra(MediaPreviewActivity.CAPTION_EXTRA, slide.getCaption().orNull()); - intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, true); - intent.setDataAndType(slide.getUri(), slide.getContentType()); - - context.startActivity(intent); - } - } - - private class ThumbnailClickListener implements View.OnClickListener { - @Override - public void onClick(View v) { - if (slide.isPresent()) previewImageDraft(slide.get()); - } - } - - private class RemoveButtonListener implements View.OnClickListener { - @Override - public void onClick(View v) { - cleanup(); - clear(GlideApp.with(context.getApplicationContext()), true); - } - } - public interface AttachmentListener { void onAttachmentChanged(); } @@ -513,6 +375,5 @@ public class AttachmentManager { return DOCUMENT; } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MessageBubbleUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MessageBubbleUtilities.kt new file mode 100644 index 0000000000..c4c5d5a5d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MessageBubbleUtilities.kt @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.conversation.v2.utilities + +import android.content.Context +import network.loki.messenger.R +import kotlin.math.roundToInt + +object MessageBubbleUtilities { + + fun calculateRadii(context: Context, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean, isOutgoing: Boolean): IntArray { + val roundedDimen = context.resources.getDimension(R.dimen.message_corner_radius).roundToInt() + val collapsedDimen = context.resources.getDimension(R.dimen.message_corner_collapse_radius).roundToInt() + val (tl, tr, bl, br) = when { + // Single message + isStartOfMessageCluster && isEndOfMessageCluster -> intArrayOf(roundedDimen, roundedDimen, roundedDimen, roundedDimen) + // Start of message cluster; collapsed BL + isStartOfMessageCluster -> intArrayOf(roundedDimen, roundedDimen, collapsedDimen, roundedDimen) + // End of message cluster; collapsed TL + isEndOfMessageCluster -> intArrayOf(collapsedDimen, roundedDimen, roundedDimen, roundedDimen) + // In the middle; no rounding on the left + else -> intArrayOf(collapsedDimen, roundedDimen, collapsedDimen, roundedDimen) + } + // TL, TR, BR, BL (CW direction) + // Flip if the message is outgoing + return intArrayOf( + if (!isOutgoing) tl else tr, // TL + if (!isOutgoing) tr else tl, // TR + if (!isOutgoing) br else bl, // BR + if (!isOutgoing) bl else br // BL + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java index 3df423a6de..c12a3f196b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java @@ -81,7 +81,6 @@ public class MarkReadReceiver extends BroadcastReceiver { for (Address address : addressMap.keySet()) { List timestamps = Stream.of(addressMap.get(address)).map(SyncMessageId::getTimetamp).toList(); - // Loki - Check whether we want to send a read receipt to this user if (!SessionMetaProtocol.shouldSendReadReceipt(address)) { continue; } ReadReceipt readReceipt = new ReadReceipt(timestamps); readReceipt.setSentTimestamp(System.currentTimeMillis()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt index 2fc8c1cbaf..62002c88b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt @@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId class ReadReceiptManager: SSKEnvironment.ReadReceiptManagerProtocol { + override fun processReadReceipts(context: Context, fromRecipientId: String, sentTimestamps: List, readTimestamp: Long) { if (TextSecurePreferences.isReadReceiptsEnabled(context)) { diff --git a/app/src/main/res/layout/view_control_message.xml b/app/src/main/res/layout/view_control_message.xml index 714c7150ee..5dbbe14ab6 100644 --- a/app/src/main/res/layout/view_control_message.xml +++ b/app/src/main/res/layout/view_control_message.xml @@ -16,7 +16,7 @@ Download %s is blocked. Unblock them? + + Failed to prepare attachment for sending. diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 5928d5687f..110ac1dfe2 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -18,7 +18,7 @@ @color/default_background_start @color/compose_view_background - @style/ThemeOverlay.AppCompat.Light + @style/ThemeOverlay.AppCompat.DayNight @null @style/ThemeOverlay.AppCompat.DayNight.ActionBar @style/Widget.Session.ActionBar