3771: Add "display replied to" functionality (#4834)

Earlier PR: https://github.com/tuskyapp/Tusky/pull/3778

Fixes: https://github.com/tuskyapp/Tusky/issues/3771
This commit is contained in:
UlrichKu 2025-01-06 10:27:27 +01:00 committed by GitHub
commit d6b276d8df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 104 additions and 38 deletions

View file

@ -404,13 +404,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
protected void setIsReply(boolean isReply) {
protected void setReplyButtonImage(boolean isReply) {
if (isReply) {
replyButton.setImageResource(R.drawable.ic_reply_all_24dp);
} else {
replyButton.setImageResource(R.drawable.ic_reply_24dp);
}
}
protected void setReplyCount(int repliesCount, boolean fullStats) {
@ -771,21 +770,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
popup.show();
}
public void setupWithStatus(@NonNull StatusViewData.Concrete status, final @NonNull StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions) {
this.setupWithStatus(status, listener, statusDisplayOptions, null);
}
public void setupWithStatus(@NonNull StatusViewData.Concrete status,
@NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
@Nullable Object payloads,
final boolean showStatusInfo) {
if (payloads == null) {
Status actionable = status.getActionable();
setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
setUsername(actionable.getAccount().getUsername());
setMetaData(status, statusDisplayOptions, listener);
setIsReply(actionable.getInReplyToId() != null);
setReplyButtonImage(actionable.isReply());
setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline());
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
actionable.getAccount().getBot(), statusDisplayOptions);

View file

@ -140,13 +140,14 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
public void setupWithStatus(@NonNull final StatusViewData.Concrete status,
@NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
@Nullable Object payloads,
final 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();

View file

@ -27,9 +27,9 @@ import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
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,7 +38,8 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.List;
import java.util.Collections;
import java.util.Objects;
import at.connyduck.sparkbutton.helpers.Utils;
@ -63,24 +64,37 @@ public class StatusViewHolder extends StatusBaseViewHolder {
public void setupWithStatus(@NonNull StatusViewData.Concrete status,
@NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
@Nullable Object payloads,
final 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) {
if (!showStatusInfo || status.getFilterAction() == Filter.Action.WARN) {
hideStatusInfo();
} else {
String rebloggedByDisplayName = reblogging.getAccount().getName();
setRebloggedByDisplayName(rebloggedByDisplayName,
reblogging.getAccount().getEmojis(), statusDisplayOptions);
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
}
Status rebloggingStatus = status.getRebloggingStatus();
boolean isReplyOnly = rebloggingStatus == null && status.isReply();
boolean isReplySelf = isReplyOnly && status.isSelfReply();
boolean hasStatusInfo = rebloggingStatus != null | isReplyOnly;
TimelineAccount statusInfoAccount = rebloggingStatus != null ? rebloggingStatus.getAccount() : status.getRepliedToAccount();
if (!hasStatusInfo) {
hideStatusInfo();
} else {
setStatusInfoContent(statusInfoAccount, isReplyOnly, isReplySelf, statusDisplayOptions);
}
if (isReplyOnly) {
statusInfo.setOnClickListener(null);
} else {
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
}
}
}
reblogsCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE);
@ -88,23 +102,42 @@ public class StatusViewHolder extends StatusBaseViewHolder {
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<Emoji> accountEmoji,
final StatusDisplayOptions statusDisplayOptions) {
private void setStatusInfoContent(final TimelineAccount account,
final boolean isReply,
final boolean isSelfReply,
final StatusDisplayOptions statusDisplayOptions) {
Context context = statusInfo.getContext();
CharSequence wrappedName = StringUtils.unicodeWrap(name);
CharSequence boostedText = context.getString(R.string.post_boosted_format, wrappedName);
CharSequence accountName = account != null ? account.getName() : "";
CharSequence wrappedName = StringUtils.unicodeWrap(accountName);
CharSequence translatedText = "";
if (!isReply) {
translatedText = context.getString(R.string.post_boosted_format, wrappedName);
} else if (isSelfReply) {
translatedText = context.getString(R.string.post_replied_self);
} else {
if (account != null && accountName.length() > 0) {
translatedText = context.getString(R.string.post_replied_format, wrappedName);
} else {
translatedText = context.getString(R.string.post_replied);
}
}
CharSequence emojifiedText = CustomEmojiHelper.emojify(
boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis()
translatedText,
account != null ? account.getEmojis() : Collections.emptyList(),
statusInfo,
statusDisplayOptions.animateEmojis()
);
statusInfo.setText(emojifiedText);
statusInfo.setCompoundDrawablesWithIntrinsicBounds(isReply ? R.drawable.ic_reply_all_18dp : R.drawable.ic_reblog_18dp, 0, 0, 0);
statusInfo.setVisibility(View.VISIBLE);
}
// don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed
// don't use this on the same ViewHolder as setStatusInfoContent, will cause recycling issues as paddings are changed
protected void setPollInfo(final boolean ownPoll) {
statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted);
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0);

View file

@ -87,7 +87,6 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
setDisplayName(displayName, account.getEmojis(), statusDisplayOptions);
setUsername(account.getUsername());
setMetaData(statusViewData, statusDisplayOptions, listener);
setIsReply(status.getInReplyToId() != null);
setFavourited(status.getFavourited());
setBookmarked(status.getBookmarked());
List<Attachment> attachments = status.getAttachments();

View file

@ -79,7 +79,8 @@ internal class StatusNotificationViewHolder(
showNotificationContent(false)
} else {
showNotificationContent(true)
val (_, _, account, _, _, _, _, createdAt) = statusViewData.actionable
val account = statusViewData.actionable.account
val createdAt = statusViewData.actionable.createdAt
setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis)
setUsername(account.username)
setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime)

View file

@ -47,7 +47,8 @@ internal class StatusViewHolder(
statusViewData,
statusActionListener,
statusDisplayOptions,
payloads.firstOrNull()
payloads.firstOrNull(),
false
)
}
if (viewData.type == Notification.Type.POLL) {

View file

@ -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, null, true)
}
}

