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.base.Preconditions;
import com.google.common.collect.Collections2; import com.google.common.collect.Collections2;
import com.google.common.collect.Lists; 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.MessageContentEntity;
import im.conversations.android.database.entity.MessageEntity; import im.conversations.android.database.entity.MessageEntity;
import im.conversations.android.database.entity.MessageReactionEntity; 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" + " JOIN axolotl_identity ON c.accountId=axolotl_identity.accountId AND"
+ " m.senderIdentity=axolotl_identity.address AND" + " m.senderIdentity=axolotl_identity.address AND"
+ " message_version.identityKey=axolotl_identity.identityKey WHERE c.id=:chatId 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); 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( public void setInReplyTo(
ChatIdentifier chat, ChatIdentifier chat,
MessageIdentifier messageIdentifier, MessageIdentifier messageIdentifier,

View file

@ -3,6 +3,7 @@ package im.conversations.android.repository;
import android.content.Context; import android.content.Context;
import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveData;
import androidx.paging.PagingSource; import androidx.paging.PagingSource;
import com.google.common.util.concurrent.ListenableFuture;
import im.conversations.android.database.model.ChatFilter; import im.conversations.android.database.model.ChatFilter;
import im.conversations.android.database.model.ChatInfo; import im.conversations.android.database.model.ChatInfo;
import im.conversations.android.database.model.ChatOverviewItem; import im.conversations.android.database.model.ChatOverviewItem;
@ -35,4 +36,8 @@ public class ChatRepository extends AbstractRepository {
public PagingSource<Integer, MessageWithContentReactions> getMessages(final long chatId) { public PagingSource<Integer, MessageWithContentReactions> getMessages(final long chatId) {
return this.database.messageDao().getMessages(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) { 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); final ReliableScroller reliableScroller = new ReliableScroller(recyclerView);
reliableScroller.scrollToPosition(position); reliableScroller.scrollToPosition(position, onScrolledRunnable);
} }
private static class ReliableScroller { private static class ReliableScroller {
@ -36,7 +40,7 @@ public class RecyclerViewScroller {
this.recyclerViewReference = new WeakReference<>(recyclerView); 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(); final var recyclerView = this.recyclerViewReference.get();
if (recyclerView == null) { if (recyclerView == null) {
return; return;
@ -64,13 +68,16 @@ public class RecyclerViewScroller {
LOGGER.info("scrollToPosition({})", position); LOGGER.info("scrollToPosition({})", position);
recyclerView.scrollToPosition(position); recyclerView.scrollToPosition(position);
} }
if (onScrolledRunnable != null) {
recyclerView.post(onScrolledRunnable);
}
return; return;
} }
recyclerView.scrollToPosition(position); recyclerView.scrollToPosition(position);
accumulatedDelay += INTERVAL; accumulatedDelay += INTERVAL;
recyclerView.postDelayed( recyclerView.postDelayed(
() -> { () -> {
scrollToPosition(position); scrollToPosition(position, onScrolledRunnable);
}, },
INTERVAL); 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.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.paging.PagingData; import androidx.paging.PagingData;
import androidx.paging.PagingDataTransforms;
import androidx.recyclerview.widget.LinearLayoutManager; 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.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.databinding.FragmentChatBinding;
import im.conversations.android.ui.Activities; import im.conversations.android.ui.Activities;
import im.conversations.android.ui.NavControllers; import im.conversations.android.ui.NavControllers;
import im.conversations.android.ui.RecyclerViewScroller; import im.conversations.android.ui.RecyclerViewScroller;
import im.conversations.android.ui.adapter.MessageAdapter; 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.adapter.MessageComparator;
import im.conversations.android.ui.model.ChatViewModel; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -54,39 +55,10 @@ public class ChatFragment extends Fragment {
this.messageAdapter = new MessageAdapter(new MessageComparator()); this.messageAdapter = new MessageAdapter(new MessageComparator());
this.binding.messages.setAdapter(this.messageAdapter); this.binding.messages.setAdapter(this.messageAdapter);
this.chatViewModel.getMessages().observe(getViewLifecycleOwner(), this::submitPagingData);
this.chatViewModel this.chatViewModel
.getMessages() .isShowDateSeparators()
.observe( .observe(getViewLifecycleOwner(), this::submitPagingData);
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);
});
this.binding.materialToolbar.setNavigationOnClickListener( this.binding.materialToolbar.setNavigationOnClickListener(
view -> { view -> {
NavControllers.findNavController(requireActivity(), R.id.nav_host_fragment) NavControllers.findNavController(requireActivity(), R.id.nav_host_fragment)
@ -94,18 +66,65 @@ public class ChatFragment extends Fragment {
}); });
this.binding.addContent.setOnClickListener( this.binding.addContent.setOnClickListener(
v -> { v -> {
scrollToPosition(messageAdapter.getItemCount() - 1); scrollToMessageId(1039);
}); });
this.binding.messageLayout.setEndIconOnClickListener( this.binding.messageLayout.setEndIconOnClickListener(
v -> { v -> {
scrollToPosition(0); this.scrollToPositionToEnd();
}); });
Activities.setStatusAndNavigationBarColors(requireActivity(), binding.getRoot(), true); Activities.setStatusAndNavigationBarColors(requireActivity(), binding.getRoot(), true);
return this.binding.getRoot(); return this.binding.getRoot();
} }
private void scrollToPosition(final int position) { private void submitPagingData(final Boolean isShowDateSeparators) {
LOGGER.info("scrollToPosition({})", position); final var pagingData = this.chatViewModel.getMessages().getValue();
this.recyclerViewScroller.scrollToPosition(position); 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.PagingConfig;
import androidx.paging.PagingData; import androidx.paging.PagingData;
import androidx.paging.PagingLiveData; 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.ChatInfo;
import im.conversations.android.database.model.MessageWithContentReactions; import im.conversations.android.database.model.MessageWithContentReactions;
import im.conversations.android.repository.ChatRepository; import im.conversations.android.repository.ChatRepository;
@ -25,6 +28,7 @@ public class ChatViewModel extends AndroidViewModel {
private final MutableLiveData<Long> chatId = new MutableLiveData<>(); private final MutableLiveData<Long> chatId = new MutableLiveData<>();
private final LiveData<ChatInfo> chatInfo; private final LiveData<ChatInfo> chatInfo;
private final LiveData<PagingData<MessageWithContentReactions>> messages; private final LiveData<PagingData<MessageWithContentReactions>> messages;
private final MutableLiveData<Boolean> showDateSeparators = new MutableLiveData<>(true);
public ChatViewModel(@NonNull Application application) { public ChatViewModel(@NonNull Application application) {
super(application); super(application);
@ -59,4 +63,31 @@ public class ChatViewModel extends AndroidViewModel {
public LiveData<PagingData<MessageWithContentReactions>> getMessages() { public LiveData<PagingData<MessageWithContentReactions>> getMessages() {
return this.messages; 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());
}
} }