diff --git a/app/src/main/java/im/conversations/android/database/dao/MessageDao.java b/app/src/main/java/im/conversations/android/database/dao/MessageDao.java index 670890022..9d58d35b0 100644 --- a/app/src/main/java/im/conversations/android/database/dao/MessageDao.java +++ b/app/src/main/java/im/conversations/android/database/dao/MessageDao.java @@ -11,6 +11,7 @@ import androidx.room.Update; import com.google.common.base.Preconditions; import com.google.common.collect.Collections2; import com.google.common.collect.Lists; +import com.google.common.util.concurrent.ListenableFuture; import im.conversations.android.database.entity.MessageContentEntity; import im.conversations.android.database.entity.MessageEntity; import im.conversations.android.database.entity.MessageReactionEntity; @@ -480,9 +481,17 @@ public abstract class MessageDao { + " JOIN axolotl_identity ON c.accountId=axolotl_identity.accountId AND" + " m.senderIdentity=axolotl_identity.address AND" + " message_version.identityKey=axolotl_identity.identityKey WHERE c.id=:chatId AND" - + " latestVersion IS NOT NULL ORDER BY m.receivedAt DESC") + + " latestVersion IS NOT NULL ORDER BY m.receivedAt DESC,m.id DESC") public abstract PagingSource getMessages(long chatId); + @Query( + "SELECT CASE WHEN (SELECT EXISTS(SELECT id FROM message WHERE chatId=:chatId AND" + + " id=:messageId)) THEN (SELECT count(id) FROM message WHERE chatId=:chatId AND" + + " (receivedAt > (SELECT receivedAt FROM message WHERE id=:messageId) OR" + + " (receivedAt=(SELECT receivedAt FROM message WHERE id=:messageId) AND id >" + + " :messageId))) ELSE NULL END") + public abstract ListenableFuture getPosition(final long chatId, final long messageId); + public void setInReplyTo( ChatIdentifier chat, MessageIdentifier messageIdentifier, diff --git a/app/src/main/java/im/conversations/android/repository/ChatRepository.java b/app/src/main/java/im/conversations/android/repository/ChatRepository.java index 8ee348de2..175cccf93 100644 --- a/app/src/main/java/im/conversations/android/repository/ChatRepository.java +++ b/app/src/main/java/im/conversations/android/repository/ChatRepository.java @@ -3,6 +3,7 @@ package im.conversations.android.repository; import android.content.Context; import androidx.lifecycle.LiveData; import androidx.paging.PagingSource; +import com.google.common.util.concurrent.ListenableFuture; import im.conversations.android.database.model.ChatFilter; import im.conversations.android.database.model.ChatInfo; import im.conversations.android.database.model.ChatOverviewItem; @@ -35,4 +36,8 @@ public class ChatRepository extends AbstractRepository { public PagingSource getMessages(final long chatId) { return this.database.messageDao().getMessages(chatId); } + + public ListenableFuture getMessagePosition(final long chatId, final long messageId) { + return this.database.messageDao().getPosition(chatId, messageId); + } } diff --git a/app/src/main/java/im/conversations/android/ui/RecyclerViewScroller.java b/app/src/main/java/im/conversations/android/ui/RecyclerViewScroller.java index f36e121d8..6a719474c 100644 --- a/app/src/main/java/im/conversations/android/ui/RecyclerViewScroller.java +++ b/app/src/main/java/im/conversations/android/ui/RecyclerViewScroller.java @@ -19,8 +19,12 @@ public class RecyclerViewScroller { } public void scrollToPosition(final int position) { + this.scrollToPosition(position, null); + } + + public void scrollToPosition(final int position, final Runnable onScrolledRunnable) { final ReliableScroller reliableScroller = new ReliableScroller(recyclerView); - reliableScroller.scrollToPosition(position); + reliableScroller.scrollToPosition(position, onScrolledRunnable); } private static class ReliableScroller { @@ -36,7 +40,7 @@ public class RecyclerViewScroller { this.recyclerViewReference = new WeakReference<>(recyclerView); } - private void scrollToPosition(final int position) { + private void scrollToPosition(final int position, final Runnable onScrolledRunnable) { final var recyclerView = this.recyclerViewReference.get(); if (recyclerView == null) { return; @@ -64,13 +68,16 @@ public class RecyclerViewScroller { LOGGER.info("scrollToPosition({})", position); recyclerView.scrollToPosition(position); } + if (onScrolledRunnable != null) { + recyclerView.post(onScrolledRunnable); + } return; } recyclerView.scrollToPosition(position); accumulatedDelay += INTERVAL; recyclerView.postDelayed( () -> { - scrollToPosition(position); + scrollToPosition(position, onScrolledRunnable); }, INTERVAL); } diff --git a/app/src/main/java/im/conversations/android/ui/adapter/MessageAdapterItems.java b/app/src/main/java/im/conversations/android/ui/adapter/MessageAdapterItems.java new file mode 100644 index 000000000..8799aedbc --- /dev/null +++ b/app/src/main/java/im/conversations/android/ui/adapter/MessageAdapterItems.java @@ -0,0 +1,48 @@ +package im.conversations.android.ui.adapter; + +import androidx.annotation.Nullable; +import androidx.paging.PagingData; +import androidx.paging.PagingDataTransforms; +import com.google.common.util.concurrent.MoreExecutors; +import im.conversations.android.database.model.MessageAdapterItem; +import im.conversations.android.database.model.MessageWithContentReactions; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; + +public final class MessageAdapterItems { + + private MessageAdapterItems() {} + + public static PagingData insertSeparators( + final PagingData pagingData) { + return PagingDataTransforms.insertSeparators( + pagingData, + MoreExecutors.directExecutor(), + (before, after) -> { + final var dayBefore = zonedDay(before); + final var dayAfter = zonedDay(after); + if (dayAfter == null && dayBefore != null) { + return new MessageAdapterItem.MessageDateSeparator(dayBefore.toInstant()); + } else if (dayBefore == null || dayBefore.equals(dayAfter)) { + return null; + } else { + return new MessageAdapterItem.MessageDateSeparator(dayBefore.toInstant()); + } + }); + } + + private static ZonedDateTime zonedDay(@Nullable final MessageWithContentReactions message) { + return message == null ? null : zonedDay(message.sentAt); + } + + private static ZonedDateTime zonedDay(final Instant instant) { + return instant.atZone(ZoneId.systemDefault()).truncatedTo(ChronoUnit.DAYS); + } + + public static PagingData of( + final PagingData pagingData) { + return PagingDataTransforms.map(pagingData, MoreExecutors.directExecutor(), m -> m); + } +} diff --git a/app/src/main/java/im/conversations/android/ui/fragment/main/ChatFragment.java b/app/src/main/java/im/conversations/android/ui/fragment/main/ChatFragment.java index 431b68696..8940ccbe1 100644 --- a/app/src/main/java/im/conversations/android/ui/fragment/main/ChatFragment.java +++ b/app/src/main/java/im/conversations/android/ui/fragment/main/ChatFragment.java @@ -9,19 +9,20 @@ import androidx.databinding.DataBindingUtil; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import androidx.paging.PagingData; -import androidx.paging.PagingDataTransforms; import androidx.recyclerview.widget.LinearLayoutManager; -import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; import im.conversations.android.R; -import im.conversations.android.database.model.MessageAdapterItem; +import im.conversations.android.database.model.MessageWithContentReactions; import im.conversations.android.databinding.FragmentChatBinding; import im.conversations.android.ui.Activities; import im.conversations.android.ui.NavControllers; import im.conversations.android.ui.RecyclerViewScroller; import im.conversations.android.ui.adapter.MessageAdapter; +import im.conversations.android.ui.adapter.MessageAdapterItems; import im.conversations.android.ui.adapter.MessageComparator; import im.conversations.android.ui.model.ChatViewModel; -import java.time.temporal.ChronoUnit; +import im.conversations.android.util.MainThreadExecutor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -54,39 +55,10 @@ public class ChatFragment extends Fragment { this.messageAdapter = new MessageAdapter(new MessageComparator()); this.binding.messages.setAdapter(this.messageAdapter); + this.chatViewModel.getMessages().observe(getViewLifecycleOwner(), this::submitPagingData); this.chatViewModel - .getMessages() - .observe( - getViewLifecycleOwner(), - pagingData -> { - final PagingData foo = - PagingDataTransforms.insertSeparators( - pagingData, - MoreExecutors.directExecutor(), - (before, after) -> { - final var dayBefore = - before == null - ? null - : before.sentAt.truncatedTo( - ChronoUnit.DAYS); - final var dayAfter = - after == null - ? null - : after.sentAt.truncatedTo( - ChronoUnit.DAYS); - if (dayAfter == null && dayBefore != null) { - return new MessageAdapterItem - .MessageDateSeparator(dayBefore); - } else if (dayBefore == null - || dayBefore.equals(dayAfter)) { - return null; - } else { - return new MessageAdapterItem - .MessageDateSeparator(dayBefore); - } - }); - messageAdapter.submitData(getLifecycle(), foo); - }); + .isShowDateSeparators() + .observe(getViewLifecycleOwner(), this::submitPagingData); this.binding.materialToolbar.setNavigationOnClickListener( view -> { NavControllers.findNavController(requireActivity(), R.id.nav_host_fragment) @@ -94,18 +66,65 @@ public class ChatFragment extends Fragment { }); this.binding.addContent.setOnClickListener( v -> { - scrollToPosition(messageAdapter.getItemCount() - 1); + scrollToMessageId(1039); }); this.binding.messageLayout.setEndIconOnClickListener( v -> { - scrollToPosition(0); + this.scrollToPositionToEnd(); }); Activities.setStatusAndNavigationBarColors(requireActivity(), binding.getRoot(), true); return this.binding.getRoot(); } - private void scrollToPosition(final int position) { - LOGGER.info("scrollToPosition({})", position); - this.recyclerViewScroller.scrollToPosition(position); + private void submitPagingData(final Boolean isShowDateSeparators) { + final var pagingData = this.chatViewModel.getMessages().getValue(); + if (pagingData == null) { + LOGGER.info("PagingData not ready"); + return; + } + this.submitPagingData(pagingData, Boolean.TRUE.equals(isShowDateSeparators)); + } + + private void submitPagingData(final PagingData pagingData) { + submitPagingData( + pagingData, + Boolean.TRUE.equals(this.chatViewModel.isShowDateSeparators().getValue())); + } + + private void submitPagingData( + final PagingData pagingData, + final boolean insertSeparators) { + if (insertSeparators) { + messageAdapter.submitData( + getLifecycle(), MessageAdapterItems.insertSeparators(pagingData)); + } else { + messageAdapter.submitData(getLifecycle(), MessageAdapterItems.of(pagingData)); + } + } + + private void scrollToPositionToEnd() { + this.recyclerViewScroller.scrollToPosition(0); + } + + private void scrollToMessageId(final long messageId) { + LOGGER.info("scrollToMessageId({})", messageId); + this.chatViewModel.setShowDateSeparators(false); + final var future = this.chatViewModel.getMessagePosition(messageId); + Futures.addCallback( + future, + new FutureCallback<>() { + @Override + public void onSuccess(final @NonNull Integer position) { + recyclerViewScroller.scrollToPosition( + position, () -> chatViewModel.setShowDateSeparators(true)); + } + + @Override + public void onFailure(@NonNull final Throwable throwable) { + LOGGER.info("Could not scroll to {}", messageId, throwable); + chatViewModel.setShowDateSeparators(true); + } + }, + MainThreadExecutor.getInstance()); } } diff --git a/app/src/main/java/im/conversations/android/ui/model/ChatViewModel.java b/app/src/main/java/im/conversations/android/ui/model/ChatViewModel.java index c6e9af226..eb9034a04 100644 --- a/app/src/main/java/im/conversations/android/ui/model/ChatViewModel.java +++ b/app/src/main/java/im/conversations/android/ui/model/ChatViewModel.java @@ -11,6 +11,9 @@ import androidx.paging.Pager; import androidx.paging.PagingConfig; import androidx.paging.PagingData; import androidx.paging.PagingLiveData; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; import im.conversations.android.database.model.ChatInfo; import im.conversations.android.database.model.MessageWithContentReactions; import im.conversations.android.repository.ChatRepository; @@ -25,6 +28,7 @@ public class ChatViewModel extends AndroidViewModel { private final MutableLiveData chatId = new MutableLiveData<>(); private final LiveData chatInfo; private final LiveData> messages; + private final MutableLiveData showDateSeparators = new MutableLiveData<>(true); public ChatViewModel(@NonNull Application application) { super(application); @@ -59,4 +63,31 @@ public class ChatViewModel extends AndroidViewModel { public LiveData> getMessages() { return this.messages; } + + public LiveData isShowDateSeparators() { + return this.showDateSeparators; + } + + public void setShowDateSeparators(final boolean showDateSeparators) { + this.showDateSeparators.postValue(showDateSeparators); + } + + public ListenableFuture getMessagePosition(final long messageId) { + final Long chatId = this.chatId.getValue(); + if (chatId == null) { + return Futures.immediateFailedFuture( + new IllegalStateException("Chat id has not been configured yet")); + } + return Futures.transform( + this.chatRepository.getMessagePosition(chatId, messageId), + position -> { + if (position == null) { + throw new IllegalStateException( + String.format( + "messageId %s is not part of chat %s", messageId, chatId)); + } + return position; + }, + MoreExecutors.directExecutor()); + } }