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 70a55fb10f..9c5ae07b7f 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 @@ -6,11 +6,13 @@ import android.content.res.Resources import android.database.Cursor import android.graphics.Rect import android.os.Bundle +import android.util.Log import android.view.* import android.widget.RelativeLayout 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.* @@ -35,11 +37,12 @@ import org.thoughtcrime.securesms.database.DraftDatabase.Drafts import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.loki.utilities.toPx import org.thoughtcrime.securesms.mms.GlideApp -import kotlin.math.abs -import kotlin.math.roundToInt -import kotlin.math.sqrt +import kotlin.math.* -class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate, InputBarRecordingViewDelegate { +class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate, + InputBarRecordingViewDelegate, ConversationRecyclerViewDelegate { + private val scrollButtonFullVisibilityThreshold by lazy { toPx(120.0f, resources) } + private val scrollButtonNoVisibilityThreshold by lazy { toPx(20.0f, resources) } private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private var threadID: Long = -1 private var actionMode: ActionMode? = null @@ -113,6 +116,7 @@ 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 { @@ -171,7 +175,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (!isOxenHostedOpenGroup) { return } openGroupGuidelinesView.visibility = View.VISIBLE val recyclerViewLayoutParams = conversationRecyclerView.layoutParams as RelativeLayout.LayoutParams - recyclerViewLayoutParams.topMargin = toPx(57, resources) + recyclerViewLayoutParams.topMargin = toPx(57, resources) // The height of the open group guidelines view is hardcoded to this conversationRecyclerView.layoutParams = recyclerViewLayoutParams } @@ -315,6 +319,13 @@ 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) + Log.d("Test", "$alpha") + } // endregion // region Interaction @@ -408,6 +419,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun isValidLockViewLocation(x: Int, y: Int): Boolean { + // We can be anywhere above the lock view and a bit to the side of it (at most `lockViewHitMargin` + // to the side) val lockViewLocation = IntArray(2) { 0 } lockView.getLocationOnScreen(lockViewLocation) val hitRect = Rect(lockViewLocation[0] - lockViewHitMargin, 0, 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 ea10f0176e..246b84668f 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 @@ -2,17 +2,21 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context import android.util.AttributeSet +import android.util.Log import android.view.MotionEvent import android.view.VelocityTracker import androidx.recyclerview.widget.RecyclerView +import kotlinx.android.synthetic.main.activity_conversation_v2.* import org.thoughtcrime.securesms.loki.utilities.disableClipping import org.thoughtcrime.securesms.loki.utilities.toPx import kotlin.math.abs +import kotlin.math.max 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() } @@ -20,6 +24,20 @@ class ConversationRecyclerView : RecyclerView { private fun initialize() { disableClipping() + addOnScrollListener(object : RecyclerView.OnScrollListener() { + private var maxScrollOffset = 0 + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + // Do nothing + } + + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + val scrollOffset = recyclerView.computeVerticalScrollOffset() + maxScrollOffset = max(maxScrollOffset, scrollOffset) + val bottomOffset = (maxScrollOffset - scrollOffset) + delegate?.handleConversationRecyclerViewBottomOffsetChanged(bottomOffset) + } + }) } override fun onInterceptTouchEvent(e: MotionEvent): Boolean { @@ -48,4 +66,9 @@ class ConversationRecyclerView : RecyclerView { velocityTracker?.addMovement(e) 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 0c583af299..e5bab6373e 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 @@ -26,6 +26,7 @@ import kotlin.math.roundToInt class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate { private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val vMargin by lazy { toDp(4, resources) } + private val minHeight by lazy { toPx(56, resources) } var delegate: InputBarDelegate? = null var additionalContentHeight = 0 @@ -82,7 +83,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate { } override fun inputBarEditTextHeightChanged(newValue: Int) { - val newHeight = max(newValue + 2 * vMargin, toPx(56, resources)) + inputBarAdditionalContentContainer.height + val newHeight = max(newValue + 2 * vMargin, minHeight) + inputBarAdditionalContentContainer.height setHeight(newHeight) } @@ -100,18 +101,23 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate { quoteView.delegate = this inputBarAdditionalContentContainer.addView(quoteView) val attachments = (message as? MmsMessageRecord)?.slideDeck + // The max content width is the screen width - 2 times the horizontal input bar padding - the + // quote view content area's start and end margins. This unfortunately has to be calculated manually + // 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) + // 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) - val newHeight = max(inputBarEditText.height + 2 * vMargin, toPx(56, resources)) + quoteViewIntrinsicHeight + val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight) + quoteViewIntrinsicHeight additionalContentHeight = quoteViewIntrinsicHeight setHeight(newHeight) } override fun cancelQuoteDraft() { inputBarAdditionalContentContainer.removeAllViews() - val newHeight = max(inputBarEditText.height + 2 * vMargin, toPx(56, resources)) + val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight) additionalContentHeight = 0 setHeight(newHeight) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt index 9fb480aeb9..f2d3e5eded 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt @@ -29,7 +29,8 @@ class InputBarEditText : AppCompatEditText { super.onTextChanged(text, start, lengthBefore, lengthAfter) delegate?.inputBarEditTextContentChanged(text) // Calculate the width manually to get it right even before layout has happened (i.e. - // when restoring a draft) + // when restoring a draft). The 64 DP is the horizontal margin around the input bar + // edit text. val width = (screenWidth - 2 * toPx(64.0f, resources)).roundToInt() if (width < 0) { return } // screenWidth initially evaluates to 0 val height = TextUtilities.getIntrinsicHeight(text, paint, width).toFloat() 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 26c85be8f9..356745328a 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 @@ -52,7 +52,8 @@ class LinkPreviewView : LinearLayout { } titleTextView.setTextColor(ResourcesCompat.getColor(resources, textColorID, context.theme)) // Body - mainLinkPreviewContainer.addView(VisibleMessageContentView.getBodyTextView(context, message)) + val bodyTextView = VisibleMessageContentView.getBodyTextView(context, message) + mainLinkPreviewContainer.addView(bodyTextView) } 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 afa27898ca..43c4f17412 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 @@ -29,6 +29,13 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt +// There's quite some calculation going on here. It's a bit complex so don't make changes +// if you don't need to. If you do then test: +// • Quoted text in both private chats and group chats +// • Quoted images and videos in both private chats and group chats +// • Quoted voice messages and documents in both private chats and group chats +// • All of the above in both dark mode and light mode + class QuoteView : LinearLayout { private lateinit var mode: Mode private val vPadding by lazy { toPx(6, resources) } @@ -44,6 +51,8 @@ class QuoteView : LinearLayout { constructor(context: Context, mode: Mode) : super(context) { this.mode = mode LayoutInflater.from(context).inflate(R.layout.view_quote, this) + // Add padding here (not on mainQuoteViewContainer) to get a bit of a top inset while avoiding + // the clipping issue described in getIntrinsicHeight(maxContentWidth:). setPadding(0, toPx(6, resources), 0, 0) when (mode) { Mode.Draft -> quoteViewCancelButton.setOnClickListener { delegate?.cancelQuoteDraft() } @@ -51,6 +60,7 @@ class QuoteView : LinearLayout { quoteViewCancelButton.isVisible = false mainQuoteViewContainer.setBackgroundColor(ResourcesCompat.getColor(resources, R.color.transparent, context.theme)) val quoteViewMainContentContainerLayoutParams = quoteViewMainContentContainer.layoutParams as RelativeLayout.LayoutParams + // Since we're not showing the cancel button we can shorten the end margin quoteViewMainContentContainerLayoutParams.marginEnd = resources.getDimension(R.dimen.medium_spacing).roundToInt() quoteViewMainContentContainer.layoutParams = quoteViewMainContentContainerLayoutParams } @@ -60,6 +70,7 @@ class QuoteView : LinearLayout { // region General fun getIntrinsicContentHeight(maxContentWidth: Int): Int { + // If we're showing an attachment thumbnail, just constrain to the height of that if (quoteViewAttachmentPreviewContainer.isVisible) { return toPx(40, resources) } var result = 0 var authorTextViewIntrinsicHeight = 0 @@ -72,16 +83,24 @@ class QuoteView : LinearLayout { val bodyTextViewIntrinsicHeight = TextUtilities.getIntrinsicHeight(body, quoteViewBodyTextView.paint, maxContentWidth) result += bodyTextViewIntrinsicHeight if (!quoteViewAuthorTextView.isVisible) { - return min(max(result, toPx(32, resources)), toPx(60, resources)) + // We want to at least be as high as the cancel button, and no higher than 56 DP (that's + // approximately the height of 3 lines. + return min(max(result, toPx(32, resources)), toPx(56, resources)) } else { - return min(result, toPx(60, resources) + authorTextViewIntrinsicHeight) + // Because we're showing the author text view, we should have a height of at least 32 DP + // anyway, so there's no need to constrain to that. We constrain to a max height of 56 DP + // because that's approximately the height of the author text view + 2 lines of the body + // text view. + return min(result, toPx(56, resources)) } } fun getIntrinsicHeight(maxContentWidth: Int): Int { - var result = getIntrinsicContentHeight(maxContentWidth) - result += 2 * vPadding - return result + // The way all this works is that we just calculate the total height the quote view should be + // and then center everything inside vertically. This effectively means we're applying padding. + // Applying padding the regular way results in a clipping issue though due to a bug in + // RelativeLayout. + return getIntrinsicContentHeight(maxContentWidth) + 2 * vPadding } // endregion @@ -89,6 +108,10 @@ class QuoteView : LinearLayout { fun bind(authorPublicKey: String, body: String?, attachments: SlideDeck?, thread: Recipient, isOutgoingMessage: Boolean, maxContentWidth: Int, isOpenGroupInvitation: Boolean) { 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 + // to get too big. + quoteViewBodyTextView.maxLines = if (thread.isGroupRecipient) 2 else 3 // Author if (thread.isGroupRecipient) { val author = contactDB.getContactWithSessionID(authorPublicKey) @@ -106,7 +129,7 @@ class QuoteView : LinearLayout { quoteViewAttachmentPreviewContainer.isVisible = hasAttachments if (!hasAttachments) { val accentLineLayoutParams = quoteViewAccentLine.layoutParams as RelativeLayout.LayoutParams - accentLineLayoutParams.height = getIntrinsicContentHeight(maxContentWidth) + accentLineLayoutParams.height = getIntrinsicContentHeight(maxContentWidth) // Match the intrinsic * content * height quoteViewAccentLine.layoutParams = accentLineLayoutParams quoteViewAccentLine.setBackgroundColor(getLineColor(isOutgoingMessage)) } else { @@ -127,6 +150,7 @@ class QuoteView : LinearLayout { } mainQuoteViewContainer.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, getIntrinsicHeight(maxContentWidth)) val quoteViewMainContentContainerLayoutParams = quoteViewMainContentContainer.layoutParams as RelativeLayout.LayoutParams + // The start margin is different if we just show the accent line vs if we show an attachment thumbnail quoteViewMainContentContainerLayoutParams.marginStart = if (!hasAttachments) toPx(16, resources) else toPx(48, resources) quoteViewMainContentContainer.layoutParams = quoteViewMainContentContainerLayoutParams } 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 fb4b6e6b3c..b085171a0d 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 @@ -1,8 +1,11 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context +import android.content.res.ColorStateList import android.graphics.drawable.Drawable +import android.text.util.Linkify import android.util.AttributeSet +import android.util.Log import android.util.TypedValue import android.view.LayoutInflater import android.widget.LinearLayout @@ -18,6 +21,7 @@ import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.messaging.utilities.UpdateMessageData.Companion.fromJSON import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.ViewUtil +import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.loki.utilities.UiMode @@ -43,7 +47,8 @@ class VisibleMessageContentView : LinearLayout { // endregion // region Updating - fun bind(message: MessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean, glide: GlideRequests, maxWidth: Int) { + fun bind(message: MessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean, + glide: GlideRequests, maxWidth: Int, thread: Recipient) { // Background val background = getBackground(message.isOutgoing, isStartOfMessageCluster, isEndOfMessageCluster) val colorID = if (message.isOutgoing) R.attr.message_sent_background_color else R.attr.message_received_background_color @@ -62,8 +67,11 @@ class VisibleMessageContentView : LinearLayout { } else if (message is MmsMessageRecord && message.quote != null) { val quote = message.quote!! val quoteView = QuoteView(context, QuoteView.Mode.Regular) + // The max content width is the max message bubble size - 2 times the horizontal padding - the + // quote view content area's start margin. This unfortunately has to be calculated manually + // 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, message.recipient, + quoteView.bind(quote.author.toString(), quote.text, quote.attachment, thread, message.isOutgoing, maxContentWidth, message.isOpenGroupInvitation) mainContainer.addView(quoteView) val bodyTextView = VisibleMessageContentView.getBodyTextView(context, message) @@ -73,6 +81,8 @@ class VisibleMessageContentView : LinearLayout { val voiceMessageView = VoiceMessageView(context) voiceMessageView.bind(message, background) 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. onContentClick = { voiceMessageView.togglePlayback() } } else if (message is MmsMessageRecord && message.slideDeck.documentSlide != null) { val documentView = DocumentView(context) @@ -124,6 +134,8 @@ class VisibleMessageContentView : LinearLayout { result.setTextSize(TypedValue.COMPLEX_UNIT_PX, context.resources.getDimension(R.dimen.small_font_size)) val color = getTextColor(context, message) result.setTextColor(color) + result.setLinkTextColor(color) + Linkify.addLinks(result, Linkify.WEB_URLS) return result } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 4b5be84221..3d665c5cbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -79,15 +79,14 @@ class VisibleMessageView : LinearLayout { val senderSessionID = sender.address.serialize() val threadID = message.threadId val threadDB = DatabaseFactory.getThreadDatabase(context) - val thread = threadDB.getRecipientForThreadId(threadID) + val thread = threadDB.getRecipientForThreadId(threadID)!! val contactDB = DatabaseFactory.getSessionContactDatabase(context) - val isGroupThread = (thread?.isGroupRecipient == true) + val isGroupThread = thread.isGroupRecipient val isStartOfMessageCluster = isStartOfMessageCluster(message, previous, isGroupThread) val isEndOfMessageCluster = isEndOfMessageCluster(message, next, isGroupThread) // Show profile picture and sender name if this is a group thread AND // the message is incoming if (isGroupThread && !message.isOutgoing) { - thread!! profilePictureContainer.visibility = if (isEndOfMessageCluster) View.VISIBLE else View.INVISIBLE profilePictureView.publicKey = senderSessionID profilePictureView.glide = glide @@ -139,12 +138,11 @@ class VisibleMessageView : LinearLayout { } else { messageStatusImageView.isVisible = false } - // Populate content view + // Calculate max message bubble width var maxWidth = screenWidth - messageContentContainerLayoutParams.leftMargin - messageContentContainerLayoutParams.rightMargin - if (profilePictureContainer.visibility != View.GONE) { - maxWidth -= profilePictureContainer.width - } - messageContentView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster, glide, maxWidth) + if (profilePictureContainer.visibility != View.GONE) { maxWidth -= profilePictureContainer.width } + // Populate content view + messageContentView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster, glide, maxWidth, thread) } private fun setMessageSpacing(isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean) {