View file

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

View file

@ -109,7 +109,7 @@ fun Status.toEntity(
)
fun TimelineStatusEntity.toStatus(
account: TimelineAccountEntity
account: TimelineAccountEntity,
) = Status(
id = serverId,
url = url,
@ -192,6 +192,7 @@ fun HomeTimelineData.toViewData(isDetailed: Boolean = false, translation: Transl
isShowingContent = this.status.contentShowing,
isCollapsed = this.status.contentCollapsed,
isDetailed = isDetailed,
repliedToAccount = repliedToAccount?.toAccount(),
translation = translation,
)
}

View file

@ -59,7 +59,7 @@ class ThreadAdapter(
if (viewHolder is FilteredStatusViewHolder) {
viewHolder.bind(status)
} else if (viewHolder is StatusBaseViewHolder) {
viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions)
viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions, null, false)
}
}

View file

@ -117,6 +117,7 @@ class ViewThreadViewModel @Inject constructor(
isShowingContent = statusAndAccount.first.contentShowing,
isCollapsed = statusAndAccount.first.contentCollapsed,
isDetailed = true,
// NOTE repliedToAccount is null here: this avoids showing "in reply to" over every post
translation = null
)
} else {

View file

@ -44,11 +44,16 @@ rb.serverId as 'rb_serverId', rb.tuskyAccountId 'rb_tuskyAccountId',
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',
replied.serverId as 'replied_serverId', replied.tuskyAccountId 'replied_tuskyAccountId',
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',
h.loading
FROM HomeTimelineEntity h
LEFT JOIN TimelineStatusEntity s ON (h.statusId = s.serverId AND s.tuskyAccountId = :tuskyAccountId)
LEFT JOIN TimelineAccountEntity a ON (s.authorServerId = a.serverId AND a.tuskyAccountId = :tuskyAccountId)
LEFT JOIN TimelineAccountEntity rb ON (h.reblogAccountId = rb.serverId AND rb.tuskyAccountId = :tuskyAccountId)
LEFT JOIN TimelineAccountEntity replied ON (s.inReplyToAccountId = replied.serverId)
WHERE h.tuskyAccountId = :tuskyAccountId
ORDER BY LENGTH(h.id) DESC, h.id DESC"""
)

View file

@ -64,5 +64,6 @@ data class HomeTimelineData(
@Embedded val status: TimelineStatusEntity?,
@Embedded(prefix = "a_") val account: TimelineAccountEntity?,
@Embedded(prefix = "rb_") val reblogAccount: TimelineAccountEntity?,
@Embedded(prefix = "replied_") val repliedToAccount: TimelineAccountEntity?,
val loading: Boolean
)

View file

@ -67,6 +67,9 @@ data class Status(
val actionableStatus: Status
get() = reblog ?: this
val isReply: Boolean
get() = inReplyToId != null
@JsonClass(generateAdapter = false)
enum class Visibility(val int: Int) {
UNKNOWN(0),

View file

@ -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
@ -55,6 +56,7 @@ sealed class StatusViewData {
*/
val isCollapsed: Boolean,
val isDetailed: Boolean = false,
val repliedToAccount: TimelineAccount? = null,
val translation: TranslationViewData? = null,
) : StatusViewData() {
override val id: String
@ -105,6 +107,12 @@ sealed class StatusViewData {
val rebloggingStatus: Status?
get() = if (status.reblog != null) status else null
val isReply: Boolean
get() = status.inReplyToAccountId != null
val isSelfReply: Boolean
get() = status.inReplyToAccountId == status.account.id
/** Helper for Java */
fun copyWithCollapsed(isCollapsed: Boolean): Concrete {
return copy(isCollapsed = isCollapsed)

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="?android:attr/textColorTertiary"
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

@ -5,6 +5,6 @@
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#fff"
android:fillColor="?android:attr/textColorTertiary"
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

@ -24,6 +24,7 @@
android:textColor="?android:textColorTertiary"
android:textSize="?attr/status_text_medium"
app:drawableStartCompat="@drawable/ic_reblog_18dp"
app:drawableTint="?android:textColorTertiary"
app:layout_constraintLeft_toRightOf="parent"
app:layout_constraintRight_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"

View file

@ -86,6 +86,9 @@
<string name="post_username_format">\@%1$s</string>
<string name="post_boosted_format">%1$s boosted</string>
<string name="post_replied">Replied</string>
<string name="post_replied_format">In reply to %1$s</string>
<string name="post_replied_self">Continued thread</string>
<string name="post_sensitive_media_title">Sensitive content</string>
<string name="post_media_hidden_title">Media hidden</string>
<string name="post_media_alt">ALT</string>

View file

@ -133,6 +133,7 @@ fun fakeHomeTimelineData(
tuskyAccountId = tuskyAccountId,
)
},
repliedToAccount = null,
loading = false
)
}
@ -144,6 +145,7 @@ fun fakePlaceholderHomeTimelineData(
account = null,
status = null,
reblogAccount = null,
repliedToAccount = null,
loading = false
)