diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index e706c041a3..e2f4a30360 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -115,5 +115,6 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, void onGiftBadgeRevealed(@NonNull MessageRecord messageRecord); void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args); void onEditedIndicatorClicked(@NonNull MessageRecord messageRecord); + void onShowGroupDescriptionClicked(@NonNull String groupName, @NonNull String description, boolean shouldLinkifyWebLinks); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt index 464f03a16a..601cd21f51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt @@ -19,7 +19,7 @@ class CallLogPagedDataSource( return callsCount + hasFilter.toInt() + hasCallLinkRow.toInt() } - override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList { + override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList { val calls = mutableListOf() val callLimit = length - hasCallLinkRow.toInt() diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt index 86f2e42351..4e21656809 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt @@ -74,7 +74,7 @@ class ContactSearchPagedDataSource( return searchSize } - override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList { + override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList { val sections: List = if (displayEmptyState) { contactConfiguration.emptyStateSections } else { 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 971a592841..6bf89e7034 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java @@ -37,7 +37,6 @@ import org.whispersystems.signalservice.api.push.ServiceId; import java.util.ArrayList; import java.util.HashSet; import java.util.List; -import java.util.Objects; import java.util.Set; /** @@ -99,7 +98,7 @@ public class ConversationDataSource implements PagedDataSource load(int start, int length, @NonNull CancellationSignal cancellationSignal) { + public @NonNull List load(int start, int length, int totalSize, @NonNull CancellationSignal cancellationSignal) { Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), thread " + threadId); List records = new ArrayList<>(length); MentionHelper mentionHelper = new MentionHelper(); @@ -128,11 +127,11 @@ public class ConversationDataSource implements PagedDataSource= size())) { + if (messageRequestData.includeWarningUpdateMessage() && (start + length >= totalSize)) { 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)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 8310b89c1c..1599cb985a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -89,7 +89,6 @@ import org.thoughtcrime.securesms.components.TypingStatusRepository; import org.thoughtcrime.securesms.components.menu.ActionItem; import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar; 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.DonateToSignalType; 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.concurrent.ListenableFuture; import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; -import org.thoughtcrime.securesms.verify.VerifyIdentityActivity; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; 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 public void onActivatePaymentsClicked() { Intent intent = new Intent(requireContext(), PaymentsActivity.class); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java index 5e56cb935a..286462cbb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java @@ -437,7 +437,7 @@ public class ConversationIntents { } 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..."); // TODO [alex] -- Yes, this hits the database. No, we shouldn't be doing this. return SignalDatabase.threads().getOrCreateThreadIdFor(Recipient.resolved(recipientId)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt index df5c8d887f..6625aa22a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt @@ -5,8 +5,10 @@ package org.thoughtcrime.securesms.conversation.v2 +import android.text.TextUtils import android.view.View import android.view.ViewGroup +import androidx.core.text.HtmlCompat import androidx.lifecycle.LifecycleOwner import com.google.android.exoplayer2.MediaItem 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.conversation.ConversationAdapter import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge +import org.thoughtcrime.securesms.conversation.ConversationBannerView import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode import org.thoughtcrime.securesms.conversation.ConversationMessage 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.OutgoingMedia 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.giph.mp4.GiphyMp4Playable 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.phonenumbers.PhoneNumberFormatter +import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.CachedInflater +import org.thoughtcrime.securesms.util.HtmlUtil import org.thoughtcrime.securesms.util.Projection import org.thoughtcrime.securesms.util.ProjectionList import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder @@ -65,6 +74,8 @@ class ConversationAdapterV2( private val condensedMode: ConversationItemDisplayMode? = null init { + registerFactory(ThreadHeader::class.java, ::ThreadHeaderViewHolder, R.layout.conversation_item_banner) + registerFactory(ConversationUpdate::class.java) { parent -> val view = CachedInflater.from(parent.context).inflate(R.layout.conversation_item_update, parent, false) ConversationUpdateViewHolder(view) @@ -91,14 +102,14 @@ class ConversationAdapterV2( } } - fun getAdapterPositionForMessagePosition(startPosition: Int): Int { - return startPosition - 1 + /** [messagePosition] is one-based index and adapter is zero-based. */ + fun getAdapterPositionForMessagePosition(messagePosition: Int): Int { + return messagePosition - 1 } fun getLastVisibleConversationMessage(position: Int): ConversationMessage? { return try { - // todo [cody] handle conversation banner adjustment - getConversationMessage(position) + getConversationMessage(position) ?: getConversationMessage(position - 1) } catch (e: IndexOutOfBoundsException) { Log.w(TAG, "Race condition changed size of conversation", e) null @@ -131,6 +142,7 @@ class ConversationAdapterV2( override fun getConversationMessage(position: Int): ConversationMessage? { return when (val item = getItem(position)) { is ConversationMessageElement -> item.conversationMessage + is ThreadHeader -> null null -> null else -> throw AssertionError("Invalid item: ${item.javaClass}") } @@ -326,4 +338,75 @@ class ConversationAdapterV2( return bindable.getColorizerProjections(coordinateRoot) } } + + inner class ThreadHeaderViewHolder(itemView: View) : MappingViewHolder(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() + } + } + } } 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 index 6ed40cf9ed..95b614fd25 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -247,15 +247,16 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) .flatMapObservable { it.items.data } .observeOn(AndroidSchedulers.mainThread()) .subscribeBy(onNext = { - SignalLocalMetrics.ConversationOpen.onDataPostedToMain() + if (firstRender) { + SignalLocalMetrics.ConversationOpen.onDataPostedToMain() + } adapter.submitList(it) { scrollToPositionDelegate.notifyListCommitted() - binding.conversationItemRecycler.doAfterNextLayout { - SignalLocalMetrics.ConversationOpen.onRenderFinished() - - if (firstRender) { + if (firstRender) { + binding.conversationItemRecycler.doAfterNextLayout { + SignalLocalMetrics.ConversationOpen.onRenderFinished() firstRender = false doAfterFirstRender() animationsAllowed = true @@ -891,6 +892,10 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) override fun onItemLongClick(itemView: View?, item: MultiselectPart?) { // 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRecipientRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRecipientRepository.kt index 67d9e2cb11..8ef3383b20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRecipientRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRecipientRepository.kt @@ -17,6 +17,7 @@ class ConversationRecipientRepository(threadId: Long) { .flatMapObservable { Recipient.observable(it) } .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) + .distinctUntilChanged { previous, next -> previous === next || previous.hasSameContent(next) } .replay(1) .refCount() .observeOn(Schedulers.io()) 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 index f7e361f678..dc2bcd46bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.conversation.v2 import android.content.Context @@ -29,9 +34,12 @@ class ConversationRepository(context: Context) { */ fun getConversationThreadState(threadId: Long, requestedStartPosition: Int): Single { return Single.fromCallable { - SignalLocalMetrics.ConversationOpen.onMetadataLoadStarted() val recipient = SignalDatabase.threads.getRecipientForThreadId(threadId)!! + + SignalLocalMetrics.ConversationOpen.onMetadataLoadStarted() val metadata = oldConversationRepository.getConversationData(threadId, recipient, requestedStartPosition) + SignalLocalMetrics.ConversationOpen.onMetadataLoaded() + val messageRequestData = metadata.messageRequestData val dataSource = ConversationDataSource( applicationContext, @@ -48,9 +56,7 @@ class ConversationRepository(context: Context) { ConversationThreadState( items = PagedData.createForObservable(dataSource, config), meta = metadata - ).apply { - SignalLocalMetrics.ConversationOpen.onMetadataLoaded() - } + ) }.subscribeOn(Schedulers.io()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index e8305a194f..d6b9da3ef7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -79,6 +79,11 @@ class ConversationViewModel( _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) .subscribeBy(onSuccess = { pagingController.set(it.items.controller) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageRequestViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageRequestViewModel.kt index 074340e3e8..23204b70ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageRequestViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageRequestViewModel.kt @@ -12,10 +12,10 @@ import io.reactivex.rxjava3.subjects.PublishSubject import org.signal.core.util.concurrent.subscribeWithSubject import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason import org.thoughtcrime.securesms.messagerequests.GroupInfo +import org.thoughtcrime.securesms.messagerequests.MessageRequestRecipientInfo import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository import org.thoughtcrime.securesms.messagerequests.MessageRequestState 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.Status import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil @@ -70,12 +70,12 @@ class MessageRequestViewModel( } }.subscribeWithSubject(BehaviorSubject.create(), disposables) - val recipientInfo: Observable = Observable.combineLatest( + val recipientInfo: Observable = Observable.combineLatest( recipientRepository.conversationRecipient, groupInfo, groups, messageDataSubject.map { it.messageState }, - ::RecipientInfo + ::MessageRequestRecipientInfo ) override fun onCleared() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt index e5355ae493..b0bc2722f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel @@ -33,10 +34,12 @@ private typealias ConversationElement = MappingModel<*> sealed interface ConversationElementKey { companion object { fun forMessage(id: Long): ConversationElementKey = MessageBackedKey(id) + val threadHeader: ConversationElementKey = ThreadHeaderKey } } private data class MessageBackedKey(val id: Long) : ConversationElementKey +private object ThreadHeaderKey : ConversationElementKey /** * ConversationDataSource for V2. Assumes that ThreadId is never -1L. @@ -46,11 +49,13 @@ class ConversationDataSource( private val threadId: Long, private val messageRequestData: ConversationData.MessageRequestData, private val showUniversalExpireTimerUpdate: Boolean, - private var baseSize: Int + private var baseSize: Int, + private val messageRequestRepository: MessageRequestRepository = MessageRequestRepository(context) ) : PagedDataSource { companion object { private val TAG = Log.tag(ConversationDataSource::class.java) + private const val THREAD_HEADER_COUNT = 1 } init { @@ -64,6 +69,7 @@ class ConversationDataSource( override fun size(): Int { val startTime = System.currentTimeMillis() val size: Int = getSizeInternal() + + THREAD_HEADER_COUNT + messageRequestData.includeWarningUpdateMessage().toInt() + messageRequestData.isHidden.toInt() + showUniversalExpireTimerUpdate.toInt() @@ -85,7 +91,7 @@ class ConversationDataSource( return SignalDatabase.messages.getMessageCountForThread(threadId) } - override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): List { + override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): List { val stopwatch = Stopwatch("load($start, $length), thread $threadId") var records: MutableList = ArrayList(length) 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)) } - if (messageRequestData.isHidden && (start + length >= size())) { + if (messageRequestData.isHidden && (start + length >= totalSize)) { records.add(RemovedContactHidden(threadId)) } @@ -174,12 +180,26 @@ class ConversationDataSource( } stopwatch.split("conversion") + + val threadHeaderIndex = totalSize - THREAD_HEADER_COUNT + + val threadHeaders: List = if (start + length > threadHeaderIndex) { + listOf(loadThreadHeader()) + } else { + emptyList() + } + + stopwatch.split("header") stopwatch.stop(TAG) - return messages + return if (threadHeaders.isNotEmpty()) messages + threadHeaders else messages } override fun load(key: ConversationElementKey): ConversationElement? { + if (key is ThreadHeaderKey) { + return loadThreadHeader() + } + if (key !is MessageBackedKey) { Log.w(TAG, "Loading non-message related id $key") return null @@ -249,10 +269,15 @@ class ConversationDataSource( override fun getKey(conversationMessage: ConversationElement): ConversationElementKey { return when (conversationMessage) { is ConversationMessageElement -> MessageBackedKey(conversationMessage.conversationMessage.messageRecord.id) + is ThreadHeader -> ThreadHeaderKey else -> throw AssertionError() } } + private fun loadThreadHeader(): ThreadHeader { + return ThreadHeader(messageRequestRepository.getRecipientInfo(threadRecipient.id, threadId)) + } + private fun ConversationMessage.toMappingModel(): MappingModel<*> { return if (messageRecord.isUpdate) { ConversationUpdate(this) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationElements.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationElements.kt index b63a7c40fd..297398231b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationElements.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationElements.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.conversation.v2.data import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.messagerequests.MessageRequestRecipientInfo import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel sealed interface ConversationMessageElement { @@ -71,3 +72,13 @@ data class IncomingMedia( return false } } + +data class ThreadHeader(val recipientInfo: MessageRequestRecipientInfo) : MappingModel { + override fun areItemsTheSame(newItem: ThreadHeader): Boolean { + return true + } + + override fun areContentsTheSame(newItem: ThreadHeader): Boolean { + return false + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java index 5593d88760..0be2b9bbf0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java @@ -66,7 +66,7 @@ abstract class ConversationListDataSource implements PagedDataSource load(int start, int length, @NonNull CancellationSignal cancellationSignal) { + public @NonNull List load(int start, int length, int totalSize, @NonNull CancellationSignal cancellationSignal) { SignalTrace.beginSection("ConversationListDataSource#load"); Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), " + getClass().getSimpleName() + ", " + conversationFilter); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt index 399807b76d..df913b1e4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt @@ -149,7 +149,10 @@ class ConversationListViewModel( conversationListDataSource .subscribeOn(Schedulers.io()) .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) } .addTo(disposables) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ItemDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ItemDecoration.kt index fb20fe9b50..2442f2e0e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ItemDecoration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ItemDecoration.kt @@ -4,6 +4,7 @@ import android.graphics.Canvas import androidx.core.view.children import androidx.recyclerview.widget.RecyclerView import org.thoughtcrime.securesms.conversation.ConversationAdapter +import org.thoughtcrime.securesms.conversation.v2.ConversationAdapterV2 import kotlin.math.min /** @@ -28,7 +29,7 @@ class GiphyMp4ItemDecoration( } else { val footerViewHolder = parent.children .map { parent.getChildViewHolder(it) } - .filterIsInstance(ConversationAdapter.FooterViewHolder::class.java) + .filter { it is ConversationAdapter.FooterViewHolder || it is ConversationAdapterV2.ThreadHeaderViewHolder } .firstOrNull() if (footerViewHolder == null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PagedDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PagedDataSource.java index 843acf73ef..788a82994a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PagedDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4PagedDataSource.java @@ -66,7 +66,7 @@ final class GiphyMp4PagedDataSource implements PagedDataSource load(int start, int length, @NonNull CancellationSignal cancellationSignal) { + public @NonNull List load(int start, int length, int totalSize, @NonNull CancellationSignal cancellationSignal) { try { Log.d(TAG, "Loading from " + start + " to " + (start + length)); return new LinkedList<>(performFetch(start, length).getData()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogDataSource.kt index 8762564b3c..d20bd48d5f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogDataSource.kt @@ -24,7 +24,7 @@ class LogDataSource( return prefixLines.size + logDatabase.getLogCountBeforeTime(untilTime) } - override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): List { + override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): List { if (start + length < prefixLines.size) { return prefixLines.subList(start, start + length) } else if (start < prefixLines.size) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRecipientInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRecipientInfo.kt new file mode 100644 index 0000000000..559269ca1f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRecipientInfo.kt @@ -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 = emptyList(), + val messageRequestState: MessageRequestState? = null +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java index 7debc39bd3..b9b9a54467 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java @@ -72,6 +72,31 @@ public final class MessageRequestRepository { }); } + @WorkerThread + public @NonNull MessageRequestRecipientInfo getRecipientInfo(@NonNull RecipientId recipientId, long threadId) { + List sharedGroups = SignalDatabase.groups().getPushGroupNamesContainingMember(recipientId); + Optional 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 public @NonNull MessageRequestState getMessageRequestState(@NonNull Recipient recipient, long threadId) { if (recipient.isBlocked()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt index 4a7d74d78a..ad69dd021e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt @@ -15,7 +15,7 @@ class StoryGroupReplyDataSource(private val parentStoryId: Long) : PagedDataSour return SignalDatabase.messages.getNumberOfStoryReplies(parentStoryId) } - override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList { + override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList { val results: MutableList = ArrayList(length) SignalDatabase.messages.getStoryReplies(parentStoryId).use { cursor -> cursor.moveToPosition(start - 1) diff --git a/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt b/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt index c3f3c7ab32..2a8f632538 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt @@ -53,7 +53,7 @@ class ContactSearchPagedDataSourceTest { @Test fun `Given recentsWHeader and individualsWHeaderWExpand, when I load 12, then I expect properly structured output`() { val testSubject = createTestSubject() - val result = testSubject.load(0, 12) { false } + val result = testSubject.load(0, 12, 20) { false } val expected = listOf( ContactSearchKey.Header(ContactSearchConfiguration.SectionKey.RECENTS), @@ -78,7 +78,7 @@ class ContactSearchPagedDataSourceTest { @Test fun `Given recentsWHeader and individualsWHeaderWExpand, when I load 10 with offset 5, then I expect properly structured output`() { val testSubject = createTestSubject() - val result = testSubject.load(5, 10) { false } + val result = testSubject.load(5, 10, 15) { false } val expected = listOf( ContactSearchKey.RecipientSearchKey(RecipientId.UNKNOWN, false), @@ -101,7 +101,7 @@ class ContactSearchPagedDataSourceTest { @Test fun `Given storiesWithHeaderAndExtras, when I load 11, then I expect properly structured output`() { val testSubject = createStoriesSubject() - val result = testSubject.load(0, 12) { false } + val result = testSubject.load(0, 12, 12) { false } val expected = listOf( ContactSearchKey.Header(ContactSearchConfiguration.SectionKey.STORIES), @@ -136,7 +136,7 @@ class ContactSearchPagedDataSourceTest { fun `Given only arbitrary elements, when I load 1, then I expect 1`() { val testSubject = createArbitrarySubject() 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")) } diff --git a/paging/app/src/main/java/org/signal/pagingtest/MainDataSource.java b/paging/app/src/main/java/org/signal/pagingtest/MainDataSource.java index 3cbb1137fe..67ad7f2374 100644 --- a/paging/app/src/main/java/org/signal/pagingtest/MainDataSource.java +++ b/paging/app/src/main/java/org/signal/pagingtest/MainDataSource.java @@ -6,7 +6,6 @@ import androidx.annotation.Nullable; import org.signal.paging.PagedDataSource; import java.util.ArrayList; -import java.util.Iterator; import java.util.List; import java.util.ListIterator; import java.util.UUID; @@ -25,7 +24,7 @@ class MainDataSource implements PagedDataSource { } @Override - public @NonNull List load(int start, int length, @NonNull CancellationSignal cancellationSignal) { + public @NonNull List load(int start, int length, int totalSize, @NonNull CancellationSignal cancellationSignal) { try { Thread.sleep(500); } catch (InterruptedException e) { diff --git a/paging/lib/src/main/java/org/signal/paging/FixedSizePagingController.java b/paging/lib/src/main/java/org/signal/paging/FixedSizePagingController.java index 9c4ac60c94..4c3780b6c9 100644 --- a/paging/lib/src/main/java/org/signal/paging/FixedSizePagingController.java +++ b/paging/lib/src/main/java/org/signal/paging/FixedSizePagingController.java @@ -66,6 +66,7 @@ class FixedSizePagingController implements PagingController { final int loadStart; final int loadEnd; + final int totalSize; synchronized (loadState) { if (loadState.size() == 0) { @@ -94,7 +95,7 @@ class FixedSizePagingController implements PagingController { return; } - int totalSize = loadState.size(); + totalSize = loadState.size(); loadState.markRange(loadStart, loadEnd); @@ -107,7 +108,7 @@ class FixedSizePagingController implements PagingController { return; } - List loaded = dataSource.load(loadStart, loadEnd - loadStart, () -> invalidated); + List loaded = dataSource.load(loadStart, loadEnd - loadStart, totalSize, () -> invalidated); if (invalidated) { Log.w(TAG, buildDataNeededLog(aroundIndex, "Invalidated! Just after data was loaded.")); diff --git a/paging/lib/src/main/java/org/signal/paging/PagedDataSource.java b/paging/lib/src/main/java/org/signal/paging/PagedDataSource.java index 55b1d31557..7b48066588 100644 --- a/paging/lib/src/main/java/org/signal/paging/PagedDataSource.java +++ b/paging/lib/src/main/java/org/signal/paging/PagedDataSource.java @@ -17,15 +17,15 @@ public interface PagedDataSource { int size(); /** - * @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 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 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. - * * @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 - @NonNull List load(int start, int length, @NonNull CancellationSignal cancellationSignal); + @NonNull List load(int start, int length, int totalSize, @NonNull CancellationSignal cancellationSignal); @WorkerThread @Nullable Data load(Key key);