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.Colorizable;
import org.thoughtcrime.securesms.conversation.colors.Colorizer; import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; 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.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
@ -78,7 +79,8 @@ import java.util.Set;
*/ */
public class ConversationAdapter public class ConversationAdapter
extends ListAdapter<ConversationMessage, RecyclerView.ViewHolder> 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); 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) { public @Nullable ConversationMessage getItem(int position) {
position = isTypingViewEnabled() ? position - 1 : position; position = isTypingViewEnabled() ? position - 1 : position;
@ -453,7 +459,7 @@ public class ConversationAdapter
} }
} }
boolean hasNoConversationMessages() { public boolean hasNoConversationMessages() {
return super.getItemCount() == 0; 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 { public interface ItemClickListener extends BindableConversationItem.EventListener {
void onItemClick(MultiselectPart item); void onItemClick(MultiselectPart item);
void onItemLongClick(View itemView, 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 package org.thoughtcrime.securesms.conversation
import android.view.Menu import android.view.Menu
@ -8,6 +13,7 @@ import androidx.core.view.MenuProvider
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.ThreadTable import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
@ -18,6 +24,8 @@ import org.thoughtcrime.securesms.recipients.Recipient
*/ */
internal object ConversationOptionsMenu { internal object ConversationOptionsMenu {
private val TAG = Log.tag(ConversationOptionsMenu::class.java)
/** /**
* MenuProvider implementation for the conversation options menu. * MenuProvider implementation for the conversation options menu.
*/ */
@ -43,7 +51,12 @@ internal object ConversationOptionsMenu {
isInBubble isInBubble
) = callback.getSnapshot() ) = 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) { if (isActiveGroup) {
menuInflater.inflate(R.menu.conversation_message_requests_group, menu) 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; package org.thoughtcrime.securesms.conversation;
import android.content.Context; import android.content.Context;
@ -5,12 +10,12 @@ import android.content.Context;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.lifecycle.Lifecycle; import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LifecycleOwner;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log; 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.MessageTable;
import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable; 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. * @return A Present(Long) if there's a timestamp to proceed with, or Empty if this request should be ignored.
*/ */
@SuppressWarnings("resource") @SuppressWarnings("resource")
public static @NonNull Optional<Long> getLatestTimestamp(@NonNull ConversationAdapter conversationAdapter, public static @NonNull Optional<Long> getLatestTimestamp(@NonNull ConversationAdapterBridge conversationAdapter,
@NonNull SmoothScrollingLinearLayoutManager layoutManager) @NonNull LinearLayoutManager layoutManager)
{ {
if (conversationAdapter.hasNoConversationMessages()) { if (conversationAdapter.hasNoConversationMessages()) {
return Optional.empty(); return Optional.empty();
@ -84,9 +89,9 @@ public class MarkReadHelper {
return Optional.empty(); return Optional.empty();
} }
ConversationMessage item = conversationAdapter.getItem(position); ConversationMessage item = conversationAdapter.getConversationMessage(position);
if (item == null) { if (item == null) {
item = conversationAdapter.getItem(position + 1); item = conversationAdapter.getConversationMessage(position + 1);
} }
if (item != null) { 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 package org.thoughtcrime.securesms.conversation.mutiselect
import android.animation.Animator import android.animation.Animator
@ -27,8 +32,8 @@ import com.airbnb.lottie.SimpleColorFilter
import com.google.android.material.animation.ArgbEvaluatorCompat import com.google.android.material.animation.ArgbEvaluatorCompat
import org.signal.core.util.SetUtil import org.signal.core.util.SetUtil
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationAdapter import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge
import org.thoughtcrime.securesms.conversation.ConversationAdapter.PulseRequest import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge.PulseRequest
import org.thoughtcrime.securesms.conversation.ConversationItem import org.thoughtcrime.securesms.conversation.ConversationItem
import org.thoughtcrime.securesms.util.ThemeUtil import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.ViewUtil
@ -118,7 +123,7 @@ class MultiselectItemDecoration(
} }
private fun getCurrentSelection(parent: RecyclerView): Set<MultiselectPart> { 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) { override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
@ -150,7 +155,7 @@ class MultiselectItemDecoration(
outRect.setEmpty() outRect.setEmpty()
updateChildOffsets(parent, view) updateChildOffsets(parent, view)
consumePulseRequest(parent.adapter as ConversationAdapter) consumePulseRequest(parent.adapter as ConversationAdapterBridge)
} }
/** /**
@ -158,7 +163,7 @@ class MultiselectItemDecoration(
*/ */
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { 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()) { if (adapter.selectedItems.isEmpty()) {
drawFocusShadeUnderIfNecessary(canvas, parent) drawFocusShadeUnderIfNecessary(canvas, parent)
@ -221,7 +226,7 @@ class MultiselectItemDecoration(
* Draws the selected check or empty circle. * Draws the selected check or empty circle.
*/ */
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { 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()) { if (adapter.selectedItems.isEmpty()) {
drawFocusShadeOverIfNecessary(canvas, parent) drawFocusShadeOverIfNecessary(canvas, parent)
} }
@ -232,7 +237,7 @@ class MultiselectItemDecoration(
invalidateIfEnterExitAnimatorsAreRunning(parent) 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 drawCircleBehindSelector = chatWallpaperProvider()?.isPhoto == true
val multiselectChildren: Sequence<Multiselectable> = parent.children.filterIsInstance(Multiselectable::class.java) 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. * called in getItemOffsets to ensure the gutter goes away when multiselect mode ends.
*/ */
private fun updateChildOffsets(parent: RecyclerView, child: View) { 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 isLtr = ViewUtil.isLtr(child)
val isAnimatingSelection = enterExitAnimation != null && isInitialAnimation() val isAnimatingSelection = enterExitAnimation != null && isInitialAnimation()
@ -542,8 +547,8 @@ class MultiselectItemDecoration(
} }
} }
private fun consumePulseRequest(adapter: ConversationAdapter) { private fun consumePulseRequest(adapter: ConversationAdapterBridge) {
val pulseRequest = adapter.consumePulseRequest() val pulseRequest: PulseRequest? = adapter.consumePulseRequest()
if (pulseRequest != null) { if (pulseRequest != null) {
val pulseColor = if (pulseRequest.isOutgoing) pulseOutgoingColor else pulseIncomingColor val pulseColor = if (pulseRequest.isOutgoing) pulseOutgoingColor else pulseIncomingColor
pulseRequestAnimators[pulseRequest]?.cancel() 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 package org.thoughtcrime.securesms.conversation.v2
import android.annotation.SuppressLint import android.annotation.SuppressLint
@ -169,12 +174,12 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
private val colorizer = Colorizer() private val colorizer = Colorizer()
private lateinit var conversationOptionsMenuProvider: ConversationOptionsMenu.Provider private lateinit var conversationOptionsMenuProvider: ConversationOptionsMenu.Provider
private lateinit var layoutManager: SmoothScrollingLinearLayoutManager private lateinit var layoutManager: LinearLayoutManager
private lateinit var markReadHelper: MarkReadHelper private lateinit var markReadHelper: MarkReadHelper
private lateinit var giphyMp4ProjectionRecycler: GiphyMp4ProjectionRecycler private lateinit var giphyMp4ProjectionRecycler: GiphyMp4ProjectionRecycler
private lateinit var addToContactsLauncher: ActivityResultLauncher<Intent> private lateinit var addToContactsLauncher: ActivityResultLauncher<Intent>
private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate
private lateinit var adapter: ConversationAdapter private lateinit var adapter: ConversationAdapterV2
private lateinit var recyclerViewColorizer: RecyclerViewColorizer private lateinit var recyclerViewColorizer: RecyclerViewColorizer
private var animationsAllowed = false private var animationsAllowed = false
@ -434,14 +439,12 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
binding.conversationItemRecycler.layoutManager = layoutManager binding.conversationItemRecycler.layoutManager = layoutManager
binding.conversationItemRecycler.addOnScrollListener(ScrollListener()) binding.conversationItemRecycler.addOnScrollListener(ScrollListener())
adapter = ConversationAdapter( adapter = ConversationAdapterV2(
requireContext(), lifecycleOwner = viewLifecycleOwner,
viewLifecycleOwner, glideRequests = GlideApp.with(this),
GlideApp.with(this), clickListener = ConversationItemClickListener(),
Locale.getDefault(), hasWallpaper = args.wallpaper != null,
ConversationItemClickListener(), colorizer = colorizer
args.wallpaper != null,
colorizer
) )
scrollToPositionDelegate = ScrollToPositionDelegate( scrollToPositionDelegate = ScrollToPositionDelegate(
@ -500,7 +503,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
return callback return callback
} }
private fun toast(@StringRes toastTextId: Int, toastDuration: Int) { private fun toast(@StringRes toastTextId: Int, toastDuration: Int = Toast.LENGTH_SHORT) {
ThreadUtil.runOnMain { ThreadUtil.runOnMain {
if (context != null) { if (context != null) {
Toast.makeText(context, toastTextId, toastDuration).show() Toast.makeText(context, toastTextId, toastDuration).show()
@ -570,7 +573,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
if (quote.isOriginalMissing) { if (quote.isOriginalMissing) {
Log.i(TAG, "onQuoteClicked: Original message is missing.") 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 return
} }
@ -594,7 +597,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
if (it >= 0) { if (it >= 0) {
moveToPosition(it) moveToPosition(it)
} else { } 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( private class LastSeenPositionUpdater(
val adapter: ConversationAdapter, val adapter: ConversationAdapterV2,
val layoutManager: SmoothScrollingLinearLayoutManager, val layoutManager: LinearLayoutManager,
val viewModel: ConversationViewModel val viewModel: ConversationViewModel
) : DefaultLifecycleObserver { ) : DefaultLifecycleObserver {
override fun onPause(owner: LifecycleOwner) { 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 package org.thoughtcrime.securesms.conversation.v2
import org.signal.paging.ObservablePagedData import org.signal.paging.ObservablePagedData
import org.thoughtcrime.securesms.conversation.ConversationData import org.thoughtcrime.securesms.conversation.ConversationData
import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
/** /**
* Represents the content that will be displayed in the conversation * Represents the content that will be displayed in the conversation
* thread (recycler). * thread (recycler).
*/ */
class ConversationThreadState( class ConversationThreadState(
val items: ObservablePagedData<MessageId, ConversationMessage>, val items: ObservablePagedData<ConversationElementKey, MappingModel<*>>,
val meta: ConversationData 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 package org.thoughtcrime.securesms.conversation.v2
import androidx.lifecycle.ViewModel 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.ConversationIntents.Args
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
import org.thoughtcrime.securesms.conversation.colors.NameColor 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.DatabaseObserver
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.Quote import org.thoughtcrime.securesms.database.model.Quote
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@ -57,7 +62,7 @@ class ConversationViewModel(
.onBackpressureBuffer() .onBackpressureBuffer()
.distinct() .distinct()
val pagingController = ProxyPagingController<MessageId>() val pagingController = ProxyPagingController<ConversationElementKey>()
val nameColorsMap: Observable<Map<RecipientId, NameColor>> = _recipient.flatMap { repository.getNameColorsMap(it, groupAuthorNameColorHelper) } val nameColorsMap: Observable<Map<RecipientId, NameColor>> = _recipient.flatMap { repository.getNameColorsMap(it, groupAuthorNameColorHelper) }
@ -84,10 +89,10 @@ class ConversationViewModel(
Observable.create<Unit> { emitter -> Observable.create<Unit> { emitter ->
val controller = threadState.items.controller val controller = threadState.items.controller
val messageUpdateObserver = DatabaseObserver.MessageObserver { val messageUpdateObserver = DatabaseObserver.MessageObserver {
controller.onDataItemChanged(it) controller.onDataItemChanged(ConversationElementKey.forMessage(it.id))
} }
val messageInsertObserver = DatabaseObserver.MessageObserver { val messageInsertObserver = DatabaseObserver.MessageObserver {
controller.onDataItemInserted(it, 0) controller.onDataItemInserted(ConversationElementKey.forMessage(it.id), 0)
} }
val conversationObserver = DatabaseObserver.Observer { val conversationObserver = DatabaseObserver.Observer {
controller.onDataInvalidated() 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 package org.thoughtcrime.securesms.conversation.v2.data
import android.content.Context 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.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.whispersystems.signalservice.api.push.ServiceId 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. * ConversationDataSource for V2. Assumes that ThreadId is never -1L.
*/ */
@ -31,16 +47,16 @@ class ConversationDataSource(
private val messageRequestData: ConversationData.MessageRequestData, private val messageRequestData: ConversationData.MessageRequestData,
private val showUniversalExpireTimerUpdate: Boolean, private val showUniversalExpireTimerUpdate: Boolean,
private var baseSize: Int private var baseSize: Int
) : PagedDataSource<MessageId, ConversationMessage> { ) : PagedDataSource<ConversationElementKey, ConversationElement> {
init {
check(threadId > 0)
}
companion object { companion object {
private val TAG = Log.tag(ConversationDataSource::class.java) private val TAG = Log.tag(ConversationDataSource::class.java)
} }
init {
check(threadId > 0)
}
private val threadRecipient: Recipient by lazy { private val threadRecipient: Recipient by lazy {
SignalDatabase.threads.getRecipientForThreadId(threadId)!! SignalDatabase.threads.getRecipientForThreadId(threadId)!!
} }
@ -69,7 +85,7 @@ class ConversationDataSource(
return SignalDatabase.messages.getMessageCountForThread(threadId) 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") val stopwatch = Stopwatch("load($start, $length), thread $threadId")
var records: MutableList<MessageRecord> = ArrayList(length) var records: MutableList<MessageRecord> = ArrayList(length)
val mentionHelper = MentionHelper() val mentionHelper = MentionHelper()
@ -146,15 +162,15 @@ class ConversationDataSource(
referencedIds.forEach { Recipient.resolved(RecipientId.from(it)) } referencedIds.forEach { Recipient.resolved(RecipientId.from(it)) }
stopwatch.split("recipient-resolves") stopwatch.split("recipient-resolves")
val messages = records.map { m -> val messages = records.map { record ->
ConversationMessageFactory.createWithUnresolvedData( ConversationMessageFactory.createWithUnresolvedData(
context, context,
m, record,
m.getDisplayBody(context), record.getDisplayBody(context),
mentionHelper.getMentions(m.id), mentionHelper.getMentions(record.id),
quotedHelper.isQuoted(m.id), quotedHelper.isQuoted(record.id),
threadRecipient threadRecipient
) ).toMappingModel()
} }
stopwatch.split("conversion") stopwatch.split("conversion")
@ -163,9 +179,14 @@ class ConversationDataSource(
return messages return messages
} }
override fun load(messageId: MessageId): ConversationMessage? { override fun load(key: ConversationElementKey): ConversationElement? {
val stopwatch = Stopwatch("load($messageId), thread $threadId") if (key !is MessageBackedKey) {
var record = SignalDatabase.messages.getMessageRecordOrNull(messageId.id) 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) { if ((record as? MediaMmsMessageRecord)?.parentStoryId?.isGroupReply() == true) {
return null return null
@ -182,17 +203,17 @@ class ConversationDataSource(
if (record == null) { if (record == null) {
return null return null
} else { } else {
val mentions = SignalDatabase.mentions.getMentionsForMessage(messageId.id) val mentions = SignalDatabase.mentions.getMentionsForMessage(key.id)
stopwatch.split("mentions") stopwatch.split("mentions")
val isQuoted = SignalDatabase.messages.isQuoted(record) val isQuoted = SignalDatabase.messages.isQuoted(record)
stopwatch.split("is-quoted") stopwatch.split("is-quoted")
val reactions = SignalDatabase.reactions.getReactions(messageId) val reactions = SignalDatabase.reactions.getReactions(MessageId(key.id))
record = ReactionHelper.recordWithReactions(record, reactions) record = ReactionHelper.recordWithReactions(record, reactions)
stopwatch.split("reactions") stopwatch.split("reactions")
val attachments = SignalDatabase.attachments.getAttachmentsForMessage(messageId.id) val attachments = SignalDatabase.attachments.getAttachmentsForMessage(key.id)
if (attachments.size > 0) { if (attachments.size > 0) {
record = (record as MediaMmsMessageRecord).withAttachments(context, attachments) record = (record as MediaMmsMessageRecord).withAttachments(context, attachments)
} }
@ -218,14 +239,35 @@ class ConversationDataSource(
mentions, mentions,
isQuoted, isQuoted,
threadRecipient threadRecipient
) ).toMappingModel()
} }
} finally { } finally {
stopwatch.stop(TAG) stopwatch.stop(TAG)
} }
} }
override fun getKey(conversationMessage: ConversationMessage): MessageId { override fun getKey(conversationMessage: ConversationElement): ConversationElementKey {
return MessageId(conversationMessage.messageRecord.id) 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
}
}