Add new text-only conversation item.

This commit is contained in:
Alex Hart
2023-06-29 13:18:33 -03:00
committed by Greyson Parrelli
parent e6cc789c6f
commit 53e62f2be0
21 changed files with 1485 additions and 111 deletions

View File

@@ -59,6 +59,10 @@ class ConversationElementGenerator {
return MessageTypes.BASE_SENT_TYPE or MessageTypes.SECURE_MESSAGE_BIT
}
private fun getSentFailedOutgoingType(): Long {
return MessageTypes.BASE_SENT_FAILED_TYPE or MessageTypes.SECURE_MESSAGE_BIT
}
private fun generateMessage(key: ConversationElementKey): MappingModel<*> {
val messageId = key.requireMessageId()
val now = getNow()
@@ -82,7 +86,7 @@ class ConversationElementGenerator {
1,
testMessage,
SlideDeck(),
if (isIncoming) getIncomingType() else getSentOutgoingType(),
if (isIncoming) getIncomingType() else getSentFailedOutgoingType(),
emptySet(),
emptySet(),
0,

View File

@@ -64,7 +64,8 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
glideRequests = GlideApp.with(this),
clickListener = ClickListener(),
hasWallpaper = springboardViewModel.hasWallpaper.value,
colorizer = Colorizer()
colorizer = Colorizer(),
startExpirationTimeout = {}
)
if (springboardViewModel.hasWallpaper.value) {

View File

@@ -60,6 +60,7 @@ import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import androidx.core.text.util.LinkifyCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Stream;
import com.google.android.exoplayer2.MediaItem;
@@ -98,6 +99,7 @@ import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectCollection;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.conversation.ui.payment.PaymentMessageView;
import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.MediaTable;
import org.thoughtcrime.securesms.database.MessageTable;
@@ -172,7 +174,8 @@ import kotlin.jvm.functions.Function1;
public final class ConversationItem extends RelativeLayout implements BindableConversationItem,
RecipientForeverObserver,
OpenableGift
OpenableGift,
InteractiveConversationElement
{
private static final String TAG = Log.tag(ConversationItem.class);
@@ -2114,6 +2117,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
return getSnapshotProjections(coordinateRoot, true, true);
}
@Override
public @NonNull ProjectionList getSnapshotProjections(@NonNull ViewGroup coordinateRoot, boolean clipOutMedia) {
return getSnapshotProjections(coordinateRoot, clipOutMedia, true);
}
@@ -2262,6 +2266,45 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
return AnimationSign.get(ViewUtil.isLtr(this), messageRecord.isOutgoing());
}
@Override
public @Nullable View getQuotedIndicatorView() {
return quotedIndicator;
}
@Override
public @NonNull View getReplyView() {
return reply;
}
@Override
public @Nullable View getContactPhotoHolderView() {
return contactPhotoHolder;
}
@Override
public @Nullable View getBadgeImageView() {
return badgeImageView;
}
@NonNull @Override public List<View> getBubbleViews() {
return Collections.singletonList(bodyBubble);
}
@Override
public int getAdapterPosition(@NonNull RecyclerView recyclerView) {
return recyclerView.getChildAdapterPosition(this);
}
@Override
public @NonNull ViewGroup getRoot() {
return this;
}
@Override
public @NonNull View getBubbleView() {
return bodyBubble;
}
private class SharedContactEventListener implements SharedContactView.EventListener {
@Override
public void onAddToContactsClicked(@NonNull Contact contact) {

View File

@@ -10,54 +10,58 @@ import androidx.core.graphics.withTranslation
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.hasNoBubble
object ConversationItemSelection {
@JvmStatic
fun snapshotView(
conversationItem: ConversationItem,
target: InteractiveConversationElement,
list: RecyclerView,
messageRecord: MessageRecord,
videoBitmap: Bitmap?
): Bitmap {
val isOutgoing = messageRecord.isOutgoing
val hasNoBubble = messageRecord.hasNoBubble(conversationItem.context)
val hasNoBubble = messageRecord.hasNoBubble(list.context)
return snapshotMessage(
conversationItem = conversationItem,
target = target,
list = list,
videoBitmap = videoBitmap,
drawConversationItem = !isOutgoing || hasNoBubble,
drawConversationItem = !SignalStore.internalValues().useConversationFragmentV2() && (!isOutgoing || hasNoBubble),
hasReaction = messageRecord.reactions.isNotEmpty()
)
}
private fun snapshotMessage(
conversationItem: ConversationItem,
target: InteractiveConversationElement,
list: RecyclerView,
videoBitmap: Bitmap?,
drawConversationItem: Boolean,
hasReaction: Boolean
): Bitmap {
val bodyBubble = conversationItem.bodyBubble
val reactionsView = conversationItem.reactionsView
val bodyBubble = target.bubbleView
val reactionsView = target.reactionsView
val originalScale = bodyBubble.scaleX
bodyBubble.scaleX = 1.0f
bodyBubble.scaleY = 1.0f
val projections = conversationItem.getSnapshotProjections(list, false)
val projections = target.getSnapshotProjections(list, false)
val path = Path()
val xTranslation = -conversationItem.x - bodyBubble.x
val yTranslation = -conversationItem.y - bodyBubble.y
val xTranslation = -target.root.x - bodyBubble.x
val yTranslation = -target.root.y - bodyBubble.y
val mp4Projection = conversationItem.getGiphyMp4PlayableProjection(list)
var scaledVideoBitmap = videoBitmap
if (videoBitmap != null) {
val mp4Projection = (target as? GiphyMp4Playable)?.getGiphyMp4PlayableProjection(list)
var scaledVideoBitmap: Bitmap? = null
if (videoBitmap != null && mp4Projection != null) {
scaledVideoBitmap = Bitmap.createScaledBitmap(
videoBitmap,
(videoBitmap.width / originalScale).toInt(),
@@ -78,7 +82,7 @@ object ConversationItemSelection {
}
}
conversationItem.destroyAllDrawingCaches()
target.root.destroyAllDrawingCaches()
var bitmapHeight = bodyBubble.height
if (hasReaction) {
@@ -93,7 +97,7 @@ object ConversationItemSelection {
withTranslation(x = xTranslation, y = yTranslation) {
list.draw(this)
if (scaledVideoBitmap != null) {
if (scaledVideoBitmap != null && mp4Projection != null) {
drawBitmap(scaledVideoBitmap, mp4Projection.x - xTranslation, mp4Projection.y - yTranslation, null)
}
}
@@ -106,7 +110,7 @@ object ConversationItemSelection {
reactionsView.draw(this)
}
}.also {
mp4Projection.release()
mp4Projection?.release()
bodyBubble.scaleX = originalScale
bodyBubble.scaleY = originalScale
}

View File

@@ -8,12 +8,16 @@ import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement;
import org.thoughtcrime.securesms.util.AccessibilityUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
import java.util.Objects;
public class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
private static float SWIPE_SUCCESS_DX = ConversationSwipeAnimationHelper.TRIGGER_DX;
@@ -87,14 +91,14 @@ public class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallbac
boolean isCorrectSwipeDir = sameSign(dx, sign);
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && isCorrectSwipeDir) {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, Math.abs(dx), sign);
ConversationSwipeAnimationHelper.update(requireInteractiveConversationElement(viewHolder), Math.abs(dx), sign);
recyclerView.invalidate();
handleSwipeFeedback((ConversationItem) viewHolder.itemView, Math.abs(dx));
handleSwipeFeedback(recyclerView.getContext(), requireInteractiveConversationElement(viewHolder), Math.abs(dx));
if (canTriggerSwipe) {
setTouchListener(recyclerView, viewHolder, Math.abs(dx));
}
} else if (actionState == ItemTouchHelper.ACTION_STATE_IDLE || dx == 0) {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, 0, 1);
ConversationSwipeAnimationHelper.update(requireInteractiveConversationElement(viewHolder), 0, 1);
recyclerView.invalidate();
}
@@ -104,10 +108,10 @@ public class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallbac
}
}
private void handleSwipeFeedback(@NonNull ConversationItem item, float dx) {
private void handleSwipeFeedback(@NonNull Context context, @NonNull InteractiveConversationElement interactiveConversationElement, float dx) {
if (dx > SWIPE_SUCCESS_DX && shouldTriggerSwipeFeedback) {
vibrate(item.getContext());
ConversationSwipeAnimationHelper.trigger(item);
vibrate(context);
ConversationSwipeAnimationHelper.trigger(interactiveConversationElement);
shouldTriggerSwipeFeedback = false;
}
}
@@ -115,10 +119,9 @@ public class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallbac
private void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder) {
if (cannotSwipeViewHolder(viewHolder)) return;
ConversationItem item = ((ConversationItem) viewHolder.itemView);
ConversationMessage messageRecord = item.getConversationMessage();
InteractiveConversationElement element = requireInteractiveConversationElement(viewHolder);
onSwipeListener.onSwipe(messageRecord);
onSwipeListener.onSwipe(element.getConversationMessage());
}
@SuppressLint("ClickableViewAccessibility")
@@ -160,19 +163,35 @@ public class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallbac
private void resetProgressIfAnimationsDisabled(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
if (AccessibilityUtil.areAnimationsDisabled(viewHolder.itemView.getContext())) {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView,
ConversationSwipeAnimationHelper.update(requireInteractiveConversationElement(viewHolder),
0f,
getSignFromDirection(viewHolder.itemView));
recyclerView.invalidate();
}
}
private boolean cannotSwipeViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
if (!(viewHolder.itemView instanceof ConversationItem)) return true;
private @NonNull InteractiveConversationElement requireInteractiveConversationElement(@NonNull RecyclerView.ViewHolder viewHolder) {
return Objects.requireNonNull(getInteractiveConversationElement(viewHolder));
}
ConversationItem item = ((ConversationItem) viewHolder.itemView);
return !swipeAvailabilityProvider.isSwipeAvailable(item.getConversationMessage()) ||
item.disallowSwipe(latestDownX, latestDownY);
private @Nullable InteractiveConversationElement getInteractiveConversationElement(@NonNull RecyclerView.ViewHolder viewHolder) {
if (viewHolder instanceof InteractiveConversationElement) {
return (InteractiveConversationElement) viewHolder;
} else if (viewHolder.itemView instanceof InteractiveConversationElement) {
return (InteractiveConversationElement) viewHolder.itemView;
} else {
return null;
}
}
private boolean cannotSwipeViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
InteractiveConversationElement element = getInteractiveConversationElement(viewHolder);
if (element == null) {
return true;
}
return !swipeAvailabilityProvider.isSwipeAvailable(element.getConversationMessage()) ||
element.disallowSwipe(latestDownX, latestDownY);
}
private void updateLatestDownCoordinate(float x, float y) {

View File

@@ -8,8 +8,11 @@ import android.view.animation.Interpolator;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement;
import org.thoughtcrime.securesms.util.Util;
import java.util.List;
final class ConversationSwipeAnimationHelper {
static final float TRIGGER_DX = dpToPx(64);
@@ -30,23 +33,25 @@ final class ConversationSwipeAnimationHelper {
private ConversationSwipeAnimationHelper() {
}
public static void update(@NonNull ConversationItem conversationItem, float dx, float sign) {
public static void update(@NonNull InteractiveConversationElement interactiveConversationElement, float dx, float sign) {
float progress = dx / TRIGGER_DX;
updateBodyBubbleTransition(conversationItem.bodyBubble, dx, sign);
updateReactionsTransition(conversationItem.reactionsView, dx, sign);
updateQuotedIndicatorTransition(conversationItem.quotedIndicator, dx, progress, sign);
updateReplyIconTransition(conversationItem.reply, dx, progress, sign);
updateContactPhotoHolderTransition(conversationItem.contactPhotoHolder, progress, sign);
updateContactPhotoHolderTransition(conversationItem.badgeImageView, progress, sign);
updateBodyBubbleTransition(interactiveConversationElement.getBubbleViews(), dx, sign);
updateReactionsTransition(interactiveConversationElement.getReactionsView(), dx, sign);
updateQuotedIndicatorTransition(interactiveConversationElement.getQuotedIndicatorView(), dx, progress, sign);
updateReplyIconTransition(interactiveConversationElement.getReplyView(), dx, progress, sign);
updateContactPhotoHolderTransition(interactiveConversationElement.getContactPhotoHolderView(), progress, sign);
updateContactPhotoHolderTransition(interactiveConversationElement.getBadgeImageView(), progress, sign);
}
public static void trigger(@NonNull ConversationItem conversationItem) {
triggerReplyIcon(conversationItem.reply);
public static void trigger(@NonNull InteractiveConversationElement interactiveConversationElement) {
triggerReplyIcon(interactiveConversationElement.getReplyView());
}
private static void updateBodyBubbleTransition(@NonNull View bodyBubble, float dx, float sign) {
bodyBubble.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign);
private static void updateBodyBubbleTransition(@NonNull List<View> bubbleViews, float dx, float sign) {
for (View view : bubbleViews) {
view.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign);
}
}
private static void updateReactionsTransition(@NonNull View reactionsContainer, float dx, float sign) {

View File

@@ -16,7 +16,7 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.toOptional
import org.thoughtcrime.securesms.BindableConversationItem
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener
import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge
import org.thoughtcrime.securesms.conversation.ConversationHeaderView
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
@@ -32,7 +32,12 @@ import org.thoughtcrime.securesms.conversation.v2.data.IncomingTextOnly
import org.thoughtcrime.securesms.conversation.v2.data.OutgoingMedia
import org.thoughtcrime.securesms.conversation.v2.data.OutgoingTextOnly
import org.thoughtcrime.securesms.conversation.v2.data.ThreadHeader
import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationContext
import org.thoughtcrime.securesms.conversation.v2.items.V2TextOnlyViewHolder
import org.thoughtcrime.securesms.conversation.v2.items.bridge
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyIncomingBinding
import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyOutgoingBinding
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer
import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil
@@ -52,10 +57,11 @@ import java.util.Optional
class ConversationAdapterV2(
private val lifecycleOwner: LifecycleOwner,
private val glideRequests: GlideRequests,
private val clickListener: ConversationAdapter.ItemClickListener,
override val clickListener: ItemClickListener,
private var hasWallpaper: Boolean,
private val colorizer: Colorizer
) : PagingMappingAdapter<ConversationElementKey>(), ConversationAdapterBridge {
private val colorizer: Colorizer,
private val startExpirationTimeout: (MessageRecord) -> Unit
) : PagingMappingAdapter<ConversationElementKey>(), ConversationAdapterBridge, V2ConversationContext {
companion object {
private val TAG = Log.tag(ConversationAdapterV2::class.java)
@@ -83,8 +89,8 @@ class ConversationAdapterV2(
}
registerFactory(OutgoingTextOnly::class.java) { parent ->
val view = CachedInflater.from(parent.context).inflate<View>(R.layout.conversation_item_sent_text_only, parent, false)
OutgoingTextOnlyViewHolder(view)
val view = CachedInflater.from(parent.context).inflate<View>(R.layout.v2_conversation_item_text_only_outgoing, parent, false)
V2TextOnlyViewHolder(V2ConversationItemTextOnlyOutgoingBinding.bind(view).bridge(), this)
}
registerFactory(OutgoingMedia::class.java) { parent ->
@@ -93,8 +99,8 @@ class ConversationAdapterV2(
}
registerFactory(IncomingTextOnly::class.java) { parent ->
val view = CachedInflater.from(parent.context).inflate<View>(R.layout.conversation_item_received_text_only, parent, false)
IncomingTextOnlyViewHolder(view)
val view = CachedInflater.from(parent.context).inflate<View>(R.layout.v2_conversation_item_text_only_incoming, parent, false)
V2TextOnlyViewHolder(V2ConversationItemTextOnlyIncomingBinding.bind(view).bridge(), this)
}
registerFactory(IncomingMedia::class.java) { parent ->
@@ -123,6 +129,24 @@ class ConversationAdapterV2(
}
}
}
override val displayMode: ConversationItemDisplayMode
get() = condensedMode ?: ConversationItemDisplayMode.STANDARD
override fun onStartExpirationTimeout(messageRecord: MessageRecord) {
startExpirationTimeout(messageRecord)
}
override fun hasWallpaper(): Boolean = hasWallpaper && displayMode.displayWallpaper()
override fun getColorizer(): Colorizer = colorizer
override fun getNextMessage(adapterPosition: Int): MessageRecord? {
return getConversationMessage(adapterPosition - 1)?.messageRecord
}
override fun getPreviousMessage(adapterPosition: Int): MessageRecord? {
return getConversationMessage(adapterPosition + 1)?.messageRecord
}
fun updateSearchQuery(searchQuery: String?) {
this.searchQuery = searchQuery
@@ -245,29 +269,6 @@ class ConversationAdapterV2(
}
}
private inner class OutgoingTextOnlyViewHolder(itemView: View) : ConversationViewHolder<OutgoingTextOnly>(itemView) {
override fun bind(model: OutgoingTextOnly) {
bindable.setEventListener(clickListener)
bindable.bind(
lifecycleOwner,
model.conversationMessage,
previousMessage,
nextMessage,
glideRequests,
Locale.getDefault(),
_selected,
model.conversationMessage.threadRecipient,
searchQuery,
false,
hasWallpaper && displayMode.displayWallpaper(),
true, // isMessageRequestAccepted,
model.conversationMessage == inlineContent,
colorizer,
displayMode
)
}
}
private inner class OutgoingMediaViewHolder(itemView: View) : ConversationViewHolder<OutgoingMedia>(itemView) {
override fun bind(model: OutgoingMedia) {
bindable.setEventListener(clickListener)
@@ -291,29 +292,6 @@ class ConversationAdapterV2(
}
}
private inner class IncomingTextOnlyViewHolder(itemView: View) : ConversationViewHolder<IncomingTextOnly>(itemView) {
override fun bind(model: IncomingTextOnly) {
bindable.setEventListener(clickListener)
bindable.bind(
lifecycleOwner,
model.conversationMessage,
previousMessage,
nextMessage,
glideRequests,
Locale.getDefault(),
_selected,
model.conversationMessage.threadRecipient,
searchQuery,
false,
hasWallpaper && displayMode.displayWallpaper(),
true, // isMessageRequestAccepted,
model.conversationMessage == inlineContent,
colorizer,
displayMode
)
}
}
private inner class IncomingMediaViewHolder(itemView: View) : ConversationViewHolder<IncomingMedia>(itemView) {
override fun bind(model: IncomingMedia) {
bindable.setEventListener(clickListener)

View File

@@ -170,6 +170,7 @@ import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryResults
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryViewModelV2
import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupCallViewModel
import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupViewModel
import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement
import org.thoughtcrime.securesms.conversation.v2.keyboard.AttachmentKeyboardFragment
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
@@ -1117,7 +1118,8 @@ class ConversationFragment :
glideRequests = GlideApp.with(this),
clickListener = ConversationItemClickListener(),
hasWallpaper = args.wallpaper != null,
colorizer = colorizer
colorizer = colorizer,
startExpirationTimeout = viewModel::startExpirationTimeout
)
scrollToPositionDelegate = ScrollToPositionDelegate(
@@ -2198,13 +2200,24 @@ class ConversationFragment :
binding.reactionsShade.visibility = View.VISIBLE
binding.conversationItemRecycler.suppressLayout(true)
if (itemView is ConversationItem) {
val target: InteractiveConversationElement? = if (itemView is InteractiveConversationElement) {
itemView
} else {
val viewHolder = binding.conversationItemRecycler.getChildViewHolder(itemView)
if (viewHolder is InteractiveConversationElement) {
viewHolder
} else {
null
}
}
if (target != null) {
val audioUri = messageRecord.getAudioUriForLongClick()
if (audioUri != null) {
getVoiceNoteMediaController().pausePlayback(audioUri)
}
val childAdapterPosition = binding.conversationItemRecycler.getChildAdapterPosition(itemView)
val childAdapterPosition = target.getAdapterPosition(binding.conversationItemRecycler)
var mp4Holder: GiphyMp4ProjectionPlayerHolder? = null
var videoBitmap: Bitmap? = null
if (childAdapterPosition != RecyclerView.NO_POSITION) {
@@ -2216,10 +2229,10 @@ class ConversationFragment :
}
}
val snapshot = ConversationItemSelection.snapshotView(itemView, binding.conversationItemRecycler, messageRecord, videoBitmap)
val snapshot = ConversationItemSelection.snapshotView(target, binding.conversationItemRecycler, messageRecord, videoBitmap)
val focusedView = if (container.isInputShowing) null else itemView.rootView.findFocus()
val bodyBubble = itemView.bodyBubble!!
val bodyBubble = target.bubbleView
val selectedConversationModel = SelectedConversationModel(
snapshot,
itemView.x,
@@ -2233,11 +2246,11 @@ class ConversationFragment :
)
bodyBubble.visibility = View.INVISIBLE
itemView.reactionsView?.visibility = View.INVISIBLE
target.reactionsView.visibility = View.INVISIBLE
val quotedIndicatorVisible = itemView.quotedIndicator?.visibility == View.VISIBLE
val quotedIndicatorVisible = target.quotedIndicatorView?.visibility == View.VISIBLE
if (quotedIndicatorVisible) {
ViewUtil.fadeOut(itemView.quotedIndicator!!, 150, View.INVISIBLE)
ViewUtil.fadeOut(target.quotedIndicatorView!!, 150, View.INVISIBLE)
}
ViewUtil.hideKeyboard(requireContext(), itemView)
@@ -2247,7 +2260,7 @@ class ConversationFragment :
viewModel.setShowScrollButtons(false)
}
val conversationItem: ConversationItem = itemView
val targetViews: InteractiveConversationElement = target
handleReaction(
item.conversationMessage,
ReactionsToolbarListener(item.conversationMessage),
@@ -2277,10 +2290,10 @@ class ConversationFragment :
}
bodyBubble.visibility = View.VISIBLE
conversationItem.reactionsView?.visibility = View.VISIBLE
targetViews.reactionsView.visibility = View.VISIBLE
if (quotedIndicatorVisible && conversationItem.quotedIndicator != null) {
ViewUtil.fadeIn(conversationItem.quotedIndicator!!, 150)
if (quotedIndicatorVisible && targetViews.quotedIndicatorView != null) {
ViewUtil.fadeIn(targetViews.quotedIndicatorView!!, 150)
}
if (showScrollButtons) {

View File

@@ -560,6 +560,15 @@ class ConversationRepository(
}
}
fun startExpirationTimeout(messageRecord: MessageRecord) {
SignalExecutors.BOUNDED_IO.execute {
val now = System.currentTimeMillis()
SignalDatabase.messages.markExpireStarted(messageRecord.id, now)
ApplicationDependencies.getExpiringMessageManager().scheduleDeletion(messageRecord.id, messageRecord.isMms, now, messageRecord.expiresIn)
}
}
/**
* Glide target for a contact photo which expects an error drawable, and publishes
* the result to the given emitter.

View File

@@ -266,6 +266,10 @@ class ConversationViewModel(
}
}
fun startExpirationTimeout(messageRecord: MessageRecord) {
repository.startExpirationTimeout(messageRecord)
}
fun updateReaction(messageRecord: MessageRecord, emoji: String): Completable {
val oldRecord = messageRecord.oldReactionRecord()

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2.items
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.util.ProjectionList
/**
* A conversation element that a user can either swipe or snapshot
*/
interface InteractiveConversationElement {
val conversationMessage: ConversationMessage
val root: ViewGroup
val bubbleView: View
val bubbleViews: List<View>
val reactionsView: View
val quotedIndicatorView: View?
val replyView: View
val contactPhotoHolderView: View?
val badgeImageView: View?
/**
* Whether or not the given element is swipeable
*/
fun disallowSwipe(latestDownX: Float, latestDownY: Float): Boolean
/**
* Gets the adapter position for this element. Since this element can either be a ConversationItem or a
* ViewHolder, we require a delegate method.
*/
fun getAdapterPosition(recyclerView: RecyclerView): Int
/**
* Note: Since we always clip out the view we want to display, we can ignore corners when providing this
* projection list. This will prevent artifacts when we draw the bitmap.
*/
fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean): ProjectionList
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2.items
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.database.model.MessageRecord
/**
* Describes the Adapter "context" that would normally have been
* visible to an inner class.
*/
interface V2ConversationContext {
val displayMode: ConversationItemDisplayMode
val clickListener: ConversationAdapter.ItemClickListener
fun onStartExpirationTimeout(messageRecord: MessageRecord)
fun hasWallpaper(): Boolean
fun getColorizer(): Colorizer
fun getNextMessage(adapterPosition: Int): MessageRecord?
fun getPreviousMessage(adapterPosition: Int): MessageRecord?
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2.items
import android.content.Context
import android.util.AttributeSet
import androidx.constraintlayout.widget.ConstraintLayout
/**
* Base Conversation item layout. Gives consistent patterns for manipulating child
* views.
*/
class V2ConversationItemLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : ConstraintLayout(context, attrs) {
private var onMeasureListener: OnMeasureListener? = null
/**
* Set the onMeasureListener to be invoked by this view whenever onMeasure is called.
*/
fun setOnMeasureListener(onMeasureListener: OnMeasureListener?) {
this.onMeasureListener = onMeasureListener
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
onMeasureListener?.onPreMeasure()
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val remeasure = onMeasureListener?.onPostMeasure() ?: false
if (remeasure) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
}
interface OnMeasureListener {
/**
* Allows the view to be manipulated before super.onMeasure is called.
*/
fun onPreMeasure()
/**
* Custom onMeasure implementation. Use this to position views and set padding
* *after* an initial measure pass, and optionally invoke an additional measure pass.
*
* @return true if super.onMeasure should be called again, false otherwise.
*/
fun onPostMeasure(): Boolean
}
}

View File

@@ -0,0 +1,180 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2.items
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import org.signal.core.util.dp
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Projection
import org.thoughtcrime.securesms.util.isScheduled
import java.util.concurrent.TimeUnit
import kotlin.math.abs
/**
* Determines the shape for a conversation item based off of the appearance context
* and message data.
*/
class V2ConversationItemShape(
private val conversationContext: V2ConversationContext
) {
companion object {
private var bigRadius: Float = 18f.dp
private var smallRadius: Float = 4f.dp
private var collapsedSpacing: Float = 1f.dp
private var defaultSpacing: Float = 8f.dp
}
var corners: Projection.Corners = Projection.Corners(bigRadius)
private set
var bodyBubble: MaterialShapeDrawable = MaterialShapeDrawable(
ShapeAppearanceModel.Builder().setAllCornerSizes(bigRadius).build()
)
private set
var spacing: Pair<Float, Float> = Pair(defaultSpacing, defaultSpacing)
private set
/**
* Sets the message spacing and corners based off the given information. This
* updates the class state.
*/
fun setMessageShape(
isLtr: Boolean,
conversationMessage: ConversationMessage,
adapterPosition: Int
): MessageShape {
val currentMessage: MessageRecord = conversationMessage.messageRecord
val nextMessage: MessageRecord? = conversationContext.getNextMessage(adapterPosition)
val previousMessage: MessageRecord? = conversationContext.getPreviousMessage(adapterPosition)
val isGroupThread: Boolean = conversationMessage.threadRecipient.isGroup
if (isSingularMessage(currentMessage, previousMessage, nextMessage, isGroupThread)) {
setBodyBubbleCorners(isLtr, bigRadius, bigRadius, bigRadius, bigRadius)
spacing = Pair(defaultSpacing, defaultSpacing)
return MessageShape.SINGLE
} else if (isStartOfMessageCluster(currentMessage, previousMessage, isGroupThread)) {
val bottomEnd = if (currentMessage.isOutgoing) smallRadius else bigRadius
val bottomStart = if (currentMessage.isOutgoing) bigRadius else smallRadius
setBodyBubbleCorners(isLtr, bigRadius, bigRadius, bottomEnd, bottomStart)
spacing = Pair(defaultSpacing, collapsedSpacing)
return MessageShape.START
} else if (isEndOfMessageCluster(currentMessage, nextMessage)) {
val topStart = if (currentMessage.isOutgoing) bigRadius else smallRadius
val topEnd = if (currentMessage.isOutgoing) smallRadius else bigRadius
setBodyBubbleCorners(isLtr, topStart, topEnd, bigRadius, bigRadius)
spacing = Pair(collapsedSpacing, defaultSpacing)
return MessageShape.END
} else {
val start = if (currentMessage.isOutgoing) bigRadius else smallRadius
val end = if (currentMessage.isOutgoing) smallRadius else bigRadius
setBodyBubbleCorners(isLtr, start, end, end, start)
spacing = Pair(collapsedSpacing, collapsedSpacing)
return MessageShape.MIDDLE
}
}
private fun setBodyBubbleCorners(
isLtr: Boolean,
topStart: Float,
topEnd: Float,
bottomEnd: Float,
bottomStart: Float
) {
val newCorners = Projection.Corners(
if (isLtr) topStart else topEnd,
if (isLtr) topEnd else topStart,
if (isLtr) bottomEnd else bottomStart,
if (isLtr) bottomStart else bottomEnd
)
if (corners == newCorners) {
return
}
corners = newCorners
bodyBubble.shapeAppearanceModel = ShapeAppearanceModel.builder()
.setTopLeftCornerSize(corners.topLeft)
.setTopRightCornerSize(corners.topRight)
.setBottomLeftCornerSize(corners.bottomLeft)
.setBottomRightCornerSize(corners.bottomRight)
.build()
}
private fun isSingularMessage(
currentMessage: MessageRecord,
previousMessage: MessageRecord?,
nextMessage: MessageRecord?,
isGroupThread: Boolean
): Boolean {
return isStartOfMessageCluster(currentMessage, previousMessage, isGroupThread) && isEndOfMessageCluster(currentMessage, nextMessage)
}
private fun isStartOfMessageCluster(
currentMessage: MessageRecord,
previousMessage: MessageRecord?,
isGroupThread: Boolean
): Boolean {
if (previousMessage == null ||
previousMessage.isUpdate ||
!DateUtils.isSameDay(currentMessage.timestamp, previousMessage.timestamp) ||
!isWithinClusteringTime(currentMessage, previousMessage) ||
currentMessage.isScheduled() ||
currentMessage.fromRecipient != previousMessage.fromRecipient
) {
return true
}
return isGroupThread || currentMessage.isSecure != previousMessage.isSecure
}
private fun isEndOfMessageCluster(
currentMessage: MessageRecord,
nextMessage: MessageRecord?
): Boolean {
if (nextMessage == null ||
nextMessage.isUpdate ||
!DateUtils.isSameDay(currentMessage.timestamp, nextMessage.timestamp) ||
!isWithinClusteringTime(currentMessage, nextMessage) ||
currentMessage.isScheduled() ||
currentMessage.reactions.isNotEmpty()
) {
return true
}
return currentMessage.fromRecipient != nextMessage.fromRecipient
}
private fun isWithinClusteringTime(currentMessage: MessageRecord, previousMessage: MessageRecord): Boolean {
return abs(currentMessage.dateSent - previousMessage.dateSent) <= TimeUnit.MINUTES.toMillis(3)
}
enum class MessageShape {
/**
* This message stands alone.
*/
SINGLE,
/**
* This message is the start of a cluster
*/
START,
/**
* This message is the end of a cluster
*/
END,
/**
* This message is in the middle of a cluster
*/
MIDDLE
}
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2.items
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import com.google.android.material.imageview.ShapeableImageView
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AlertView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.DeliveryStatusView
import org.thoughtcrime.securesms.components.ExpirationTimerView
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyIncomingBinding
import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyOutgoingBinding
import org.thoughtcrime.securesms.reactions.ReactionsConversationView
/**
* Pass-through interface for bridging incoming and outgoing text-only message views.
*
* Essentially, just a convenience wrapper since the layouts differ *very slightly* and
* we want to be able to have each follow the same code-path.
*/
data class V2ConversationItemTextOnlyBindingBridge(
val root: V2ConversationItemLayout,
val senderName: EmojiTextView?,
val senderPhoto: AvatarImageView?,
val senderBadge: BadgeImageView?,
val conversationItemBodyWrapper: ViewGroup,
val conversationItemBody: EmojiTextView,
val conversationItemReply: ShapeableImageView,
val conversationItemReactions: ReactionsConversationView,
val conversationItemDeliveryStatus: DeliveryStatusView?,
val conversationItemFooterDate: TextView,
val conversationItemFooterExpiry: ExpirationTimerView,
val conversationItemFooterBackground: View,
val conversationItemAlert: AlertView?,
val isIncoming: Boolean
)
/**
* Wraps the binding in the bridge.
*/
fun V2ConversationItemTextOnlyIncomingBinding.bridge(): V2ConversationItemTextOnlyBindingBridge {
return V2ConversationItemTextOnlyBindingBridge(
root = root,
senderName = groupMessageSender,
senderPhoto = contactPhoto,
senderBadge = badge,
conversationItemBody = conversationItemBody,
conversationItemBodyWrapper = conversationItemBodyWrapper,
conversationItemReply = conversationItemReply,
conversationItemReactions = conversationItemReactions,
conversationItemDeliveryStatus = null,
conversationItemFooterDate = conversationItemFooterDate,
conversationItemFooterExpiry = conversationItemExpirationTimer,
conversationItemFooterBackground = conversationItemFooterBackground,
conversationItemAlert = null,
isIncoming = false
)
}
/**
* Wraps the binding in the bridge.
*/
fun V2ConversationItemTextOnlyOutgoingBinding.bridge(): V2ConversationItemTextOnlyBindingBridge {
return V2ConversationItemTextOnlyBindingBridge(
root = root,
senderName = null,
senderPhoto = null,
senderBadge = null,
conversationItemBody = conversationItemBody,
conversationItemBodyWrapper = conversationItemBodyWrapper,
conversationItemReply = conversationItemReply,
conversationItemReactions = conversationItemReactions,
conversationItemDeliveryStatus = conversationItemDeliveryStatus,
conversationItemFooterDate = conversationItemFooterDate,
conversationItemFooterExpiry = conversationItemExpirationTimer,
conversationItemFooterBackground = conversationItemFooterBackground,
conversationItemAlert = conversationItemAlert,
isIncoming = false
)
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2.items
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.util.hasNoBubble
/**
* Color information for conversation items.
*/
class V2ConversationItemTheme(
private val context: Context,
private val conversationContext: V2ConversationContext
) {
@ColorInt
fun getReplyIconBackgroundColor(): Int {
return if (conversationContext.hasWallpaper()) {
ContextCompat.getColor(context, R.color.signal_colorSurface1)
} else {
Color.TRANSPARENT
}
}
@ColorInt
fun getFooterIconColor(
conversationMessage: ConversationMessage
): Int {
return getColor(
conversationMessage,
conversationContext.getColorizer()::getOutgoingFooterIconColor,
conversationContext.getColorizer()::getIncomingFooterIconColor
)
}
@ColorInt
fun getFooterTextColor(
conversationMessage: ConversationMessage
): Int {
return getColor(
conversationMessage,
conversationContext.getColorizer()::getOutgoingFooterTextColor,
conversationContext.getColorizer()::getIncomingFooterTextColor
)
}
@ColorInt
fun getBodyTextColor(
conversationMessage: ConversationMessage
): Int {
return getColor(
conversationMessage,
conversationContext.getColorizer()::getOutgoingBodyTextColor,
conversationContext.getColorizer()::getIncomingBodyTextColor
)
}
fun getBodyBubbleColor(
conversationMessage: ConversationMessage
): ColorStateList {
if (conversationMessage.messageRecord.hasNoBubble(context)) {
return ColorStateList.valueOf(Color.TRANSPARENT)
}
return getFooterBubbleColor(conversationMessage)
}
fun getFooterBubbleColor(
conversationMessage: ConversationMessage
): ColorStateList {
return ColorStateList.valueOf(
if (conversationMessage.messageRecord.isOutgoing) {
Color.TRANSPARENT
} else {
if (conversationContext.hasWallpaper()) {
ContextCompat.getColor(context, R.color.signal_colorSurface)
} else {
ContextCompat.getColor(context, R.color.signal_colorSurface2)
}
}
)
}
@ColorInt
private fun getColor(
conversationMessage: ConversationMessage,
outgoingColor: (Context) -> Int,
incomingColor: (Context, Boolean) -> Int
): Int {
return if (conversationMessage.messageRecord.isOutgoing) {
outgoingColor(context)
} else {
incomingColor(context, conversationContext.hasWallpaper())
}
}
}

View File

@@ -0,0 +1,349 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2.items
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.Colorizable
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElement
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Projection
import org.thoughtcrime.securesms.util.ProjectionList
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.hasNoBubble
import org.thoughtcrime.securesms.util.isScheduled
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
/**
* Base ViewHolder to share some common properties shared among conversation items.
*/
abstract class V2BaseViewHolder<Model : MappingModel<Model>>(
root: V2ConversationItemLayout,
appearanceInfoProvider: V2ConversationContext
) : MappingViewHolder<Model>(root) {
protected val shapeDelegate = V2ConversationItemShape(appearanceInfoProvider)
protected val themeDelegate = V2ConversationItemTheme(context, appearanceInfoProvider)
}
/**
* Represents a text-only conversation item.
*/
class V2TextOnlyViewHolder<Model : MappingModel<Model>>(
private val binding: V2ConversationItemTextOnlyBindingBridge,
private val conversationContext: V2ConversationContext
) : V2BaseViewHolder<Model>(binding.root, conversationContext), Colorizable, InteractiveConversationElement {
private var messageId: Long = Long.MAX_VALUE
private val projections = ProjectionList()
private val footerDelegate = V2FooterPositionDelegate(binding)
private val conversationItemFooterBackgroundCorners = Projection.Corners(18f.dp)
private val conversationItemFooterBackground = MaterialShapeDrawable(
ShapeAppearanceModel.Builder()
.setAllCornerSizes(18f.dp)
.build()
)
override lateinit var conversationMessage: ConversationMessage
override val root: ViewGroup = binding.root
override val bubbleView: View = binding.conversationItemBodyWrapper
override val bubbleViews: List<View> = listOfNotNull(
binding.conversationItemBodyWrapper,
binding.conversationItemFooterDate,
binding.conversationItemFooterExpiry,
binding.conversationItemDeliveryStatus,
binding.conversationItemFooterBackground
)
override val reactionsView: View = binding.conversationItemReactions
override val quotedIndicatorView: View? = null
override val replyView: View = binding.conversationItemReply
override val contactPhotoHolderView: View? = binding.senderPhoto
override val badgeImageView: View? = binding.senderBadge
init {
binding.root.setOnMeasureListener(footerDelegate)
}
override fun bind(model: Model) {
check(model is ConversationMessageElement)
conversationMessage = model.conversationMessage
itemView.setOnClickListener(null)
itemView.setOnLongClickListener {
conversationContext.clickListener.onItemLongClick(itemView, MultiselectPart.Message(conversationMessage))
true
}
val shape = shapeDelegate.setMessageShape(
itemView.layoutDirection == View.LAYOUT_DIRECTION_LTR,
conversationMessage,
bindingAdapterPosition
)
binding.conversationItemBody.setTextColor(themeDelegate.getBodyTextColor(conversationMessage))
shapeDelegate.bodyBubble.fillColor = themeDelegate.getBodyBubbleColor(conversationMessage)
binding.conversationItemBody.text = conversationMessage.getDisplayBody(context)
binding.conversationItemBodyWrapper.background = shapeDelegate.bodyBubble
binding.conversationItemReply.setBackgroundColor(themeDelegate.getReplyIconBackgroundColor())
presentDate(shape)
presentDeliveryStatus(shape)
presentFooterBackground(shape)
presentFooterExpiry(shape)
presentAlert()
presentSender()
val (topPadding, bottomPadding) = shapeDelegate.spacing
ViewUtil.setPaddingTop(itemView, topPadding.toInt())
ViewUtil.setPaddingBottom(itemView, bottomPadding.toInt())
}
override fun getAdapterPosition(recyclerView: RecyclerView): Int = bindingAdapterPosition
override fun getSnapshotProjections(coordinateRoot: ViewGroup, clipOutMedia: Boolean): ProjectionList {
projections.clear()
projections.add(
Projection.relativeToParent(
coordinateRoot,
binding.conversationItemBodyWrapper,
Projection.Corners.NONE
).translateX(binding.conversationItemBodyWrapper.translationX)
)
return projections
}
override fun getColorizerProjections(coordinateRoot: ViewGroup): ProjectionList {
projections.clear()
if (conversationMessage.messageRecord.isOutgoing) {
if (!conversationMessage.messageRecord.hasNoBubble(context)) {
projections.add(
Projection.relativeToParent(
coordinateRoot,
binding.conversationItemBodyWrapper,
shapeDelegate.corners
).translateX(binding.conversationItemBodyWrapper.translationX)
)
} else if (conversationContext.hasWallpaper()) {
projections.add(
Projection.relativeToParent(
coordinateRoot,
binding.conversationItemFooterBackground,
conversationItemFooterBackgroundCorners
).translateX(binding.conversationItemFooterBackground.translationX)
)
}
}
return projections
}
private fun MessageRecord.buildMessageId(): Long {
return if (isMms) -id else id
}
private fun presentFooterExpiry(shape: V2ConversationItemShape.MessageShape) {
if (shape == V2ConversationItemShape.MessageShape.MIDDLE || shape == V2ConversationItemShape.MessageShape.START) {
binding.conversationItemFooterExpiry.stopAnimation()
binding.conversationItemFooterExpiry.visible = false
return
}
binding.conversationItemFooterExpiry.setColorFilter(themeDelegate.getFooterIconColor(conversationMessage))
val timer = binding.conversationItemFooterExpiry
val record = conversationMessage.messageRecord
if (record.expiresIn > 0 && !record.isPending) {
binding.conversationItemFooterExpiry.visible = true
binding.conversationItemFooterExpiry.setPercentComplete(0f)
if (record.expireStarted > 0) {
timer.setExpirationTime(record.expireStarted, record.expiresIn)
timer.startAnimation()
if (record.expireStarted + record.expiresIn <= System.currentTimeMillis()) {
ApplicationDependencies.getExpiringMessageManager().checkSchedule()
}
} else if (!record.isOutgoing && !record.isMediaPending) {
conversationContext.onStartExpirationTimeout(record)
}
} else {
timer.visible = false
}
}
private fun presentSender() {
if (binding.senderName == null || binding.senderPhoto == null || binding.senderBadge == null) {
return
}
if (conversationMessage.threadRecipient.isGroup) {
val sender = conversationMessage.messageRecord.fromRecipient
binding.senderName.visible = true
binding.senderPhoto.visible = true
binding.senderBadge.visible = true
binding.senderName.text = sender.getDisplayName(context)
binding.senderName.setTextColor(conversationContext.getColorizer().getIncomingGroupSenderColor(context, sender))
binding.senderPhoto.setAvatar(sender)
binding.senderBadge.setBadgeFromRecipient(sender)
} else {
binding.senderName.visible = false
binding.senderPhoto.visible = false
binding.senderBadge.visible = false
}
}
private fun presentAlert() {
val record = conversationMessage.messageRecord
binding.conversationItemBody.setCompoundDrawablesWithIntrinsicBounds(
0,
0,
if (record.isKeyExchange) R.drawable.ic_menu_login else 0,
0
)
val alert = binding.conversationItemAlert ?: return
when {
record.isFailed -> alert.setFailed()
record.isPendingInsecureSmsFallback -> alert.setPendingApproval()
record.isRateLimited -> alert.setRateLimited()
else -> alert.setNone()
}
if (conversationContext.hasWallpaper()) {
alert.setBackgroundResource(R.drawable.wallpaper_message_decoration_background)
} else {
alert.background = null
}
}
private fun presentFooterBackground(shape: V2ConversationItemShape.MessageShape) {
if (!binding.conversationItemBody.isJumbomoji ||
!conversationContext.hasWallpaper() ||
shape == V2ConversationItemShape.MessageShape.MIDDLE ||
shape == V2ConversationItemShape.MessageShape.START
) {
binding.conversationItemFooterBackground.visible = false
return
}
binding.conversationItemFooterBackground.visible = true
binding.conversationItemFooterBackground.background = conversationItemFooterBackground
conversationItemFooterBackground.fillColor = themeDelegate.getFooterBubbleColor(conversationMessage)
}
private fun presentDate(shape: V2ConversationItemShape.MessageShape) {
if (shape == V2ConversationItemShape.MessageShape.MIDDLE || shape == V2ConversationItemShape.MessageShape.START) {
binding.conversationItemFooterDate.visible = false
return
}
binding.conversationItemFooterDate.visible = true
binding.conversationItemFooterDate.setTextColor(themeDelegate.getFooterTextColor(conversationMessage))
val record = conversationMessage.messageRecord
if (record.isFailed) {
val errorMessage = when {
record.hasFailedWithNetworkFailures() -> R.string.ConversationItem_error_network_not_delivered
record.toRecipient.isPushGroup && record.isIdentityMismatchFailure -> R.string.ConversationItem_error_partially_not_delivered
else -> R.string.ConversationItem_error_not_sent_tap_for_details
}
binding.conversationItemFooterDate.setText(errorMessage)
} else if (record.isPendingInsecureSmsFallback) {
binding.conversationItemFooterDate.setText(R.string.ConversationItem_click_to_approve_unencrypted)
} else if (record.isRateLimited) {
binding.conversationItemFooterDate.setText(R.string.ConversationItem_send_paused)
} else if (record.isScheduled()) {
binding.conversationItemFooterDate.text = (DateUtils.getOnlyTimeString(getContext(), Locale.getDefault(), (record as MediaMmsMessageRecord).scheduledDate))
} else {
var date = DateUtils.getSimpleRelativeTimeSpanString(context, Locale.getDefault(), record.timestamp)
if (conversationContext.displayMode != ConversationItemDisplayMode.DETAILED && record is MediaMmsMessageRecord && record.isEditMessage()) {
date = getContext().getString(R.string.ConversationItem_edited_timestamp_footer, date)
}
binding.conversationItemFooterDate.text = date
}
}
private fun presentDeliveryStatus(shape: V2ConversationItemShape.MessageShape) {
val deliveryStatus = binding.conversationItemDeliveryStatus ?: return
if (shape == V2ConversationItemShape.MessageShape.MIDDLE || shape == V2ConversationItemShape.MessageShape.START) {
deliveryStatus.setNone()
return
}
val record = conversationMessage.messageRecord
val newMessageId = record.buildMessageId()
if (messageId != newMessageId && deliveryStatus.isPending && !record.isPending) {
if (record.toRecipient.isGroup) {
SignalLocalMetrics.GroupMessageSend.onUiUpdated(record.id)
} else {
SignalLocalMetrics.IndividualMessageSend.onUiUpdated(record.id)
}
}
messageId = newMessageId
if (!record.isOutgoing || record.isFailed || record.isPendingInsecureSmsFallback || record.isScheduled()) {
deliveryStatus.setNone()
return
}
val onlyShowSendingStatus = when {
record.isOutgoing && !record.isRemoteDelete -> false
record.isRemoteDelete -> true
else -> false
}
if (onlyShowSendingStatus) {
if (record.isPending) {
deliveryStatus.setPending()
} else {
deliveryStatus.setNone()
}
return
}
when {
record.isPending -> deliveryStatus.setPending()
record.isRemoteRead -> deliveryStatus.setRead()
record.isDelivered -> deliveryStatus.setDelivered()
else -> deliveryStatus.setSent()
}
}
override fun disallowSwipe(latestDownX: Float, latestDownY: Float): Boolean {
return false
}
}

View File

@@ -0,0 +1,124 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2.items
import android.view.View
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.util.padding
import org.thoughtcrime.securesms.util.visible
/**
* Logical delegate for determining the footer position for a particular conversation item.
*/
class V2FooterPositionDelegate private constructor(
private val isIncoming: Boolean,
private val root: V2ConversationItemLayout,
private val footerViews: List<View>,
private val bodyContainer: View,
private val body: EmojiTextView
) : V2ConversationItemLayout.OnMeasureListener {
constructor(binding: V2ConversationItemTextOnlyBindingBridge) : this(
binding.isIncoming,
binding.root,
listOfNotNull(binding.conversationItemFooterDate, binding.conversationItemDeliveryStatus, binding.conversationItemFooterExpiry),
binding.conversationItemBodyWrapper,
binding.conversationItemBody
)
private val gutters = 48.dp + 16.dp
private val horizontalFooterPadding = root.context.resources.getDimensionPixelOffset(R.dimen.message_bubble_horizontal_padding)
private var displayState: DisplayState = DisplayState.NONE
override fun onPreMeasure() {
displayTuckedIntoBody()
}
override fun onPostMeasure(): Boolean {
val maxWidth = root.measuredWidth - gutters
val lastLineWidth = body.lastLineWidth
val footerWidth = footerViews.sumOf { it.measuredWidth }
if (footerViews.all { !it.visible }) {
return false
}
if (body.isJumbomoji) {
displayUnderneathBody()
return true
}
val availableTuck = bodyContainer.measuredWidth - lastLineWidth - (horizontalFooterPadding * 2)
if (body.lineCount > 1 && availableTuck > footerWidth) {
return false
}
val availableWidth = maxWidth - lastLineWidth
if (body.lineCount == 1 && availableWidth > footerWidth) {
displayAtEndOfBody()
return true
}
displayUnderneathBody()
return true
}
private fun displayUnderneathBody() {
if (displayState == DisplayState.UNDERNEATH) {
return
}
footerViews.forEach {
it.translationY = 0f
}
bodyContainer.padding(right = 0, left = 0, bottom = footerViews.first().measuredHeight)
displayState = DisplayState.UNDERNEATH
}
private fun displayAtEndOfBody() {
if (displayState == DisplayState.END) {
return
}
footerViews.forEach {
it.translationY = 0f
}
val end = footerViews.sumOf { it.measuredWidth } + if (isIncoming) 4.dp else 8.dp
val (left, right) = if (bodyContainer.layoutDirection == View.LAYOUT_DIRECTION_LTR) {
0 to end
} else {
end to 0
}
bodyContainer.padding(right = right, left = left, bottom = 0)
displayState = DisplayState.END
}
private fun displayTuckedIntoBody() {
if (displayState == DisplayState.TUCKED) {
return
}
footerViews.forEach {
it.translationY = 0f
}
bodyContainer.padding(right = 0, left = 0, bottom = 0)
displayState = DisplayState.TUCKED
}
private enum class DisplayState {
NONE,
UNDERNEATH,
END,
TUCKED
}
}

View File

@@ -248,6 +248,9 @@ public final class Projection {
public static final class Corners {
public static final Corners NONE = new Corners(0f);
private final float topLeft;
private final float topRight;
private final float bottomRight;

View File

@@ -0,0 +1,164 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2023 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:focusable="true"
android:nextFocusLeft="@+id/container"
android:nextFocusRight="@+id/embedded_text_editor">
<!-- STR Icon -->
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/conversation_item_reply"
android:layout_width="@dimen/conversation_item_reply_size"
android:layout_height="@dimen/conversation_item_reply_size"
android:alpha="0"
android:tint="@color/signal_icon_tint_secondary"
app:contentPadding="9dp"
app:layout_constraintBottom_toBottomOf="@id/conversation_item_body_wrapper"
app:layout_constraintStart_toStartOf="@id/conversation_item_body_wrapper"
app:layout_constraintTop_toTopOf="@id/conversation_item_body_wrapper"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
app:srcCompat="@drawable/symbol_reply_24" />
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/contact_photo"
android:layout_width="@dimen/conversation_item_avatar_size"
android:layout_height="@dimen/conversation_item_avatar_size"
android:layout_marginStart="12dp"
android:contentDescription="@string/conversation_item_received__contact_photo_description"
android:cropToPadding="true"
android:visibility="gone"
app:fallbackImageSize="small"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" />
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/badge"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="14dp"
android:layout_marginTop="16dp"
android:visibility="gone"
app:badge_size="small"
app:layout_constraintStart_toStartOf="@id/contact_photo"
app:layout_constraintTop_toTopOf="@id/contact_photo"
tools:visibility="visible" />
<!-- Body -->
<LinearLayout
android:id="@+id/conversation_item_body_wrapper"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="48dp"
android:orientation="vertical"
app:cardElevation="0dp"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/contact_photo"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginStart="16dp"
tools:background="@color/black">
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/group_message_sender"
style="@style/TextAppearance.Signal.Subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="7dp"
android:layout_marginEnd="4sp"
android:layout_marginBottom="-6dp"
android:ellipsize="end"
android:maxLines="1"
android:paddingStart="@dimen/message_bubble_horizontal_padding"
android:paddingEnd="@dimen/message_bubble_horizontal_padding"
android:textColor="@color/signal_text_primary"
android:textStyle="bold"
tools:text="+14152222222"
tools:visibility="visible" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/conversation_item_body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/message_bubble_horizontal_padding"
android:paddingTop="@dimen/message_bubble_top_padding"
android:paddingEnd="@dimen/message_bubble_horizontal_padding"
android:paddingBottom="@dimen/message_bubble_collapsed_footer_padding"
android:textAppearance="@style/Signal.Text.BodyLarge"
android:textColor="@color/conversation_item_sent_text_primary_color"
android:textColorLink="@color/conversation_item_sent_text_primary_color"
android:textSize="16sp"
app:emoji_maxLength="1000"
app:measureLastLine="true"
app:scaleEmojis="true"
tools:text="Testy test test test" />
</LinearLayout>
<!-- Footer -->
<View
android:id="@+id/conversation_item_footer_background"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="-12dp"
android:layout_marginEnd="-12dp"
android:layout_marginBottom="4dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/conversation_item_footer_date"
app:layout_constraintEnd_toEndOf="@id/conversation_item_expiration_timer"
app:layout_constraintStart_toStartOf="@id/conversation_item_footer_date"
app:layout_constraintTop_toTopOf="@id/conversation_item_footer_date"
tools:background="@color/blue_500"
tools:visibility="visible" />
<TextView
android:id="@+id/conversation_item_footer_date"
style="@style/Signal.Text.Caption.MessageSent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="@dimen/message_bubble_horizontal_padding"
android:autoLink="none"
android:ellipsize="end"
android:linksClickable="false"
android:maxLines="1"
android:paddingBottom="@dimen/message_bubble_bottom_padding"
android:textColor="@color/signal_text_secondary"
app:layout_constraintBottom_toBottomOf="@id/conversation_item_body_wrapper"
app:layout_constraintEnd_toStartOf="@id/conversation_item_expiration_timer"
tools:text="13:14pm" />
<org.thoughtcrime.securesms.components.ExpirationTimerView
android:id="@+id/conversation_item_expiration_timer"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_marginBottom="@dimen/message_bubble_bottom_padding"
app:layout_constraintBottom_toBottomOf="@id/conversation_item_footer_date"
app:layout_constraintEnd_toEndOf="@id/conversation_item_body_wrapper"
app:layout_constraintTop_toTopOf="@id/conversation_item_footer_date" />
<!-- End Footer -->
<!-- Replies Icon -->
<!-- Reactions -->
<org.thoughtcrime.securesms.reactions.ReactionsConversationView
android:id="@+id/conversation_item_reactions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="-4dp"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/conversation_item_body_wrapper"
app:layout_constraintTop_toBottomOf="@id/conversation_item_body_wrapper"
app:rcv_outgoing="false" />
</org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemLayout>

View File

@@ -0,0 +1,156 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2023 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:focusable="true"
android:nextFocusLeft="@+id/container"
android:nextFocusRight="@+id/embedded_text_editor">
<!-- STR Icon -->
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/conversation_item_reply"
android:layout_width="@dimen/conversation_item_reply_size"
android:layout_height="@dimen/conversation_item_reply_size"
android:alpha="0"
android:tint="@color/signal_icon_tint_secondary"
app:contentPadding="9dp"
app:layout_constraintBottom_toBottomOf="@id/conversation_item_body_wrapper"
app:layout_constraintStart_toStartOf="@id/conversation_item_body_wrapper"
app:layout_constraintTop_toTopOf="@id/conversation_item_body_wrapper"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
app:srcCompat="@drawable/symbol_reply_24" />
<!-- Body -->
<FrameLayout
android:id="@+id/conversation_item_body_wrapper"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="48dp"
app:cardElevation="0dp"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/conversation_item_alert"
app:layout_constraintHorizontal_bias="1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginEnd="16dp"
tools:background="@color/black">
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/conversation_item_body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/message_bubble_horizontal_padding"
android:paddingTop="@dimen/message_bubble_top_padding"
android:paddingEnd="@dimen/message_bubble_horizontal_padding"
android:paddingBottom="@dimen/message_bubble_collapsed_footer_padding"
android:textAppearance="@style/Signal.Text.BodyLarge"
android:textColor="@color/conversation_item_sent_text_primary_color"
android:textColorLink="@color/conversation_item_sent_text_primary_color"
android:textSize="16sp"
app:emoji_maxLength="1000"
app:measureLastLine="true"
app:scaleEmojis="true"
tools:text="Mango pickle lorem ipsum" />
</FrameLayout>
<org.thoughtcrime.securesms.components.AlertView
android:id="@+id/conversation_item_alert"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="16dp"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Footer -->
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_footer_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:constraint_referenced_ids="conversation_item_delivery_status,conversation_item_footer_date" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_footer_bottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="conversation_item_delivery_status,conversation_item_footer_date" />
<View
android:id="@+id/conversation_item_footer_background"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="-12dp"
android:layout_marginEnd="-12dp"
android:layout_marginBottom="4dp"
android:background="@color/blue_500"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/barrier_footer_bottom"
app:layout_constraintEnd_toEndOf="@id/conversation_item_delivery_status"
app:layout_constraintStart_toStartOf="@id/conversation_item_footer_date"
app:layout_constraintTop_toTopOf="@id/barrier_footer_top"
tools:visibility="visible" />
<TextView
android:id="@+id/conversation_item_footer_date"
style="@style/Signal.Text.Caption.MessageSent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:autoLink="none"
android:ellipsize="end"
android:linksClickable="false"
android:maxLines="1"
android:paddingBottom="@dimen/message_bubble_bottom_padding"
android:textColor="@color/signal_text_secondary"
app:layout_constraintBottom_toBottomOf="@id/conversation_item_body_wrapper"
app:layout_constraintEnd_toStartOf="@id/conversation_item_delivery_status"
app:layout_goneMarginEnd="@dimen/message_bubble_horizontal_padding"
tools:text="13:14pm" />
<org.thoughtcrime.securesms.components.ExpirationTimerView
android:id="@+id/conversation_item_expiration_timer"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_marginBottom="@dimen/message_bubble_bottom_padding"
app:layout_constraintBottom_toBottomOf="@id/conversation_item_body_wrapper"
app:layout_constraintEnd_toStartOf="@id/conversation_item_delivery_status"
app:layout_constraintStart_toEndOf="@id/conversation_item_footer_date"
app:layout_goneMarginEnd="@dimen/message_bubble_horizontal_padding" />
<org.thoughtcrime.securesms.components.DeliveryStatusView
android:id="@+id/conversation_item_delivery_status"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center_vertical"
android:layout_marginEnd="@dimen/message_bubble_horizontal_padding"
android:paddingBottom="@dimen/message_bubble_bottom_padding"
app:layout_constraintBottom_toBottomOf="@id/conversation_item_body_wrapper"
app:layout_constraintEnd_toEndOf="@id/conversation_item_body_wrapper" />
<!-- End Footer -->
<!-- Replies Icon -->
<!-- Reactions -->
<org.thoughtcrime.securesms.reactions.ReactionsConversationView
android:id="@+id/conversation_item_reactions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="-4dp"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/conversation_item_body_wrapper"
app:layout_constraintTop_toBottomOf="@id/conversation_item_body_wrapper"
app:rcv_outgoing="false" />
</org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemLayout>