Display thread header in CFv2.

This commit is contained in:
Cody Henthorne
2023-05-11 15:39:59 -04:00
committed by Greyson Parrelli
parent ffbbdc1576
commit 3ba128793a
26 changed files with 235 additions and 49 deletions

View File

@@ -115,5 +115,6 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onGiftBadgeRevealed(@NonNull MessageRecord messageRecord); void onGiftBadgeRevealed(@NonNull MessageRecord messageRecord);
void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args); void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args);
void onEditedIndicatorClicked(@NonNull MessageRecord messageRecord); void onEditedIndicatorClicked(@NonNull MessageRecord messageRecord);
void onShowGroupDescriptionClicked(@NonNull String groupName, @NonNull String description, boolean shouldLinkifyWebLinks);
} }
} }

View File

@@ -19,7 +19,7 @@ class CallLogPagedDataSource(
return callsCount + hasFilter.toInt() + hasCallLinkRow.toInt() return callsCount + hasFilter.toInt() + hasCallLinkRow.toInt()
} }
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> { override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
val calls = mutableListOf<CallLogRow>() val calls = mutableListOf<CallLogRow>()
val callLimit = length - hasCallLinkRow.toInt() val callLimit = length - hasCallLinkRow.toInt()

View File

@@ -74,7 +74,7 @@ class ContactSearchPagedDataSource(
return searchSize return searchSize
} }
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<ContactSearchData> { override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<ContactSearchData> {
val sections: List<ContactSearchConfiguration.Section> = if (displayEmptyState) { val sections: List<ContactSearchConfiguration.Section> = if (displayEmptyState) {
contactConfiguration.emptyStateSections contactConfiguration.emptyStateSections
} else { } else {

View File

@@ -37,7 +37,6 @@ import org.whispersystems.signalservice.api.push.ServiceId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Set; import java.util.Set;
/** /**
@@ -99,7 +98,7 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
} }
@Override @Override
public @NonNull List<ConversationMessage> load(int start, int length, @NonNull CancellationSignal cancellationSignal) { public @NonNull List<ConversationMessage> load(int start, int length, int totalSize, @NonNull CancellationSignal cancellationSignal) {
Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), thread " + threadId); Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), thread " + threadId);
List<MessageRecord> records = new ArrayList<>(length); List<MessageRecord> records = new ArrayList<>(length);
MentionHelper mentionHelper = new MentionHelper(); MentionHelper mentionHelper = new MentionHelper();
@@ -128,11 +127,11 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
} }
} }
if (messageRequestData.includeWarningUpdateMessage() && (start + length >= size())) { if (messageRequestData.includeWarningUpdateMessage() && (start + length >= totalSize)) {
records.add(new InMemoryMessageRecord.NoGroupsInCommon(threadId, messageRequestData.isGroup())); records.add(new InMemoryMessageRecord.NoGroupsInCommon(threadId, messageRequestData.isGroup()));
} }
if (messageRequestData.isHidden() && (start + length >= size())) { if (messageRequestData.isHidden() && (start + length >= totalSize)) {
records.add(new InMemoryMessageRecord.RemovedContactHidden(threadId)); records.add(new InMemoryMessageRecord.RemovedContactHidden(threadId));
} }

View File

@@ -89,7 +89,6 @@ import org.thoughtcrime.securesms.components.TypingStatusRepository;
import org.thoughtcrime.securesms.components.menu.ActionItem; import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar; import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar;
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager; import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment; import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment;
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType; import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner; import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
@@ -194,7 +193,6 @@ import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.WindowUtil; import org.thoughtcrime.securesms.util.WindowUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import java.io.IOException; import java.io.IOException;
@@ -2065,6 +2063,11 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
} }
} }
@Override
public void onShowGroupDescriptionClicked(@NonNull String groupName, @NonNull String description, boolean shouldLinkifyWebLinks) {
GroupDescriptionDialog.show(getChildFragmentManager(), groupName, description, shouldLinkifyWebLinks);
}
@Override @Override
public void onActivatePaymentsClicked() { public void onActivatePaymentsClicked() {
Intent intent = new Intent(requireContext(), PaymentsActivity.class); Intent intent = new Intent(requireContext(), PaymentsActivity.class);

View File

@@ -437,7 +437,7 @@ public class ConversationIntents {
} }
private static long resolveThreadId(@NonNull RecipientId recipientId, long threadId) { private static long resolveThreadId(@NonNull RecipientId recipientId, long threadId) {
if (threadId >= 0 && SignalStore.internalValues().useConversationFragmentV2()) { if (threadId < 0 && SignalStore.internalValues().useConversationFragmentV2()) {
Log.w(TAG, "Getting thread id from database..."); Log.w(TAG, "Getting thread id from database...");
// TODO [alex] -- Yes, this hits the database. No, we shouldn't be doing this. // TODO [alex] -- Yes, this hits the database. No, we shouldn't be doing this.
return SignalDatabase.threads().getOrCreateThreadIdFor(Recipient.resolved(recipientId)); return SignalDatabase.threads().getOrCreateThreadIdFor(Recipient.resolved(recipientId));

View File

@@ -5,8 +5,10 @@
package org.thoughtcrime.securesms.conversation.v2 package org.thoughtcrime.securesms.conversation.v2
import android.text.TextUtils
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.text.HtmlCompat
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
@@ -15,6 +17,7 @@ import org.thoughtcrime.securesms.BindableConversationItem
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationAdapter import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge
import org.thoughtcrime.securesms.conversation.ConversationBannerView
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.Colorizable import org.thoughtcrime.securesms.conversation.colors.Colorizable
@@ -27,11 +30,17 @@ import org.thoughtcrime.securesms.conversation.v2.data.IncomingMedia
import org.thoughtcrime.securesms.conversation.v2.data.IncomingTextOnly import org.thoughtcrime.securesms.conversation.v2.data.IncomingTextOnly
import org.thoughtcrime.securesms.conversation.v2.data.OutgoingMedia import org.thoughtcrime.securesms.conversation.v2.data.OutgoingMedia
import org.thoughtcrime.securesms.conversation.v2.data.OutgoingTextOnly import org.thoughtcrime.securesms.conversation.v2.data.OutgoingTextOnly
import org.thoughtcrime.securesms.conversation.v2.data.ThreadHeader
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
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer
import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.CachedInflater import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.HtmlUtil
import org.thoughtcrime.securesms.util.Projection import org.thoughtcrime.securesms.util.Projection
import org.thoughtcrime.securesms.util.ProjectionList import org.thoughtcrime.securesms.util.ProjectionList
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
@@ -65,6 +74,8 @@ class ConversationAdapterV2(
private val condensedMode: ConversationItemDisplayMode? = null private val condensedMode: ConversationItemDisplayMode? = null
init { init {
registerFactory(ThreadHeader::class.java, ::ThreadHeaderViewHolder, R.layout.conversation_item_banner)
registerFactory(ConversationUpdate::class.java) { parent -> registerFactory(ConversationUpdate::class.java) { parent ->
val view = CachedInflater.from(parent.context).inflate<View>(R.layout.conversation_item_update, parent, false) val view = CachedInflater.from(parent.context).inflate<View>(R.layout.conversation_item_update, parent, false)
ConversationUpdateViewHolder(view) ConversationUpdateViewHolder(view)
@@ -91,14 +102,14 @@ class ConversationAdapterV2(
} }
} }
fun getAdapterPositionForMessagePosition(startPosition: Int): Int { /** [messagePosition] is one-based index and adapter is zero-based. */
return startPosition - 1 fun getAdapterPositionForMessagePosition(messagePosition: Int): Int {
return messagePosition - 1
} }
fun getLastVisibleConversationMessage(position: Int): ConversationMessage? { fun getLastVisibleConversationMessage(position: Int): ConversationMessage? {
return try { return try {
// todo [cody] handle conversation banner adjustment getConversationMessage(position) ?: getConversationMessage(position - 1)
getConversationMessage(position)
} catch (e: IndexOutOfBoundsException) { } catch (e: IndexOutOfBoundsException) {
Log.w(TAG, "Race condition changed size of conversation", e) Log.w(TAG, "Race condition changed size of conversation", e)
null null
@@ -131,6 +142,7 @@ class ConversationAdapterV2(
override fun getConversationMessage(position: Int): ConversationMessage? { override fun getConversationMessage(position: Int): ConversationMessage? {
return when (val item = getItem(position)) { return when (val item = getItem(position)) {
is ConversationMessageElement -> item.conversationMessage is ConversationMessageElement -> item.conversationMessage
is ThreadHeader -> null
null -> null null -> null
else -> throw AssertionError("Invalid item: ${item.javaClass}") else -> throw AssertionError("Invalid item: ${item.javaClass}")
} }
@@ -326,4 +338,75 @@ class ConversationAdapterV2(
return bindable.getColorizerProjections(coordinateRoot) return bindable.getColorizerProjections(coordinateRoot)
} }
} }
inner class ThreadHeaderViewHolder(itemView: View) : MappingViewHolder<ThreadHeader>(itemView) {
private val conversationBanner: ConversationBannerView = itemView as ConversationBannerView
override fun bind(model: ThreadHeader) {
val (recipient, groupInfo, sharedGroups, messageRequestState) = model.recipientInfo
val isSelf = recipient.id == Recipient.self().id
conversationBanner.setAvatar(glideRequests, recipient)
conversationBanner.showBackgroundBubble(recipient.hasWallpaper())
val title: String = conversationBanner.setTitle(recipient)
conversationBanner.setAbout(recipient)
if (recipient.isGroup) {
if (groupInfo.pendingMemberCount > 0) {
val invited = context.resources.getQuantityString(R.plurals.MessageRequestProfileView_invited, groupInfo.pendingMemberCount, groupInfo.pendingMemberCount)
conversationBanner.setSubtitle(context.resources.getQuantityString(R.plurals.MessageRequestProfileView_members_and_invited, groupInfo.fullMemberCount, groupInfo.fullMemberCount, invited))
} else if (groupInfo.fullMemberCount > 0) {
conversationBanner.setSubtitle(context.resources.getQuantityString(R.plurals.MessageRequestProfileView_members, groupInfo.fullMemberCount, groupInfo.fullMemberCount))
} else {
conversationBanner.setSubtitle(null)
}
} else if (isSelf) {
conversationBanner.setSubtitle(context.getString(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation))
} else {
val subtitle: String? = recipient.e164.map { e164: String? -> PhoneNumberFormatter.prettyPrint(e164!!) }.orElse(null)
if (subtitle == null || subtitle == title) {
conversationBanner.hideSubtitle()
} else {
conversationBanner.setSubtitle(subtitle)
}
}
if (sharedGroups.isEmpty() || isSelf) {
if (TextUtils.isEmpty(groupInfo.description)) {
conversationBanner.setLinkifyDescription(false)
conversationBanner.hideDescription()
} else {
conversationBanner.setLinkifyDescription(true)
val linkifyWebLinks = messageRequestState == MessageRequestState.NONE
conversationBanner.showDescription()
GroupDescriptionUtil.setText(
context,
conversationBanner.description,
groupInfo.description,
linkifyWebLinks
) {
clickListener.onShowGroupDescriptionClicked(recipient.getDisplayName(context), groupInfo.description, linkifyWebLinks)
}
}
} else {
val description: String = when (sharedGroups.size) {
1 -> context.getString(R.string.MessageRequestProfileView_member_of_one_group, HtmlUtil.bold(sharedGroups[0]))
2 -> context.getString(R.string.MessageRequestProfileView_member_of_two_groups, HtmlUtil.bold(sharedGroups[0]), HtmlUtil.bold(sharedGroups[1]))
3 -> context.getString(R.string.MessageRequestProfileView_member_of_many_groups, HtmlUtil.bold(sharedGroups[0]), HtmlUtil.bold(sharedGroups[1]), HtmlUtil.bold(sharedGroups[2]))
else -> {
val others: Int = sharedGroups.size - 2
context.getString(
R.string.MessageRequestProfileView_member_of_many_groups,
HtmlUtil.bold(sharedGroups[0]),
HtmlUtil.bold(sharedGroups[1]),
context.resources.getQuantityString(R.plurals.MessageRequestProfileView_member_of_d_additional_groups, others, others)
)
}
}
conversationBanner.setDescription(HtmlCompat.fromHtml(description, 0))
conversationBanner.showDescription()
}
}
}
} }

View File

@@ -247,15 +247,16 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
.flatMapObservable { it.items.data } .flatMapObservable { it.items.data }
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribeBy(onNext = { .subscribeBy(onNext = {
SignalLocalMetrics.ConversationOpen.onDataPostedToMain() if (firstRender) {
SignalLocalMetrics.ConversationOpen.onDataPostedToMain()
}
adapter.submitList(it) { adapter.submitList(it) {
scrollToPositionDelegate.notifyListCommitted() scrollToPositionDelegate.notifyListCommitted()
binding.conversationItemRecycler.doAfterNextLayout { if (firstRender) {
SignalLocalMetrics.ConversationOpen.onRenderFinished() binding.conversationItemRecycler.doAfterNextLayout {
SignalLocalMetrics.ConversationOpen.onRenderFinished()
if (firstRender) {
firstRender = false firstRender = false
doAfterFirstRender() doAfterFirstRender()
animationsAllowed = true animationsAllowed = true
@@ -891,6 +892,10 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
override fun onItemLongClick(itemView: View?, item: MultiselectPart?) { override fun onItemLongClick(itemView: View?, item: MultiselectPart?) {
// TODO [alex] -- ("Not yet implemented") // TODO [alex] -- ("Not yet implemented")
} }
override fun onShowGroupDescriptionClicked(groupName: String, description: String, shouldLinkifyWebLinks: Boolean) {
GroupDescriptionDialog.show(childFragmentManager, groupName, description, shouldLinkifyWebLinks)
}
} }
private inner class ConversationOptionsMenuCallback : ConversationOptionsMenu.Callback { private inner class ConversationOptionsMenuCallback : ConversationOptionsMenu.Callback {

View File

@@ -17,6 +17,7 @@ class ConversationRecipientRepository(threadId: Long) {
.flatMapObservable { Recipient.observable(it) } .flatMapObservable { Recipient.observable(it) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.distinctUntilChanged { previous, next -> previous === next || previous.hasSameContent(next) }
.replay(1) .replay(1)
.refCount() .refCount()
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())

View File

@@ -1,3 +1,8 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2 package org.thoughtcrime.securesms.conversation.v2
import android.content.Context import android.content.Context
@@ -29,9 +34,12 @@ class ConversationRepository(context: Context) {
*/ */
fun getConversationThreadState(threadId: Long, requestedStartPosition: Int): Single<ConversationThreadState> { fun getConversationThreadState(threadId: Long, requestedStartPosition: Int): Single<ConversationThreadState> {
return Single.fromCallable { return Single.fromCallable {
SignalLocalMetrics.ConversationOpen.onMetadataLoadStarted()
val recipient = SignalDatabase.threads.getRecipientForThreadId(threadId)!! val recipient = SignalDatabase.threads.getRecipientForThreadId(threadId)!!
SignalLocalMetrics.ConversationOpen.onMetadataLoadStarted()
val metadata = oldConversationRepository.getConversationData(threadId, recipient, requestedStartPosition) val metadata = oldConversationRepository.getConversationData(threadId, recipient, requestedStartPosition)
SignalLocalMetrics.ConversationOpen.onMetadataLoaded()
val messageRequestData = metadata.messageRequestData val messageRequestData = metadata.messageRequestData
val dataSource = ConversationDataSource( val dataSource = ConversationDataSource(
applicationContext, applicationContext,
@@ -48,9 +56,7 @@ class ConversationRepository(context: Context) {
ConversationThreadState( ConversationThreadState(
items = PagedData.createForObservable(dataSource, config), items = PagedData.createForObservable(dataSource, config),
meta = metadata meta = metadata
).apply { )
SignalLocalMetrics.ConversationOpen.onMetadataLoaded()
}
}.subscribeOn(Schedulers.io()) }.subscribeOn(Schedulers.io())
} }

View File

@@ -79,6 +79,11 @@ class ConversationViewModel(
_recipient.onNext(it) _recipient.onNext(it)
}) })
disposables += recipientRepository
.conversationRecipient
.skip(1) // We can safely skip the first emission since this is used for updating the header on future changes
.subscribeBy { pagingController.onDataItemChanged(ConversationElementKey.threadHeader) }
disposables += repository.getConversationThreadState(threadId, requestedStartingPosition) disposables += repository.getConversationThreadState(threadId, requestedStartingPosition)
.subscribeBy(onSuccess = { .subscribeBy(onSuccess = {
pagingController.set(it.items.controller) pagingController.set(it.items.controller)

View File

@@ -12,10 +12,10 @@ import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.concurrent.subscribeWithSubject import org.signal.core.util.concurrent.subscribeWithSubject
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
import org.thoughtcrime.securesms.messagerequests.GroupInfo import org.thoughtcrime.securesms.messagerequests.GroupInfo
import org.thoughtcrime.securesms.messagerequests.MessageRequestRecipientInfo
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.messagerequests.MessageRequestState import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel.MessageData import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel.MessageData
import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel.RecipientInfo
import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel.RequestReviewDisplayState import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel.RequestReviewDisplayState
import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel.Status import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel.Status
import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil
@@ -70,12 +70,12 @@ class MessageRequestViewModel(
} }
}.subscribeWithSubject(BehaviorSubject.create(), disposables) }.subscribeWithSubject(BehaviorSubject.create(), disposables)
val recipientInfo: Observable<RecipientInfo> = Observable.combineLatest( val recipientInfo: Observable<MessageRequestRecipientInfo> = Observable.combineLatest(
recipientRepository.conversationRecipient, recipientRepository.conversationRecipient,
groupInfo, groupInfo,
groups, groups,
messageDataSubject.map { it.messageState }, messageDataSubject.map { it.messageState },
::RecipientInfo ::MessageRequestRecipientInfo
) )
override fun onCleared() { override fun onCleared() {

View File

@@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageId 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.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
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.thoughtcrime.securesms.util.adapter.mapping.MappingModel
@@ -33,10 +34,12 @@ private typealias ConversationElement = MappingModel<*>
sealed interface ConversationElementKey { sealed interface ConversationElementKey {
companion object { companion object {
fun forMessage(id: Long): ConversationElementKey = MessageBackedKey(id) fun forMessage(id: Long): ConversationElementKey = MessageBackedKey(id)
val threadHeader: ConversationElementKey = ThreadHeaderKey
} }
} }
private data class MessageBackedKey(val id: Long) : ConversationElementKey private data class MessageBackedKey(val id: Long) : ConversationElementKey
private object ThreadHeaderKey : ConversationElementKey
/** /**
* ConversationDataSource for V2. Assumes that ThreadId is never -1L. * ConversationDataSource for V2. Assumes that ThreadId is never -1L.
@@ -46,11 +49,13 @@ class ConversationDataSource(
private val threadId: Long, private val threadId: Long,
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,
private val messageRequestRepository: MessageRequestRepository = MessageRequestRepository(context)
) : PagedDataSource<ConversationElementKey, ConversationElement> { ) : PagedDataSource<ConversationElementKey, ConversationElement> {
companion object { companion object {
private val TAG = Log.tag(ConversationDataSource::class.java) private val TAG = Log.tag(ConversationDataSource::class.java)
private const val THREAD_HEADER_COUNT = 1
} }
init { init {
@@ -64,6 +69,7 @@ class ConversationDataSource(
override fun size(): Int { override fun size(): Int {
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
val size: Int = getSizeInternal() + val size: Int = getSizeInternal() +
THREAD_HEADER_COUNT +
messageRequestData.includeWarningUpdateMessage().toInt() + messageRequestData.includeWarningUpdateMessage().toInt() +
messageRequestData.isHidden.toInt() + messageRequestData.isHidden.toInt() +
showUniversalExpireTimerUpdate.toInt() showUniversalExpireTimerUpdate.toInt()
@@ -85,7 +91,7 @@ class ConversationDataSource(
return SignalDatabase.messages.getMessageCountForThread(threadId) return SignalDatabase.messages.getMessageCountForThread(threadId)
} }
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): List<ConversationElement> { override fun load(start: Int, length: Int, totalSize: 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()
@@ -115,11 +121,11 @@ class ConversationDataSource(
} }
} }
if (messageRequestData.includeWarningUpdateMessage() && (start + length >= size())) { if (messageRequestData.includeWarningUpdateMessage() && (start + length >= totalSize)) {
records.add(NoGroupsInCommon(threadId, messageRequestData.isGroup)) records.add(NoGroupsInCommon(threadId, messageRequestData.isGroup))
} }
if (messageRequestData.isHidden && (start + length >= size())) { if (messageRequestData.isHidden && (start + length >= totalSize)) {
records.add(RemovedContactHidden(threadId)) records.add(RemovedContactHidden(threadId))
} }
@@ -174,12 +180,26 @@ class ConversationDataSource(
} }
stopwatch.split("conversion") stopwatch.split("conversion")
val threadHeaderIndex = totalSize - THREAD_HEADER_COUNT
val threadHeaders: List<ConversationElement> = if (start + length > threadHeaderIndex) {
listOf(loadThreadHeader())
} else {
emptyList()
}
stopwatch.split("header")
stopwatch.stop(TAG) stopwatch.stop(TAG)
return messages return if (threadHeaders.isNotEmpty()) messages + threadHeaders else messages
} }
override fun load(key: ConversationElementKey): ConversationElement? { override fun load(key: ConversationElementKey): ConversationElement? {
if (key is ThreadHeaderKey) {
return loadThreadHeader()
}
if (key !is MessageBackedKey) { if (key !is MessageBackedKey) {
Log.w(TAG, "Loading non-message related id $key") Log.w(TAG, "Loading non-message related id $key")
return null return null
@@ -249,10 +269,15 @@ class ConversationDataSource(
override fun getKey(conversationMessage: ConversationElement): ConversationElementKey { override fun getKey(conversationMessage: ConversationElement): ConversationElementKey {
return when (conversationMessage) { return when (conversationMessage) {
is ConversationMessageElement -> MessageBackedKey(conversationMessage.conversationMessage.messageRecord.id) is ConversationMessageElement -> MessageBackedKey(conversationMessage.conversationMessage.messageRecord.id)
is ThreadHeader -> ThreadHeaderKey
else -> throw AssertionError() else -> throw AssertionError()
} }
} }
private fun loadThreadHeader(): ThreadHeader {
return ThreadHeader(messageRequestRepository.getRecipientInfo(threadRecipient.id, threadId))
}
private fun ConversationMessage.toMappingModel(): MappingModel<*> { private fun ConversationMessage.toMappingModel(): MappingModel<*> {
return if (messageRecord.isUpdate) { return if (messageRecord.isUpdate) {
ConversationUpdate(this) ConversationUpdate(this)

View File

@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.conversation.v2.data package org.thoughtcrime.securesms.conversation.v2.data
import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.messagerequests.MessageRequestRecipientInfo
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
sealed interface ConversationMessageElement { sealed interface ConversationMessageElement {
@@ -71,3 +72,13 @@ data class IncomingMedia(
return false return false
} }
} }
data class ThreadHeader(val recipientInfo: MessageRequestRecipientInfo) : MappingModel<ThreadHeader> {
override fun areItemsTheSame(newItem: ThreadHeader): Boolean {
return true
}
override fun areContentsTheSame(newItem: ThreadHeader): Boolean {
return false
}
}

View File

@@ -66,7 +66,7 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
} }
@Override @Override
public @NonNull List<Conversation> load(int start, int length, @NonNull CancellationSignal cancellationSignal) { public @NonNull List<Conversation> load(int start, int length, int totalSize, @NonNull CancellationSignal cancellationSignal) {
SignalTrace.beginSection("ConversationListDataSource#load"); SignalTrace.beginSection("ConversationListDataSource#load");
Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), " + getClass().getSimpleName() + ", " + conversationFilter); Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), " + getClass().getSimpleName() + ", " + conversationFilter);

View File

@@ -149,7 +149,10 @@ class ConversationListViewModel(
conversationListDataSource conversationListDataSource
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.firstOrError() .firstOrError()
.map { dataSource -> dataSource.load(0, dataSource.size()) { disposables.isDisposed } } .map { dataSource ->
val totalSize = dataSource.size()
dataSource.load(0, totalSize, totalSize) { disposables.isDisposed }
}
.subscribe { newSelection -> setSelection(newSelection) } .subscribe { newSelection -> setSelection(newSelection) }
.addTo(disposables) .addTo(disposables)
} }

View File

@@ -4,6 +4,7 @@ import android.graphics.Canvas
import androidx.core.view.children import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.conversation.ConversationAdapter import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapterV2
import kotlin.math.min import kotlin.math.min
/** /**
@@ -28,7 +29,7 @@ class GiphyMp4ItemDecoration(
} else { } else {
val footerViewHolder = parent.children val footerViewHolder = parent.children
.map { parent.getChildViewHolder(it) } .map { parent.getChildViewHolder(it) }
.filterIsInstance(ConversationAdapter.FooterViewHolder::class.java) .filter { it is ConversationAdapter.FooterViewHolder || it is ConversationAdapterV2.ThreadHeaderViewHolder }
.firstOrNull() .firstOrNull()
if (footerViewHolder == null) { if (footerViewHolder == null) {

View File

@@ -66,7 +66,7 @@ final class GiphyMp4PagedDataSource implements PagedDataSource<String, GiphyImag
} }
@Override @Override
public @NonNull List<GiphyImage> load(int start, int length, @NonNull CancellationSignal cancellationSignal) { public @NonNull List<GiphyImage> load(int start, int length, int totalSize, @NonNull CancellationSignal cancellationSignal) {
try { try {
Log.d(TAG, "Loading from " + start + " to " + (start + length)); Log.d(TAG, "Loading from " + start + " to " + (start + length));
return new LinkedList<>(performFetch(start, length).getData()); return new LinkedList<>(performFetch(start, length).getData());

View File

@@ -24,7 +24,7 @@ class LogDataSource(
return prefixLines.size + logDatabase.getLogCountBeforeTime(untilTime) return prefixLines.size + logDatabase.getLogCountBeforeTime(untilTime)
} }
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): List<LogLine> { override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): List<LogLine> {
if (start + length < prefixLines.size) { if (start + length < prefixLines.size) {
return prefixLines.subList(start, start + length) return prefixLines.subList(start, start + length)
} else if (start < prefixLines.size) { } else if (start < prefixLines.size) {

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.messagerequests
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Thread recipient and message request state information necessary to render
* a thread header.
*/
data class MessageRequestRecipientInfo(
val recipient: Recipient,
val groupInfo: GroupInfo = GroupInfo.ZERO,
val sharedGroups: List<String> = emptyList(),
val messageRequestState: MessageRequestState? = null
)

View File

@@ -72,6 +72,31 @@ public final class MessageRequestRepository {
}); });
} }
@WorkerThread
public @NonNull MessageRequestRecipientInfo getRecipientInfo(@NonNull RecipientId recipientId, long threadId) {
List<String> sharedGroups = SignalDatabase.groups().getPushGroupNamesContainingMember(recipientId);
Optional<GroupRecord> groupRecord = SignalDatabase.groups().getGroup(recipientId);
GroupInfo groupInfo = GroupInfo.ZERO;
if (groupRecord.isPresent()) {
if (groupRecord.get().isV2Group()) {
DecryptedGroup decryptedGroup = groupRecord.get().requireV2GroupProperties().getDecryptedGroup();
groupInfo = new GroupInfo(decryptedGroup.getMembersCount(), decryptedGroup.getPendingMembersCount(), decryptedGroup.getDescription());
} else {
groupInfo = new GroupInfo(groupRecord.get().getMembers().size(), 0, "");
}
}
Recipient recipient = Recipient.resolved(recipientId);
return new MessageRequestRecipientInfo(
recipient,
groupInfo,
sharedGroups,
getMessageRequestState(recipient, threadId)
);
}
@WorkerThread @WorkerThread
public @NonNull MessageRequestState getMessageRequestState(@NonNull Recipient recipient, long threadId) { public @NonNull MessageRequestState getMessageRequestState(@NonNull Recipient recipient, long threadId) {
if (recipient.isBlocked()) { if (recipient.isBlocked()) {

View File

@@ -15,7 +15,7 @@ class StoryGroupReplyDataSource(private val parentStoryId: Long) : PagedDataSour
return SignalDatabase.messages.getNumberOfStoryReplies(parentStoryId) return SignalDatabase.messages.getNumberOfStoryReplies(parentStoryId)
} }
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<ReplyBody> { override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<ReplyBody> {
val results: MutableList<ReplyBody> = ArrayList(length) val results: MutableList<ReplyBody> = ArrayList(length)
SignalDatabase.messages.getStoryReplies(parentStoryId).use { cursor -> SignalDatabase.messages.getStoryReplies(parentStoryId).use { cursor ->
cursor.moveToPosition(start - 1) cursor.moveToPosition(start - 1)

View File

@@ -53,7 +53,7 @@ class ContactSearchPagedDataSourceTest {
@Test @Test
fun `Given recentsWHeader and individualsWHeaderWExpand, when I load 12, then I expect properly structured output`() { fun `Given recentsWHeader and individualsWHeaderWExpand, when I load 12, then I expect properly structured output`() {
val testSubject = createTestSubject() val testSubject = createTestSubject()
val result = testSubject.load(0, 12) { false } val result = testSubject.load(0, 12, 20) { false }
val expected = listOf( val expected = listOf(
ContactSearchKey.Header(ContactSearchConfiguration.SectionKey.RECENTS), ContactSearchKey.Header(ContactSearchConfiguration.SectionKey.RECENTS),
@@ -78,7 +78,7 @@ class ContactSearchPagedDataSourceTest {
@Test @Test
fun `Given recentsWHeader and individualsWHeaderWExpand, when I load 10 with offset 5, then I expect properly structured output`() { fun `Given recentsWHeader and individualsWHeaderWExpand, when I load 10 with offset 5, then I expect properly structured output`() {
val testSubject = createTestSubject() val testSubject = createTestSubject()
val result = testSubject.load(5, 10) { false } val result = testSubject.load(5, 10, 15) { false }
val expected = listOf( val expected = listOf(
ContactSearchKey.RecipientSearchKey(RecipientId.UNKNOWN, false), ContactSearchKey.RecipientSearchKey(RecipientId.UNKNOWN, false),
@@ -101,7 +101,7 @@ class ContactSearchPagedDataSourceTest {
@Test @Test
fun `Given storiesWithHeaderAndExtras, when I load 11, then I expect properly structured output`() { fun `Given storiesWithHeaderAndExtras, when I load 11, then I expect properly structured output`() {
val testSubject = createStoriesSubject() val testSubject = createStoriesSubject()
val result = testSubject.load(0, 12) { false } val result = testSubject.load(0, 12, 12) { false }
val expected = listOf( val expected = listOf(
ContactSearchKey.Header(ContactSearchConfiguration.SectionKey.STORIES), ContactSearchKey.Header(ContactSearchConfiguration.SectionKey.STORIES),
@@ -136,7 +136,7 @@ class ContactSearchPagedDataSourceTest {
fun `Given only arbitrary elements, when I load 1, then I expect 1`() { fun `Given only arbitrary elements, when I load 1, then I expect 1`() {
val testSubject = createArbitrarySubject() val testSubject = createArbitrarySubject()
val expected = ContactSearchData.Arbitrary("two", bundleOf("n" to "two")) val expected = ContactSearchData.Arbitrary("two", bundleOf("n" to "two"))
val actual = testSubject.load(1, 1) { false }[0] as ContactSearchData.Arbitrary val actual = testSubject.load(1, 1, 1) { false }[0] as ContactSearchData.Arbitrary
Assert.assertEquals(expected.data?.getString("n"), actual.data?.getString("n")) Assert.assertEquals(expected.data?.getString("n"), actual.data?.getString("n"))
} }

View File

@@ -6,7 +6,6 @@ import androidx.annotation.Nullable;
import org.signal.paging.PagedDataSource; import org.signal.paging.PagedDataSource;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.ListIterator; import java.util.ListIterator;
import java.util.UUID; import java.util.UUID;
@@ -25,7 +24,7 @@ class MainDataSource implements PagedDataSource<String, Item> {
} }
@Override @Override
public @NonNull List<Item> load(int start, int length, @NonNull CancellationSignal cancellationSignal) { public @NonNull List<Item> load(int start, int length, int totalSize, @NonNull CancellationSignal cancellationSignal) {
try { try {
Thread.sleep(500); Thread.sleep(500);
} catch (InterruptedException e) { } catch (InterruptedException e) {

View File

@@ -66,6 +66,7 @@ class FixedSizePagingController<Key, Data> implements PagingController<Key> {
final int loadStart; final int loadStart;
final int loadEnd; final int loadEnd;
final int totalSize;
synchronized (loadState) { synchronized (loadState) {
if (loadState.size() == 0) { if (loadState.size() == 0) {
@@ -94,7 +95,7 @@ class FixedSizePagingController<Key, Data> implements PagingController<Key> {
return; return;
} }
int totalSize = loadState.size(); totalSize = loadState.size();
loadState.markRange(loadStart, loadEnd); loadState.markRange(loadStart, loadEnd);
@@ -107,7 +108,7 @@ class FixedSizePagingController<Key, Data> implements PagingController<Key> {
return; return;
} }
List<Data> loaded = dataSource.load(loadStart, loadEnd - loadStart, () -> invalidated); List<Data> loaded = dataSource.load(loadStart, loadEnd - loadStart, totalSize, () -> invalidated);
if (invalidated) { if (invalidated) {
Log.w(TAG, buildDataNeededLog(aroundIndex, "Invalidated! Just after data was loaded.")); Log.w(TAG, buildDataNeededLog(aroundIndex, "Invalidated! Just after data was loaded."));

View File

@@ -17,15 +17,15 @@ public interface PagedDataSource<Key, Data> {
int size(); int size();
/** /**
* @param start The index of the first item that should be included in your results. * @param start The index of the first item that should be included in your results.
* @param length The total number of items you should return. * @param length The total number of items you should return.
* @param totalSize The total number of items in the data source
* @param cancellationSignal An object that you can check to see if the load operation was canceled. * @param cancellationSignal An object that you can check to see if the load operation was canceled.
*
* @return A list of length {@code length} that represents the data starting at {@code start}. * @return A list of length {@code length} that represents the data starting at {@code start}.
* If you don't have the full range, just populate what you can. * If you don't have the full range, just populate what you can.
*/ */
@WorkerThread @WorkerThread
@NonNull List<Data> load(int start, int length, @NonNull CancellationSignal cancellationSignal); @NonNull List<Data> load(int start, int length, int totalSize, @NonNull CancellationSignal cancellationSignal);
@WorkerThread @WorkerThread
@Nullable Data load(Key key); @Nullable Data load(Key key);