Add ConversationAdapterV2.

This commit is contained in:
Cody Henthorne 2023-05-05 13:16:46 -04:00
parent a1eb33b1f6
commit 65d5f4c426
21 changed files with 660 additions and 92 deletions

6
.idea/copyright/Signal.xml generated Normal file
View file

@ -0,0 +1,6 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="Copyright &amp;#36;today.year Signal Messenger, LLC&#10;SPDX-License-Identifier: AGPL-3.0-only" />
<option name="myName" value="Signal" />
</copyright>
</component>

7
.idea/copyright/profiles_settings.xml generated Normal file
View file

@ -0,0 +1,7 @@
<component name="CopyrightManager">
<settings>
<module2copyright>
<element module="All" copyright="Signal" />
</module2copyright>
</settings>
</component>

View file

@ -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} {
}

9
.idea/fileTemplates/internal/Class.java generated Normal file
View file

@ -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} {
}

9
.idea/fileTemplates/internal/Enum.java generated Normal file
View file

@ -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} {
}

View file

@ -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} {
}

View file

@ -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} {
}

View file

@ -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} {
}

View file

@ -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")

View file

@ -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} {
}

View file

@ -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<ConversationMessage, RecyclerView.ViewHolder>
implements StickyHeaderDecoration.StickyHeaderAdapter<ConversationAdapter.StickyHeaderViewHolder>
implements StickyHeaderDecoration.StickyHeaderAdapter<ConversationAdapter.StickyHeaderViewHolder>,
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);

View file

@ -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<MultiselectPart>
data class PulseRequest(val position: Int, val isOutgoing: Boolean)
}

View file

@ -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)
}

View file

@ -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<Long> getLatestTimestamp(@NonNull ConversationAdapter conversationAdapter,
@NonNull SmoothScrollingLinearLayoutManager layoutManager)
public static @NonNull Optional<Long> 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) {

View file

@ -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<MultiselectPart> {
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<Multiselectable> = 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()

View file

@ -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<ConversationElementKey>(), ConversationAdapterBridge {
companion object {
private val TAG = Log.tag(ConversationAdapterV2::class.java)
}
private val _selected = hashSetOf<MultiselectPart>()
override val selectedItems: Set<MultiselectPart>
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<View>(R.layout.conversation_item_update, parent, false)
ConversationUpdateViewHolder(view)
}
registerFactory(OutgoingTextOnly::class.java) { parent ->
val view = CachedInflater.from(parent.context).inflate<View>(R.layout.conversation_item_sent_text_only, parent, false)
OutgoingTextOnlyViewHolder(view)
}
registerFactory(OutgoingMedia::class.java) { parent ->
val view = CachedInflater.from(parent.context).inflate<View>(R.layout.conversation_item_sent_multimedia, parent, false)
OutgoingMediaViewHolder(view)
}
registerFactory(IncomingTextOnly::class.java) { parent ->
val view = CachedInflater.from(parent.context).inflate<View>(R.layout.conversation_item_received_text_only, parent, false)
IncomingTextOnlyViewHolder(view)
}
registerFactory(IncomingMedia::class.java) { parent ->
val view = CachedInflater.from(parent.context).inflate<View>(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<ConversationUpdate>(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<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)
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<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)
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<T>(itemView: View) : MappingViewHolder<T>(itemView), GiphyMp4Playable, Colorizable {
val bindable: BindableConversationItem
get() = itemView as BindableConversationItem
protected val previousMessage: Optional<MessageRecord>
get() = getConversationMessage(bindingAdapterPosition + 1)?.messageRecord.toOptional()
protected val nextMessage: Optional<MessageRecord>
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)
}
}
}

View file

@ -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<Intent>
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) {

View file

@ -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<MessageId, ConversationMessage>,
val items: ObservablePagedData<ConversationElementKey, MappingModel<*>>,
val meta: ConversationData
)

View file

@ -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<MessageId>()
val pagingController = ProxyPagingController<ConversationElementKey>()
val nameColorsMap: Observable<Map<RecipientId, NameColor>> = _recipient.flatMap { repository.getNameColorsMap(it, groupAuthorNameColorHelper) }
@ -84,10 +89,10 @@ class ConversationViewModel(
Observable.create<Unit> { 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()

View file

@ -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<MessageId, ConversationMessage> {
init {
check(threadId > 0)
}
) : PagedDataSource<ConversationElementKey, ConversationElement> {
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<ConversationMessage> {
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): List<ConversationElement> {
val stopwatch = Stopwatch("load($start, $length), thread $threadId")
var records: MutableList<MessageRecord> = 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)
}
}
}
}

View file

@ -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<ConversationUpdate> {
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<OutgoingTextOnly> {
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<OutgoingMedia> {
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<IncomingTextOnly> {
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<IncomingMedia> {
override fun areItemsTheSame(newItem: IncomingMedia): Boolean {
return conversationMessage.messageRecord.id == newItem.conversationMessage.messageRecord.id
}
override fun areContentsTheSame(newItem: IncomingMedia): Boolean {
return false
}
}