From 65d5f4c4261851b73f25764e74a7ab24132c3581 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Fri, 5 May 2023 13:16:46 -0400 Subject: [PATCH] Add ConversationAdapterV2. --- .idea/copyright/Signal.xml | 6 + .idea/copyright/profiles_settings.xml | 7 + .../internal/AnnotationType.java | 9 + .idea/fileTemplates/internal/Class.java | 9 + .idea/fileTemplates/internal/Enum.java | 9 + .idea/fileTemplates/internal/Interface.java | 9 + .idea/fileTemplates/internal/Kotlin Class.kt | 11 + .idea/fileTemplates/internal/Kotlin Enum.kt | 11 + .idea/fileTemplates/internal/Kotlin File.kt | 9 + .../internal/Kotlin Interface.kt | 11 + .../conversation/ConversationAdapter.java | 41 +-- .../conversation/ConversationAdapterBridge.kt | 22 ++ .../conversation/ConversationOptionsMenu.kt | 15 +- .../conversation/MarkReadHelper.java | 15 +- .../mutiselect/MultiselectItemDecoration.kt | 25 +- .../conversation/v2/ConversationAdapterV2.kt | 329 ++++++++++++++++++ .../conversation/v2/ConversationFragment.kt | 33 +- .../v2/ConversationThreadState.kt | 11 +- .../conversation/v2/ConversationViewModel.kt | 13 +- .../v2/data/ConversationDataSource.kt | 84 +++-- .../v2/data/ConversationElements.kt | 73 ++++ 21 files changed, 660 insertions(+), 92 deletions(-) create mode 100644 .idea/copyright/Signal.xml create mode 100644 .idea/copyright/profiles_settings.xml create mode 100644 .idea/fileTemplates/internal/AnnotationType.java create mode 100644 .idea/fileTemplates/internal/Class.java create mode 100644 .idea/fileTemplates/internal/Enum.java create mode 100644 .idea/fileTemplates/internal/Interface.java create mode 100644 .idea/fileTemplates/internal/Kotlin Class.kt create mode 100644 .idea/fileTemplates/internal/Kotlin Enum.kt create mode 100644 .idea/fileTemplates/internal/Kotlin File.kt create mode 100644 .idea/fileTemplates/internal/Kotlin Interface.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapterBridge.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationElements.kt diff --git a/.idea/copyright/Signal.xml b/.idea/copyright/Signal.xml new file mode 100644 index 0000000000..824922d658 --- /dev/null +++ b/.idea/copyright/Signal.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100644 index 0000000000..b1e51bb9e1 --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/fileTemplates/internal/AnnotationType.java b/.idea/fileTemplates/internal/AnnotationType.java new file mode 100644 index 0000000000..6ab0195978 --- /dev/null +++ b/.idea/fileTemplates/internal/AnnotationType.java @@ -0,0 +1,9 @@ +/* + * Copyright ${YEAR} Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end +#parse("File Header.java") +public @interface ${NAME} { +} diff --git a/.idea/fileTemplates/internal/Class.java b/.idea/fileTemplates/internal/Class.java new file mode 100644 index 0000000000..28bffa5665 --- /dev/null +++ b/.idea/fileTemplates/internal/Class.java @@ -0,0 +1,9 @@ +/* + * Copyright ${YEAR} Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end +#parse("File Header.java") +public class ${NAME} { +} diff --git a/.idea/fileTemplates/internal/Enum.java b/.idea/fileTemplates/internal/Enum.java new file mode 100644 index 0000000000..83f1c231f2 --- /dev/null +++ b/.idea/fileTemplates/internal/Enum.java @@ -0,0 +1,9 @@ +/* + * Copyright ${YEAR} Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end +#parse("File Header.java") +public enum ${NAME} { +} diff --git a/.idea/fileTemplates/internal/Interface.java b/.idea/fileTemplates/internal/Interface.java new file mode 100644 index 0000000000..0e8c0e7d65 --- /dev/null +++ b/.idea/fileTemplates/internal/Interface.java @@ -0,0 +1,9 @@ +/* + * Copyright ${YEAR} Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end +#parse("File Header.java") +public interface ${NAME} { +} diff --git a/.idea/fileTemplates/internal/Kotlin Class.kt b/.idea/fileTemplates/internal/Kotlin Class.kt new file mode 100644 index 0000000000..a73bee7914 --- /dev/null +++ b/.idea/fileTemplates/internal/Kotlin Class.kt @@ -0,0 +1,11 @@ +/* + * Copyright ${YEAR} Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME} + +#end +#parse("File Header.java") +class ${NAME} { +} \ No newline at end of file diff --git a/.idea/fileTemplates/internal/Kotlin Enum.kt b/.idea/fileTemplates/internal/Kotlin Enum.kt new file mode 100644 index 0000000000..da4684b9cc --- /dev/null +++ b/.idea/fileTemplates/internal/Kotlin Enum.kt @@ -0,0 +1,11 @@ +/* + * Copyright ${YEAR} Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME} + +#end +#parse("File Header.java") +enum class ${NAME} { +} \ No newline at end of file diff --git a/.idea/fileTemplates/internal/Kotlin File.kt b/.idea/fileTemplates/internal/Kotlin File.kt new file mode 100644 index 0000000000..7674dde461 --- /dev/null +++ b/.idea/fileTemplates/internal/Kotlin File.kt @@ -0,0 +1,9 @@ +/* + * Copyright ${YEAR} Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME} + +#end +#parse("File Header.java") diff --git a/.idea/fileTemplates/internal/Kotlin Interface.kt b/.idea/fileTemplates/internal/Kotlin Interface.kt new file mode 100644 index 0000000000..358dc15bc5 --- /dev/null +++ b/.idea/fileTemplates/internal/Kotlin Interface.kt @@ -0,0 +1,11 @@ +/* + * Copyright ${YEAR} Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME} + +#end +#parse("File Header.java") +interface ${NAME} { +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index d10572c124..2fe95123f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.conversation.colors.Colorizable; import org.thoughtcrime.securesms.conversation.colors.Colorizer; import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; +import org.thoughtcrime.securesms.conversationlist.model.Conversation; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable; @@ -78,7 +79,8 @@ import java.util.Set; */ public class ConversationAdapter extends ListAdapter - implements StickyHeaderDecoration.StickyHeaderAdapter + implements StickyHeaderDecoration.StickyHeaderAdapter, + ConversationAdapterBridge { private static final String TAG = Log.tag(ConversationAdapter.class); @@ -380,6 +382,10 @@ public class ConversationAdapter } } + public @Nullable ConversationMessage getConversationMessage(int position) { + return getItem(position); + } + public @Nullable ConversationMessage getItem(int position) { position = isTypingViewEnabled() ? position - 1 : position; @@ -453,7 +459,7 @@ public class ConversationAdapter } } - boolean hasNoConversationMessages() { + public boolean hasNoConversationMessages() { return super.getItemCount() == 0; } @@ -825,37 +831,6 @@ public class ConversationAdapter } } - public static class PulseRequest { - private final int position; - private final boolean isOutgoing; - - PulseRequest(int position, boolean isOutgoing) { - this.position = position; - this.isOutgoing = isOutgoing; - } - - public int getPosition() { - return position; - } - - public boolean isOutgoing() { - return isOutgoing; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - final PulseRequest that = (PulseRequest) o; - return position == that.position; - } - - @Override - public int hashCode() { - return Objects.hash(position); - } - } - public interface ItemClickListener extends BindableConversationItem.EventListener { void onItemClick(MultiselectPart item); void onItemLongClick(View itemView, MultiselectPart item); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapterBridge.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapterBridge.kt new file mode 100644 index 0000000000..8533aad2c7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapterBridge.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation + +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart + +/** + * Temporary shared interface between the two conversation adapters strictly for use in + * shared decorators and other utils. + */ +interface ConversationAdapterBridge { + fun hasNoConversationMessages(): Boolean + fun getConversationMessage(position: Int): ConversationMessage? + fun consumePulseRequest(): PulseRequest? + + val selectedItems: Set + + data class PulseRequest(val position: Int, val isOutgoing: Boolean) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt index 8363490098..8e586d11dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.conversation import android.view.Menu @@ -8,6 +13,7 @@ import androidx.core.view.MenuProvider import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.kotlin.subscribeBy import org.signal.core.util.concurrent.LifecycleDisposable +import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.database.ThreadTable import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -18,6 +24,8 @@ import org.thoughtcrime.securesms.recipients.Recipient */ internal object ConversationOptionsMenu { + private val TAG = Log.tag(ConversationOptionsMenu::class.java) + /** * MenuProvider implementation for the conversation options menu. */ @@ -43,7 +51,12 @@ internal object ConversationOptionsMenu { isInBubble ) = callback.getSnapshot() - if (isInMessageRequest && (recipient != null) && !recipient.isBlocked) { + if (recipient == null) { + Log.w(TAG, "Recipient is null, no menu") + return + } + + if (isInMessageRequest && !recipient.isBlocked) { if (isActiveGroup) { menuInflater.inflate(R.menu.conversation_message_requests_group, menu) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MarkReadHelper.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MarkReadHelper.java index 4c7eb0f660..223e7da35e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MarkReadHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MarkReadHelper.java @@ -1,3 +1,8 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.conversation; import android.content.Context; @@ -5,12 +10,12 @@ import android.content.Context; import androidx.annotation.NonNull; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleOwner; +import androidx.recyclerview.widget.LinearLayoutManager; import com.annimon.stream.Stream; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager; import org.thoughtcrime.securesms.database.MessageTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.ThreadTable; @@ -72,8 +77,8 @@ public class MarkReadHelper { * @return A Present(Long) if there's a timestamp to proceed with, or Empty if this request should be ignored. */ @SuppressWarnings("resource") - public static @NonNull Optional getLatestTimestamp(@NonNull ConversationAdapter conversationAdapter, - @NonNull SmoothScrollingLinearLayoutManager layoutManager) + public static @NonNull Optional getLatestTimestamp(@NonNull ConversationAdapterBridge conversationAdapter, + @NonNull LinearLayoutManager layoutManager) { if (conversationAdapter.hasNoConversationMessages()) { return Optional.empty(); @@ -84,9 +89,9 @@ public class MarkReadHelper { return Optional.empty(); } - ConversationMessage item = conversationAdapter.getItem(position); + ConversationMessage item = conversationAdapter.getConversationMessage(position); if (item == null) { - item = conversationAdapter.getItem(position + 1); + item = conversationAdapter.getConversationMessage(position + 1); } if (item != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt index 325f40f4f2..e86ef6eae9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.conversation.mutiselect import android.animation.Animator @@ -27,8 +32,8 @@ import com.airbnb.lottie.SimpleColorFilter import com.google.android.material.animation.ArgbEvaluatorCompat import org.signal.core.util.SetUtil import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.conversation.ConversationAdapter -import org.thoughtcrime.securesms.conversation.ConversationAdapter.PulseRequest +import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge +import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge.PulseRequest import org.thoughtcrime.securesms.conversation.ConversationItem import org.thoughtcrime.securesms.util.ThemeUtil import org.thoughtcrime.securesms.util.ViewUtil @@ -118,7 +123,7 @@ class MultiselectItemDecoration( } private fun getCurrentSelection(parent: RecyclerView): Set { - return (parent.adapter as ConversationAdapter).selectedItems + return (parent.adapter as ConversationAdapterBridge).selectedItems } override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { @@ -150,7 +155,7 @@ class MultiselectItemDecoration( outRect.setEmpty() updateChildOffsets(parent, view) - consumePulseRequest(parent.adapter as ConversationAdapter) + consumePulseRequest(parent.adapter as ConversationAdapterBridge) } /** @@ -158,7 +163,7 @@ class MultiselectItemDecoration( */ @Suppress("DEPRECATION") override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { - val adapter = parent.adapter as ConversationAdapter + val adapter = parent.adapter as ConversationAdapterBridge if (adapter.selectedItems.isEmpty()) { drawFocusShadeUnderIfNecessary(canvas, parent) @@ -221,7 +226,7 @@ class MultiselectItemDecoration( * Draws the selected check or empty circle. */ override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { - val adapter = parent.adapter as ConversationAdapter + val adapter = parent.adapter as ConversationAdapterBridge if (adapter.selectedItems.isEmpty()) { drawFocusShadeOverIfNecessary(canvas, parent) } @@ -232,7 +237,7 @@ class MultiselectItemDecoration( invalidateIfEnterExitAnimatorsAreRunning(parent) } - private fun drawChecks(parent: RecyclerView, canvas: Canvas, adapter: ConversationAdapter) { + private fun drawChecks(parent: RecyclerView, canvas: Canvas, adapter: ConversationAdapterBridge) { val drawCircleBehindSelector = chatWallpaperProvider()?.isPhoto == true val multiselectChildren: Sequence = parent.children.filterIsInstance(Multiselectable::class.java) @@ -337,7 +342,7 @@ class MultiselectItemDecoration( * called in getItemOffsets to ensure the gutter goes away when multiselect mode ends. */ private fun updateChildOffsets(parent: RecyclerView, child: View) { - val adapter = parent.adapter as ConversationAdapter + val adapter = parent.adapter as ConversationAdapterBridge val isLtr = ViewUtil.isLtr(child) val isAnimatingSelection = enterExitAnimation != null && isInitialAnimation() @@ -542,8 +547,8 @@ class MultiselectItemDecoration( } } - private fun consumePulseRequest(adapter: ConversationAdapter) { - val pulseRequest = adapter.consumePulseRequest() + private fun consumePulseRequest(adapter: ConversationAdapterBridge) { + val pulseRequest: PulseRequest? = adapter.consumePulseRequest() if (pulseRequest != null) { val pulseColor = if (pulseRequest.isOutgoing) pulseOutgoingColor else pulseIncomingColor pulseRequestAnimators[pulseRequest]?.cancel() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt new file mode 100644 index 0000000000..df5c8d887f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt @@ -0,0 +1,329 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.LifecycleOwner +import com.google.android.exoplayer2.MediaItem +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.ConversationAdapterBridge +import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.conversation.colors.Colorizable +import org.thoughtcrime.securesms.conversation.colors.Colorizer +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart +import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey +import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElement +import org.thoughtcrime.securesms.conversation.v2.data.ConversationUpdate +import org.thoughtcrime.securesms.conversation.v2.data.IncomingMedia +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.database.model.MessageRecord +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer +import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.util.CachedInflater +import org.thoughtcrime.securesms.util.Projection +import org.thoughtcrime.securesms.util.ProjectionList +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder +import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter +import java.util.Locale +import java.util.Optional + +class ConversationAdapterV2( + private val lifecycleOwner: LifecycleOwner, + private val glideRequests: GlideRequests, + private val clickListener: ConversationAdapter.ItemClickListener, + private var hasWallpaper: Boolean, + private val colorizer: Colorizer +) : PagingMappingAdapter(), ConversationAdapterBridge { + + companion object { + private val TAG = Log.tag(ConversationAdapterV2::class.java) + } + + private val _selected = hashSetOf() + + override val selectedItems: Set + get() = _selected.toSet() + + private var searchQuery: String? = null + private var inlineContent: ConversationMessage? = null + + private var recordToPulse: ConversationMessage? = null + private var pulseRequest: ConversationAdapterBridge.PulseRequest? = null + + private val condensedMode: ConversationItemDisplayMode? = null + + init { + registerFactory(ConversationUpdate::class.java) { parent -> + val view = CachedInflater.from(parent.context).inflate(R.layout.conversation_item_update, parent, false) + ConversationUpdateViewHolder(view) + } + + registerFactory(OutgoingTextOnly::class.java) { parent -> + val view = CachedInflater.from(parent.context).inflate(R.layout.conversation_item_sent_text_only, parent, false) + OutgoingTextOnlyViewHolder(view) + } + + registerFactory(OutgoingMedia::class.java) { parent -> + val view = CachedInflater.from(parent.context).inflate(R.layout.conversation_item_sent_multimedia, parent, false) + OutgoingMediaViewHolder(view) + } + + registerFactory(IncomingTextOnly::class.java) { parent -> + val view = CachedInflater.from(parent.context).inflate(R.layout.conversation_item_received_text_only, parent, false) + IncomingTextOnlyViewHolder(view) + } + + registerFactory(IncomingMedia::class.java) { parent -> + val view = CachedInflater.from(parent.context).inflate(R.layout.conversation_item_received_multimedia, parent, false) + IncomingMediaViewHolder(view) + } + } + + fun getAdapterPositionForMessagePosition(startPosition: Int): Int { + return startPosition - 1 + } + + fun getLastVisibleConversationMessage(position: Int): ConversationMessage? { + return try { + // todo [cody] handle conversation banner adjustment + getConversationMessage(position) + } catch (e: IndexOutOfBoundsException) { + Log.w(TAG, "Race condition changed size of conversation", e) + null + } + } + + fun canJumpToPosition(absolutePosition: Int): Boolean { + // todo [cody] handle typing indicator + val position = absolutePosition + + if (position < 0) { + return false + } + + if (position > super.getItemCount()) { + Log.d(TAG, "Could not access corrected position $position as it is out of bounds.") + return false + } + + return isRangeAvailable(position - 10, position + 5) + } + + fun playInlineContent(conversationMessage: ConversationMessage?) { + if (this.inlineContent !== conversationMessage) { + this.inlineContent = conversationMessage + notifyDataSetChanged() + } + } + + override fun getConversationMessage(position: Int): ConversationMessage? { + return when (val item = getItem(position)) { + is ConversationMessageElement -> item.conversationMessage + null -> null + else -> throw AssertionError("Invalid item: ${item.javaClass}") + } + } + + override fun hasNoConversationMessages(): Boolean { + return itemCount == 0 + } + + /** + * Momentarily highlights a mention at the requested position. + */ + fun pulseAtPosition(position: Int) { + if (position >= 0 && position < itemCount) { + // todo [cody] adjust for typing indicator + val correctedPosition = position + + recordToPulse = getConversationMessage(correctedPosition) + if (recordToPulse != null) { + pulseRequest = ConversationAdapterBridge.PulseRequest(position, recordToPulse!!.messageRecord.isOutgoing) + } + notifyItemChanged(correctedPosition) + } + } + + override fun consumePulseRequest(): ConversationAdapterBridge.PulseRequest? { + val request = pulseRequest + pulseRequest = null + return request + } + + fun onHasWallpaperChanged(hasChanged: Boolean) { + // todo [cody] implement + } + + private inner class ConversationUpdateViewHolder(itemView: View) : ConversationViewHolder(itemView) { + override fun bind(model: ConversationUpdate) { + 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 OutgoingTextOnlyViewHolder(itemView: View) : ConversationViewHolder(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(itemView) { + override fun bind(model: OutgoingMedia) { + 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 IncomingTextOnlyViewHolder(itemView: View) : ConversationViewHolder(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(itemView) { + override fun bind(model: IncomingMedia) { + 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 abstract inner class ConversationViewHolder(itemView: View) : MappingViewHolder(itemView), GiphyMp4Playable, Colorizable { + val bindable: BindableConversationItem + get() = itemView as BindableConversationItem + + protected val previousMessage: Optional + get() = getConversationMessage(bindingAdapterPosition + 1)?.messageRecord.toOptional() + + protected val nextMessage: Optional + get() = getConversationMessage(bindingAdapterPosition - 1)?.messageRecord.toOptional() + + protected val displayMode: ConversationItemDisplayMode + get() = condensedMode ?: ConversationItemDisplayMode.STANDARD + + override fun showProjectionArea() { + bindable.showProjectionArea() + } + + override fun hideProjectionArea() { + bindable.hideProjectionArea() + } + + override fun getMediaItem(): MediaItem? { + return bindable.mediaItem + } + + override fun getPlaybackPolicyEnforcer(): GiphyMp4PlaybackPolicyEnforcer? { + return bindable.playbackPolicyEnforcer + } + + override fun getGiphyMp4PlayableProjection(recyclerView: ViewGroup): Projection { + return bindable.getGiphyMp4PlayableProjection(recyclerView) + } + + override fun canPlayContent(): Boolean { + return bindable.canPlayContent() + } + + override fun shouldProjectContent(): Boolean { + return bindable.shouldProjectContent() + } + + override fun getColorizerProjections(coordinateRoot: ViewGroup): ProjectionList { + return bindable.getColorizerProjections(coordinateRoot) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 81540a5237..6ed40cf9ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.conversation.v2 import android.annotation.SuppressLint @@ -169,12 +174,12 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) private val colorizer = Colorizer() private lateinit var conversationOptionsMenuProvider: ConversationOptionsMenu.Provider - private lateinit var layoutManager: SmoothScrollingLinearLayoutManager + private lateinit var layoutManager: LinearLayoutManager private lateinit var markReadHelper: MarkReadHelper private lateinit var giphyMp4ProjectionRecycler: GiphyMp4ProjectionRecycler private lateinit var addToContactsLauncher: ActivityResultLauncher private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate - private lateinit var adapter: ConversationAdapter + private lateinit var adapter: ConversationAdapterV2 private lateinit var recyclerViewColorizer: RecyclerViewColorizer private var animationsAllowed = false @@ -434,14 +439,12 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) binding.conversationItemRecycler.layoutManager = layoutManager binding.conversationItemRecycler.addOnScrollListener(ScrollListener()) - adapter = ConversationAdapter( - requireContext(), - viewLifecycleOwner, - GlideApp.with(this), - Locale.getDefault(), - ConversationItemClickListener(), - args.wallpaper != null, - colorizer + adapter = ConversationAdapterV2( + lifecycleOwner = viewLifecycleOwner, + glideRequests = GlideApp.with(this), + clickListener = ConversationItemClickListener(), + hasWallpaper = args.wallpaper != null, + colorizer = colorizer ) scrollToPositionDelegate = ScrollToPositionDelegate( @@ -500,7 +503,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) return callback } - private fun toast(@StringRes toastTextId: Int, toastDuration: Int) { + private fun toast(@StringRes toastTextId: Int, toastDuration: Int = Toast.LENGTH_SHORT) { ThreadUtil.runOnMain { if (context != null) { Toast.makeText(context, toastTextId, toastDuration).show() @@ -570,7 +573,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) if (quote.isOriginalMissing) { Log.i(TAG, "onQuoteClicked: Original message is missing.") - toast(R.string.ConversationFragment_quoted_message_not_found, Toast.LENGTH_SHORT) + toast(R.string.ConversationFragment_quoted_message_not_found) return } @@ -594,7 +597,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) if (it >= 0) { moveToPosition(it) } else { - toast(R.string.ConversationFragment_quoted_message_no_longer_available, Toast.LENGTH_SHORT) + toast(R.string.ConversationFragment_quoted_message_no_longer_available) } } } @@ -998,8 +1001,8 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } private class LastSeenPositionUpdater( - val adapter: ConversationAdapter, - val layoutManager: SmoothScrollingLinearLayoutManager, + val adapter: ConversationAdapterV2, + val layoutManager: LinearLayoutManager, val viewModel: ConversationViewModel ) : DefaultLifecycleObserver { override fun onPause(owner: LifecycleOwner) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationThreadState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationThreadState.kt index 542ff542c7..84b7637313 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationThreadState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationThreadState.kt @@ -1,15 +1,20 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.conversation.v2 import org.signal.paging.ObservablePagedData import org.thoughtcrime.securesms.conversation.ConversationData -import org.thoughtcrime.securesms.conversation.ConversationMessage -import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel /** * Represents the content that will be displayed in the conversation * thread (recycler). */ class ConversationThreadState( - val items: ObservablePagedData, + val items: ObservablePagedData>, val meta: ConversationData ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 9171d3e28d..e8305a194f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.conversation.v2 import androidx.lifecycle.ViewModel @@ -17,8 +22,8 @@ import org.signal.paging.ProxyPagingController import org.thoughtcrime.securesms.conversation.ConversationIntents.Args import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper import org.thoughtcrime.securesms.conversation.colors.NameColor +import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey import org.thoughtcrime.securesms.database.DatabaseObserver -import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.Quote import org.thoughtcrime.securesms.dependencies.ApplicationDependencies @@ -57,7 +62,7 @@ class ConversationViewModel( .onBackpressureBuffer() .distinct() - val pagingController = ProxyPagingController() + val pagingController = ProxyPagingController() val nameColorsMap: Observable> = _recipient.flatMap { repository.getNameColorsMap(it, groupAuthorNameColorHelper) } @@ -84,10 +89,10 @@ class ConversationViewModel( Observable.create { emitter -> val controller = threadState.items.controller val messageUpdateObserver = DatabaseObserver.MessageObserver { - controller.onDataItemChanged(it) + controller.onDataItemChanged(ConversationElementKey.forMessage(it.id)) } val messageInsertObserver = DatabaseObserver.MessageObserver { - controller.onDataItemInserted(it, 0) + controller.onDataItemInserted(ConversationElementKey.forMessage(it.id), 0) } val conversationObserver = DatabaseObserver.Observer { controller.onDataInvalidated() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt index ab239b81f0..e5355ae493 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.conversation.v2.data import android.content.Context @@ -20,8 +25,19 @@ import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel import org.whispersystems.signalservice.api.push.ServiceId +private typealias ConversationElement = MappingModel<*> + +sealed interface ConversationElementKey { + companion object { + fun forMessage(id: Long): ConversationElementKey = MessageBackedKey(id) + } +} + +private data class MessageBackedKey(val id: Long) : ConversationElementKey + /** * ConversationDataSource for V2. Assumes that ThreadId is never -1L. */ @@ -31,16 +47,16 @@ class ConversationDataSource( private val messageRequestData: ConversationData.MessageRequestData, private val showUniversalExpireTimerUpdate: Boolean, private var baseSize: Int -) : PagedDataSource { - - init { - check(threadId > 0) - } +) : PagedDataSource { companion object { private val TAG = Log.tag(ConversationDataSource::class.java) } + init { + check(threadId > 0) + } + private val threadRecipient: Recipient by lazy { SignalDatabase.threads.getRecipientForThreadId(threadId)!! } @@ -69,7 +85,7 @@ class ConversationDataSource( return SignalDatabase.messages.getMessageCountForThread(threadId) } - override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): List { + override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): List { val stopwatch = Stopwatch("load($start, $length), thread $threadId") var records: MutableList = ArrayList(length) val mentionHelper = MentionHelper() @@ -146,15 +162,15 @@ class ConversationDataSource( referencedIds.forEach { Recipient.resolved(RecipientId.from(it)) } stopwatch.split("recipient-resolves") - val messages = records.map { m -> + val messages = records.map { record -> ConversationMessageFactory.createWithUnresolvedData( context, - m, - m.getDisplayBody(context), - mentionHelper.getMentions(m.id), - quotedHelper.isQuoted(m.id), + record, + record.getDisplayBody(context), + mentionHelper.getMentions(record.id), + quotedHelper.isQuoted(record.id), threadRecipient - ) + ).toMappingModel() } stopwatch.split("conversion") @@ -163,9 +179,14 @@ class ConversationDataSource( return messages } - override fun load(messageId: MessageId): ConversationMessage? { - val stopwatch = Stopwatch("load($messageId), thread $threadId") - var record = SignalDatabase.messages.getMessageRecordOrNull(messageId.id) + override fun load(key: ConversationElementKey): ConversationElement? { + if (key !is MessageBackedKey) { + Log.w(TAG, "Loading non-message related id $key") + return null + } + + val stopwatch = Stopwatch("load($key), thread $threadId") + var record = SignalDatabase.messages.getMessageRecordOrNull(key.id) if ((record as? MediaMmsMessageRecord)?.parentStoryId?.isGroupReply() == true) { return null @@ -182,17 +203,17 @@ class ConversationDataSource( if (record == null) { return null } else { - val mentions = SignalDatabase.mentions.getMentionsForMessage(messageId.id) + val mentions = SignalDatabase.mentions.getMentionsForMessage(key.id) stopwatch.split("mentions") val isQuoted = SignalDatabase.messages.isQuoted(record) stopwatch.split("is-quoted") - val reactions = SignalDatabase.reactions.getReactions(messageId) + val reactions = SignalDatabase.reactions.getReactions(MessageId(key.id)) record = ReactionHelper.recordWithReactions(record, reactions) stopwatch.split("reactions") - val attachments = SignalDatabase.attachments.getAttachmentsForMessage(messageId.id) + val attachments = SignalDatabase.attachments.getAttachmentsForMessage(key.id) if (attachments.size > 0) { record = (record as MediaMmsMessageRecord).withAttachments(context, attachments) } @@ -218,14 +239,35 @@ class ConversationDataSource( mentions, isQuoted, threadRecipient - ) + ).toMappingModel() } } finally { stopwatch.stop(TAG) } } - override fun getKey(conversationMessage: ConversationMessage): MessageId { - return MessageId(conversationMessage.messageRecord.id) + override fun getKey(conversationMessage: ConversationElement): ConversationElementKey { + return when (conversationMessage) { + is ConversationMessageElement -> MessageBackedKey(conversationMessage.conversationMessage.messageRecord.id) + else -> throw AssertionError() + } + } + + private fun ConversationMessage.toMappingModel(): MappingModel<*> { + return if (messageRecord.isUpdate) { + ConversationUpdate(this) + } else if (messageRecord.isOutgoing) { + if (this.isTextOnly(context)) { + OutgoingTextOnly(this) + } else { + OutgoingMedia(this) + } + } else { + if (this.isTextOnly(context)) { + IncomingTextOnly(this) + } else { + IncomingMedia(this) + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationElements.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationElements.kt new file mode 100644 index 0000000000..b63a7c40fd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationElements.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.data + +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel + +sealed interface ConversationMessageElement { + val conversationMessage: ConversationMessage +} + +data class ConversationUpdate( + override val conversationMessage: ConversationMessage +) : ConversationMessageElement, MappingModel { + override fun areItemsTheSame(newItem: ConversationUpdate): Boolean { + return conversationMessage.messageRecord.id == newItem.conversationMessage.messageRecord.id + } + + override fun areContentsTheSame(newItem: ConversationUpdate): Boolean { + return false + } +} + +data class OutgoingTextOnly( + override val conversationMessage: ConversationMessage +) : ConversationMessageElement, MappingModel { + override fun areItemsTheSame(newItem: OutgoingTextOnly): Boolean { + return conversationMessage.messageRecord.id == newItem.conversationMessage.messageRecord.id + } + + override fun areContentsTheSame(newItem: OutgoingTextOnly): Boolean { + return false + } +} + +data class OutgoingMedia( + override val conversationMessage: ConversationMessage +) : ConversationMessageElement, MappingModel { + override fun areItemsTheSame(newItem: OutgoingMedia): Boolean { + return conversationMessage.messageRecord.id == newItem.conversationMessage.messageRecord.id + } + + override fun areContentsTheSame(newItem: OutgoingMedia): Boolean { + return false + } +} + +data class IncomingTextOnly( + override val conversationMessage: ConversationMessage +) : ConversationMessageElement, MappingModel { + override fun areItemsTheSame(newItem: IncomingTextOnly): Boolean { + return conversationMessage.messageRecord.id == newItem.conversationMessage.messageRecord.id + } + + override fun areContentsTheSame(newItem: IncomingTextOnly): Boolean { + return false + } +} + +data class IncomingMedia( + override val conversationMessage: ConversationMessage +) : ConversationMessageElement, MappingModel { + override fun areItemsTheSame(newItem: IncomingMedia): Boolean { + return conversationMessage.messageRecord.id == newItem.conversationMessage.messageRecord.id + } + + override fun areContentsTheSame(newItem: IncomingMedia): Boolean { + return false + } +}