diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/view/AvatarView.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/view/AvatarView.kt index feab95e3e..c100b36a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/view/AvatarView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/view/AvatarView.kt @@ -6,6 +6,7 @@ import android.view.View import android.widget.FrameLayout import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.recipients.Recipient @@ -46,6 +47,14 @@ class AvatarView @JvmOverloads constructor( avatar.scaleY = 1f } + fun setStoryRingFromState(storyViewState: StoryViewState) { + when (storyViewState) { + StoryViewState.NONE -> hideStoryRing() + StoryViewState.UNVIEWED -> showStoryRing(true) + StoryViewState.VIEWED -> showStoryRing(false) + } + } + /** * Displays Note-to-Self */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 406b89e75..2fa79453b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -266,6 +266,7 @@ class ConversationSettingsFragment : DSLSettingsFragment( customPref( AvatarPreference.Model( recipient = state.recipient, + storyViewState = state.storyViewState, onAvatarClick = { avatar -> if (!state.recipient.isSelf) { // startActivity(StoryViewerActivity.createIntent(requireContext(), state.recipient.id)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt index b506dcc45..6695d6133 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt @@ -4,6 +4,8 @@ import android.content.Context import android.database.Cursor import androidx.annotation.WorkerThread import androidx.lifecycle.LiveData +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.logging.Log import org.signal.storageservice.protos.groups.local.DecryptedGroup @@ -13,6 +15,7 @@ import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.MediaDatabase import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.IdentityRecord +import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.GroupManager @@ -44,6 +47,14 @@ class ConversationSettingsRepository( } } + fun getStoryViewState(groupId: GroupId): Observable { + return Observable.fromCallable { + SignalDatabase.recipients.getByGroupId(groupId) + }.flatMap { + StoryViewState.getForRecipientId(it.get()) + }.observeOn(Schedulers.io()) + } + fun getThreadId(recipientId: RecipientId, consumer: (Long) -> Unit) { SignalExecutors.BOUNDED.execute { consumer(SignalDatabase.threads.getThreadIdIfExistsFor(recipientId)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt index 39ef13e73..0f42447b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt @@ -4,12 +4,14 @@ import android.database.Cursor import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference import org.thoughtcrime.securesms.database.model.IdentityRecord +import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry import org.thoughtcrime.securesms.recipients.Recipient data class ConversationSettingsState( val threadId: Long = -1, + val storyViewState: StoryViewState = StoryViewState.NONE, val recipient: Recipient = Recipient.UNKNOWN, val buttonStripState: ButtonStripPreference.State = ButtonStripPreference.State(), val disappearingMessagesLifespan: Int = 0, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt index b1c60dbae..06940abd7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt @@ -6,12 +6,15 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign import org.signal.core.util.ThreadUtil import org.signal.core.util.concurrent.SignalExecutors import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.LiveGroup import org.thoughtcrime.securesms.recipients.Recipient @@ -46,6 +49,8 @@ sealed class ConversationSettingsViewModel( val state: LiveData = store.stateLiveData val events: LiveData = internalEvents + protected val disposable = CompositeDisposable() + init { val threadId: LiveData = Transformations.distinctUntilChanged(Transformations.map(state) { it.threadId }) val updater: LiveData = LiveDataUtil.combineLatest(threadId, sharedMediaUpdateTrigger) { tId, _ -> tId } @@ -105,6 +110,7 @@ sealed class ConversationSettingsViewModel( cleared = true openedMediaCursors.forEach { it.ensureClosed() } store.clear() + disposable.clear() } private fun Cursor?.ensureClosed() { @@ -126,6 +132,10 @@ sealed class ConversationSettingsViewModel( private val liveRecipient = Recipient.live(recipientId) init { + disposable += StoryViewState.getForRecipientId(recipientId).subscribe { storyViewState -> + store.update { it.copy(storyViewState = storyViewState) } + } + store.update(liveRecipient.liveData) { recipient, state -> state.copy( recipient = recipient, @@ -240,6 +250,10 @@ sealed class ConversationSettingsViewModel( private val liveGroup = LiveGroup(groupId) init { + disposable += repository.getStoryViewState(groupId).subscribe { storyViewState -> + store.update { it.copy(storyViewState = storyViewState) } + } + val recipientAndIsActive = LiveDataUtil.combineLatest(liveGroup.groupRecipient, liveGroup.isActive) { r, a -> r to a } store.update(recipientAndIsActive) { (recipient, isActive), state -> state.copy( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt index 56111a127..48686538b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt @@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.components.settings.PreferenceModel import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto +import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory @@ -26,6 +27,7 @@ object AvatarPreference { class Model( val recipient: Recipient, + val storyViewState: StoryViewState, val onAvatarClick: (View) -> Unit, val onBadgeClick: (Badge) -> Unit ) : PreferenceModel() { @@ -63,6 +65,7 @@ object AvatarPreference { } } + avatar.setStoryRingFromState(model.storyViewState) avatar.displayChatAvatar(model.recipient) avatar.disableQuickContact() avatar.setOnClickListener { model.onAvatarClick(avatar) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index 65b2db669..71d669d9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -553,6 +553,8 @@ public class ConversationParentFragment extends Fragment initializeInsightObserver(); initializeActionBar(); + viewModel.getStoryViewState(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), titleView::setStoryRingFromState); + requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java index fc5f16e39..551c82a58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.avatar.view.AvatarView; import org.thoughtcrime.securesms.badges.BadgeImageView; import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.database.model.StoryViewState; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; @@ -124,6 +125,10 @@ public class ConversationTitleView extends RelativeLayout { updateVerifiedSubtitleVisibility(); } + public void setStoryRingFromState(@NonNull StoryViewState storyViewState) { + avatar.setStoryRingFromState(storyViewState); + } + public void setVerified(boolean verified) { this.verified.setVisibility(verified ? View.VISIBLE : View.GONE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java index 03b854e07..58c054371 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java @@ -5,6 +5,7 @@ import android.app.Application; import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveDataReactiveStreams; import androidx.lifecycle.MutableLiveData; @@ -18,6 +19,7 @@ import com.annimon.stream.Stream; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; +import org.reactivestreams.Publisher; import org.signal.core.util.MapUtil; import org.signal.core.util.logging.Log; import org.signal.paging.PagedData; @@ -30,6 +32,7 @@ import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; import org.thoughtcrime.securesms.conversation.colors.NameColor; import org.thoughtcrime.securesms.database.DatabaseObserver; import org.thoughtcrime.securesms.database.model.MessageId; +import org.thoughtcrime.securesms.database.model.StoryViewState; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.LiveGroup; @@ -61,6 +64,7 @@ import java.util.Set; import java.util.concurrent.TimeUnit; import io.reactivex.rxjava3.core.BackpressureStrategy; +import io.reactivex.rxjava3.core.Flowable; import io.reactivex.rxjava3.core.Observable; public class ConversationViewModel extends ViewModel { @@ -198,6 +202,14 @@ public class ConversationViewModel extends ViewModel { threadAnimationStateStore.getStateLiveData().observeForever(threadAnimationStateStoreDriver); } + LiveData getStoryViewState(@NonNull LifecycleOwner lifecycle) { + Publisher recipientIdPublisher = LiveDataReactiveStreams.toPublisher(lifecycle, recipientId); + Flowable storyViewState = Flowable.fromPublisher(recipientIdPublisher) + .flatMap(id -> StoryViewState.getForRecipientId(id).toFlowable(BackpressureStrategy.LATEST)); + + return LiveDataReactiveStreams.fromPublisher(storyViewState); + } + void onMessagesCommitted(@NonNull List conversationMessages) { if (Util.hasItems(conversationMessages)) { threadAnimationStateStore.update(state -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java index 4c1ef007d..0c194c1a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.database.documents.NetworkFailure; import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.database.model.StoryViewState; import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; import org.thoughtcrime.securesms.insights.InsightsConstants; import org.thoughtcrime.securesms.mms.IncomingMediaMessage; @@ -193,6 +194,7 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns public abstract long getUnreadStoryCount(); public abstract @Nullable Long getOldestStorySendTimestamp(); public abstract int deleteStoriesOlderThan(long timestamp); + public abstract @NonNull StoryViewState getStoryViewState(@NonNull RecipientId recipientId); final @NonNull String getOutgoingTypeClause() { List segments = new ArrayList<>(Types.OUTGOING_MESSAGE_TYPES.length); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index c633f3a14..748ba87db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -23,6 +23,7 @@ import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.annimon.stream.Collectors; import com.annimon.stream.Stream; @@ -52,6 +53,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord; import org.thoughtcrime.securesms.database.model.Quote; import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.database.model.StoryViewState; import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; @@ -74,6 +76,7 @@ import org.thoughtcrime.securesms.revealable.ViewOnceUtil; import org.thoughtcrime.securesms.sms.IncomingTextMessage; import org.thoughtcrime.securesms.sms.OutgoingTextMessage; import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.JsonUtils; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.SqlUtil; @@ -591,6 +594,46 @@ public class MmsDatabase extends MessageDatabase { return new Reader(cursor); } + @Override + public @NonNull StoryViewState getStoryViewState(@NonNull RecipientId recipientId) { + if (!FeatureFlags.stories() || SignalStore.storyValues().isFeatureDisabled()) { + return StoryViewState.NONE; + } + + long threadId = SignalDatabase.threads().getThreadIdIfExistsFor(recipientId); + + return getStoryViewState(threadId); + } + + @VisibleForTesting + @NonNull StoryViewState getStoryViewState(long threadId) { + final String hasStoryQuery = "SELECT EXISTS(SELECT 1 FROM " + TABLE_NAME + " WHERE " + IS_STORY_CLAUSE + " AND " + THREAD_ID_WHERE + " LIMIT 1)"; + final String[] hasStoryArgs = SqlUtil.buildArgs(1, 0, threadId); + final boolean hasStories; + + try (Cursor cursor = getReadableDatabase().rawQuery(hasStoryQuery, hasStoryArgs)) { + hasStories = cursor != null && cursor.moveToFirst() && !cursor.isNull(0) && cursor.getInt(0) == 1; + } + + if (!hasStories) { + return StoryViewState.NONE; + } + + final String hasUnviewedStoriesQuery = "SELECT EXISTS(SELECT 1 FROM " + TABLE_NAME + " WHERE " + IS_STORY_CLAUSE + " AND " + THREAD_ID_WHERE + " AND " + VIEWED_RECEIPT_COUNT + " = ? " + "AND NOT (" + getOutgoingTypeClause() + ") LIMIT 1)"; + final String[] hasUnviewedStoriesArgs = SqlUtil.buildArgs(1, 0, threadId, 0); + final boolean hasUnviewedStories; + + try (Cursor cursor = getReadableDatabase().rawQuery(hasUnviewedStoriesQuery, hasUnviewedStoriesArgs)) { + hasUnviewedStories = cursor != null && cursor.moveToFirst() && !cursor.isNull(0) && cursor.getInt(0) == 1; + } + + if (hasUnviewedStories) { + return StoryViewState.UNVIEWED; + } else { + return StoryViewState.VIEWED; + } + } + @Override public @NonNull MessageId getStoryId(@NonNull RecipientId authorId, long sentTimestamp) throws NoSuchMessageException { SQLiteDatabase database = databaseHelper.getSignalReadableDatabase(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index d5c473053..1f484d5e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil; import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.database.model.StoryViewState; import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails; import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; @@ -1431,6 +1432,11 @@ public class SmsDatabase extends MessageDatabase { throw new UnsupportedOperationException(); } + @Override + public @NonNull StoryViewState getStoryViewState(@NonNull RecipientId recipientId) { + throw new UnsupportedOperationException(); + } + @Override public int deleteStoriesOlderThan(long timestamp) { throw new UnsupportedOperationException(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/StoryViewState.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/StoryViewState.kt new file mode 100644 index 000000000..e3158d6d1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/StoryViewState.kt @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.database.model + +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.DatabaseObserver +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * Denotes whether a given recipient has stories, and whether those stories are viewed or unviewed. + */ +enum class StoryViewState { + NONE, + UNVIEWED, + VIEWED; + + companion object { + @JvmStatic + fun getForRecipientId(recipientId: RecipientId): Observable { + return Observable.create { emitter -> + fun refresh() { + emitter.onNext(SignalDatabase.mms.getStoryViewState(recipientId)) + } + + val storyObserver = DatabaseObserver.Observer { + refresh() + } + + ApplicationDependencies.getDatabaseObserver().registerStoryObserver(recipientId, storyObserver) + emitter.setCancellable { + ApplicationDependencies.getDatabaseObserver().unregisterObserver(storyObserver) + } + + refresh() + }.observeOn(Schedulers.io()) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java index 23379f3de..e80d67873 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java @@ -3,10 +3,8 @@ package org.thoughtcrime.securesms.recipients.ui.bottomsheet; import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.Intent; -import android.content.res.ColorStateList; import android.graphics.drawable.Drawable; import android.os.Bundle; -import android.text.SpannableString; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.view.LayoutInflater; @@ -20,10 +18,9 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; -import androidx.core.widget.TextViewCompat; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.FragmentManager; -import androidx.lifecycle.ViewModelProviders; +import androidx.lifecycle.ViewModelProvider; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; @@ -32,7 +29,6 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.avatar.view.AvatarView; import org.thoughtcrime.securesms.badges.BadgeImageView; import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment; -import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon; import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; @@ -50,7 +46,6 @@ import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.SpanUtil; import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.Util; -import org.thoughtcrime.securesms.util.ViewUtil; import java.util.Objects; @@ -150,7 +145,11 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF RecipientDialogViewModel.Factory factory = new RecipientDialogViewModel.Factory(requireContext().getApplicationContext(), recipientId, groupId); - viewModel = ViewModelProviders.of(this, factory).get(RecipientDialogViewModel.class); + viewModel = new ViewModelProvider(this, factory).get(RecipientDialogViewModel.class); + + viewModel.getStoryViewState().observe(getViewLifecycleOwner(), state -> { + avatar.setStoryRingFromState(state); + }); viewModel.getRecipient().observe(getViewLifecycleOwner(), recipient -> { interactionsContainer.setVisibility(recipient.isSelf() ? View.GONE : View.VISIBLE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java index a30a5010f..68739a59d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java @@ -18,10 +18,10 @@ import androidx.lifecycle.ViewModelProvider; import org.signal.core.util.ThreadUtil; import org.thoughtcrime.securesms.BlockUnblockDialog; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.verify.VerifyIdentityActivity; import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.model.IdentityRecord; +import org.thoughtcrime.securesms.database.model.StoryViewState; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.LiveGroup; import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; @@ -32,9 +32,13 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.thoughtcrime.securesms.verify.VerifyIdentityActivity; import java.util.Objects; +import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.disposables.Disposable; + final class RecipientDialogViewModel extends ViewModel { private final Context context; @@ -44,6 +48,8 @@ final class RecipientDialogViewModel extends ViewModel { private final LiveData adminActionStatus; private final LiveData canAddToAGroup; private final MutableLiveData adminActionBusy; + private final MutableLiveData storyViewState; + private final CompositeDisposable disposables; private RecipientDialogViewModel(@NonNull Context context, @NonNull RecipientDialogRepository recipientDialogRepository) @@ -52,6 +58,8 @@ final class RecipientDialogViewModel extends ViewModel { this.recipientDialogRepository = recipientDialogRepository; this.identity = new MutableLiveData<>(); this.adminActionBusy = new MutableLiveData<>(false); + this.storyViewState = new MutableLiveData<>(); + this.disposables = new CompositeDisposable(); boolean recipientIsSelf = recipientDialogRepository.getRecipientId().equals(Recipient.self().getId()); @@ -87,6 +95,20 @@ final class RecipientDialogViewModel extends ViewModel { (r, count) -> count > 0 && r.isRegistered() && !r.isGroup() && !r.isSelf() && !r.isBlocked()); recipientDialogRepository.getActiveGroupCount(localGroupCount::postValue); + + Disposable storyViewStateDisposable = StoryViewState.getForRecipientId(recipientDialogRepository.getRecipientId()) + .subscribe(storyViewState::postValue); + + disposables.add(storyViewStateDisposable); + } + + @Override protected void onCleared() { + super.onCleared(); + disposables.clear(); + } + + LiveData getStoryViewState() { + return storyViewState; } LiveData getRecipient() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt index 90a984c3b..1b86a3ce4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt @@ -79,7 +79,7 @@ object StoriesLandingItem { val record = model.data.primaryStory.messageRecord as MediaMmsMessageRecord - avatarView.showStoryRing(model.data.hasUnreadStory) + avatarView.setStoryRingFromState(model.data.storyViewState) storyPreview.setImageResource(GlideApp.with(storyPreview), record.slideDeck.thumbnailSlide!!, false, true) if (model.data.secondaryStory != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItemData.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItemData.kt index 46c81861a..9f93c2e4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItemData.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItemData.kt @@ -1,13 +1,14 @@ package org.thoughtcrime.securesms.stories.landing import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.recipients.Recipient /** * Data required by each row of the Stories Landing Page for proper rendering. */ data class StoriesLandingItemData( - val hasUnreadStory: Boolean, + val storyViewState: StoryViewState, val hasReplies: Boolean, val hasRepliesFromSelf: Boolean, val isHidden: Boolean, diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingRepository.kt index 0ea7c099c..14b2bdbd5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingRepository.kt @@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.database.DatabaseObserver import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientForeverObserver @@ -66,7 +67,7 @@ class StoriesLandingRepository(context: Context) { fun refresh() { val itemData = StoriesLandingItemData( storyRecipient = sender, - hasUnreadStory = messageRecords.any { it.viewedReceiptCount == 0 && !it.isOutgoing }, + storyViewState = getStoryViewState(messageRecords), hasReplies = messageRecords.any { SignalDatabase.mms.getNumberOfStoryReplies(it.id) > 0 }, hasRepliesFromSelf = messageRecords.any { SignalDatabase.mms.hasSelfReplyInStory(it.id) }, isHidden = Recipient.resolved(messageRecords.first().recipient.id).shouldHideStory(), @@ -105,4 +106,17 @@ class StoriesLandingRepository(context: Context) { SignalDatabase.recipients.setHideStory(recipientId, hideStory) }.subscribeOn(Schedulers.io()) } + + private fun getStoryViewState(messageRecords: List): StoryViewState { + val incoming = messageRecords.filterNot { it.isOutgoing } + if (incoming.isEmpty()) { + return StoryViewState.NONE + } + + if (incoming.any { it.viewedReceiptCount == 0 }) { + return StoryViewState.UNVIEWED + } + + return StoryViewState.VIEWED + } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/MmsDatabaseTest.kt b/app/src/test/java/org/thoughtcrime/securesms/database/MmsDatabaseTest.kt index ea6b3a7c5..99b759fcf 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/MmsDatabaseTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/MmsDatabaseTest.kt @@ -13,6 +13,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.thoughtcrime.securesms.database.MmsSmsColumns.Types +import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.testing.TestDatabaseUtil @RunWith(RobolectricTestRunner::class) @@ -71,4 +72,43 @@ class MmsDatabaseTest { TestMms.insert(db, threadId = 1, sentTimeMillis = 3, type = Types.BASE_SENDING_TYPE or Types.SECURE_MESSAGE_BIT or Types.PUSH_MESSAGE_BIT or Types.GROUP_LEAVE_BIT or Types.GROUP_V2_BIT or Types.GROUP_UPDATE_BIT) assertEquals(-1, mmsDatabase.getLatestGroupQuitTimestamp(1, 4)) } + + @Test + fun `Given no stories in database, when I getStoryViewState, then I expect NONE`() { + assertEquals(StoryViewState.NONE, mmsDatabase.getStoryViewState(1)) + } + + @Test + fun `Given stories in database not in thread 1, when I getStoryViewState for thread 1, then I expect NONE`() { + TestMms.insert(db, threadId = 2, isStory = true) + TestMms.insert(db, threadId = 2, isStory = true) + assertEquals(StoryViewState.NONE, mmsDatabase.getStoryViewState(1)) + } + + @Test + fun `Given viewed incoming stories in database, when I getStoryViewState, then I expect VIEWED`() { + TestMms.insert(db, threadId = 1, isStory = true, viewed = true) + TestMms.insert(db, threadId = 1, isStory = true, viewed = true) + assertEquals(StoryViewState.VIEWED, mmsDatabase.getStoryViewState(1)) + } + + @Test + fun `Given unviewed incoming stories in database, when I getStoryViewState, then I expect UNVIEWED`() { + TestMms.insert(db, threadId = 1, isStory = true, viewed = false) + TestMms.insert(db, threadId = 1, isStory = true, viewed = false) + assertEquals(StoryViewState.UNVIEWED, mmsDatabase.getStoryViewState(1)) + } + + @Test + fun `Given mix of viewed and unviewed incoming stories in database, when I getStoryViewState, then I expect UNVIEWED`() { + TestMms.insert(db, threadId = 1, isStory = true, viewed = true) + TestMms.insert(db, threadId = 1, isStory = true, viewed = false) + assertEquals(StoryViewState.UNVIEWED, mmsDatabase.getStoryViewState(1)) + } + + @Test + fun `Given only outgoing story in database, when I getStoryViewState, then I expect VIEWED`() { + TestMms.insert(db, threadId = 1, isStory = true, type = Types.BASE_OUTBOX_TYPE) + assertEquals(StoryViewState.VIEWED, mmsDatabase.getStoryViewState(1)) + } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt b/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt index f3305accf..ce9fb7771 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt @@ -23,7 +23,9 @@ object TestMms { distributionType: Int = ThreadDatabase.DistributionTypes.DEFAULT, type: Long = MmsSmsColumns.Types.BASE_INBOX_TYPE, unread: Boolean = false, - threadId: Long = 1 + viewed: Boolean = false, + threadId: Long = 1, + isStory: Boolean = false ): Long { val message = OutgoingMediaMessage( recipient, @@ -34,7 +36,7 @@ object TestMms { expiresIn, viewOnce, distributionType, - false, + isStory, null, null, emptyList(), @@ -50,6 +52,7 @@ object TestMms { body = body, type = type, unread = unread, + viewed = viewed, threadId = threadId, receivedTimestampMillis = receivedTimestampMillis ) @@ -61,6 +64,7 @@ object TestMms { body: String = message.body, type: Long = MmsSmsColumns.Types.BASE_INBOX_TYPE, unread: Boolean = false, + viewed: Boolean = false, threadId: Long = 1, receivedTimestampMillis: Long = System.currentTimeMillis(), ): Long { @@ -78,6 +82,8 @@ object TestMms { put(MmsSmsColumns.RECIPIENT_ID, message.recipient.id.serialize()) put(MmsSmsColumns.DELIVERY_RECEIPT_COUNT, 0) put(MmsSmsColumns.RECEIPT_TIMESTAMP, 0) + put(MmsSmsColumns.VIEWED_RECEIPT_COUNT, if (viewed) 1 else 0) + put(MmsDatabase.IS_STORY, if (message.isStory) 1 else 0) put(MmsSmsColumns.BODY, body) put(MmsDatabase.PART_COUNT, 0)