Add ConversationAdapterV2.
This commit is contained in:
parent
a1eb33b1f6
commit
65d5f4c426
21 changed files with 660 additions and 92 deletions
6
.idea/copyright/Signal.xml
generated
Normal file
6
.idea/copyright/Signal.xml
generated
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<component name="CopyrightManager">
|
||||||
|
<copyright>
|
||||||
|
<option name="notice" value="Copyright &#36;today.year Signal Messenger, LLC SPDX-License-Identifier: AGPL-3.0-only" />
|
||||||
|
<option name="myName" value="Signal" />
|
||||||
|
</copyright>
|
||||||
|
</component>
|
7
.idea/copyright/profiles_settings.xml
generated
Normal file
7
.idea/copyright/profiles_settings.xml
generated
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<component name="CopyrightManager">
|
||||||
|
<settings>
|
||||||
|
<module2copyright>
|
||||||
|
<element module="All" copyright="Signal" />
|
||||||
|
</module2copyright>
|
||||||
|
</settings>
|
||||||
|
</component>
|
9
.idea/fileTemplates/internal/AnnotationType.java
generated
Normal file
9
.idea/fileTemplates/internal/AnnotationType.java
generated
Normal 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
9
.idea/fileTemplates/internal/Class.java
generated
Normal 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
9
.idea/fileTemplates/internal/Enum.java
generated
Normal 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} {
|
||||||
|
}
|
9
.idea/fileTemplates/internal/Interface.java
generated
Normal file
9
.idea/fileTemplates/internal/Interface.java
generated
Normal 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} {
|
||||||
|
}
|
11
.idea/fileTemplates/internal/Kotlin Class.kt
generated
Normal file
11
.idea/fileTemplates/internal/Kotlin Class.kt
generated
Normal 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} {
|
||||||
|
}
|
11
.idea/fileTemplates/internal/Kotlin Enum.kt
generated
Normal file
11
.idea/fileTemplates/internal/Kotlin Enum.kt
generated
Normal 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} {
|
||||||
|
}
|
9
.idea/fileTemplates/internal/Kotlin File.kt
generated
Normal file
9
.idea/fileTemplates/internal/Kotlin File.kt
generated
Normal 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")
|
11
.idea/fileTemplates/internal/Kotlin Interface.kt
generated
Normal file
11
.idea/fileTemplates/internal/Kotlin Interface.kt
generated
Normal 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} {
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue