Utilize database-backed unread message count in thread.
This commit is contained in:
parent
fe6058e0df
commit
0b44935ae2
8 changed files with 280 additions and 16 deletions
|
@ -397,10 +397,8 @@ public class ConversationAdapter
|
||||||
return recipient.getId().equals(recipientId);
|
return recipient.getId().equals(recipientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void onBindLastSeenViewHolder(StickyHeaderViewHolder viewHolder, int position) {
|
void onBindLastSeenViewHolder(StickyHeaderViewHolder viewHolder, long unreadCount) {
|
||||||
int messagePosition = isTypingViewEnabled ? position - 1 : position;
|
viewHolder.setText(viewHolder.itemView.getContext().getResources().getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, (int) unreadCount, (int) unreadCount));
|
||||||
int count = messagePosition + 1;
|
|
||||||
viewHolder.setText(viewHolder.itemView.getContext().getResources().getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, count, count));
|
|
||||||
|
|
||||||
if (hasWallpaper) {
|
if (hasWallpaper) {
|
||||||
viewHolder.setBackgroundRes(R.drawable.wallpaper_bubble_background_18);
|
viewHolder.setBackgroundRes(R.drawable.wallpaper_bubble_background_18);
|
||||||
|
|
|
@ -203,6 +203,7 @@ import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||||
import kotlin.Unit;
|
import kotlin.Unit;
|
||||||
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
@SuppressLint("StaticFieldLeak")
|
||||||
|
@ -216,6 +217,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||||
private final ActionModeCallback actionModeCallback = new ActionModeCallback();
|
private final ActionModeCallback actionModeCallback = new ActionModeCallback();
|
||||||
private final ItemClickListener selectionClickListener = new ConversationFragmentItemClickListener();
|
private final ItemClickListener selectionClickListener = new ConversationFragmentItemClickListener();
|
||||||
private final LifecycleDisposable disposables = new LifecycleDisposable();
|
private final LifecycleDisposable disposables = new LifecycleDisposable();
|
||||||
|
private final LifecycleDisposable lastSeenDisposable = new LifecycleDisposable();
|
||||||
|
|
||||||
private ConversationFragmentListener listener;
|
private ConversationFragmentListener listener;
|
||||||
|
|
||||||
|
@ -225,7 +227,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||||
private Locale locale;
|
private Locale locale;
|
||||||
private FrameLayout videoContainer;
|
private FrameLayout videoContainer;
|
||||||
private RecyclerView list;
|
private RecyclerView list;
|
||||||
private RecyclerView.ItemDecoration lastSeenDecoration;
|
private LastSeenHeader lastSeenDecoration;
|
||||||
private RecyclerView.ItemDecoration inlineDateDecoration;
|
private RecyclerView.ItemDecoration inlineDateDecoration;
|
||||||
private ViewSwitcher topLoadMoreView;
|
private ViewSwitcher topLoadMoreView;
|
||||||
private ViewSwitcher bottomLoadMoreView;
|
private ViewSwitcher bottomLoadMoreView;
|
||||||
|
@ -258,7 +260,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||||
private Colorizer colorizer;
|
private Colorizer colorizer;
|
||||||
private ConversationUpdateTick conversationUpdateTick;
|
private ConversationUpdateTick conversationUpdateTick;
|
||||||
private MultiselectItemDecoration multiselectItemDecoration;
|
private MultiselectItemDecoration multiselectItemDecoration;
|
||||||
private LifecycleDisposable lifecycleDisposable;
|
|
||||||
|
|
||||||
private @Nullable ConversationData conversationData;
|
private @Nullable ConversationData conversationData;
|
||||||
private @Nullable ChatWallpaper chatWallpaper;
|
private @Nullable ChatWallpaper chatWallpaper;
|
||||||
|
@ -286,6 +287,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||||
@Override
|
@Override
|
||||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) {
|
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) {
|
||||||
disposables.bindTo(getViewLifecycleOwner());
|
disposables.bindTo(getViewLifecycleOwner());
|
||||||
|
lastSeenDisposable.bindTo(getViewLifecycleOwner());
|
||||||
|
|
||||||
final View view = inflater.inflate(R.layout.conversation_fragment, container, false);
|
final View view = inflater.inflate(R.layout.conversation_fragment, container, false);
|
||||||
videoContainer = view.findViewById(R.id.video_container);
|
videoContainer = view.findViewById(R.id.video_container);
|
||||||
|
@ -354,9 +356,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||||
|
|
||||||
giphyMp4ProjectionRecycler = initializeGiphyMp4();
|
giphyMp4ProjectionRecycler = initializeGiphyMp4();
|
||||||
|
|
||||||
lifecycleDisposable = new LifecycleDisposable();
|
|
||||||
lifecycleDisposable.bindTo(getViewLifecycleOwner());
|
|
||||||
|
|
||||||
this.groupViewModel = new ViewModelProvider(getParentFragment(), new ConversationGroupViewModel.Factory()).get(ConversationGroupViewModel.class);
|
this.groupViewModel = new ViewModelProvider(getParentFragment(), new ConversationGroupViewModel.Factory()).get(ConversationGroupViewModel.class);
|
||||||
this.messageCountsViewModel = new ViewModelProvider(getParentFragment()).get(MessageCountsViewModel.class);
|
this.messageCountsViewModel = new ViewModelProvider(getParentFragment()).get(MessageCountsViewModel.class);
|
||||||
this.conversationViewModel = new ViewModelProvider(getParentFragment(), new ConversationViewModel.Factory()).get(ConversationViewModel.class);
|
this.conversationViewModel = new ViewModelProvider(getParentFragment(), new ConversationViewModel.Factory()).get(ConversationViewModel.class);
|
||||||
|
@ -438,6 +437,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||||
|
|
||||||
conversationViewModel.getSearchQuery().observe(getViewLifecycleOwner(), this::onSearchQueryUpdated);
|
conversationViewModel.getSearchQuery().observe(getViewLifecycleOwner(), this::onSearchQueryUpdated);
|
||||||
|
|
||||||
|
disposables.add(conversationViewModel.getMarkReadRequests()
|
||||||
|
.subscribe(timeSince -> markReadHelper.onViewsRevealed(timeSince)));
|
||||||
|
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -975,12 +977,23 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setLastSeen(long lastSeen) {
|
public void setLastSeen(long lastSeen) {
|
||||||
|
lastSeenDisposable.clear();
|
||||||
if (lastSeenDecoration != null) {
|
if (lastSeenDecoration != null) {
|
||||||
list.removeItemDecoration(lastSeenDecoration);
|
list.removeItemDecoration(lastSeenDecoration);
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSeenDecoration = new LastSeenHeader(getListAdapter(), lastSeen);
|
lastSeenDecoration = new LastSeenHeader(getListAdapter(), lastSeen);
|
||||||
list.addItemDecoration(lastSeenDecoration, 0);
|
list.addItemDecoration(lastSeenDecoration, 0);
|
||||||
|
|
||||||
|
if (lastSeen > 0) {
|
||||||
|
lastSeenDisposable.add(conversationViewModel.getThreadUnreadCount()
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(unreadCount -> {
|
||||||
|
lastSeenDecoration.setUnreadCount(unreadCount);
|
||||||
|
list.invalidateItemDecorations();
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleCopyMessage(final Set<MultiselectPart> multiselectParts) {
|
private void handleCopyMessage(final Set<MultiselectPart> multiselectParts) {
|
||||||
|
@ -1383,7 +1396,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||||
.max(Long::compareTo)
|
.max(Long::compareTo)
|
||||||
.orElse(0L);
|
.orElse(0L);
|
||||||
|
|
||||||
markReadHelper.onViewsRevealed(Math.max(record.getDateReceived(), latestReactionReceived));
|
conversationViewModel.submitMarkReadRequest(Math.max(record.getDateReceived(), latestReactionReceived));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2126,7 +2139,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleBlockJoinRequest(@NonNull Recipient recipient) {
|
private void handleBlockJoinRequest(@NonNull Recipient recipient) {
|
||||||
lifecycleDisposable.add(
|
disposables.add(
|
||||||
groupViewModel.blockJoinRequests(ConversationFragment.this.recipient.get(), recipient)
|
groupViewModel.blockJoinRequests(ConversationFragment.this.recipient.get(), recipient)
|
||||||
.subscribe(result -> {
|
.subscribe(result -> {
|
||||||
if (result.isFailure()) {
|
if (result.isFailure()) {
|
||||||
|
|
|
@ -9,11 +9,14 @@ import androidx.annotation.WorkerThread;
|
||||||
import org.signal.core.util.concurrent.SignalExecutors;
|
import org.signal.core.util.concurrent.SignalExecutors;
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||||
|
import org.thoughtcrime.securesms.database.Database;
|
||||||
|
import org.thoughtcrime.securesms.database.DatabaseObserver;
|
||||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||||
|
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
|
import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
|
@ -162,4 +165,22 @@ class ConversationRepository {
|
||||||
true);
|
true);
|
||||||
}).subscribeOn(Schedulers.io());
|
}).subscribeOn(Schedulers.io());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Observable<ThreadRecord> getThreadRecord(long threadId) {
|
||||||
|
if (threadId == -1L) {
|
||||||
|
return Observable.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Observable.<ThreadRecord>create(emitter -> {
|
||||||
|
|
||||||
|
DatabaseObserver.Observer listener = () -> {
|
||||||
|
emitter.onNext(SignalDatabase.threads().getThreadRecord(threadId));
|
||||||
|
};
|
||||||
|
|
||||||
|
ApplicationDependencies.getDatabaseObserver().registerConversationObserver(threadId, listener);
|
||||||
|
emitter.setCancellable(() -> ApplicationDependencies.getDatabaseObserver().unregisterObserver(listener));
|
||||||
|
|
||||||
|
listener.onChanged();
|
||||||
|
}).subscribeOn(Schedulers.io());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,6 +59,7 @@ import io.reactivex.rxjava3.core.BackpressureStrategy;
|
||||||
import io.reactivex.rxjava3.core.Flowable;
|
import io.reactivex.rxjava3.core.Flowable;
|
||||||
import io.reactivex.rxjava3.core.Observable;
|
import io.reactivex.rxjava3.core.Observable;
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||||
|
import io.reactivex.rxjava3.processors.PublishProcessor;
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||||
import io.reactivex.rxjava3.subjects.BehaviorSubject;
|
import io.reactivex.rxjava3.subjects.BehaviorSubject;
|
||||||
import kotlin.Unit;
|
import kotlin.Unit;
|
||||||
|
@ -95,6 +96,8 @@ public class ConversationViewModel extends ViewModel {
|
||||||
private final RxStore<ConversationState> conversationStateStore;
|
private final RxStore<ConversationState> conversationStateStore;
|
||||||
private final CompositeDisposable disposables;
|
private final CompositeDisposable disposables;
|
||||||
private final BehaviorSubject<Unit> conversationStateTick;
|
private final BehaviorSubject<Unit> conversationStateTick;
|
||||||
|
private final RxStore<ThreadCountAggregator> threadCountStore;
|
||||||
|
private final PublishProcessor<Long> markReadRequestPublisher;
|
||||||
|
|
||||||
private ConversationIntents.Args args;
|
private ConversationIntents.Args args;
|
||||||
private int jumpToPosition;
|
private int jumpToPosition;
|
||||||
|
@ -123,6 +126,8 @@ public class ConversationViewModel extends ViewModel {
|
||||||
this.conversationStateStore = new RxStore<>(ConversationState.create(), Schedulers.io());
|
this.conversationStateStore = new RxStore<>(ConversationState.create(), Schedulers.io());
|
||||||
this.disposables = new CompositeDisposable();
|
this.disposables = new CompositeDisposable();
|
||||||
this.conversationStateTick = BehaviorSubject.createDefault(Unit.INSTANCE);
|
this.conversationStateTick = BehaviorSubject.createDefault(Unit.INSTANCE);
|
||||||
|
this.threadCountStore = new RxStore<>(ThreadCountAggregator.Init.INSTANCE, Schedulers.computation());
|
||||||
|
this.markReadRequestPublisher = PublishProcessor.create();
|
||||||
|
|
||||||
BehaviorSubject<Recipient> recipientCache = BehaviorSubject.create();
|
BehaviorSubject<Recipient> recipientCache = BehaviorSubject.create();
|
||||||
|
|
||||||
|
@ -132,6 +137,11 @@ public class ConversationViewModel extends ViewModel {
|
||||||
.map(Recipient::resolved)
|
.map(Recipient::resolved)
|
||||||
.subscribe(recipientCache);
|
.subscribe(recipientCache);
|
||||||
|
|
||||||
|
disposables.add(threadCountStore.update(
|
||||||
|
threadId.switchMap(conversationRepository::getThreadRecord).toFlowable(BackpressureStrategy.BUFFER),
|
||||||
|
(record, count) -> count.updateWith(record)
|
||||||
|
));
|
||||||
|
|
||||||
conversationStateStore.update(Observable.combineLatest(recipientId, conversationStateTick, (id, tick) -> id)
|
conversationStateStore.update(Observable.combineLatest(recipientId, conversationStateTick, (id, tick) -> id)
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.switchMap(conversationRepository::getSecurityInfo)
|
.switchMap(conversationRepository::getSecurityInfo)
|
||||||
|
@ -248,6 +258,10 @@ public class ConversationViewModel extends ViewModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void submitMarkReadRequest(long timestampSince) {
|
||||||
|
markReadRequestPublisher.onNext(timestampSince);
|
||||||
|
}
|
||||||
|
|
||||||
boolean shouldPlayMessageAnimations() {
|
boolean shouldPlayMessageAnimations() {
|
||||||
return threadAnimationStateStore.getState().shouldPlayMessageAnimations();
|
return threadAnimationStateStore.getState().shouldPlayMessageAnimations();
|
||||||
}
|
}
|
||||||
|
@ -292,6 +306,16 @@ public class ConversationViewModel extends ViewModel {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NonNull Flowable<Long> getMarkReadRequests() {
|
||||||
|
Flowable<ThreadCountAggregator> nonInitialThreadCount = threadCountStore.getStateFlowable().filter(count -> !(count instanceof ThreadCountAggregator.Init)).take(1);
|
||||||
|
|
||||||
|
return Flowable.combineLatest(markReadRequestPublisher.onBackpressureBuffer(), nonInitialThreadCount, (time, count) -> time);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull Flowable<Integer> getThreadUnreadCount() {
|
||||||
|
return threadCountStore.getStateFlowable().map(ThreadCountAggregator::getCount);
|
||||||
|
}
|
||||||
|
|
||||||
@NonNull Flowable<ConversationState> getConversationState() {
|
@NonNull Flowable<ConversationState> getConversationState() {
|
||||||
return conversationStateStore.getStateFlowable().observeOn(AndroidSchedulers.mainThread());
|
return conversationStateStore.getStateFlowable().observeOn(AndroidSchedulers.mainThread());
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,12 +16,18 @@ class LastSeenHeader extends StickyHeaderDecoration {
|
||||||
private final ConversationAdapter adapter;
|
private final ConversationAdapter adapter;
|
||||||
private final long lastSeenTimestamp;
|
private final long lastSeenTimestamp;
|
||||||
|
|
||||||
|
private long unreadCount;
|
||||||
|
|
||||||
LastSeenHeader(ConversationAdapter adapter, long lastSeenTimestamp) {
|
LastSeenHeader(ConversationAdapter adapter, long lastSeenTimestamp) {
|
||||||
super(adapter, false, false, ConversationAdapter.HEADER_TYPE_LAST_SEEN);
|
super(adapter, false, false, ConversationAdapter.HEADER_TYPE_LAST_SEEN);
|
||||||
this.adapter = adapter;
|
this.adapter = adapter;
|
||||||
this.lastSeenTimestamp = lastSeenTimestamp;
|
this.lastSeenTimestamp = lastSeenTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setUnreadCount(long unreadCount) {
|
||||||
|
this.unreadCount = unreadCount;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected boolean hasHeader(RecyclerView parent, StickyHeaderAdapter stickyAdapter, int position) {
|
protected boolean hasHeader(RecyclerView parent, StickyHeaderAdapter stickyAdapter, int position) {
|
||||||
if (lastSeenTimestamp <= 0) {
|
if (lastSeenTimestamp <= 0) {
|
||||||
|
@ -42,7 +48,7 @@ class LastSeenHeader extends StickyHeaderDecoration {
|
||||||
@Override
|
@Override
|
||||||
protected @NonNull RecyclerView.ViewHolder getHeader(RecyclerView parent, StickyHeaderAdapter stickyAdapter, int position) {
|
protected @NonNull RecyclerView.ViewHolder getHeader(RecyclerView parent, StickyHeaderAdapter stickyAdapter, int position) {
|
||||||
StickyHeaderViewHolder viewHolder = new StickyHeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_item_last_seen, parent, false));
|
StickyHeaderViewHolder viewHolder = new StickyHeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_item_last_seen, parent, false));
|
||||||
adapter.onBindLastSeenViewHolder(viewHolder, position);
|
adapter.onBindLastSeenViewHolder(viewHolder, unreadCount);
|
||||||
|
|
||||||
int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY);
|
int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY);
|
||||||
int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED);
|
int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED);
|
||||||
|
|
|
@ -0,0 +1,70 @@
|
||||||
|
package org.thoughtcrime.securesms.conversation
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.database.model.ThreadRecord
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes and aggregates the thread count for a particular thread, for use in the "Last Seen" header.
|
||||||
|
*/
|
||||||
|
sealed class ThreadCountAggregator {
|
||||||
|
|
||||||
|
abstract val count: Int
|
||||||
|
|
||||||
|
abstract fun updateWith(record: ThreadRecord): ThreadCountAggregator
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Init object, used as an initial state and returned whenever the given record is an outgoing message.
|
||||||
|
* The conversation fragment already properly cleans up the header when an outgoing message is emitted, so
|
||||||
|
* there's no need to worry about seeing a "zero."
|
||||||
|
*/
|
||||||
|
object Init : ThreadCountAggregator() {
|
||||||
|
override val count: Int = 0
|
||||||
|
|
||||||
|
override fun updateWith(record: ThreadRecord): ThreadCountAggregator {
|
||||||
|
return when {
|
||||||
|
record.isOutgoing -> Outgoing
|
||||||
|
else -> Count(record.threadId, record.unreadCount, record.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Outgoing object, returned whenever the given record is an outgoing message.
|
||||||
|
* The conversation fragment already properly cleans up the header when an outgoing message is emitted, so
|
||||||
|
* there's no need to worry about seeing a "zero."
|
||||||
|
*/
|
||||||
|
object Outgoing : ThreadCountAggregator() {
|
||||||
|
override val count: Int = 0
|
||||||
|
|
||||||
|
override fun updateWith(record: ThreadRecord): ThreadCountAggregator {
|
||||||
|
return when {
|
||||||
|
record.isOutgoing -> Outgoing
|
||||||
|
else -> Count(record.threadId, record.unreadCount, record.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an actual count. We keep record of the id and date to use in comparisons with future
|
||||||
|
* ThreadRecord objects.
|
||||||
|
*/
|
||||||
|
class Count(val threadId: Long, val unreadCount: Int, val threadDate: Long) : ThreadCountAggregator() {
|
||||||
|
override val count: Int = unreadCount
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Ratchets" the count to the new state.
|
||||||
|
* * Outgoing records will always result in Empty
|
||||||
|
* * Mismatched threadIds will always create a new Count, initialized with the new thread
|
||||||
|
* * Matching dates will be ignored, as this means that there was no actual change.
|
||||||
|
* * Otherwise, we'll proceed with the new date and aggregate the count.
|
||||||
|
*/
|
||||||
|
override fun updateWith(record: ThreadRecord): ThreadCountAggregator {
|
||||||
|
return when {
|
||||||
|
record.isOutgoing -> Outgoing
|
||||||
|
threadId != record.threadId -> Init.updateWith(record)
|
||||||
|
threadDate >= record.date -> this
|
||||||
|
record.unreadCount > 1 -> Init.updateWith(record)
|
||||||
|
else -> Count(threadId, unreadCount + 1, record.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -521,6 +521,18 @@ public class ThreadDatabase extends Database {
|
||||||
return getUnreadThreadIdAggregate(SqlUtil.COUNT, cursor -> CursorUtil.getAggregateOrDefault(cursor, 0L, cursor::getLong));
|
return getUnreadThreadIdAggregate(SqlUtil.COUNT, cursor -> CursorUtil.getAggregateOrDefault(cursor, 0L, cursor::getLong));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public long getUnreadMessageCount(long threadId) {
|
||||||
|
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
|
||||||
|
|
||||||
|
try (Cursor cursor = db.query(TABLE_NAME, SqlUtil.buildArgs(UNREAD_COUNT), ID_WHERE, SqlUtil.buildArgs(threadId), null, null, null)) {
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
return CursorUtil.requireLong(cursor, UNREAD_COUNT);
|
||||||
|
} else {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public @Nullable String getUnreadThreadIdList() {
|
public @Nullable String getUnreadThreadIdList() {
|
||||||
return getUnreadThreadIdAggregate(SqlUtil.buildArgs("GROUP_CONCAT(" + ID + ")"),
|
return getUnreadThreadIdAggregate(SqlUtil.buildArgs("GROUP_CONCAT(" + ID + ")"),
|
||||||
cursor -> CursorUtil.getAggregateOrDefault(cursor, null, cursor::getString));
|
cursor -> CursorUtil.getAggregateOrDefault(cursor, null, cursor::getString));
|
||||||
|
|
|
@ -0,0 +1,120 @@
|
||||||
|
package org.thoughtcrime.securesms.conversation
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.thoughtcrime.securesms.database.MmsSmsColumns
|
||||||
|
import org.thoughtcrime.securesms.database.model.ThreadRecord
|
||||||
|
|
||||||
|
class ThreadCountTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given an Init, when I getCount, then I expect 0`() {
|
||||||
|
// GIVEN
|
||||||
|
val threadCount = ThreadCountAggregator.Init
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
val result = threadCount.count
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
assertEquals(0, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given an Empty, when I updateWith an outgoing record, then I expect Outgoing`() {
|
||||||
|
// GIVEN
|
||||||
|
val threadRecord = createThreadRecord(isOutgoing = true)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
val result = ThreadCountAggregator.Init.updateWith(threadRecord)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
assertEquals(result, ThreadCountAggregator.Outgoing)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given an Empty, when I updateWith an incoming record, then I expect 5`() {
|
||||||
|
// GIVEN
|
||||||
|
val threadRecord = createThreadRecord(unreadCount = 5)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
val result = ThreadCountAggregator.Init.updateWith(threadRecord)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
assertEquals(5, result.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given a Count, when I updateWith an incoming record with the same date, then I expect 5`() {
|
||||||
|
// GIVEN
|
||||||
|
val threadRecord = createThreadRecord(unreadCount = 5)
|
||||||
|
val newThreadRecord = createThreadRecord(unreadCount = 1)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
val result = ThreadCountAggregator.Init.updateWith(threadRecord).updateWith(newThreadRecord)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
assertEquals(5, result.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given a Count, when I updateWith an incoming record with an earlier date, then I expect 5`() {
|
||||||
|
// GIVEN
|
||||||
|
val threadRecord = createThreadRecord(unreadCount = 5)
|
||||||
|
val newThreadRecord = createThreadRecord(unreadCount = 1, date = 0L)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
val result = ThreadCountAggregator.Init.updateWith(threadRecord).updateWith(newThreadRecord)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
assertEquals(5, result.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given a Count, when I updateWith an incoming record with a later date, then I expect 6`() {
|
||||||
|
// GIVEN
|
||||||
|
val threadRecord = createThreadRecord(unreadCount = 5)
|
||||||
|
val newThreadRecord = createThreadRecord(unreadCount = 1, date = 2L)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
val result = ThreadCountAggregator.Init.updateWith(threadRecord).updateWith(newThreadRecord)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
assertEquals(6, result.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given a Count, when I updateWith an incoming record with a later date and unread count gt 1, then I expect new unread count`() {
|
||||||
|
// GIVEN
|
||||||
|
val threadRecord = createThreadRecord(unreadCount = 5)
|
||||||
|
val newThreadRecord = createThreadRecord(unreadCount = 3, date = 2L)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
val result = ThreadCountAggregator.Init.updateWith(threadRecord).updateWith(newThreadRecord)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
assertEquals(3, result.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given a Count, when I updateWith an incoming record with a different id, then I expect 3`() {
|
||||||
|
// GIVEN
|
||||||
|
val threadRecord = createThreadRecord(threadId = 1L, unreadCount = 5)
|
||||||
|
val newThreadRecord = createThreadRecord(threadId = 2L, unreadCount = 3)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
val result = ThreadCountAggregator.Init.updateWith(threadRecord).updateWith(newThreadRecord)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
assertEquals(3, result.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createThreadRecord(threadId: Long = 1L, unreadCount: Int = 0, date: Long = 1L, isOutgoing: Boolean = false): ThreadRecord {
|
||||||
|
val outgoingMessageType = MmsSmsColumns.Types.getOutgoingEncryptedMessageType()
|
||||||
|
|
||||||
|
return ThreadRecord.Builder(threadId)
|
||||||
|
.setUnreadCount(unreadCount)
|
||||||
|
.setDate(date)
|
||||||
|
.setType(if (isOutgoing) outgoingMessageType else (outgoingMessageType.inv()))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue