diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1b78571c6c..f500f27aff 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -300,6 +300,16 @@
+
+
+
+
= 30) {
- return true;
- }
-
- applyInsets(insets);
-
- return true;
- }
-
- private void applyInsets(@NonNull Rect insets) {
+ public void applyInsets(@NonNull Insets insets) {
Guideline statusBarGuideline = findViewById(R.id.status_bar_guideline);
Guideline navigationBarGuideline = findViewById(R.id.navigation_bar_guideline);
Guideline parentStartGuideline = findViewById(R.id.parent_start_guideline);
@@ -83,4 +75,15 @@ public class InsetAwareConstraintLayout extends ConstraintLayout {
}
}
}
+
+ public interface WindowInsetsTypeProvider {
+
+ WindowInsetsTypeProvider ALL = () ->
+ WindowInsetsCompat.Type.ime() |
+ WindowInsetsCompat.Type.systemBars() |
+ WindowInsetsCompat.Type.displayCutout();
+
+ @WindowInsetsCompat.Type.InsetsType
+ int getInsetsType();
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java
index bf61b89eff..eec0615cbb 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java
@@ -219,6 +219,10 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
}
private int getDeviceRotation() {
+ if (isInEditMode()) {
+ return Surface.ROTATION_0;
+ }
+
if (Build.VERSION.SDK_INT >= 30) {
getContext().getDisplay().getRealMetrics(displayMetrics);
} else {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt
index b5e08c38c7..aa8fa3fed4 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt
@@ -595,6 +595,15 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
}
+
+ dividerPref()
+ switchPref(
+ title = DSLSettingsText.from("Use V2 ConversationFragment"),
+ isChecked = state.useConversationFragmentV2,
+ onClick = {
+ viewModel.setUseConversationFragmentV2(!state.useConversationFragmentV2)
+ }
+ )
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt
index 0829de176b..f924e1c8f3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt
@@ -21,5 +21,6 @@ data class InternalSettingsState(
val delayResends: Boolean,
val disableStorageService: Boolean,
val canClearOnboardingState: Boolean,
- val pnpInitialized: Boolean
+ val pnpInitialized: Boolean,
+ val useConversationFragmentV2: Boolean
)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt
index 1a93577c4d..9c0e607c95 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt
@@ -104,6 +104,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
+ fun setUseConversationFragmentV2(enabled: Boolean) {
+ SignalStore.internalValues().setUseConversationFragmentV2(enabled)
+ refresh()
+ }
+
fun addSampleReleaseNote() {
repository.addSampleReleaseNote()
}
@@ -130,7 +135,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
delayResends = SignalStore.internalValues().delayResends(),
disableStorageService = SignalStore.internalValues().storageServiceDisabled(),
canClearOnboardingState = SignalStore.storyValues().hasDownloadedOnboardingStory && Stories.isFeatureEnabled(),
- pnpInitialized = SignalStore.misc().hasPniInitializedDevices()
+ pnpInitialized = SignalStore.misc().hasPniInitializedDevices(),
+ useConversationFragmentV2 = SignalStore.internalValues().useConversationFragmentV2()
)
fun onClearOnboardingState() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java
index 4e33c23dd3..bc1f3cdb3c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java
@@ -58,7 +58,7 @@ public class ConversationDataSource implements PagedDataSource media;
- private final StickerLocator stickerLocator;
- private final boolean isBorderless;
- private final int distributionType;
- private final int startingPosition;
- private final boolean firstTimeInSelfCreatedGroup;
- private final boolean withSearchOpen;
- private final Badge giftBadge;
- private final long shareDataTimestamp;
+ public final static class Args {
+ private final RecipientId recipientId;
+ private final long threadId;
+ private final String draftText;
+ private final ArrayList media;
+ private final StickerLocator stickerLocator;
+ private final boolean isBorderless;
+ private final int distributionType;
+ private final int startingPosition;
+ private final boolean firstTimeInSelfCreatedGroup;
+ private final boolean withSearchOpen;
+ private final Badge giftBadge;
+ private final long shareDataTimestamp;
+ private final ConversationScreenType conversationScreenType;
- static Args from(@NonNull Bundle arguments) {
+ public static Args from(@NonNull Bundle arguments) {
Uri intentDataUri = getIntentData(arguments);
if (isBubbleIntentUri(intentDataUri)) {
return new Args(RecipientId.from(intentDataUri.getQueryParameter(EXTRA_RECIPIENT)),
@@ -122,7 +130,8 @@ public class ConversationIntents {
false,
false,
null,
- -1L);
+ -1L,
+ ConversationScreenType.BUBBLE);
}
return new Args(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))),
@@ -136,7 +145,8 @@ public class ConversationIntents {
arguments.getBoolean(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false),
arguments.getBoolean(EXTRA_WITH_SEARCH_OPEN, false),
arguments.getParcelable(EXTRA_GIFT_BADGE),
- arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L));
+ arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L),
+ ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)));
}
private Args(@NonNull RecipientId recipientId,
@@ -150,7 +160,8 @@ public class ConversationIntents {
boolean firstTimeInSelfCreatedGroup,
boolean withSearchOpen,
@Nullable Badge giftBadge,
- long shareDataTimestamp)
+ long shareDataTimestamp,
+ @NonNull ConversationScreenType conversationScreenType)
{
this.recipientId = recipientId;
this.threadId = threadId;
@@ -162,8 +173,9 @@ public class ConversationIntents {
this.startingPosition = startingPosition;
this.firstTimeInSelfCreatedGroup = firstTimeInSelfCreatedGroup;
this.withSearchOpen = withSearchOpen;
- this.giftBadge = giftBadge;
- this.shareDataTimestamp = shareDataTimestamp;
+ this.giftBadge = giftBadge;
+ this.shareDataTimestamp = shareDataTimestamp;
+ this.conversationScreenType = conversationScreenType;
}
public @NonNull RecipientId getRecipientId() {
@@ -221,43 +233,54 @@ public class ConversationIntents {
public long getShareDataTimestamp() {
return shareDataTimestamp;
}
+
+ public @NonNull ConversationScreenType getConversationScreenType() {
+ return conversationScreenType;
+ }
}
public final static class Builder {
- private final Context context;
- private final Class extends ConversationActivity> conversationActivityClass;
- private final RecipientId recipientId;
- private final long threadId;
+ private final Context context;
+ private final Class extends Activity> conversationActivityClass;
+ private final RecipientId recipientId;
+ private final long threadId;
- private String draftText;
- private List media;
- private StickerLocator stickerLocator;
- private boolean isBorderless;
- private int distributionType = ThreadTable.DistributionTypes.DEFAULT;
- private int startingPosition = -1;
- private Uri dataUri;
- private String dataType;
- private boolean firstTimeInSelfCreatedGroup;
- private boolean withSearchOpen;
- private Badge giftBadge;
- private long shareDataTimestamp = -1L;
+ private String draftText;
+ private List media;
+ private StickerLocator stickerLocator;
+ private boolean isBorderless;
+ private int distributionType = ThreadTable.DistributionTypes.DEFAULT;
+ private int startingPosition = -1;
+ private Uri dataUri;
+ private String dataType;
+ private boolean firstTimeInSelfCreatedGroup;
+ private boolean withSearchOpen;
+ private Badge giftBadge;
+ private long shareDataTimestamp = -1L;
+ private ConversationScreenType conversationScreenType;
private Builder(@NonNull Context context,
@NonNull RecipientId recipientId,
long threadId)
{
- this(context, ConversationActivity.class, recipientId, threadId);
+ this(
+ context,
+ getBaseConversationActivity(),
+ recipientId,
+ threadId
+ );
}
private Builder(@NonNull Context context,
- @NonNull Class extends ConversationActivity> conversationActivityClass,
+ @NonNull Class extends Activity> conversationActivityClass,
@NonNull RecipientId recipientId,
long threadId)
{
this.context = context;
this.conversationActivityClass = conversationActivityClass;
this.recipientId = recipientId;
- this.threadId = threadId;
+ this.threadId = resolveThreadId(recipientId, threadId);
+ this.conversationScreenType = ConversationScreenType.fromActivityClass(conversationActivityClass);
}
public @NonNull Builder withDraftText(@Nullable String draftText) {
@@ -309,7 +332,7 @@ public class ConversationIntents {
this.firstTimeInSelfCreatedGroup = true;
return this;
}
-
+
public Builder withGiftBadge(@NonNull Badge badge) {
this.giftBadge = badge;
return this;
@@ -319,7 +342,7 @@ public class ConversationIntents {
this.shareDataTimestamp = timestamp;
return this;
}
-
+
public @NonNull Intent build() {
if (stickerLocator != null && media != null) {
throw new IllegalStateException("Cannot have both sticker and media array");
@@ -347,6 +370,7 @@ public class ConversationIntents {
intent.putExtra(EXTRA_WITH_SEARCH_OPEN, withSearchOpen);
intent.putExtra(EXTRA_GIFT_BADGE, giftBadge);
intent.putExtra(EXTRA_SHARE_DATA_TIMESTAMP, shareDataTimestamp);
+ intent.putExtra(EXTRA_CONVERSATION_TYPE, conversationScreenType.code);
if (draftText != null) {
intent.putExtra(EXTRA_TEXT, draftText);
@@ -371,4 +395,62 @@ public class ConversationIntents {
return intent;
}
}
+
+ public enum ConversationScreenType {
+ NORMAL(0),
+ BUBBLE(1),
+ POPUP(2);
+
+ private final int code;
+
+ ConversationScreenType(int code) {
+ this.code = code;
+ }
+
+ public boolean isInBubble() {
+ return Objects.equals(this, BUBBLE);
+ }
+
+ public boolean isNormal() {
+ return Objects.equals(this, NORMAL);
+ }
+
+ private static @NonNull ConversationScreenType from(int code) {
+ for (ConversationScreenType type : values()) {
+ if (type.code == code) {
+ return type;
+ }
+ }
+
+ return NORMAL;
+ }
+
+ private static @NonNull ConversationScreenType fromActivityClass(Class extends Activity> activityClass) {
+ if (Objects.equals(activityClass, ConversationPopupActivity.class)) {
+ return POPUP;
+ } else if (Objects.equals(activityClass, BubbleConversationActivity.class)) {
+ return BUBBLE;
+ } else {
+ return NORMAL;
+ }
+ }
+ }
+
+ private static long resolveThreadId(@NonNull RecipientId recipientId, long threadId) {
+ if (threadId >= 0 && SignalStore.internalValues().useConversationFragmentV2()) {
+ Log.w(TAG, "Getting thread id from database...");
+ // TODO [alex] -- Yes, this hits the database. No, we shouldn't be doing this.
+ return SignalDatabase.threads().getOrCreateThreadIdFor(Recipient.resolved(recipientId));
+ } else {
+ return threadId;
+ }
+ }
+
+ private static Class extends Activity> getBaseConversationActivity() {
+ if (SignalStore.internalValues().useConversationFragmentV2()) {
+ return ConversationActivity.class;
+ } else {
+ return org.thoughtcrime.securesms.conversation.ConversationActivity.class;
+ }
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java
index 96f224d70a..43a8069066 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java
@@ -35,13 +35,13 @@ import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
-class ConversationRepository {
+public class ConversationRepository {
private static final String TAG = Log.tag(ConversationRepository.class);
private final Context context;
- ConversationRepository() {
+ public ConversationRepository() {
this.context = ApplicationDependencies.getApplication();
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt
new file mode 100644
index 0000000000..357bf35171
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt
@@ -0,0 +1,35 @@
+package org.thoughtcrime.securesms.conversation.v2
+
+import android.content.Intent
+import androidx.fragment.app.Fragment
+import org.thoughtcrime.securesms.components.FragmentWrapperActivity
+import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
+import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
+import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
+
+/**
+ * Wrapper activity for ConversationFragment.
+ */
+class ConversationActivity : FragmentWrapperActivity(), VoiceNoteMediaControllerOwner {
+
+ private val theme = DynamicNoActionBarTheme()
+ override val voiceNoteMediaController = VoiceNoteMediaController(this, true)
+
+ override fun onPreCreate() {
+ theme.onCreate(this)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ theme.onResume(this)
+ }
+
+ override fun getFragment(): Fragment = ConversationFragment().apply {
+ arguments = intent.extras
+ }
+
+ override fun onNewIntent(intent: Intent?) {
+ super.onNewIntent(intent)
+ error("ON NEW INTENT")
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt
new file mode 100644
index 0000000000..a48700b11d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt
@@ -0,0 +1,23 @@
+package org.thoughtcrime.securesms.conversation.v2
+
+import android.content.Context
+import android.content.DialogInterface
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.thoughtcrime.securesms.R
+
+/**
+ * Centralized object for displaying dialogs to the user from the
+ * conversation fragment.
+ */
+object ConversationDialogs {
+ /**
+ * Dialog which is displayed when the user attempts to start a video call
+ * as a non-admin in an announcement group.
+ */
+ fun displayCannotStartGroupCallDueToPermissionsDialog(context: Context) {
+ MaterialAlertDialogBuilder(context).setTitle(R.string.ConversationActivity_cant_start_group_call)
+ .setMessage(R.string.ConversationActivity_only_admins_of_this_group_can_start_a_call)
+ .setPositiveButton(R.string.ok) { d: DialogInterface, w: Int -> d.dismiss() }
+ .show()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt
new file mode 100644
index 0000000000..df599eb4bb
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt
@@ -0,0 +1,634 @@
+package org.thoughtcrime.securesms.conversation.v2
+
+import android.net.Uri
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import androidx.core.app.ActivityCompat
+import androidx.core.app.ActivityOptionsCompat
+import androidx.core.content.ContextCompat
+import androidx.core.view.ViewCompat
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.Observer
+import androidx.recyclerview.widget.RecyclerView
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.core.Observable
+import io.reactivex.rxjava3.core.Single
+import io.reactivex.rxjava3.kotlin.subscribeBy
+import org.greenrobot.eventbus.EventBus
+import org.signal.core.util.concurrent.LifecycleDisposable
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.LoggingFragment
+import org.thoughtcrime.securesms.MainActivity
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.ViewBinderDelegate
+import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
+import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
+import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState
+import org.thoughtcrime.securesms.contactshare.Contact
+import org.thoughtcrime.securesms.contactshare.ContactUtil
+import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity
+import org.thoughtcrime.securesms.conversation.ConversationAdapter
+import org.thoughtcrime.securesms.conversation.ConversationIntents
+import org.thoughtcrime.securesms.conversation.ConversationIntents.ConversationScreenType
+import org.thoughtcrime.securesms.conversation.ConversationItem
+import org.thoughtcrime.securesms.conversation.ConversationMessage
+import org.thoughtcrime.securesms.conversation.ConversationOptionsMenu
+import org.thoughtcrime.securesms.conversation.MarkReadHelper
+import org.thoughtcrime.securesms.conversation.colors.Colorizer
+import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
+import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration
+import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
+import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupCallViewModel
+import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupViewModel
+import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
+import org.thoughtcrime.securesms.database.model.MessageRecord
+import org.thoughtcrime.securesms.database.model.MmsMessageRecord
+import org.thoughtcrime.securesms.databinding.V2ConversationFragmentBinding
+import org.thoughtcrime.securesms.groups.GroupId
+import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
+import org.thoughtcrime.securesms.invites.InviteActions
+import org.thoughtcrime.securesms.linkpreview.LinkPreview
+import org.thoughtcrime.securesms.longmessage.LongMessageFragment
+import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
+import org.thoughtcrime.securesms.mms.GlideApp
+import org.thoughtcrime.securesms.notifications.v2.ConversationId
+import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment
+import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.recipients.RecipientId
+import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
+import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
+import org.thoughtcrime.securesms.stickers.StickerLocator
+import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity
+import org.thoughtcrime.securesms.util.CommunicationActions
+import org.thoughtcrime.securesms.util.ContextUtil
+import org.thoughtcrime.securesms.util.DrawableUtil
+import org.thoughtcrime.securesms.util.FullscreenHelper
+import org.thoughtcrime.securesms.util.WindowUtil
+import org.thoughtcrime.securesms.util.fragments.requireListener
+import org.thoughtcrime.securesms.util.visible
+import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
+import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil
+import java.util.Locale
+
+/**
+ * A single unified fragment for Conversations.
+ */
+class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) {
+
+ companion object {
+ private val TAG = Log.tag(ConversationFragment::class.java)
+ }
+
+ private val args: ConversationIntents.Args by lazy {
+ ConversationIntents.Args.from(requireArguments())
+ }
+
+ private val disposables = LifecycleDisposable()
+ private val binding by ViewBinderDelegate(V2ConversationFragmentBinding::bind)
+ private val viewModel: ConversationViewModel by viewModels(
+ factoryProducer = {
+ ConversationViewModel.Factory(args, ConversationRepository(requireContext()))
+ }
+ )
+
+ private val groupCallViewModel: ConversationGroupCallViewModel by viewModels(
+ factoryProducer = {
+ ConversationGroupCallViewModel.Factory(args.threadId)
+ }
+ )
+
+ private val conversationGroupViewModel: ConversationGroupViewModel by viewModels(
+ factoryProducer = {
+ ConversationGroupViewModel.Factory(args.threadId)
+ }
+ )
+
+ private val conversationTooltips = ConversationTooltips(this)
+ private lateinit var conversationOptionsMenuProvider: ConversationOptionsMenu.Provider
+ private lateinit var layoutManager: SmoothScrollingLinearLayoutManager
+ private lateinit var markReadHelper: MarkReadHelper
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ conversationOptionsMenuProvider = ConversationOptionsMenu.Provider(ConversationOptionsMenuCallback(), disposables)
+ markReadHelper = MarkReadHelper(ConversationId.forConversation(args.threadId), requireContext(), viewLifecycleOwner)
+
+ FullscreenHelper(requireActivity()).showSystemUI()
+
+ layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true)
+ binding.conversationItemRecycler.layoutManager = layoutManager
+
+ val recyclerViewColorizer = RecyclerViewColorizer(binding.conversationItemRecycler)
+ recyclerViewColorizer.setChatColors(args.chatColors)
+
+ val conversationToolbarOnScrollHelper = ConversationToolbarOnScrollHelper(
+ requireActivity(),
+ binding.toolbar,
+ viewModel::wallpaperSnapshot
+ )
+ conversationToolbarOnScrollHelper.attach(binding.conversationItemRecycler)
+
+ disposables.bindTo(viewLifecycleOwner)
+ disposables += viewModel.recipient
+ .firstOrError()
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribeBy(onSuccess = {
+ onFirstRecipientLoad(it)
+ })
+
+ presentWallpaper(args.wallpaper)
+ disposables += viewModel.recipient
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribeBy(onNext = {
+ recyclerViewColorizer.setChatColors(it.chatColors)
+ presentWallpaper(it.wallpaper)
+ presentConversationTitle(it)
+ })
+
+ EventBus.getDefault().registerForLifecycle(groupCallViewModel, viewLifecycleOwner)
+ presentGroupCallJoinButton()
+ }
+
+ override fun onResume() {
+ super.onResume()
+
+ WindowUtil.setLightNavigationBarFromTheme(requireActivity())
+ WindowUtil.setLightStatusBarFromTheme(requireActivity())
+ groupCallViewModel.peekGroupCall()
+ }
+
+ private fun onFirstRecipientLoad(recipient: Recipient) {
+ Log.d(TAG, "onFirstRecipientLoad")
+
+ val colorizer = Colorizer()
+ val adapter = ConversationAdapter(
+ requireContext(),
+ viewLifecycleOwner,
+ GlideApp.with(this),
+ Locale.getDefault(),
+ ConversationItemClickListener(),
+ recipient,
+ colorizer
+ )
+
+ adapter.setPagingController(viewModel.pagingController)
+ viewLifecycleOwner.lifecycle.addObserver(LastSeenPositionUpdater(adapter, layoutManager, viewModel))
+ binding.conversationItemRecycler.adapter = adapter
+
+ binding.conversationItemRecycler.addItemDecoration(
+ MultiselectItemDecoration(
+ requireContext()
+ ) { viewModel.wallpaperSnapshot }
+ )
+
+ disposables += viewModel
+ .conversationThreadState
+ .flatMap { it.items.data }
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribeBy(onNext = {
+ adapter.submitList(it)
+ })
+
+ disposables += viewModel
+ .nameColorsMap
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribeBy(onNext = {
+ colorizer.onNameColorsChanged(it)
+ adapter.notifyItemRangeChanged(0, adapter.itemCount)
+ })
+
+ presentActionBarMenu()
+ }
+
+ private fun invalidateOptionsMenu() {
+ // TODO [alex] -- Handle search... is there a better way to manage this state? Maybe an event system?
+ conversationOptionsMenuProvider.onCreateMenu(binding.toolbar.menu, requireActivity().menuInflater)
+ }
+
+ private fun presentActionBarMenu() {
+ invalidateOptionsMenu()
+
+ when (args.conversationScreenType) {
+ ConversationScreenType.NORMAL -> presentNavigationIconForNormal()
+ ConversationScreenType.BUBBLE -> presentNavigationIconForBubble()
+ ConversationScreenType.POPUP -> Unit
+ }
+
+ binding.toolbar.setOnMenuItemClickListener(conversationOptionsMenuProvider::onMenuItemSelected)
+ }
+
+ private fun presentNavigationIconForNormal() {
+ binding.toolbar.setNavigationIcon(R.drawable.ic_arrow_left_24)
+ binding.toolbar.setNavigationOnClickListener {
+ requireActivity().finishAfterTransition()
+ }
+ }
+
+ private fun presentNavigationIconForBubble() {
+ binding.toolbar.navigationIcon = DrawableUtil.tint(
+ ContextUtil.requireDrawable(requireContext(), R.drawable.ic_notification),
+ ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)
+ )
+
+ binding.toolbar.setNavigationOnClickListener {
+ startActivity(MainActivity.clearTop(requireContext()))
+ }
+ }
+
+ private fun presentConversationTitle(recipient: Recipient) {
+ binding.conversationTitleView.root.setTitle(GlideApp.with(this), recipient)
+ }
+
+ private fun presentWallpaper(chatWallpaper: ChatWallpaper?) {
+ if (chatWallpaper != null) {
+ chatWallpaper.loadInto(binding.conversationWallpaper)
+ ChatWallpaperDimLevelUtil.applyDimLevelForNightMode(binding.conversationWallpaperDim, chatWallpaper)
+ } else {
+ binding.conversationWallpaperDim.visible = false
+ }
+
+ binding.conversationWallpaper.visible = chatWallpaper != null
+ }
+
+ private fun presentGroupCallJoinButton() {
+ binding.conversationGroupCallJoin.setOnClickListener {
+ handleVideoCall()
+ }
+
+ disposables += groupCallViewModel.hasActiveGroupCall.subscribeBy(onNext = {
+ // invalidateOptionsMenu
+ binding.conversationGroupCallJoin.visible = it
+ })
+
+ disposables += groupCallViewModel.hasCapacity.subscribeBy(onNext = {
+ binding.conversationGroupCallJoin.setText(
+ if (it) R.string.ConversationActivity_join else R.string.ConversationActivity_full
+ )
+ })
+ }
+
+ private fun handleVideoCall() {
+ val recipient: Single = viewModel.recipient.firstOrError()
+ val hasActiveGroupCall: Single = groupCallViewModel.hasActiveGroupCall.firstOrError()
+ val isNonAdminInAnnouncementGroup: Boolean = conversationGroupViewModel.isNonAdminInAnnouncementGroup()
+ val cannotCreateGroupCall = Single.zip(recipient, hasActiveGroupCall) { r, active ->
+ r to (r.isPushV2Group && !active && isNonAdminInAnnouncementGroup)
+ }
+
+ disposables += cannotCreateGroupCall
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe { (recipient, notAllowed) ->
+ if (notAllowed) {
+ ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
+ } else {
+ CommunicationActions.startVideoCall(this, recipient)
+ }
+ }
+ }
+
+ private fun getVoiceNoteMediaController() = requireListener().voiceNoteMediaController
+
+ private inner class ConversationItemClickListener : ConversationAdapter.ItemClickListener {
+ override fun onQuoteClicked(messageRecord: MmsMessageRecord?) {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun onLinkPreviewClicked(linkPreview: LinkPreview) {
+ val activity = activity ?: return
+ CommunicationActions.openBrowserLink(activity, linkPreview.url)
+ }
+
+ override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun onMoreTextClicked(conversationRecipientId: RecipientId, messageId: Long, isMms: Boolean) {
+ context ?: return
+ LongMessageFragment.create(messageId, isMms).show(childFragmentManager, null)
+ }
+
+ override fun onStickerClicked(stickerLocator: StickerLocator) {
+ context ?: return
+ startActivity(StickerPackPreviewActivity.getIntent(stickerLocator.packId, stickerLocator.packKey))
+ }
+
+ override fun onViewOnceMessageClicked(messageRecord: MmsMessageRecord) {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun onSharedContactDetailsClicked(contact: Contact, avatarTransitionView: View) {
+ val activity = activity ?: return
+ ViewCompat.setTransitionName(avatarTransitionView, "avatar")
+ val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, avatarTransitionView, "avatar").toBundle()
+ ActivityCompat.startActivity(activity, SharedContactDetailsActivity.getIntent(activity, contact), bundle)
+ }
+
+ override fun onAddToContactsClicked(contact: Contact) {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun onMessageSharedContactClicked(choices: MutableList) {
+ val context = context ?: return
+ ContactUtil.selectRecipientThroughDialog(context, choices, Locale.getDefault()) { recipient: Recipient ->
+ CommunicationActions.startConversation(context, recipient, null)
+ }
+ }
+
+ override fun onInviteSharedContactClicked(choices: MutableList) {
+ val context = context ?: return
+ ContactUtil.selectRecipientThroughDialog(context, choices, Locale.getDefault()) { recipient: Recipient ->
+ CommunicationActions.composeSmsThroughDefaultApp(
+ context,
+ recipient,
+ getString(R.string.InviteActivity_lets_switch_to_signal, getString(R.string.install_url))
+ )
+ }
+ }
+
+ override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) {
+ parentFragment ?: return
+ ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(parentFragmentManager, null)
+ }
+
+ override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) {
+ parentFragment ?: return
+ RecipientBottomSheetDialogFragment.create(recipientId, groupId).show(parentFragmentManager, "BOTTOM")
+ }
+
+ override fun onMessageWithErrorClicked(messageRecord: MessageRecord) {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) {
+ RecaptchaProofBottomSheetFragment.show(childFragmentManager)
+ }
+
+ override fun onIncomingIdentityMismatchClicked(recipientId: RecipientId) {
+ SafetyNumberBottomSheet.forRecipientId(recipientId).show(parentFragmentManager)
+ }
+
+ override fun onRegisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer) {
+ getVoiceNoteMediaController()
+ .voiceNotePlaybackState
+ .observe(viewLifecycleOwner, onPlaybackStartObserver)
+ }
+
+ override fun onUnregisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer) {
+ getVoiceNoteMediaController()
+ .voiceNotePlaybackState
+ .removeObserver(onPlaybackStartObserver)
+ }
+
+ override fun onVoiceNotePause(uri: Uri) {
+ getVoiceNoteMediaController().pausePlayback(uri)
+ }
+
+ override fun onVoiceNotePlay(uri: Uri, messageId: Long, position: Double) {
+ getVoiceNoteMediaController().startConsecutivePlayback(uri, messageId, position)
+ }
+
+ override fun onVoiceNoteSeekTo(uri: Uri, position: Double) {
+ getVoiceNoteMediaController().seekToPosition(uri, position)
+ }
+
+ override fun onVoiceNotePlaybackSpeedChanged(uri: Uri, speed: Float) {
+ getVoiceNoteMediaController().setPlaybackSpeed(uri, speed)
+ }
+
+ override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) {
+ // TODO [alex] -- ("Not yet implemented")
+ }
+
+ override fun onChatSessionRefreshLearnMoreClicked() {
+ // TODO [alex] -- ("Not yet implemented")
+ }
+
+ override fun onBadDecryptLearnMoreClicked(author: RecipientId) {
+ // TODO [alex] -- ("Not yet implemented")
+ }
+
+ override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) {
+ // TODO [alex] -- ("Not yet implemented")
+ }
+
+ override fun onJoinGroupCallClicked() {
+ // TODO [alex] -- ("Not yet implemented")
+ }
+
+ override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) {
+ // TODO [alex] -- ("Not yet implemented")
+ }
+
+ override fun onEnableCallNotificationsClicked() {
+ // TODO [alex] -- ("Not yet implemented")
+ }
+
+ override fun onPlayInlineContent(conversationMessage: ConversationMessage?) {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun onInMemoryMessageClicked(messageRecord: InMemoryMessageRecord) {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun onViewGroupDescriptionChange(groupId: GroupId?, description: String, isMessageRequestAccepted: Boolean) {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun onChangeNumberUpdateContact(recipient: Recipient) {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun onCallToAction(action: String) {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun onDonateClicked() {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun onBlockJoinRequest(recipient: Recipient) {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun onRecipientNameClicked(target: RecipientId) {
+ // TODO [alex] ("Not yet implemented")
+ }
+
+ override fun onInviteToSignalClicked() {
+ val recipient = viewModel.recipientSnapshot ?: return
+ InviteActions.inviteUserToSignal(
+ requireContext(),
+ recipient,
+ {}, // TODO [alex] -- append to compose
+ this@ConversationFragment::startActivity
+ )
+ }
+
+ override fun onActivatePaymentsClicked() {
+ // TODO [alex] -- ("Not yet implemented")
+ }
+
+ override fun onSendPaymentClicked(recipientId: RecipientId) {
+ // TODO [alex] -- ("Not yet implemented")
+ }
+
+ override fun onScheduledIndicatorClicked(view: View, messageRecord: MessageRecord) {
+ // TODO [alex] -- ("Not yet implemented")
+ }
+
+ override fun onUrlClicked(url: String): Boolean {
+ return CommunicationActions.handlePotentialGroupLinkUrl(requireActivity(), url) ||
+ CommunicationActions.handlePotentialProxyLinkUrl(requireActivity(), url)
+ }
+
+ override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) {
+ // TODO [alex] -- ("Not yet implemented")
+ }
+
+ override fun onGiftBadgeRevealed(messageRecord: MessageRecord) {
+ // TODO [alex] -- ("Not yet implemented")
+ }
+
+ override fun goToMediaPreview(parent: ConversationItem?, sharedElement: View?, args: MediaIntentFactory.MediaPreviewArgs?) {
+ // TODO [alex] -- ("Not yet implemented")
+ }
+
+ override fun onItemClick(item: MultiselectPart?) {
+ // TODO [alex] -- ("Not yet implemented")
+ }
+
+ override fun onItemLongClick(itemView: View?, item: MultiselectPart?) {
+ // TODO [alex] -- ("Not yet implemented")
+ }
+ }
+
+ private inner class ConversationOptionsMenuCallback : ConversationOptionsMenu.Callback {
+ override fun getSnapshot(): ConversationOptionsMenu.Snapshot {
+ val recipient: Recipient? = viewModel.recipientSnapshot
+ return ConversationOptionsMenu.Snapshot(
+ recipient = recipient,
+ isPushAvailable = true, // TODO [alex]
+ canShowAsBubble = Observable.empty(),
+ isActiveGroup = recipient?.isActiveGroup == true,
+ isActiveV2Group = recipient?.let { it.isActiveGroup && it.isPushV2Group } == true,
+ isInActiveGroup = recipient?.isActiveGroup == false,
+ hasActiveGroupCall = groupCallViewModel.hasActiveGroupCallSnapshot,
+ distributionType = args.distributionType,
+ threadId = args.threadId,
+ isInMessageRequest = false, // TODO [alex]
+ isInBubble = args.conversationScreenType.isInBubble
+ )
+ }
+
+ override fun onOptionsMenuCreated(menu: Menu) {
+ // TODO [alex]
+ }
+
+ override fun handleVideo() {
+ this@ConversationFragment.handleVideoCall()
+ }
+
+ override fun handleDial(isSecure: Boolean) {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleViewMedia() {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleAddShortcut() {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleSearch() {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleAddToContacts() {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleDisplayGroupRecipients() {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleDistributionBroadcastEnabled(menuItem: MenuItem) {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleDistributionConversationEnabled(menuItem: MenuItem) {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleManageGroup() {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleLeavePushGroup() {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleInviteLink() {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleMuteNotifications() {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleUnmuteNotifications() {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleConversationSettings() {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleSelectMessageExpiration() {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleCreateBubble() {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun handleGoHome() {
+ // TODO [alex] - ("Not yet implemented")
+ }
+
+ override fun showExpiring(recipient: Recipient) {
+ binding.conversationTitleView.root.showExpiring(recipient)
+ }
+
+ override fun clearExpiring() {
+ binding.conversationTitleView.root.clearExpiring()
+ }
+
+ override fun showGroupCallingTooltip() {
+ conversationTooltips.displayGroupCallingTooltip(requireView().findViewById(R.id.menu_video_secure))
+ }
+ }
+
+ private class LastSeenPositionUpdater(
+ val adapter: ConversationAdapter,
+ val layoutManager: SmoothScrollingLinearLayoutManager,
+ val viewModel: ConversationViewModel
+ ) : DefaultLifecycleObserver {
+ override fun onPause(owner: LifecycleOwner) {
+ val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()
+ val firstVisiblePosition = layoutManager.findFirstCompletelyVisibleItemPosition()
+ val lastVisibleMessageTimestamp = if (firstVisiblePosition > 0 && lastVisiblePosition != RecyclerView.NO_POSITION) {
+ adapter.getLastVisibleConversationMessage(lastVisiblePosition)?.messageRecord?.dateReceived ?: 0L
+ } else {
+ 0L
+ }
+
+ viewModel.setLastScrolled(lastVisibleMessageTimestamp)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt
new file mode 100644
index 0000000000..a0617d35ca
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt
@@ -0,0 +1,127 @@
+package org.thoughtcrime.securesms.conversation.v2
+
+import android.content.Context
+import io.reactivex.rxjava3.core.Observable
+import io.reactivex.rxjava3.core.Single
+import io.reactivex.rxjava3.kotlin.subscribeBy
+import io.reactivex.rxjava3.schedulers.Schedulers
+import org.signal.core.util.concurrent.SignalExecutors
+import org.signal.paging.PagedData
+import org.signal.paging.PagingConfig
+import org.thoughtcrime.securesms.conversation.ConversationDataSource
+import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
+import org.thoughtcrime.securesms.conversation.colors.NameColor
+import org.thoughtcrime.securesms.database.DatabaseObserver
+import org.thoughtcrime.securesms.database.SignalDatabase
+import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.recipients.RecipientId
+import kotlin.math.max
+
+class ConversationRepository(context: Context) {
+
+ private val applicationContext = context.applicationContext
+ private val oldConversationRepository = org.thoughtcrime.securesms.conversation.ConversationRepository()
+
+ /**
+ * Observes the recipient tied to the given thread id, returning an error if
+ * the thread id does not exist or somehow does not have a recipient attached to it.
+ */
+ fun observeRecipientForThread(threadId: Long): Observable {
+ return Observable.create { emitter ->
+ val recipientId = SignalDatabase.threads.getRecipientIdForThreadId(threadId)
+
+ if (recipientId != null) {
+ val disposable = Recipient.live(recipientId).observable()
+ .subscribeOn(Schedulers.io())
+ .subscribeBy(onNext = emitter::onNext)
+
+ emitter.setCancellable {
+ disposable.dispose()
+ }
+ } else {
+ emitter.onError(Exception("Thread $threadId does not exist."))
+ }
+ }.subscribeOn(Schedulers.io())
+ }
+
+ /**
+ * Loads the details necessary to display the conversation thread.
+ */
+ fun getConversationThreadState(threadId: Long, requestedStartPosition: Int): Single {
+ return Single.create { emitter ->
+ val recipient = SignalDatabase.threads.getRecipientForThreadId(threadId)!!
+ val metadata = oldConversationRepository.getConversationData(threadId, recipient, requestedStartPosition)
+ val messageRequestData = metadata.messageRequestData
+ val startPosition = when {
+ metadata.shouldJumpToMessage() -> metadata.jumpToPosition
+ messageRequestData.isMessageRequestAccepted && metadata.shouldScrollToLastSeen() -> metadata.lastSeenPosition
+ messageRequestData.isMessageRequestAccepted -> metadata.lastScrolledPosition
+ else -> metadata.threadSize
+ }
+ val dataSource = ConversationDataSource(
+ applicationContext,
+ threadId,
+ messageRequestData,
+ metadata.showUniversalExpireTimerMessage,
+ metadata.threadSize
+ )
+ val config = PagingConfig.Builder().setPageSize(25)
+ .setBufferPages(2)
+ .setStartIndex(max(startPosition, 0))
+ .build()
+
+ val threadState = ConversationThreadState(
+ items = PagedData.createForObservable(dataSource, config),
+ meta = metadata
+ )
+
+ val controller = threadState.items.controller
+ val messageUpdateObserver = DatabaseObserver.MessageObserver {
+ controller.onDataItemChanged(it)
+ }
+ val messageInsertObserver = DatabaseObserver.MessageObserver {
+ controller.onDataItemInserted(it, 0)
+ }
+ val conversationObserver = DatabaseObserver.Observer {
+ controller.onDataInvalidated()
+ }
+
+ ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageUpdateObserver)
+ ApplicationDependencies.getDatabaseObserver().registerMessageInsertObserver(threadId, messageInsertObserver)
+ ApplicationDependencies.getDatabaseObserver().registerConversationObserver(threadId, conversationObserver)
+
+ emitter.setCancellable {
+ ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageUpdateObserver)
+ ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageInsertObserver)
+ ApplicationDependencies.getDatabaseObserver().unregisterObserver(conversationObserver)
+ }
+
+ emitter.onSuccess(threadState)
+ }
+ }
+
+ /**
+ * Generates the name color-map for groups.
+ */
+ fun getNameColorsMap(
+ recipient: Recipient,
+ groupAuthorNameColorHelper: GroupAuthorNameColorHelper
+ ): Observable