Compare commits

...

2 Commits

Author SHA1 Message Date
UlrichKu a9f2c0202f
Merge e6bbf9043b into 197a1f4eda 2024-04-26 13:21:15 +00:00
Lakoja e6bbf9043b 3771: Add an "in reply to" text to a reply 2024-04-26 15:21:02 +02:00
24 changed files with 194 additions and 63 deletions

View File

@ -194,7 +194,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter<RecyclerView.View
if (payloads == null) { if (payloads == null) {
holder.showStatusContent(true); holder.showStatusContent(true);
} }
holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder); holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder, true);
} }
if (concreteNotification.getType() == Notification.Type.POLL) { if (concreteNotification.getType() == Notification.Type.POLL) {
holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId())); holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId()));

View File

@ -769,14 +769,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} }
public void setupWithStatus(@NonNull StatusViewData.Concrete status, final @NonNull StatusActionListener listener, public void setupWithStatus(@NonNull StatusViewData.Concrete status, final @NonNull StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions) { @NonNull StatusDisplayOptions statusDisplayOptions, boolean showStatusInfo) {
this.setupWithStatus(status, listener, statusDisplayOptions, null); this.setupWithStatus(status, listener, statusDisplayOptions, null, showStatusInfo);
} }
public void setupWithStatus(@NonNull StatusViewData.Concrete status, public void setupWithStatus(@NonNull StatusViewData.Concrete status,
@NonNull final StatusActionListener listener, @NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) { @Nullable Object payloads,
boolean showStatusInfo) {
if (payloads == null) { if (payloads == null) {
Status actionable = status.getActionable(); Status actionable = status.getActionable();
setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions);

View File

@ -143,13 +143,14 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
public void setupWithStatus(@NonNull final StatusViewData.Concrete status, public void setupWithStatus(@NonNull final StatusViewData.Concrete status,
@NonNull final StatusActionListener listener, @NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) { @Nullable Object payloads,
boolean showStatusInfo) {
// We never collapse statuses in the detail view // We never collapse statuses in the detail view
StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ? StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ?
status.copyWithCollapsed(false) : status.copyWithCollapsed(false) :
status; 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 setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
if (payloads == null) { if (payloads == null) {
Status actionable = uncollapsedStatus.getActionable(); Status actionable = uncollapsedStatus.getActionable();

View File

@ -30,6 +30,7 @@ import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.NumberUtils; import com.keylesspalace.tusky.util.NumberUtils;
@ -38,6 +39,7 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.StringUtils; import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.viewdata.StatusViewData; import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.Collections;
import java.util.List; import java.util.List;
import at.connyduck.sparkbutton.helpers.Utils; import at.connyduck.sparkbutton.helpers.Utils;
@ -63,21 +65,37 @@ public class StatusViewHolder extends StatusBaseViewHolder {
public void setupWithStatus(@NonNull StatusViewData.Concrete status, public void setupWithStatus(@NonNull StatusViewData.Concrete status,
@NonNull final StatusActionListener listener, @NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) { @Nullable Object payloads,
boolean showStatusInfo) {
if (payloads == null) { if (payloads == null) {
boolean sensitive = !TextUtils.isEmpty(status.getActionable().getSpoilerText()); boolean sensitive = !TextUtils.isEmpty(status.getActionable().getSpoilerText());
boolean expanded = status.isExpanded(); boolean expanded = status.isExpanded();
setupCollapsedState(sensitive, expanded, status, listener); setupCollapsedState(sensitive, expanded, status, listener);
Status reblogging = status.getRebloggingStatus(); 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(); hideStatusInfo();
} else { } else {
String rebloggedByDisplayName = reblogging.getAccount().getName(); String accountName = "";
setRebloggedByDisplayName(rebloggedByDisplayName, List<Emoji> emojis = Collections.emptyList();
reblogging.getAccount().getEmojis(), statusDisplayOptions); 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())); statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
} }
@ -88,19 +106,27 @@ public class StatusViewHolder extends StatusBaseViewHolder {
setFavouritedCount(status.getActionable().getFavouritesCount()); setFavouritedCount(status.getActionable().getFavouritesCount());
setReblogsCount(status.getActionable().getReblogsCount()); setReblogsCount(status.getActionable().getReblogsCount());
super.setupWithStatus(status, listener, statusDisplayOptions, payloads); super.setupWithStatus(status, listener, statusDisplayOptions, payloads, showStatusInfo);
} }
private void setRebloggedByDisplayName(final CharSequence name, private void setStatusInfoText(final boolean isReply,
final CharSequence name,
final List<Emoji> accountEmoji, final List<Emoji> accountEmoji,
final StatusDisplayOptions statusDisplayOptions) { final StatusDisplayOptions statusDisplayOptions) {
Context context = statusInfo.getContext(); Context context = statusInfo.getContext();
if (name.length() > 0) {
CharSequence wrappedName = StringUtils.unicodeWrap(name); CharSequence wrappedName = StringUtils.unicodeWrap(name);
CharSequence boostedText = context.getString(R.string.post_boosted_format, wrappedName); CharSequence statusContextText = context.getString(isReply ? R.string.post_replied_format : R.string.post_boosted_format, wrappedName);
CharSequence emojifiedText = CustomEmojiHelper.emojify( CharSequence emojifiedText = CustomEmojiHelper.emojify(
boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis() statusContextText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis()
); );
statusInfo.setText(emojifiedText); 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); statusInfo.setVisibility(View.VISIBLE);
} }

View File

@ -135,6 +135,8 @@ data class ConversationStatusEntity(
language = language, language = language,
filtered = emptyList() filtered = emptyList()
), ),
// TODO? implementation gap: not needed here atm, but inconsistent
inReplyToAccount = null,
isExpanded = expanded, isExpanded = expanded,
isShowingContent = showingHiddenContent, isShowingContent = showingHiddenContent,
isCollapsed = collapsed isCollapsed = collapsed

View File

@ -38,7 +38,7 @@ class SearchStatusesAdapter(
override fun onBindViewHolder(holder: StatusViewHolder, position: Int) { override fun onBindViewHolder(holder: StatusViewHolder, position: Int) {
getItem(position)?.let { item -> getItem(position)?.let { item ->
holder.setupWithStatus(item, statusListener, statusDisplayOptions) holder.setupWithStatus(item, statusListener, statusDisplayOptions, true)
} }
} }

View File

@ -90,7 +90,8 @@ class TimelinePagingAdapter(
status, status,
statusListener, statusListener,
statusDisplayOptions, statusDisplayOptions,
if (payloads != null && payloads.isNotEmpty()) payloads[0] else null if (payloads != null && payloads.isNotEmpty()) payloads[0] else null,
true
) )
} }
} }

View File

@ -206,8 +206,8 @@ fun TimelineStatusWithAccount.toViewData(moshi: Moshi, isDetailed: Boolean = fal
// no url for reblogs // no url for reblogs
url = null, url = null,
account = this.reblogAccount!!.toAccount(moshi), account = this.reblogAccount!!.toAccount(moshi),
inReplyToId = null, inReplyToId = status.inReplyToId,
inReplyToAccountId = null, inReplyToAccountId = status.inReplyToAccountId,
reblog = reblog, reblog = reblog,
content = "", content = "",
// lie but whatever? // lie but whatever?
@ -269,6 +269,7 @@ fun TimelineStatusWithAccount.toViewData(moshi: Moshi, isDetailed: Boolean = fal
} }
return StatusViewData.Concrete( return StatusViewData.Concrete(
status = status, status = status,
inReplyToAccount = this.inReplyToAccount?.toAccount(moshi),
isExpanded = this.status.expanded, isExpanded = this.status.expanded,
isShowingContent = this.status.contentShowing, isShowingContent = this.status.contentShowing,
isCollapsed = this.status.contentCollapsed, isCollapsed = this.status.contentCollapsed,

View File

@ -20,19 +20,27 @@ import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType import androidx.paging.LoadType
import androidx.paging.PagingState import androidx.paging.PagingState
import androidx.paging.RemoteMediator import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.components.timeline.toAccount
import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager 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.HttpHeaderLink
import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import com.squareup.moshi.Moshi
import retrofit2.HttpException import retrofit2.HttpException
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
class NetworkTimelineRemoteMediator( class NetworkTimelineRemoteMediator(
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val viewModel: NetworkTimelineViewModel private val viewModel: NetworkTimelineViewModel,
db: AppDatabase,
private val moshi: Moshi,
) : RemoteMediator<String, StatusViewData>() { ) : RemoteMediator<String, StatusViewData>() {
private val accountDao = db.timelineAccountDao()
private val statusIds = mutableSetOf<String>() private val statusIds = mutableSetOf<String>()
init { init {
@ -80,7 +88,13 @@ class NetworkTimelineRemoteMediator(
val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler
val contentCollapsed = oldStatus?.isCollapsed ?: true val contentCollapsed = oldStatus?.isCollapsed ?: true
var inReplyToAccount: TimelineAccount? = null
if (status.inReplyToAccountId != null) {
inReplyToAccount = accountDao.get(status.inReplyToAccountId)?.toAccount(moshi)
}
status.toViewData( status.toViewData(
inReplyToAccount = inReplyToAccount,
isShowingContent = contentShowing, isShowingContent = contentShowing,
isExpanded = expanded, isExpanded = expanded,
isCollapsed = contentCollapsed isCollapsed = contentCollapsed

View File

@ -29,6 +29,7 @@ import at.connyduck.calladapter.networkresult.onFailure
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
@ -41,6 +42,7 @@ import com.keylesspalace.tusky.util.isLessThanOrEqual
import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData import com.keylesspalace.tusky.viewdata.TranslationViewData
import com.squareup.moshi.Moshi
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -60,7 +62,9 @@ class NetworkTimelineViewModel @Inject constructor(
eventHub: EventHub, eventHub: EventHub,
accountManager: AccountManager, accountManager: AccountManager,
sharedPreferences: SharedPreferences, sharedPreferences: SharedPreferences,
filterModel: FilterModel filterModel: FilterModel,
private val db: AppDatabase,
private val moshi: Moshi,
) : TimelineViewModel( ) : TimelineViewModel(
timelineCases, timelineCases,
api, api,
@ -86,7 +90,7 @@ class NetworkTimelineViewModel @Inject constructor(
currentSource = source currentSource = source
} }
}, },
remoteMediator = NetworkTimelineRemoteMediator(accountManager, this) remoteMediator = NetworkTimelineRemoteMediator(accountManager, this, db, moshi)
).flow ).flow
.map { pagingData -> .map { pagingData ->
pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData -> pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData ->

View File

@ -53,7 +53,7 @@ class ThreadAdapter(
override fun onBindViewHolder(viewHolder: StatusBaseViewHolder, position: Int) { override fun onBindViewHolder(viewHolder: StatusBaseViewHolder, position: Int) {
val status = getItem(position) val status = getItem(position)
viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions) viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions, false)
} }
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int): Int {

View File

@ -61,6 +61,7 @@ public abstract class AppDatabase extends RoomDatabase {
@NonNull public abstract ConversationsDao conversationDao(); @NonNull public abstract ConversationsDao conversationDao();
@NonNull public abstract TimelineDao timelineDao(); @NonNull public abstract TimelineDao timelineDao();
@NonNull public abstract DraftDao draftDao(); @NonNull public abstract DraftDao draftDao();
@NonNull public abstract TimelineAccountDao timelineAccountDao();
public static final Migration MIGRATION_2_3 = new Migration(2, 3) { public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override @Override

View File

@ -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 <http://www.gnu.org/licenses>. */
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?
}

View File

@ -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.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.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, 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', author.serverId as 'author_serverId', author.timelineUserId as 'author_timelineUserId',
a.localUsername as 'a_localUsername', a.username as 'a_username', author.localUsername as 'author_localUsername', author.username as 'author_username',
a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', author.displayName as 'author_displayName', author.url as 'author_url', author.avatar as 'author_avatar',
a.emojis as 'a_emojis', a.bot as 'a_bot', author.emojis as 'author_emojis', author.bot as 'author_bot',
rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId', replied.serverId as 'replied_serverId', replied.timelineUserId 'replied_timelineUserId',
rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', replied.localUsername as 'replied_localUsername', replied.username as 'replied_username',
rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', replied.displayName as 'replied_displayName', replied.url as 'replied_url', replied.avatar as 'replied_avatar',
rb.emojis as 'rb_emojis', rb.bot as 'rb_bot' 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 FROM TimelineStatusEntity s
LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) LEFT JOIN TimelineAccountEntity author ON (s.timelineUserId = author.timelineUserId AND s.authorServerId = author.serverId)
LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.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 WHERE s.timelineUserId = :account
ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""" 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.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.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, 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', author.serverId as 'author_serverId', author.timelineUserId as 'author_timelineUserId',
a.localUsername as 'a_localUsername', a.username as 'a_username', author.localUsername as 'author_localUsername', author.username as 'author_username',
a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', author.displayName as 'author_displayName', author.url as 'author_url', author.avatar as 'author_avatar',
a.emojis as 'a_emojis', a.bot as 'a_bot', author.emojis as 'author_emojis', author.bot as 'author_bot',
rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId', replied.serverId as 'replied_serverId', replied.timelineUserId 'replied_timelineUserId',
rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', replied.localUsername as 'replied_localUsername', replied.username as 'replied_username',
rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', replied.displayName as 'replied_displayName', replied.url as 'replied_url', replied.avatar as 'replied_avatar',
rb.emojis as 'rb_emojis', rb.bot as 'rb_bot' 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 FROM TimelineStatusEntity s
LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) LEFT JOIN TimelineAccountEntity author ON (s.timelineUserId = author.timelineUserId AND s.authorServerId = author.serverId)
LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.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) WHERE (s.serverId = :statusId OR s.reblogServerId = :statusId)
AND s.authorServerId IS NOT NULL AND s.authorServerId IS NOT NULL
AND s.timelineUserId = :accountId""" AND s.timelineUserId = :accountId"""

View File

@ -111,10 +111,16 @@ data class TimelineAccountEntity(
data class TimelineStatusWithAccount( data class TimelineStatusWithAccount(
@Embedded @Embedded
val status: TimelineStatusEntity, val status: TimelineStatusEntity,
// null when placeholder // null when placeholder
@Embedded(prefix = "a_") @Embedded(prefix = "author_")
val account: TimelineAccountEntity? = null, val account: TimelineAccountEntity? = null,
// null when no reply
@Embedded(prefix = "replied_")
val inReplyToAccount: TimelineAccountEntity? = null,
// null when no reblog // null when no reblog
@Embedded(prefix = "rb_") @Embedded(prefix = "reblogger_")
val reblogAccount: TimelineAccountEntity? = null val reblogAccount: TimelineAccountEntity? = null
) )

View File

@ -38,6 +38,7 @@ import androidx.paging.CombinedLoadStates
import androidx.paging.LoadState import androidx.paging.LoadState
import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.entity.TrendingTag import com.keylesspalace.tusky.entity.TrendingTag
import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
@ -49,10 +50,12 @@ fun Status.toViewData(
isExpanded: Boolean, isExpanded: Boolean,
isCollapsed: Boolean, isCollapsed: Boolean,
isDetailed: Boolean = false, isDetailed: Boolean = false,
inReplyToAccount: TimelineAccount? = null,
translation: TranslationViewData? = null, translation: TranslationViewData? = null,
): StatusViewData.Concrete { ): StatusViewData.Concrete {
return StatusViewData.Concrete( return StatusViewData.Concrete(
status = this, status = this,
inReplyToAccount = inReplyToAccount,
isShowingContent = isShowingContent, isShowingContent = isShowingContent,
isCollapsed = isCollapsed, isCollapsed = isCollapsed,
isExpanded = isExpanded, isExpanded = isExpanded,
@ -71,6 +74,7 @@ fun Notification.toViewData(
this.type, this.type,
this.id, this.id,
this.account, this.account,
// TODO? account null implementation gap; and other locations:
this.status?.toViewData(isShowingContent, isExpanded, isCollapsed), this.status?.toViewData(isShowingContent, isExpanded, isCollapsed),
this.report this.report
) )

View File

@ -18,6 +18,7 @@ import android.text.Spanned
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.entity.Translation import com.keylesspalace.tusky.entity.Translation
import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.shouldTrimStatus
@ -45,6 +46,7 @@ sealed class StatusViewData {
data class Concrete( data class Concrete(
val status: Status, val status: Status,
val inReplyToAccount: TimelineAccount?,
val isExpanded: Boolean, val isExpanded: Boolean,
val isShowingContent: Boolean, val isShowingContent: Boolean,
/** /**

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:autoMirrored="true"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#fff"
android:pathData="M7,8L7,5l-7,7 7,7v-3l-4,-4 4,-4zM13,9L13,5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z" />
</vector>

View File

@ -29,7 +29,8 @@
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlSymmetry" tools:ignore="RtlSymmetry"
tools:text="ConnyDuck boosted" tools:text="ConnyDuck boosted"
tools:visibility="visible" /> tools:visibility="visible"
app:drawableTint="?android:textColorTertiary" />
<ImageView <ImageView
android:id="@+id/status_avatar" android:id="@+id/status_avatar"

View File

@ -84,6 +84,8 @@
<string name="post_username_format">\@%s</string> <string name="post_username_format">\@%s</string>
<string name="post_boosted_format">%s boosted</string> <string name="post_boosted_format">%s boosted</string>
<string name="post_replied">Replied</string>
<string name="post_replied_format">In reply to %s</string>
<string name="post_sensitive_media_title">Sensitive content</string> <string name="post_sensitive_media_title">Sensitive content</string>
<string name="post_media_hidden_title">Media hidden</string> <string name="post_media_hidden_title">Media hidden</string>
<string name="post_media_alt">ALT</string> <string name="post_media_alt">ALT</string>

View File

@ -44,12 +44,14 @@ class StatusComparisonTest {
fun `two equal status view data - should be equal`() { fun `two equal status view data - should be equal`() {
val viewdata1 = StatusViewData.Concrete( val viewdata1 = StatusViewData.Concrete(
status = createStatus(), status = createStatus(),
inReplyToAccount = null,
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
isCollapsed = false isCollapsed = false
) )
val viewdata2 = StatusViewData.Concrete( val viewdata2 = StatusViewData.Concrete(
status = createStatus(), status = createStatus(),
inReplyToAccount = null,
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
isCollapsed = false isCollapsed = false
@ -61,12 +63,14 @@ class StatusComparisonTest {
fun `status view data with different isExpanded - should not be equal`() { fun `status view data with different isExpanded - should not be equal`() {
val viewdata1 = StatusViewData.Concrete( val viewdata1 = StatusViewData.Concrete(
status = createStatus(), status = createStatus(),
inReplyToAccount = null,
isExpanded = true, isExpanded = true,
isShowingContent = false, isShowingContent = false,
isCollapsed = false isCollapsed = false
) )
val viewdata2 = StatusViewData.Concrete( val viewdata2 = StatusViewData.Concrete(
status = createStatus(), status = createStatus(),
inReplyToAccount = null,
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
isCollapsed = false isCollapsed = false
@ -78,12 +82,14 @@ class StatusComparisonTest {
fun `status view data with different statuses- should not be equal`() { fun `status view data with different statuses- should not be equal`() {
val viewdata1 = StatusViewData.Concrete( val viewdata1 = StatusViewData.Concrete(
status = createStatus(content = "whatever"), status = createStatus(content = "whatever"),
inReplyToAccount = null,
isExpanded = true, isExpanded = true,
isShowingContent = false, isShowingContent = false,
isCollapsed = false isCollapsed = false
) )
val viewdata2 = StatusViewData.Concrete( val viewdata2 = StatusViewData.Concrete(
status = createStatus(), status = createStatus(),
inReplyToAccount = null,
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
isCollapsed = false isCollapsed = false
@ -104,7 +110,7 @@ class StatusComparisonTest {
"id": "$id", "id": "$id",
"created_at": "2022-02-26T09:54:45.000Z", "created_at": "2022-02-26T09:54:45.000Z",
"in_reply_to_id": null, "in_reply_to_id": null,
"in_reply_to_account_id": null, "in_reply_to_account": null,
"sensitive": false, "sensitive": false,
"spoiler_text": "", "spoiler_text": "",
"visibility": "public", "visibility": "public",

View File

@ -12,7 +12,10 @@ import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineView
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager 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.keylesspalace.tusky.viewdata.StatusViewData
import com.squareup.moshi.Moshi
import java.io.IOException import java.io.IOException
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.Headers import okhttp3.Headers
@ -21,6 +24,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doReturn import org.mockito.kotlin.doReturn
import org.mockito.kotlin.doThrow 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 @Test
@ExperimentalPagingApi @ExperimentalPagingApi
fun `should return error when network call returns error code`() { 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()) 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()) } val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) }
@ -70,7 +82,7 @@ class NetworkTimelineRemoteMediatorTest {
onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() 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()) } 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( val state = state(
listOf( listOf(
@ -146,7 +158,7 @@ class NetworkTimelineRemoteMediatorTest {
) )
} }
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel, db, moshi)
val state = state( val state = state(
listOf( listOf(
@ -198,7 +210,7 @@ class NetworkTimelineRemoteMediatorTest {
) )
} }
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel, db, moshi)
val state = state( val state = state(
listOf( listOf(
@ -251,7 +263,7 @@ class NetworkTimelineRemoteMediatorTest {
) )
} }
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel, db, moshi)
val state = state( val state = state(
listOf( listOf(
@ -308,7 +320,7 @@ class NetworkTimelineRemoteMediatorTest {
) )
} }
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel, db, moshi)
val state = state( val state = state(
listOf( listOf(
@ -354,7 +366,7 @@ class NetworkTimelineRemoteMediatorTest {
on { nextKey } doReturn null on { nextKey } doReturn null
} }
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel, db, moshi)
val state = state( val state = state(
listOf( listOf(
@ -409,7 +421,7 @@ class NetworkTimelineRemoteMediatorTest {
) )
} }
val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel, db, moshi)
val state = state( val state = state(
listOf( listOf(

View File

@ -79,6 +79,7 @@ fun mockStatusViewData(
favourited = favourited, favourited = favourited,
bookmarked = bookmarked bookmarked = bookmarked
), ),
inReplyToAccount = null,
isExpanded = isExpanded, isExpanded = isExpanded,
isShowingContent = isShowingContent, isShowingContent = isShowingContent,
isCollapsed = isCollapsed, isCollapsed = isCollapsed,

View File

@ -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-compiler = { module = "com.github.bumptech.glide:ksp", version.ref = "glide" }
glide-core = { module = "com.github.bumptech.glide:glide", 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" } 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-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", 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" } image-cropper = { module = "com.github.CanHub:Android-Image-Cropper", version.ref = "image-cropper" }