From 4e1ac167655b424d27151a99e70835818a5b3f24 Mon Sep 17 00:00:00 2001 From: Lakoja Date: Wed, 28 Jun 2023 13:56:59 +0200 Subject: [PATCH] 3771: Add an "in reply to" text to a reply --- .../tusky/adapter/NotificationsAdapter.java | 2 +- .../tusky/adapter/StatusBaseViewHolder.java | 7 ++- .../adapter/StatusDetailedViewHolder.java | 5 +- .../tusky/adapter/StatusViewHolder.java | 58 ++++++++++++++----- .../conversation/ConversationEntity.kt | 1 + .../search/adapter/SearchStatusesAdapter.kt | 2 +- .../timeline/TimelinePagingAdapter.kt | 3 +- .../timeline/TimelineTypeMappers.kt | 5 +- .../NetworkTimelineRemoteMediator.kt | 16 ++++- .../viewmodel/NetworkTimelineViewModel.kt | 9 ++- .../components/viewthread/ThreadAdapter.kt | 2 +- .../keylesspalace/tusky/db/AppDatabase.java | 1 + .../tusky/db/TimelineAccountDao.kt | 25 ++++++++ .../com/keylesspalace/tusky/db/TimelineDao.kt | 50 +++++++++------- .../tusky/db/TimelineStatusEntity.kt | 12 ++-- .../keylesspalace/tusky/util/ViewDataUtils.kt | 6 +- .../tusky/viewdata/StatusViewData.kt | 2 + .../main/res/drawable/ic_reply_all_18dp.xml | 10 ++++ app/src/main/res/layout/item_status.xml | 3 +- app/src/main/res/values/strings.xml | 2 + .../tusky/StatusComparisonTest.kt | 8 ++- .../NetworkTimelineRemoteMediatorTest.kt | 30 +++++++--- .../tusky/components/timeline/StatusMocker.kt | 1 + gradle/libs.versions.toml | 1 + 24 files changed, 193 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/db/TimelineAccountDao.kt create mode 100644 app/src/main/res/drawable/ic_reply_all_18dp.xml diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 1794645435..8c6b570c00 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -194,7 +194,7 @@ private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int pos if (payloads == null) { holder.showStatusContent(true); } - holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder); + holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder, true); } if (concreteNotification.getType() == Notification.Type.POLL) { holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId())); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 03267c49b2..a08d29335f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -769,14 +769,15 @@ private void showConfirmFavourite(StatusActionListener listener, } public void setupWithStatus(@NonNull StatusViewData.Concrete status, final @NonNull StatusActionListener listener, - @NonNull StatusDisplayOptions statusDisplayOptions) { - this.setupWithStatus(status, listener, statusDisplayOptions, null); + @NonNull StatusDisplayOptions statusDisplayOptions, boolean showStatusInfo) { + this.setupWithStatus(status, listener, statusDisplayOptions, null, showStatusInfo); } public void setupWithStatus(@NonNull StatusViewData.Concrete status, @NonNull final StatusActionListener listener, @NonNull StatusDisplayOptions statusDisplayOptions, - @Nullable Object payloads) { + @Nullable Object payloads, + boolean showStatusInfo) { if (payloads == null) { Status actionable = status.getActionable(); setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index bb5a78e4eb..1bf17625db 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -143,13 +143,14 @@ private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionLis public void setupWithStatus(@NonNull final StatusViewData.Concrete status, @NonNull final StatusActionListener listener, @NonNull StatusDisplayOptions statusDisplayOptions, - @Nullable Object payloads) { + @Nullable Object payloads, + boolean showStatusInfo) { // We never collapse statuses in the detail view StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ? status.copyWithCollapsed(false) : status; - super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads); + super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads, showStatusInfo); setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status if (payloads == null) { Status actionable = uncollapsedStatus.getActionable(); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 327f7cfbb8..e1af811b25 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -30,6 +30,7 @@ import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.TimelineAccount; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.NumberUtils; @@ -38,6 +39,7 @@ import com.keylesspalace.tusky.util.StringUtils; import com.keylesspalace.tusky.viewdata.StatusViewData; +import java.util.Collections; import java.util.List; import at.connyduck.sparkbutton.helpers.Utils; @@ -63,21 +65,37 @@ public StatusViewHolder(@NonNull View itemView) { public void setupWithStatus(@NonNull StatusViewData.Concrete status, @NonNull final StatusActionListener listener, @NonNull StatusDisplayOptions statusDisplayOptions, - @Nullable Object payloads) { + @Nullable Object payloads, + boolean showStatusInfo) { if (payloads == null) { - boolean sensitive = !TextUtils.isEmpty(status.getActionable().getSpoilerText()); boolean expanded = status.isExpanded(); setupCollapsedState(sensitive, expanded, status, listener); Status reblogging = status.getRebloggingStatus(); - if (reblogging == null || status.getFilterAction() == Filter.Action.WARN) { + boolean isReply = status.getStatus().getInReplyToId() != null; + boolean isReplyOnly = isReply && reblogging == null; + + boolean hasStatusContext = reblogging != null || isReply; + + if (!hasStatusContext || !showStatusInfo || status.getFilterAction() == Filter.Action.WARN) { hideStatusInfo(); } else { - String rebloggedByDisplayName = reblogging.getAccount().getName(); - setRebloggedByDisplayName(rebloggedByDisplayName, - reblogging.getAccount().getEmojis(), statusDisplayOptions); + String accountName = ""; + List emojis = Collections.emptyList(); + if (reblogging != null) { + accountName = reblogging.getAccount().getName(); + emojis = reblogging.getAccount().getEmojis(); + } else if (isReply) { + TimelineAccount repliedTo = status.getInReplyToAccount(); + if (repliedTo != null) { + accountName = repliedTo.getName(); + emojis = repliedTo.getEmojis(); + } + } + + setStatusInfoText(isReplyOnly, accountName, emojis, statusDisplayOptions); statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition())); } @@ -88,19 +106,27 @@ public void setupWithStatus(@NonNull StatusViewData.Concrete status, setFavouritedCount(status.getActionable().getFavouritesCount()); setReblogsCount(status.getActionable().getReblogsCount()); - super.setupWithStatus(status, listener, statusDisplayOptions, payloads); + super.setupWithStatus(status, listener, statusDisplayOptions, payloads, showStatusInfo); } - private void setRebloggedByDisplayName(final CharSequence name, - final List accountEmoji, - final StatusDisplayOptions statusDisplayOptions) { + private void setStatusInfoText(final boolean isReply, + final CharSequence name, + final List accountEmoji, + final StatusDisplayOptions statusDisplayOptions) { + Context context = statusInfo.getContext(); - CharSequence wrappedName = StringUtils.unicodeWrap(name); - CharSequence boostedText = context.getString(R.string.post_boosted_format, wrappedName); - CharSequence emojifiedText = CustomEmojiHelper.emojify( - boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis() - ); - statusInfo.setText(emojifiedText); + if (name.length() > 0) { + CharSequence wrappedName = StringUtils.unicodeWrap(name); + CharSequence statusContextText = context.getString(isReply ? R.string.post_replied_format : R.string.post_boosted_format, wrappedName); + CharSequence emojifiedText = CustomEmojiHelper.emojify( + statusContextText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis() + ); + statusInfo.setText(emojifiedText); + } else { + statusInfo.setText(context.getString(R.string.post_replied)); + } + statusInfo.setCompoundDrawablesWithIntrinsicBounds(isReply ? R.drawable.ic_reply_all_18dp : R.drawable.ic_reblog_18dp, 0, 0, 0); + statusInfo.setVisibility(View.VISIBLE); } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index d38898c77f..a9569952e6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -135,6 +135,7 @@ data class ConversationStatusEntity( language = language, filtered = emptyList() ), + inReplyToAccount = null, // TODO? implementation gap: not needed here atm, but inconsistent isExpanded = expanded, isShowingContent = showingHiddenContent, isCollapsed = collapsed diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt index 1d3cabe214..fee743746b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt @@ -38,7 +38,7 @@ class SearchStatusesAdapter( override fun onBindViewHolder(holder: StatusViewHolder, position: Int) { getItem(position)?.let { item -> - holder.setupWithStatus(item, statusListener, statusDisplayOptions) + holder.setupWithStatus(item, statusListener, statusDisplayOptions, true) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index 716a30199e..8f7615c15a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -90,7 +90,8 @@ class TimelinePagingAdapter( status, statusListener, statusDisplayOptions, - if (payloads != null && payloads.isNotEmpty()) payloads[0] else null + if (payloads != null && payloads.isNotEmpty()) payloads[0] else null, + true ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index 4f8251d9ed..e4afb78506 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -206,8 +206,8 @@ fun TimelineStatusWithAccount.toViewData(moshi: Moshi, isDetailed: Boolean = fal // no url for reblogs url = null, account = this.reblogAccount!!.toAccount(moshi), - inReplyToId = null, - inReplyToAccountId = null, + inReplyToId = status.inReplyToId, + inReplyToAccountId = status.inReplyToAccountId, reblog = reblog, content = "", // lie but whatever? @@ -269,6 +269,7 @@ fun TimelineStatusWithAccount.toViewData(moshi: Moshi, isDetailed: Boolean = fal } return StatusViewData.Concrete( status = status, + inReplyToAccount = this.inReplyToAccount?.toAccount(moshi), isExpanded = this.status.expanded, isShowingContent = this.status.contentShowing, isCollapsed = this.status.contentCollapsed, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index f19b2240f8..d66ac79336 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -20,19 +20,27 @@ import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator +import com.keylesspalace.tusky.components.timeline.toAccount import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import com.squareup.moshi.Moshi import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class NetworkTimelineRemoteMediator( private val accountManager: AccountManager, - private val viewModel: NetworkTimelineViewModel + private val viewModel: NetworkTimelineViewModel, + db: AppDatabase, + private val moshi: Moshi, ) : RemoteMediator() { + private val accountDao = db.timelineAccountDao() + private val statusIds = mutableSetOf() init { @@ -80,7 +88,13 @@ class NetworkTimelineRemoteMediator( val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler val contentCollapsed = oldStatus?.isCollapsed ?: true + var inReplyToAccount: TimelineAccount? = null + if (status.inReplyToAccountId != null) { + inReplyToAccount = accountDao.get(status.inReplyToAccountId)?.toAccount(moshi) + } + status.toViewData( + inReplyToAccount = inReplyToAccount, isShowingContent = contentShowing, isExpanded = expanded, isCollapsed = contentCollapsed diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 60eed28e05..df92e94742 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -29,9 +29,11 @@ import at.connyduck.calladapter.networkresult.onFailure import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.Translation import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases @@ -41,6 +43,7 @@ import com.keylesspalace.tusky.util.isLessThanOrEqual import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData +import com.squareup.moshi.Moshi import java.io.IOException import javax.inject.Inject import kotlinx.coroutines.Dispatchers @@ -60,7 +63,9 @@ class NetworkTimelineViewModel @Inject constructor( eventHub: EventHub, accountManager: AccountManager, sharedPreferences: SharedPreferences, - filterModel: FilterModel + filterModel: FilterModel, + private val db: AppDatabase, + private val moshi: Moshi, ) : TimelineViewModel( timelineCases, api, @@ -86,7 +91,7 @@ class NetworkTimelineViewModel @Inject constructor( currentSource = source } }, - remoteMediator = NetworkTimelineRemoteMediator(accountManager, this) + remoteMediator = NetworkTimelineRemoteMediator(accountManager, this, db, moshi) ).flow .map { pagingData -> pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt index 0c1c7fc5b7..a5c314f1c3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt @@ -53,7 +53,7 @@ class ThreadAdapter( override fun onBindViewHolder(viewHolder: StatusBaseViewHolder, position: Int) { val status = getItem(position) - viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions) + viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions, false) } override fun getItemViewType(position: Int): Int { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 39261cb555..efa0093b22 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -61,6 +61,7 @@ public abstract class AppDatabase extends RoomDatabase { @NonNull public abstract ConversationsDao conversationDao(); @NonNull public abstract TimelineDao timelineDao(); @NonNull public abstract DraftDao draftDao(); + @NonNull public abstract TimelineAccountDao timelineAccountDao(); public static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineAccountDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineAccountDao.kt new file mode 100644 index 0000000000..d2759627a5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineAccountDao.kt @@ -0,0 +1,25 @@ +/* Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db + +import androidx.room.Dao +import androidx.room.Query + +@Dao +interface TimelineAccountDao { + @Query("SELECT * FROM TimelineAccountEntity WHERE serverId = :id") + suspend fun get(id: String): TimelineAccountEntity? +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 3cd49baacd..87a13c18b4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -44,17 +44,22 @@ s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt, s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered, -a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', -a.localUsername as 'a_localUsername', a.username as 'a_username', -a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', -a.emojis as 'a_emojis', a.bot as 'a_bot', -rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId', -rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', -rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', -rb.emojis as 'rb_emojis', rb.bot as 'rb_bot' +author.serverId as 'author_serverId', author.timelineUserId as 'author_timelineUserId', +author.localUsername as 'author_localUsername', author.username as 'author_username', +author.displayName as 'author_displayName', author.url as 'author_url', author.avatar as 'author_avatar', +author.emojis as 'author_emojis', author.bot as 'author_bot', +replied.serverId as 'replied_serverId', replied.timelineUserId 'replied_timelineUserId', +replied.localUsername as 'replied_localUsername', replied.username as 'replied_username', +replied.displayName as 'replied_displayName', replied.url as 'replied_url', replied.avatar as 'replied_avatar', +replied.emojis as 'replied_emojis', replied.bot as 'replied_bot', +reblogger.serverId as 'reblogger_serverId', reblogger.timelineUserId 'reblogger_timelineUserId', +reblogger.localUsername as 'reblogger_localUsername', reblogger.username as 'reblogger_username', +reblogger.displayName as 'reblogger_displayName', reblogger.url as 'reblogger_url', reblogger.avatar as 'reblogger_avatar', +reblogger.emojis as 'reblogger_emojis', reblogger.bot as 'reblogger_bot' FROM TimelineStatusEntity s -LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) -LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) +LEFT JOIN TimelineAccountEntity author ON (s.timelineUserId = author.timelineUserId AND s.authorServerId = author.serverId) +LEFT JOIN TimelineAccountEntity replied ON (s.inReplyToAccountId = replied.serverId) +LEFT JOIN TimelineAccountEntity reblogger ON (s.timelineUserId = reblogger.timelineUserId AND s.reblogAccountId = reblogger.serverId) WHERE s.timelineUserId = :account ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""" ) @@ -67,17 +72,22 @@ s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt, s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered, -a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', -a.localUsername as 'a_localUsername', a.username as 'a_username', -a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', -a.emojis as 'a_emojis', a.bot as 'a_bot', -rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId', -rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', -rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', -rb.emojis as 'rb_emojis', rb.bot as 'rb_bot' +author.serverId as 'author_serverId', author.timelineUserId as 'author_timelineUserId', +author.localUsername as 'author_localUsername', author.username as 'author_username', +author.displayName as 'author_displayName', author.url as 'author_url', author.avatar as 'author_avatar', +author.emojis as 'author_emojis', author.bot as 'author_bot', +replied.serverId as 'replied_serverId', replied.timelineUserId 'replied_timelineUserId', +replied.localUsername as 'replied_localUsername', replied.username as 'replied_username', +replied.displayName as 'replied_displayName', replied.url as 'replied_url', replied.avatar as 'replied_avatar', +replied.emojis as 'replied_emojis', replied.bot as 'replied_bot', +reblogger.serverId as 'reblogger_serverId', reblogger.timelineUserId 'reblogger_timelineUserId', +reblogger.localUsername as 'reblogger_localUsername', reblogger.username as 'reblogger_username', +reblogger.displayName as 'reblogger_displayName', reblogger.url as 'reblogger_url', reblogger.avatar as 'reblogger_avatar', +reblogger.emojis as 'reblogger_emojis', reblogger.bot as 'reblogger_bot' FROM TimelineStatusEntity s -LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) -LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) +LEFT JOIN TimelineAccountEntity author ON (s.timelineUserId = author.timelineUserId AND s.authorServerId = author.serverId) +LEFT JOIN TimelineAccountEntity replied ON (s.inReplyToAccountId = replied.serverId) +LEFT JOIN TimelineAccountEntity reblogger ON (s.timelineUserId = reblogger.timelineUserId AND s.reblogAccountId = reblogger.serverId) WHERE (s.serverId = :statusId OR s.reblogServerId = :statusId) AND s.authorServerId IS NOT NULL AND s.timelineUserId = :accountId""" diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index ba32f63804..f5381d5670 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -111,10 +111,10 @@ data class TimelineAccountEntity( data class TimelineStatusWithAccount( @Embedded val status: TimelineStatusEntity, - // null when placeholder - @Embedded(prefix = "a_") - val account: TimelineAccountEntity? = null, - // null when no reblog - @Embedded(prefix = "rb_") - val reblogAccount: TimelineAccountEntity? = null + @Embedded(prefix = "author_") + val account: TimelineAccountEntity? = null, // null when placeholder + @Embedded(prefix = "replied_") + val inReplyToAccount: TimelineAccountEntity? = null, // null when no reply + @Embedded(prefix = "reblogger_") + val reblogAccount: TimelineAccountEntity? = null // null when no reblog ) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index 0975b00f10..b41d5af9b5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -34,10 +34,12 @@ package com.keylesspalace.tusky.util +import android.util.Log import androidx.paging.CombinedLoadStates import androidx.paging.LoadState import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TrendingTag import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.StatusViewData @@ -49,10 +51,12 @@ fun Status.toViewData( isExpanded: Boolean, isCollapsed: Boolean, isDetailed: Boolean = false, + inReplyToAccount: TimelineAccount? = null, translation: TranslationViewData? = null, ): StatusViewData.Concrete { return StatusViewData.Concrete( status = this, + inReplyToAccount = inReplyToAccount, isShowingContent = isShowingContent, isCollapsed = isCollapsed, isExpanded = isExpanded, @@ -71,7 +75,7 @@ fun Notification.toViewData( this.type, this.id, this.account, - this.status?.toViewData(isShowingContent, isExpanded, isCollapsed), + this.status?.toViewData(isShowingContent, isExpanded, isCollapsed), // TODO? account null implementation gap; and other locations this.report ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index 6f8d5d6983..e744b4ca95 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -18,6 +18,7 @@ import android.text.Spanned import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.Translation import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.shouldTrimStatus @@ -45,6 +46,7 @@ sealed class StatusViewData { data class Concrete( val status: Status, + val inReplyToAccount: TimelineAccount?, val isExpanded: Boolean, val isShowingContent: Boolean, /** diff --git a/app/src/main/res/drawable/ic_reply_all_18dp.xml b/app/src/main/res/drawable/ic_reply_all_18dp.xml new file mode 100644 index 0000000000..9c46d7fd1f --- /dev/null +++ b/app/src/main/res/drawable/ic_reply_all_18dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index c022c2dba6..31d107f2db 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -29,7 +29,8 @@ app:layout_constraintTop_toTopOf="parent" tools:ignore="RtlSymmetry" tools:text="ConnyDuck boosted" - tools:visibility="visible" /> + tools:visibility="visible" + app:drawableTint="?android:textColorTertiary" /> \@%s %s boosted + Replied + In reply to %s Sensitive content Media hidden ALT diff --git a/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt b/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt index 6fd1c6a51b..963394e3aa 100644 --- a/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt @@ -44,12 +44,14 @@ class StatusComparisonTest { fun `two equal status view data - should be equal`() { val viewdata1 = StatusViewData.Concrete( status = createStatus(), + inReplyToAccount = null, isExpanded = false, isShowingContent = false, isCollapsed = false ) val viewdata2 = StatusViewData.Concrete( status = createStatus(), + inReplyToAccount = null, isExpanded = false, isShowingContent = false, isCollapsed = false @@ -61,12 +63,14 @@ class StatusComparisonTest { fun `status view data with different isExpanded - should not be equal`() { val viewdata1 = StatusViewData.Concrete( status = createStatus(), + inReplyToAccount = null, isExpanded = true, isShowingContent = false, isCollapsed = false ) val viewdata2 = StatusViewData.Concrete( status = createStatus(), + inReplyToAccount = null, isExpanded = false, isShowingContent = false, isCollapsed = false @@ -78,12 +82,14 @@ class StatusComparisonTest { fun `status view data with different statuses- should not be equal`() { val viewdata1 = StatusViewData.Concrete( status = createStatus(content = "whatever"), + inReplyToAccount = null, isExpanded = true, isShowingContent = false, isCollapsed = false ) val viewdata2 = StatusViewData.Concrete( status = createStatus(), + inReplyToAccount = null, isExpanded = false, isShowingContent = false, isCollapsed = false @@ -104,7 +110,7 @@ class StatusComparisonTest { "id": "$id", "created_at": "2022-02-26T09:54:45.000Z", "in_reply_to_id": null, - "in_reply_to_account_id": null, + "in_reply_to_account": null, "sensitive": false, "spoiler_text": "", "visibility": "public", diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt index fe33c09335..3d779a2f24 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -12,7 +12,10 @@ import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineView import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.TimelineAccountDao import com.keylesspalace.tusky.viewdata.StatusViewData +import com.squareup.moshi.Moshi import java.io.IOException import kotlinx.coroutines.runBlocking import okhttp3.Headers @@ -21,6 +24,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow @@ -45,6 +49,14 @@ class NetworkTimelineRemoteMediatorTest { ) } + private val accountDao: TimelineAccountDao = mock { + onBlocking { get(any()) } doReturn null + } + private val db: AppDatabase = mock { + on { timelineAccountDao() } doReturn accountDao + } + private val moshi: Moshi = mock {} + @Test @ExperimentalPagingApi fun `should return error when network call returns error code`() { @@ -53,7 +65,7 @@ class NetworkTimelineRemoteMediatorTest { onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody()) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel, db, moshi) val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } @@ -70,7 +82,7 @@ class NetworkTimelineRemoteMediatorTest { onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel, db, moshi) val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } @@ -99,7 +111,7 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel, db, moshi) val state = state( listOf( @@ -146,7 +158,7 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel, db, moshi) val state = state( listOf( @@ -198,7 +210,7 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel, db, moshi) val state = state( listOf( @@ -251,7 +263,7 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel, db, moshi) val state = state( listOf( @@ -308,7 +320,7 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel, db, moshi) val state = state( listOf( @@ -354,7 +366,7 @@ class NetworkTimelineRemoteMediatorTest { on { nextKey } doReturn null } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel, db, moshi) val state = state( listOf( @@ -409,7 +421,7 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel, db, moshi) val state = state( listOf( diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt index 7bcd656fde..a4e4a49b54 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt @@ -79,6 +79,7 @@ fun mockStatusViewData( favourited = favourited, bookmarked = bookmarked ), + inReplyToAccount = null, isExpanded = isExpanded, isShowingContent = isShowingContent, isCollapsed = isCollapsed, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9b0da4bc8d..22d28cecab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -108,6 +108,7 @@ glide-animation-plugin = { module = "com.github.penfeizhou.android.animation:gli glide-compiler = { module = "com.github.bumptech.glide:ksp", version.ref = "glide" } glide-core = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } glide-okhttp3-integration = { module = "com.github.bumptech.glide:okhttp3-integration", version.ref = "glide" } +kapt = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } image-cropper = { module = "com.github.CanHub:Android-Image-Cropper", version.ref = "image-cropper" }