jump to message id

This commit is contained in:
Daniel Gultsch 2023-03-28 17:15:35 +02:00
parent 4bfcf209d7
commit 4d5445d123
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
6 changed files with 164 additions and 45 deletions

View file

@ -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<Integer, MessageWithContentReactions> 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<Integer> getPosition(final long chatId, final long messageId);
public void setInReplyTo(
ChatIdentifier chat,
MessageIdentifier messageIdentifier,

View file

@ -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<Integer, MessageWithContentReactions> getMessages(final long chatId) {
return this.database.messageDao().getMessages(chatId);
}
public ListenableFuture<Integer> getMessagePosition(final long chatId, final long messageId) {
return this.database.messageDao().getPosition(chatId, messageId);
}
}

View file

@ -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);
}

View file

@ -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<MessageAdapterItem> insertSeparators(
final PagingData<MessageWithContentReactions> 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<MessageAdapterItem> of(
final PagingData<MessageWithContentReactions> pagingData) {
return PagingDataTransforms.map(pagingData, MoreExecutors.directExecutor(), m -> m);
}
}

View file

@ -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<MessageAdapterItem> 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<MessageWithContentReactions> pagingData) {
submitPagingData(
pagingData,
Boolean.TRUE.equals(this.chatViewModel.isShowDateSeparators().getValue()));
}
private void submitPagingData(
final PagingData<MessageWithContentReactions> 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());
}
}

View file

@ -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<Long> chatId = new MutableLiveData<>();
private final LiveData<ChatInfo> chatInfo;
private final LiveData<PagingData<MessageWithContentReactions>> messages;
private final MutableLiveData<Boolean> showDateSeparators = new MutableLiveData<>(true);
public ChatViewModel(@NonNull Application application) {
super(application);
@ -59,4 +63,31 @@ public class ChatViewModel extends AndroidViewModel {
public LiveData<PagingData<MessageWithContentReactions>> getMessages() {
return this.messages;
}
public LiveData<Boolean> isShowDateSeparators() {
return this.showDateSeparators;
}
public void setShowDateSeparators(final boolean showDateSeparators) {
this.showDateSeparators.postValue(showDateSeparators);
}
public ListenableFuture<Integer> 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());
}
}