mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 21:15:48 +00:00
Add new text-only conversation item.
This commit is contained in:
committed by
Greyson Parrelli
parent
e6cc789c6f
commit
53e62f2be0
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -266,6 +266,10 @@ class ConversationViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun startExpirationTimeout(messageRecord: MessageRecord) {
|
||||
repository.startExpirationTimeout(messageRecord)
|
||||
}
|
||||
|
||||
fun updateReaction(messageRecord: MessageRecord, emoji: String): Completable {
|
||||
val oldRecord = messageRecord.oldReactionRecord()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user